@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
@@ -9,8 +9,14 @@ import {
9
9
  validHarnessQuestion,
10
10
  } from '../shared/harness-question.mjs';
11
11
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
12
+ import { runCompactCommand } from '../shared/compact-command.mjs';
12
13
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
13
14
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
15
+ import {
16
+ hasInboundImages,
17
+ imagePromptUserMessage,
18
+ promptContentForMessage,
19
+ } from '../shared/image-prompt.mjs';
14
20
 
15
21
  const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
16
22
  const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
@@ -19,8 +25,9 @@ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无
19
25
  const HELP_TEXT = [
20
26
  '钉钉机器人已连接 DeepSeek Harness。',
21
27
  '',
22
- '直接发送文字即可继续当前会话。',
28
+ '直接发送文字或图片即可继续当前会话。',
23
29
  '/new 开启一个全新会话',
30
+ '/compact 压缩当前会话的较早上下文',
24
31
  '/workspace 工作区绝对路径 切换工作区',
25
32
  '/workspacelist 列出工作区绝对路径',
26
33
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
@@ -33,10 +40,123 @@ function nonEmptyString(value) {
33
40
  return typeof value === 'string' && value.trim() ? value.trim() : null;
34
41
  }
35
42
 
43
+ function safeErrorDiagnostic(error) {
44
+ const chain = [];
45
+ const seen = new Set();
46
+ let current = error;
47
+ while (current && typeof current === 'object' && chain.length < 3 && !seen.has(current)) {
48
+ seen.add(current);
49
+ const name = nonEmptyString(current.name)?.slice(0, 80);
50
+ const code = nonEmptyString(current.code)?.slice(0, 80);
51
+ const providerCode = nonEmptyString(current.providerCode)?.slice(0, 160);
52
+ const status = Number.isInteger(current.status) ? current.status : undefined;
53
+ chain.push({
54
+ ...(name ? { name } : {}),
55
+ ...(code ? { code } : {}),
56
+ ...(providerCode ? { providerCode } : {}),
57
+ ...(status ? { status } : {}),
58
+ });
59
+ current = current.cause;
60
+ }
61
+ return chain;
62
+ }
63
+
64
+ function dingtalkImageErrorUserMessage(error) {
65
+ let current = error;
66
+ const seen = new Set();
67
+ while (current && typeof current === 'object' && !seen.has(current)) {
68
+ seen.add(current);
69
+ if (current.code === 'image-download-address-failed') {
70
+ return '钉钉未能换取图片下载地址,请重新发送;若持续失败,请检查机器人的“企业内机器人发送消息权限”。';
71
+ }
72
+ if (current.code === 'invalid-image-download') {
73
+ return '钉钉没有返回图片下载地址,请重新发送。';
74
+ }
75
+ if (current.code === 'image-content-download-failed') {
76
+ return '钉钉返回的图片临时地址无法读取,请重新发送。';
77
+ }
78
+ current = current.cause;
79
+ }
80
+ return imagePromptUserMessage(error);
81
+ }
82
+
36
83
  function senderStaffId(message) {
37
84
  return nonEmptyString(message?.senderStaffId) ?? nonEmptyString(message?.senderId);
38
85
  }
39
86
 
87
+ function parsedMessageContent(message) {
88
+ if (message?.content && typeof message.content === 'object') return message.content;
89
+ if (typeof message?.content !== 'string') return null;
90
+ try {
91
+ const parsed = JSON.parse(message.content);
92
+ return parsed && typeof parsed === 'object' ? parsed : null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function richTextEntries(content) {
99
+ const entries = content?.richText ?? content?.rich_text;
100
+ return Array.isArray(entries) ? entries : [];
101
+ }
102
+
103
+ function richTextEntryText(entry) {
104
+ if (typeof entry?.text === 'string') return nonEmptyString(entry.text);
105
+ if (typeof entry?.text?.content === 'string') return nonEmptyString(entry.text.content);
106
+ if (String(entry?.type).toLowerCase() === 'text' && typeof entry?.content === 'string') {
107
+ return nonEmptyString(entry.content);
108
+ }
109
+ return null;
110
+ }
111
+
112
+ function downloadCodeFor(value) {
113
+ return nonEmptyString(value?.downloadCode) ?? nonEmptyString(value?.pictureDownloadCode);
114
+ }
115
+
116
+ /** Normalize DingTalk picture and richText callbacks into lazy image references. */
117
+ export function dingtalkInboundMessage(message, {
118
+ api,
119
+ clientId,
120
+ clientSecret,
121
+ } = {}) {
122
+ const msgtype = String(message?.msgtype ?? '').toLowerCase();
123
+ const content = parsedMessageContent(message);
124
+ const richEntries = msgtype === 'richtext' ? richTextEntries(content) : [];
125
+ const text = msgtype === 'text'
126
+ ? nonEmptyString(message?.text?.content) ?? ''
127
+ : richEntries.map(richTextEntryText).filter(Boolean).join('\n');
128
+ const imageCodes = [];
129
+ if (msgtype === 'picture') {
130
+ const code = downloadCodeFor(content);
131
+ if (code) imageCodes.push(code);
132
+ } else if (msgtype === 'richtext') {
133
+ for (const entry of richEntries) {
134
+ if (String(entry?.type ?? '').toLowerCase() !== 'picture') continue;
135
+ const code = downloadCodeFor(entry);
136
+ if (code) imageCodes.push(code);
137
+ }
138
+ }
139
+ return {
140
+ content: text,
141
+ images: imageCodes.map((downloadCode, index) => ({
142
+ name: index === 0 ? 'image' : `image-${index + 1}`,
143
+ load: ({ signal, maxBytes }) => {
144
+ if (typeof api?.downloadImage !== 'function') {
145
+ throw new Error('DingTalk API does not support image downloads');
146
+ }
147
+ return api.downloadImage({
148
+ clientId,
149
+ clientSecret,
150
+ robotCode: message?.robotCode,
151
+ downloadCode,
152
+ signal,
153
+ maxBytes,
154
+ });
155
+ },
156
+ })),
157
+ };
158
+ }
159
+
40
160
  function conversationKey(message, sender) {
41
161
  if (String(message?.conversationType) === '2') {
42
162
  const conversationId = nonEmptyString(message?.conversationId);
@@ -315,38 +435,63 @@ export class DingtalkHarnessBridge {
315
435
  return;
316
436
  }
317
437
 
318
- const text = message?.msgtype === 'text' ? nonEmptyString(message?.text?.content) : null;
438
+ const promptMessage = dingtalkInboundMessage(message, {
439
+ api: this.#api,
440
+ clientId: this.#clientId,
441
+ clientSecret: this.#clientSecret,
442
+ });
443
+ const text = promptMessage.content;
444
+ const hasImages = hasInboundImages(promptMessage);
445
+ const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
319
446
  let cardStream = null;
320
447
  let cardStarted = false;
321
448
  try {
322
- if (!text) {
323
- await this.#send(sessionWebhook, '目前仅支持文字消息。');
449
+ if (!text && !hasImages) {
450
+ await this.#send(sessionWebhook, '目前支持文字和图片消息。');
324
451
  return;
325
452
  }
326
453
 
327
454
  const command = text.toLowerCase();
328
- if (command === '/help') {
455
+ if (isPlainText && !hasImages && command === '/help') {
329
456
  await this.#send(sessionWebhook, HELP_TEXT);
330
457
  return;
331
458
  }
332
- if (command === '/status') {
459
+ if (isPlainText && !hasImages && command === '/status') {
333
460
  await this.#harness.ensureRunning({ signal: this.#signal });
334
461
  await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
335
462
  return;
336
463
  }
337
- if (command === '/new') {
464
+ if (isPlainText && !hasImages && command === '/new') {
338
465
  await this.#state.clearSession(key);
339
466
  await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
340
467
  return;
341
468
  }
342
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
469
+ const workspaceCommand = isPlainText && !hasImages
470
+ ? await runWorkspaceCommand(text, this.#harness, key)
471
+ : null;
343
472
  if (workspaceCommand) {
344
473
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
345
474
  await this.#send(sessionWebhook, reply);
346
475
  }
347
476
  return;
348
477
  }
478
+ const compactCommand = isPlainText && !hasImages
479
+ ? await runCompactCommand(
480
+ text,
481
+ this.#harness,
482
+ this.#state,
483
+ key,
484
+ { signal: this.#signal },
485
+ )
486
+ : null;
487
+ if (compactCommand) {
488
+ await this.#send(sessionWebhook, compactCommand.message);
489
+ return;
490
+ }
349
491
 
492
+ const content = hasImages
493
+ ? await promptContentForMessage(promptMessage, { signal: this.#signal })
494
+ : undefined;
350
495
  if (typeof this.#api.createAiCard === 'function'
351
496
  && typeof this.#api.updateAiCard === 'function'
352
497
  && typeof this.#api.finishAiCard === 'function') {
@@ -364,7 +509,7 @@ export class DingtalkHarnessBridge {
364
509
  harness: this.#harness,
365
510
  state: this.#state,
366
511
  key,
367
- text,
512
+ ...(hasImages ? { content } : { text }),
368
513
  createOptions: { signal: this.#signal },
369
514
  existsOptions: { signal: this.#signal },
370
515
  askOptions: {
@@ -387,13 +532,17 @@ export class DingtalkHarnessBridge {
387
532
  increment(this.#status, 'messagesReplied');
388
533
  this.#status.lastReplyAt = new Date().toISOString();
389
534
  this.#status.lastError = null;
390
- } catch {
535
+ } catch (error) {
391
536
  if (this.#signal?.aborted) return;
392
537
  this.#status.lastError = '钉钉消息处理失败。';
393
- this.#logger.error?.('[dsh-dingtalk] failed to process an inbound message');
538
+ this.#logger.error?.(
539
+ '[dsh-dingtalk] failed to process an inbound message',
540
+ safeErrorDiagnostic(error),
541
+ );
394
542
  try {
395
- const streamed = cardStarted && await cardStream.finish(CARD_ERROR_TEXT);
396
- if (!streamed) await this.#send(sessionWebhook, CARD_ERROR_TEXT);
543
+ const errorText = dingtalkImageErrorUserMessage(error) ?? CARD_ERROR_TEXT;
544
+ const streamed = cardStarted && await cardStream.finish(errorText);
545
+ if (!streamed) await this.#send(sessionWebhook, errorText);
397
546
  } catch {
398
547
  this.#logger.error?.('[dsh-dingtalk] failed to send the safe error reply');
399
548
  }
@@ -108,7 +108,7 @@ export class DiscordApi {
108
108
  headers: {
109
109
  authorization: `Bot ${this.#token}`,
110
110
  'content-type': 'application/json',
111
- 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.8.0)',
111
+ 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.10.0)',
112
112
  },
113
113
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
114
114
  signal: requestSignal(signal, timeoutMs),
@@ -1,9 +1,19 @@
1
1
  import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
2
+ import { fetchImageBuffer } from '../shared/image-prompt.mjs';
2
3
  import { DiscordApi } from './discord-api.mjs';
3
4
  import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
4
5
 
5
6
  const DISCORD_GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12);
6
7
  const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
8
+ const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
9
+ const IMAGE_FILE_TYPES = new Map([
10
+ ['.jpg', 'image/jpeg'],
11
+ ['.jpeg', 'image/jpeg'],
12
+ ['.png', 'image/png'],
13
+ ['.webp', 'image/webp'],
14
+ ['.gif', 'image/gif'],
15
+ ]);
16
+ const DISCORD_IMAGE_HOSTS = Object.freeze(['cdn.discordapp.com']);
7
17
 
8
18
  function socketUrl(value) {
9
19
  const url = new URL(value);
@@ -48,7 +58,37 @@ function stripBotMention(text, botId) {
48
58
  return text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
49
59
  }
50
60
 
51
- export function normalizeDiscordMessage(message, botId) {
61
+ function attachmentMediaType(attachment) {
62
+ const value = typeof attachment?.content_type === 'string'
63
+ ? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : '';
64
+ if (IMAGE_MEDIA_TYPES.has(value)) return value;
65
+ const filename = typeof attachment?.filename === 'string' ? attachment.filename.toLowerCase() : '';
66
+ for (const [extension, mediaType] of IMAGE_FILE_TYPES) {
67
+ if (filename.endsWith(extension)) return mediaType;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ function attachmentSize(value) {
73
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
74
+ }
75
+
76
+ function discordImageSource(attachment, fetchImpl) {
77
+ const mediaType = attachmentMediaType(attachment);
78
+ if (!mediaType || typeof attachment?.url !== 'string') return null;
79
+ return {
80
+ name: typeof attachment.filename === 'string' ? attachment.filename : undefined,
81
+ mediaType,
82
+ size: attachmentSize(attachment.size),
83
+ load: (options) => fetchImageBuffer(attachment.url, {
84
+ ...options,
85
+ fetchImpl,
86
+ allowedHosts: DISCORD_IMAGE_HOSTS,
87
+ }),
88
+ };
89
+ }
90
+
91
+ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
52
92
  if (!message?.id || !message?.channel_id || !message?.author?.id) return null;
53
93
  const direct = !message.guild_id;
54
94
  const addressed = direct
@@ -60,6 +100,9 @@ export function normalizeDiscordMessage(message, botId) {
60
100
  kind: direct ? 'direct' : 'group',
61
101
  conversationId: String(message.channel_id),
62
102
  content: stripBotMention(message.content ?? '', botId),
103
+ images: Array.isArray(message.attachments)
104
+ ? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
105
+ : [],
63
106
  addressed,
64
107
  replyTarget: {
65
108
  channelId: String(message.channel_id),
@@ -1,16 +1,23 @@
1
1
  import {
2
2
  conversationKey,
3
+ extractInboundMessage,
3
4
  extractText,
4
5
  isAllowedSender,
5
6
  isBotSender,
6
7
  splitText,
7
8
  } from './message-utils.mjs';
9
+ import {
10
+ hasInboundImages,
11
+ imagePromptUserMessage,
12
+ promptContentForMessage,
13
+ } from '../shared/image-prompt.mjs';
8
14
  import {
9
15
  harnessAnswerForQuestion,
10
16
  harnessQuestionText,
11
17
  validHarnessQuestion,
12
18
  } from '../shared/harness-question.mjs';
13
19
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
20
+ import { runCompactCommand } from '../shared/compact-command.mjs';
14
21
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
15
22
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
16
23
 
@@ -20,8 +27,9 @@ const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
20
27
  const HELP_TEXT = [
21
28
  '北汇星河 AIOS 已连接 DeepSeek Harness。',
22
29
  '',
23
- '直接发送问题即可继续当前会话。',
30
+ '直接发送文字或图片即可继续当前会话。',
24
31
  '/new 开启一个全新会话',
32
+ '/compact 压缩当前会话的较早上下文',
25
33
  '/workspace 工作区绝对路径 切换工作区',
26
34
  '/workspacelist 列出工作区绝对路径',
27
35
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
@@ -271,7 +279,8 @@ export class FeishuHarnessBridge {
271
279
  await this.#finishReaction(messageId, processingReaction, 'ERROR');
272
280
  await this.#send(
273
281
  event.message.chat_id,
274
- '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
282
+ imagePromptUserMessage(error)
283
+ ?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
275
284
  ).catch(() => undefined);
276
285
  }
277
286
 
@@ -295,37 +304,55 @@ export class FeishuHarnessBridge {
295
304
  this.#status.messagesReceived += 1;
296
305
  }
297
306
 
298
- const text = extractText(event);
299
- if (!text) {
300
- await this.#send(event.message.chat_id, '目前仅支持文字消息。');
307
+ const message = extractInboundMessage(event, this.#client);
308
+ const text = message.content;
309
+ const hasImages = hasInboundImages(message);
310
+ const commandText = event.message.message_type === 'text' && !hasImages ? text : null;
311
+ if (!text && !hasImages) {
312
+ await this.#send(event.message.chat_id, '目前支持文字和图片消息。');
301
313
  return;
302
314
  }
303
315
 
304
- if (text === '/help') {
316
+ if (commandText === '/help') {
305
317
  await this.#send(event.message.chat_id, HELP_TEXT);
306
318
  return;
307
319
  }
308
- if (text === '/new') {
320
+ if (commandText === '/new') {
309
321
  await this.#state.clearSession(key);
310
322
  await this.#send(event.message.chat_id, '已开启全新 Harness 会话。');
311
323
  return;
312
324
  }
313
- if (text === '/status') {
325
+ if (commandText === '/status') {
314
326
  await this.#harness.ensureRunning({ signal: this.#signal });
315
327
  await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
316
328
  return;
317
329
  }
318
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
330
+ const workspaceCommand = commandText === null
331
+ ? null
332
+ : await runWorkspaceCommand(text, this.#harness, key);
319
333
  if (workspaceCommand) {
320
334
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
321
335
  await this.#send(event.message.chat_id, reply);
322
336
  }
323
337
  return;
324
338
  }
339
+ const compactCommand = commandText === null
340
+ ? null
341
+ : await runCompactCommand(
342
+ commandText,
343
+ this.#harness,
344
+ this.#state,
345
+ key,
346
+ { signal: this.#signal },
347
+ );
348
+ if (compactCommand) {
349
+ await this.#send(event.message.chat_id, compactCommand.message);
350
+ return;
351
+ }
325
352
 
326
353
  this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
327
354
  try {
328
- await this.#answerWithStream(event, key, text);
355
+ await this.#answerWithStream(event, key, message);
329
356
  this.#status.messagesReplied += 1;
330
357
  this.#status.lastReplyAt = new Date().toISOString();
331
358
  this.#status.lastError = null;
@@ -349,15 +376,20 @@ export class FeishuHarnessBridge {
349
376
  };
350
377
  }
351
378
 
352
- async #answerWithStream(event, key, text) {
379
+ async #answerWithStream(event, key, message) {
353
380
  const chatId = event.message.chat_id;
354
381
  const messageId = event.message.message_id;
382
+ const text = message.content;
383
+ const content = hasInboundImages(message)
384
+ ? await promptContentForMessage(message, { signal: this.#signal })
385
+ : undefined;
355
386
  if (!this.#channel?.stream) {
356
387
  const { answer } = await askInWorkspaceSession({
357
388
  harness: this.#harness,
358
389
  state: this.#state,
359
390
  key,
360
391
  text,
392
+ content,
361
393
  createOptions: { signal: this.#signal },
362
394
  existsOptions: { signal: this.#signal },
363
395
  askOptions: this.#interactionAskOptions(event, key),
@@ -385,6 +417,7 @@ export class FeishuHarnessBridge {
385
417
  state: this.#state,
386
418
  key,
387
419
  text,
420
+ content,
388
421
  createOptions: { signal: this.#signal },
389
422
  existsOptions: { signal: this.#signal },
390
423
  askOptions,
@@ -412,6 +445,7 @@ export class FeishuHarnessBridge {
412
445
  state: this.#state,
413
446
  key,
414
447
  text,
448
+ content,
415
449
  createOptions: { signal: this.#signal },
416
450
  existsOptions: { signal: this.#signal },
417
451
  askOptions: this.#interactionAskOptions(event, key),
@@ -1,3 +1,5 @@
1
+ import { ImagePromptError } from '../shared/image-prompt.mjs';
2
+
1
3
  export function conversationKey(event) {
2
4
  const chatType = event?.message?.chat_type;
3
5
  if (chatType === 'p2p') {
@@ -10,19 +12,151 @@ export function conversationKey(event) {
10
12
  return `group:${chatId}`;
11
13
  }
12
14
 
13
- export function extractText(event) {
14
- if (event?.message?.message_type !== 'text') return null;
15
- let parsed;
15
+ function parsedMessageContent(event) {
16
+ const value = event?.message?.content;
17
+ if (value && typeof value === 'object') return value;
18
+ if (typeof value !== 'string') return null;
16
19
  try {
17
- parsed = JSON.parse(event.message.content);
20
+ const parsed = JSON.parse(value);
21
+ return parsed && typeof parsed === 'object' ? parsed : null;
18
22
  } catch {
19
23
  return null;
20
24
  }
21
- let text = typeof parsed.text === 'string' ? parsed.text : '';
22
- for (const mention of event.message.mentions ?? []) {
23
- if (typeof mention.key === 'string' && mention.key) text = text.replaceAll(mention.key, '');
25
+ }
26
+
27
+ function withoutMentions(text, event) {
28
+ let result = typeof text === 'string' ? text : '';
29
+ for (const mention of event?.message?.mentions ?? []) {
30
+ if (typeof mention?.key === 'string' && mention.key) {
31
+ result = result.replaceAll(mention.key, '');
32
+ }
33
+ }
34
+ return result.trim();
35
+ }
36
+
37
+ export function extractText(event) {
38
+ if (event?.message?.message_type !== 'text') return null;
39
+ const parsed = parsedMessageContent(event);
40
+ return parsed ? withoutMentions(parsed.text, event) : null;
41
+ }
42
+
43
+ function nonEmptyString(value) {
44
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
45
+ }
46
+
47
+ function postContent(event, parsed = parsedMessageContent(event)) {
48
+ if (event?.message?.message_type !== 'post') return null;
49
+ if (!parsed) return null;
50
+
51
+ const lines = [];
52
+ const title = nonEmptyString(withoutMentions(parsed.title, event));
53
+ if (title) lines.push(title);
54
+ const imageKeys = [];
55
+ for (const paragraph of Array.isArray(parsed.content) ? parsed.content : []) {
56
+ if (!Array.isArray(paragraph)) continue;
57
+ let visibleText = '';
58
+ for (const element of paragraph) {
59
+ const tag = String(element?.tag ?? '').toLowerCase();
60
+ if (tag === 'img') {
61
+ const key = nonEmptyString(element?.image_key);
62
+ if (key) imageKeys.push(key);
63
+ } else if (tag === 'text' || tag === 'a' || tag === 'link') {
64
+ if (typeof element?.text === 'string') visibleText += element.text;
65
+ }
66
+ }
67
+ const line = nonEmptyString(withoutMentions(visibleText, event));
68
+ if (line) lines.push(line);
69
+ }
70
+
71
+ return {
72
+ text: lines.join('\n'),
73
+ imageKeys,
74
+ };
75
+ }
76
+
77
+ function headerValue(headers, name) {
78
+ if (typeof headers?.get === 'function') return headers.get(name);
79
+ return headers?.[name] ?? headers?.[name.toLowerCase()] ?? null;
80
+ }
81
+
82
+ function declaredSize(headers) {
83
+ const header = headerValue(headers, 'content-length');
84
+ if (header === null || header === undefined || header === '') return null;
85
+ const value = Number(header);
86
+ return Number.isFinite(value) && value >= 0 ? value : null;
87
+ }
88
+
89
+ async function readBoundedStream(stream, { signal, maxBytes }) {
90
+ if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
91
+ throw new Error('Feishu image download returned no readable stream');
24
92
  }
25
- return text.trim();
93
+ signal?.throwIfAborted();
94
+ const abort = () => stream.destroy?.(
95
+ signal.reason ?? new DOMException('Feishu image download aborted', 'AbortError'),
96
+ );
97
+ signal?.addEventListener('abort', abort, { once: true });
98
+ const chunks = [];
99
+ let size = 0;
100
+ try {
101
+ for await (const chunk of stream) {
102
+ signal?.throwIfAborted();
103
+ const data = Buffer.from(chunk);
104
+ size += data.length;
105
+ if (size > maxBytes) {
106
+ stream.destroy?.();
107
+ throw new ImagePromptError(
108
+ 'image-too-large',
109
+ `Feishu image exceeds ${maxBytes} bytes`,
110
+ '图片超过 5 MB,请压缩后重试。',
111
+ );
112
+ }
113
+ chunks.push(data);
114
+ }
115
+ signal?.throwIfAborted();
116
+ return Buffer.concat(chunks, size);
117
+ } finally {
118
+ signal?.removeEventListener('abort', abort);
119
+ }
120
+ }
121
+
122
+ function feishuImageSource(event, client, key) {
123
+ return {
124
+ async load({ signal, maxBytes }) {
125
+ signal?.throwIfAborted();
126
+ const resource = await client?.im?.v1?.messageResource?.get?.({
127
+ path: {
128
+ message_id: event.message.message_id,
129
+ file_key: key,
130
+ },
131
+ params: { type: 'image' },
132
+ });
133
+ signal?.throwIfAborted();
134
+ const size = declaredSize(resource?.headers);
135
+ if (size !== null && size > maxBytes) {
136
+ resource?.getReadableStream?.().destroy?.();
137
+ throw new ImagePromptError(
138
+ 'image-too-large',
139
+ `Feishu image declares ${size} bytes; the limit is ${maxBytes}`,
140
+ '图片超过 5 MB,请压缩后重试。',
141
+ );
142
+ }
143
+ return readBoundedStream(resource?.getReadableStream?.(), { signal, maxBytes });
144
+ },
145
+ };
146
+ }
147
+
148
+ export function extractInboundMessage(event, client) {
149
+ const messageType = event?.message?.message_type;
150
+ const parsed = parsedMessageContent(event);
151
+ const post = postContent(event, parsed);
152
+ const standaloneImageKey = messageType === 'image'
153
+ ? nonEmptyString(parsed?.image_key)
154
+ : null;
155
+ const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
156
+ return {
157
+ content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
158
+ images: imageKeys.map((key) => feishuImageSource(event, client, key)),
159
+ };
26
160
  }
27
161
 
28
162
  export function splitText(text, maxChars = 9000) {
@@ -5,6 +5,7 @@ export const FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
5
5
  export const REQUIRED_TENANT_SCOPES = Object.freeze([
6
6
  'im:message.p2p_msg:readonly',
7
7
  'im:message.group_at_msg:readonly',
8
+ 'im:message:readonly',
8
9
  'im:message:send_as_bot',
9
10
  'im:message.reactions:write_only',
10
11
  'im:message:recall',