@xmanrui/dsh-im 0.8.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.
Files changed (48) hide show
  1. package/README.en.md +8 -4
  2. package/README.md +8 -4
  3. package/lib/client.js +16 -14
  4. package/lib/index.js +123 -121
  5. package/package.json +1 -1
  6. package/plugin-src/client/styles.js +15 -14
  7. package/plugin-src/host/channels/dingtalk/index.mjs +1 -1
  8. package/plugin-src/host/channels/dingtalk/production.mjs +3 -0
  9. package/plugin-src/host/channels/discord/index.mjs +1 -1
  10. package/plugin-src/host/channels/feishu/index.mjs +1 -1
  11. package/plugin-src/host/channels/feishu/production.mjs +3 -0
  12. package/plugin-src/host/channels/qq/index.mjs +1 -1
  13. package/plugin-src/host/channels/qq/production.mjs +3 -0
  14. package/plugin-src/host/channels/shared/production.mjs +3 -0
  15. package/plugin-src/host/channels/slack/index.mjs +1 -1
  16. package/plugin-src/host/channels/slack/production.mjs +3 -0
  17. package/plugin-src/host/channels/telegram/index.mjs +1 -1
  18. package/plugin-src/host/channels/wecom/index.mjs +1 -1
  19. package/plugin-src/host/channels/wecom/production.mjs +3 -0
  20. package/plugin-src/host/channels/weixin/index.mjs +1 -1
  21. package/plugin-src/host/channels/weixin/production.mjs +3 -0
  22. package/plugin-src/host/channels/whatsapp/index.mjs +1 -1
  23. package/plugin-src/host/channels/whatsapp/production.mjs +3 -0
  24. package/plugin-src/host/harness-command-executor.mjs +21 -0
  25. package/plugin-src/host/index.mjs +1 -1
  26. package/src/channels/dingtalk/dingtalk-api.mjs +82 -1
  27. package/src/channels/dingtalk/dingtalk-bridge.mjs +162 -13
  28. package/src/channels/discord/discord-api.mjs +1 -1
  29. package/src/channels/discord/discord-runtime.mjs +44 -1
  30. package/src/channels/feishu/bridge.mjs +45 -11
  31. package/src/channels/feishu/message-utils.mjs +142 -8
  32. package/src/channels/feishu/plugin-controller.mjs +1 -0
  33. package/src/channels/qq/qq-bridge.mjs +107 -13
  34. package/src/channels/shared/bot-workspace-store.mjs +13 -0
  35. package/src/channels/shared/compact-command.mjs +95 -0
  36. package/src/channels/shared/harness-client.mjs +32 -2
  37. package/src/channels/shared/image-prompt.mjs +268 -0
  38. package/src/channels/shared/text-harness-bridge.mjs +49 -9
  39. package/src/channels/shared/workspace-session.mjs +2 -1
  40. package/src/channels/slack/manifest.mjs +1 -0
  41. package/src/channels/slack/slack-api.mjs +95 -0
  42. package/src/channels/slack/slack-runtime.mjs +24 -3
  43. package/src/channels/telegram/telegram-api.mjs +37 -0
  44. package/src/channels/telegram/telegram-runtime.mjs +71 -9
  45. package/src/channels/wecom/wecom-bridge.mjs +163 -16
  46. package/src/channels/weixin/weixin-api.mjs +98 -2
  47. package/src/channels/weixin/weixin-bridge.mjs +55 -12
  48. package/src/channels/whatsapp/whatsapp-runtime.mjs +144 -1
