@sidleo3/dsh-chat-weixin 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1939 @@
1
+ import { createRequire as __dshCreateRequire } from 'node:module';
2
+ import { dirname as __dshDirname } from 'node:path';
3
+ import { fileURLToPath as __dshFileURLToPath } from 'node:url';
4
+ const require = __dshCreateRequire(import.meta.url);
5
+ const __filename = __dshFileURLToPath(import.meta.url);
6
+ const __dirname = __dshDirname(__filename);
7
+
8
+ // packages/dsh-chat-weixin/host/controller.mjs
9
+ import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
10
+ import { join } from "node:path";
11
+
12
+ // packages/dsh-chat-weixin/host/config-store.mjs
13
+ var ACCOUNT_ID = /^[A-Za-z0-9_@.:+-]{1,128}$/;
14
+ var TOKEN_REF = /^[A-Za-z_][A-Za-z0-9_]*$/;
15
+ var FALLBACK_BASE_URL = "https://ilinkai.weixin.qq.com/";
16
+ function cleanString(value) {
17
+ return typeof value === "string" && value.trim() ? value.trim() : null;
18
+ }
19
+ function normalizeAccount(value) {
20
+ if (!value || typeof value !== "object") return null;
21
+ const botId = cleanString(value.botId);
22
+ const accountId = cleanString(value.accountId);
23
+ const tokenRef = cleanString(value.tokenRef);
24
+ const ownerUserId = cleanString(value.ownerUserId);
25
+ if (!botId || !ACCOUNT_ID.test(botId)) return null;
26
+ if (!accountId || !ACCOUNT_ID.test(accountId)) return null;
27
+ if (!tokenRef || !TOKEN_REF.test(tokenRef)) return null;
28
+ if (!ownerUserId) return null;
29
+ return Object.freeze({
30
+ botId,
31
+ accountId,
32
+ tokenRef,
33
+ ownerUserId,
34
+ baseUrl: cleanString(value.baseUrl) ?? FALLBACK_BASE_URL,
35
+ botName: cleanString(value.botName),
36
+ createdAt: cleanString(value.createdAt),
37
+ connectedAt: cleanString(value.connectedAt)
38
+ });
39
+ }
40
+ function normalizeDocument(value) {
41
+ const source = value && typeof value === "object" && Array.isArray(value.accounts) ? value : null;
42
+ if (!source) return { version: 1, accounts: [] };
43
+ const accounts = source.accounts.map((account) => normalizeAccount(account));
44
+ if (accounts.some((account) => account === null)) {
45
+ throw new Error("dsh-weixin config.json \u542B\u65E0\u6CD5\u8BC6\u522B\u7684\u8D26\u53F7\u6761\u76EE");
46
+ }
47
+ return { version: 1, accounts };
48
+ }
49
+ function createWeixinConfigStore({ path, createJsonStore }) {
50
+ if (typeof createJsonStore !== "function") {
51
+ throw new TypeError("\u5FAE\u4FE1\u914D\u7F6E\u5B58\u50A8\u9700\u8981 hub \u63D0\u4F9B\u7684 createJsonStore\u3002");
52
+ }
53
+ const store = createJsonStore({
54
+ path,
55
+ normalize: normalizeDocument,
56
+ empty: () => ({ version: 1, accounts: [] }),
57
+ label: "\u5FAE\u4FE1\u8D26\u53F7\u914D\u7F6E"
58
+ });
59
+ return {
60
+ path,
61
+ ready: () => store.ready(),
62
+ subscribe: (listener) => store.subscribe(listener),
63
+ /** @returns 全部账号。 */
64
+ list() {
65
+ return Object.freeze([...store.snapshot().accounts ?? []]);
66
+ },
67
+ /** @returns 指定账号,未配置时 undefined。 */
68
+ get(botId) {
69
+ return store.snapshot().accounts.find((account) => account.botId === botId);
70
+ },
71
+ /** 追加或覆盖一个账号。 */
72
+ async saveAccount(account) {
73
+ const normalized = normalizeAccount(account);
74
+ if (!normalized) throw new TypeError("\u5FAE\u4FE1\u8D26\u53F7\u4FE1\u606F\u4E0D\u5B8C\u6574\uFF08botId/accountId/tokenRef/ownerUserId \u5FC5\u586B\uFF09\u3002");
75
+ await store.update((current) => {
76
+ const accounts = [...current.accounts];
77
+ const index = accounts.findIndex((item) => item.botId === normalized.botId);
78
+ if (index >= 0) accounts[index] = normalized;
79
+ else accounts.push(normalized);
80
+ return { version: 1, accounts };
81
+ });
82
+ return normalized;
83
+ },
84
+ /** 删除一个账号。 */
85
+ async removeAccount(botId) {
86
+ let removed = false;
87
+ await store.update((current) => {
88
+ const accounts = current.accounts.filter((account) => account.botId !== botId);
89
+ if (accounts.length === current.accounts.length) return null;
90
+ removed = true;
91
+ return { version: 1, accounts };
92
+ });
93
+ return removed;
94
+ }
95
+ };
96
+ }
97
+
98
+ // packages/dsh-chat-weixin/host/ilink-client.mjs
99
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
100
+
101
+ // packages/dsh-chat-weixin/host/media.mjs
102
+ import { createCipheriv, createDecipheriv } from "node:crypto";
103
+ var MEDIA_CDN_HOST = "novac2c.cdn.weixin.qq.com";
104
+ var MEDIA_CDN_BASE_URL = `https://${MEDIA_CDN_HOST}/c2c`;
105
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
106
+ var MAX_FILE_BYTES = 30 * 1024 * 1024;
107
+ var DOWNLOAD_TIMEOUT_MS = 3e4;
108
+ var UPLOAD_CHUNK_BYTES = 64 * 1024;
109
+ var UPLOAD_IDLE_TIMEOUT_MS = 6e4;
110
+ var UPLOAD_RETRIES = 3;
111
+ var MEDIA_CDN_UPLOAD_PATH = "/c2c/upload";
112
+ var WeixinMediaError = class extends Error {
113
+ constructor(code, message, options = {}) {
114
+ super(message, options);
115
+ this.name = "WeixinMediaError";
116
+ this.code = code;
117
+ }
118
+ };
119
+ function nonEmptyString(value) {
120
+ return typeof value === "string" && value.trim() ? value.trim() : null;
121
+ }
122
+ function strictBase64(value) {
123
+ const text = nonEmptyString(value);
124
+ if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
125
+ return Buffer.from(text, "base64");
126
+ }
127
+ function parseMediaAesKey(item) {
128
+ const directHex = nonEmptyString(item?.aeskey);
129
+ if (directHex) {
130
+ if (!/^[0-9a-fA-F]{32}$/.test(directHex)) {
131
+ throw new WeixinMediaError("invalid-media-key", "\u8FD9\u6761\u5FAE\u4FE1\u6D88\u606F\u7684\u52A0\u5BC6\u5BC6\u94A5\u65E0\u6548\u3002");
132
+ }
133
+ return Buffer.from(directHex, "hex");
134
+ }
135
+ const encoded = strictBase64(item?.media?.aes_key);
136
+ if (encoded?.length === 16) return encoded;
137
+ if (encoded?.length === 32 && /^[0-9a-fA-F]{32}$/.test(encoded.toString("ascii"))) {
138
+ return Buffer.from(encoded.toString("ascii"), "hex");
139
+ }
140
+ throw new WeixinMediaError("invalid-media-key", "\u8FD9\u6761\u5FAE\u4FE1\u6D88\u606F\u7684\u52A0\u5BC6\u5BC6\u94A5\u65E0\u6548\u3002");
141
+ }
142
+ function decryptMedia(ciphertext, key) {
143
+ const encrypted = Buffer.from(ciphertext);
144
+ const aesKey = Buffer.from(key);
145
+ if (aesKey.length !== 16 || encrypted.length === 0 || encrypted.length % 16 !== 0) {
146
+ throw new WeixinMediaError("invalid-media-ciphertext", "\u8FD9\u6761\u5FAE\u4FE1\u6D88\u606F\u7684\u52A0\u5BC6\u6570\u636E\u65E0\u6548\u3002");
147
+ }
148
+ try {
149
+ const decipher = createDecipheriv("aes-128-ecb", aesKey, null);
150
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]);
151
+ } catch (cause) {
152
+ throw new WeixinMediaError("media-decryption-failed", "\u5FAE\u4FE1\u5A92\u4F53\u89E3\u5BC6\u5931\u8D25\u3002", { cause });
153
+ }
154
+ }
155
+ function mediaDownloadUrl(media) {
156
+ const query = nonEmptyString(media?.encrypt_query_param);
157
+ if (query) {
158
+ return `${MEDIA_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(query)}`;
159
+ }
160
+ const fullUrl = nonEmptyString(media?.full_url);
161
+ if (!fullUrl) throw new WeixinMediaError("missing-media-url", "\u8FD9\u6761\u5FAE\u4FE1\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u4E0B\u8F7D\u5730\u5740\u3002");
162
+ let url;
163
+ try {
164
+ url = new URL(fullUrl);
165
+ } catch {
166
+ throw new WeixinMediaError("invalid-media-url", "\u8FD9\u6761\u5FAE\u4FE1\u6D88\u606F\u7684\u4E0B\u8F7D\u5730\u5740\u65E0\u6548\u3002");
167
+ }
168
+ if (url.protocol !== "https:" || url.hostname !== MEDIA_CDN_HOST || url.port && url.port !== "443" || !url.pathname.startsWith("/c2c/")) {
169
+ throw new WeixinMediaError("untrusted-media-url", "\u8FD9\u6761\u5FAE\u4FE1\u6D88\u606F\u7684\u4E0B\u8F7D\u5730\u5740\u4E0D\u53D7\u4FE1\u4EFB\u3002");
170
+ }
171
+ url.username = "";
172
+ url.password = "";
173
+ url.hash = "";
174
+ return url.toString();
175
+ }
176
+ async function readBodyLimited(response, maxBytes) {
177
+ const declared = Number(response?.headers?.get?.("content-length"));
178
+ if (Number.isFinite(declared) && declared > maxBytes) {
179
+ await response?.body?.cancel?.().catch?.(() => void 0);
180
+ throw new WeixinMediaError("media-too-large", `\u5185\u5BB9\u8D85\u8FC7\u4E0A\u9650\uFF08${Math.round(maxBytes / 1024 / 1024)} MB\uFF09\u3002`);
181
+ }
182
+ if (!response?.body?.[Symbol.asyncIterator]) {
183
+ const data = Buffer.from(await response.arrayBuffer());
184
+ if (data.length > maxBytes) {
185
+ throw new WeixinMediaError("media-too-large", `\u5185\u5BB9\u8D85\u8FC7\u4E0A\u9650\uFF08${Math.round(maxBytes / 1024 / 1024)} MB\uFF09\u3002`);
186
+ }
187
+ return data;
188
+ }
189
+ const chunks = [];
190
+ let size = 0;
191
+ for await (const chunk of response.body) {
192
+ const data = Buffer.from(chunk);
193
+ size += data.length;
194
+ if (size > maxBytes) {
195
+ await response.body.cancel?.().catch?.(() => void 0);
196
+ throw new WeixinMediaError("media-too-large", `\u5185\u5BB9\u8D85\u8FC7\u4E0A\u9650\uFF08${Math.round(maxBytes / 1024 / 1024)} MB\uFF09\u3002`);
197
+ }
198
+ chunks.push(data);
199
+ }
200
+ return Buffer.concat(chunks, size);
201
+ }
202
+ async function downloadMedia(item, {
203
+ signal,
204
+ maxBytes = MAX_IMAGE_BYTES,
205
+ fetchImpl = fetch
206
+ } = {}) {
207
+ if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl \u5FC5\u987B\u662F\u51FD\u6570\u3002");
208
+ signal?.throwIfAborted();
209
+ const key = parseMediaAesKey(item);
210
+ const url = mediaDownloadUrl(item?.media);
211
+ const timeout = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
212
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
213
+ let response;
214
+ try {
215
+ response = await fetchImpl(new URL(url), { method: "GET", redirect: "manual", signal: combined });
216
+ } catch (cause) {
217
+ if (signal?.aborted) signal.throwIfAborted();
218
+ throw new WeixinMediaError("media-download-failed", `\u5FAE\u4FE1\u5A92\u4F53\u4E0B\u8F7D\u5931\u8D25\uFF1A${cause?.message ?? cause}`, { cause });
219
+ }
220
+ if (Number.isInteger(response?.status) && response.status >= 300 && response.status < 400) {
221
+ await response.body?.cancel?.().catch?.(() => void 0);
222
+ throw new WeixinMediaError("media-redirect-blocked", "\u5FAE\u4FE1\u5A92\u4F53\u4E0B\u8F7D\u5730\u5740\u53D1\u751F\u4E86\u91CD\u5B9A\u5411\uFF0C\u5DF2\u4E2D\u6B62\u3002");
223
+ }
224
+ if (!response?.ok) {
225
+ await response?.body?.cancel?.().catch?.(() => void 0);
226
+ throw new WeixinMediaError(
227
+ "media-download-failed",
228
+ `\u5FAE\u4FE1\u5A92\u4F53\u4E0B\u8F7D\u5931\u8D25\uFF08HTTP ${response?.status ?? "unknown"}\uFF09\u3002`
229
+ );
230
+ }
231
+ const ciphertext = await readBodyLimited(response, maxBytes + 16);
232
+ signal?.throwIfAborted();
233
+ return decryptMedia(ciphertext, key);
234
+ }
235
+ function sniffImageMediaType(bytes, contentType) {
236
+ const supported = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
237
+ const declared = String(contentType ?? "").split(";")[0].trim().toLowerCase();
238
+ if (supported.has(declared)) return declared;
239
+ const head = bytes.subarray(0, 12);
240
+ if (head.length >= 8 && head[0] === 137 && head[1] === 80 && head[2] === 78) return "image/png";
241
+ if (head.length >= 3 && head[0] === 255 && head[1] === 216 && head[2] === 255) return "image/jpeg";
242
+ if (head.length >= 6 && head.subarray(0, 4).toString("latin1") === "GIF8") return "image/gif";
243
+ if (head.length >= 12 && head.subarray(0, 4).toString("latin1") === "RIFF" && head.subarray(8, 12).toString("latin1") === "WEBP") return "image/webp";
244
+ return null;
245
+ }
246
+ function extractInboundMedia(message) {
247
+ const images = [];
248
+ const files = [];
249
+ for (const item of message?.item_list ?? []) {
250
+ if (item?.image_item && typeof item.image_item === "object") {
251
+ images.push({
252
+ name: images.length === 0 ? "weixin-image" : `weixin-image-${images.length + 1}`,
253
+ item: item.image_item
254
+ });
255
+ continue;
256
+ }
257
+ if (item?.file_item && typeof item.file_item === "object") {
258
+ const declaredSize = Number(item.file_item.len);
259
+ files.push({
260
+ name: nonEmptyString(item.file_item.file_name) ?? (files.length === 0 ? "weixin-file" : `weixin-file-${files.length + 1}`),
261
+ ...Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {},
262
+ item: item.file_item
263
+ });
264
+ }
265
+ }
266
+ return { images, files };
267
+ }
268
+ function aesEcbPaddedSize(size) {
269
+ return Math.ceil((size + 1) / 16) * 16;
270
+ }
271
+ function trustedUploadUrl(value) {
272
+ let url;
273
+ try {
274
+ url = new URL(value);
275
+ } catch {
276
+ throw new WeixinMediaError("invalid-upload-url", "\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6548\u7684\u6587\u4EF6\u4E0A\u4F20\u5730\u5740\u3002");
277
+ }
278
+ if (url.protocol !== "https:" || url.hostname !== MEDIA_CDN_HOST || url.port && url.port !== "443" || url.pathname !== MEDIA_CDN_UPLOAD_PATH || url.username || url.password) {
279
+ throw new WeixinMediaError("untrusted-upload-url", "\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u4E0D\u53D7\u4FE1\u4EFB\u7684\u6587\u4EF6\u4E0A\u4F20\u5730\u5740\u3002");
280
+ }
281
+ url.hash = "";
282
+ return url;
283
+ }
284
+ function mediaUploadUrl(response, fileKey) {
285
+ const fullUrl = nonEmptyString(response?.upload_full_url);
286
+ if (fullUrl) return trustedUploadUrl(fullUrl);
287
+ const uploadParam = nonEmptyString(response?.upload_param);
288
+ if (!uploadParam) throw new WeixinMediaError("missing-upload-url", "\u5FAE\u4FE1\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6587\u4EF6\u4E0A\u4F20\u5730\u5740\u3002");
289
+ const url = new URL(`${MEDIA_CDN_BASE_URL}/upload`);
290
+ url.searchParams.set("encrypted_query_param", uploadParam);
291
+ url.searchParams.set("filekey", fileKey);
292
+ return trustedUploadUrl(url.toString());
293
+ }
294
+ async function* encryptChunks(bytes, key, { signal, onProgress }) {
295
+ const cipher = createCipheriv("aes-128-ecb", key, null);
296
+ for (let offset = 0; offset < bytes.byteLength; offset += UPLOAD_CHUNK_BYTES) {
297
+ signal?.throwIfAborted();
298
+ const chunk = cipher.update(bytes.subarray(offset, offset + UPLOAD_CHUNK_BYTES));
299
+ onProgress();
300
+ if (chunk.byteLength) yield chunk;
301
+ }
302
+ signal?.throwIfAborted();
303
+ onProgress();
304
+ yield cipher.final();
305
+ }
306
+ async function uploadMediaToCdn({
307
+ url,
308
+ bytes,
309
+ key,
310
+ signal,
311
+ fetchImpl = fetch
312
+ }) {
313
+ if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl \u5FC5\u987B\u662F\u51FD\u6570\u3002");
314
+ const target = url instanceof URL ? url : trustedUploadUrl(url);
315
+ let lastError;
316
+ for (let attempt = 1; attempt <= UPLOAD_RETRIES; attempt += 1) {
317
+ signal?.throwIfAborted();
318
+ const idle = new AbortController();
319
+ const uploadSignal = signal ? AbortSignal.any([signal, idle.signal]) : idle.signal;
320
+ let timer;
321
+ let active = true;
322
+ const onProgress = () => {
323
+ if (!active) return;
324
+ clearTimeout(timer);
325
+ timer = setTimeout(() => idle.abort(new WeixinMediaError(
326
+ "upload-timeout",
327
+ "\u5FAE\u4FE1\u6587\u4EF6\u4E0A\u4F20\u957F\u65F6\u95F4\u6CA1\u6709\u8FDB\u5C55\uFF0C\u5DF2\u8D85\u65F6\u3002"
328
+ )), UPLOAD_IDLE_TIMEOUT_MS);
329
+ };
330
+ const body = encryptChunks(bytes, key, { signal: uploadSignal, onProgress });
331
+ let response;
332
+ onProgress();
333
+ try {
334
+ response = await fetchImpl(target, {
335
+ method: "POST",
336
+ headers: {
337
+ "content-type": "application/octet-stream",
338
+ "content-length": String(aesEcbPaddedSize(bytes.byteLength))
339
+ },
340
+ body,
341
+ duplex: "half",
342
+ redirect: "error",
343
+ signal: uploadSignal
344
+ });
345
+ uploadSignal.throwIfAborted();
346
+ if (response.status >= 400 && response.status < 500) {
347
+ throw new WeixinMediaError("upload-rejected", `\u5FAE\u4FE1\u6587\u4EF6\u4E0A\u4F20\u88AB\u62D2\u7EDD\uFF08HTTP ${response.status}\uFF09\u3002`);
348
+ }
349
+ if (response.status !== 200) {
350
+ throw new WeixinMediaError("upload-failed", `\u5FAE\u4FE1\u6587\u4EF6\u4E0A\u4F20\u5931\u8D25\uFF08HTTP ${response.status}\uFF09\u3002`);
351
+ }
352
+ const downloadParam = nonEmptyString(response.headers?.get?.("x-encrypted-param"));
353
+ if (!downloadParam) {
354
+ throw new WeixinMediaError("invalid-upload-response", "\u5FAE\u4FE1\u6587\u4EF6\u4E0A\u4F20\u54CD\u5E94\u7F3A\u5C11\u4E0B\u8F7D\u53C2\u6570\u3002");
355
+ }
356
+ return downloadParam;
357
+ } catch (cause) {
358
+ if (signal?.aborted) signal.throwIfAborted();
359
+ const failure = idle.signal.aborted ? idle.signal.reason : cause;
360
+ lastError = failure;
361
+ if (failure instanceof WeixinMediaError && (failure.code === "upload-rejected" || failure.code === "upload-timeout" || failure.code === "invalid-upload-response")) {
362
+ throw failure;
363
+ }
364
+ } finally {
365
+ active = false;
366
+ clearTimeout(timer);
367
+ await body.return?.();
368
+ await response?.body?.cancel?.().catch?.(() => void 0);
369
+ }
370
+ }
371
+ throw lastError instanceof WeixinMediaError ? lastError : new WeixinMediaError("upload-failed", "\u5FAE\u4FE1\u6587\u4EF6\u4E0A\u4F20\u5931\u8D25\u3002", { cause: lastError });
372
+ }
373
+
374
+ // packages/dsh-chat-weixin/host/ilink-client.mjs
375
+ var DEFAULT_QR_BASE_URL = "https://ilinkai.weixin.qq.com/";
376
+ var PROTOCOL_VERSION = "2.4.6";
377
+ var DEFAULT_BOT_TYPE = "3";
378
+ var MAX_MESSAGE_CHARS = 1800;
379
+ var ILINK_APP_ID = "bot";
380
+ var ILINK_CLIENT_VERSION = 2 << 16 | 4 << 8 | 6;
381
+ var DEFAULT_TIMEOUT_MS = 15e3;
382
+ var LONG_POLL_TIMEOUT_MS = 35e3;
383
+ var LOGIN_STATUSES = Object.freeze([
384
+ "wait",
385
+ "scaned",
386
+ "confirmed",
387
+ "expired",
388
+ "scaned_but_redirect",
389
+ "need_verifycode",
390
+ "verify_code_blocked",
391
+ "binded_redirect"
392
+ ]);
393
+ var IlinkError = class extends Error {
394
+ constructor(code, message, options = {}) {
395
+ super(message, options);
396
+ this.name = "IlinkError";
397
+ this.code = code;
398
+ this.status = options.status;
399
+ this.providerCode = options.providerCode;
400
+ this.timeoutMs = options.timeoutMs;
401
+ }
402
+ };
403
+ function nonEmptyString2(value) {
404
+ return typeof value === "string" && value.trim() ? value.trim() : null;
405
+ }
406
+ function abortError(signal) {
407
+ if (signal?.reason instanceof Error) return signal.reason;
408
+ const error = new Error("\u64CD\u4F5C\u5DF2\u53D6\u6D88");
409
+ error.name = "AbortError";
410
+ return error;
411
+ }
412
+ function rejectedResponse(value, fields = ["ret", "errcode"]) {
413
+ if (!value || typeof value !== "object") return null;
414
+ for (const field of fields) {
415
+ const raw = value[field];
416
+ if (raw === void 0 || raw === 0 || raw === "0") continue;
417
+ return typeof raw === "string" || typeof raw === "number" ? String(raw) : "rejected";
418
+ }
419
+ return null;
420
+ }
421
+ function isWeixinHost(hostname) {
422
+ const normalized = hostname.toLowerCase().replace(/\.$/, "");
423
+ return normalized === "weixin.qq.com" || normalized.endsWith(".weixin.qq.com") || normalized === "wechat.com" || normalized.endsWith(".wechat.com");
424
+ }
425
+ function normalizeBaseUrl(value) {
426
+ let url;
427
+ try {
428
+ url = new URL(value);
429
+ } catch {
430
+ throw new IlinkError("invalid-base-url", "\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6548\u7684\u8FDE\u63A5\u5730\u5740\u3002");
431
+ }
432
+ if (url.protocol !== "https:" || !isWeixinHost(url.hostname) || url.port !== "" && url.port !== "443") {
433
+ throw new IlinkError("untrusted-base-url", "\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u4E0D\u53D7\u4FE1\u4EFB\u7684\u8FDE\u63A5\u5730\u5740\u3002");
434
+ }
435
+ url.username = "";
436
+ url.password = "";
437
+ url.search = "";
438
+ url.hash = "";
439
+ if (!url.pathname.endsWith("/")) url.pathname += "/";
440
+ return url.toString();
441
+ }
442
+ function normalizeQrUrl(value) {
443
+ const text = nonEmptyString2(value);
444
+ if (!text) return null;
445
+ let url;
446
+ try {
447
+ url = new URL(text);
448
+ } catch {
449
+ throw new IlinkError("invalid-qr", "\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6548\u7684\u626B\u7801\u5730\u5740\u3002");
450
+ }
451
+ if (url.protocol !== "https:" || !isWeixinHost(url.hostname)) {
452
+ throw new IlinkError("untrusted-qr", "\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u4E0D\u53D7\u4FE1\u4EFB\u7684\u626B\u7801\u5730\u5740\u3002");
453
+ }
454
+ return url.toString();
455
+ }
456
+ function commonHeaders() {
457
+ return {
458
+ "iLink-App-Id": ILINK_APP_ID,
459
+ "iLink-App-ClientVersion": String(ILINK_CLIENT_VERSION)
460
+ };
461
+ }
462
+ function authenticatedHeaders(token) {
463
+ const headers = {
464
+ ...commonHeaders(),
465
+ "content-type": "application/json",
466
+ AuthorizationType: "ilink_bot_token",
467
+ "X-WECHAT-UIN": Buffer.from(String(randomBytes(4).readUInt32BE(0)), "utf8").toString("base64")
468
+ };
469
+ const value = nonEmptyString2(token);
470
+ if (value) headers.Authorization = `Bearer ${value}`;
471
+ return headers;
472
+ }
473
+ function baseInfo() {
474
+ return { channel_version: PROTOCOL_VERSION, bot_agent: "dsh-chat/0.0.1" };
475
+ }
476
+ async function requestJson(fetchImpl, {
477
+ method,
478
+ baseUrl,
479
+ endpoint,
480
+ body,
481
+ token,
482
+ timeoutMs = DEFAULT_TIMEOUT_MS,
483
+ signal,
484
+ authenticated = true
485
+ }) {
486
+ const trustedBase = normalizeBaseUrl(baseUrl);
487
+ const url = new URL(endpoint, trustedBase);
488
+ if (!isWeixinHost(url.hostname)) {
489
+ throw new IlinkError("untrusted-endpoint", "\u62D2\u7EDD\u8BBF\u95EE\u4E0D\u53D7\u4FE1\u4EFB\u7684\u5FAE\u4FE1\u670D\u52A1\u5730\u5740\u3002");
490
+ }
491
+ if (signal?.aborted) throw abortError(signal);
492
+ const controller = new AbortController();
493
+ const onAbort = () => controller.abort(signal?.reason);
494
+ signal?.addEventListener("abort", onAbort, { once: true });
495
+ let timedOut = false;
496
+ const timer = timeoutMs > 0 ? setTimeout(() => {
497
+ timedOut = true;
498
+ controller.abort();
499
+ }, timeoutMs) : null;
500
+ try {
501
+ const response = await fetchImpl(url, {
502
+ method,
503
+ headers: authenticated ? authenticatedHeaders(token) : commonHeaders(),
504
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
505
+ signal: controller.signal
506
+ });
507
+ if (!response.ok) {
508
+ throw new IlinkError("http-error", `\u5FAE\u4FE1\u670D\u52A1\u8BF7\u6C42\u5931\u8D25\uFF08HTTP ${response.status}\uFF09\u3002`, {
509
+ status: response.status
510
+ });
511
+ }
512
+ try {
513
+ return await response.json();
514
+ } catch (error) {
515
+ throw new IlinkError("invalid-response", "\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u89E3\u6790\u7684\u54CD\u5E94\u3002", { cause: error });
516
+ }
517
+ } catch (error) {
518
+ if (signal?.aborted) throw abortError(signal);
519
+ if (timedOut) {
520
+ throw new IlinkError("timeout", "\u5FAE\u4FE1\u670D\u52A1\u8BF7\u6C42\u8D85\u65F6\u3002", { cause: error, timeoutMs });
521
+ }
522
+ throw error instanceof IlinkError ? error : new IlinkError("network-error", "\u6682\u65F6\u65E0\u6CD5\u8BBF\u95EE\u5FAE\u4FE1\u670D\u52A1\u3002", { cause: error });
523
+ } finally {
524
+ if (timer) clearTimeout(timer);
525
+ signal?.removeEventListener?.("abort", onAbort);
526
+ }
527
+ }
528
+ function extractText(message) {
529
+ for (const item of message?.item_list ?? []) {
530
+ if (item?.type === 1 && typeof item.text_item?.text === "string") {
531
+ const text = item.text_item.text.trim();
532
+ if (text) return text;
533
+ }
534
+ if (item?.type === 3 && typeof item.voice_item?.text === "string") {
535
+ const text = item.voice_item.text.trim();
536
+ if (text) return text;
537
+ }
538
+ }
539
+ return null;
540
+ }
541
+ function messageId(message) {
542
+ if (message?.message_id !== void 0 && message.message_id !== null) {
543
+ return String(message.message_id);
544
+ }
545
+ return nonEmptyString2(message?.client_id);
546
+ }
547
+ function splitText(text, maxChars = MAX_MESSAGE_CHARS) {
548
+ if (text.length <= maxChars) return [text];
549
+ const chunks = [];
550
+ let remaining = text;
551
+ while (remaining.length > maxChars) {
552
+ let splitAt = remaining.lastIndexOf("\n", maxChars);
553
+ if (splitAt < Math.floor(maxChars * 0.6)) splitAt = maxChars;
554
+ chunks.push(remaining.slice(0, splitAt));
555
+ remaining = remaining.slice(splitAt).replace(/^\n+/, "");
556
+ }
557
+ if (remaining) chunks.push(remaining);
558
+ return chunks;
559
+ }
560
+ async function sendArtifact(fetchImpl, {
561
+ baseUrl,
562
+ token,
563
+ toUserId,
564
+ bytes,
565
+ contextToken,
566
+ runId,
567
+ signal
568
+ }, { mediaType, buildItem }) {
569
+ const recipient = nonEmptyString2(toUserId);
570
+ if (!recipient || !bytes?.byteLength) {
571
+ throw new TypeError("\u53D1\u9001\u5A92\u4F53\u9700\u8981 toUserId \u4E0E\u975E\u7A7A\u5B57\u8282\u3002");
572
+ }
573
+ signal?.throwIfAborted();
574
+ const fileKey = randomBytes(16).toString("hex");
575
+ const aesKey = randomBytes(16);
576
+ const ciphertextSize = aesEcbPaddedSize(bytes.byteLength);
577
+ const upload = await requestJson(fetchImpl, {
578
+ method: "POST",
579
+ baseUrl,
580
+ endpoint: "ilink/bot/getuploadurl",
581
+ token,
582
+ signal,
583
+ body: {
584
+ filekey: fileKey,
585
+ media_type: mediaType,
586
+ to_user_id: recipient,
587
+ rawsize: bytes.byteLength,
588
+ rawfilemd5: createHash("md5").update(bytes).digest("hex"),
589
+ filesize: ciphertextSize,
590
+ no_need_thumb: true,
591
+ aeskey: aesKey.toString("hex"),
592
+ base_info: baseInfo()
593
+ }
594
+ });
595
+ const uploadRejection = rejectedResponse(upload);
596
+ if (uploadRejection) {
597
+ throw new IlinkError("upload-url-rejected", "\u5FAE\u4FE1\u670D\u52A1\u62D2\u7EDD\u4E86\u6587\u4EF6\u4E0A\u4F20\u8BF7\u6C42\u3002", {
598
+ providerCode: uploadRejection
599
+ });
600
+ }
601
+ const downloadParam = await uploadMediaToCdn({
602
+ url: mediaUploadUrl(upload, fileKey),
603
+ bytes,
604
+ key: aesKey,
605
+ signal,
606
+ fetchImpl
607
+ });
608
+ const media = {
609
+ encrypt_query_param: downloadParam,
610
+ // 服务端要的是"十六进制字符串再做 base64",与入站解析保持一致。
611
+ aes_key: Buffer.from(aesKey.toString("hex"), "utf8").toString("base64"),
612
+ encrypt_type: 1
613
+ };
614
+ const clientId = `dsh-chat-weixin-${randomUUID()}`;
615
+ const response = await requestJson(fetchImpl, {
616
+ method: "POST",
617
+ baseUrl,
618
+ endpoint: "ilink/bot/sendmessage",
619
+ token,
620
+ signal,
621
+ body: {
622
+ msg: {
623
+ from_user_id: "",
624
+ to_user_id: recipient,
625
+ client_id: clientId,
626
+ message_type: 2,
627
+ message_state: 2,
628
+ item_list: [buildItem({ media, ciphertextSize })],
629
+ ...nonEmptyString2(contextToken) ? { context_token: contextToken } : {},
630
+ ...nonEmptyString2(runId) ? { run_id: runId } : {}
631
+ },
632
+ base_info: baseInfo()
633
+ }
634
+ });
635
+ const sendRejection = rejectedResponse(response);
636
+ if (sendRejection) {
637
+ throw new IlinkError("send-rejected", "\u5FAE\u4FE1\u670D\u52A1\u62D2\u7EDD\u4E86\u6587\u4EF6\u6D88\u606F\u3002", { providerCode: sendRejection });
638
+ }
639
+ return { providerMessageIds: [clientId] };
640
+ }
641
+ function createIlinkClient({ fetchImpl = fetch } = {}) {
642
+ if (typeof fetchImpl !== "function") throw new TypeError("ilink \u5BA2\u6237\u7AEF\u9700\u8981 fetch\u3002");
643
+ return Object.freeze({
644
+ /**
645
+ * 申请登录二维码。
646
+ *
647
+ * @param options - { localTokens, botType, signal }。
648
+ * @returns { qrcode, qrcodeUrl }。
649
+ */
650
+ async beginLogin({ localTokens = [], botType = DEFAULT_BOT_TYPE, signal } = {}) {
651
+ const tokens = [...new Set(localTokens.map(nonEmptyString2).filter(Boolean))].slice(-10);
652
+ const response = await requestJson(fetchImpl, {
653
+ method: "POST",
654
+ baseUrl: DEFAULT_QR_BASE_URL,
655
+ endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
656
+ body: { local_token_list: tokens },
657
+ timeoutMs: 1e4,
658
+ signal
659
+ });
660
+ const rejection = rejectedResponse(response, ["errcode", "ret"]);
661
+ if (rejection) {
662
+ throw new IlinkError("qr-request-rejected", "\u5FAE\u4FE1\u670D\u52A1\u62D2\u7EDD\u4E86\u4E8C\u7EF4\u7801\u7533\u8BF7\u3002", {
663
+ providerCode: rejection
664
+ });
665
+ }
666
+ const qrcode = nonEmptyString2(response?.qrcode);
667
+ if (!qrcode) throw new IlinkError("invalid-qr", "\u5FAE\u4FE1\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u4E8C\u7EF4\u7801\u4EE4\u724C\u3002");
668
+ return { qrcode, qrcodeUrl: normalizeQrUrl(response?.qrcode_img_content) };
669
+ },
670
+ /**
671
+ * 轮询扫码状态。
672
+ *
673
+ * @param options - { qrcode, baseUrl, verifyCode, signal }。
674
+ * @returns 服务端状态对象。
675
+ */
676
+ async pollLogin({ qrcode, baseUrl = DEFAULT_QR_BASE_URL, verifyCode, signal }) {
677
+ const qr = nonEmptyString2(qrcode);
678
+ if (!qr) throw new TypeError("pollLogin \u9700\u8981 qrcode\u3002");
679
+ let endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qr)}`;
680
+ const code = nonEmptyString2(verifyCode);
681
+ if (code) endpoint += `&verify_code=${encodeURIComponent(code)}`;
682
+ const response = await requestJson(fetchImpl, {
683
+ method: "GET",
684
+ baseUrl,
685
+ endpoint,
686
+ timeoutMs: LONG_POLL_TIMEOUT_MS,
687
+ signal,
688
+ authenticated: false
689
+ });
690
+ if (!response || typeof response !== "object" || !LOGIN_STATUSES.includes(response.status)) {
691
+ throw new IlinkError("invalid-login-status", "\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u626B\u7801\u72B6\u6001\u3002");
692
+ }
693
+ return response;
694
+ },
695
+ /**
696
+ * 长轮询收取消息;超时视为"这一轮没有新消息"。
697
+ *
698
+ * @param options - { baseUrl, token, getUpdatesBuf, timeoutMs, signal }。
699
+ * @returns { ret, msgs, get_updates_buf }。
700
+ */
701
+ async getUpdates({ baseUrl, token, getUpdatesBuf = "", timeoutMs, signal }) {
702
+ try {
703
+ return await requestJson(fetchImpl, {
704
+ method: "POST",
705
+ baseUrl,
706
+ endpoint: "ilink/bot/getupdates",
707
+ body: { get_updates_buf: getUpdatesBuf, base_info: baseInfo() },
708
+ token,
709
+ timeoutMs: timeoutMs ?? LONG_POLL_TIMEOUT_MS,
710
+ signal
711
+ });
712
+ } catch (error) {
713
+ if (error instanceof IlinkError && error.code === "timeout") {
714
+ return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf };
715
+ }
716
+ throw error;
717
+ }
718
+ },
719
+ /**
720
+ * 取该用户的机器人配置(主要是 typing_ticket)。
721
+ *
722
+ * @param options - { baseUrl, token, toUserId, contextToken, signal }。
723
+ * @returns { typingTicket }。
724
+ */
725
+ async getConfig({ baseUrl, token, toUserId, contextToken, signal }) {
726
+ const recipient = nonEmptyString2(toUserId);
727
+ if (!recipient) throw new TypeError("getConfig \u9700\u8981 toUserId\u3002");
728
+ const response = await requestJson(fetchImpl, {
729
+ method: "POST",
730
+ baseUrl,
731
+ endpoint: "ilink/bot/getconfig",
732
+ token,
733
+ signal,
734
+ timeoutMs: 1e4,
735
+ body: {
736
+ ilink_user_id: recipient,
737
+ ...nonEmptyString2(contextToken) ? { context_token: contextToken } : {},
738
+ base_info: baseInfo()
739
+ }
740
+ });
741
+ if (response?.ret !== void 0 && response.ret !== 0) {
742
+ throw new IlinkError("config-rejected", "\u5FAE\u4FE1\u670D\u52A1\u62D2\u7EDD\u4E86\u673A\u5668\u4EBA\u914D\u7F6E\u8BF7\u6C42\u3002", {
743
+ providerCode: String(response.ret)
744
+ });
745
+ }
746
+ return { typingTicket: nonEmptyString2(response?.typing_ticket) };
747
+ },
748
+ /**
749
+ * 发送/结束"正在输入"。
750
+ *
751
+ * @param options - { baseUrl, token, toUserId, typingTicket, status },status 1=开始 2=结束。
752
+ */
753
+ async sendTyping({ baseUrl, token, toUserId, typingTicket, status, signal }) {
754
+ const recipient = nonEmptyString2(toUserId);
755
+ const ticket = nonEmptyString2(typingTicket);
756
+ if (!recipient || !ticket) throw new TypeError("sendTyping \u9700\u8981 toUserId \u4E0E typingTicket\u3002");
757
+ if (status !== 1 && status !== 2) throw new TypeError("typing status \u53EA\u80FD\u662F 1 \u6216 2\u3002");
758
+ const response = await requestJson(fetchImpl, {
759
+ method: "POST",
760
+ baseUrl,
761
+ endpoint: "ilink/bot/sendtyping",
762
+ token,
763
+ signal,
764
+ timeoutMs: 1e4,
765
+ body: {
766
+ ilink_user_id: recipient,
767
+ typing_ticket: ticket,
768
+ status,
769
+ base_info: baseInfo()
770
+ }
771
+ });
772
+ if (response?.ret !== void 0 && response.ret !== 0) {
773
+ throw new IlinkError("typing-rejected", "\u5FAE\u4FE1\u670D\u52A1\u62D2\u7EDD\u4E86\u8F93\u5165\u72B6\u6001\u8BF7\u6C42\u3002", {
774
+ providerCode: String(response.ret)
775
+ });
776
+ }
777
+ return true;
778
+ },
779
+ /**
780
+ * 发送一条文本消息。
781
+ *
782
+ * @param options - { baseUrl, token, toUserId, text, contextToken, runId, signal }。
783
+ * @returns { providerMessageIds }。
784
+ */
785
+ async sendText({ baseUrl, token, toUserId, text, contextToken, runId, signal }) {
786
+ const recipient = nonEmptyString2(toUserId);
787
+ const content = nonEmptyString2(text);
788
+ if (!recipient || !content) throw new TypeError("sendText \u9700\u8981 toUserId \u4E0E text\u3002");
789
+ const clientId = `dsh-chat-weixin-${randomUUID()}`;
790
+ const response = await requestJson(fetchImpl, {
791
+ method: "POST",
792
+ baseUrl,
793
+ endpoint: "ilink/bot/sendmessage",
794
+ token,
795
+ signal,
796
+ body: {
797
+ msg: {
798
+ from_user_id: "",
799
+ to_user_id: recipient,
800
+ client_id: clientId,
801
+ message_type: 2,
802
+ message_state: 2,
803
+ item_list: [{ type: 1, text_item: { text: content } }],
804
+ ...nonEmptyString2(contextToken) ? { context_token: contextToken } : {},
805
+ ...nonEmptyString2(runId) ? { run_id: runId } : {}
806
+ },
807
+ base_info: baseInfo()
808
+ }
809
+ });
810
+ const rejection = rejectedResponse(response);
811
+ if (rejection) {
812
+ throw new IlinkError("send-rejected", "\u5FAE\u4FE1\u670D\u52A1\u62D2\u7EDD\u4E86\u56DE\u590D\u6D88\u606F\u3002", { providerCode: rejection });
813
+ }
814
+ return { providerMessageIds: [clientId] };
815
+ },
816
+ /**
817
+ * 发送一个文件(`file_item`)。
818
+ *
819
+ * @param options - { baseUrl, token, toUserId, fileName, bytes, contextToken, runId, signal }。
820
+ * @returns { providerMessageIds }。
821
+ */
822
+ async sendFile({
823
+ baseUrl,
824
+ token,
825
+ toUserId,
826
+ fileName,
827
+ bytes,
828
+ contextToken,
829
+ runId,
830
+ signal
831
+ }) {
832
+ const name2 = nonEmptyString2(fileName);
833
+ if (!name2) throw new TypeError("sendFile \u9700\u8981 fileName\u3002");
834
+ return sendArtifact(fetchImpl, {
835
+ baseUrl,
836
+ token,
837
+ toUserId,
838
+ bytes,
839
+ contextToken,
840
+ runId,
841
+ signal
842
+ }, {
843
+ mediaType: 3,
844
+ buildItem: ({ media }) => ({
845
+ type: 4,
846
+ file_item: { media, file_name: name2, len: String(bytes.byteLength) }
847
+ })
848
+ });
849
+ },
850
+ /**
851
+ * 发送一张图片(`image_item`,聊天里显示为图片气泡)。
852
+ *
853
+ * @param options - { baseUrl, token, toUserId, bytes, contextToken, runId, signal }。
854
+ * @returns { providerMessageIds }。
855
+ */
856
+ async sendImage({
857
+ baseUrl,
858
+ token,
859
+ toUserId,
860
+ bytes,
861
+ contextToken,
862
+ runId,
863
+ signal
864
+ }) {
865
+ return sendArtifact(fetchImpl, {
866
+ baseUrl,
867
+ token,
868
+ toUserId,
869
+ bytes,
870
+ contextToken,
871
+ runId,
872
+ signal
873
+ }, {
874
+ mediaType: 1,
875
+ buildItem: ({ media, ciphertextSize }) => ({
876
+ type: 2,
877
+ image_item: { media, mid_size: ciphertextSize }
878
+ })
879
+ });
880
+ },
881
+ /** 告诉服务端本机器人开始工作(连接建立时调用)。 */
882
+ async notifyStart({ baseUrl, token, signal }) {
883
+ const response = await requestJson(fetchImpl, {
884
+ method: "POST",
885
+ baseUrl,
886
+ endpoint: "ilink/bot/msg/notifystart",
887
+ token,
888
+ signal,
889
+ timeoutMs: 1e4,
890
+ body: { base_info: baseInfo() }
891
+ });
892
+ const rejection = rejectedResponse(response, ["errcode", "ret"]);
893
+ if (rejection) {
894
+ throw new IlinkError(
895
+ rejection === "-14" ? "stale-token" : "start-rejected",
896
+ rejection === "-14" ? "\u5FAE\u4FE1\u767B\u5F55\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u626B\u7801\u3002" : "\u5FAE\u4FE1\u8D26\u53F7\u8FDE\u63A5\u542F\u52A8\u5931\u8D25\u3002",
897
+ { providerCode: rejection }
898
+ );
899
+ }
900
+ return response;
901
+ },
902
+ /** 告诉服务端本机器人停止工作。 */
903
+ async notifyStop({ baseUrl, token, signal }) {
904
+ const response = await requestJson(fetchImpl, {
905
+ method: "POST",
906
+ baseUrl,
907
+ endpoint: "ilink/bot/msg/notifystop",
908
+ token,
909
+ signal,
910
+ timeoutMs: 1e4,
911
+ body: { base_info: baseInfo() }
912
+ });
913
+ const rejection = rejectedResponse(response, ["errcode", "ret"]);
914
+ if (rejection) {
915
+ throw new IlinkError("stop-rejected", "\u5FAE\u4FE1\u670D\u52A1\u672A\u786E\u8BA4\u505C\u6B62\u901A\u77E5\u3002", { providerCode: rejection });
916
+ }
917
+ return response;
918
+ }
919
+ });
920
+ }
921
+
922
+ // packages/dsh-chat-weixin/host/runtime.mjs
923
+ import { readFile, stat } from "node:fs/promises";
924
+ import { basename } from "node:path";
925
+ function createWeixinRuntime({
926
+ account,
927
+ token,
928
+ deps,
929
+ client,
930
+ state,
931
+ logger = console,
932
+ fetchImpl = fetch
933
+ }) {
934
+ if (!account?.botId) throw new TypeError("\u5FAE\u4FE1\u8FD0\u884C\u65F6\u9700\u8981\u8D26\u53F7\u914D\u7F6E\u3002");
935
+ if (!token) throw new TypeError("\u5FAE\u4FE1\u8FD0\u884C\u65F6\u9700\u8981\u8BBF\u95EE\u4EE4\u724C\u3002");
936
+ if (typeof deps?.sessions?.ask !== "function" || typeof deps?.contextEnhancement?.enhanceContent !== "function") {
937
+ throw new TypeError("\u5FAE\u4FE1\u8FD0\u884C\u65F6\u9700\u8981 hub \u7684 sessions.ask \u4E0E contextEnhancement\u3002");
938
+ }
939
+ const baseUrl = account.baseUrl;
940
+ let phase = "idle";
941
+ let error = null;
942
+ let handled = 0;
943
+ let lastHandledAt = null;
944
+ let lastMessageAt = null;
945
+ let typingTickets = /* @__PURE__ */ new Map();
946
+ let loop = null;
947
+ function setPhase(next, detail = null) {
948
+ phase = next;
949
+ error = detail;
950
+ }
951
+ async function typingTicket(userId, contextToken, signal) {
952
+ const cached = typingTickets.get(userId);
953
+ if (cached) return cached;
954
+ const config = await client.getConfig({
955
+ baseUrl,
956
+ token,
957
+ toUserId: userId,
958
+ contextToken,
959
+ signal
960
+ });
961
+ if (config?.typingTicket) {
962
+ if (typingTickets.size > 200) typingTickets = /* @__PURE__ */ new Map();
963
+ typingTickets.set(userId, config.typingTicket);
964
+ return config.typingTicket;
965
+ }
966
+ return null;
967
+ }
968
+ async function typing(userId, contextToken, status, signal) {
969
+ try {
970
+ const ticket = await typingTicket(userId, contextToken, signal);
971
+ if (!ticket) return false;
972
+ await client.sendTyping({
973
+ baseUrl,
974
+ token,
975
+ toUserId: userId,
976
+ typingTicket: ticket,
977
+ status,
978
+ signal
979
+ });
980
+ return true;
981
+ } catch (cause) {
982
+ typingTickets.delete(userId);
983
+ logger.warn?.(`[dsh-chat-weixin] \u53D1\u9001\u8F93\u5165\u72B6\u6001\u5931\u8D25\uFF1A${cause?.message ?? cause}`);
984
+ return false;
985
+ }
986
+ }
987
+ async function reply(userId, text, contextToken, runId, signal) {
988
+ const chunks = splitText(text);
989
+ for (const chunk of chunks) {
990
+ await client.sendText({
991
+ baseUrl,
992
+ token,
993
+ toUserId: userId,
994
+ text: chunk,
995
+ contextToken,
996
+ runId,
997
+ signal
998
+ });
999
+ }
1000
+ return chunks.length;
1001
+ }
1002
+ async function loadAttachments({ media, key, workspacePath, signal }) {
1003
+ const parts = [];
1004
+ for (const image of media.images) {
1005
+ const bytes = await downloadMedia(image.item, { signal, maxBytes: MAX_IMAGE_BYTES, fetchImpl });
1006
+ const mediaType = sniffImageMediaType(bytes);
1007
+ if (!mediaType) {
1008
+ throw new WeixinMediaError("unsupported-image", "\u8FD9\u5F20\u56FE\u7247\u7684\u683C\u5F0F\u6682\u4E0D\u652F\u6301\uFF0C\u8BF7\u53D1 PNG/JPEG/WebP/GIF\u3002");
1009
+ }
1010
+ parts.push({ type: "image", mediaType, data: bytes.toString("base64"), name: image.name });
1011
+ logger.info?.(`[dsh-chat-weixin] \u5DF2\u6536\u5230\u56FE\u7247\uFF1A${mediaType}\uFF08${bytes.length} \u5B57\u8282\uFF0C${account.botId}\uFF09`);
1012
+ }
1013
+ for (const file of media.files) {
1014
+ const bytes = await downloadMedia(file.item, { signal, maxBytes: MAX_FILE_BYTES, fetchImpl });
1015
+ const { sessionId } = await deps.sessions.ensure({
1016
+ channelId: deps.channelId,
1017
+ botId: account.botId,
1018
+ key,
1019
+ workspacePath
1020
+ });
1021
+ const uploaded = await deps.sessions.uploadFile({
1022
+ sessionId,
1023
+ name: file.name,
1024
+ bytes: new Uint8Array(bytes),
1025
+ signal
1026
+ });
1027
+ if (!uploaded?.receiptId) throw new Error("\u4E0A\u4F20\u540E\u6CA1\u6709\u62FF\u5230 receiptId");
1028
+ parts.push({ type: "file", receiptId: uploaded.receiptId });
1029
+ logger.info?.(`[dsh-chat-weixin] \u5DF2\u6536\u5230\u6587\u4EF6\uFF1A${file.name}\uFF08${bytes.length} \u5B57\u8282\uFF0C${account.botId}\uFF09`);
1030
+ }
1031
+ return parts;
1032
+ }
1033
+ async function accept(message, signal) {
1034
+ try {
1035
+ await handleMessage(message, signal);
1036
+ } catch (cause) {
1037
+ const detail = cause?.message ?? String(cause);
1038
+ error = detail;
1039
+ logger.error?.(`[dsh-chat-weixin] \u5904\u7406\u5165\u7AD9\u6D88\u606F\u5F02\u5E38\uFF1A${detail}`);
1040
+ await state.recordFailure(detail);
1041
+ const sender = typeof message?.from_user_id === "string" ? message.from_user_id.trim() : "";
1042
+ if (sender) {
1043
+ try {
1044
+ const token2 = typeof message.context_token === "string" ? message.context_token : state.contextToken(sender);
1045
+ await reply(sender, `\u5904\u7406\u5931\u8D25\uFF1A${detail}`, token2, message?.run_id, signal);
1046
+ } catch {
1047
+ }
1048
+ }
1049
+ }
1050
+ }
1051
+ const detachInteractions = deps.interactions?.attach?.({
1052
+ channelId: deps.channelId,
1053
+ botId: account.botId,
1054
+ send: async ({ key, text }) => {
1055
+ const userId = (key.startsWith("p2p:") ? key.slice(4) : key).trim();
1056
+ if (!userId) throw new TypeError("\u4EA4\u4E92\u56DE\u4F20\u9700\u8981 userId\u3002");
1057
+ await reply(userId, String(text ?? ""), state.contextToken(userId));
1058
+ }
1059
+ });
1060
+ const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"]);
1061
+ async function sendDeliverables({ userId, files, contextToken, signal }) {
1062
+ if (!Array.isArray(files) || files.length === 0) return;
1063
+ for (const file of files) {
1064
+ const path = typeof file?.path === "string" ? file.path : "";
1065
+ if (!path) continue;
1066
+ const name2 = path.split("/").pop() || "\u4EA4\u4ED8\u6587\u4EF6";
1067
+ try {
1068
+ const info = await stat(path);
1069
+ if (!info.isFile() || info.size === 0) throw new Error("\u4E0D\u662F\u666E\u901A\u6587\u4EF6\u6216\u5185\u5BB9\u4E3A\u7A7A");
1070
+ if (info.size > MAX_FILE_BYTES) {
1071
+ throw new Error(`\u8D85\u8FC7 ${Math.round(MAX_FILE_BYTES / 1024 / 1024)}MB \u4E0A\u9650`);
1072
+ }
1073
+ const ext = name2.slice(name2.lastIndexOf(".")).toLowerCase();
1074
+ const bytes = await readFile(path);
1075
+ const sent = IMAGE_EXTENSIONS.has(ext) ? await client.sendImage({ baseUrl, token, toUserId: userId, bytes, contextToken, signal }) : await client.sendFile({
1076
+ baseUrl,
1077
+ token,
1078
+ toUserId: userId,
1079
+ fileName: name2,
1080
+ bytes,
1081
+ contextToken,
1082
+ signal
1083
+ });
1084
+ logger.info?.(`[dsh-chat-weixin] \u5DF2\u53D1\u9001\u4EA4\u4ED8\u6587\u4EF6\uFF1A${name2}\uFF08${info.size} \u5B57\u8282\uFF0C${account.botId}\uFF09`);
1085
+ void sent;
1086
+ } catch (cause) {
1087
+ const reason = cause?.message ?? String(cause);
1088
+ error = `\u4EA4\u4ED8\u6587\u4EF6 ${name2} \u53D1\u9001\u5931\u8D25\uFF1A${reason}`;
1089
+ logger.error?.(`[dsh-chat-weixin] ${error}`);
1090
+ await state.recordFailure(error);
1091
+ try {
1092
+ await reply(userId, `\u4EA4\u4ED8\u6587\u4EF6\u300C${name2}\u300D\u6CA1\u80FD\u53D1\u51FA\u53BB\uFF1A${reason}`, contextToken, void 0, signal);
1093
+ } catch {
1094
+ }
1095
+ }
1096
+ }
1097
+ }
1098
+ deps.deferred?.register?.({
1099
+ channelId: deps.channelId,
1100
+ botId: account.botId,
1101
+ deliver: async ({ key, text }) => {
1102
+ const userId = key.startsWith("p2p:") ? key.slice("p2p:".length) : key;
1103
+ const contextToken = state.contextToken?.(userId) ?? null;
1104
+ if (!contextToken) {
1105
+ throw new Error(`\u5FAE\u4FE1\u6CA1\u6709 ${userId} \u7684 context token\uFF0C\u8865\u53D1\u4E0D\u4E86\uFF08\u7B49\u4ED6\u518D\u53D1\u4E00\u6761\u6D88\u606F\u540E\u91CD\u8BD5\uFF09`);
1106
+ }
1107
+ await reply(userId, `\uFF08\u4E0A\u4E00\u8F6E\u8D85\u65F6\u4E4B\u540E\u8DD1\u5B8C\u4E86\uFF0C\u8865\u53D1\u7ED3\u679C\uFF09
1108
+
1109
+ ${text}`, contextToken, null, null);
1110
+ logger.info?.(`[dsh-chat-weixin] \u5EF6\u8FDF\u4EA4\u4ED8\u5DF2\u8865\u53D1\uFF1A${account.botId} ${key} ${text.length} \u5B57`);
1111
+ }
1112
+ });
1113
+ async function handleMessage(message, signal) {
1114
+ if (message?.message_type === 2) return;
1115
+ const id = messageId(message);
1116
+ const sender = typeof message?.from_user_id === "string" ? message.from_user_id.trim() : "";
1117
+ if (!id || !sender) return;
1118
+ if (!state.markSeen(id)) return;
1119
+ lastMessageAt = (/* @__PURE__ */ new Date()).toISOString();
1120
+ await deps.ready?.();
1121
+ const record = deps.storage.read(account.botId);
1122
+ const access = deps.accessPolicy.evaluateAccess({
1123
+ policy: record.accessPolicy,
1124
+ conversationType: "direct",
1125
+ senderIds: [sender],
1126
+ // 属主判定走与飞书同一份规则(`*` 表示没有属主,不授权任何人绕过策略)。
1127
+ isOwner: deps.accessPolicy?.isOwnerId?.([account.ownerUserId], sender) === true
1128
+ });
1129
+ if (!access.allowed) {
1130
+ logger.info?.(`[dsh-chat-weixin] \u5FFD\u7565\u672A\u653E\u884C\u7684\u6D88\u606F\uFF1A${account.botId} sender=${sender}\uFF08${access.reason}\uFF09`);
1131
+ return;
1132
+ }
1133
+ const text = extractText(message);
1134
+ const media = extractInboundMedia(message);
1135
+ const hasMedia = media.images.length > 0 || media.files.length > 0;
1136
+ if (!text && !hasMedia) {
1137
+ await reply(
1138
+ sender,
1139
+ "\u76EE\u524D\u652F\u6301\u6587\u672C\u3001\u8BED\u97F3\u8F6C\u5199\u3001\u56FE\u7247\u4E0E\u6587\u4EF6\uFF0C\u5176\u4ED6\u7C7B\u578B\uFF08\u89C6\u9891\u3001\u8868\u60C5\u7B49\uFF09\u6682\u4E0D\u652F\u6301\u3002",
1140
+ message.context_token,
1141
+ message.run_id,
1142
+ signal
1143
+ );
1144
+ return;
1145
+ }
1146
+ const inboundToken = typeof message.context_token === "string" ? message.context_token : void 0;
1147
+ const runId = typeof message.run_id === "string" ? message.run_id : void 0;
1148
+ if (inboundToken) await state.rememberContextToken(sender, inboundToken);
1149
+ const contextToken = inboundToken ?? state.contextToken(sender);
1150
+ const key = `p2p:${sender}`;
1151
+ if (!hasMedia && deps.interactions?.offer?.({
1152
+ channelId: deps.channelId,
1153
+ botId: account.botId,
1154
+ key,
1155
+ text
1156
+ })) {
1157
+ logger.info?.(`[dsh-chat-weixin] \u8BA4\u9886\u4E3A\u4EA4\u4E92\u56DE\u7B54\uFF08${account.botId} ${key}\uFF09`);
1158
+ return;
1159
+ }
1160
+ if (!hasMedia) {
1161
+ if (text.startsWith("/")) {
1162
+ const commandAccess = deps.accessPolicy.evaluateAccess({
1163
+ policy: record.accessPolicy,
1164
+ conversationType: "direct",
1165
+ senderIds: [sender],
1166
+ isCommand: true,
1167
+ isOwner: deps.accessPolicy?.isOwnerId?.([account.ownerUserId], sender) === true
1168
+ });
1169
+ if (!commandAccess.allowed) {
1170
+ logger.info?.(`[dsh-chat-weixin] \u547D\u4EE4\u88AB\u62D2\u7EDD\uFF1A${account.botId} sender=${sender}\uFF08${commandAccess.reason}\uFF09`);
1171
+ await reply(sender, "\u4F60\u6CA1\u6709\u6267\u884C\u673A\u5668\u4EBA\u547D\u4EE4\u7684\u6743\u9650\u3002", contextToken, runId, signal);
1172
+ return;
1173
+ }
1174
+ }
1175
+ const command = await deps.commands?.handle?.({
1176
+ text,
1177
+ channelId: deps.channelId,
1178
+ botId: account.botId,
1179
+ key,
1180
+ conversationType: "direct",
1181
+ senderId: sender,
1182
+ // 属主判定只有渠道知道(属主在渠道配置里),带上给命令内核用。
1183
+ isOwner: deps.accessPolicy?.isOwnerId?.([account.ownerUserId], sender) === true,
1184
+ botLabel: account.botName ?? account.botId,
1185
+ channelLabel: "\u5FAE\u4FE1"
1186
+ }).catch((cause) => {
1187
+ logger.warn?.(`[dsh-chat-weixin] \u547D\u4EE4\u5904\u7406\u5931\u8D25\uFF1A${cause?.message ?? cause}`);
1188
+ return null;
1189
+ });
1190
+ if (command?.handled) {
1191
+ if (command.reply) await reply(sender, command.reply, contextToken, runId, signal);
1192
+ handled += 1;
1193
+ lastHandledAt = (/* @__PURE__ */ new Date()).toISOString();
1194
+ return;
1195
+ }
1196
+ }
1197
+ const identity = { senderId: sender, chatId: sender };
1198
+ const captured = deps.contextEnhancement.captureContextEnhancementSource(
1199
+ { botId: account.botId, channel: "weixin", readConfig: () => record.contextEnhancement },
1200
+ "direct",
1201
+ identity,
1202
+ () => ({ channel: "weixin", ...identity })
1203
+ );
1204
+ let attachmentParts = [];
1205
+ if (hasMedia) {
1206
+ await typing(sender, contextToken, 1, signal);
1207
+ try {
1208
+ attachmentParts = await loadAttachments({
1209
+ media,
1210
+ key,
1211
+ workspacePath: record.workspace,
1212
+ signal
1213
+ });
1214
+ } catch (cause) {
1215
+ const reason = cause?.message ?? String(cause);
1216
+ error = reason;
1217
+ logger.error?.(`[dsh-chat-weixin] \u63A5\u6536\u5A92\u4F53\u5931\u8D25\uFF1A${reason}`);
1218
+ await state.recordFailure(reason);
1219
+ const label = media.images.length > 0 && media.files.length === 0 ? "\u56FE\u7247" : "\u6587\u4EF6";
1220
+ await typing(sender, contextToken, 2, signal);
1221
+ await reply(sender, `\u8FD9\u4E2A${label}\u6CA1\u80FD\u6536\u4E0B\uFF1A${reason}`, contextToken, runId, signal);
1222
+ return;
1223
+ }
1224
+ }
1225
+ let finalParts;
1226
+ if (attachmentParts.length > 0) {
1227
+ const base = [...text ? [{ type: "text", text }] : [], ...attachmentParts];
1228
+ const enhanced = deps.contextEnhancement.enhanceContent(
1229
+ base,
1230
+ captured?.snapshot ?? null,
1231
+ captured?.source
1232
+ );
1233
+ finalParts = Array.isArray(enhanced) ? enhanced : base;
1234
+ } else {
1235
+ finalParts = [{
1236
+ type: "text",
1237
+ text: deps.contextEnhancement.enhanceContent(
1238
+ text,
1239
+ captured?.snapshot ?? null,
1240
+ captured?.source
1241
+ )
1242
+ }];
1243
+ }
1244
+ await typing(sender, contextToken, 1, signal);
1245
+ try {
1246
+ const result = await deps.sessions.ask({
1247
+ channelId: deps.channelId,
1248
+ botId: account.botId,
1249
+ key,
1250
+ workspacePath: record.workspace,
1251
+ content: finalParts,
1252
+ sourceGuidance: captured?.snapshot?.scope?.guidance,
1253
+ // 同一会话已有回合在跑:先回一句"排队中"。
1254
+ onQueued: (ahead) => {
1255
+ void reply(sender, `\u5DF2\u6392\u961F\uFF08\u524D\u9762\u8FD8\u6709 ${ahead} \u6761\uFF09\uFF0C\u5904\u7406\u5B8C\u4F1A\u4F9D\u6B21\u56DE\u590D\u3002`, contextToken, runId, signal).catch(() => {
1256
+ });
1257
+ },
1258
+ // 会话列表里一眼看出渠道与聊天:微信只有私聊,而且拿不到昵称——用掩码 id 兜底。
1259
+ channelLabel: "\u5FAE\u4FE1",
1260
+ chatLabel: `\u79C1\u804A ${String(sender ?? "").length > 12 ? `${String(sender).slice(0, 12)}\u2026` : String(sender ?? "")}`.trim(),
1261
+ botLabel: account.botName ?? account.botId,
1262
+ signal
1263
+ });
1264
+ const answer = typeof result?.text === "string" && result.text.trim() ? result.text.trim() : result?.reason?.kind && result.reason.kind !== "completed" ? `\u4EFB\u52A1\u672A\u6B63\u5E38\u5B8C\u6210\uFF08${result.reason.kind}\uFF09\u3002` : "\uFF08\u672C\u8F6E\u6CA1\u6709\u6587\u672C\u8F93\u51FA\uFF09";
1265
+ await reply(sender, answer, contextToken, runId, signal);
1266
+ await sendDeliverables({
1267
+ userId: sender,
1268
+ files: result?.files,
1269
+ contextToken,
1270
+ signal
1271
+ });
1272
+ handled += 1;
1273
+ lastHandledAt = (/* @__PURE__ */ new Date()).toISOString();
1274
+ } catch (cause) {
1275
+ error = cause?.message ?? String(cause);
1276
+ logger.error?.(`[dsh-chat-weixin] \u5904\u7406\u6D88\u606F\u5931\u8D25\uFF1A${error}`);
1277
+ await state.recordFailure(error);
1278
+ try {
1279
+ await reply(sender, `\u5904\u7406\u5931\u8D25\uFF1A${error}`, contextToken, runId, signal);
1280
+ } catch {
1281
+ }
1282
+ } finally {
1283
+ await typing(sender, contextToken, 2, signal);
1284
+ }
1285
+ }
1286
+ async function runLoop(signal) {
1287
+ setPhase("running");
1288
+ while (!signal.aborted) {
1289
+ let response;
1290
+ try {
1291
+ response = await client.getUpdates({
1292
+ baseUrl,
1293
+ token,
1294
+ getUpdatesBuf: state.getUpdatesBuf(),
1295
+ signal
1296
+ });
1297
+ } catch (cause) {
1298
+ if (signal.aborted) break;
1299
+ setPhase("reconnecting", cause?.message ?? String(cause));
1300
+ logger.warn?.(`[dsh-chat-weixin] \u957F\u8F6E\u8BE2\u5931\u8D25\uFF0C2s \u540E\u91CD\u8BD5\uFF1A${cause?.message ?? cause}`);
1301
+ await new Promise((resolve) => setTimeout(resolve, 2e3));
1302
+ continue;
1303
+ }
1304
+ if (signal.aborted) break;
1305
+ const rejection = rejectedResponse(response);
1306
+ if (rejection) {
1307
+ if (rejection === "-14") {
1308
+ setPhase("failed", "\u5FAE\u4FE1\u767B\u5F55\u5DF2\u5931\u6548\uFF0C\u8BF7\u5728\u8BBE\u7F6E\u9875\u91CD\u65B0\u626B\u7801\u3002");
1309
+ logger.error?.("[dsh-chat-weixin] \u4EE4\u724C\u5931\u6548\uFF0C\u505C\u6B62\u957F\u8F6E\u8BE2");
1310
+ return;
1311
+ }
1312
+ logger.warn?.(`[dsh-chat-weixin] \u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE ${rejection}\uFF0C\u5FFD\u7565\u672C\u8F6E`);
1313
+ }
1314
+ if (typeof response?.get_updates_buf === "string" && response.get_updates_buf) {
1315
+ await state.saveGetUpdatesBuf(response.get_updates_buf).catch(() => void 0);
1316
+ }
1317
+ for (const message of response?.msgs ?? []) {
1318
+ if (signal.aborted) break;
1319
+ try {
1320
+ await accept(message, signal);
1321
+ } catch (cause) {
1322
+ logger.error?.(`[dsh-chat-weixin] \u5904\u7406\u5165\u7AD9\u6D88\u606F\u5F02\u5E38\uFF1A${cause?.message ?? cause}`);
1323
+ }
1324
+ }
1325
+ }
1326
+ if (!signal.aborted) return;
1327
+ setPhase("stopped");
1328
+ }
1329
+ return {
1330
+ botId: account.botId,
1331
+ /**
1332
+ * 启动:先 notifyStart,再进入长轮询。
1333
+ *
1334
+ * @param options - { signal }。
1335
+ */
1336
+ async start({ signal }) {
1337
+ setPhase("starting");
1338
+ await client.notifyStart({ baseUrl, token, signal });
1339
+ loop = runLoop(signal);
1340
+ await loop;
1341
+ },
1342
+ /** 停止:中断长轮询并尽力通知服务端。 */
1343
+ async stop(signal) {
1344
+ setPhase("stopped");
1345
+ detachInteractions?.();
1346
+ try {
1347
+ await client.notifyStop({ baseUrl, token, signal });
1348
+ } catch (cause) {
1349
+ logger.warn?.(`[dsh-chat-weixin] \u505C\u6B62\u901A\u77E5\u5931\u8D25\uFF1A${cause?.message ?? cause}`);
1350
+ }
1351
+ },
1352
+ /**
1353
+ * 主动发一条文本(定时任务/脚本用)。
1354
+ *
1355
+ * @param options - { userId, text, signal }。
1356
+ */
1357
+ async sendProactive({ userId, text, signal }) {
1358
+ const recipient = typeof userId === "string" ? userId.trim() : "";
1359
+ if (!recipient) throw new TypeError("sendProactive \u9700\u8981 userId\u3002");
1360
+ const chunks = await reply(recipient, String(text ?? ""), state.contextToken(recipient), void 0, signal);
1361
+ return { chunks };
1362
+ },
1363
+ /**
1364
+ * 主动发一个文件或图片(agent 的 `chat_send_file` 与定时任务用)。
1365
+ *
1366
+ * 由调用方给"绝对路径 + 显示名 + kind"(hub 的投递层已经校验过存在、非空、不超限),
1367
+ * 这里只负责读字节、加密上传、发送。kind 为 image 时走图片气泡,否则走文件消息。
1368
+ *
1369
+ * @param options - { userId, path, name, kind, signal }。
1370
+ * @returns { kind, name, size, providerMessageIds }。
1371
+ */
1372
+ async sendFileProactive({ userId, path, name: name2, kind, signal }) {
1373
+ const recipient = typeof userId === "string" ? userId.trim() : "";
1374
+ if (!recipient) throw new TypeError("sendFileProactive \u9700\u8981 userId\u3002");
1375
+ if (typeof path !== "string" || !path) throw new TypeError("sendFileProactive \u9700\u8981 path\u3002");
1376
+ const bytes = await readFile(path);
1377
+ if (bytes.byteLength === 0) throw new Error("\u8981\u53D1\u9001\u7684\u6587\u4EF6\u662F\u7A7A\u7684\u3002");
1378
+ const fileName = typeof name2 === "string" && name2.trim() ? name2.trim() : basename(path);
1379
+ const contextToken = state.contextToken(recipient);
1380
+ const sent = kind === "image" ? await client.sendImage({ baseUrl, token, toUserId: recipient, bytes, contextToken, signal }) : await client.sendFile({
1381
+ baseUrl,
1382
+ token,
1383
+ toUserId: recipient,
1384
+ fileName,
1385
+ bytes,
1386
+ contextToken,
1387
+ signal
1388
+ });
1389
+ logger.info?.(`[dsh-chat-weixin] \u5DF2\u53D1\u9001${kind === "image" ? "\u56FE\u7247" : "\u6587\u4EF6"}\uFF1A${fileName}\uFF08${bytes.byteLength} \u5B57\u8282\uFF0C${account.botId}\uFF09`);
1390
+ return { ...sent, kind: kind === "image" ? "image" : "file", name: fileName, size: bytes.byteLength };
1391
+ },
1392
+ status: () => Object.freeze({
1393
+ botId: account.botId,
1394
+ phase,
1395
+ error,
1396
+ handled,
1397
+ lastHandledAt,
1398
+ lastMessageAt
1399
+ }),
1400
+ /** 供测试直接投喂一条消息。 */
1401
+ accept
1402
+ };
1403
+ }
1404
+
1405
+ // packages/dsh-chat-weixin/host/state-store.mjs
1406
+ var MAX_SEEN = 1e3;
1407
+ var MAX_CONTEXT_TOKENS = 200;
1408
+ function isPlainObject(value) {
1409
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1410
+ }
1411
+ function normalizeDocument2(value) {
1412
+ const source = isPlainObject(value) ? value : {};
1413
+ const sessions = {};
1414
+ if (isPlainObject(source.sessions)) {
1415
+ for (const [key, sessionId] of Object.entries(source.sessions)) {
1416
+ if (typeof sessionId === "string" && sessionId) sessions[key] = sessionId;
1417
+ }
1418
+ }
1419
+ const seenMessageIds = Array.isArray(source.seenMessageIds) ? source.seenMessageIds.filter((id) => typeof id === "string" && id).slice(-MAX_SEEN) : [];
1420
+ const contextTokens = {};
1421
+ if (isPlainObject(source.contextTokens)) {
1422
+ for (const [userId, token] of Object.entries(source.contextTokens).slice(-MAX_CONTEXT_TOKENS)) {
1423
+ if (typeof token === "string" && token) contextTokens[userId] = token;
1424
+ }
1425
+ }
1426
+ const lastError = isPlainObject(source.lastError) && typeof source.lastError.message === "string" ? { message: source.lastError.message, at: source.lastError.at ?? null } : null;
1427
+ return {
1428
+ version: 1,
1429
+ sessions,
1430
+ seenMessageIds,
1431
+ contextTokens,
1432
+ lastError,
1433
+ getUpdatesBuf: typeof source.getUpdatesBuf === "string" ? source.getUpdatesBuf : ""
1434
+ };
1435
+ }
1436
+ function createWeixinStateStore({ path, createJsonStore }) {
1437
+ if (typeof createJsonStore !== "function") {
1438
+ throw new TypeError("\u5FAE\u4FE1\u72B6\u6001\u5B58\u50A8\u9700\u8981 hub \u63D0\u4F9B\u7684 createJsonStore\u3002");
1439
+ }
1440
+ const store = createJsonStore({
1441
+ path,
1442
+ normalize: normalizeDocument2,
1443
+ empty: () => ({
1444
+ version: 1,
1445
+ sessions: {},
1446
+ seenMessageIds: [],
1447
+ contextTokens: {},
1448
+ getUpdatesBuf: ""
1449
+ }),
1450
+ label: "\u5FAE\u4FE1\u8D26\u53F7\u72B6\u6001"
1451
+ });
1452
+ return {
1453
+ path,
1454
+ ready: () => store.ready(),
1455
+ /** @returns 旧实现的会话绑定(交给 hub 的会话桥 adopt)。 */
1456
+ sessions() {
1457
+ return Object.freeze({ ...store.snapshot().sessions ?? {} });
1458
+ },
1459
+ /** @returns 长轮询游标。 */
1460
+ getUpdatesBuf() {
1461
+ return store.snapshot().getUpdatesBuf ?? "";
1462
+ },
1463
+ /** 记录长轮询游标(每轮都会变,写入串行且失败不阻塞收消息)。 */
1464
+ async saveGetUpdatesBuf(value) {
1465
+ if (typeof value !== "string" || value === store.snapshot().getUpdatesBuf) return;
1466
+ await store.update((current) => ({ ...current, getUpdatesBuf: value }));
1467
+ },
1468
+ /** 某个用户最近一次的 context_token(回复时要原样带回)。 */
1469
+ contextToken(userId) {
1470
+ return store.snapshot().contextTokens?.[userId];
1471
+ },
1472
+ /** 记录 context_token。 */
1473
+ async rememberContextToken(userId, token) {
1474
+ if (typeof userId !== "string" || !userId) return;
1475
+ if (typeof token !== "string" || !token) return;
1476
+ if (store.snapshot().contextTokens?.[userId] === token) return;
1477
+ await store.update((current) => {
1478
+ const contextTokens = { ...current.contextTokens ?? {} };
1479
+ delete contextTokens[userId];
1480
+ contextTokens[userId] = token;
1481
+ const keys = Object.keys(contextTokens);
1482
+ for (const stale of keys.slice(0, Math.max(0, keys.length - MAX_CONTEXT_TOKENS))) {
1483
+ delete contextTokens[stale];
1484
+ }
1485
+ return { ...current, contextTokens };
1486
+ });
1487
+ },
1488
+ /**
1489
+ * 去重:第一次见到返回 true。
1490
+ *
1491
+ * @param id - 平台消息 id。
1492
+ */
1493
+ markSeen(id) {
1494
+ if (typeof id !== "string" || !id) return true;
1495
+ const current = store.snapshot();
1496
+ if (current.seenMessageIds.includes(id)) return false;
1497
+ const seenMessageIds = [...current.seenMessageIds, id].slice(-MAX_SEEN);
1498
+ void store.update((doc) => ({ ...doc, seenMessageIds })).catch(() => {
1499
+ });
1500
+ return true;
1501
+ },
1502
+ /** 等待已排队的写入落定(停机前调用)。 */
1503
+ async flush() {
1504
+ await store.flush();
1505
+ },
1506
+ /**
1507
+ * 记下最近一次处理失败。
1508
+ *
1509
+ * 目的很直接:出问题时**不需要用户去翻终端**——直接读 state.json 就能看到
1510
+ * 最后一条错误的原文与时间。
1511
+ *
1512
+ * @param message - 错误原文。
1513
+ */
1514
+ async recordFailure(message) {
1515
+ const text = typeof message === "string" ? message.slice(0, 500) : String(message).slice(0, 500);
1516
+ await store.update((current) => ({
1517
+ ...current,
1518
+ lastError: { message: text, at: (/* @__PURE__ */ new Date()).toISOString() }
1519
+ })).catch(() => void 0);
1520
+ }
1521
+ };
1522
+ }
1523
+
1524
+ // packages/dsh-chat-weixin/host/controller.mjs
1525
+ var LOGIN_TTL_MS = 5 * 6e4;
1526
+ function deriveIdentity(accountId) {
1527
+ const raw = typeof accountId === "string" ? accountId.trim() : "";
1528
+ if (!raw) throw new TypeError("deriveIdentity \u9700\u8981 accountId\u3002");
1529
+ const digest = createHash2("sha256").update(raw).digest("hex").slice(0, 24);
1530
+ return { botId: `wx_${digest}`, tokenRef: `DSH_WEIXIN_BOT_TOKEN_${digest.toUpperCase()}` };
1531
+ }
1532
+ function maskAccountId(accountId) {
1533
+ const raw = typeof accountId === "string" ? accountId : "";
1534
+ if (raw.length <= 8) return "****";
1535
+ return `${raw.slice(0, 4)}****${raw.slice(-4)}`;
1536
+ }
1537
+ function apiBaseFromServer(value, fallback) {
1538
+ const raw = typeof value === "string" ? value.trim() : "";
1539
+ if (!raw) return fallback;
1540
+ try {
1541
+ const url = new URL(raw);
1542
+ if (url.protocol !== "https:") return fallback;
1543
+ const host = url.hostname.toLowerCase();
1544
+ if (!host.endsWith("weixin.qq.com") && !host.endsWith("wechat.com")) return fallback;
1545
+ return url.toString();
1546
+ } catch {
1547
+ return fallback;
1548
+ }
1549
+ }
1550
+ async function resolveToken(credentials, ref) {
1551
+ if (typeof credentials?.resolve !== "function") {
1552
+ throw new Error("\u5F53\u524D Host \u672A\u63D0\u4F9B\u51ED\u636E\u670D\u52A1\uFF0C\u65E0\u6CD5\u8BFB\u53D6\u5FAE\u4FE1\u767B\u5F55\u4EE4\u724C\u3002");
1553
+ }
1554
+ const resolved = await credentials.resolve(ref);
1555
+ if (!resolved?.value) {
1556
+ const error = new Error("\u5FAE\u4FE1\u767B\u5F55\u4EE4\u724C\u7F3A\u5931\uFF0C\u8BF7\u5728\u8BBE\u7F6E\u9875\u91CD\u65B0\u626B\u7801\u3002");
1557
+ error.code = "weixin/token-missing";
1558
+ throw error;
1559
+ }
1560
+ return resolved.value;
1561
+ }
1562
+ function createWeixinController({ deps, logger = console, config = {}, internals = {} }) {
1563
+ const dataDir = deps.dataDir;
1564
+ if (typeof dataDir !== "string" || !dataDir) throw new TypeError("\u5FAE\u4FE1\u63A7\u5236\u5668\u9700\u8981 deps.dataDir\u3002");
1565
+ if (typeof deps.sessions?.ask !== "function" || typeof deps.contextEnhancement?.enhanceContent !== "function") {
1566
+ throw new TypeError("\u5FAE\u4FE1\u63A7\u5236\u5668\u9700\u8981 hub \u7684 sessions.ask \u4E0E contextEnhancement\uFF08\u8BF7\u786E\u8BA4 dsh-chat \u5DF2\u52A0\u8F7D\uFF09\u3002");
1567
+ }
1568
+ if (typeof deps.createJsonStore !== "function") {
1569
+ throw new TypeError("\u5FAE\u4FE1\u63A7\u5236\u5668\u9700\u8981 hub \u7684 createJsonStore\u3002");
1570
+ }
1571
+ const clientFactory = internals.createClient ?? createIlinkClient;
1572
+ const configStore = createWeixinConfigStore({
1573
+ path: join(dataDir, "config.json"),
1574
+ createJsonStore: deps.createJsonStore
1575
+ });
1576
+ const runtimes = /* @__PURE__ */ new Map();
1577
+ const attempts = /* @__PURE__ */ new Map();
1578
+ function newClient() {
1579
+ return clientFactory({ fetchImpl: internals.fetchImpl });
1580
+ }
1581
+ async function startAccount(account) {
1582
+ const existing = runtimes.get(account.botId);
1583
+ if (existing && ["starting", "running", "reconnecting"].includes(existing.phase)) return existing;
1584
+ const record = {
1585
+ account,
1586
+ phase: "starting",
1587
+ error: null,
1588
+ runtime: null,
1589
+ controller: new AbortController()
1590
+ };
1591
+ runtimes.set(account.botId, record);
1592
+ try {
1593
+ const token = await resolveToken(deps.credentials, account.tokenRef);
1594
+ const client = newClient();
1595
+ const state = createWeixinStateStore({
1596
+ path: join(dataDir, "accounts", account.botId, "state.json"),
1597
+ createJsonStore: deps.createJsonStore
1598
+ });
1599
+ await state.ready();
1600
+ if (deps.sessions?.bindings?.adopt) {
1601
+ await deps.sessions.bindings.adopt(deps.channelId, account.botId, state.sessions());
1602
+ }
1603
+ const runtime = createWeixinRuntime({
1604
+ account,
1605
+ token,
1606
+ deps,
1607
+ client,
1608
+ state,
1609
+ logger
1610
+ });
1611
+ record.runtime = runtime;
1612
+ record.state = state;
1613
+ void runtime.start({ signal: record.controller.signal }).catch((error) => {
1614
+ record.phase = "failed";
1615
+ record.error = error?.code ?? "weixin/runtime-failed";
1616
+ record.errorMessage = error?.message ?? String(error);
1617
+ logger.error?.(`[dsh-chat-weixin] ${account.botId} \u8FD0\u884C\u5931\u8D25\uFF1A${record.errorMessage}`);
1618
+ });
1619
+ record.phase = "running";
1620
+ record.error = null;
1621
+ logger.info?.(`[dsh-chat-weixin] ${account.botName ?? maskAccountId(account.accountId)} \u957F\u8F6E\u8BE2\u5DF2\u542F\u52A8`);
1622
+ } catch (error) {
1623
+ record.phase = "failed";
1624
+ record.error = typeof error?.code === "string" ? error.code : "weixin/start-failed";
1625
+ record.errorMessage = error?.message ?? String(error);
1626
+ logger.error?.(`[dsh-chat-weixin] ${account.botId} \u542F\u52A8\u5931\u8D25\uFF1A${record.errorMessage}`);
1627
+ }
1628
+ return record;
1629
+ }
1630
+ async function stopAccount(botId) {
1631
+ const record = runtimes.get(botId);
1632
+ if (!record) return;
1633
+ record.controller?.abort?.();
1634
+ try {
1635
+ await record.runtime?.stop?.(record.controller.signal);
1636
+ } catch (error) {
1637
+ logger.warn?.(`[dsh-chat-weixin] ${botId} \u505C\u6B62\u65F6\u62A5\u9519\uFF1A${error?.message ?? error}`);
1638
+ }
1639
+ try {
1640
+ await record.state?.flush?.();
1641
+ } catch {
1642
+ }
1643
+ record.runtime = null;
1644
+ if (record.phase !== "failed") record.phase = "stopped";
1645
+ }
1646
+ function accountStatus(record) {
1647
+ const runtime = record.runtime?.status?.() ?? {};
1648
+ return Object.freeze({
1649
+ botId: record.account.botId,
1650
+ accountIdMasked: maskAccountId(record.account.accountId),
1651
+ botName: record.account.botName ?? null,
1652
+ // 规范化字段(契约要求):hub 的机器人列表按这几个键渲染。
1653
+ name: record.account.botName ?? null,
1654
+ state: runtime.phase ?? record.phase,
1655
+ error: record.error ?? null,
1656
+ errorMessage: record.errorMessage ?? runtime.error ?? null,
1657
+ handled: runtime.handled ?? 0,
1658
+ lastHandledAt: runtime.lastHandledAt ?? null,
1659
+ lastMessageAt: runtime.lastMessageAt ?? null
1660
+ });
1661
+ }
1662
+ function pruneAttempts() {
1663
+ const now = Date.now();
1664
+ for (const [id, attempt] of attempts) {
1665
+ if (now - attempt.createdAt > LOGIN_TTL_MS) attempts.delete(id);
1666
+ }
1667
+ }
1668
+ async function status() {
1669
+ await configStore.ready();
1670
+ const accounts = Object.freeze(configStore.list().map((account) => accountStatus(
1671
+ runtimes.get(account.botId) ?? { account, phase: "stopped", runtime: null }
1672
+ )));
1673
+ return Object.freeze({
1674
+ channel: deps.channelId,
1675
+ dataDir,
1676
+ // `bots` 是契约里的规范化名单(hub 的机器人列表按它渲染);`accounts` 保留给老代码。
1677
+ bots: accounts,
1678
+ accounts
1679
+ });
1680
+ }
1681
+ async function startAll() {
1682
+ await configStore.ready();
1683
+ const accounts = configStore.list();
1684
+ logger.info?.(`[dsh-chat-weixin] \u53D1\u73B0 ${accounts.length} \u4E2A\u5DF2\u7ED1\u5B9A\u8D26\u53F7`);
1685
+ await Promise.all(accounts.map((account) => startAccount(account)));
1686
+ }
1687
+ function targetFromKey(key) {
1688
+ const value = String(key ?? "");
1689
+ if (!value.startsWith("p2p:")) return null;
1690
+ const userId = value.slice(4);
1691
+ if (!userId) return null;
1692
+ const short = userId.length > 12 ? `${userId.slice(0, 6)}\u2026${userId.slice(-4)}` : userId;
1693
+ return {
1694
+ id: value.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64),
1695
+ name: `\u79C1\u804A \xB7 ${short}`,
1696
+ kind: "direct",
1697
+ route: { userId }
1698
+ };
1699
+ }
1700
+ const delivery = Object.freeze({
1701
+ /** 主动发文本:私聊对端就是 `from_user_id`,回复要带该用户最近一次的 context_token。 */
1702
+ async send({ botId, target, text }) {
1703
+ const record = runtimes.get(botId);
1704
+ if (!record?.runtime || record.phase !== "running") {
1705
+ const error = new Error(`\u8D26\u53F7 ${botId} \u5F53\u524D\u4E0D\u5728\u7EBF\uFF0C\u65E0\u6CD5\u6295\u9012\u3002`);
1706
+ error.code = "weixin/account-offline";
1707
+ throw error;
1708
+ }
1709
+ const userId = target.route?.userId;
1710
+ if (!userId) {
1711
+ const error = new Error("\u6295\u9012\u76EE\u6807\u7684 route \u7F3A\u5C11 userId\u3002");
1712
+ error.code = "chat/bad-target";
1713
+ throw error;
1714
+ }
1715
+ return record.runtime.sendProactive({ userId, text });
1716
+ },
1717
+ /**
1718
+ * 主动发一个文件或图片(`delivery.sendFile`)。
1719
+ *
1720
+ * 与文本同一条安全边界:只能发给**已保存**的目标(hub 已校验),这里只确认账号在线、
1721
+ * 目标带得上 userId,然后把"路径 + 显示名 + kind"交给运行时去读字节并发送。
1722
+ */
1723
+ async sendFile({ botId, target, file }) {
1724
+ const record = runtimes.get(botId);
1725
+ if (!record?.runtime || record.phase !== "running") {
1726
+ const error = new Error(`\u8D26\u53F7 ${botId} \u5F53\u524D\u4E0D\u5728\u7EBF\uFF0C\u65E0\u6CD5\u6295\u9012\u3002`);
1727
+ error.code = "weixin/account-offline";
1728
+ throw error;
1729
+ }
1730
+ const userId = target.route?.userId;
1731
+ if (!userId) {
1732
+ const error = new Error("\u6295\u9012\u76EE\u6807\u7684 route \u7F3A\u5C11 userId\u3002");
1733
+ error.code = "chat/bad-target";
1734
+ throw error;
1735
+ }
1736
+ return record.runtime.sendFileProactive({
1737
+ userId,
1738
+ path: file.path,
1739
+ name: file.name,
1740
+ kind: file.kind
1741
+ });
1742
+ },
1743
+ /** 从该账号的会话记录里发现候选目标。 */
1744
+ async discover({ botId }) {
1745
+ const record = runtimes.get(botId);
1746
+ if (!record?.state) return [];
1747
+ return Object.keys(record.state.sessions?.() ?? {}).map((key) => targetFromKey(key)).filter(Boolean);
1748
+ },
1749
+ /** 把 hub 持久会话绑定表里的会话键翻成目标(重启后仍有候选)。 */
1750
+ targetFromKey
1751
+ });
1752
+ return Object.freeze({
1753
+ start: startAll,
1754
+ delivery,
1755
+ async stop() {
1756
+ await Promise.all([...runtimes.keys()].map((botId) => stopAccount(botId)));
1757
+ },
1758
+ status,
1759
+ endpoints: Object.freeze({
1760
+ "connection.status": async () => ({ ok: true, value: await status() }),
1761
+ /** 申请登录二维码。 */
1762
+ "login.begin": async () => {
1763
+ try {
1764
+ const client = newClient();
1765
+ const known = configStore.list().map((account) => account.accountId);
1766
+ const { qrcode, qrcodeUrl } = await client.beginLogin({ localTokens: known });
1767
+ pruneAttempts();
1768
+ const attemptId = randomUUID2();
1769
+ attempts.set(attemptId, {
1770
+ qrcode,
1771
+ createdAt: Date.now(),
1772
+ baseUrl: config.connectBaseUrl
1773
+ });
1774
+ return { ok: true, value: { attemptId, qrcodeUrl, expiresInMs: LOGIN_TTL_MS } };
1775
+ } catch (error) {
1776
+ return {
1777
+ ok: false,
1778
+ error: {
1779
+ code: error?.code ?? "weixin/qr-failed",
1780
+ message: error?.message ?? "\u7533\u8BF7\u4E8C\u7EF4\u7801\u5931\u8D25\u3002",
1781
+ details: {}
1782
+ }
1783
+ };
1784
+ }
1785
+ },
1786
+ /**
1787
+ * 轮询扫码状态;确认后落盘账号并启动长轮询。
1788
+ *
1789
+ * @param payload - { attemptId, verifyCode? }。
1790
+ */
1791
+ "login.poll": async (payload) => {
1792
+ const attempt = attempts.get(payload?.attemptId);
1793
+ if (!attempt) {
1794
+ return {
1795
+ ok: false,
1796
+ error: { code: "weixin/unknown-attempt", message: "\u767B\u5F55\u5C1D\u8BD5\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801\u3002", details: {} }
1797
+ };
1798
+ }
1799
+ try {
1800
+ const client = newClient();
1801
+ const response = await client.pollLogin({
1802
+ qrcode: attempt.qrcode,
1803
+ baseUrl: attempt.baseUrl,
1804
+ verifyCode: payload?.verifyCode
1805
+ });
1806
+ const statusValue = response.status;
1807
+ if (statusValue === "scaned_but_redirect") {
1808
+ attempt.baseUrl = apiBaseFromServer(response.redirect_host, attempt.baseUrl);
1809
+ }
1810
+ if (statusValue !== "confirmed") {
1811
+ if (statusValue === "expired" || statusValue === "verify_code_blocked") {
1812
+ attempts.delete(payload.attemptId);
1813
+ }
1814
+ return { ok: true, value: { status: statusValue } };
1815
+ }
1816
+ const accountId = typeof response.ilink_bot_id === "string" ? response.ilink_bot_id.trim() : "";
1817
+ const ownerUserId = typeof response.ilink_user_id === "string" ? response.ilink_user_id.trim() : "";
1818
+ const token = typeof response.bot_token === "string" ? response.bot_token.trim() : "";
1819
+ if (!accountId || !ownerUserId || !token) {
1820
+ return {
1821
+ ok: false,
1822
+ error: { code: "weixin/incomplete-login", message: "\u5FAE\u4FE1\u6388\u6743\u6210\u529F\u4F46\u8FD4\u56DE\u7684\u51ED\u636E\u4E0D\u5B8C\u6574\u3002", details: {} }
1823
+ };
1824
+ }
1825
+ const identity = deriveIdentity(accountId);
1826
+ await deps.credentials.set(identity.tokenRef, token);
1827
+ const account = await configStore.saveAccount({
1828
+ ...identity,
1829
+ accountId,
1830
+ ownerUserId,
1831
+ baseUrl: apiBaseFromServer(response.baseurl, attempt.baseUrl),
1832
+ botName: typeof response.nickname === "string" ? response.nickname : null,
1833
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1834
+ connectedAt: (/* @__PURE__ */ new Date()).toISOString()
1835
+ });
1836
+ attempts.delete(payload.attemptId);
1837
+ await startAccount(account);
1838
+ return { ok: true, value: { status: "connected", botId: account.botId } };
1839
+ } catch (error) {
1840
+ return {
1841
+ ok: false,
1842
+ error: {
1843
+ code: error?.code ?? "weixin/login-poll-failed",
1844
+ message: error?.message ?? "\u67E5\u8BE2\u626B\u7801\u72B6\u6001\u5931\u8D25\u3002",
1845
+ details: {}
1846
+ }
1847
+ };
1848
+ }
1849
+ },
1850
+ /** 取消扫码。 */
1851
+ "login.cancel": async (payload) => {
1852
+ const existed = attempts.delete(payload?.attemptId);
1853
+ return { ok: true, value: { cancelled: existed } };
1854
+ },
1855
+ /** 重连某个账号。 */
1856
+ "account.reconnect": async (payload) => {
1857
+ if (typeof payload?.botId !== "string" || !payload.botId) {
1858
+ return { ok: false, error: { code: "chat/bad-request", message: "\u9700\u8981 botId\u3002", details: {} } };
1859
+ }
1860
+ await configStore.ready();
1861
+ const account = configStore.get(payload.botId);
1862
+ if (!account) {
1863
+ return {
1864
+ ok: false,
1865
+ error: { code: "weixin/unknown-account", message: `\u672A\u627E\u5230\u8D26\u53F7 ${payload.botId}\u3002`, details: {} }
1866
+ };
1867
+ }
1868
+ await stopAccount(account.botId);
1869
+ const record = await startAccount(account);
1870
+ return { ok: true, value: accountStatus(record) };
1871
+ },
1872
+ /** 移除账号(配置与运行态;凭据一并清除)。 */
1873
+ "account.delete": async (payload) => {
1874
+ if (typeof payload?.botId !== "string" || payload.confirm !== true) {
1875
+ return {
1876
+ ok: false,
1877
+ error: { code: "chat/bad-request", message: "\u5220\u9664\u9700\u8981 botId \u4E0E confirm=true\u3002", details: {} }
1878
+ };
1879
+ }
1880
+ await configStore.ready();
1881
+ const account = configStore.get(payload.botId);
1882
+ await stopAccount(payload.botId);
1883
+ runtimes.delete(payload.botId);
1884
+ if (account) {
1885
+ await configStore.removeAccount(payload.botId);
1886
+ try {
1887
+ await deps.credentials.unset(account.tokenRef);
1888
+ } catch (error) {
1889
+ logger.warn?.(`[dsh-chat-weixin] \u6E05\u9664\u51ED\u636E\u5931\u8D25\uFF1A${error?.message ?? error}`);
1890
+ }
1891
+ }
1892
+ return { ok: true, value: { removed: Boolean(account) } };
1893
+ }
1894
+ })
1895
+ });
1896
+ }
1897
+
1898
+ // packages/dsh-chat-weixin/host/index.mjs
1899
+ var CHANNEL_VERSION = "0.0.4";
1900
+ var name = "dsh-chat-weixin-host";
1901
+ var inject = ["dshChat"];
1902
+ var EXPECTED_CONTRACT = 1;
1903
+ var CHANNEL_ID = "weixin";
1904
+ function apply(ctx) {
1905
+ const service = ctx.dshChat;
1906
+ const actual = service?.contractVersion;
1907
+ if (actual !== EXPECTED_CONTRACT) {
1908
+ throw new Error(
1909
+ `dsh-chat-weixin \u9700\u8981 dsh-chat \u5951\u7EA6 v${EXPECTED_CONTRACT}\uFF0C\u5F53\u524D hub \u63D0\u4F9B v${String(actual)}\uFF1B\u8BF7\u5347\u7EA7 dsh-chat \u6216\u5B89\u88C5\u5339\u914D\u7248\u672C\u7684\u6E20\u9053\u63D2\u4EF6\uFF08\u89C1 CONTRACT.md\uFF09\u3002`
1910
+ );
1911
+ }
1912
+ ctx.effect(() => service.registerChannel({
1913
+ id: CHANNEL_ID,
1914
+ label: "\u5FAE\u4FE1",
1915
+ version: CHANNEL_VERSION,
1916
+ order: 10,
1917
+ legacy: { dir: "dsh-weixin" },
1918
+ async createChannel(deps) {
1919
+ const controller = createWeixinController({ deps, logger: deps.logger });
1920
+ void controller.start().catch((error) => {
1921
+ deps.reportStatus("failed", error);
1922
+ deps.logger.error?.(`[dsh-chat-weixin] \u542F\u52A8\u5931\u8D25\uFF1A${error?.message ?? error}`);
1923
+ });
1924
+ return {
1925
+ async stop() {
1926
+ await controller.stop();
1927
+ },
1928
+ endpoints: controller.endpoints,
1929
+ // hub 用它把"主动投递"接到该渠道上。
1930
+ delivery: controller.delivery
1931
+ };
1932
+ }
1933
+ }), "dsh-chat-weixin: \u6CE8\u518C\u6E20\u9053");
1934
+ }
1935
+ export {
1936
+ apply,
1937
+ inject,
1938
+ name
1939
+ };