@xmanrui/dsh-im 1.2.0 → 1.4.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 (33) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/lib/index.js +176 -163
  4. package/package.json +1 -1
  5. package/plugin-src/host/channels/dingtalk/production.mjs +3 -1
  6. package/plugin-src/host/channels/feishu/production.mjs +3 -1
  7. package/plugin-src/host/channels/qq/production.mjs +3 -1
  8. package/plugin-src/host/channels/shared/production.mjs +3 -1
  9. package/plugin-src/host/channels/slack/production.mjs +3 -1
  10. package/plugin-src/host/channels/wecom/production.mjs +3 -1
  11. package/plugin-src/host/channels/weixin/production.mjs +3 -1
  12. package/plugin-src/host/channels/whatsapp/production.mjs +3 -1
  13. package/plugin-src/host/harness-session-coordinator.mjs +32 -5
  14. package/src/channels/dingtalk/dingtalk-api.mjs +93 -27
  15. package/src/channels/dingtalk/dingtalk-bridge.mjs +60 -13
  16. package/src/channels/discord/discord-runtime.mjs +23 -0
  17. package/src/channels/feishu/bridge.mjs +18 -10
  18. package/src/channels/feishu/message-utils.mjs +47 -0
  19. package/src/channels/qq/markdown-reply.mjs +176 -0
  20. package/src/channels/qq/qq-bridge.mjs +124 -55
  21. package/src/channels/shared/file-download.mjs +64 -0
  22. package/src/channels/shared/harness-client.mjs +111 -13
  23. package/src/channels/shared/inbound-file.mjs +206 -0
  24. package/src/channels/shared/text-harness-bridge.mjs +31 -11
  25. package/src/channels/slack/slack-api.mjs +27 -4
  26. package/src/channels/slack/slack-runtime.mjs +55 -5
  27. package/src/channels/telegram/telegram-api.mjs +21 -6
  28. package/src/channels/telegram/telegram-runtime.mjs +24 -3
  29. package/src/channels/wecom/wecom-bridge.mjs +73 -10
  30. package/src/channels/weixin/weixin-api.mjs +45 -0
  31. package/src/channels/weixin/weixin-bridge.mjs +52 -18
  32. package/src/channels/whatsapp/whatsapp-runtime.mjs +42 -2
  33. package/src/channels/whatsapp/whatsapp-web-session.mjs +1 -1
@@ -12,6 +12,10 @@ import {
12
12
  imagePromptUserMessage,
13
13
  promptContentForMessage,
14
14
  } from '../shared/image-prompt.mjs';
