@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
|
@@ -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,6 +104,7 @@ 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,
|
|
@@ -250,6 +311,7 @@ export class TelegramRuntime {
|
|
|
250
311
|
const message = normalizeTelegramUpdate(update, {
|
|
251
312
|
botId: this.#config.platformId,
|
|
252
313
|
username: this.#config.username,
|
|
314
|
+
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
|
|
253
315
|
});
|
|
254
316
|
if (message) {
|
|
255
317
|
void this.#bridge.accept(message).catch((error) => {
|
|
@@ -5,14 +5,22 @@ import {
|
|
|
5
5
|
validHarnessQuestion,
|
|
6
6
|
} from '../shared/harness-question.mjs';
|
|
7
7
|
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|
8
|
+
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
8
9
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
9
10
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
11
|
+
import {
|
|
12
|
+
hasInboundImages,
|
|
13
|
+
ImagePromptError,
|
|
14
|
+
imagePromptUserMessage,
|
|
15
|
+
promptContentForMessage,
|
|
16
|
+
} from '../shared/image-prompt.mjs';
|
|
10
17
|
|
|
11
18
|
const HELP_TEXT = [
|
|
12
19
|
'企业微信机器人已连接 DeepSeek Harness。',
|
|
13
20
|
'',
|
|
14
|
-
'
|
|
21
|
+
'直接发送文字或图片即可继续当前会话。',
|
|
15
22
|
'/new 开启一个全新会话',
|
|
23
|
+
'/compact 压缩当前会话的较早上下文',
|
|
16
24
|
'/workspace 工作区绝对路径 切换工作区',
|
|
17
25
|
'/workspacelist 列出工作区绝对路径',
|
|
18
26
|
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
@@ -21,6 +29,8 @@ const HELP_TEXT = [
|
|
|
21
29
|
'/help 显示本帮助',
|
|
22
30
|
].join('\n');
|
|
23
31
|
const MAX_REPLY_BYTES = 18_000;
|
|
32
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
33
|
+
const MAX_PREFETCHED_IMAGES = 4;
|
|
24
34
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
25
35
|
|
|
26
36
|
function nonEmptyString(value) {
|
|
@@ -57,6 +67,102 @@ function messageText(frame) {
|
|
|
57
67
|
: text;
|
|
58
68
|
}
|
|
59
69
|
|
|
70
|
+
function imageContents(frame) {
|
|
71
|
+
const body = bodyOf(frame);
|
|
72
|
+
if (body.msgtype === 'image') return [body.image];
|
|
73
|
+
if (body.msgtype !== 'mixed' || !Array.isArray(body.mixed?.msg_item)) return [];
|
|
74
|
+
return body.mixed.msg_item
|
|
75
|
+
.filter((item) => item?.msgtype === 'image')
|
|
76
|
+
.map((item) => item.image);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function imageSource(client, image) {
|
|
80
|
+
const url = nonEmptyString(image?.url);
|
|
81
|
+
if (!url) return null;
|
|
82
|
+
const aeskey = nonEmptyString(image?.aeskey) ?? undefined;
|
|
83
|
+
return {
|
|
84
|
+
async load({ signal, maxBytes }) {
|
|
85
|
+
signal?.throwIfAborted();
|
|
86
|
+
if (typeof client?.downloadFile !== 'function') {
|
|
87
|
+
throw new Error('Enterprise WeChat image download is unavailable');
|
|
88
|
+
}
|
|
89
|
+
const result = await client.downloadFile(url, aeskey);
|
|
90
|
+
signal?.throwIfAborted();
|
|
91
|
+
const raw = result?.buffer;
|
|
92
|
+
if (!Buffer.isBuffer(raw) && !(raw instanceof Uint8Array)) {
|
|
93
|
+
throw new Error('Enterprise WeChat image download returned no data');
|
|
94
|
+
}
|
|
95
|
+
const data = Buffer.from(raw);
|
|
96
|
+
if (Number.isFinite(maxBytes) && data.length > maxBytes) {
|
|
97
|
+
throw new ImagePromptError(
|
|
98
|
+
'image-too-large',
|
|
99
|
+
`Enterprise WeChat image exceeds ${maxBytes} bytes`,
|
|
100
|
+
'图片超过 5 MB,请压缩后重试。',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return { data, name: result?.filename };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function wecomInboundMessage(frame, client) {
|
|
109
|
+
return {
|
|
110
|
+
content: messageText(frame),
|
|
111
|
+
images: imageContents(frame).map((image) => imageSource(client, image)).filter(Boolean),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function prefetchInboundImages(message, signal) {
|
|
116
|
+
if (!hasInboundImages(message)) return message;
|
|
117
|
+
return {
|
|
118
|
+
...message,
|
|
119
|
+
images: message.images.map((source) => {
|
|
120
|
+
const download = source.load({ signal, maxBytes: MAX_IMAGE_BYTES });
|
|
121
|
+
// The conversation queue may not consume this promise immediately. Keep
|
|
122
|
+
// an attached rejection handler while preserving the original outcome.
|
|
123
|
+
download.catch(() => undefined);
|
|
124
|
+
return {
|
|
125
|
+
...source,
|
|
126
|
+
async load({ signal: loadSignal, maxBytes = MAX_IMAGE_BYTES } = {}) {
|
|
127
|
+
loadSignal?.throwIfAborted();
|
|
128
|
+
const result = await download;
|
|
129
|
+
loadSignal?.throwIfAborted();
|
|
130
|
+
const raw = result?.data ?? result?.buffer ?? result;
|
|
131
|
+
const size = Buffer.isBuffer(raw) || raw instanceof Uint8Array ? raw.length : 0;
|
|
132
|
+
if (size > maxBytes) {
|
|
133
|
+
throw new ImagePromptError(
|
|
134
|
+
'image-too-large',
|
|
135
|
+
`Enterprise WeChat image exceeds ${maxBytes} bytes`,
|
|
136
|
+
'图片超过 5 MB,请压缩后重试。',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function imageQueueFullMessage(message) {
|
|
147
|
+
return {
|
|
148
|
+
...message,
|
|
149
|
+
images: message.images.map((source) => ({
|
|
150
|
+
...source,
|
|
151
|
+
async load() {
|
|
152
|
+
throw new ImagePromptError(
|
|
153
|
+
'image-queue-full',
|
|
154
|
+
`Enterprise WeChat already has ${MAX_PREFETCHED_IMAGES} prefetched images`,
|
|
155
|
+
'当前待处理图片较多,请稍后重新发送。',
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
})),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function interactionReplyText(frame) {
|
|
163
|
+
return bodyOf(frame).msgtype === 'text' ? messageText(frame) : '';
|
|
164
|
+
}
|
|
165
|
+
|
|
60
166
|
function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) {
|
|
61
167
|
const source = String(text ?? '').trim();
|
|
62
168
|
if (!source) return [];
|
|
@@ -87,7 +193,7 @@ function progressText(update) {
|
|
|
87
193
|
function canClaimInteractionReply(frame, pending) {
|
|
88
194
|
return pending.questions[pending.index]
|
|
89
195
|
&& nonEmptyString(bodyOf(frame).from?.userid) === pending.actor
|
|
90
|
-
&& nonEmptyString(
|
|
196
|
+
&& nonEmptyString(interactionReplyText(frame));
|
|
91
197
|
}
|
|
92
198
|
|
|
93
199
|
export function createWecomBridgeStatus() {
|
|
@@ -117,6 +223,7 @@ export class WecomHarnessBridge {
|
|
|
117
223
|
#acceptedMessageIds = new Set();
|
|
118
224
|
#approvalTasks = new Set();
|
|
119
225
|
#approvals;
|
|
226
|
+
#prefetchedImageCount = 0;
|
|
120
227
|
|
|
121
228
|
constructor({
|
|
122
229
|
client,
|
|
@@ -167,7 +274,7 @@ export class WecomHarnessBridge {
|
|
|
167
274
|
key,
|
|
168
275
|
actor: senderId,
|
|
169
276
|
messageId,
|
|
170
|
-
text:
|
|
277
|
+
text: interactionReplyText(frame),
|
|
171
278
|
addressed: true,
|
|
172
279
|
hasPendingQuestion: Boolean(pending),
|
|
173
280
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
@@ -230,11 +337,28 @@ export class WecomHarnessBridge {
|
|
|
230
337
|
releaseMessageId = true,
|
|
231
338
|
alreadyRecorded = false,
|
|
232
339
|
} = {}) {
|
|
340
|
+
// WeCom image URLs expire after five minutes, while a conversation turn
|
|
341
|
+
// may legally stay queued longer. Start the authenticated SDK download as
|
|
342
|
+
// soon as the validated callback is accepted, then consume it in order.
|
|
343
|
+
const inboundMessage = wecomInboundMessage(frame, this.#client);
|
|
344
|
+
const imageCount = inboundMessage.images.length;
|
|
345
|
+
let reservedImages = 0;
|
|
346
|
+
let preparedMessage = inboundMessage;
|
|
347
|
+
if (imageCount > 0) {
|
|
348
|
+
if (this.#prefetchedImageCount + imageCount <= MAX_PREFETCHED_IMAGES) {
|
|
349
|
+
reservedImages = imageCount;
|
|
350
|
+
this.#prefetchedImageCount += reservedImages;
|
|
351
|
+
preparedMessage = prefetchInboundImages(inboundMessage, this.#signal);
|
|
352
|
+
} else {
|
|
353
|
+
preparedMessage = imageQueueFullMessage(inboundMessage);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
233
356
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
234
357
|
const current = previous
|
|
235
358
|
.catch(() => undefined)
|
|
236
|
-
.then(() => this.#process(frame, { alreadyRecorded }))
|
|
359
|
+
.then(() => this.#process(frame, { alreadyRecorded, preparedMessage }))
|
|
237
360
|
.finally(() => {
|
|
361
|
+
this.#prefetchedImageCount -= reservedImages;
|
|
238
362
|
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
|
239
363
|
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
|
240
364
|
});
|
|
@@ -273,7 +397,7 @@ export class WecomHarnessBridge {
|
|
|
273
397
|
}
|
|
274
398
|
}
|
|
275
399
|
|
|
276
|
-
async #process(frame, { alreadyRecorded = false } = {}) {
|
|
400
|
+
async #process(frame, { alreadyRecorded = false, preparedMessage } = {}) {
|
|
277
401
|
if (this.#signal?.aborted) return;
|
|
278
402
|
const body = bodyOf(frame);
|
|
279
403
|
const messageId = typeof body.msgid === 'string' ? body.msgid : '';
|
|
@@ -285,35 +409,39 @@ export class WecomHarnessBridge {
|
|
|
285
409
|
this.#status.messagesReceived += 1;
|
|
286
410
|
this.#status.lastMessageAt = new Date().toISOString();
|
|
287
411
|
}
|
|
288
|
-
const
|
|
412
|
+
const message = preparedMessage ?? wecomInboundMessage(frame, this.#client);
|
|
413
|
+
const text = message.content;
|
|
414
|
+
const hasImages = hasInboundImages(message);
|
|
289
415
|
const key = conversationKey(frame);
|
|
290
416
|
let streamId = null;
|
|
291
417
|
let streamStarted = false;
|
|
292
418
|
try {
|
|
293
|
-
if (!text) {
|
|
294
|
-
await this.#sendImmediate(frame, chatId, '
|
|
419
|
+
if (!text && !hasImages) {
|
|
420
|
+
await this.#sendImmediate(frame, chatId, '目前支持文字、图片和语音转写消息。');
|
|
295
421
|
await this.#state.markSeen(messageId);
|
|
296
422
|
return;
|
|
297
423
|
}
|
|
298
424
|
const command = text.toLowerCase();
|
|
299
|
-
if (command === '/help') {
|
|
425
|
+
if (!hasImages && command === '/help') {
|
|
300
426
|
await this.#sendImmediate(frame, chatId, HELP_TEXT);
|
|
301
427
|
await this.#state.markSeen(messageId);
|
|
302
428
|
return;
|
|
303
429
|
}
|
|
304
|
-
if (command === '/status') {
|
|
430
|
+
if (!hasImages && command === '/status') {
|
|
305
431
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
306
432
|
await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
|
|
307
433
|
await this.#state.markSeen(messageId);
|
|
308
434
|
return;
|
|
309
435
|
}
|
|
310
|
-
if (command === '/new') {
|
|
436
|
+
if (!hasImages && command === '/new') {
|
|
311
437
|
await this.#state.clearSession(key);
|
|
312
438
|
await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。');
|
|
313
439
|
await this.#state.markSeen(messageId);
|
|
314
440
|
return;
|
|
315
441
|
}
|
|
316
|
-
const workspaceCommand =
|
|
442
|
+
const workspaceCommand = hasImages
|
|
443
|
+
? null
|
|
444
|
+
: await runWorkspaceCommand(text, this.#harness, key);
|
|
317
445
|
if (workspaceCommand) {
|
|
318
446
|
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
319
447
|
await this.#sendImmediate(frame, chatId, reply);
|
|
@@ -321,6 +449,20 @@ export class WecomHarnessBridge {
|
|
|
321
449
|
await this.#state.markSeen(messageId);
|
|
322
450
|
return;
|
|
323
451
|
}
|
|
452
|
+
const compactCommand = hasImages
|
|
453
|
+
? null
|
|
454
|
+
: await runCompactCommand(
|
|
455
|
+
text,
|
|
456
|
+
this.#harness,
|
|
457
|
+
this.#state,
|
|
458
|
+
key,
|
|
459
|
+
{ signal: this.#signal },
|
|
460
|
+
);
|
|
461
|
+
if (compactCommand) {
|
|
462
|
+
await this.#sendImmediate(frame, chatId, compactCommand.message);
|
|
463
|
+
await this.#state.markSeen(messageId);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
324
466
|
|
|
325
467
|
streamId = this.#generateReqId('stream');
|
|
326
468
|
try {
|
|
@@ -330,11 +472,15 @@ export class WecomHarnessBridge {
|
|
|
330
472
|
this.#logger.warn?.('[dsh-im:wecom] unable to start a stream; using an active reply:', error);
|
|
331
473
|
}
|
|
332
474
|
|
|
475
|
+
const content = hasImages
|
|
476
|
+
? await promptContentForMessage(message, { signal: this.#signal })
|
|
477
|
+
: undefined;
|
|
333
478
|
const { answer } = await askInWorkspaceSession({
|
|
334
479
|
harness: this.#harness,
|
|
335
480
|
state: this.#state,
|
|
336
481
|
key,
|
|
337
482
|
text,
|
|
483
|
+
content,
|
|
338
484
|
createOptions: { signal: this.#signal },
|
|
339
485
|
existsOptions: { signal: this.#signal },
|
|
340
486
|
askOptions: {
|
|
@@ -378,11 +524,12 @@ export class WecomHarnessBridge {
|
|
|
378
524
|
if (this.#signal?.aborted) return;
|
|
379
525
|
this.#status.lastError = error?.message ?? String(error);
|
|
380
526
|
this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
|
|
527
|
+
const errorText = imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。';
|
|
381
528
|
try {
|
|
382
529
|
if (streamStarted && streamId) {
|
|
383
|
-
await this.#client.replyStream(frame, streamId,
|
|
530
|
+
await this.#client.replyStream(frame, streamId, errorText, true);
|
|
384
531
|
} else {
|
|
385
|
-
await this.#sendImmediate(frame, chatId,
|
|
532
|
+
await this.#sendImmediate(frame, chatId, errorText);
|
|
386
533
|
}
|
|
387
534
|
await this.#state.markSeen(messageId);
|
|
388
535
|
} catch {
|
|
@@ -411,9 +558,9 @@ export class WecomHarnessBridge {
|
|
|
411
558
|
this.#status.messagesReceived += 1;
|
|
412
559
|
this.#status.lastMessageAt = new Date().toISOString();
|
|
413
560
|
|
|
414
|
-
const text = nonEmptyString(
|
|
561
|
+
const text = nonEmptyString(interactionReplyText(frame));
|
|
415
562
|
if (!text) {
|
|
416
|
-
await this.#sendImmediate(frame, chatId, '
|
|
563
|
+
await this.#sendImmediate(frame, chatId, '请用文字回答当前问题。')
|
|
417
564
|
.catch(() => undefined);
|
|
418
565
|
return;
|
|
419
566
|
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { randomBytes, randomUUID } from 'node:crypto';
|
|
1
|
+
import { createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
|
2
4
|
|
|
3
5
|
export const WEIXIN_QR_BASE_URL = 'https://ilinkai.weixin.qq.com/';
|
|
4
6
|
export const WEIXIN_PROTOCOL_VERSION = '2.4.6';
|
|
5
7
|
export const DEFAULT_BOT_TYPE = '3';
|
|
8
|
+
export const WEIXIN_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';
|
|
9
|
+
|
|
10
|
+
const WEIXIN_CDN_HOST = 'novac2c.cdn.weixin.qq.com';
|
|
6
11
|
|
|
7
12
|
const ILINK_APP_ID = 'bot';
|
|
8
13
|
const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
|
|
@@ -32,6 +37,93 @@ function nonEmptyString(value) {
|
|
|
32
37
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
function strictBase64(value) {
|
|
41
|
+
const text = nonEmptyString(value);
|
|
42
|
+
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
|
|
43
|
+
return Buffer.from(text, 'base64');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parse the two AES key encodings used by Weixin iLink image messages. */
|
|
47
|
+
export function parseWeixinImageAesKey(imageItem) {
|
|
48
|
+
const directHex = nonEmptyString(imageItem?.aeskey);
|
|
49
|
+
if (directHex) {
|
|
50
|
+
if (!/^[0-9a-fA-F]{32}$/.test(directHex)) {
|
|
51
|
+
throw new WeixinApiError('invalid-image-key', '微信图片的加密密钥无效。');
|
|
52
|
+
}
|
|
53
|
+
return Buffer.from(directHex, 'hex');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const encoded = strictBase64(imageItem?.media?.aes_key);
|
|
57
|
+
if (encoded?.length === 16) return encoded;
|
|
58
|
+
if (encoded?.length === 32 && /^[0-9a-fA-F]{32}$/.test(encoded.toString('ascii'))) {
|
|
59
|
+
return Buffer.from(encoded.toString('ascii'), 'hex');
|
|
60
|
+
}
|
|
61
|
+
throw new WeixinApiError('invalid-image-key', '微信图片的加密密钥无效。');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function decryptWeixinImage(ciphertext, key) {
|
|
65
|
+
const encrypted = Buffer.from(ciphertext);
|
|
66
|
+
const aesKey = Buffer.from(key);
|
|
67
|
+
if (aesKey.length !== 16 || encrypted.length === 0 || encrypted.length % 16 !== 0) {
|
|
68
|
+
throw new WeixinApiError('invalid-image-ciphertext', '微信图片的加密数据无效。');
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const decipher = createDecipheriv('aes-128-ecb', aesKey, null);
|
|
72
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new WeixinApiError('image-decryption-failed', '微信图片解密失败。', { cause: error });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function weixinImageDownloadUrl(media) {
|
|
79
|
+
const query = nonEmptyString(media?.encrypt_query_param);
|
|
80
|
+
if (query) {
|
|
81
|
+
return `${WEIXIN_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(query)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const fullUrl = nonEmptyString(media?.full_url);
|
|
85
|
+
if (!fullUrl) throw new WeixinApiError('missing-image-url', '微信图片没有可用的下载地址。');
|
|
86
|
+
let url;
|
|
87
|
+
try {
|
|
88
|
+
url = new URL(fullUrl);
|
|
89
|
+
} catch {
|
|
90
|
+
throw new WeixinApiError('invalid-image-url', '微信图片的下载地址无效。');
|
|
91
|
+
}
|
|
92
|
+
if (url.protocol !== 'https:' || url.hostname !== WEIXIN_CDN_HOST
|
|
93
|
+
|| (url.port && url.port !== '443') || !url.pathname.startsWith('/c2c/')) {
|
|
94
|
+
throw new WeixinApiError('untrusted-image-url', '微信图片的下载地址不受信任。');
|
|
95
|
+
}
|
|
96
|
+
url.username = '';
|
|
97
|
+
url.password = '';
|
|
98
|
+
url.hash = '';
|
|
99
|
+
return url.toString();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Convert iLink image items into lazily downloaded, decrypted image references. */
|
|
103
|
+
export function extractWeixinImages(message, { fetchImpl = fetch } = {}) {
|
|
104
|
+
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
|
105
|
+
const images = [];
|
|
106
|
+
for (const item of message?.item_list ?? []) {
|
|
107
|
+
const imageItem = item?.image_item;
|
|
108
|
+
if (!imageItem || typeof imageItem !== 'object') continue;
|
|
109
|
+
images.push({
|
|
110
|
+
name: images.length === 0 ? 'image' : `image-${images.length + 1}`,
|
|
111
|
+
load: async ({ signal, maxBytes }) => {
|
|
112
|
+
const key = parseWeixinImageAesKey(imageItem);
|
|
113
|
+
const url = weixinImageDownloadUrl(imageItem.media);
|
|
114
|
+
const ciphertext = await fetchImageBuffer(url, {
|
|
115
|
+
fetchImpl,
|
|
116
|
+
signal,
|
|
117
|
+
maxBytes: maxBytes + 16,
|
|
118
|
+
allowedHosts: [WEIXIN_CDN_HOST],
|
|
119
|
+
});
|
|
120
|
+
return decryptWeixinImage(ciphertext, key);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return images;
|
|
125
|
+
}
|
|
126
|
+
|
|
35
127
|
function isWeixinHost(hostname) {
|
|
36
128
|
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
|
37
129
|
return normalized === 'weixin.qq.com' || normalized.endsWith('.weixin.qq.com');
|
|
@@ -92,7 +184,7 @@ function authenticatedHeaders(token) {
|
|
|
92
184
|
function baseInfo() {
|
|
93
185
|
return {
|
|
94
186
|
channel_version: WEIXIN_PROTOCOL_VERSION,
|
|
95
|
-
bot_agent: 'DeepSeekHarness/0.
|
|
187
|
+
bot_agent: 'DeepSeekHarness/0.10.0',
|
|
96
188
|
};
|
|
97
189
|
}
|
|
98
190
|
|
|
@@ -170,6 +262,10 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|
|
170
262
|
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
|
171
263
|
|
|
172
264
|
return Object.freeze({
|
|
265
|
+
inboundImages(message) {
|
|
266
|
+
return extractWeixinImages(message, { fetchImpl });
|
|
267
|
+
},
|
|
268
|
+
|
|
173
269
|
async beginLogin({ localTokens = [], botType = DEFAULT_BOT_TYPE, signal } = {}) {
|
|
174
270
|
const tokens = [...new Set(localTokens.map(nonEmptyString).filter(Boolean))].slice(-10);
|
|
175
271
|
const response = await requestJson(fetchImpl, {
|