@xmanrui/dsh-im 1.2.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/lib/index.js +166 -163
- package/package.json +1 -1
- package/plugin-src/host/channels/dingtalk/production.mjs +3 -1
- package/plugin-src/host/channels/feishu/production.mjs +3 -1
- package/plugin-src/host/channels/qq/production.mjs +3 -1
- package/plugin-src/host/channels/shared/production.mjs +3 -1
- package/plugin-src/host/channels/slack/production.mjs +3 -1
- package/plugin-src/host/channels/wecom/production.mjs +3 -1
- package/plugin-src/host/channels/weixin/production.mjs +3 -1
- package/plugin-src/host/channels/whatsapp/production.mjs +3 -1
- package/plugin-src/host/harness-session-coordinator.mjs +32 -5
- package/src/channels/dingtalk/dingtalk-api.mjs +93 -27
- package/src/channels/dingtalk/dingtalk-bridge.mjs +60 -13
- package/src/channels/discord/discord-runtime.mjs +23 -0
- package/src/channels/feishu/bridge.mjs +18 -10
- package/src/channels/feishu/message-utils.mjs +47 -0
- package/src/channels/qq/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/whatsapp-runtime.mjs +42 -2
- package/src/channels/whatsapp/whatsapp-web-session.mjs +1 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createWriteStream } from 'node:fs';
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
|
|
6
|
+
const FILES_DIRECTORY = join('.dsh-im', 'inbound');
|
|
7
|
+
|
|
8
|
+
export class InboundFileError extends Error {
|
|
9
|
+
constructor(code, message, userMessage = '文件接收失败,请重新发送后再试。', options = {}) {
|
|
10
|
+
super(message, options);
|
|
11
|
+
this.name = 'InboundFileError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.userMessage = userMessage;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fileSources(message) {
|
|
18
|
+
return Array.isArray(message?.files) ? message.files.filter(Boolean) : [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function displayName(value, fallback) {
|
|
22
|
+
if (typeof value !== 'string') return fallback;
|
|
23
|
+
const cleaned = value
|
|
24
|
+
.replaceAll('\\', '/')
|
|
25
|
+
.split('/')
|
|
26
|
+
.at(-1)
|
|
27
|
+
?.replace(/[\u0000-\u001f\u007f]/g, '')
|
|
28
|
+
.trim();
|
|
29
|
+
return cleaned || fallback;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function storageName(value, index) {
|
|
33
|
+
const cleaned = displayName(value, 'file')
|
|
34
|
+
.replace(/[^\p{L}\p{N}._ -]/gu, '_')
|
|
35
|
+
.replace(/^\.+/, '')
|
|
36
|
+
.slice(0, 160) || 'file';
|
|
37
|
+
return `${String(index + 1).padStart(2, '0')}-${cleaned}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadedFile(value) {
|
|
41
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
|
42
|
+
return { data: Buffer.from(value) };
|
|
43
|
+
}
|
|
44
|
+
const raw = value?.data ?? value?.buffer;
|
|
45
|
+
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
|
|
46
|
+
return {
|
|
47
|
+
data: Buffer.from(raw),
|
|
48
|
+
name: value?.name ?? value?.filename,
|
|
49
|
+
mediaType: value?.mediaType ?? value?.mimetype,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const stream = value?.stream ?? value;
|
|
53
|
+
if (stream && typeof stream[Symbol.asyncIterator] === 'function') {
|
|
54
|
+
return {
|
|
55
|
+
stream,
|
|
56
|
+
name: value?.name ?? value?.filename,
|
|
57
|
+
mediaType: value?.mediaType ?? value?.mimetype,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function hasInboundFiles(message) {
|
|
64
|
+
return fileSources(message).length > 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Start provider downloads immediately while preserving the lazy file-source contract. */
|
|
68
|
+
export function prefetchInboundFiles(message, { signal } = {}) {
|
|
69
|
+
const sources = fileSources(message);
|
|
70
|
+
if (sources.length === 0) return message;
|
|
71
|
+
return {
|
|
72
|
+
...message,
|
|
73
|
+
files: sources.map((source) => {
|
|
74
|
+
if (source?.data !== undefined || typeof source?.load !== 'function') return source;
|
|
75
|
+
let download;
|
|
76
|
+
try {
|
|
77
|
+
download = Promise.resolve(source.load({ signal }));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
download = Promise.reject(error);
|
|
80
|
+
}
|
|
81
|
+
download.catch(() => undefined);
|
|
82
|
+
return {
|
|
83
|
+
...source,
|
|
84
|
+
async load({ signal: loadSignal } = {}) {
|
|
85
|
+
loadSignal?.throwIfAborted();
|
|
86
|
+
const result = await download;
|
|
87
|
+
loadSignal?.throwIfAborted();
|
|
88
|
+
return result;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function stageInboundFiles(message, {
|
|
96
|
+
workspace,
|
|
97
|
+
signal,
|
|
98
|
+
} = {}) {
|
|
99
|
+
const sources = fileSources(message);
|
|
100
|
+
if (sources.length === 0) return null;
|
|
101
|
+
if (typeof workspace !== 'string' || !isAbsolute(workspace)) {
|
|
102
|
+
throw new InboundFileError(
|
|
103
|
+
'inbound-file-workspace-unavailable',
|
|
104
|
+
'The Harness Session workspace is unavailable for inbound files.',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
signal?.throwIfAborted();
|
|
109
|
+
const root = resolve(workspace, FILES_DIRECTORY);
|
|
110
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
111
|
+
const directory = await mkdtemp(join(root, 'turn-'));
|
|
112
|
+
const files = [];
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
for (const [index, source] of sources.entries()) {
|
|
116
|
+
signal?.throwIfAborted();
|
|
117
|
+
let value;
|
|
118
|
+
try {
|
|
119
|
+
value = source?.data === undefined
|
|
120
|
+
? await source?.load?.({ signal })
|
|
121
|
+
: source.data;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (signal?.aborted) throw error;
|
|
124
|
+
throw new InboundFileError(
|
|
125
|
+
'inbound-file-download-failed',
|
|
126
|
+
`Unable to download inbound file ${index + 1}: ${error?.message ?? String(error)}`,
|
|
127
|
+
'文件下载失败,请重新发送后再试。',
|
|
128
|
+
{ cause: error },
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const loaded = loadedFile(value);
|
|
133
|
+
if (!loaded) {
|
|
134
|
+
throw new InboundFileError(
|
|
135
|
+
'inbound-file-data-invalid',
|
|
136
|
+
`Inbound file ${index + 1} returned no readable data.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const name = displayName(loaded.name ?? source?.name, `file-${index + 1}`);
|
|
140
|
+
const path = join(directory, storageName(name, index));
|
|
141
|
+
if (loaded.data) {
|
|
142
|
+
await writeFile(path, loaded.data, { mode: 0o600, signal });
|
|
143
|
+
} else {
|
|
144
|
+
try {
|
|
145
|
+
await pipeline(
|
|
146
|
+
loaded.stream,
|
|
147
|
+
createWriteStream(path, { flags: 'wx', mode: 0o600 }),
|
|
148
|
+
{ signal },
|
|
149
|
+
);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (signal?.aborted) throw error;
|
|
152
|
+
throw new InboundFileError(
|
|
153
|
+
'inbound-file-download-failed',
|
|
154
|
+
`Unable to stream inbound file ${index + 1}: ${error?.message ?? String(error)}`,
|
|
155
|
+
'文件下载失败,请重新发送后再试。',
|
|
156
|
+
{ cause: error },
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const relativePath = relative(resolve(workspace), path);
|
|
161
|
+
if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
|
162
|
+
throw new InboundFileError(
|
|
163
|
+
'inbound-file-path-invalid',
|
|
164
|
+
'The staged inbound file escaped the Harness Session workspace.',
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
files.push(Object.freeze({
|
|
168
|
+
name,
|
|
169
|
+
path: relativePath,
|
|
170
|
+
...(typeof (loaded.mediaType ?? source?.mediaType) === 'string'
|
|
171
|
+
&& (loaded.mediaType ?? source.mediaType).trim()
|
|
172
|
+
? { mediaType: (loaded.mediaType ?? source.mediaType).trim() }
|
|
173
|
+
: {}),
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
return Object.freeze({
|
|
177
|
+
files: Object.freeze(files),
|
|
178
|
+
async cleanup() {
|
|
179
|
+
await rm(directory, { recursive: true, force: true });
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
} catch (error) {
|
|
183
|
+
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function appendInboundFilesToPrompt(prompt, staged) {
|
|
189
|
+
if (!staged?.files?.length) return prompt;
|
|
190
|
+
const manifest = [
|
|
191
|
+
'<dsh_im_files>',
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
description: 'Files uploaded with this user message. Paths are relative to the current Harness workspace.',
|
|
194
|
+
files: staged.files,
|
|
195
|
+
}),
|
|
196
|
+
'</dsh_im_files>',
|
|
197
|
+
].join('\n');
|
|
198
|
+
|
|
199
|
+
if (Array.isArray(prompt)) return [...prompt, { type: 'text', text: manifest }];
|
|
200
|
+
const text = typeof prompt === 'string' ? prompt.trim() : '';
|
|
201
|
+
return text ? `${text}\n\n${manifest}` : manifest;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function inboundFileUserMessage(error) {
|
|
205
|
+
return error instanceof InboundFileError ? error.userMessage : null;
|
|
206
|
+
}
|
|
@@ -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,
|