15
+ import {
16
+ hasInboundFiles,
17
+ inboundFileUserMessage,
18
+ } from '../shared/inbound-file.mjs';
15
19
  import {
16
20
  harnessAnswerForQuestion,
17
21
  harnessQuestionText,
@@ -91,7 +95,7 @@ const REPAIR_URL_HOSTS = new Set([
91
95
  const HELP_TEXT = [
92
96
  '北汇星河 AIOS 已连接 DeepSeek Harness。',
93
97
  '',
94
- '直接发送文字或图片即可继续当前会话。',
98
+ '直接发送文字、图片或文件即可继续当前会话。',
95
99
  '/new 开启一个全新会话',
96
100
  '/compact 压缩当前会话的较早上下文',
97
101
  '/workspace 工作区绝对路径 切换工作区',
@@ -431,7 +435,7 @@ export class FeishuHarnessBridge {
431
435
  const processingReaction = this.#addReaction(messageId, 'OnIt');
432
436
  const commandMessage = extractInboundMessage(event, this.#client);
433
437
  const commandText = nonEmptyString(commandMessage.content) ?? '';
434
- const commandRunner = isControlCommand(commandText)
438
+ const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
435
439
  ? runControlCommand
436
440
  : (isModelCommand(commandText)
437
441
  ? runModelCommand
@@ -610,7 +614,8 @@ export class FeishuHarnessBridge {
610
614
  await this.#finishReaction(messageId, processingReaction, 'ERROR');
611
615
  await this.#send(
612
616
  event.message.chat_id,
613
- imagePromptUserMessage(error)
617
+ inboundFileUserMessage(error)
618
+ ?? imagePromptUserMessage(error)
614
619
  ?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
615
620
  ).catch(() => undefined);
616
621
  }
@@ -641,6 +646,7 @@ export class FeishuHarnessBridge {
641
646
  {
642
647
  signal: this.#signal,
643
648
  hasImages: hasInboundImages(message),
649
+ hasFiles: hasInboundFiles(message),
644
650
  pendingInteraction: this.#pendingInteractions.has(key)
645
651
  || this.#approvals.hasPending(key),
646
652
  control: { owner: this, key },
@@ -671,9 +677,10 @@ export class FeishuHarnessBridge {
671
677
  const message = extractInboundMessage(event, this.#client);
672
678
  const text = message.content;
673
679
  const hasImages = hasInboundImages(message);
674
- const commandText = event.message.message_type === 'text' && !hasImages ? text : null;
675
- if (!text && !hasImages) {
676
- await this.#send(event.message.chat_id, '目前支持文字和图片消息。');
680
+ const hasFiles = hasInboundFiles(message);
681
+ const commandText = event.message.message_type === 'text' && !hasImages && !hasFiles ? text : null;
682
+ if (!text && !hasImages && !hasFiles) {
683
+ await this.#send(event.message.chat_id, '目前支持文字、图片和文件消息。');
677
684
  return;
678
685
  }
679
686
 
@@ -1603,7 +1610,7 @@ export class FeishuHarnessBridge {
1603
1610
  }
1604
1611
  }
1605
1612
 
1606
- #interactionAskOptions(event, key) {
1613
+ #interactionAskOptions(event, key, files) {
1607
1614
  return {
1608
1615
  timeoutMs: this.#replyTimeoutMs,
1609
1616
  signal: this.#signal,
@@ -1615,6 +1622,7 @@ export class FeishuHarnessBridge {
1615
1622
  requiresMention: event.message.chat_type !== 'p2p',
1616
1623
  }),
1617
1624
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
1625
+ files,
1618
1626
  };
1619
1627
  }
1620
1628
 
@@ -1709,7 +1717,7 @@ export class FeishuHarnessBridge {
1709
1717
  content,
1710
1718
  createOptions: { signal: this.#signal },
1711
1719
  existsOptions: { signal: this.#signal },
1712
- askOptions: this.#interactionAskOptions(event, key),
1720
+ askOptions: this.#interactionAskOptions(event, key, message.files),
1713
1721
  });
1714
1722
  let textReceipt;
1715
1723
  let textSendError = null;
@@ -1749,7 +1757,7 @@ export class FeishuHarnessBridge {
1749
1757
  markdown: async (controller) => {
1750
1758
  promptStarted = true;
1751
1759
  const askOptions = {
1752
- ...this.#interactionAskOptions(event, key),
1760
+ ...this.#interactionAskOptions(event, key, message.files),
1753
1761
  onUpdate: async (update) => {
1754
1762
  await controller.setContent(this.#progressText(update));
1755
1763
  this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
@@ -1821,7 +1829,7 @@ export class FeishuHarnessBridge {
1821
1829
  content,
1822
1830
  createOptions: { signal: this.#signal },
1823
1831
  existsOptions: { signal: this.#signal },
1824
- askOptions: this.#interactionAskOptions(event, key),
1832
+ askOptions: this.#interactionAskOptions(event, key, message.files),
1825
1833
  });
1826
1834
  let textReceipt;
1827
1835
  let textSendError = null;
@@ -125,6 +125,31 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
125
125
  }
126
126
  }
127
127
 
128
+ async function readStream(stream, { signal }) {
129
+ if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
130
+ throw new Error('Feishu file download returned no readable stream');
131
+ }
132
+ signal?.throwIfAborted();
133
+ const abort = () => stream.destroy?.(
134
+ signal.reason ?? new DOMException('Feishu file download aborted', 'AbortError'),
135
+ );
136
+ signal?.addEventListener('abort', abort, { once: true });
137
+ const chunks = [];
138
+ let size = 0;
139
+ try {
140
+ for await (const chunk of stream) {
141
+ signal?.throwIfAborted();
142
+ const data = Buffer.from(chunk);
143
+ size += data.length;
144
+ chunks.push(data);
145
+ }
146
+ signal?.throwIfAborted();
147
+ return Buffer.concat(chunks, size);
148
+ } finally {
149
+ signal?.removeEventListener('abort', abort);
150
+ }
151
+ }
152
+
128
153
  function providerCode(value) {
129
154
  if (!value || typeof value !== 'object') return null;
130
155
  const code = value.code ?? value.error?.code;
@@ -232,6 +257,26 @@ function feishuImageSource(event, client, key) {
232
257
  };
233
258
  }
234
259
 
260
+ function feishuFileSource(event, client, file) {
261
+ const key = nonEmptyString(file?.file_key);
262
+ if (!key) return null;
263
+ return {
264
+ name: nonEmptyString(file?.file_name) ?? 'file',
265
+ async load({ signal } = {}) {
266
+ signal?.throwIfAborted();
267
+ const resource = await client?.im?.v1?.messageResource?.get?.({
268
+ path: {
269
+ message_id: event.message.message_id,
270
+ file_key: key,
271
+ },
272
+ params: { type: 'file' },
273
+ });
274
+ signal?.throwIfAborted();
275
+ return readStream(resource?.getReadableStream?.(), { signal });
276
+ },
277
+ };
278
+ }
279
+
235
280
  export function extractInboundMessage(event, client) {
236
281
  const messageType = event?.message?.message_type;
237
282
  const parsed = parsedMessageContent(event);
@@ -240,9 +285,11 @@ export function extractInboundMessage(event, client) {
240
285
  ? nonEmptyString(parsed?.image_key)
241
286
  : null;
242
287
  const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
288
+ const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null;
243
289
  return {
244
290
  content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
245
291
  images: imageKeys.map((key) => feishuImageSource(event, client, key)),
292
+ files: file ? [file] : [],
246
293
  };
247
294
  }
248
295
 
@@ -0,0 +1,176 @@
1
+ // QQ markdown 回复投递:长文尽量按结构边界切分,以 msg_type=2 发送,
2
+ // 平台拒绝 markdown 时逐条回退纯文本。
3
+
4
+ const DEFAULT_CHUNK_LIMIT = 4_500;
5
+ const CODE_FENCE_OPEN = /^```/;
6
+ const GFM_TABLE_LINE = /^\|.+\|$/;
7
+ const PASSIVE_REPLY_LIMIT = Object.freeze({ c2c: 4, group: 5 });
8
+ const PARTIAL_REPLY_NOTICE = '回答较长,后续内容未能通过 QQ 完整发送,请回复“继续”。';
9
+
10
+ function safeSliceIndex(value, limit) {
11
+ let index = Math.min(limit, value.length);
12
+ const before = value.charCodeAt(index - 1);
13
+ const after = value.charCodeAt(index);
14
+ if (before >= 0xD800 && before <= 0xDBFF && after >= 0xDC00 && after <= 0xDFFF) {
15
+ index -= 1;
16
+ }
17
+ return Math.max(1, index);
18
+ }
19
+
20
+ /**
21
+ * 按换行边界切分 Markdown 文本:
22
+ * - 不在代码块中间断开;
23
+ * - 不在 GFM 表格中间断开;
24
+ * - 超长行在 limit 处硬切,避免单行超限无法投递。
25
+ */
26
+ export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
27
+ const value = typeof text === 'string' ? text : '';
28
+ const bound = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_CHUNK_LIMIT;
29
+ if (value.length <= bound) return value ? [value] : [];
30
+
31
+ const lines = value.split('\n');
32
+ const chunks = [];
33
+ let current = '';
34
+ let inCodeBlock = false;
35
+ let tableBuffer = [];
36
+
37
+ const appendBlock = (block) => {
38
+ if (block.length <= bound) {
39
+ if (!current) {
40
+ current = block;
41
+ return;
42
+ }
43
+ const candidate = `${current}\n${block}`;
44
+ if (candidate.length > bound) {
45
+ chunks.push(current);
46
+ current = block;
47
+ } else {
48
+ current = candidate;
49
+ }
50
+ return;
51
+ }
52
+ // 超大块:收束当前块后按 bound 硬切,保证每块可投递。
53
+ if (current) {
54
+ chunks.push(current);
55
+ current = '';
56
+ }
57
+ let remaining = block;
58
+ while (remaining.length > bound) {
59
+ const index = safeSliceIndex(remaining, bound);
60
+ chunks.push(remaining.slice(0, index));
61
+ remaining = remaining.slice(index);
62
+ }
63
+ current = remaining;
64
+ };
65
+
66
+ const flushTable = () => {
67
+ if (tableBuffer.length === 0) return;
68
+ const block = tableBuffer.join('\n');
69
+ tableBuffer = [];
70
+ appendBlock(block);
71
+ };
72
+
73
+ const appendLine = (line) => {
74
+ let remaining = line;
75
+ // 超长行先硬切,保证每块不超过 bound。
76
+ while (remaining.length > bound) {
77
+ if (current) {
78
+ chunks.push(current);
79
+ current = '';
80
+ }
81
+ const index = safeSliceIndex(remaining, bound);
82
+ chunks.push(remaining.slice(0, index));
83
+ remaining = remaining.slice(index);
84
+ }
85
+ appendBlock(remaining);
86
+ };
87
+
88
+ for (const line of lines) {
89
+ if (CODE_FENCE_OPEN.test(line)) {
90
+ flushTable();
91
+ if (!inCodeBlock && current) {
92
+ // 代码块开启:先收束当前块,让整个代码块从新块开始。
93
+ chunks.push(current);
94
+ current = '';
95
+ }
96
+ inCodeBlock = !inCodeBlock;
97
+ appendLine(line);
98
+ continue;
99
+ }
100
+ if (inCodeBlock) {
101
+ appendLine(line);
102
+ continue;
103
+ }
104
+ if (GFM_TABLE_LINE.test(line)) {
105
+ tableBuffer.push(line);
106
+ continue;
107
+ }
108
+ flushTable();
109
+ appendLine(line);
110
+ }
111
+
112
+ flushTable();
113
+ if (current) chunks.push(current);
114
+ return chunks;
115
+ }
116
+
117
+ function nextMsgSeq() {
118
+ // 与 SDK getNextMsgSeq 相同的随机策略:被动回复同 msg_id 的多条消息
119
+ // 各自带不同 msg_seq,避免平台去重(错误码 40054005)。
120
+ const timePart = Date.now() % 100_000_000;
121
+ const random = Math.floor(Math.random() * 65_536);
122
+ return (timePart ^ random) % 65_536;
123
+ }
124
+
125
+ /**
126
+ * 以 markdown(msg_type=2)发送回复;单条被平台拒绝时回退纯文本(msg_type=0)。
127
+ * 返回每条消息的平台响应,供调用方提取 provider message ids。
128
+ */
129
+ export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
130
+ const chunks = chunkMarkdownText(text);
131
+ const results = [];
132
+ const passiveLimit = target?.msgId ? PASSIVE_REPLY_LIMIT[target.scope] : null;
133
+ const overflow = passiveLimit !== null && chunks.length > passiveLimit;
134
+ const passiveContentCount = overflow ? passiveLimit - 1 : chunks.length;
135
+ const proactiveTarget = target?.msgId
136
+ ? { scope: target.scope, targetId: target.targetId }
137
+ : target;
138
+ let partialNoticeSent = false;
139
+
140
+ const sendPartialNotice = async () => {
141
+ if (partialNoticeSent || !target?.msgId) return;
142
+ partialNoticeSent = true;
143
+ try {
144
+ results.push(await bot.sendText(target, PARTIAL_REPLY_NOTICE));
145
+ } catch (error) {
146
+ logger?.warn?.('[dsh-im:qq] unable to send partial reply notice:', error);
147
+ }
148
+ };
149
+
150
+ for (const [index, chunk] of chunks.entries()) {
151
+ const deliveryTarget = overflow && index >= passiveContentCount
152
+ ? proactiveTarget
153
+ : target;
154
+ if (typeof bot?.send === 'function') {
155
+ try {
156
+ results.push(await bot.send({
157
+ target: deliveryTarget,
158
+ msgType: 2,
159
+ markdown: { content: chunk },
160
+ extra: { msg_seq: nextMsgSeq() },
161
+ }));
162
+ continue;
163
+ } catch (error) {
164
+ logger?.warn?.('[dsh-im:qq] markdown delivery failed; retrying as plain text:', error);
165
+ }
166
+ }
167
+ try {
168
+ results.push(await bot.sendText(deliveryTarget, chunk));
169
+ } catch (error) {
170
+ if (results.length === 0) throw error;
171
+ await sendPartialNotice();
172
+ break;
173
+ }
174
+ }
175
+ return results;
176
+ }