@xmanrui/dsh-im 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.en.md +7 -3
  2. package/README.md +7 -3
  3. package/lib/client.js +378 -27
  4. package/lib/index.js +134 -131
  5. package/package.json +1 -1
  6. package/plugin-src/client/channels/dingtalk/api.js +19 -0
  7. package/plugin-src/client/channels/dingtalk/index.js +67 -10
  8. package/plugin-src/client/channels/feishu/index.js +35 -5
  9. package/plugin-src/client/channels/qq/api.js +21 -0
  10. package/plugin-src/client/channels/qq/index.js +39 -1
  11. package/plugin-src/client/channels/shared/token-channel.js +29 -3
  12. package/plugin-src/client/channels/wecom/api.js +11 -0
  13. package/plugin-src/client/channels/wecom/index.js +71 -1
  14. package/plugin-src/client/channels/weixin/api.js +11 -0
  15. package/plugin-src/client/channels/weixin/index.js +40 -4
  16. package/plugin-src/client/channels/whatsapp/index.js +41 -2
  17. package/plugin-src/client/i18n.js +13 -0
  18. package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
  19. package/plugin-src/host/channels/dingtalk/rpc.mjs +29 -3
  20. package/plugin-src/host/channels/feishu/production.mjs +1 -1
  21. package/plugin-src/host/channels/feishu/rpc.mjs +34 -3
  22. package/plugin-src/host/channels/qq/production.mjs +1 -1
  23. package/plugin-src/host/channels/qq/rpc.mjs +35 -2
  24. package/plugin-src/host/channels/shared/production.mjs +1 -1
  25. package/plugin-src/host/channels/shared/rpc.mjs +25 -1
  26. package/plugin-src/host/channels/slack/production.mjs +1 -1
  27. package/plugin-src/host/channels/slack/rpc.mjs +28 -2
  28. package/plugin-src/host/channels/wecom/production.mjs +1 -1
  29. package/plugin-src/host/channels/wecom/rpc.mjs +31 -2
  30. package/plugin-src/host/channels/weixin/production.mjs +1 -1
  31. package/plugin-src/host/channels/weixin/rpc.mjs +29 -3
  32. package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
  33. package/plugin-src/host/channels/whatsapp/rpc.mjs +31 -3
  34. package/src/channels/dingtalk/dingtalk-api.mjs +82 -1
  35. package/src/channels/dingtalk/dingtalk-bridge.mjs +160 -20
  36. package/src/channels/dingtalk/dingtalk-controller.mjs +18 -0
  37. package/src/channels/dingtalk/dingtalk-runtime.mjs +21 -0
  38. package/src/channels/discord/discord-api.mjs +1 -1
  39. package/src/channels/discord/discord-runtime.mjs +54 -1
  40. package/src/channels/feishu/bridge.mjs +45 -18
  41. package/src/channels/feishu/feishu-runtime.mjs +45 -0
  42. package/src/channels/feishu/message-utils.mjs +227 -6
  43. package/src/channels/feishu/multi-bot-controller.mjs +21 -0
  44. package/src/channels/feishu/plugin-controller.mjs +1 -0
  45. package/src/channels/qq/qq-bridge.mjs +107 -20
  46. package/src/channels/qq/qq-controller.mjs +18 -0
  47. package/src/channels/qq/qq-runtime.mjs +26 -0
  48. package/src/channels/shared/bot-workspace-store.mjs +2 -0
  49. package/src/channels/shared/connection-test.mjs +54 -0
  50. package/src/channels/shared/harness-client.mjs +14 -8
  51. package/src/channels/shared/image-prompt.mjs +268 -0
  52. package/src/channels/shared/text-harness-bridge.mjs +62 -16
  53. package/src/channels/shared/token-bot-controller.mjs +21 -0
  54. package/src/channels/shared/workspace-session.mjs +2 -1
  55. package/src/channels/slack/manifest.mjs +1 -0
  56. package/src/channels/slack/slack-api.mjs +95 -0
  57. package/src/channels/slack/slack-controller.mjs +19 -0
  58. package/src/channels/slack/slack-runtime.mjs +34 -3
  59. package/src/channels/telegram/telegram-api.mjs +37 -0
  60. package/src/channels/telegram/telegram-runtime.mjs +81 -9
  61. package/src/channels/wecom/wecom-bridge.mjs +160 -23
  62. package/src/channels/wecom/wecom-controller.mjs +18 -0
  63. package/src/channels/wecom/wecom-runtime.mjs +18 -0
  64. package/src/channels/weixin/weixin-api.mjs +98 -2
  65. package/src/channels/weixin/weixin-bridge.mjs +52 -19
  66. package/src/channels/weixin/weixin-controller.mjs +18 -0
  67. package/src/channels/weixin/weixin-runtime.mjs +25 -0
  68. package/src/channels/whatsapp/whatsapp-controller.mjs +20 -0
  69. package/src/channels/whatsapp/whatsapp-runtime.mjs +160 -1
