@xmanrui/dsh-im 0.9.0 → 0.11.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 +7 -3
- package/README.md +7 -3
- package/lib/client.js +378 -27
- package/lib/index.js +134 -131
- package/package.json +1 -1
- package/plugin-src/client/channels/dingtalk/api.js +19 -0
- package/plugin-src/client/channels/dingtalk/index.js +67 -10
- package/plugin-src/client/channels/feishu/index.js +35 -5
- package/plugin-src/client/channels/qq/api.js +21 -0
- package/plugin-src/client/channels/qq/index.js +39 -1
- package/plugin-src/client/channels/shared/token-channel.js +29 -3
- package/plugin-src/client/channels/wecom/api.js +11 -0
- package/plugin-src/client/channels/wecom/index.js +71 -1
- package/plugin-src/client/channels/weixin/api.js +11 -0
- package/plugin-src/client/channels/weixin/index.js +40 -4
- package/plugin-src/client/channels/whatsapp/index.js +41 -2
- package/plugin-src/client/i18n.js +13 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
- package/plugin-src/host/channels/dingtalk/rpc.mjs +29 -3
- package/plugin-src/host/channels/feishu/production.mjs +1 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +34 -3
- package/plugin-src/host/channels/qq/production.mjs +1 -1
- package/plugin-src/host/channels/qq/rpc.mjs +35 -2
- package/plugin-src/host/channels/shared/production.mjs +1 -1
- package/plugin-src/host/channels/shared/rpc.mjs +25 -1
- package/plugin-src/host/channels/slack/production.mjs +1 -1
- package/plugin-src/host/channels/slack/rpc.mjs +28 -2
- package/plugin-src/host/channels/wecom/production.mjs +1 -1
- package/plugin-src/host/channels/wecom/rpc.mjs +31 -2
- package/plugin-src/host/channels/weixin/production.mjs +1 -1
- package/plugin-src/host/channels/weixin/rpc.mjs +29 -3
- package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
- package/plugin-src/host/channels/whatsapp/rpc.mjs +31 -3
- package/src/channels/dingtalk/dingtalk-api.mjs +82 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +160 -20
- package/src/channels/dingtalk/dingtalk-controller.mjs +18 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +21 -0
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/discord/discord-runtime.mjs +54 -1
- package/src/channels/feishu/bridge.mjs +45 -18
- package/src/channels/feishu/feishu-runtime.mjs +45 -0
- package/src/channels/feishu/message-utils.mjs +227 -6
- package/src/channels/feishu/multi-bot-controller.mjs +21 -0
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/qq/qq-bridge.mjs +107 -20
- package/src/channels/qq/qq-controller.mjs +18 -0
- package/src/channels/qq/qq-runtime.mjs +26 -0
- package/src/channels/shared/bot-workspace-store.mjs +2 -0
- package/src/channels/shared/connection-test.mjs +54 -0
- package/src/channels/shared/harness-client.mjs +14 -8
- package/src/channels/shared/image-prompt.mjs +268 -0
- package/src/channels/shared/text-harness-bridge.mjs +62 -16
- package/src/channels/shared/token-bot-controller.mjs +21 -0
- 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-controller.mjs +19 -0
- package/src/channels/slack/slack-runtime.mjs +34 -3
- package/src/channels/telegram/telegram-api.mjs +37 -0
- package/src/channels/telegram/telegram-runtime.mjs +81 -9
- package/src/channels/wecom/wecom-bridge.mjs +160 -23
- package/src/channels/wecom/wecom-controller.mjs +18 -0
- package/src/channels/wecom/wecom-runtime.mjs +18 -0
- package/src/channels/weixin/weixin-api.mjs +98 -2
- package/src/channels/weixin/weixin-bridge.mjs +52 -19
- package/src/channels/weixin/weixin-controller.mjs +18 -0
- package/src/channels/weixin/weixin-runtime.mjs +25 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +20 -0
- package/src/channels/whatsapp/whatsapp-runtime.mjs +160 -1
|
@@ -5,6 +5,7 @@ import { createSlackBridgeStatus, SlackHarnessBridge } from './slack-bridge.mjs'
|
|
|
5
5
|
const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
|
|
6
6
|
const SLACK_MESSAGE_LIMIT = 38_000;
|
|
7
7
|
const SLACK_STREAM_CHUNK_LIMIT = 11_000;
|
|
8
|
+
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
8
9
|
|
|
9
10
|
function addSocketListener(socket, event, listener) {
|
|
10
11
|
if (typeof socket.addEventListener === 'function') socket.addEventListener(event, listener);
|
|
@@ -42,13 +43,28 @@ function stripBotMention(value, botUserId) {
|
|
|
42
43
|
.trim();
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
function slackImageSource(file, loadFile) {
|
|
47
|
+
const mediaType = typeof file?.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
|
|
48
|
+
const url = typeof file?.url_private_download === 'string' && file.url_private_download
|
|
49
|
+
? file.url_private_download : file?.url_private;
|
|
50
|
+
if (!IMAGE_MEDIA_TYPES.has(mediaType) || typeof url !== 'string' || !url) return null;
|
|
51
|
+
return {
|
|
52
|
+
name: typeof file.name === 'string' ? file.name : undefined,
|
|
53
|
+
mediaType,
|
|
54
|
+
size: Number.isSafeInteger(file.size) && file.size >= 0 ? file.size : undefined,
|
|
55
|
+
load: (options) => loadFile(url, options),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function normalizeSlackEvent(payload, botUserId, { loadFile = async () => {
|
|
60
|
+
throw new Error('Slack file downloader is unavailable');
|
|
61
|
+
} } = {}) {
|
|
46
62
|
const event = payload?.event;
|
|
47
63
|
if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null;
|
|
48
64
|
const direct = event.type === 'message' && event.channel_type === 'im';
|
|
49
65
|
const mentioned = event.type === 'app_mention';
|
|
50
66
|
if (!direct && !mentioned) return null;
|
|
51
|
-
if (event.subtype || event.bot_id || event.app_id) return null;
|
|
67
|
+
if ((event.subtype && event.subtype !== 'file_share') || event.bot_id || event.app_id) return null;
|
|
52
68
|
const threadTs = String(event.thread_ts ?? event.ts);
|
|
53
69
|
return {
|
|
54
70
|
messageId: String(payload.event_id),
|
|
@@ -57,6 +73,9 @@ export function normalizeSlackEvent(payload, botUserId) {
|
|
|
57
73
|
kind: direct ? 'direct' : 'group',
|
|
58
74
|
conversationId: direct ? String(event.channel) : `${event.channel}:${threadTs}`,
|
|
59
75
|
content: stripBotMention(event.text ?? '', botUserId),
|
|
76
|
+
images: Array.isArray(event.files)
|
|
77
|
+
? event.files.map((file) => slackImageSource(file, loadFile)).filter(Boolean)
|
|
78
|
+
: [],
|
|
60
79
|
addressed: direct || mentioned,
|
|
61
80
|
replyTarget: {
|
|
62
81
|
channelId: String(event.channel),
|
|
@@ -64,6 +83,7 @@ export function normalizeSlackEvent(payload, botUserId) {
|
|
|
64
83
|
recipientUserId: String(event.user),
|
|
65
84
|
recipientTeamId: String(event.user_team ?? payload.team_id ?? ''),
|
|
66
85
|
},
|
|
86
|
+
connectionTestTarget: { channelId: String(event.channel) },
|
|
67
87
|
};
|
|
68
88
|
}
|
|
69
89
|
|
|
@@ -274,6 +294,15 @@ export class SlackRuntime {
|
|
|
274
294
|
return structuredClone(this.#status);
|
|
275
295
|
}
|
|
276
296
|
|
|
297
|
+
async sendConnectionTest(text) {
|
|
298
|
+
if (!this.#status.ready || !this.#bridge) {
|
|
299
|
+
const error = new Error('Slack bot is not connected');
|
|
300
|
+
error.code = 'test-target-unavailable';
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
return this.#bridge.sendConnectionTest(text);
|
|
304
|
+
}
|
|
305
|
+
|
|
277
306
|
async start() {
|
|
278
307
|
if (this.#status.ready && this.#socket) return this.status;
|
|
279
308
|
if (this.#starting) return this.#starting;
|
|
@@ -389,7 +418,9 @@ export class SlackRuntime {
|
|
|
389
418
|
if (packet.type !== 'events_api' || packet.payload?.type !== 'event_callback') return;
|
|
390
419
|
if (this.#appId && packet.payload.api_app_id
|
|
391
420
|
&& packet.payload.api_app_id !== this.#appId) return;
|
|
392
|
-
const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1]
|
|
421
|
+
const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1], {
|
|
422
|
+
loadFile: (url, options) => this.#api.downloadFile({ url, ...options }),
|
|
423
|
+
});
|
|
393
424
|
const bridge = this.#bridge;
|
|
394
425
|
if (message && bridge) {
|
|
395
426
|
void bridge.accept(message).catch((error) => {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
|
2
|
+
|
|
1
3
|
const DEFAULT_BASE_URL = 'https://api.telegram.org/';
|
|
4
|
+
const TELEGRAM_FILE_HOSTS = Object.freeze(['api.telegram.org']);
|
|
2
5
|
|
|
3
6
|
function cleanString(value) {
|
|
4
7
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
@@ -47,6 +50,40 @@ export class TelegramApi {
|
|
|
47
50
|
});
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
async getFile({ fileId, signal } = {}) {
|
|
54
|
+
const id = cleanString(fileId);
|
|
55
|
+
if (!id || !/^[A-Za-z0-9_-]{1,512}$/.test(id)) {
|
|
56
|
+
throw new TypeError('Telegram file id is invalid');
|
|
57
|
+
}
|
|
58
|
+
return this.#call('getFile', { file_id: id }, { signal });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async downloadFile({ fileId, signal, maxBytes } = {}) {
|
|
62
|
+
const file = await this.getFile({ fileId, signal });
|
|
63
|
+
const filePath = cleanString(file?.file_path);
|
|
64
|
+
if (!filePath || filePath.startsWith('/') || filePath.includes('\\')
|
|
65
|
+
|| filePath.includes('?') || filePath.includes('#')) {
|
|
66
|
+
throw new Error('Telegram returned an invalid file path');
|
|
67
|
+
}
|
|
68
|
+
let decodedSegments;
|
|
69
|
+
try {
|
|
70
|
+
decodedSegments = filePath.split('/').map((segment) => decodeURIComponent(segment));
|
|
71
|
+
} catch {
|
|
72
|
+
throw new Error('Telegram returned an invalid file path');
|
|
73
|
+
}
|
|
74
|
+
if (decodedSegments.some((segment) => !segment || segment === '.' || segment === '..')) {
|
|
75
|
+
throw new Error('Telegram returned an invalid file path');
|
|
76
|
+
}
|
|
77
|
+
const url = new URL(this.#baseUrl);
|
|
78
|
+
url.pathname = `/file/bot${this.#token}/${filePath}`;
|
|
79
|
+
return fetchImageBuffer(url, {
|
|
80
|
+
fetchImpl: this.#fetch,
|
|
81
|
+
signal,
|
|
82
|
+
maxBytes,
|
|
83
|
+
allowedHosts: TELEGRAM_FILE_HOSTS,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
50
87
|
async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) {
|
|
51
88
|
return this.#call('sendMessage', {
|
|
52
89
|
chat_id: chatId,
|
|
@@ -7,14 +7,17 @@ function escaped(value) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
function mentionedUsername(message, username) {
|
|
10
|
-
if (!username
|
|
11
|
-
return
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
if (!username) return false;
|
|
11
|
+
return [
|
|
12
|
+
[message?.text, message?.entities],
|
|
13
|
+
[message?.caption, message?.caption_entities],
|
|
14
|
+
].some(([text, entities]) => typeof text === 'string' && Array.isArray(entities)
|
|
15
|
+
&& entities.some((entity) => {
|
|
16
|
+
if (entity?.type !== 'mention' || !Number.isInteger(entity.offset)
|
|
17
|
+
|| !Number.isInteger(entity.length)) return false;
|
|
18
|
+
return text.slice(entity.offset, entity.offset + entity.length).toLowerCase()
|
|
19
|
+
=== `@${username.toLowerCase()}`;
|
|
20
|
+
}));
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
function withoutBotMention(text, username) {
|
|
@@ -22,7 +25,63 @@ function withoutBotMention(text, username) {
|
|
|
22
25
|
return text.replace(new RegExp(`@${escaped(username)}\\b`, 'ig'), '').trim();
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
|
|
28
|
+
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
29
|
+
const IMAGE_FILE_TYPES = new Map([
|
|
30
|
+
['.jpg', 'image/jpeg'],
|
|
31
|
+
['.jpeg', 'image/jpeg'],
|
|
32
|
+
['.png', 'image/png'],
|
|
33
|
+
['.webp', 'image/webp'],
|
|
34
|
+
['.gif', 'image/gif'],
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
function imageTypeForDocument(document) {
|
|
38
|
+
const declaredType = document?.mime_type ?? document?.mimetype;
|
|
39
|
+
const type = typeof declaredType === 'string' ? declaredType.toLowerCase() : '';
|
|
40
|
+
if (IMAGE_MEDIA_TYPES.has(type)) return type;
|
|
41
|
+
const filename = typeof document?.file_name === 'string' ? document.file_name.toLowerCase() : '';
|
|
42
|
+
for (const [extension, mediaType] of IMAGE_FILE_TYPES) {
|
|
43
|
+
if (filename.endsWith(extension)) return mediaType;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fileSize(value) {
|
|
49
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function photoScore(photo) {
|
|
53
|
+
return fileSize(photo?.file_size) ?? ((Number(photo?.width) || 0) * (Number(photo?.height) || 0));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function telegramImageSource(message, loadFile) {
|
|
57
|
+
let file;
|
|
58
|
+
let mediaType;
|
|
59
|
+
let name;
|
|
60
|
+
if (Array.isArray(message?.photo) && message.photo.length > 0) {
|
|
61
|
+
file = message.photo.reduce((largest, candidate) => (
|
|
62
|
+
photoScore(candidate) > photoScore(largest) ? candidate : largest
|
|
63
|
+
));
|
|
64
|
+
mediaType = 'image/jpeg';
|
|
65
|
+
name = `${file.file_unique_id ?? file.file_id ?? 'telegram-photo'}.jpg`;
|
|
66
|
+
} else if (message?.document) {
|
|
67
|
+
const type = imageTypeForDocument(message.document);
|
|
68
|
+
if (!type) return null;
|
|
69
|
+
file = message.document;
|
|
70
|
+
mediaType = type;
|
|
71
|
+
name = typeof file.file_name === 'string' ? file.file_name : undefined;
|
|
72
|
+
}
|
|
73
|
+
if (!file || typeof file.file_id !== 'string') return null;
|
|
74
|
+
return {
|
|
75
|
+
name,
|
|
76
|
+
mediaType,
|
|
77
|
+
size: fileSize(file.file_size),
|
|
78
|
+
load: (options) => loadFile(file.file_id, options),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function normalizeTelegramUpdate(update, { botId, username, loadFile = async () => {
|
|
83
|
+
throw new Error('Telegram file downloader is unavailable');
|
|
84
|
+
} }) {
|
|
26
85
|
const message = update?.message;
|
|
27
86
|
const chatId = message?.chat?.id;
|
|
28
87
|
const senderId = message?.from?.id;
|
|
@@ -36,6 +95,7 @@ export function normalizeTelegramUpdate(update, { botId, username }) {
|
|
|
36
95
|
|| mentionedUsername(message, username);
|
|
37
96
|
const messageThreadId = Number.isSafeInteger(message.message_thread_id)
|
|
38
97
|
? message.message_thread_id : undefined;
|
|
98
|
+
const image = telegramImageSource(message, loadFile);
|
|
39
99
|
return {
|
|
40
100
|
messageId: String(update.update_id),
|
|
41
101
|
senderId: String(senderId),
|
|
@@ -44,12 +104,14 @@ export function normalizeTelegramUpdate(update, { botId, username }) {
|
|
|
44
104
|
conversationId: messageThreadId === undefined
|
|
45
105
|
? String(chatId) : `${chatId}:${messageThreadId}`,
|
|
46
106
|
content: withoutBotMention(message.text ?? message.caption ?? '', username),
|
|
107
|
+
images: image ? [image] : [],
|
|
47
108
|
addressed,
|
|
48
109
|
replyTarget: {
|
|
49
110
|
chatId,
|
|
50
111
|
replyToMessageId: messageId,
|
|
51
112
|
messageThreadId,
|
|
52
113
|
},
|
|
114
|
+
connectionTestTarget: { chatId, messageThreadId },
|
|
53
115
|
};
|
|
54
116
|
}
|
|
55
117
|
|
|
@@ -168,6 +230,15 @@ export class TelegramRuntime {
|
|
|
168
230
|
return structuredClone(this.#status);
|
|
169
231
|
}
|
|
170
232
|
|
|
233
|
+
async sendConnectionTest(text) {
|
|
234
|
+
if (!this.#status.ready || !this.#bridge) {
|
|
235
|
+
const error = new Error('Telegram bot is not connected');
|
|
236
|
+
error.code = 'test-target-unavailable';
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
return this.#bridge.sendConnectionTest(text);
|
|
240
|
+
}
|
|
241
|
+
|
|
171
242
|
async start() {
|
|
172
243
|
if (this.#status.ready && this.#pollTask) return this.status;
|
|
173
244
|
if (this.#starting) return this.#starting;
|
|
@@ -250,6 +321,7 @@ export class TelegramRuntime {
|
|
|
250
321
|
const message = normalizeTelegramUpdate(update, {
|
|
251
322
|
botId: this.#config.platformId,
|
|
252
323
|
username: this.#config.username,
|
|
324
|
+
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
|
|
253
325
|
});
|
|
254
326
|
if (message) {
|
|
255
327
|
void this.#bridge.accept(message).catch((error) => {
|
|
@@ -8,11 +8,18 @@ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|
|
8
8
|
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
9
9
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
10
10
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
11
|
+
import {
|
|
12
|
+
hasInboundImages,
|
|
13
|
+
ImagePromptError,
|
|
14
|
+
imagePromptUserMessage,
|
|
15
|
+
promptContentForMessage,
|
|
16
|
+
} from '../shared/image-prompt.mjs';
|
|
17
|
+
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
|
11
18
|
|
|
12
19
|
const HELP_TEXT = [
|
|
13
20
|
'企业微信机器人已连接 DeepSeek Harness。',
|
|
14
21
|
'',
|
|
15
|
-
'
|
|
22
|
+
'直接发送文字或图片即可继续当前会话。',
|
|
16
23
|
'/new 开启一个全新会话',
|
|
17
24
|
'/compact 压缩当前会话的较早上下文',
|
|
18
25
|
'/workspace 工作区绝对路径 切换工作区',
|
|
@@ -23,6 +30,8 @@ const HELP_TEXT = [
|
|
|
23
30
|
'/help 显示本帮助',
|
|
24
31
|
].join('\n');
|
|
25
32
|
const MAX_REPLY_BYTES = 18_000;
|
|
33
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
34
|
+
const MAX_PREFETCHED_IMAGES = 4;
|
|
26
35
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
27
36
|
|
|
28
37
|
function nonEmptyString(value) {
|
|
@@ -59,6 +68,102 @@ function messageText(frame) {
|
|
|
59
68
|
: text;
|
|
60
69
|
}
|
|
61
70
|
|
|
71
|
+
function imageContents(frame) {
|
|
72
|
+
const body = bodyOf(frame);
|
|
73
|
+
if (body.msgtype === 'image') return [body.image];
|
|
74
|
+
if (body.msgtype !== 'mixed' || !Array.isArray(body.mixed?.msg_item)) return [];
|
|
75
|
+
return body.mixed.msg_item
|
|
76
|
+
.filter((item) => item?.msgtype === 'image')
|
|
77
|
+
.map((item) => item.image);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function imageSource(client, image) {
|
|
81
|
+
const url = nonEmptyString(image?.url);
|
|
82
|
+
if (!url) return null;
|
|
83
|
+
const aeskey = nonEmptyString(image?.aeskey) ?? undefined;
|
|
84
|
+
return {
|
|
85
|
+
async load({ signal, maxBytes }) {
|
|
86
|
+
signal?.throwIfAborted();
|
|
87
|
+
if (typeof client?.downloadFile !== 'function') {
|
|
88
|
+
throw new Error('Enterprise WeChat image download is unavailable');
|
|
89
|
+
}
|
|
90
|
+
const result = await client.downloadFile(url, aeskey);
|
|
91
|
+
signal?.throwIfAborted();
|
|
92
|
+
const raw = result?.buffer;
|
|
93
|
+
if (!Buffer.isBuffer(raw) && !(raw instanceof Uint8Array)) {
|
|
94
|
+
throw new Error('Enterprise WeChat image download returned no data');
|
|
95
|
+
}
|
|
96
|
+
const data = Buffer.from(raw);
|
|
97
|
+
if (Number.isFinite(maxBytes) && data.length > maxBytes) {
|
|
98
|
+
throw new ImagePromptError(
|
|
99
|
+
'image-too-large',
|
|
100
|
+
`Enterprise WeChat image exceeds ${maxBytes} bytes`,
|
|
101
|
+
'图片超过 5 MB,请压缩后重试。',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return { data, name: result?.filename };
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function wecomInboundMessage(frame, client) {
|
|
110
|
+
return {
|
|
111
|
+
content: messageText(frame),
|
|
112
|
+
images: imageContents(frame).map((image) => imageSource(client, image)).filter(Boolean),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function prefetchInboundImages(message, signal) {
|
|
117
|
+
if (!hasInboundImages(message)) return message;
|
|
118
|
+
return {
|
|
119
|
+
...message,
|
|
120
|
+
images: message.images.map((source) => {
|
|
121
|
+
const download = source.load({ signal, maxBytes: MAX_IMAGE_BYTES });
|
|
122
|
+
// The conversation queue may not consume this promise immediately. Keep
|
|
123
|
+
// an attached rejection handler while preserving the original outcome.
|
|
124
|
+
download.catch(() => undefined);
|
|
125
|
+
return {
|
|
126
|
+
...source,
|
|
127
|
+
async load({ signal: loadSignal, maxBytes = MAX_IMAGE_BYTES } = {}) {
|
|
128
|
+
loadSignal?.throwIfAborted();
|
|
129
|
+
const result = await download;
|
|
130
|
+
loadSignal?.throwIfAborted();
|
|
131
|
+
const raw = result?.data ?? result?.buffer ?? result;
|
|
132
|
+
const size = Buffer.isBuffer(raw) || raw instanceof Uint8Array ? raw.length : 0;
|
|
133
|
+
if (size > maxBytes) {
|
|
134
|
+
throw new ImagePromptError(
|
|
135
|
+
'image-too-large',
|
|
136
|
+
`Enterprise WeChat image exceeds ${maxBytes} bytes`,
|
|
137
|
+
'图片超过 5 MB,请压缩后重试。',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function imageQueueFullMessage(message) {
|
|
148
|
+
return {
|
|
149
|
+
...message,
|
|
150
|
+
images: message.images.map((source) => ({
|
|
151
|
+
...source,
|
|
152
|
+
async load() {
|
|
153
|
+
throw new ImagePromptError(
|
|
154
|
+
'image-queue-full',
|
|
155
|
+
`Enterprise WeChat already has ${MAX_PREFETCHED_IMAGES} prefetched images`,
|
|
156
|
+
'当前待处理图片较多,请稍后重新发送。',
|
|
157
|
+
);
|
|
158
|
+
},
|
|
159
|
+
})),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function interactionReplyText(frame) {
|
|
164
|
+
return bodyOf(frame).msgtype === 'text' ? messageText(frame) : '';
|
|
165
|
+
}
|
|
166
|
+
|
|
62
167
|
function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) {
|
|
63
168
|
const source = String(text ?? '').trim();
|
|
64
169
|
if (!source) return [];
|
|
@@ -89,7 +194,7 @@ function progressText(update) {
|
|
|
89
194
|
function canClaimInteractionReply(frame, pending) {
|
|
90
195
|
return pending.questions[pending.index]
|
|
91
196
|
&& nonEmptyString(bodyOf(frame).from?.userid) === pending.actor
|
|
92
|
-
&& nonEmptyString(
|
|
197
|
+
&& nonEmptyString(interactionReplyText(frame));
|
|
93
198
|
}
|
|
94
199
|
|
|
95
200
|
export function createWecomBridgeStatus() {
|
|
@@ -119,6 +224,7 @@ export class WecomHarnessBridge {
|
|
|
119
224
|
#acceptedMessageIds = new Set();
|
|
120
225
|
#approvalTasks = new Set();
|
|
121
226
|
#approvals;
|
|
227
|
+
#prefetchedImageCount = 0;
|
|
122
228
|
|
|
123
229
|
constructor({
|
|
124
230
|
client,
|
|
@@ -164,12 +270,15 @@ export class WecomHarnessBridge {
|
|
|
164
270
|
|
|
165
271
|
const key = conversationKey(frame);
|
|
166
272
|
this.#acceptedMessageIds.add(messageId);
|
|
273
|
+
if (body.chattype === 'single') {
|
|
274
|
+
rememberConnectionTestTarget(this.#state, { chatId });
|
|
275
|
+
}
|
|
167
276
|
const pending = this.#pendingInteractions.get(key);
|
|
168
277
|
const approval = this.#approvals.claimReply({
|
|
169
278
|
key,
|
|
170
279
|
actor: senderId,
|
|
171
280
|
messageId,
|
|
172
|
-
text:
|
|
281
|
+
text: interactionReplyText(frame),
|
|
173
282
|
addressed: true,
|
|
174
283
|
hasPendingQuestion: Boolean(pending),
|
|
175
284
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
@@ -232,11 +341,28 @@ export class WecomHarnessBridge {
|
|
|
232
341
|
releaseMessageId = true,
|
|
233
342
|
alreadyRecorded = false,
|
|
234
343
|
} = {}) {
|
|
344
|
+
// WeCom image URLs expire after five minutes, while a conversation turn
|
|
345
|
+
// may legally stay queued longer. Start the authenticated SDK download as
|
|
346
|
+
// soon as the validated callback is accepted, then consume it in order.
|
|
347
|
+
const inboundMessage = wecomInboundMessage(frame, this.#client);
|
|
348
|
+
const imageCount = inboundMessage.images.length;
|
|
349
|
+
let reservedImages = 0;
|
|
350
|
+
let preparedMessage = inboundMessage;
|
|
351
|
+
if (imageCount > 0) {
|
|
352
|
+
if (this.#prefetchedImageCount + imageCount <= MAX_PREFETCHED_IMAGES) {
|
|
353
|
+
reservedImages = imageCount;
|
|
354
|
+
this.#prefetchedImageCount += reservedImages;
|
|
355
|
+
preparedMessage = prefetchInboundImages(inboundMessage, this.#signal);
|
|
356
|
+
} else {
|
|
357
|
+
preparedMessage = imageQueueFullMessage(inboundMessage);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
235
360
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
236
361
|
const current = previous
|
|
237
362
|
.catch(() => undefined)
|
|
238
|
-
.then(() => this.#process(frame, { alreadyRecorded }))
|
|
363
|
+
.then(() => this.#process(frame, { alreadyRecorded, preparedMessage }))
|
|
239
364
|
.finally(() => {
|
|
365
|
+
this.#prefetchedImageCount -= reservedImages;
|
|
240
366
|
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
|
241
367
|
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
|
242
368
|
});
|
|
@@ -275,7 +401,7 @@ export class WecomHarnessBridge {
|
|
|
275
401
|
}
|
|
276
402
|
}
|
|
277
403
|
|
|
278
|
-
async #process(frame, { alreadyRecorded = false } = {}) {
|
|
404
|
+
async #process(frame, { alreadyRecorded = false, preparedMessage } = {}) {
|
|
279
405
|
if (this.#signal?.aborted) return;
|
|
280
406
|
const body = bodyOf(frame);
|
|
281
407
|
const messageId = typeof body.msgid === 'string' ? body.msgid : '';
|
|
@@ -287,35 +413,39 @@ export class WecomHarnessBridge {
|
|
|
287
413
|
this.#status.messagesReceived += 1;
|
|
288
414
|
this.#status.lastMessageAt = new Date().toISOString();
|
|
289
415
|
}
|
|
290
|
-
const
|
|
416
|
+
const message = preparedMessage ?? wecomInboundMessage(frame, this.#client);
|
|
417
|
+
const text = message.content;
|
|
418
|
+
const hasImages = hasInboundImages(message);
|
|
291
419
|
const key = conversationKey(frame);
|
|
292
420
|
let streamId = null;
|
|
293
421
|
let streamStarted = false;
|
|
294
422
|
try {
|
|
295
|
-
if (!text) {
|
|
296
|
-
await this.#sendImmediate(frame, chatId, '
|
|
423
|
+
if (!text && !hasImages) {
|
|
424
|
+
await this.#sendImmediate(frame, chatId, '目前支持文字、图片和语音转写消息。');
|
|
297
425
|
await this.#state.markSeen(messageId);
|
|
298
426
|
return;
|
|
299
427
|
}
|
|
300
428
|
const command = text.toLowerCase();
|
|
301
|
-
if (command === '/help') {
|
|
429
|
+
if (!hasImages && command === '/help') {
|
|
302
430
|
await this.#sendImmediate(frame, chatId, HELP_TEXT);
|
|
303
431
|
await this.#state.markSeen(messageId);
|
|
304
432
|
return;
|
|
305
433
|
}
|
|
306
|
-
if (command === '/status') {
|
|
434
|
+
if (!hasImages && command === '/status') {
|
|
307
435
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
308
436
|
await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
|
|
309
437
|
await this.#state.markSeen(messageId);
|
|
310
438
|
return;
|
|
311
439
|
}
|
|
312
|
-
if (command === '/new') {
|
|
440
|
+
if (!hasImages && command === '/new') {
|
|
313
441
|
await this.#state.clearSession(key);
|
|
314
442
|
await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。');
|
|
315
443
|
await this.#state.markSeen(messageId);
|
|
316
444
|
return;
|
|
317
445
|
}
|
|
318
|
-
const workspaceCommand =
|
|
446
|
+
const workspaceCommand = hasImages
|
|
447
|
+
? null
|
|
448
|
+
: await runWorkspaceCommand(text, this.#harness, key);
|
|
319
449
|
if (workspaceCommand) {
|
|
320
450
|
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
321
451
|
await this.#sendImmediate(frame, chatId, reply);
|
|
@@ -323,13 +453,15 @@ export class WecomHarnessBridge {
|
|
|
323
453
|
await this.#state.markSeen(messageId);
|
|
324
454
|
return;
|
|
325
455
|
}
|
|
326
|
-
const compactCommand =
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
456
|
+
const compactCommand = hasImages
|
|
457
|
+
? null
|
|
458
|
+
: await runCompactCommand(
|
|
459
|
+
text,
|
|
460
|
+
this.#harness,
|
|
461
|
+
this.#state,
|
|
462
|
+
key,
|
|
463
|
+
{ signal: this.#signal },
|
|
464
|
+
);
|
|
333
465
|
if (compactCommand) {
|
|
334
466
|
await this.#sendImmediate(frame, chatId, compactCommand.message);
|
|
335
467
|
await this.#state.markSeen(messageId);
|
|
@@ -344,11 +476,15 @@ export class WecomHarnessBridge {
|
|
|
344
476
|
this.#logger.warn?.('[dsh-im:wecom] unable to start a stream; using an active reply:', error);
|
|
345
477
|
}
|
|
346
478
|
|
|
479
|
+
const content = hasImages
|
|
480
|
+
? await promptContentForMessage(message, { signal: this.#signal })
|
|
481
|
+
: undefined;
|
|
347
482
|
const { answer } = await askInWorkspaceSession({
|
|
348
483
|
harness: this.#harness,
|
|
349
484
|
state: this.#state,
|
|
350
485
|
key,
|
|
351
486
|
text,
|
|
487
|
+
content,
|
|
352
488
|
createOptions: { signal: this.#signal },
|
|
353
489
|
existsOptions: { signal: this.#signal },
|
|
354
490
|
askOptions: {
|
|
@@ -392,11 +528,12 @@ export class WecomHarnessBridge {
|
|
|
392
528
|
if (this.#signal?.aborted) return;
|
|
393
529
|
this.#status.lastError = error?.message ?? String(error);
|
|
394
530
|
this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
|
|
531
|
+
const errorText = imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。';
|
|
395
532
|
try {
|
|
396
533
|
if (streamStarted && streamId) {
|
|
397
|
-
await this.#client.replyStream(frame, streamId,
|
|
534
|
+
await this.#client.replyStream(frame, streamId, errorText, true);
|
|
398
535
|
} else {
|
|
399
|
-
await this.#sendImmediate(frame, chatId,
|
|
536
|
+
await this.#sendImmediate(frame, chatId, errorText);
|
|
400
537
|
}
|
|
401
538
|
await this.#state.markSeen(messageId);
|
|
402
539
|
} catch {
|
|
@@ -425,9 +562,9 @@ export class WecomHarnessBridge {
|
|
|
425
562
|
this.#status.messagesReceived += 1;
|
|
426
563
|
this.#status.lastMessageAt = new Date().toISOString();
|
|
427
564
|
|
|
428
|
-
const text = nonEmptyString(
|
|
565
|
+
const text = nonEmptyString(interactionReplyText(frame));
|
|
429
566
|
if (!text) {
|
|
430
|
-
await this.#sendImmediate(frame, chatId, '
|
|
567
|
+
await this.#sendImmediate(frame, chatId, '请用文字回答当前问题。')
|
|
431
568
|
.catch(() => undefined);
|
|
432
569
|
return;
|
|
433
570
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
import { deriveWecomBotIdentity, maskWecomBotId } from './config-store.mjs';
|
|
4
|
+
import {
|
|
5
|
+
connectionTestMessage,
|
|
6
|
+
connectionTestTargetUnavailable,
|
|
7
|
+
} from '../shared/connection-test.mjs';
|
|
4
8
|
|
|
5
9
|
const ACTIVE_ATTEMPT_STATES = new Set(['pending', 'connecting']);
|
|
6
10
|
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled', 'expired']);
|
|
@@ -237,6 +241,20 @@ export class WecomController {
|
|
|
237
241
|
return this.status();
|
|
238
242
|
}
|
|
239
243
|
|
|
244
|
+
async sendConnectionTest(botId) {
|
|
245
|
+
const config = this.#configStore.get(botId);
|
|
246
|
+
if (!config) throw new Error('Unknown Enterprise WeChat bot');
|
|
247
|
+
return this.#withBotTransition(botId, async () => {
|
|
248
|
+
const runtime = this.#runtimes.get(botId);
|
|
249
|
+
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
|
250
|
+
throw connectionTestTargetUnavailable('企业微信机器人');
|
|
251
|
+
}
|
|
252
|
+
return runtime.sendConnectionTest(connectionTestMessage(
|
|
253
|
+
`企业微信机器人(${maskWecomBotId(config.remoteBotId)})`,
|
|
254
|
+
));
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
240
258
|
async deleteBot(botId) {
|
|
241
259
|
const config = this.#configStore.get(botId);
|
|
242
260
|
if (!config) throw new Error('Unknown Enterprise WeChat bot');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { WSAuthFailureError, WSClient, WSReconnectExhaustedError } from '@wecom/aibot-node-sdk';
|
|
2
2
|
|
|
3
3
|
import { createWecomBridgeStatus, WecomHarnessBridge } from './wecom-bridge.mjs';
|
|
4
|
+
import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
|
|
4
5
|
|
|
5
6
|
function timeoutError() {
|
|
6
7
|
const error = new Error('Enterprise WeChat WebSocket authentication timed out');
|
|
@@ -197,6 +198,23 @@ export class WecomRuntime {
|
|
|
197
198
|
return this.status;
|
|
198
199
|
}
|
|
199
200
|
|
|
201
|
+
async sendConnectionTest(text) {
|
|
202
|
+
return sendRememberedConnectionTest({
|
|
203
|
+
state: this.#state,
|
|
204
|
+
text,
|
|
205
|
+
channelLabel: '企业微信机器人',
|
|
206
|
+
send: async ({ chatId }, content) => {
|
|
207
|
+
if (!this.#status.ready || !this.#client) {
|
|
208
|
+
throw new Error('Enterprise WeChat runtime is not connected');
|
|
209
|
+
}
|
|
210
|
+
await this.#client.sendMessage(chatId, {
|
|
211
|
+
msgtype: 'markdown',
|
|
212
|
+
markdown: { content },
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
200
218
|
async #stopActive() {
|
|
201
219
|
const client = this.#client;
|
|
202
220
|
const bridge = this.#bridge;
|