@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.
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/lib/index.js +176 -163
- package/package.json +1 -1
- package/plugin-src/host/channels/dingtalk/production.mjs +3 -1
- package/plugin-src/host/channels/feishu/production.mjs +3 -1
- package/plugin-src/host/channels/qq/production.mjs +3 -1
- package/plugin-src/host/channels/shared/production.mjs +3 -1
- package/plugin-src/host/channels/slack/production.mjs +3 -1
- package/plugin-src/host/channels/wecom/production.mjs +3 -1
- package/plugin-src/host/channels/weixin/production.mjs +3 -1
- package/plugin-src/host/channels/whatsapp/production.mjs +3 -1
- package/plugin-src/host/harness-session-coordinator.mjs +32 -5
- package/src/channels/dingtalk/dingtalk-api.mjs +93 -27
- package/src/channels/dingtalk/dingtalk-bridge.mjs +60 -13
- package/src/channels/discord/discord-runtime.mjs +23 -0
- package/src/channels/feishu/bridge.mjs +18 -10
- package/src/channels/feishu/message-utils.mjs +47 -0
- package/src/channels/qq/markdown-reply.mjs +176 -0
- package/src/channels/qq/qq-bridge.mjs +124 -55
- package/src/channels/shared/file-download.mjs +64 -0
- package/src/channels/shared/harness-client.mjs +111 -13
- package/src/channels/shared/inbound-file.mjs +206 -0
- package/src/channels/shared/text-harness-bridge.mjs +31 -11
- package/src/channels/slack/slack-api.mjs +27 -4
- package/src/channels/slack/slack-runtime.mjs +55 -5
- package/src/channels/telegram/telegram-api.mjs +21 -6
- package/src/channels/telegram/telegram-runtime.mjs +24 -3
- package/src/channels/wecom/wecom-bridge.mjs +73 -10
- package/src/channels/weixin/weixin-api.mjs +45 -0
- package/src/channels/weixin/weixin-bridge.mjs +52 -18
- package/src/channels/whatsapp/whatsapp-runtime.mjs +42 -2
- 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
|
-
|
|
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
|
|
675
|
-
|
|
676
|
-
|
|
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
|
+
}
|