@@ -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.11.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, {
@@ -1,4 +1,5 @@
1
1
  import {
2
+ extractWeixinImages,
2
3
  extractWeixinText,
3
4
  splitWeixinText,
4
5
  weixinMessageId,
@@ -12,13 +13,19 @@ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
12
13
  import { runCompactCommand } from '../shared/compact-command.mjs';
13
14
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
14
15
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
16
+ import {
17
+ hasInboundImages,
18
+ imagePromptUserMessage,
19
+ promptContentForMessage,
20
+ } from '../shared/image-prompt.mjs';
21
+ import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
15
22
 
16
23
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
17
24
 
18
25
  const HELP_TEXT = [
19
26
  '微信已连接 DeepSeek Harness。',
20
27
  '',
21
- '直接发送文字或带文字识别结果的语音即可继续当前会话。',
28
+ '直接发送文字、图片或带文字识别结果的语音即可继续当前会话。',
22
29
  '/new 开启一个全新会话',
23
30
  '/compact 压缩当前会话的较早上下文',
24
31
  '/workspace 工作区绝对路径 切换工作区',
@@ -37,9 +44,15 @@ function nonEmptyString(value) {
37
44
  return typeof value === 'string' && value.trim() ? value.trim() : null;
38
45
  }
39
46
 
47
+ function hasWeixinImageItems(message) {
48
+ return Array.isArray(message?.item_list)
49
+ && message.item_list.some((item) => item?.image_item && typeof item.image_item === 'object');
50
+ }
51
+
40
52
  function canClaimInteractionReply(message, pending) {
41
53
  return pending.questions[pending.index]
42
54
  && nonEmptyString(message?.from_user_id) === pending.actor
55
+ && !hasWeixinImageItems(message)
43
56
  && nonEmptyString(extractWeixinText(message));
44
57
  }
45
58
 
@@ -116,6 +129,9 @@ export class WeixinHarnessBridge {
116
129
  if (!messageId || !sender || this.#state.hasSeen(messageId)
117
130
  || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
118
131
  this.#acceptedMessageIds.add(messageId);
132
+ if (sender === this.#ownerUserId) {
133
+ rememberConnectionTestTarget(this.#state, { toUserId: sender });
134
+ }
119
135
  const key = conversationKey(sender);
120
136
  const contextToken = nonEmptyString(message?.context_token) ?? undefined;
121
137
  const runId = nonEmptyString(message?.run_id) ?? undefined;
@@ -124,7 +140,7 @@ export class WeixinHarnessBridge {
124
140
  key,
125
141
  actor: sender,
126
142
  messageId,
127
- text: extractWeixinText(message),
143
+ text: hasWeixinImageItems(message) ? '' : extractWeixinText(message),
128
144
  addressed: true,
129
145
  hasPendingQuestion: Boolean(pending),
130
146
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -216,33 +232,40 @@ export class WeixinHarnessBridge {
216
232
 
217
233
  const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
218
234
  const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
219
- const text = extractWeixinText(message);
235
+ const text = extractWeixinText(message) ?? '';
220
236
  try {
221
- if (!text) {
222
- await this.#send(sender, '目前仅支持文字消息,以及微信已转成文字的语音消息。', contextToken, runId);
237
+ const images = typeof this.#api.inboundImages === 'function'
238
+ ? this.#api.inboundImages(message)
239
+ : extractWeixinImages(message);
240
+ const promptMessage = { content: text, images };
241
+ const hasImages = hasInboundImages(promptMessage);
242
+ if (!text && !hasImages) {
243
+ await this.#send(sender, '目前支持文字、图片,以及微信已转成文字的语音消息。', contextToken, runId);
223
244
  await this.#state.markSeen(messageId);
224
245
  return;
225
246
  }
226
247
 
227
248
  const command = text.trim().toLowerCase();
228
- if (command === '/help') {
249
+ if (!hasImages && command === '/help') {
229
250
  await this.#send(sender, HELP_TEXT, contextToken, runId);
230
251
  await this.#state.markSeen(messageId);
231
252
  return;
232
253
  }
233
- if (command === '/status') {
254
+ if (!hasImages && command === '/status') {
234
255
  await this.#harness.ensureRunning({ signal: this.#signal });
235
256
  await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
236
257
  await this.#state.markSeen(messageId);
237
258
  return;
238
259
  }
239
- if (command === '/new') {
260
+ if (!hasImages && command === '/new') {
240
261
  await this.#state.clearSession(key);
241
262
  await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
242
263
  await this.#state.markSeen(messageId);
243
264
  return;
244
265
  }
245
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
266
+ const workspaceCommand = hasImages
267
+ ? null
268
+ : await runWorkspaceCommand(text, this.#harness, key);
246
269
  if (workspaceCommand) {
247
270
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
248
271
  await this.#send(sender, reply, contextToken, runId);
@@ -250,26 +273,31 @@ export class WeixinHarnessBridge {
250
273
  await this.#state.markSeen(messageId);
251
274
  return;
252
275
  }
253
- const compactCommand = await runCompactCommand(
254
- text,
255
- this.#harness,
256
- this.#state,
257
- key,
258
- { signal: this.#signal },
259
- );
276
+ const compactCommand = hasImages
277
+ ? null
278
+ : await runCompactCommand(
279
+ text,
280
+ this.#harness,
281
+ this.#state,
282
+ key,
283
+ { signal: this.#signal },
284
+ );
260
285
  if (compactCommand) {
261
286
  await this.#send(sender, compactCommand.message, contextToken, runId);
262
287
  await this.#state.markSeen(messageId);
263
288
  return;
264
289
  }
265
290
 
291
+ const content = hasImages
292
+ ? await promptContentForMessage(promptMessage, { signal: this.#signal })
293
+ : undefined;
266
294
  let answer;
267
295
  try {
268
296
  ({ answer } = await askInWorkspaceSession({
269
297
  harness: this.#harness,
270
298
  state: this.#state,
271
299
  key,
272
- text,
300
+ ...(hasImages ? { content } : { text }),
273
301
  createOptions: { signal: this.#signal },
274
302
  existsOptions: { signal: this.#signal },
275
303
  askOptions: {
@@ -300,7 +328,12 @@ export class WeixinHarnessBridge {
300
328
  this.#status.lastError = error?.message ?? String(error);
301
329
  this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
302
330
  try {
303
- await this.#send(sender, '消息处理失败,请稍后重试。', contextToken, runId);
331
+ await this.#send(
332
+ sender,
333
+ imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
334
+ contextToken,
335
+ runId,
336
+ );
304
337
  await this.#state.markSeen(messageId);
305
338
  } catch (sendError) {
306
339
  this.#logger.error?.('[dsh-weixin] failed to send the safe error reply:', sendError);
@@ -326,7 +359,7 @@ export class WeixinHarnessBridge {
326
359
  const text = nonEmptyString(extractWeixinText(message));
327
360
  const contextToken = nonEmptyString(message?.context_token) ?? undefined;
328
361
  const runId = nonEmptyString(message?.run_id) ?? undefined;
329
- if (!text) {
362
+ if (!text || hasWeixinImageItems(message)) {
330
363
  await this.#send(
331
364
  expected.actor,
332
365
  '请用文字回答当前问题。',
@@ -6,6 +6,10 @@ import {
6
6
  WeixinApiError,
7
7
  } from './weixin-api.mjs';
8
8
  import { deriveWeixinBotIdentity, maskWeixinAccountId } from './config-store.mjs';
9
+ import {
10
+ connectionTestMessage,
11
+ connectionTestTargetUnavailable,
12
+ } from '../shared/connection-test.mjs';
9
13
 
10
14
  const ACTIVE_ATTEMPT_STATES = new Set([
11
15
  'starting',
@@ -240,6 +244,20 @@ export class WeixinController {
240
244
  return this.status();
241
245
  }
242
246
 
247
+ async sendConnectionTest(botId) {
248
+ const config = this.#configStore.get(botId);
249
+ if (!config) throw new Error('Unknown Weixin account');
250
+ return this.#withBotTransition(botId, async () => {
251
+ const runtime = this.#runtimes.get(botId);
252
+ if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
253
+ throw connectionTestTargetUnavailable('微信机器人');
254
+ }
255
+ return runtime.sendConnectionTest(connectionTestMessage(
256
+ `微信机器人(${maskWeixinAccountId(config.accountId)})`,
257
+ ));
258
+ });
259
+ }
260
+
243
261
  async deleteBot(botId) {
244
262
  const config = this.#configStore.get(botId);
245
263
  if (!config) throw new Error('Unknown Weixin account');
@@ -1,5 +1,9 @@
1
1
  import { WeixinApiError } from './weixin-api.mjs';
2
2
  import { createWeixinBridgeStatus, WeixinHarnessBridge } from './weixin-bridge.mjs';
3
+ import {
4
+ connectionTestTarget,
5
+ connectionTestTargetUnavailable,
6
+ } from '../shared/connection-test.mjs';
3
7
 
4
8
  const DEFAULT_START_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000]);
5
9
 
@@ -250,4 +254,25 @@ export class WeixinRuntime {
250
254
  this.#status.weixinConnectionState = 'idle';
251
255
  return this.status;
252
256
  }
257
+
258
+ async sendConnectionTest(text) {
259
+ const remembered = connectionTestTarget(this.#state);
260
+ const toUserId = typeof remembered?.toUserId === 'string' && remembered.toUserId.trim()
261
+ ? remembered.toUserId.trim()
262
+ : typeof this.#config.ownerUserId === 'string' && this.#config.ownerUserId.trim()
263
+ ? this.#config.ownerUserId.trim()
264
+ : null;
265
+ if (!toUserId) throw connectionTestTargetUnavailable('微信机器人');
266
+ if (!this.#status.ready || !this.#abortController) {
267
+ throw new Error('Weixin runtime is not connected');
268
+ }
269
+ await this.#api.sendText({
270
+ baseUrl: this.#config.baseUrl,
271
+ token: this.#token,
272
+ toUserId,
273
+ text,
274
+ signal: this.#abortController.signal,
275
+ });
276
+ return { sent: true };
277
+ }
253
278
  }
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
+ import { connectionTestMessage } from '../shared/connection-test.mjs';
3
4
  import { deriveWhatsappBotId, maskWhatsappAccount } from './config-store.mjs';
4
5
 
5
6
  const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
@@ -198,6 +199,25 @@ export class WhatsappController {
198
199
  return this.status();
199
200
  }
200
201
 
202
+ async sendConnectionTest(botId) {
203
+ const config = this.#configStore.get(botId);
204
+ if (!config) throw new Error('Unknown WhatsApp bot');
205
+ return this.#withBotTransition(botId, async () => {
206
+ const runtime = this.#runtimes.get(botId);
207
+ if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
208
+ const error = new Error('WhatsApp机器人尚未连接');
209
+ error.code = 'test-target-unavailable';
210
+ throw error;
211
+ }
212
+ return runtime.sendConnectionTest(
213
+ connectionTestMessage(
214
+ `${config.name}(${maskWhatsappAccount(config.accountJid)})`,
215
+ 'WhatsApp机器人',
216
+ ),
217
+ );
218
+ });
219
+ }
220
+
201
221
  async deleteBot(botId) {
202
222
  const config = this.#configStore.get(botId);
203
223
  if (!config) throw new Error('Unknown WhatsApp bot');
@@ -1,12 +1,45 @@
1
1
  import {
2
2
  areJidsSameUser,
3
+ downloadMediaMessage,
3
4
  normalizeMessageContent,
4
5
  } from '@whiskeysockets/baileys';
5
6
 
6
7
  import { splitMessageText } from '../shared/editable-message-stream.mjs';
8
+ import { ImagePromptError } from '../shared/image-prompt.mjs';
7
9
  import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
8
10
  import { createWhatsappWebSession } from './whatsapp-web-session.mjs';
9
11
 
12
+ const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
13
+ const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
14
+ const IMAGE_DOWNLOAD_TIMEOUT_MS = 15_000;
15
+ const MESSAGE_WRAPPER_KEYS = [
16
+ 'ephemeralMessage',
17
+ 'viewOnceMessage',
18
+ 'documentWithCaptionMessage',
19
+ 'viewOnceMessageV2',
20
+ 'viewOnceMessageV2Extension',
21
+ 'editedMessage',
22
+ 'associatedChildMessage',
23
+ 'groupStatusMessage',
24
+ 'groupStatusMessageV2',
25
+ ];
26
+ const VIEW_ONCE_WRAPPER_KEYS = new Set([
27
+ 'viewOnceMessage',
28
+ 'viewOnceMessageV2',
29
+ 'viewOnceMessageV2Extension',
30
+ ]);
31
+
32
+ function hasViewOnceWrapper(content) {
33
+ let current = content;
34
+ for (let depth = 0; depth < 5 && current && typeof current === 'object'; depth += 1) {
35
+ const wrapperKey = MESSAGE_WRAPPER_KEYS.find((key) => current[key]?.message);
36
+ if (!wrapperKey) return false;
37
+ if (VIEW_ONCE_WRAPPER_KEYS.has(wrapperKey)) return true;
38
+ current = current[wrapperKey].message;
39
+ }
40
+ return false;
41
+ }
42
+
10
43
  function messageContext(content) {
11
44
  return content?.extendedTextMessage?.contextInfo
12
45
  ?? content?.imageMessage?.contextInfo
@@ -24,7 +57,114 @@ function messageText(content) {
24
57
  ?? '';
25
58
  }
26
59
 
27
- export function normalizeWhatsappMessage(message, accountJid) {
60
+ function mediaSize(value) {
61
+ if (Number.isSafeInteger(value) && value >= 0) return value;
62
+ let converted;
63
+ try {
64
+ converted = Number(value?.toString?.());
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ return Number.isSafeInteger(converted) && converted >= 0 ? converted : undefined;
69
+ }
70
+
71
+ async function downloadWhatsappImage(message, download, {
72
+ signal,
73
+ maxBytes = DEFAULT_MAX_IMAGE_BYTES,
74
+ } = {}) {
75
+ signal?.throwIfAborted();
76
+ const timeout = AbortSignal.timeout(IMAGE_DOWNLOAD_TIMEOUT_MS);
77
+ const downloadSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
78
+ const pendingStream = Promise.resolve().then(() => (
79
+ download(message, 'stream', { options: { signal: downloadSignal } })
80
+ ));
81
+ const stream = await new Promise((resolve, reject) => {
82
+ let settled = false;
83
+ const finish = (callback, value) => {
84
+ if (settled) return false;
85
+ settled = true;
86
+ downloadSignal.removeEventListener('abort', onAbort);
87
+ callback(value);
88
+ return true;
89
+ };
90
+ const onAbort = () => finish(reject, downloadSignal.reason);
91
+ downloadSignal.addEventListener('abort', onAbort, { once: true });
92
+ pendingStream.then((value) => {
93
+ if (!finish(resolve, value)) value?.destroy?.();
94
+ }, (error) => finish(reject, error));
95
+ if (downloadSignal.aborted) onAbort();
96
+ }).catch((error) => {
97
+ if (signal?.aborted) throw signal.reason ?? error;
98
+ if (timeout.aborted) {
99
+ throw new ImagePromptError(
100
+ 'image-download-failed',
101
+ `WhatsApp image download timed out after ${IMAGE_DOWNLOAD_TIMEOUT_MS} ms`,
102
+ '图片下载失败,请重新发送后再试。',
103
+ );
104
+ }
105
+ throw error;
106
+ });
107
+ const chunks = [];
108
+ let size = 0;
109
+ const abortStream = () => stream?.destroy?.(downloadSignal.reason);
110
+ downloadSignal.addEventListener('abort', abortStream, { once: true });
111
+ try {
112
+ for await (const chunk of stream) {
113
+ downloadSignal.throwIfAborted();
114
+ const data = Buffer.from(chunk);
115
+ size += data.length;
116
+ if (size > maxBytes) {
117
+ stream.destroy?.();
118
+ throw new ImagePromptError(
119
+ 'image-too-large',
120
+ `WhatsApp image exceeded ${maxBytes} bytes`,
121
+ '图片超过 5 MB,请压缩后重试。',
122
+ );
123
+ }
124
+ chunks.push(data);
125
+ }
126
+ } catch (error) {
127
+ if (signal?.aborted) throw signal.reason ?? error;
128
+ if (timeout.aborted) {
129
+ throw new ImagePromptError(
130
+ 'image-download-failed',
131
+ `WhatsApp image stream timed out after ${IMAGE_DOWNLOAD_TIMEOUT_MS} ms`,
132
+ '图片下载失败,请重新发送后再试。',
133
+ );
134
+ }
135
+ throw error;
136
+ } finally {
137
+ downloadSignal.removeEventListener('abort', abortStream);
138
+ }
139
+ return Buffer.concat(chunks, size);
140
+ }
141
+
142
+ function whatsappImageSource(message, content, download, { viewOnce = false } = {}) {
143
+ let media;
144
+ let name;
145
+ if (!viewOnce && content?.imageMessage && content.imageMessage.viewOnce !== true) {
146
+ media = content.imageMessage;
147
+ } else if (content?.documentMessage) {
148
+ const type = typeof content.documentMessage.mimetype === 'string'
149
+ ? content.documentMessage.mimetype.toLowerCase() : '';
150
+ if (!IMAGE_MEDIA_TYPES.has(type)) return null;
151
+ media = content.documentMessage;
152
+ name = typeof media.fileName === 'string' ? media.fileName : undefined;
153
+ }
154
+ if (!media) return null;
155
+ const mediaType = typeof media.mimetype === 'string' ? media.mimetype.toLowerCase() : '';
156
+ if (!IMAGE_MEDIA_TYPES.has(mediaType)) return null;
157
+ return {
158
+ name,
159
+ mediaType,
160
+ size: mediaSize(media.fileLength),
161
+ load: (options) => downloadWhatsappImage(message, download, options),
162
+ };
163
+ }
164
+
165
+ export function normalizeWhatsappMessage(message, accountJid, {
166
+ download = downloadMediaMessage,
167
+ } = {}) {
28
168
  const remoteJid = typeof message?.key?.remoteJid === 'string' ? message.key.remoteJid : '';
29
169
  const alternateRemoteJid = typeof message?.key?.remoteJidAlt === 'string'
30
170
  ? message.key.remoteJidAlt : '';
@@ -38,12 +178,14 @@ export function normalizeWhatsappMessage(message, accountJid) {
38
178
  if (fromMe && !selfChat) return null;
39
179
  const senderJid = selfChat ? accountJid : group ? message.key.participant : remoteJid;
40
180
  if (typeof senderJid !== 'string' || !senderJid) return null;
181
+ const viewOnce = hasViewOnceWrapper(message.message);
41
182
  const content = normalizeMessageContent(message.message);
42
183
  const context = messageContext(content);
43
184
  const mentioned = Array.isArray(context?.mentionedJid)
44
185
  && context.mentionedJid.some((jid) => areJidsSameUser(jid, accountJid));
45
186
  const replyToSelf = typeof context?.participant === 'string'
46
187
  && areJidsSameUser(context.participant, accountJid);
188
+ const image = whatsappImageSource(message, content, download, { viewOnce });
47
189
  return {
48
190
  messageId: `${remoteJid}:${messageId}`,
49
191
  providerMessageId: messageId,
@@ -52,6 +194,7 @@ export function normalizeWhatsappMessage(message, accountJid) {
52
194
  kind: group ? 'group' : 'direct',
53
195
  conversationId: remoteJid,
54
196
  content: messageText(content),
197
+ images: image ? [image] : [],
55
198
  addressed: !group || mentioned || replyToSelf,
56
199
  selfChat,
57
200
  replyTarget: { jid: remoteJid, quoted: message, selfChat },
@@ -278,6 +421,22 @@ export class WhatsappRuntime {
278
421
  return this.stop();
279
422
  }
280
423
 
424
+ async sendConnectionTest(text) {
425
+ if (!this.#status.ready || !this.#client) {
426
+ const error = new Error('WhatsApp机器人尚未连接');
427
+ error.code = 'test-target-unavailable';
428
+ throw error;
429
+ }
430
+ if (typeof text !== 'string' || !text.trim()) {
431
+ throw new TypeError('WhatsApp connection test text is required');
432
+ }
433
+ await this.#client.sendText({
434
+ jid: this.#config.accountJid,
435
+ selfChat: true,
436
+ }, text);
437
+ return { sent: true };
438
+ }
439
+
281
440
  async stop() {
282
441
  const session = this.#session;
283
442
  const client = this.#client;