@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.
@@ -0,0 +1,577 @@
1
+ /**
2
+ * 微信账号运行时:长轮询收消息 → hub 会话桥 → 回复。
3
+ *
4
+ * 微信通道按协议只支持私聊(iLink 的 `from_user_id` 就是单聊对端),因此这里
5
+ * 固定 `conversationType: 'direct'`;过程展示只做"正在输入 + 最终分段回复"
6
+ * (逐步过程消息是飞书那边的能力)。
7
+ *
8
+ * @module dsh-chat-weixin/runtime
9
+ */
10
+
11
+ import { readFile, stat } from 'node:fs/promises';
12
+ import { basename } from 'node:path';
13
+
14
+ import {
15
+ extractText,
16
+ messageId,
17
+ rejectedResponse,
18
+ splitText,
19
+ } from './ilink-client.mjs';
20
+ import {
21
+ MAX_FILE_BYTES,
22
+ MAX_IMAGE_BYTES,
23
+ WeixinMediaError,
24
+ downloadMedia,
25
+ extractInboundMedia,
26
+ sniffImageMediaType,
27
+ } from './media.mjs';
28
+
29
+ /**
30
+ * 创建账号运行时。
31
+ *
32
+ * @param options - { account, token, deps, client, state, logger, fetchImpl }。
33
+ * `fetchImpl` 只用于下载入站媒体(默认全局 fetch),便于测试注入。
34
+ * @returns 运行时。
35
+ */
36
+ export function createWeixinRuntime({
37
+ account,
38
+ token,
39
+ deps,
40
+ client,
41
+ state,
42
+ logger = console,
43
+ fetchImpl = fetch,
44
+ }) {
45
+ if (!account?.botId) throw new TypeError('微信运行时需要账号配置。');
46
+ if (!token) throw new TypeError('微信运行时需要访问令牌。');
47
+ if (typeof deps?.sessions?.ask !== 'function' || typeof deps?.contextEnhancement?.enhanceContent !== 'function') {
48
+ throw new TypeError('微信运行时需要 hub 的 sessions.ask 与 contextEnhancement。');
49
+ }
50
+
51
+ const baseUrl = account.baseUrl;
52
+ let phase = 'idle';
53
+ let error = null;
54
+ let handled = 0;
55
+ let lastHandledAt = null;
56
+ let lastMessageAt = null;
57
+ let typingTickets = new Map();
58
+ let loop = null;
59
+
60
+ function setPhase(next, detail = null) {
61
+ phase = next;
62
+ error = detail;
63
+ }
64
+
65
+ /** 取(并缓存)typing_ticket。 */
66
+ async function typingTicket(userId, contextToken, signal) {
67
+ const cached = typingTickets.get(userId);
68
+ if (cached) return cached;
69
+ const config = await client.getConfig({
70
+ baseUrl, token, toUserId: userId, contextToken, signal,
71
+ });
72
+ if (config?.typingTicket) {
73
+ if (typingTickets.size > 200) typingTickets = new Map();
74
+ typingTickets.set(userId, config.typingTicket);
75
+ return config.typingTicket;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ async function typing(userId, contextToken, status, signal) {
81
+ try {
82
+ const ticket = await typingTicket(userId, contextToken, signal);
83
+ if (!ticket) return false;
84
+ await client.sendTyping({
85
+ baseUrl, token, toUserId: userId, typingTicket: ticket, status, signal,
86
+ });
87
+ return true;
88
+ } catch (cause) {
89
+ // 输入状态是锦上添花:失败不影响收答案,但把 ticket 作废以便下次重取。
90
+ typingTickets.delete(userId);
91
+ logger.warn?.(`[dsh-chat-weixin] 发送输入状态失败:${cause?.message ?? cause}`);
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /** 回复正文(按微信单条上限分段)。 */
97
+ async function reply(userId, text, contextToken, runId, signal) {
98
+ const chunks = splitText(text);
99
+ for (const chunk of chunks) {
100
+ await client.sendText({
101
+ baseUrl, token, toUserId: userId, text: chunk, contextToken, runId, signal,
102
+ });
103
+ }
104
+ return chunks.length;
105
+ }
106
+
107
+ /**
108
+ * 处理一条入站消息。
109
+ *
110
+ * @param message - iLink 消息。
111
+ * @param signal - 取消信号。
112
+ */
113
+ /**
114
+ * 处理一条入站消息。
115
+ *
116
+ * 外层包一层:任何未预料的异常都要**留下痕迹并让用户看见**,
117
+ * 绝不静默("发了没反应"是最难排查的故障形态)。
118
+ *
119
+ * @param message - iLink 消息。
120
+ * @param signal - 取消信号。
121
+ */
122
+ /**
123
+ * 下载并准备入站附件。
124
+ *
125
+ * 图片解密后按魔数认类型,转成内容块(base64);文件解密后先入会话换成 receipt
126
+ * ——文件内容块只能引用"本会话上传"得到的收据。任何一步失败都**抛出**,
127
+ * 由上层回复用户原因(绝不静默丢消息)。
128
+ *
129
+ * @param options - { media, key, workspacePath, signal }。
130
+ * @returns 内容部分数组。
131
+ */
132
+ async function loadAttachments({ media, key, workspacePath, signal }) {
133
+ const parts = [];
134
+ for (const image of media.images) {
135
+ const bytes = await downloadMedia(image.item, { signal, maxBytes: MAX_IMAGE_BYTES, fetchImpl });
136
+ const mediaType = sniffImageMediaType(bytes);
137
+ if (!mediaType) {
138
+ throw new WeixinMediaError('unsupported-image', '这张图片的格式暂不支持,请发 PNG/JPEG/WebP/GIF。');
139
+ }
140
+ parts.push({ type: 'image', mediaType, data: bytes.toString('base64'), name: image.name });
141
+ logger.info?.(`[dsh-chat-weixin] 已收到图片:${mediaType}(${bytes.length} 字节,${account.botId})`);
142
+ }
143
+ for (const file of media.files) {
144
+ const bytes = await downloadMedia(file.item, { signal, maxBytes: MAX_FILE_BYTES, fetchImpl });
145
+ const { sessionId } = await deps.sessions.ensure({
146
+ channelId: deps.channelId,
147
+ botId: account.botId,
148
+ key,
149
+ workspacePath,
150
+ });
151
+ const uploaded = await deps.sessions.uploadFile({
152
+ sessionId, name: file.name, bytes: new Uint8Array(bytes), signal,
153
+ });
154
+ if (!uploaded?.receiptId) throw new Error('上传后没有拿到 receiptId');
155
+ parts.push({ type: 'file', receiptId: uploaded.receiptId });
156
+ logger.info?.(`[dsh-chat-weixin] 已收到文件:${file.name}(${bytes.length} 字节,${account.botId})`);
157
+ }
158
+ return parts;
159
+ }
160
+
161
+ async function accept(message, signal) {
162
+ try {
163
+ await handleMessage(message, signal);
164
+ } catch (cause) {
165
+ const detail = cause?.message ?? String(cause);
166
+ error = detail;
167
+ logger.error?.(`[dsh-chat-weixin] 处理入站消息异常:${detail}`);
168
+ await state.recordFailure(detail);
169
+ const sender = typeof message?.from_user_id === 'string' ? message.from_user_id.trim() : '';
170
+ if (sender) {
171
+ try {
172
+ const token = typeof message.context_token === 'string'
173
+ ? message.context_token
174
+ : state.contextToken(sender);
175
+ await reply(sender, `处理失败:${detail}`, token, message?.run_id, signal);
176
+ } catch {
177
+ // 连失败回复都发不出去时只留日志与状态文件。
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ // 接入 IM 回传:agent 的提问/审批发到这个用户,用户的下一条消息就是答案。
184
+ const detachInteractions = deps.interactions?.attach?.({
185
+ channelId: deps.channelId,
186
+ botId: account.botId,
187
+ send: async ({ key, text }) => {
188
+ const userId = (key.startsWith('p2p:') ? key.slice(4) : key).trim();
189
+ if (!userId) throw new TypeError('交互回传需要 userId。');
190
+ // 与 sendProactive 走同一条发送路径(带上该用户最近一次的 context_token)。
191
+ await reply(userId, String(text ?? ''), state.contextToken(userId));
192
+ },
193
+ });
194
+
195
+ /** 图片扩展名(图片走图片气泡,预览更友好)。 */
196
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
197
+
198
+ /**
199
+ * 把本轮 agent 交付的文件(`present` 声明的)当附件发出去。
200
+ *
201
+ * 失败必须可见:发不出去要回一句可读原因并落 `lastError`——"文件没收到"同样是最难
202
+ * 排查的故障形态,不能只留一行日志。
203
+ *
204
+ * @param options - { userId, files, contextToken, signal }。
205
+ */
206
+ async function sendDeliverables({ userId, files, contextToken, signal }) {
207
+ if (!Array.isArray(files) || files.length === 0) return;
208
+ for (const file of files) {
209
+ const path = typeof file?.path === 'string' ? file.path : '';
210
+ if (!path) continue;
211
+ const name = path.split('/').pop() || '交付文件';
212
+ try {
213
+ const info = await stat(path);
214
+ if (!info.isFile() || info.size === 0) throw new Error('不是普通文件或内容为空');
215
+ if (info.size > MAX_FILE_BYTES) {
216
+ throw new Error(`超过 ${Math.round(MAX_FILE_BYTES / 1024 / 1024)}MB 上限`);
217
+ }
218
+ const ext = name.slice(name.lastIndexOf('.')).toLowerCase();
219
+ const bytes = await readFile(path);
220
+ const sent = IMAGE_EXTENSIONS.has(ext)
221
+ ? await client.sendImage({ baseUrl, token, toUserId: userId, bytes, contextToken, signal })
222
+ : await client.sendFile({
223
+ baseUrl, token, toUserId: userId, fileName: name, bytes, contextToken, signal,
224
+ });
225
+ logger.info?.(`[dsh-chat-weixin] 已发送交付文件:${name}(${info.size} 字节,${account.botId})`);
226
+ void sent;
227
+ } catch (cause) {
228
+ const reason = cause?.message ?? String(cause);
229
+ error = `交付文件 ${name} 发送失败:${reason}`;
230
+ logger.error?.(`[dsh-chat-weixin] ${error}`);
231
+ await state.recordFailure(error);
232
+ try {
233
+ await reply(userId, `交付文件「${name}」没能发出去:${reason}`, contextToken, undefined, signal);
234
+ } catch {
235
+ // 连失败说明都发不出去时,至少日志与 lastError 有记录。
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ /**
242
+ * 延迟交付:`ask()` 超时之后那一轮要是自己跑完了,hub 会把结果交回这里补发。
243
+ *
244
+ * 微信复用**该用户最近一次记下的 context token**(收消息时存下来的)——iLink 的回复要带它;
245
+ * 没有 token 就发不出去,这时如实抛错,让 hub 记 `lastError`(不静默)。
246
+ */
247
+ deps.deferred?.register?.({
248
+ channelId: deps.channelId,
249
+ botId: account.botId,
250
+ deliver: async ({ key, text }) => {
251
+ const userId = key.startsWith('p2p:') ? key.slice('p2p:'.length) : key;
252
+ const contextToken = state.contextToken?.(userId) ?? null;
253
+ if (!contextToken) {
254
+ throw new Error(`微信没有 ${userId} 的 context token,补发不了(等他再发一条消息后重试)`);
255
+ }
256
+ await reply(userId, `(上一轮超时之后跑完了,补发结果)\n\n${text}`, contextToken, null, null);
257
+ logger.info?.(`[dsh-chat-weixin] 延迟交付已补发:${account.botId} ${key} ${text.length} 字`);
258
+ },
259
+ });
260
+
261
+ async function handleMessage(message, signal) {
262
+ // message_type 2 是自己发出去的(服务端回显),必须忽略。
263
+ if (message?.message_type === 2) return;
264
+ const id = messageId(message);
265
+ const sender = typeof message?.from_user_id === 'string' ? message.from_user_id.trim() : '';
266
+ if (!id || !sender) return;
267
+ if (!state.markSeen(id)) return;
268
+
269
+ lastMessageAt = new Date().toISOString();
270
+ await deps.ready?.();
271
+ const record = deps.storage.read(account.botId);
272
+
273
+ // 门禁:属主绕过,其余按访问策略(open / allowlist)判定。
274
+ const access = deps.accessPolicy.evaluateAccess({
275
+ policy: record.accessPolicy,
276
+ conversationType: 'direct',
277
+ senderIds: [sender],
278
+ // 属主判定走与飞书同一份规则(`*` 表示没有属主,不授权任何人绕过策略)。
279
+ isOwner: deps.accessPolicy?.isOwnerId?.([account.ownerUserId], sender) === true,
280
+ });
281
+ if (!access.allowed) {
282
+ logger.info?.(`[dsh-chat-weixin] 忽略未放行的消息:${account.botId} sender=${sender}(${access.reason})`);
283
+ return;
284
+ }
285
+
286
+ const text = extractText(message);
287
+ // 图片/文件与文字可以混在同一条消息里(item_list 各占一项),因此两者互不排斥。
288
+ const media = extractInboundMedia(message);
289
+ const hasMedia = media.images.length > 0 || media.files.length > 0;
290
+ if (!text && !hasMedia) {
291
+ await reply(sender, '目前支持文本、语音转写、图片与文件,其他类型(视频、表情等)暂不支持。',
292
+ message.context_token, message.run_id, signal);
293
+ return;
294
+ }
295
+
296
+ const inboundToken = typeof message.context_token === 'string' ? message.context_token : undefined;
297
+ const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
298
+ if (inboundToken) await state.rememberContextToken(sender, inboundToken);
299
+ // 回复必须带上会话上下文:优先用本条消息的,缺失时回落到该用户最近一次记下的。
300
+ const contextToken = inboundToken ?? state.contextToken(sender);
301
+
302
+ const key = `p2p:${sender}`;
303
+
304
+ // 正在等这个用户回答 agent 的提问/审批:这条消息就是答案,不再进模型。
305
+ // 带媒体的消息不作数——那多半是用户顺手发了张图,不该被当成选项答案。
306
+ if (!hasMedia && deps.interactions?.offer?.({
307
+ channelId: deps.channelId,
308
+ botId: account.botId,
309
+ key,
310
+ text,
311
+ })) {
312
+ logger.info?.(`[dsh-chat-weixin] 认领为交互回答(${account.botId} ${key})`);
313
+ return;
314
+ }
315
+
316
+ if (!hasMedia) {
317
+ // 命令权限单独判定(白名单用户可以被允许对话、但不允许执行命令)。
318
+ if (text.startsWith('/')) {
319
+ const commandAccess = deps.accessPolicy.evaluateAccess({
320
+ policy: record.accessPolicy,
321
+ conversationType: 'direct',
322
+ senderIds: [sender],
323
+ isCommand: true,
324
+ isOwner: deps.accessPolicy?.isOwnerId?.([account.ownerUserId], sender) === true,
325
+ });
326
+ if (!commandAccess.allowed) {
327
+ logger.info?.(`[dsh-chat-weixin] 命令被拒绝:${account.botId} sender=${sender}(${commandAccess.reason})`);
328
+ await reply(sender, '你没有执行机器人命令的权限。', contextToken, runId, signal);
329
+ return;
330
+ }
331
+ }
332
+
333
+ // 命令优先:命令不进入模型、也不做上下文增强。
334
+ const command = await deps.commands?.handle?.({
335
+ text,
336
+ channelId: deps.channelId,
337
+ botId: account.botId,
338
+ key,
339
+ conversationType: 'direct',
340
+ senderId: sender,
341
+ // 属主判定只有渠道知道(属主在渠道配置里),带上给命令内核用。
342
+ isOwner: deps.accessPolicy?.isOwnerId?.([account.ownerUserId], sender) === true,
343
+ botLabel: account.botName ?? account.botId,
344
+ channelLabel: '微信',
345
+ }).catch((cause) => {
346
+ logger.warn?.(`[dsh-chat-weixin] 命令处理失败:${cause?.message ?? cause}`);
347
+ return null;
348
+ });
349
+ if (command?.handled) {
350
+ if (command.reply) await reply(sender, command.reply, contextToken, runId, signal);
351
+ handled += 1;
352
+ lastHandledAt = new Date().toISOString();
353
+ return;
354
+ }
355
+ }
356
+
357
+ const identity = { senderId: sender, chatId: sender };
358
+ const captured = deps.contextEnhancement.captureContextEnhancementSource(
359
+ { botId: account.botId, channel: 'weixin', readConfig: () => record.contextEnhancement },
360
+ 'direct',
361
+ identity,
362
+ () => ({ channel: 'weixin', ...identity }),
363
+ );
364
+
365
+ // 附件先落地(下载 + 解密 + 入库):失败要让用户看见原因,绝不静默。
366
+ let attachmentParts = [];
367
+ if (hasMedia) {
368
+ await typing(sender, contextToken, 1, signal);
369
+ try {
370
+ attachmentParts = await loadAttachments({
371
+ media, key, workspacePath: record.workspace, signal,
372
+ });
373
+ } catch (cause) {
374
+ const reason = cause?.message ?? String(cause);
375
+ error = reason;
376
+ logger.error?.(`[dsh-chat-weixin] 接收媒体失败:${reason}`);
377
+ await state.recordFailure(reason);
378
+ const label = media.images.length > 0 && media.files.length === 0 ? '图片' : '文件';
379
+ await typing(sender, contextToken, 2, signal);
380
+ await reply(sender, `这个${label}没能收下:${reason}`, contextToken, runId, signal);
381
+ return;
382
+ }
383
+ }
384
+
385
+ // 文本保持"前缀拼进同一个文本块"的老形态;媒体走内容数组,enhanceContent 会在
386
+ // 前面插一个上下文文本块,于是附件也带上来源信息。
387
+ let finalParts;
388
+ if (attachmentParts.length > 0) {
389
+ const base = [...(text ? [{ type: 'text', text }] : []), ...attachmentParts];
390
+ const enhanced = deps.contextEnhancement.enhanceContent(
391
+ base,
392
+ captured?.snapshot ?? null,
393
+ captured?.source,
394
+ );
395
+ finalParts = Array.isArray(enhanced) ? enhanced : base;
396
+ } else {
397
+ finalParts = [{
398
+ type: 'text',
399
+ text: deps.contextEnhancement.enhanceContent(
400
+ text,
401
+ captured?.snapshot ?? null,
402
+ captured?.source,
403
+ ),
404
+ }];
405
+ }
406
+
407
+ await typing(sender, contextToken, 1, signal);
408
+ try {
409
+ const result = await deps.sessions.ask({
410
+ channelId: deps.channelId,
411
+ botId: account.botId,
412
+ key,
413
+ workspacePath: record.workspace,
414
+ content: finalParts,
415
+ sourceGuidance: captured?.snapshot?.scope?.guidance,
416
+ // 同一会话已有回合在跑:先回一句"排队中"。
417
+ onQueued: (ahead) => {
418
+ void reply(sender, `已排队(前面还有 ${ahead} 条),处理完会依次回复。`, contextToken, runId, signal)
419
+ .catch(() => {});
420
+ },
421
+ // 会话列表里一眼看出渠道与聊天:微信只有私聊,而且拿不到昵称——用掩码 id 兜底。
422
+ channelLabel: '微信',
423
+ chatLabel: `私聊 ${String(sender ?? '').length > 12 ? `${String(sender).slice(0, 12)}…` : String(sender ?? '')}`.trim(),
424
+ botLabel: account.botName ?? account.botId,
425
+ signal,
426
+ });
427
+ const answer = typeof result?.text === 'string' && result.text.trim()
428
+ ? result.text.trim()
429
+ : (result?.reason?.kind && result.reason.kind !== 'completed'
430
+ ? `任务未正常完成(${result.reason.kind})。`
431
+ : '(本轮没有文本输出)');
432
+ await reply(sender, answer, contextToken, runId, signal);
433
+ // agent 声明交付的文件要当附件真发出去(只写在回复文字里,用户拿不到文件)。
434
+ await sendDeliverables({
435
+ userId: sender, files: result?.files, contextToken, signal,
436
+ });
437
+ handled += 1;
438
+ lastHandledAt = new Date().toISOString();
439
+ } catch (cause) {
440
+ error = cause?.message ?? String(cause);
441
+ logger.error?.(`[dsh-chat-weixin] 处理消息失败:${error}`);
442
+ await state.recordFailure(error);
443
+ try {
444
+ await reply(sender, `处理失败:${error}`, contextToken, runId, signal);
445
+ } catch {
446
+ // 连失败回复都发不出去时只留日志与状态文件。
447
+ }
448
+ } finally {
449
+ await typing(sender, contextToken, 2, signal);
450
+ }
451
+ }
452
+
453
+ async function runLoop(signal) {
454
+ setPhase('running');
455
+ while (!signal.aborted) {
456
+ let response;
457
+ try {
458
+ response = await client.getUpdates({
459
+ baseUrl,
460
+ token,
461
+ getUpdatesBuf: state.getUpdatesBuf(),
462
+ signal,
463
+ });
464
+ } catch (cause) {
465
+ if (signal.aborted) break;
466
+ setPhase('reconnecting', cause?.message ?? String(cause));
467
+ logger.warn?.(`[dsh-chat-weixin] 长轮询失败,2s 后重试:${cause?.message ?? cause}`);
468
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
469
+ continue;
470
+ }
471
+ if (signal.aborted) break;
472
+
473
+ const rejection = rejectedResponse(response);
474
+ if (rejection) {
475
+ // -14 = 令牌失效,需要重新扫码;其余按可重试处理。
476
+ if (rejection === '-14') {
477
+ setPhase('failed', '微信登录已失效,请在设置页重新扫码。');
478
+ logger.error?.('[dsh-chat-weixin] 令牌失效,停止长轮询');
479
+ return;
480
+ }
481
+ logger.warn?.(`[dsh-chat-weixin] 微信服务返回 ${rejection},忽略本轮`);
482
+ }
483
+
484
+ if (typeof response?.get_updates_buf === 'string' && response.get_updates_buf) {
485
+ await state.saveGetUpdatesBuf(response.get_updates_buf).catch(() => undefined);
486
+ }
487
+ for (const message of response?.msgs ?? []) {
488
+ if (signal.aborted) break;
489
+ try {
490
+ await accept(message, signal);
491
+ } catch (cause) {
492
+ logger.error?.(`[dsh-chat-weixin] 处理入站消息异常:${cause?.message ?? cause}`);
493
+ }
494
+ }
495
+ }
496
+ if (!signal.aborted) return;
497
+ setPhase('stopped');
498
+ }
499
+
500
+ return {
501
+ botId: account.botId,
502
+
503
+ /**
504
+ * 启动:先 notifyStart,再进入长轮询。
505
+ *
506
+ * @param options - { signal }。
507
+ */
508
+ async start({ signal }) {
509
+ setPhase('starting');
510
+ await client.notifyStart({ baseUrl, token, signal });
511
+ loop = runLoop(signal);
512
+ await loop;
513
+ },
514
+
515
+ /** 停止:中断长轮询并尽力通知服务端。 */
516
+ async stop(signal) {
517
+ setPhase('stopped');
518
+ detachInteractions?.();
519
+ try {
520
+ await client.notifyStop({ baseUrl, token, signal });
521
+ } catch (cause) {
522
+ logger.warn?.(`[dsh-chat-weixin] 停止通知失败:${cause?.message ?? cause}`);
523
+ }
524
+ },
525
+
526
+ /**
527
+ * 主动发一条文本(定时任务/脚本用)。
528
+ *
529
+ * @param options - { userId, text, signal }。
530
+ */
531
+ async sendProactive({ userId, text, signal }) {
532
+ const recipient = typeof userId === 'string' ? userId.trim() : '';
533
+ if (!recipient) throw new TypeError('sendProactive 需要 userId。');
534
+ const chunks = await reply(recipient, String(text ?? ''), state.contextToken(recipient), undefined, signal);
535
+ return { chunks };
536
+ },
537
+
538
+ /**
539
+ * 主动发一个文件或图片(agent 的 `chat_send_file` 与定时任务用)。
540
+ *
541
+ * 由调用方给"绝对路径 + 显示名 + kind"(hub 的投递层已经校验过存在、非空、不超限),
542
+ * 这里只负责读字节、加密上传、发送。kind 为 image 时走图片气泡,否则走文件消息。
543
+ *
544
+ * @param options - { userId, path, name, kind, signal }。
545
+ * @returns { kind, name, size, providerMessageIds }。
546
+ */
547
+ async sendFileProactive({ userId, path, name, kind, signal }) {
548
+ const recipient = typeof userId === 'string' ? userId.trim() : '';
549
+ if (!recipient) throw new TypeError('sendFileProactive 需要 userId。');
550
+ if (typeof path !== 'string' || !path) throw new TypeError('sendFileProactive 需要 path。');
551
+ const bytes = await readFile(path);
552
+ if (bytes.byteLength === 0) throw new Error('要发送的文件是空的。');
553
+ const fileName = typeof name === 'string' && name.trim() ? name.trim() : basename(path);
554
+ const contextToken = state.contextToken(recipient);
555
+ const sent = kind === 'image'
556
+ ? await client.sendImage({ baseUrl, token, toUserId: recipient, bytes, contextToken, signal })
557
+ : await client.sendFile({
558
+ baseUrl, token, toUserId: recipient, fileName, bytes, contextToken, signal,
559
+ });
560
+ logger.info?.(`[dsh-chat-weixin] 已发送${kind === 'image' ? '图片' : '文件'}:${fileName}`
561
+ + `(${bytes.byteLength} 字节,${account.botId})`);
562
+ return { ...sent, kind: kind === 'image' ? 'image' : 'file', name: fileName, size: bytes.byteLength };
563
+ },
564
+
565
+ status: () => Object.freeze({
566
+ botId: account.botId,
567
+ phase,
568
+ error,
569
+ handled,
570
+ lastHandledAt,
571
+ lastMessageAt,
572
+ }),
573
+
574
+ /** 供测试直接投喂一条消息。 */
575
+ accept,
576
+ };
577
+ }