@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.
@@ -1,4 +1,5 @@
1
1
  import {
2
+ extractWeixinImages,
2
3
  extractWeixinText,
3
4
  splitWeixinText,
4
5
  weixinMessageId,
@@ -12,13 +13,18 @@ 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';
15
21
 
16
22
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
17
23
 
18
24
  const HELP_TEXT = [
19
25
  '微信已连接 DeepSeek Harness。',
20
26
  '',
21
- '直接发送文字或带文字识别结果的语音即可继续当前会话。',
27
+ '直接发送文字、图片或带文字识别结果的语音即可继续当前会话。',
22
28
  '/new 开启一个全新会话',
23
29
  '/compact 压缩当前会话的较早上下文',
24
30
  '/workspace 工作区绝对路径 切换工作区',
@@ -37,9 +43,15 @@ function nonEmptyString(value) {
37
43
  return typeof value === 'string' && value.trim() ? value.trim() : null;
38
44
  }
39
45
 
46
+ function hasWeixinImageItems(message) {
47
+ return Array.isArray(message?.item_list)
48
+ && message.item_list.some((item) => item?.image_item && typeof item.image_item === 'object');
49
+ }
50
+
40
51
  function canClaimInteractionReply(message, pending) {
41
52
  return pending.questions[pending.index]
42
53
  && nonEmptyString(message?.from_user_id) === pending.actor
54
+ && !hasWeixinImageItems(message)
43
55
  && nonEmptyString(extractWeixinText(message));
44
56
  }
45
57
 
@@ -124,7 +136,7 @@ export class WeixinHarnessBridge {
124
136
  key,
125
137
  actor: sender,
126
138
  messageId,
127
- text: extractWeixinText(message),
139
+ text: hasWeixinImageItems(message) ? '' : extractWeixinText(message),
128
140
  addressed: true,
129
141
  hasPendingQuestion: Boolean(pending),
130
142
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -216,33 +228,40 @@ export class WeixinHarnessBridge {
216
228
 
217
229
  const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
218
230
  const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
219
- const text = extractWeixinText(message);
231
+ const text = extractWeixinText(message) ?? '';
220
232
  try {
221
- if (!text) {
222
- await this.#send(sender, '目前仅支持文字消息,以及微信已转成文字的语音消息。', contextToken, runId);
233
+ const images = typeof this.#api.inboundImages === 'function'
234
+ ? this.#api.inboundImages(message)
235
+ : extractWeixinImages(message);
236
+ const promptMessage = { content: text, images };
237
+ const hasImages = hasInboundImages(promptMessage);
238
+ if (!text && !hasImages) {
239
+ await this.#send(sender, '目前支持文字、图片,以及微信已转成文字的语音消息。', contextToken, runId);
223
240
  await this.#state.markSeen(messageId);
224
241
  return;
225
242
  }
226
243
 
227
244
  const command = text.trim().toLowerCase();
228
- if (command === '/help') {
245
+ if (!hasImages && command === '/help') {
229
246
  await this.#send(sender, HELP_TEXT, contextToken, runId);
230
247
  await this.#state.markSeen(messageId);
231
248
  return;
232
249
  }
233
- if (command === '/status') {
250
+ if (!hasImages && command === '/status') {
234
251
  await this.#harness.ensureRunning({ signal: this.#signal });
235
252
  await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
236
253
  await this.#state.markSeen(messageId);
237
254
  return;
238
255
  }
239
- if (command === '/new') {
256
+ if (!hasImages && command === '/new') {
240
257
  await this.#state.clearSession(key);
241
258
  await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
242
259
  await this.#state.markSeen(messageId);
243
260
  return;
244
261
  }
245
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
262
+ const workspaceCommand = hasImages
263
+ ? null
264
+ : await runWorkspaceCommand(text, this.#harness, key);
246
265
  if (workspaceCommand) {
247
266
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
248
267
  await this.#send(sender, reply, contextToken, runId);
@@ -250,26 +269,31 @@ export class WeixinHarnessBridge {
250
269
  await this.#state.markSeen(messageId);
251
270
  return;
252
271
  }
253
- const compactCommand = await runCompactCommand(
254
- text,
255
- this.#harness,
256
- this.#state,
257
- key,
258
- { signal: this.#signal },
259
- );
272
+ const compactCommand = hasImages
273
+ ? null
274
+ : await runCompactCommand(
275
+ text,
276
+ this.#harness,
277
+ this.#state,
278
+ key,
279
+ { signal: this.#signal },
280
+ );
260
281
  if (compactCommand) {
261
282
  await this.#send(sender, compactCommand.message, contextToken, runId);
262
283
  await this.#state.markSeen(messageId);
263
284
  return;
264
285
  }
265
286
 
287
+ const content = hasImages
288
+ ? await promptContentForMessage(promptMessage, { signal: this.#signal })
289
+ : undefined;
266
290
  let answer;
267
291
  try {
268
292
  ({ answer } = await askInWorkspaceSession({
269
293
  harness: this.#harness,
270
294
  state: this.#state,
271
295
  key,
272
- text,
296
+ ...(hasImages ? { content } : { text }),
273
297
  createOptions: { signal: this.#signal },
274
298
  existsOptions: { signal: this.#signal },
275
299
  askOptions: {
@@ -300,7 +324,12 @@ export class WeixinHarnessBridge {
300
324
  this.#status.lastError = error?.message ?? String(error);
301
325
  this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
302
326
  try {
303
- await this.#send(sender, '消息处理失败,请稍后重试。', contextToken, runId);
327
+ await this.#send(
328
+ sender,
329
+ imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
330
+ contextToken,
331
+ runId,
332
+ );
304
333
  await this.#state.markSeen(messageId);
305
334
  } catch (sendError) {
306
335
  this.#logger.error?.('[dsh-weixin] failed to send the safe error reply:', sendError);
@@ -326,7 +355,7 @@ export class WeixinHarnessBridge {
326
355
  const text = nonEmptyString(extractWeixinText(message));
327
356
  const contextToken = nonEmptyString(message?.context_token) ?? undefined;
328
357
  const runId = nonEmptyString(message?.run_id) ?? undefined;
329
- if (!text) {
358
+ if (!text || hasWeixinImageItems(message)) {
330
359
  await this.#send(
331
360
  expected.actor,
332
361
  '请用文字回答当前问题。',
@@ -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 },