@xmanrui/dsh-im 1.1.0 → 1.3.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 +5 -3
- package/README.md +5 -3
- package/lib/client.js +212 -0
- package/lib/index.js +166 -163
- package/package.json +1 -1
- package/plugin-src/client/channels/whatsapp/api.js +11 -0
- package/plugin-src/client/channels/whatsapp/index.js +125 -0
- package/plugin-src/client/channels/whatsapp/styles.js +25 -0
- package/plugin-src/client/i18n.js +18 -0
- 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/channels/whatsapp/rpc.mjs +19 -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/qq-bridge.mjs +80 -28
- package/src/channels/shared/file-download.mjs +64 -0
- package/src/channels/shared/harness-client.mjs +45 -0
- 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/config-store.mjs +43 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +22 -1
- package/src/channels/whatsapp/whatsapp-runtime.mjs +83 -2
- package/src/channels/whatsapp/whatsapp-web-session.mjs +1 -1
|
@@ -23,6 +23,10 @@ import {
|
|
|
23
23
|
imagePromptUserMessage,
|
|
24
24
|
promptContentForMessage,
|
|
25
25
|
} from './image-prompt.mjs';
|
|
26
|
+
import {
|
|
27
|
+
hasInboundFiles,
|
|
28
|
+
inboundFileUserMessage,
|
|
29
|
+
} from './inbound-file.mjs';
|
|
26
30
|
import {
|
|
27
31
|
harnessAnswerForQuestion,
|
|
28
32
|
harnessQuestionText,
|
|
@@ -50,6 +54,7 @@ function canClaimInteractionReply(message, pending, senderId) {
|
|
|
50
54
|
return pending.actor === senderId
|
|
51
55
|
&& (message.kind !== 'group' || message.addressed === true)
|
|
52
56
|
&& !hasInboundImages(message)
|
|
57
|
+
&& !hasInboundFiles(message)
|
|
53
58
|
&& Boolean(cleanText(message.content));
|
|
54
59
|
}
|
|
55
60
|
|
|
@@ -171,7 +176,7 @@ export class TextHarnessBridge {
|
|
|
171
176
|
const key = `${kind}:${conversationId}`;
|
|
172
177
|
const pending = this.#pendingInteractions.get(key);
|
|
173
178
|
const text = cleanText(normalized.content);
|
|
174
|
-
const commandRunner = isControlCommand(text)
|
|
179
|
+
const commandRunner = hasInboundFiles(normalized) ? null : isControlCommand(text)
|
|
175
180
|
? runControlCommand
|
|
176
181
|
: (isModelCommand(text)
|
|
177
182
|
? runModelCommand
|
|
@@ -194,7 +199,7 @@ export class TextHarnessBridge {
|
|
|
194
199
|
key,
|
|
195
200
|
actor: senderId,
|
|
196
201
|
messageId,
|
|
197
|
-
text: hasInboundImages(normalized) ? '' : normalized.content,
|
|
202
|
+
text: hasInboundImages(normalized) || hasInboundFiles(normalized) ? '' : normalized.content,
|
|
198
203
|
addressed: normalized.kind !== 'group' || normalized.addressed === true,
|
|
199
204
|
hasPendingQuestion: Boolean(pending),
|
|
200
205
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
@@ -300,6 +305,7 @@ export class TextHarnessBridge {
|
|
|
300
305
|
{
|
|
301
306
|
signal: this.#signal,
|
|
302
307
|
hasImages: hasInboundImages(message),
|
|
308
|
+
hasFiles: hasInboundFiles(message),
|
|
303
309
|
pendingInteraction: this.#pendingInteractions.has(key)
|
|
304
310
|
|| this.#approvals.hasPending(key),
|
|
305
311
|
control: { owner: this, key },
|
|
@@ -423,16 +429,17 @@ export class TextHarnessBridge {
|
|
|
423
429
|
return;
|
|
424
430
|
}
|
|
425
431
|
const hasImages = hasInboundImages(message);
|
|
426
|
-
|
|
427
|
-
|
|
432
|
+
const hasFiles = hasInboundFiles(message);
|
|
433
|
+
if (!text && !hasImages && !hasFiles) {
|
|
434
|
+
await this.#bot.sendText(target, '目前支持文字、图片和文件消息。');
|
|
428
435
|
return;
|
|
429
436
|
}
|
|
430
437
|
const command = text.toLowerCase();
|
|
431
|
-
if (!hasImages && command === '/help') {
|
|
438
|
+
if (!hasImages && !hasFiles && command === '/help') {
|
|
432
439
|
await this.#bot.sendText(target, [
|
|
433
440
|
`${this.#descriptor.label}机器人已连接 DeepSeek Harness。`,
|
|
434
441
|
'',
|
|
435
|
-
'
|
|
442
|
+
'直接发送文字、图片或文件即可继续当前会话。',
|
|
436
443
|
'/new 开启一个全新会话',
|
|
437
444
|
'/compact 压缩当前会话的较早上下文',
|
|
438
445
|
'/workspace 工作区绝对路径 切换工作区',
|
|
@@ -453,12 +460,12 @@ export class TextHarnessBridge {
|
|
|
453
460
|
].join('\n'));
|
|
454
461
|
return;
|
|
455
462
|
}
|
|
456
|
-
if (!hasImages && command === '/status') {
|
|
463
|
+
if (!hasImages && !hasFiles && command === '/status') {
|
|
457
464
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
458
465
|
await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
|
|
459
466
|
return;
|
|
460
467
|
}
|
|
461
|
-
const workspaceCommand = !hasImages
|
|
468
|
+
const workspaceCommand = !hasImages && !hasFiles
|
|
462
469
|
? await runWorkspaceCommand(text, this.#harness, conversationKey)
|
|
463
470
|
: null;
|
|
464
471
|
if (workspaceCommand) {
|
|
@@ -467,12 +474,12 @@ export class TextHarnessBridge {
|
|
|
467
474
|
}
|
|
468
475
|
return;
|
|
469
476
|
}
|
|
470
|
-
if (!hasImages && command === '/new') {
|
|
477
|
+
if (!hasImages && !hasFiles && command === '/new') {
|
|
471
478
|
await this.#state.clearSession(conversationKey);
|
|
472
479
|
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
|
473
480
|
return;
|
|
474
481
|
}
|
|
475
|
-
const compactCommand = !hasImages
|
|
482
|
+
const compactCommand = !hasImages && !hasFiles
|
|
476
483
|
? await runCompactCommand(
|
|
477
484
|
text,
|
|
478
485
|
this.#harness,
|
|
@@ -527,6 +534,7 @@ export class TextHarnessBridge {
|
|
|
527
534
|
requiresMention: message.kind === 'group',
|
|
528
535
|
}),
|
|
529
536
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
537
|
+
files: message.files,
|
|
530
538
|
},
|
|
531
539
|
});
|
|
532
540
|
const visibleAnswer = !cleanText(answer) && artifacts.length > 0
|
|
@@ -600,6 +608,18 @@ export class TextHarnessBridge {
|
|
|
600
608
|
}
|
|
601
609
|
return;
|
|
602
610
|
}
|
|
611
|
+
const fileErrorMessage = inboundFileUserMessage(error);
|
|
612
|
+
if (fileErrorMessage) {
|
|
613
|
+
try {
|
|
614
|
+
await this.#bot.sendText(target, fileErrorMessage);
|
|
615
|
+
} catch (sendError) {
|
|
616
|
+
this.#logger.error?.(
|
|
617
|
+
`[dsh-im:${this.#descriptor.key}] failed to send the file error reply:`,
|
|
618
|
+
sendError,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
603
623
|
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
|
|
604
624
|
try {
|
|
605
625
|
await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
|
|
@@ -642,7 +662,7 @@ export class TextHarnessBridge {
|
|
|
642
662
|
|
|
643
663
|
const target = message.replyTarget;
|
|
644
664
|
const text = cleanText(message.content);
|
|
645
|
-
if (!text || hasInboundImages(message)) {
|
|
665
|
+
if (!text || hasInboundImages(message) || hasInboundFiles(message)) {
|
|
646
666
|
try {
|
|
647
667
|
await this.#bot.sendText(target, '请用文字回答当前问题。');
|
|
648
668
|
} catch (error) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fetchFileStream } from '../shared/file-download.mjs';
|
|
1
2
|
import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
|
|
2
3
|
|
|
3
4
|
const DEFAULT_BASE_URL = 'https://slack.com/api/';
|
|
@@ -232,6 +233,18 @@ export class SlackApi {
|
|
|
232
233
|
});
|
|
233
234
|
}
|
|
234
235
|
|
|
236
|
+
async fileInfo({ fileId, signal } = {}) {
|
|
237
|
+
const value = await this.#request('files.info', {
|
|
238
|
+
tokenKind: 'bot',
|
|
239
|
+
signal,
|
|
240
|
+
body: { file: slackId(fileId, 'file id') },
|
|
241
|
+
});
|
|
242
|
+
if (!value?.file || typeof value.file !== 'object' || Array.isArray(value.file)) {
|
|
243
|
+
throw new Error('Slack files.info returned no file object');
|
|
244
|
+
}
|
|
245
|
+
return value.file;
|
|
246
|
+
}
|
|
247
|
+
|
|
235
248
|
postMessage({ channelId, text, threadTs, signal }) {
|
|
236
249
|
return this.#request('chat.postMessage', {
|
|
237
250
|
tokenKind: 'bot',
|
|
@@ -396,6 +409,14 @@ export class SlackApi {
|
|
|
396
409
|
}
|
|
397
410
|
|
|
398
411
|
async downloadFile({ url, signal, maxBytes }) {
|
|
412
|
+
return this.#downloadFile({ url, signal, maxBytes, stream: false });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async downloadFileStream({ url, signal }) {
|
|
416
|
+
return this.#downloadFile({ url, signal, stream: true });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async #downloadFile({ url, signal, maxBytes, stream }) {
|
|
399
420
|
if (!this.#botToken) throw new TypeError('Slack bot token is required for file download');
|
|
400
421
|
const target = secureSlackFileUrl(url);
|
|
401
422
|
const fetchSlackFile = async (requestUrl, options) => {
|
|
@@ -414,13 +435,15 @@ export class SlackApi {
|
|
|
414
435
|
}
|
|
415
436
|
return response;
|
|
416
437
|
};
|
|
417
|
-
|
|
438
|
+
const options = {
|
|
418
439
|
fetchImpl: fetchSlackFile,
|
|
419
440
|
headers: { authorization: `Bearer ${this.#botToken}` },
|
|
420
441
|
signal,
|
|
421
|
-
maxBytes,
|
|
422
442
|
allowedHosts: SLACK_FILE_HOSTS,
|
|
423
|
-
}
|
|
443
|
+
};
|
|
444
|
+
return stream
|
|
445
|
+
? fetchFileStream(target, options)
|
|
446
|
+
: fetchImageBuffer(target, { ...options, maxBytes });
|
|
424
447
|
}
|
|
425
448
|
|
|
426
449
|
async #request(method, {
|
|
@@ -432,7 +455,7 @@ export class SlackApi {
|
|
|
432
455
|
}) {
|
|
433
456
|
const token = tokenKind === 'app' ? this.#appToken : this.#botToken;
|
|
434
457
|
if (!token) throw new TypeError(`Slack ${tokenKind} token is required for ${method}`);
|
|
435
|
-
const formEncoded = method === 'files.getUploadURLExternal';
|
|
458
|
+
const formEncoded = method === 'files.getUploadURLExternal' || method === 'files.info';
|
|
436
459
|
let response;
|
|
437
460
|
try {
|
|
438
461
|
response = await this.#fetch(new URL(method, this.#baseUrl), {
|
|
@@ -43,10 +43,14 @@ function stripBotMention(value, botUserId) {
|
|
|
43
43
|
.trim();
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function slackFileUrl(file) {
|
|
47
|
+
return typeof file?.url_private_download === 'string' && file.url_private_download
|
|
48
|
+
? file.url_private_download : file?.url_private;
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
function slackImageSource(file, loadFile) {
|
|
47
52
|
const mediaType = typeof file?.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
|
|
48
|
-
const url =
|
|
49
|
-
? file.url_private_download : file?.url_private;
|
|
53
|
+
const url = slackFileUrl(file);
|
|
50
54
|
if (!IMAGE_MEDIA_TYPES.has(mediaType) || typeof url !== 'string' || !url) return null;
|
|
51
55
|
return {
|
|
52
56
|
name: typeof file.name === 'string' ? file.name : undefined,
|
|
@@ -56,9 +60,50 @@ function slackImageSource(file, loadFile) {
|
|
|
56
60
|
};
|
|
57
61
|
}
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
function slackFileSource(file, loadFile, loadFileInfo) {
|
|
64
|
+
const mediaType = typeof file?.mimetype === 'string' && file.mimetype
|
|
65
|
+
? file.mimetype.toLowerCase() : undefined;
|
|
66
|
+
const url = slackFileUrl(file);
|
|
67
|
+
if (IMAGE_MEDIA_TYPES.has(mediaType)) return null;
|
|
68
|
+
const requiresInfo = file?.file_access === 'check_file_info'
|
|
69
|
+
&& typeof file?.id === 'string' && file.id;
|
|
70
|
+
if ((typeof url !== 'string' || !url) && !requiresInfo) return null;
|
|
71
|
+
return {
|
|
72
|
+
name: typeof file?.name === 'string' && file.name
|
|
73
|
+
? file.name : typeof file?.title === 'string' && file.title
|
|
74
|
+
? file.title : requiresInfo ? file.id : 'slack-file',
|
|
75
|
+
...(mediaType ? { mediaType } : {}),
|
|
76
|
+
size: Number.isSafeInteger(file?.size) && file.size >= 0 ? file.size : undefined,
|
|
77
|
+
load: async ({ signal } = {}) => {
|
|
78
|
+
if (!requiresInfo) return loadFile(url, { signal });
|
|
79
|
+
const resolved = await loadFileInfo(file.id, { signal });
|
|
80
|
+
const resolvedUrl = slackFileUrl(resolved);
|
|
81
|
+
if (typeof resolvedUrl !== 'string' || !resolvedUrl) {
|
|
82
|
+
throw new Error('Slack files.info returned no downloadable URL');
|
|
83
|
+
}
|
|
84
|
+
const loaded = await loadFile(resolvedUrl, { signal });
|
|
85
|
+
const name = typeof resolved?.name === 'string' && resolved.name
|
|
86
|
+
? resolved.name : typeof resolved?.title === 'string' && resolved.title
|
|
87
|
+
? resolved.title : file.id;
|
|
88
|
+
const resolvedMediaType = typeof resolved?.mimetype === 'string' && resolved.mimetype
|
|
89
|
+
? resolved.mimetype.toLowerCase() : undefined;
|
|
90
|
+
if (Buffer.isBuffer(loaded) || loaded instanceof Uint8Array) {
|
|
91
|
+
return { data: loaded, name, ...(resolvedMediaType ? { mediaType: resolvedMediaType } : {}) };
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
...loaded,
|
|
95
|
+
name,
|
|
96
|
+
...(resolvedMediaType ? { mediaType: resolvedMediaType } : {}),
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function normalizeSlackEvent(payload, botUserId, {
|
|
103
|
+
loadFile = async () => { throw new Error('Slack file downloader is unavailable'); },
|
|
104
|
+
loadFileStream = loadFile,
|
|
105
|
+
loadFileInfo = async () => { throw new Error('Slack file metadata loader is unavailable'); },
|
|
106
|
+
} = {}) {
|
|
62
107
|
const event = payload?.event;
|
|
63
108
|
if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null;
|
|
64
109
|
const direct = event.type === 'message' && event.channel_type === 'im';
|
|
@@ -76,6 +121,9 @@ export function normalizeSlackEvent(payload, botUserId, { loadFile = async () =>
|
|
|
76
121
|
images: Array.isArray(event.files)
|
|
77
122
|
? event.files.map((file) => slackImageSource(file, loadFile)).filter(Boolean)
|
|
78
123
|
: [],
|
|
124
|
+
files: Array.isArray(event.files)
|
|
125
|
+
? event.files.map((file) => slackFileSource(file, loadFileStream, loadFileInfo)).filter(Boolean)
|
|
126
|
+
: [],
|
|
79
127
|
addressed: direct || mentioned,
|
|
80
128
|
replyTarget: {
|
|
81
129
|
channelId: String(event.channel),
|
|
@@ -436,6 +484,8 @@ export class SlackRuntime {
|
|
|
436
484
|
&& packet.payload.api_app_id !== this.#appId) return;
|
|
437
485
|
const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1], {
|
|
438
486
|
loadFile: (url, options) => this.#api.downloadFile({ url, ...options }),
|
|
487
|
+
loadFileStream: (url, options) => this.#api.downloadFileStream({ url, ...options }),
|
|
488
|
+
loadFileInfo: (fileId, options) => this.#api.fileInfo({ fileId, ...options }),
|
|
439
489
|
});
|
|
440
490
|
const bridge = this.#bridge;
|
|
441
491
|
if (message && bridge) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fetchFileStream } from '../shared/file-download.mjs';
|
|
1
2
|
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
|
2
3
|
|
|
3
4
|
const DEFAULT_BASE_URL = 'https://api.telegram.org/';
|
|
@@ -131,6 +132,25 @@ export class TelegramApi {
|
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
async downloadFile({ fileId, signal, maxBytes } = {}) {
|
|
135
|
+
const url = await this.#downloadUrl(fileId, signal);
|
|
136
|
+
return fetchImageBuffer(url, {
|
|
137
|
+
fetchImpl: this.#fetch,
|
|
138
|
+
signal,
|
|
139
|
+
maxBytes,
|
|
140
|
+
allowedHosts: TELEGRAM_FILE_HOSTS,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async downloadFileStream({ fileId, signal } = {}) {
|
|
145
|
+
const url = await this.#downloadUrl(fileId, signal);
|
|
146
|
+
return fetchFileStream(url, {
|
|
147
|
+
fetchImpl: this.#fetch,
|
|
148
|
+
signal,
|
|
149
|
+
allowedHosts: TELEGRAM_FILE_HOSTS,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async #downloadUrl(fileId, signal) {
|
|
134
154
|
const file = await this.getFile({ fileId, signal });
|
|
135
155
|
const filePath = cleanString(file?.file_path);
|
|
136
156
|
if (!filePath || filePath.startsWith('/') || filePath.includes('\\')
|
|
@@ -148,12 +168,7 @@ export class TelegramApi {
|
|
|
148
168
|
}
|
|
149
169
|
const url = new URL(this.#baseUrl);
|
|
150
170
|
url.pathname = `/file/bot${this.#token}/${filePath}`;
|
|
151
|
-
return
|
|
152
|
-
fetchImpl: this.#fetch,
|
|
153
|
-
signal,
|
|
154
|
-
maxBytes,
|
|
155
|
-
allowedHosts: TELEGRAM_FILE_HOSTS,
|
|
156
|
-
});
|
|
171
|
+
return url;
|
|
157
172
|
}
|
|
158
173
|
|
|
159
174
|
async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) {
|
|
@@ -100,9 +100,27 @@ function telegramImageSource(message, loadFile) {
|
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
function telegramFileSource(message, loadFile) {
|
|
104
|
+
const file = message?.document;
|
|
105
|
+
if (!file || imageTypeForDocument(file)
|
|
106
|
+
|| typeof file.file_id !== 'string' || !file.file_id) return null;
|
|
107
|
+
const mediaType = typeof file.mime_type === 'string' && file.mime_type
|
|
108
|
+
? file.mime_type.toLowerCase() : undefined;
|
|
109
|
+
return {
|
|
110
|
+
name: typeof file.file_name === 'string' && file.file_name
|
|
111
|
+
? file.file_name : String(file.file_unique_id ?? file.file_id),
|
|
112
|
+
...(mediaType ? { mediaType } : {}),
|
|
113
|
+
size: fileSize(file.file_size),
|
|
114
|
+
load: ({ signal } = {}) => loadFile(file.file_id, { signal }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function normalizeTelegramUpdate(update, {
|
|
119
|
+
botId,
|
|
120
|
+
username,
|
|
121
|
+
loadFile = async () => { throw new Error('Telegram file downloader is unavailable'); },
|
|
122
|
+
loadFileStream = loadFile,
|
|
123
|
+
}) {
|
|
106
124
|
const message = update?.message;
|
|
107
125
|
const chatId = message?.chat?.id;
|
|
108
126
|
const senderId = message?.from?.id;
|
|
@@ -117,6 +135,7 @@ export function normalizeTelegramUpdate(update, { botId, username, loadFile = as
|
|
|
117
135
|
const messageThreadId = Number.isSafeInteger(message.message_thread_id)
|
|
118
136
|
? message.message_thread_id : undefined;
|
|
119
137
|
const image = telegramImageSource(message, loadFile);
|
|
138
|
+
const file = telegramFileSource(message, loadFileStream);
|
|
120
139
|
return {
|
|
121
140
|
messageId: String(update.update_id),
|
|
122
141
|
senderId: String(senderId),
|
|
@@ -126,6 +145,7 @@ export function normalizeTelegramUpdate(update, { botId, username, loadFile = as
|
|
|
126
145
|
? String(chatId) : `${chatId}:${messageThreadId}`,
|
|
127
146
|
content: withoutBotMention(message.text ?? message.caption ?? '', username),
|
|
128
147
|
images: image ? [image] : [],
|
|
148
|
+
files: file ? [file] : [],
|
|
129
149
|
addressed,
|
|
130
150
|
replyTarget: {
|
|
131
151
|
chatId,
|
|
@@ -381,6 +401,7 @@ export class TelegramRuntime {
|
|
|
381
401
|
botId: this.#config.platformId,
|
|
382
402
|
username: this.#config.username,
|
|
383
403
|
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
|
|
404
|
+
loadFileStream: (fileId, options) => this.#api.downloadFileStream({ fileId, ...options }),
|
|
384
405
|
});
|
|
385
406
|
if (message && telegramInboundAllowed(message, {
|
|
386
407
|
accessMode: this.#accessMode,
|
|
@@ -26,6 +26,10 @@ import {
|
|
|
26
26
|
imagePromptUserMessage,
|
|
27
27
|
promptContentForMessage,
|
|
28
28
|
} from '../shared/image-prompt.mjs';
|
|
29
|
+
import {
|
|
30
|
+
hasInboundFiles,
|
|
31
|
+
inboundFileUserMessage,
|
|
32
|
+
} from '../shared/inbound-file.mjs';
|
|
29
33
|
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
|
30
34
|
import {
|
|
31
35
|
materializeOutboundArtifact,
|
|
@@ -43,7 +47,7 @@ const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
|
|
43
47
|
const HELP_TEXT = [
|
|
44
48
|
'企业微信机器人已连接 DeepSeek Harness。',
|
|
45
49
|
'',
|
|
46
|
-
'
|
|
50
|
+
'直接发送文字、图片或文件即可继续当前会话。',
|
|
47
51
|
'/new 开启一个全新会话',
|
|
48
52
|
'/compact 压缩当前会话的较早上下文',
|
|
49
53
|
'/workspace 工作区绝对路径 切换工作区',
|
|
@@ -110,6 +114,13 @@ function imageContents(frame) {
|
|
|
110
114
|
.map((item) => item.image);
|
|
111
115
|
}
|
|
112
116
|
|
|
117
|
+
function fileContents(frame) {
|
|
118
|
+
const body = bodyOf(frame);
|
|
119
|
+
return body.msgtype === 'file' && body.file && typeof body.file === 'object'
|
|
120
|
+
? [body.file]
|
|
121
|
+
: [];
|
|
122
|
+
}
|
|
123
|
+
|
|
113
124
|
function imageSource(client, image) {
|
|
114
125
|
const url = nonEmptyString(image?.url);
|
|
115
126
|
if (!url) return null;
|
|
@@ -139,10 +150,56 @@ function imageSource(client, image) {
|
|
|
139
150
|
};
|
|
140
151
|
}
|
|
141
152
|
|
|
153
|
+
function fileSource(client, file) {
|
|
154
|
+
const url = nonEmptyString(file?.url);
|
|
155
|
+
if (!url) return null;
|
|
156
|
+
const aeskey = nonEmptyString(file?.aeskey) ?? undefined;
|
|
157
|
+
return {
|
|
158
|
+
name: nonEmptyString(file?.filename ?? file?.file_name ?? file?.name) ?? 'file',
|
|
159
|
+
async load({ signal } = {}) {
|
|
160
|
+
signal?.throwIfAborted();
|
|
161
|
+
if (typeof client?.downloadFile !== 'function') {
|
|
162
|
+
throw new Error('Enterprise WeChat file download is unavailable');
|
|
163
|
+
}
|
|
164
|
+
const result = await client.downloadFile(url, aeskey);
|
|
165
|
+
signal?.throwIfAborted();
|
|
166
|
+
const raw = result?.buffer ?? result?.data;
|
|
167
|
+
if (!Buffer.isBuffer(raw) && !(raw instanceof Uint8Array)) {
|
|
168
|
+
throw new Error('Enterprise WeChat file download returned no data');
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
data: Buffer.from(raw),
|
|
172
|
+
...(nonEmptyString(result?.filename) ? { name: result.filename.trim() } : {}),
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
142
178
|
export function wecomInboundMessage(frame, client) {
|
|
143
179
|
return {
|
|
144
180
|
content: messageText(frame),
|
|
145
181
|
images: imageContents(frame).map((image) => imageSource(client, image)).filter(Boolean),
|
|
182
|
+
files: fileContents(frame).map((file) => fileSource(client, file)).filter(Boolean),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function prefetchInboundFiles(message, signal) {
|
|
187
|
+
if (!Array.isArray(message?.files) || message.files.length === 0) return message;
|
|
188
|
+
return {
|
|
189
|
+
...message,
|
|
190
|
+
files: message.files.map((source) => {
|
|
191
|
+
const download = source.load({ signal });
|
|
192
|
+
download.catch(() => undefined);
|
|
193
|
+
return {
|
|
194
|
+
...source,
|
|
195
|
+
async load({ signal: loadSignal } = {}) {
|
|
196
|
+
loadSignal?.throwIfAborted();
|
|
197
|
+
const result = await download;
|
|
198
|
+
loadSignal?.throwIfAborted();
|
|
199
|
+
return result;
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}),
|
|
146
203
|
};
|
|
147
204
|
}
|
|
148
205
|
|
|
@@ -400,7 +457,7 @@ export class WecomHarnessBridge {
|
|
|
400
457
|
const pending = this.#pendingInteractions.get(key);
|
|
401
458
|
const commandMessage = wecomInboundMessage(frame, this.#client);
|
|
402
459
|
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
|
403
|
-
const commandRunner = isControlCommand(commandText)
|
|
460
|
+
const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
|
|
404
461
|
? runControlCommand
|
|
405
462
|
: (isModelCommand(commandText)
|
|
406
463
|
? runModelCommand
|
|
@@ -510,6 +567,7 @@ export class WecomHarnessBridge {
|
|
|
510
567
|
preparedMessage = imageQueueFullMessage(inboundMessage);
|
|
511
568
|
}
|
|
512
569
|
}
|
|
570
|
+
preparedMessage = prefetchInboundFiles(preparedMessage, this.#signal);
|
|
513
571
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
514
572
|
const current = previous
|
|
515
573
|
.catch(() => undefined)
|
|
@@ -543,6 +601,7 @@ export class WecomHarnessBridge {
|
|
|
543
601
|
const result = await runner(message.content, this.#harness, this.#state, key, {
|
|
544
602
|
signal: this.#signal,
|
|
545
603
|
hasImages: hasInboundImages(message),
|
|
604
|
+
hasFiles: hasInboundFiles(message),
|
|
546
605
|
pendingInteraction: this.#pendingInteractions.has(key)
|
|
547
606
|
|| this.#approvals.hasPending(key),
|
|
548
607
|
control: { owner: this, key },
|
|
@@ -701,34 +760,35 @@ export class WecomHarnessBridge {
|
|
|
701
760
|
const message = preparedMessage ?? wecomInboundMessage(frame, this.#client);
|
|
702
761
|
const text = message.content;
|
|
703
762
|
const hasImages = hasInboundImages(message);
|
|
763
|
+
const hasFiles = hasInboundFiles(message);
|
|
704
764
|
const key = conversationKey(frame);
|
|
705
765
|
let streamId = null;
|
|
706
766
|
let streamStarted = false;
|
|
707
767
|
try {
|
|
708
|
-
if (!text && !hasImages) {
|
|
709
|
-
await this.#sendImmediate(frame, chatId, '
|
|
768
|
+
if (!text && !hasImages && !hasFiles) {
|
|
769
|
+
await this.#sendImmediate(frame, chatId, '目前支持文字、图片、文件和语音转写消息。');
|
|
710
770
|
await this.#state.markSeen(messageId);
|
|
711
771
|
return;
|
|
712
772
|
}
|
|
713
773
|
const command = text.toLowerCase();
|
|
714
|
-
if (!hasImages && command === '/help') {
|
|
774
|
+
if (!hasImages && !hasFiles && command === '/help') {
|
|
715
775
|
await this.#sendImmediate(frame, chatId, HELP_TEXT);
|
|
716
776
|
await this.#state.markSeen(messageId);
|
|
717
777
|
return;
|
|
718
778
|
}
|
|
719
|
-
if (!hasImages && command === '/status') {
|
|
779
|
+
if (!hasImages && !hasFiles && command === '/status') {
|
|
720
780
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
721
781
|
await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
|
|
722
782
|
await this.#state.markSeen(messageId);
|
|
723
783
|
return;
|
|
724
784
|
}
|
|
725
|
-
if (!hasImages && command === '/new') {
|
|
785
|
+
if (!hasImages && !hasFiles && command === '/new') {
|
|
726
786
|
await this.#state.clearSession(key);
|
|
727
787
|
await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。');
|
|
728
788
|
await this.#state.markSeen(messageId);
|
|
729
789
|
return;
|
|
730
790
|
}
|
|
731
|
-
const workspaceCommand = hasImages
|
|
791
|
+
const workspaceCommand = hasImages || hasFiles
|
|
732
792
|
? null
|
|
733
793
|
: await runWorkspaceCommand(text, this.#harness, key);
|
|
734
794
|
if (workspaceCommand) {
|
|
@@ -738,7 +798,7 @@ export class WecomHarnessBridge {
|
|
|
738
798
|
await this.#state.markSeen(messageId);
|
|
739
799
|
return;
|
|
740
800
|
}
|
|
741
|
-
const compactCommand = hasImages
|
|
801
|
+
const compactCommand = hasImages || hasFiles
|
|
742
802
|
? null
|
|
743
803
|
: await runCompactCommand(
|
|
744
804
|
text,
|
|
@@ -789,6 +849,7 @@ export class WecomHarnessBridge {
|
|
|
789
849
|
requiresMention: body.chattype === 'group',
|
|
790
850
|
}),
|
|
791
851
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
852
|
+
files: message.files,
|
|
792
853
|
},
|
|
793
854
|
});
|
|
794
855
|
|
|
@@ -862,7 +923,9 @@ export class WecomHarnessBridge {
|
|
|
862
923
|
if (this.#signal?.aborted) return;
|
|
863
924
|
this.#status.lastError = error?.message ?? String(error);
|
|
864
925
|
this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
|
|
865
|
-
const errorText =
|
|
926
|
+
const errorText = inboundFileUserMessage(error)
|
|
927
|
+
?? imagePromptUserMessage(error)
|
|
928
|
+
?? '消息处理失败,请稍后重试。';
|
|
866
929
|
try {
|
|
867
930
|
if (streamStarted && streamId) {
|
|
868
931
|
await this.#client.replyStream(frame, streamId, errorText, true);
|
|
@@ -196,6 +196,47 @@ export function extractWeixinImages(message, { fetchImpl = fetch } = {}) {
|
|
|
196
196
|
return images;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
async function fetchWeixinFileCiphertext(url, { fetchImpl, signal }) {
|
|
200
|
+
const response = await fetchImpl(new URL(url), {
|
|
201
|
+
method: 'GET',
|
|
202
|
+
signal,
|
|
203
|
+
redirect: 'manual',
|
|
204
|
+
});
|
|
205
|
+
if (!response?.ok) {
|
|
206
|
+
await response?.body?.cancel?.().catch?.(() => undefined);
|
|
207
|
+
throw new WeixinApiError(
|
|
208
|
+
'file-download-failed',
|
|
209
|
+
`微信文件下载失败(HTTP ${response?.status ?? 'unknown'})。`,
|
|
210
|
+
{ status: response?.status },
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return Buffer.from(await response.arrayBuffer());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Convert native iLink file items into lazily downloaded, decrypted file references. */
|
|
217
|
+
export function extractWeixinFiles(message, { fetchImpl = fetch } = {}) {
|
|
218
|
+
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
|
219
|
+
const files = [];
|
|
220
|
+
for (const item of message?.item_list ?? []) {
|
|
221
|
+
const fileItem = item?.file_item;
|
|
222
|
+
if (!fileItem || typeof fileItem !== 'object') continue;
|
|
223
|
+
const declaredSize = Number(fileItem.len);
|
|
224
|
+
files.push({
|
|
225
|
+
name: nonEmptyString(fileItem.file_name) ?? (files.length === 0 ? 'file' : `file-${files.length + 1}`),
|
|
226
|
+
...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
|
|
227
|
+
load: async ({ signal } = {}) => {
|
|
228
|
+
signal?.throwIfAborted();
|
|
229
|
+
const key = parseWeixinImageAesKey(fileItem);
|
|
230
|
+
const url = weixinImageDownloadUrl(fileItem.media);
|
|
231
|
+
const ciphertext = await fetchWeixinFileCiphertext(url, { fetchImpl, signal });
|
|
232
|
+
signal?.throwIfAborted();
|
|
233
|
+
return decryptWeixinImage(ciphertext, key);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return files;
|
|
238
|
+
}
|
|
239
|
+
|
|
199
240
|
function isWeixinHost(hostname) {
|
|
200
241
|
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
|
201
242
|
return normalized === 'weixin.qq.com' || normalized.endsWith('.weixin.qq.com');
|
|
@@ -421,6 +462,10 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|
|
421
462
|
return extractWeixinImages(message, { fetchImpl });
|
|
422
463
|
},
|
|
423
464
|
|
|
465
|
+
inboundFiles(message) {
|
|
466
|
+
return extractWeixinFiles(message, { fetchImpl });
|
|
467
|
+
},
|
|
468
|
+
|
|
424
469
|
async beginLogin({ localTokens = [], botType = DEFAULT_BOT_TYPE, signal } = {}) {
|
|
425
470
|
const tokens = [...new Set(localTokens.map(nonEmptyString).filter(Boolean))].slice(-10);
|
|
426
471
|
const response = await requestJson(fetchImpl, {
|