@xmanrui/dsh-im 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,14 +7,17 @@ function escaped(value) {
7
7
  }
8
8
 
9
9
  function mentionedUsername(message, username) {
10
- if (!username || typeof message?.text !== 'string' || !Array.isArray(message.entities)) return false;
11
- return message.entities.some((entity) => {
12
- if (entity?.type !== 'mention' || !Number.isInteger(entity.offset) || !Number.isInteger(entity.length)) {
13
- return false;
14
- }
15
- return message.text.slice(entity.offset, entity.offset + entity.length).toLowerCase()
16
- === `@${username.toLowerCase()}`;
17
- });
10
+ if (!username) return false;
11
+ return [
12
+ [message?.text, message?.entities],
13
+ [message?.caption, message?.caption_entities],
14
+ ].some(([text, entities]) => typeof text === 'string' && Array.isArray(entities)
15
+ && entities.some((entity) => {
16
+ if (entity?.type !== 'mention' || !Number.isInteger(entity.offset)
17
+ || !Number.isInteger(entity.length)) return false;
18
+ return text.slice(entity.offset, entity.offset + entity.length).toLowerCase()
19
+ === `@${username.toLowerCase()}`;
20
+ }));
18
21
  }
19
22
 
20
23
  function withoutBotMention(text, username) {
@@ -22,7 +25,63 @@ function withoutBotMention(text, username) {
22
25
  return text.replace(new RegExp(`@${escaped(username)}\\b`, 'ig'), '').trim();
23
26
  }
24
27
 
25
- export function normalizeTelegramUpdate(update, { botId, username }) {
28
+ const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
29
+ const IMAGE_FILE_TYPES = new Map([
30
+ ['.jpg', 'image/jpeg'],
31
+ ['.jpeg', 'image/jpeg'],
32
+ ['.png', 'image/png'],
33
+ ['.webp', 'image/webp'],
34
+ ['.gif', 'image/gif'],
35
+ ]);
36
+
37
+ function imageTypeForDocument(document) {
38
+ const declaredType = document?.mime_type ?? document?.mimetype;
39
+ const type = typeof declaredType === 'string' ? declaredType.toLowerCase() : '';
40
+ if (IMAGE_MEDIA_TYPES.has(type)) return type;
41
+ const filename = typeof document?.file_name === 'string' ? document.file_name.toLowerCase() : '';
42
+ for (const [extension, mediaType] of IMAGE_FILE_TYPES) {
43
+ if (filename.endsWith(extension)) return mediaType;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function fileSize(value) {
49
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
50
+ }
51
+
52
+ function photoScore(photo) {
53
+ return fileSize(photo?.file_size) ?? ((Number(photo?.width) || 0) * (Number(photo?.height) || 0));
54
+ }
55
+
56
+ function telegramImageSource(message, loadFile) {
57
+ let file;
58
+ let mediaType;
59
+ let name;
60
+ if (Array.isArray(message?.photo) && message.photo.length > 0) {
61
+ file = message.photo.reduce((largest, candidate) => (
62
+ photoScore(candidate) > photoScore(largest) ? candidate : largest
63
+ ));
64
+ mediaType = 'image/jpeg';
65
+ name = `${file.file_unique_id ?? file.file_id ?? 'telegram-photo'}.jpg`;
66
+ } else if (message?.document) {
67
+ const type = imageTypeForDocument(message.document);
68
+ if (!type) return null;
69
+ file = message.document;
70
+ mediaType = type;
71
+ name = typeof file.file_name === 'string' ? file.file_name : undefined;
72
+ }
73
+ if (!file || typeof file.file_id !== 'string') return null;
74
+ return {
75
+ name,
76
+ mediaType,
77
+ size: fileSize(file.file_size),
78
+ load: (options) => loadFile(file.file_id, options),
79
+ };
80
+ }
81
+
82
+ export function normalizeTelegramUpdate(update, { botId, username, loadFile = async () => {
83
+ throw new Error('Telegram file downloader is unavailable');
84
+ } }) {
26
85
  const message = update?.message;
27
86
  const chatId = message?.chat?.id;
28
87
  const senderId = message?.from?.id;
@@ -36,6 +95,7 @@ export function normalizeTelegramUpdate(update, { botId, username }) {
36
95
  || mentionedUsername(message, username);
37
96
  const messageThreadId = Number.isSafeInteger(message.message_thread_id)
38
97
  ? message.message_thread_id : undefined;
98
+ const image = telegramImageSource(message, loadFile);
39
99
  return {
40
100
  messageId: String(update.update_id),
41
101
  senderId: String(senderId),
@@ -44,6 +104,7 @@ export function normalizeTelegramUpdate(update, { botId, username }) {
44
104
  conversationId: messageThreadId === undefined
45
105
  ? String(chatId) : `${chatId}:${messageThreadId}`,
46
106
  content: withoutBotMention(message.text ?? message.caption ?? '', username),
107
+ images: image ? [image] : [],
47
108
  addressed,
48
109
  replyTarget: {
49
110
  chatId,
@@ -250,6 +311,7 @@ export class TelegramRuntime {
250
311
  const message = normalizeTelegramUpdate(update, {
251
312
  botId: this.#config.platformId,
252
313
  username: this.#config.username,
314
+ loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
253
315
  });
254
316
  if (message) {
255
317
  void this.#bridge.accept(message).catch((error) => {
@@ -8,11 +8,17 @@ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
8
8
  import { runCompactCommand } from '../shared/compact-command.mjs';
9
9
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
10
10
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
11
+ import {
12
+ hasInboundImages,
13
+ ImagePromptError,
14
+ imagePromptUserMessage,
15
+ promptContentForMessage,
16
+ } from '../shared/image-prompt.mjs';
11
17
 
12
18
  const HELP_TEXT = [
13
19
  '企业微信机器人已连接 DeepSeek Harness。',
14
20
  '',
15
- '直接发送文字即可继续当前会话。',
21
+ '直接发送文字或图片即可继续当前会话。',
16
22
  '/new 开启一个全新会话',
17
23
  '/compact 压缩当前会话的较早上下文',
18
24
  '/workspace 工作区绝对路径 切换工作区',
@@ -23,6 +29,8 @@ const HELP_TEXT = [
23
29
  '/help 显示本帮助',
24
30
  ].join('\n');
25
31
  const MAX_REPLY_BYTES = 18_000;
32
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
33
+ const MAX_PREFETCHED_IMAGES = 4;
26
34
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
27
35
 
28
36
  function nonEmptyString(value) {
@@ -59,6 +67,102 @@ function messageText(frame) {
59
67
  : text;
60
68
  }
61
69
 
70
+ function imageContents(frame) {
71
+ const body = bodyOf(frame);
72
+ if (body.msgtype === 'image') return [body.image];
73
+ if (body.msgtype !== 'mixed' || !Array.isArray(body.mixed?.msg_item)) return [];
74
+ return body.mixed.msg_item
75
+ .filter((item) => item?.msgtype === 'image')
76
+ .map((item) => item.image);
77
+ }
78
+
79
+ function imageSource(client, image) {
80
+ const url = nonEmptyString(image?.url);
81
+ if (!url) return null;
82
+ const aeskey = nonEmptyString(image?.aeskey) ?? undefined;
83
+ return {
84
+ async load({ signal, maxBytes }) {
85
+ signal?.throwIfAborted();
86
+ if (typeof client?.downloadFile !== 'function') {
87
+ throw new Error('Enterprise WeChat image download is unavailable');
88
+ }
89
+ const result = await client.downloadFile(url, aeskey);
90
+ signal?.throwIfAborted();
91
+ const raw = result?.buffer;
92
+ if (!Buffer.isBuffer(raw) && !(raw instanceof Uint8Array)) {
93
+ throw new Error('Enterprise WeChat image download returned no data');
94
+ }
95
+ const data = Buffer.from(raw);
96
+ if (Number.isFinite(maxBytes) && data.length > maxBytes) {
97
+ throw new ImagePromptError(
98
+ 'image-too-large',
99
+ `Enterprise WeChat image exceeds ${maxBytes} bytes`,
100
+ '图片超过 5 MB,请压缩后重试。',
101
+ );
102
+ }
103
+ return { data, name: result?.filename };
104
+ },
105
+ };
106
+ }
107
+
108
+ export function wecomInboundMessage(frame, client) {
109
+ return {
110
+ content: messageText(frame),
111
+ images: imageContents(frame).map((image) => imageSource(client, image)).filter(Boolean),
112
+ };
113
+ }
114
+
115
+ function prefetchInboundImages(message, signal) {
116
+ if (!hasInboundImages(message)) return message;
117
+ return {
118
+ ...message,
119
+ images: message.images.map((source) => {
120
+ const download = source.load({ signal, maxBytes: MAX_IMAGE_BYTES });
121
+ // The conversation queue may not consume this promise immediately. Keep
122
+ // an attached rejection handler while preserving the original outcome.
123
+ download.catch(() => undefined);
124
+ return {
125
+ ...source,
126
+ async load({ signal: loadSignal, maxBytes = MAX_IMAGE_BYTES } = {}) {
127
+ loadSignal?.throwIfAborted();
128
+ const result = await download;
129
+ loadSignal?.throwIfAborted();
130
+ const raw = result?.data ?? result?.buffer ?? result;
131
+ const size = Buffer.isBuffer(raw) || raw instanceof Uint8Array ? raw.length : 0;
132
+ if (size > maxBytes) {
133
+ throw new ImagePromptError(
134
+ 'image-too-large',
135
+ `Enterprise WeChat image exceeds ${maxBytes} bytes`,
136
+ '图片超过 5 MB,请压缩后重试。',
137
+ );
138
+ }
139
+ return result;
140
+ },
141
+ };
142
+ }),
143
+ };
144
+ }
145
+
146
+ function imageQueueFullMessage(message) {
147
+ return {
148
+ ...message,
149
+ images: message.images.map((source) => ({
150
+ ...source,
151
+ async load() {
152
+ throw new ImagePromptError(
153
+ 'image-queue-full',
154
+ `Enterprise WeChat already has ${MAX_PREFETCHED_IMAGES} prefetched images`,
155
+ '当前待处理图片较多,请稍后重新发送。',
156
+ );
157
+ },
158
+ })),
159
+ };
160
+ }
161
+
162
+ function interactionReplyText(frame) {
163
+ return bodyOf(frame).msgtype === 'text' ? messageText(frame) : '';
164
+ }
165
+
62
166
  function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) {
63
167
  const source = String(text ?? '').trim();
64
168
  if (!source) return [];
@@ -89,7 +193,7 @@ function progressText(update) {
89
193
  function canClaimInteractionReply(frame, pending) {
90
194
  return pending.questions[pending.index]
91
195
  && nonEmptyString(bodyOf(frame).from?.userid) === pending.actor
92
- && nonEmptyString(messageText(frame));
196
+ && nonEmptyString(interactionReplyText(frame));
93
197
  }
94
198
 
95
199
  export function createWecomBridgeStatus() {
@@ -119,6 +223,7 @@ export class WecomHarnessBridge {
119
223
  #acceptedMessageIds = new Set();
120
224
  #approvalTasks = new Set();
121
225
  #approvals;
226
+ #prefetchedImageCount = 0;
122
227
 
123
228
  constructor({
124
229
  client,
@@ -169,7 +274,7 @@ export class WecomHarnessBridge {
169
274
  key,
170
275
  actor: senderId,
171
276
  messageId,
172
- text: messageText(frame),
277
+ text: interactionReplyText(frame),
173
278
  addressed: true,
174
279
  hasPendingQuestion: Boolean(pending),
175
280
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -232,11 +337,28 @@ export class WecomHarnessBridge {
232
337
  releaseMessageId = true,
233
338
  alreadyRecorded = false,
234
339
  } = {}) {
340
+ // WeCom image URLs expire after five minutes, while a conversation turn
341
+ // may legally stay queued longer. Start the authenticated SDK download as
342
+ // soon as the validated callback is accepted, then consume it in order.
343
+ const inboundMessage = wecomInboundMessage(frame, this.#client);
344
+ const imageCount = inboundMessage.images.length;
345
+ let reservedImages = 0;
346
+ let preparedMessage = inboundMessage;
347
+ if (imageCount > 0) {
348
+ if (this.#prefetchedImageCount + imageCount <= MAX_PREFETCHED_IMAGES) {
349
+ reservedImages = imageCount;
350
+ this.#prefetchedImageCount += reservedImages;
351
+ preparedMessage = prefetchInboundImages(inboundMessage, this.#signal);
352
+ } else {
353
+ preparedMessage = imageQueueFullMessage(inboundMessage);
354
+ }
355
+ }
235
356
  const previous = this.#queues.get(key) ?? Promise.resolve();
236
357
  const current = previous
237
358
  .catch(() => undefined)
238
- .then(() => this.#process(frame, { alreadyRecorded }))
359
+ .then(() => this.#process(frame, { alreadyRecorded, preparedMessage }))
239
360
  .finally(() => {
361
+ this.#prefetchedImageCount -= reservedImages;
240
362
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
241
363
  if (this.#queues.get(key) === current) this.#queues.delete(key);
242
364
  });
@@ -275,7 +397,7 @@ export class WecomHarnessBridge {
275
397
  }
276
398
  }
277
399
 
278
- async #process(frame, { alreadyRecorded = false } = {}) {
400
+ async #process(frame, { alreadyRecorded = false, preparedMessage } = {}) {
279
401
  if (this.#signal?.aborted) return;
280
402
  const body = bodyOf(frame);
281
403
  const messageId = typeof body.msgid === 'string' ? body.msgid : '';
@@ -287,35 +409,39 @@ export class WecomHarnessBridge {
287
409
  this.#status.messagesReceived += 1;
288
410
  this.#status.lastMessageAt = new Date().toISOString();
289
411
  }
290
- const text = messageText(frame);
412
+ const message = preparedMessage ?? wecomInboundMessage(frame, this.#client);
413
+ const text = message.content;
414
+ const hasImages = hasInboundImages(message);
291
415
  const key = conversationKey(frame);
292
416
  let streamId = null;
293
417
  let streamStarted = false;
294
418
  try {
295
- if (!text) {
296
- await this.#sendImmediate(frame, chatId, '目前支持文字、语音转写和图文混排中的文字消息。');
419
+ if (!text && !hasImages) {
420
+ await this.#sendImmediate(frame, chatId, '目前支持文字、图片和语音转写消息。');
297
421
  await this.#state.markSeen(messageId);
298
422
  return;
299
423
  }
300
424
  const command = text.toLowerCase();
301
- if (command === '/help') {
425
+ if (!hasImages && command === '/help') {
302
426
  await this.#sendImmediate(frame, chatId, HELP_TEXT);
303
427
  await this.#state.markSeen(messageId);
304
428
  return;
305
429
  }
306
- if (command === '/status') {
430
+ if (!hasImages && command === '/status') {
307
431
  await this.#harness.ensureRunning({ signal: this.#signal });
308
432
  await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
309
433
  await this.#state.markSeen(messageId);
310
434
  return;
311
435
  }
312
- if (command === '/new') {
436
+ if (!hasImages && command === '/new') {
313
437
  await this.#state.clearSession(key);
314
438
  await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。');
315
439
  await this.#state.markSeen(messageId);
316
440
  return;
317
441
  }
318
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
442
+ const workspaceCommand = hasImages
443
+ ? null
444
+ : await runWorkspaceCommand(text, this.#harness, key);
319
445
  if (workspaceCommand) {
320
446
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
321
447
  await this.#sendImmediate(frame, chatId, reply);
@@ -323,13 +449,15 @@ export class WecomHarnessBridge {
323
449
  await this.#state.markSeen(messageId);
324
450
  return;
325
451
  }
326
- const compactCommand = await runCompactCommand(
327
- text,
328
- this.#harness,
329
- this.#state,
330
- key,
331
- { signal: this.#signal },
332
- );
452
+ const compactCommand = hasImages
453
+ ? null
454
+ : await runCompactCommand(
455
+ text,
456
+ this.#harness,
457
+ this.#state,
458
+ key,
459
+ { signal: this.#signal },
460
+ );
333
461
  if (compactCommand) {
334
462
  await this.#sendImmediate(frame, chatId, compactCommand.message);
335
463
  await this.#state.markSeen(messageId);
@@ -344,11 +472,15 @@ export class WecomHarnessBridge {
344
472
  this.#logger.warn?.('[dsh-im:wecom] unable to start a stream; using an active reply:', error);
345
473
  }
346
474
 
475
+ const content = hasImages
476
+ ? await promptContentForMessage(message, { signal: this.#signal })
477
+ : undefined;
347
478
  const { answer } = await askInWorkspaceSession({
348
479
  harness: this.#harness,
349
480
  state: this.#state,
350
481
  key,
351
482
  text,
483
+ content,
352
484
  createOptions: { signal: this.#signal },
353
485
  existsOptions: { signal: this.#signal },
354
486
  askOptions: {
@@ -392,11 +524,12 @@ export class WecomHarnessBridge {
392
524
  if (this.#signal?.aborted) return;
393
525
  this.#status.lastError = error?.message ?? String(error);
394
526
  this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
527
+ const errorText = imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。';
395
528
  try {
396
529
  if (streamStarted && streamId) {
397
- await this.#client.replyStream(frame, streamId, '消息处理失败,请稍后重试。', true);
530
+ await this.#client.replyStream(frame, streamId, errorText, true);
398
531
  } else {
399
- await this.#sendImmediate(frame, chatId, '消息处理失败,请稍后重试。');
532
+ await this.#sendImmediate(frame, chatId, errorText);
400
533
  }
401
534
  await this.#state.markSeen(messageId);
402
535
  } catch {
@@ -425,9 +558,9 @@ export class WecomHarnessBridge {
425
558
  this.#status.messagesReceived += 1;
426
559
  this.#status.lastMessageAt = new Date().toISOString();
427
560
 
428
- const text = nonEmptyString(messageText(frame));
561
+ const text = nonEmptyString(interactionReplyText(frame));
429
562
  if (!text) {
430
- await this.#sendImmediate(frame, chatId, '请用文字或语音回答当前问题。')
563
+ await this.#sendImmediate(frame, chatId, '请用文字回答当前问题。')
431
564
  .catch(() => undefined);
432
565
  return;
433
566
  }
@@ -1,8 +1,13 @@
1
- import { randomBytes, randomUUID } from 'node:crypto';
1
+ import { createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
2
+
3
+ import { fetchImageBuffer } from '../shared/image-prompt.mjs';
2
4
 
3
5
  export const WEIXIN_QR_BASE_URL = 'https://ilinkai.weixin.qq.com/';
4
6
  export const WEIXIN_PROTOCOL_VERSION = '2.4.6';
5
7
  export const DEFAULT_BOT_TYPE = '3';
8
+ export const WEIXIN_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';
9
+
10
+ const WEIXIN_CDN_HOST = 'novac2c.cdn.weixin.qq.com';
6
11
 
7
12
  const ILINK_APP_ID = 'bot';
8
13
  const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
@@ -32,6 +37,93 @@ function nonEmptyString(value) {
32
37
  return typeof value === 'string' && value.trim() ? value.trim() : null;
33
38
  }
34
39
 
40
+ function strictBase64(value) {
41
+ const text = nonEmptyString(value);
42
+ if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
43
+ return Buffer.from(text, 'base64');
44
+ }
45
+
46
+ /** Parse the two AES key encodings used by Weixin iLink image messages. */
47
+ export function parseWeixinImageAesKey(imageItem) {
48
+ const directHex = nonEmptyString(imageItem?.aeskey);
49
+ if (directHex) {
50
+ if (!/^[0-9a-fA-F]{32}$/.test(directHex)) {
51
+ throw new WeixinApiError('invalid-image-key', '微信图片的加密密钥无效。');
52
+ }
53
+ return Buffer.from(directHex, 'hex');
54
+ }
55
+
56
+ const encoded = strictBase64(imageItem?.media?.aes_key);
57
+ if (encoded?.length === 16) return encoded;
58
+ if (encoded?.length === 32 && /^[0-9a-fA-F]{32}$/.test(encoded.toString('ascii'))) {
59
+ return Buffer.from(encoded.toString('ascii'), 'hex');
60
+ }
61
+ throw new WeixinApiError('invalid-image-key', '微信图片的加密密钥无效。');
62
+ }
63
+
64
+ export function decryptWeixinImage(ciphertext, key) {
65
+ const encrypted = Buffer.from(ciphertext);
66
+ const aesKey = Buffer.from(key);
67
+ if (aesKey.length !== 16 || encrypted.length === 0 || encrypted.length % 16 !== 0) {
68
+ throw new WeixinApiError('invalid-image-ciphertext', '微信图片的加密数据无效。');
69
+ }
70
+ try {
71
+ const decipher = createDecipheriv('aes-128-ecb', aesKey, null);
72
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]);
73
+ } catch (error) {
74
+ throw new WeixinApiError('image-decryption-failed', '微信图片解密失败。', { cause: error });
75
+ }
76
+ }
77
+
78
+ export function weixinImageDownloadUrl(media) {
79
+ const query = nonEmptyString(media?.encrypt_query_param);
80
+ if (query) {
81
+ return `${WEIXIN_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(query)}`;
82
+ }
83
+
84
+ const fullUrl = nonEmptyString(media?.full_url);
85
+ if (!fullUrl) throw new WeixinApiError('missing-image-url', '微信图片没有可用的下载地址。');
86
+ let url;
87
+ try {
88
+ url = new URL(fullUrl);
89
+ } catch {
90
+ throw new WeixinApiError('invalid-image-url', '微信图片的下载地址无效。');
91
+ }
92
+ if (url.protocol !== 'https:' || url.hostname !== WEIXIN_CDN_HOST
93
+ || (url.port && url.port !== '443') || !url.pathname.startsWith('/c2c/')) {
94
+ throw new WeixinApiError('untrusted-image-url', '微信图片的下载地址不受信任。');
95
+ }
96
+ url.username = '';
97
+ url.password = '';
98
+ url.hash = '';
99
+ return url.toString();
100
+ }
101
+
102
+ /** Convert iLink image items into lazily downloaded, decrypted image references. */
103
+ export function extractWeixinImages(message, { fetchImpl = fetch } = {}) {
104
+ if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
105
+ const images = [];
106
+ for (const item of message?.item_list ?? []) {
107
+ const imageItem = item?.image_item;
108
+ if (!imageItem || typeof imageItem !== 'object') continue;
109
+ images.push({
110
+ name: images.length === 0 ? 'image' : `image-${images.length + 1}`,
111
+ load: async ({ signal, maxBytes }) => {
112
+ const key = parseWeixinImageAesKey(imageItem);
113
+ const url = weixinImageDownloadUrl(imageItem.media);
114
+ const ciphertext = await fetchImageBuffer(url, {
115
+ fetchImpl,
116
+ signal,
117
+ maxBytes: maxBytes + 16,
118
+ allowedHosts: [WEIXIN_CDN_HOST],
119
+ });
120
+ return decryptWeixinImage(ciphertext, key);
121
+ },
122
+ });
123
+ }
124
+ return images;
125
+ }
126
+
35
127
  function isWeixinHost(hostname) {
36
128
  const normalized = hostname.toLowerCase().replace(/\.$/, '');
37
129
  return normalized === 'weixin.qq.com' || normalized.endsWith('.weixin.qq.com');
@@ -92,7 +184,7 @@ function authenticatedHeaders(token) {
92
184
  function baseInfo() {
93
185
  return {
94
186
  channel_version: WEIXIN_PROTOCOL_VERSION,
95
- bot_agent: 'DeepSeekHarness/0.9.0',
187
+ bot_agent: 'DeepSeekHarness/0.10.0',
96
188
  };
97
189
  }
98
190
 
@@ -170,6 +262,10 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
170
262
  if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
171
263
 
172
264
  return Object.freeze({
265
+ inboundImages(message) {
266
+ return extractWeixinImages(message, { fetchImpl });
267
+ },
268
+
173
269
  async beginLogin({ localTokens = [], botType = DEFAULT_BOT_TYPE, signal } = {}) {
174
270
  const tokens = [...new Set(localTokens.map(nonEmptyString).filter(Boolean))].slice(-10);
175
271
  const response = await requestJson(fetchImpl, {