@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.
- package/README.en.md +8 -4
- package/README.md +8 -4
- package/lib/client.js +16 -14
- package/lib/index.js +123 -121
- package/package.json +1 -1
- package/plugin-src/client/styles.js +15 -14
- package/plugin-src/host/channels/dingtalk/index.mjs +1 -1
- package/plugin-src/host/channels/dingtalk/production.mjs +3 -0
- package/plugin-src/host/channels/discord/index.mjs +1 -1
- package/plugin-src/host/channels/feishu/index.mjs +1 -1
- package/plugin-src/host/channels/feishu/production.mjs +3 -0
- package/plugin-src/host/channels/qq/index.mjs +1 -1
- package/plugin-src/host/channels/qq/production.mjs +3 -0
- package/plugin-src/host/channels/shared/production.mjs +3 -0
- package/plugin-src/host/channels/slack/index.mjs +1 -1
- package/plugin-src/host/channels/slack/production.mjs +3 -0
- package/plugin-src/host/channels/telegram/index.mjs +1 -1
- package/plugin-src/host/channels/wecom/index.mjs +1 -1
- package/plugin-src/host/channels/wecom/production.mjs +3 -0
- package/plugin-src/host/channels/weixin/index.mjs +1 -1
- package/plugin-src/host/channels/weixin/production.mjs +3 -0
- package/plugin-src/host/channels/whatsapp/index.mjs +1 -1
- package/plugin-src/host/channels/whatsapp/production.mjs +3 -0
- package/plugin-src/host/harness-command-executor.mjs +21 -0
- package/plugin-src/host/index.mjs +1 -1
- package/src/channels/dingtalk/dingtalk-api.mjs +82 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +162 -13
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/discord/discord-runtime.mjs +44 -1
- package/src/channels/feishu/bridge.mjs +45 -11
- package/src/channels/feishu/message-utils.mjs +142 -8
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/qq/qq-bridge.mjs +107 -13
- package/src/channels/shared/bot-workspace-store.mjs +13 -0
- package/src/channels/shared/compact-command.mjs +95 -0
- package/src/channels/shared/harness-client.mjs +32 -2
- package/src/channels/shared/image-prompt.mjs +268 -0
- package/src/channels/shared/text-harness-bridge.mjs +49 -9
- package/src/channels/shared/workspace-session.mjs +2 -1
- package/src/channels/slack/manifest.mjs +1 -0
- package/src/channels/slack/slack-api.mjs +95 -0
- package/src/channels/slack/slack-runtime.mjs +24 -3
- package/src/channels/telegram/telegram-api.mjs +37 -0
- package/src/channels/telegram/telegram-runtime.mjs +71 -9
- package/src/channels/wecom/wecom-bridge.mjs +163 -16
- package/src/channels/weixin/weixin-api.mjs +98 -2
- package/src/channels/weixin/weixin-bridge.mjs +55 -12
- 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
|
|
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 =
|
|
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?.(
|
|
538
|
+
this.#logger.error?.(
|
|
539
|
+
'[dsh-dingtalk] failed to process an inbound message',
|
|
540
|
+
safeErrorDiagnostic(error),
|
|
541
|
+
);
|
|
394
542
|
try {
|
|
395
|
-
const
|
|
396
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
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 (
|
|
316
|
+
if (commandText === '/help') {
|
|
305
317
|
await this.#send(event.message.chat_id, HELP_TEXT);
|
|
306
318
|
return;
|
|
307
319
|
}
|
|
308
|
-
if (
|
|
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 (
|
|
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 =
|
|
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,
|
|
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,
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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(
|
|
20
|
+
const parsed = JSON.parse(value);
|
|
21
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
18
22
|
} catch {
|
|
19
23
|
return null;
|
|
20
24
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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',
|