@xmanrui/dsh-im 0.9.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 +5 -3
- package/README.md +5 -3
- package/lib/client.js +1 -0
- package/lib/index.js +121 -119
- package/package.json +1 -1
- package/src/channels/dingtalk/dingtalk-api.mjs +82 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +156 -20
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/discord/discord-runtime.mjs +44 -1
- package/src/channels/feishu/bridge.mjs +39 -18
- 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 +100 -20
- package/src/channels/shared/harness-client.mjs +8 -2
- package/src/channels/shared/image-prompt.mjs +268 -0
- package/src/channels/shared/text-harness-bridge.mjs +43 -16
- 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 +156 -23
- package/src/channels/weixin/weixin-api.mjs +98 -2
- package/src/channels/weixin/weixin-bridge.mjs +48 -19
- package/src/channels/whatsapp/whatsapp-runtime.mjs +144 -1
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
2
|
+
const DEFAULT_MAX_IMAGES = 20;
|
|
3
|
+
const DEFAULT_MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_IMAGE_PROMPT = '请分析这张图片。';
|
|
6
|
+
|
|
7
|
+
export class ImagePromptError extends Error {
|
|
8
|
+
constructor(code, message, userMessage, options = {}) {
|
|
9
|
+
super(message, options);
|
|
10
|
+
this.name = 'ImagePromptError';
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.userMessage = userMessage;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function requestSignal(signal, timeoutMs) {
|
|
17
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
18
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function cancelResponseBody(response) {
|
|
22
|
+
try {
|
|
23
|
+
await response?.body?.cancel?.();
|
|
24
|
+
} catch {
|
|
25
|
+
// The original download error is more useful than a best-effort cleanup failure.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function fetchImageBuffer(url, {
|
|
30
|
+
fetchImpl = fetch,
|
|
31
|
+
headers,
|
|
32
|
+
signal,
|
|
33
|
+
maxBytes = DEFAULT_MAX_IMAGE_BYTES,
|
|
34
|
+
timeoutMs = 15_000,
|
|
35
|
+
allowedHosts,
|
|
36
|
+
} = {}) {
|
|
37
|
+
const target = new URL(url);
|
|
38
|
+
if (target.protocol !== 'https:') throw new Error('Image download URL must use HTTPS');
|
|
39
|
+
if (Array.isArray(allowedHosts) && !allowedHosts.some((rule) => (
|
|
40
|
+
typeof rule === 'string'
|
|
41
|
+
&& (target.hostname === rule
|
|
42
|
+
|| (rule.startsWith('.')
|
|
43
|
+
&& (target.hostname === rule.slice(1) || target.hostname.endsWith(rule))))
|
|
44
|
+
))) {
|
|
45
|
+
throw new Error('Image download URL is not hosted by the messaging platform');
|
|
46
|
+
}
|
|
47
|
+
const response = await fetchImpl(target, {
|
|
48
|
+
method: 'GET',
|
|
49
|
+
headers,
|
|
50
|
+
signal: requestSignal(signal, timeoutMs),
|
|
51
|
+
redirect: 'manual',
|
|
52
|
+
});
|
|
53
|
+
if (Number.isInteger(response?.status) && response.status >= 300 && response.status < 400) {
|
|
54
|
+
await cancelResponseBody(response);
|
|
55
|
+
throw new ImagePromptError(
|
|
56
|
+
'image-redirect-blocked',
|
|
57
|
+
`Image download redirect was blocked (HTTP ${response.status})`,
|
|
58
|
+
'图片下载地址发生了重定向,暂时无法读取。',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (!response?.ok) {
|
|
62
|
+
await cancelResponseBody(response);
|
|
63
|
+
throw new ImagePromptError(
|
|
64
|
+
'image-http-error',
|
|
65
|
+
`Image download failed with HTTP ${response?.status ?? 'unknown'}`,
|
|
66
|
+
`图片下载失败(HTTP ${response?.status ?? 'unknown'}),请重新发送后再试。`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const declaredLength = Number(response.headers?.get?.('content-length'));
|
|
70
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
71
|
+
await cancelResponseBody(response);
|
|
72
|
+
throw new ImagePromptError(
|
|
73
|
+
'image-too-large',
|
|
74
|
+
`Image response declares ${declaredLength} bytes; the limit is ${maxBytes}`,
|
|
75
|
+
'图片超过 5 MB,请压缩后重试。',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (response.body?.[Symbol.asyncIterator]) {
|
|
80
|
+
const chunks = [];
|
|
81
|
+
let size = 0;
|
|
82
|
+
for await (const chunk of response.body) {
|
|
83
|
+
const data = Buffer.from(chunk);
|
|
84
|
+
size += data.length;
|
|
85
|
+
if (size > maxBytes) {
|
|
86
|
+
await response.body.cancel?.().catch?.(() => undefined);
|
|
87
|
+
throw new ImagePromptError(
|
|
88
|
+
'image-too-large',
|
|
89
|
+
`Image response exceeded ${maxBytes} bytes`,
|
|
90
|
+
'图片超过 5 MB,请压缩后重试。',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
chunks.push(data);
|
|
94
|
+
}
|
|
95
|
+
return Buffer.concat(chunks, size);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const data = Buffer.from(await response.arrayBuffer());
|
|
99
|
+
if (data.length > maxBytes) {
|
|
100
|
+
throw new ImagePromptError(
|
|
101
|
+
'image-too-large',
|
|
102
|
+
`Image response contains ${data.length} bytes; the limit is ${maxBytes}`,
|
|
103
|
+
'图片超过 5 MB,请压缩后重试。',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return data;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function cleanText(value) {
|
|
110
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function imageSources(message) {
|
|
114
|
+
return Array.isArray(message?.images) ? message.images.filter(Boolean) : [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function safeName(value) {
|
|
118
|
+
if (typeof value !== 'string') return undefined;
|
|
119
|
+
const name = value
|
|
120
|
+
.replaceAll('\\', '/')
|
|
121
|
+
.split('/')
|
|
122
|
+
.at(-1)
|
|
123
|
+
?.replace(/[\u0000-\u001f\u007f]/g, '')
|
|
124
|
+
.trim()
|
|
125
|
+
.slice(0, 255);
|
|
126
|
+
return name || undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function detectedImageMediaType(data) {
|
|
130
|
+
if (data.length >= 8
|
|
131
|
+
&& data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47
|
|
132
|
+
&& data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a) {
|
|
133
|
+
return 'image/png';
|
|
134
|
+
}
|
|
135
|
+
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
|
|
136
|
+
return 'image/jpeg';
|
|
137
|
+
}
|
|
138
|
+
if (data.length >= 6) {
|
|
139
|
+
const signature = data.subarray(0, 6).toString('ascii');
|
|
140
|
+
if (signature === 'GIF87a' || signature === 'GIF89a') return 'image/gif';
|
|
141
|
+
}
|
|
142
|
+
if (data.length >= 12
|
|
143
|
+
&& data.subarray(0, 4).toString('ascii') === 'RIFF'
|
|
144
|
+
&& data.subarray(8, 12).toString('ascii') === 'WEBP') {
|
|
145
|
+
return 'image/webp';
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function loadedImage(value) {
|
|
151
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
|
152
|
+
return { data: Buffer.from(value) };
|
|
153
|
+
}
|
|
154
|
+
const raw = value?.data ?? value?.buffer;
|
|
155
|
+
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
|
|
156
|
+
return {
|
|
157
|
+
data: Buffer.from(raw),
|
|
158
|
+
name: value?.name ?? value?.filename,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function hasInboundImages(message) {
|
|
165
|
+
return imageSources(message).length > 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function hasInboundPrompt(message) {
|
|
169
|
+
return Boolean(cleanText(message?.content)) || hasInboundImages(message);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function promptContentForMessage(message, {
|
|
173
|
+
signal,
|
|
174
|
+
maxImageBytes = DEFAULT_MAX_IMAGE_BYTES,
|
|
175
|
+
maxImages = DEFAULT_MAX_IMAGES,
|
|
176
|
+
maxTotalImageBytes = DEFAULT_MAX_TOTAL_IMAGE_BYTES,
|
|
177
|
+
} = {}) {
|
|
178
|
+
const sources = imageSources(message);
|
|
179
|
+
if (sources.length > maxImages) {
|
|
180
|
+
throw new ImagePromptError(
|
|
181
|
+
'too-many-images',
|
|
182
|
+
`Image message contains ${sources.length} images; the limit is ${maxImages}`,
|
|
183
|
+
`一次最多只能处理 ${maxImages} 张图片。`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const text = cleanText(message?.content);
|
|
188
|
+
const content = [];
|
|
189
|
+
let totalImageBytes = 0;
|
|
190
|
+
if (text) content.push({ type: 'text', text });
|
|
191
|
+
else if (sources.length > 0) content.push({ type: 'text', text: DEFAULT_IMAGE_PROMPT });
|
|
192
|
+
|
|
193
|
+
for (const [index, source] of sources.entries()) {
|
|
194
|
+
signal?.throwIfAborted();
|
|
195
|
+
if (Number.isFinite(source?.size) && source.size > maxImageBytes) {
|
|
196
|
+
throw new ImagePromptError(
|
|
197
|
+
'image-too-large',
|
|
198
|
+
`Image ${index + 1} declares ${source.size} bytes; the limit is ${maxImageBytes}`,
|
|
199
|
+
'图片超过 5 MB,请压缩后重试。',
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (Number.isFinite(source?.size) && totalImageBytes + source.size > maxTotalImageBytes) {
|
|
203
|
+
throw new ImagePromptError(
|
|
204
|
+
'images-too-large',
|
|
205
|
+
`Images declare more than ${maxTotalImageBytes} bytes in total`,
|
|
206
|
+
'一次发送的图片总大小过大,请减少图片数量或压缩后重试。',
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let result;
|
|
211
|
+
try {
|
|
212
|
+
result = source?.data === undefined
|
|
213
|
+
? await source?.load?.({ signal, maxBytes: maxImageBytes })
|
|
214
|
+
: source.data;
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (signal?.aborted || error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
|
|
217
|
+
if (error instanceof ImagePromptError) throw error;
|
|
218
|
+
throw new ImagePromptError(
|
|
219
|
+
'image-download-failed',
|
|
220
|
+
`Unable to download image ${index + 1}: ${error?.message ?? String(error)}`,
|
|
221
|
+
'图片下载失败,请重新发送后再试。',
|
|
222
|
+
{ cause: error },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
const loaded = loadedImage(result);
|
|
226
|
+
if (!loaded?.data.length) {
|
|
227
|
+
throw new ImagePromptError(
|
|
228
|
+
'invalid-image-data',
|
|
229
|
+
`Image ${index + 1} returned no data`,
|
|
230
|
+
'未能读取图片内容,请重新发送。',
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (loaded.data.length > maxImageBytes) {
|
|
234
|
+
throw new ImagePromptError(
|
|
235
|
+
'image-too-large',
|
|
236
|
+
`Image ${index + 1} contains ${loaded.data.length} bytes; the limit is ${maxImageBytes}`,
|
|
237
|
+
'图片超过 5 MB,请压缩后重试。',
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (totalImageBytes + loaded.data.length > maxTotalImageBytes) {
|
|
241
|
+
throw new ImagePromptError(
|
|
242
|
+
'images-too-large',
|
|
243
|
+
`Images contain more than ${maxTotalImageBytes} bytes in total`,
|
|
244
|
+
'一次发送的图片总大小过大,请减少图片数量或压缩后重试。',
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
totalImageBytes += loaded.data.length;
|
|
248
|
+
const mediaType = detectedImageMediaType(loaded.data);
|
|
249
|
+
if (!mediaType) {
|
|
250
|
+
throw new ImagePromptError(
|
|
251
|
+
'unsupported-image-type',
|
|
252
|
+
`Image ${index + 1} is not JPEG, PNG, GIF, or WebP`,
|
|
253
|
+
'暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。',
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
content.push({
|
|
257
|
+
type: 'image',
|
|
258
|
+
mediaType,
|
|
259
|
+
data: loaded.data.toString('base64'),
|
|
260
|
+
...(safeName(loaded.name ?? source?.name) ? { name: safeName(loaded.name ?? source?.name) } : {}),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return content;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function imagePromptUserMessage(error) {
|
|
267
|
+
return error instanceof ImagePromptError ? error.userMessage : null;
|
|
268
|
+
}
|
|
@@ -2,6 +2,11 @@ import { runWorkspaceCommand } from './workspace-command.mjs';
|
|
|
2
2
|
import { runCompactCommand } from './compact-command.mjs';
|
|
3
3
|
import { askInWorkspaceSession } from './workspace-session.mjs';
|
|
4
4
|
import { HarnessApprovalQueue } from './harness-approval.mjs';
|
|
5
|
+
import {
|
|
6
|
+
hasInboundImages,
|
|
7
|
+
imagePromptUserMessage,
|
|
8
|
+
promptContentForMessage,
|
|
9
|
+
} from './image-prompt.mjs';
|
|
5
10
|
import {
|
|
6
11
|
harnessAnswerForQuestion,
|
|
7
12
|
harnessQuestionText,
|
|
@@ -17,6 +22,7 @@ function cleanText(value) {
|
|
|
17
22
|
function canClaimInteractionReply(message, pending, senderId) {
|
|
18
23
|
return pending.actor === senderId
|
|
19
24
|
&& (message.kind !== 'group' || message.addressed === true)
|
|
25
|
+
&& !hasInboundImages(message)
|
|
20
26
|
&& Boolean(cleanText(message.content));
|
|
21
27
|
}
|
|
22
28
|
|
|
@@ -98,7 +104,7 @@ export class TextHarnessBridge {
|
|
|
98
104
|
key,
|
|
99
105
|
actor: senderId,
|
|
100
106
|
messageId,
|
|
101
|
-
text: normalized.content,
|
|
107
|
+
text: hasInboundImages(normalized) ? '' : normalized.content,
|
|
102
108
|
addressed: normalized.kind !== 'group' || normalized.addressed === true,
|
|
103
109
|
hasPendingQuestion: Boolean(pending),
|
|
104
110
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
@@ -208,16 +214,17 @@ export class TextHarnessBridge {
|
|
|
208
214
|
this.#status.lastRejectedAt = new Date().toISOString();
|
|
209
215
|
return;
|
|
210
216
|
}
|
|
211
|
-
|
|
212
|
-
|
|
217
|
+
const hasImages = hasInboundImages(message);
|
|
218
|
+
if (!text && !hasImages) {
|
|
219
|
+
await this.#bot.sendText(target, '目前支持文字和图片消息。');
|
|
213
220
|
return;
|
|
214
221
|
}
|
|
215
222
|
const command = text.toLowerCase();
|
|
216
|
-
if (command === '/help') {
|
|
223
|
+
if (!hasImages && command === '/help') {
|
|
217
224
|
await this.#bot.sendText(target, [
|
|
218
225
|
`${this.#descriptor.label}机器人已连接 DeepSeek Harness。`,
|
|
219
226
|
'',
|
|
220
|
-
'
|
|
227
|
+
'直接发送文字或图片即可继续当前会话。',
|
|
221
228
|
'/new 开启一个全新会话',
|
|
222
229
|
'/compact 压缩当前会话的较早上下文',
|
|
223
230
|
'/workspace 工作区绝对路径 切换工作区',
|
|
@@ -229,30 +236,34 @@ export class TextHarnessBridge {
|
|
|
229
236
|
].join('\n'));
|
|
230
237
|
return;
|
|
231
238
|
}
|
|
232
|
-
if (command === '/status') {
|
|
239
|
+
if (!hasImages && command === '/status') {
|
|
233
240
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
234
241
|
await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
|
|
235
242
|
return;
|
|
236
243
|
}
|
|
237
|
-
const workspaceCommand =
|
|
244
|
+
const workspaceCommand = !hasImages
|
|
245
|
+
? await runWorkspaceCommand(text, this.#harness, conversationKey)
|
|
246
|
+
: null;
|
|
238
247
|
if (workspaceCommand) {
|
|
239
248
|
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
240
249
|
await this.#bot.sendText(target, reply);
|
|
241
250
|
}
|
|
242
251
|
return;
|
|
243
252
|
}
|
|
244
|
-
if (command === '/new') {
|
|
253
|
+
if (!hasImages && command === '/new') {
|
|
245
254
|
await this.#state.clearSession(conversationKey);
|
|
246
255
|
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
|
247
256
|
return;
|
|
248
257
|
}
|
|
249
|
-
const compactCommand =
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
const compactCommand = !hasImages
|
|
259
|
+
? await runCompactCommand(
|
|
260
|
+
text,
|
|
261
|
+
this.#harness,
|
|
262
|
+
this.#state,
|
|
263
|
+
conversationKey,
|
|
264
|
+
{ signal: this.#signal },
|
|
265
|
+
)
|
|
266
|
+
: null;
|
|
256
267
|
if (compactCommand) {
|
|
257
268
|
await this.#bot.sendText(target, compactCommand.message);
|
|
258
269
|
return;
|
|
@@ -272,11 +283,15 @@ export class TextHarnessBridge {
|
|
|
272
283
|
);
|
|
273
284
|
}
|
|
274
285
|
}
|
|
286
|
+
const content = hasImages
|
|
287
|
+
? await promptContentForMessage(message, { signal: this.#signal })
|
|
288
|
+
: undefined;
|
|
275
289
|
const { answer } = await askInWorkspaceSession({
|
|
276
290
|
harness: this.#harness,
|
|
277
291
|
state: this.#state,
|
|
278
292
|
key: conversationKey,
|
|
279
293
|
text,
|
|
294
|
+
content,
|
|
280
295
|
createOptions: this.#signal ? { signal: this.#signal } : undefined,
|
|
281
296
|
existsOptions: this.#signal ? { signal: this.#signal } : undefined,
|
|
282
297
|
askOptions: {
|
|
@@ -316,6 +331,18 @@ export class TextHarnessBridge {
|
|
|
316
331
|
stream?.cancel?.();
|
|
317
332
|
if (this.#signal?.aborted) return;
|
|
318
333
|
this.#status.lastError = error?.message ?? String(error);
|
|
334
|
+
const imageErrorMessage = imagePromptUserMessage(error);
|
|
335
|
+
if (imageErrorMessage) {
|
|
336
|
+
try {
|
|
337
|
+
await this.#bot.sendText(target, imageErrorMessage);
|
|
338
|
+
} catch (sendError) {
|
|
339
|
+
this.#logger.error?.(
|
|
340
|
+
`[dsh-im:${this.#descriptor.key}] failed to send the image error reply:`,
|
|
341
|
+
sendError,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
319
346
|
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
|
|
320
347
|
try {
|
|
321
348
|
await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
|
|
@@ -358,7 +385,7 @@ export class TextHarnessBridge {
|
|
|
358
385
|
|
|
359
386
|
const target = message.replyTarget;
|
|
360
387
|
const text = cleanText(message.content);
|
|
361
|
-
if (!text) {
|
|
388
|
+
if (!text || hasInboundImages(message)) {
|
|
362
389
|
try {
|
|
363
390
|
await this.#bot.sendText(target, '请用文字回答当前问题。');
|
|
364
391
|
} catch (error) {
|
|
@@ -33,6 +33,7 @@ export async function askInWorkspaceSession({
|
|
|
33
33
|
state,
|
|
34
34
|
key,
|
|
35
35
|
text,
|
|
36
|
+
content,
|
|
36
37
|
createOptions,
|
|
37
38
|
existsOptions,
|
|
38
39
|
askOptions,
|
|
@@ -48,7 +49,7 @@ export async function askInWorkspaceSession({
|
|
|
48
49
|
}
|
|
49
50
|
return {
|
|
50
51
|
sessionId,
|
|
51
|
-
answer: await session.ask(text, askOptions),
|
|
52
|
+
answer: await session.ask(content ?? text, askOptions),
|
|
52
53
|
};
|
|
53
54
|
} catch (error) {
|
|
54
55
|
if (error?.code !== WORKSPACE_SESSION_STALE) throw error;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
|
|
2
|
+
|
|
1
3
|
const DEFAULT_BASE_URL = 'https://slack.com/api/';
|
|
4
|
+
const SLACK_FILE_HOST = 'files.slack.com';
|
|
5
|
+
const LEGACY_SLACK_FILE_HOST = 'slack.com';
|
|
6
|
+
const SLACK_FILE_PATH_PREFIX = '/files-pri/';
|
|
7
|
+
const SLACK_FILE_HOSTS = Object.freeze([SLACK_FILE_HOST]);
|
|
2
8
|
|
|
3
9
|
function cleanString(value) {
|
|
4
10
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
@@ -9,6 +15,61 @@ function requestSignal(signal, timeoutMs) {
|
|
|
9
15
|
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
10
16
|
}
|
|
11
17
|
|
|
18
|
+
function isRedirectStatus(status) {
|
|
19
|
+
return Number.isInteger(status) && status >= 300 && status < 400;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function cancelResponseBody(response) {
|
|
23
|
+
try {
|
|
24
|
+
await response?.body?.cancel?.();
|
|
25
|
+
} catch {
|
|
26
|
+
// Preserve the download failure when response cleanup also fails.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function secureSlackFileUrl(value) {
|
|
31
|
+
const url = new URL(value);
|
|
32
|
+
if (url.protocol !== 'https:') throw new Error('Image download URL must use HTTPS');
|
|
33
|
+
if (url.username || url.password || (url.port && url.port !== '443')) {
|
|
34
|
+
throw new Error('Image download URL is not hosted by the messaging platform');
|
|
35
|
+
}
|
|
36
|
+
if (url.hostname === LEGACY_SLACK_FILE_HOST && url.pathname.startsWith(SLACK_FILE_PATH_PREFIX)) {
|
|
37
|
+
url.hostname = SLACK_FILE_HOST;
|
|
38
|
+
}
|
|
39
|
+
if (url.hostname !== SLACK_FILE_HOST || !url.pathname.startsWith(SLACK_FILE_PATH_PREFIX)) {
|
|
40
|
+
throw new Error('Image download URL is not hosted by the messaging platform');
|
|
41
|
+
}
|
|
42
|
+
url.hash = '';
|
|
43
|
+
return url;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function redirectUrl(response, source) {
|
|
47
|
+
const location = response?.headers?.get?.('location');
|
|
48
|
+
if (!location) return null;
|
|
49
|
+
try {
|
|
50
|
+
return new URL(location, source);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function responseOAuthScopes(response) {
|
|
57
|
+
const raw = response?.headers?.get?.('x-oauth-scopes');
|
|
58
|
+
if (raw === null || raw === undefined) return null;
|
|
59
|
+
return new Set(raw.split(',').map((scope) => scope.trim()).filter(Boolean));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isSlackWorkspaceRedirect(target) {
|
|
63
|
+
return target?.protocol === 'https:'
|
|
64
|
+
&& !target.username
|
|
65
|
+
&& !target.password
|
|
66
|
+
&& (!target.port || target.port === '443')
|
|
67
|
+
&& target.hostname.endsWith('.slack.com')
|
|
68
|
+
&& target.hostname !== SLACK_FILE_HOST
|
|
69
|
+
&& target.pathname === '/'
|
|
70
|
+
&& target.searchParams.has('redir');
|
|
71
|
+
}
|
|
72
|
+
|
|
12
73
|
function delay(ms, signal) {
|
|
13
74
|
return new Promise((resolve, reject) => {
|
|
14
75
|
if (signal?.aborted) {
|
|
@@ -79,6 +140,7 @@ export class SlackApi {
|
|
|
79
140
|
#appToken;
|
|
80
141
|
#fetch;
|
|
81
142
|
#baseUrl;
|
|
143
|
+
#botScopes = null;
|
|
82
144
|
|
|
83
145
|
constructor({ botToken, appToken, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
|
|
84
146
|
if (botToken !== undefined && !validSlackBotToken(botToken)) {
|
|
@@ -177,6 +239,34 @@ export class SlackApi {
|
|
|
177
239
|
});
|
|
178
240
|
}
|
|
179
241
|
|
|
242
|
+
async downloadFile({ url, signal, maxBytes }) {
|
|
243
|
+
if (!this.#botToken) throw new TypeError('Slack bot token is required for file download');
|
|
244
|
+
const target = secureSlackFileUrl(url);
|
|
245
|
+
const fetchSlackFile = async (requestUrl, options) => {
|
|
246
|
+
const response = await this.#fetch(requestUrl, options);
|
|
247
|
+
if (!isRedirectStatus(response?.status)) return response;
|
|
248
|
+
|
|
249
|
+
const next = redirectUrl(response, requestUrl);
|
|
250
|
+
if ((this.#botScopes && !this.#botScopes.has('files:read'))
|
|
251
|
+
|| isSlackWorkspaceRedirect(next)) {
|
|
252
|
+
await cancelResponseBody(response);
|
|
253
|
+
throw new ImagePromptError(
|
|
254
|
+
'slack-file-access-required',
|
|
255
|
+
'Slack redirected a private file request to the workspace because file access was not granted',
|
|
256
|
+
'Slack 未授权机器人读取该文件。请为应用添加 files:read 后重新安装,再重新发送图片。',
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return response;
|
|
260
|
+
};
|
|
261
|
+
return fetchImageBuffer(target, {
|
|
262
|
+
fetchImpl: fetchSlackFile,
|
|
263
|
+
headers: { authorization: `Bearer ${this.#botToken}` },
|
|
264
|
+
signal,
|
|
265
|
+
maxBytes,
|
|
266
|
+
allowedHosts: SLACK_FILE_HOSTS,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
180
270
|
async #request(method, {
|
|
181
271
|
tokenKind,
|
|
182
272
|
body,
|
|
@@ -206,6 +296,11 @@ export class SlackApi {
|
|
|
206
296
|
throw new Error(`Slack ${method} transport failed`);
|
|
207
297
|
}
|
|
208
298
|
|
|
299
|
+
if (tokenKind === 'bot') {
|
|
300
|
+
const scopes = responseOAuthScopes(response);
|
|
301
|
+
if (scopes) this.#botScopes = scopes;
|
|
302
|
+
}
|
|
303
|
+
|
|
209
304
|
let payload;
|
|
210
305
|
try {
|
|
211
306
|
payload = await response.json();
|
|
@@ -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),
|
|
@@ -389,7 +408,9 @@ export class SlackRuntime {
|
|
|
389
408
|
if (packet.type !== 'events_api' || packet.payload?.type !== 'event_callback') return;
|
|
390
409
|
if (this.#appId && packet.payload.api_app_id
|
|
391
410
|
&& packet.payload.api_app_id !== this.#appId) return;
|
|
392
|
-
const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1]
|
|
411
|
+
const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1], {
|
|
412
|
+
loadFile: (url, options) => this.#api.downloadFile({ url, ...options }),
|
|
413
|
+
});
|
|
393
414
|
const bridge = this.#bridge;
|
|
394
415
|
if (message && bridge) {
|
|
395
416
|
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,
|