@@ -1,4 +1,5 @@
1
1
  import {
2
+ extractWeixinImages,
2
3
  extractWeixinText,
3
4
  splitWeixinText,
4
5
  weixinMessageId,
@@ -9,16 +10,23 @@ import {
9
10
  validHarnessQuestion,
10
11
  } from '../shared/harness-question.mjs';
11
12
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
13
+ import { runCompactCommand } from '../shared/compact-command.mjs';
12
14
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
13
15
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
16
+ import {
17
+ hasInboundImages,
18
+ imagePromptUserMessage,
19
+ promptContentForMessage,
20
+ } from '../shared/image-prompt.mjs';
14
21
 
15
22
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
16
23
 
17
24
  const HELP_TEXT = [
18
25
  '微信已连接 DeepSeek Harness。',
19
26
  '',
20
- '直接发送文字或带文字识别结果的语音即可继续当前会话。',
27
+ '直接发送文字、图片或带文字识别结果的语音即可继续当前会话。',
21
28
  '/new 开启一个全新会话',
29
+ '/compact 压缩当前会话的较早上下文',
22
30
  '/workspace 工作区绝对路径 切换工作区',
23
31
  '/workspacelist 列出工作区绝对路径',
24
32
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
@@ -35,9 +43,15 @@ function nonEmptyString(value) {
35
43
  return typeof value === 'string' && value.trim() ? value.trim() : null;
36
44
  }
37
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
+
38
51
  function canClaimInteractionReply(message, pending) {
39
52
  return pending.questions[pending.index]
40
53
  && nonEmptyString(message?.from_user_id) === pending.actor
54
+ && !hasWeixinImageItems(message)
41
55
  && nonEmptyString(extractWeixinText(message));
42
56
  }
43
57
 
@@ -122,7 +136,7 @@ export class WeixinHarnessBridge {
122
136
  key,
123
137
  actor: sender,
124
138
  messageId,
125
- text: extractWeixinText(message),
139
+ text: hasWeixinImageItems(message) ? '' : extractWeixinText(message),
126
140
  addressed: true,
127
141
  hasPendingQuestion: Boolean(pending),
128
142
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -214,33 +228,40 @@ export class WeixinHarnessBridge {
214
228
 
215
229
  const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
216
230
  const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
217
- const text = extractWeixinText(message);
231
+ const text = extractWeixinText(message) ?? '';
218
232
  try {
219
- if (!text) {
220
- 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);
221
240
  await this.#state.markSeen(messageId);
222
241
  return;
223
242
  }
224
243
 
225
244
  const command = text.trim().toLowerCase();
226
- if (command === '/help') {
245
+ if (!hasImages && command === '/help') {
227
246
  await this.#send(sender, HELP_TEXT, contextToken, runId);
228
247
  await this.#state.markSeen(messageId);
229
248
  return;
230
249
  }
231
- if (command === '/status') {
250
+ if (!hasImages && command === '/status') {
232
251
  await this.#harness.ensureRunning({ signal: this.#signal });
233
252
  await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
234
253
  await this.#state.markSeen(messageId);
235
254
  return;
236
255
  }
237
- if (command === '/new') {
256
+ if (!hasImages && command === '/new') {
238
257
  await this.#state.clearSession(key);
239
258
  await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
240
259
  await this.#state.markSeen(messageId);
241
260
  return;
242
261
  }
243
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
262
+ const workspaceCommand = hasImages
263
+ ? null
264
+ : await runWorkspaceCommand(text, this.#harness, key);
244
265
  if (workspaceCommand) {
245
266
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
246
267
  await this.#send(sender, reply, contextToken, runId);
@@ -248,14 +269,31 @@ export class WeixinHarnessBridge {
248
269
  await this.#state.markSeen(messageId);
249
270
  return;
250
271
  }
272
+ const compactCommand = hasImages
273
+ ? null
274
+ : await runCompactCommand(
275
+ text,
276
+ this.#harness,
277
+ this.#state,
278
+ key,
279
+ { signal: this.#signal },
280
+ );
281
+ if (compactCommand) {
282
+ await this.#send(sender, compactCommand.message, contextToken, runId);
283
+ await this.#state.markSeen(messageId);
284
+ return;
285
+ }
251
286
 
287
+ const content = hasImages
288
+ ? await promptContentForMessage(promptMessage, { signal: this.#signal })
289
+ : undefined;
252
290
  let answer;
253
291
  try {
254
292
  ({ answer } = await askInWorkspaceSession({
255
293
  harness: this.#harness,
256
294
  state: this.#state,
257
295
  key,
258
- text,
296
+ ...(hasImages ? { content } : { text }),
259
297
  createOptions: { signal: this.#signal },
260
298
  existsOptions: { signal: this.#signal },
261
299
  askOptions: {
@@ -286,7 +324,12 @@ export class WeixinHarnessBridge {
286
324
  this.#status.lastError = error?.message ?? String(error);
287
325
  this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
288
326
  try {
289
- await this.#send(sender, '消息处理失败,请稍后重试。', contextToken, runId);
327
+ await this.#send(
328
+ sender,
329
+ imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
330
+ contextToken,
331
+ runId,
332
+ );
290
333
  await this.#state.markSeen(messageId);
291
334
  } catch (sendError) {
292
335
  this.#logger.error?.('[dsh-weixin] failed to send the safe error reply:', sendError);
@@ -312,7 +355,7 @@ export class WeixinHarnessBridge {
312
355
  const text = nonEmptyString(extractWeixinText(message));
313
356
  const contextToken = nonEmptyString(message?.context_token) ?? undefined;
314
357
  const runId = nonEmptyString(message?.run_id) ?? undefined;
315
- if (!text) {
358
+ if (!text || hasWeixinImageItems(message)) {
316
359
  await this.#send(
317
360
  expected.actor,
318
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 },