@xmanrui/dsh-im 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +7 -3
- package/README.md +7 -3
- package/lib/client.js +378 -27
- package/lib/index.js +134 -131
- package/package.json +1 -1
- package/plugin-src/client/channels/dingtalk/api.js +19 -0
- package/plugin-src/client/channels/dingtalk/index.js +67 -10
- package/plugin-src/client/channels/feishu/index.js +35 -5
- package/plugin-src/client/channels/qq/api.js +21 -0
- package/plugin-src/client/channels/qq/index.js +39 -1
- package/plugin-src/client/channels/shared/token-channel.js +29 -3
- package/plugin-src/client/channels/wecom/api.js +11 -0
- package/plugin-src/client/channels/wecom/index.js +71 -1
- package/plugin-src/client/channels/weixin/api.js +11 -0
- package/plugin-src/client/channels/weixin/index.js +40 -4
- package/plugin-src/client/channels/whatsapp/index.js +41 -2
- package/plugin-src/client/i18n.js +13 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
- package/plugin-src/host/channels/dingtalk/rpc.mjs +29 -3
- package/plugin-src/host/channels/feishu/production.mjs +1 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +34 -3
- package/plugin-src/host/channels/qq/production.mjs +1 -1
- package/plugin-src/host/channels/qq/rpc.mjs +35 -2
- package/plugin-src/host/channels/shared/production.mjs +1 -1
- package/plugin-src/host/channels/shared/rpc.mjs +25 -1
- package/plugin-src/host/channels/slack/production.mjs +1 -1
- package/plugin-src/host/channels/slack/rpc.mjs +28 -2
- package/plugin-src/host/channels/wecom/production.mjs +1 -1
- package/plugin-src/host/channels/wecom/rpc.mjs +31 -2
- package/plugin-src/host/channels/weixin/production.mjs +1 -1
- package/plugin-src/host/channels/weixin/rpc.mjs +29 -3
- package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
- package/plugin-src/host/channels/whatsapp/rpc.mjs +31 -3
- package/src/channels/dingtalk/dingtalk-api.mjs +82 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +160 -20
- package/src/channels/dingtalk/dingtalk-controller.mjs +18 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +21 -0
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/discord/discord-runtime.mjs +54 -1
- package/src/channels/feishu/bridge.mjs +45 -18
- package/src/channels/feishu/feishu-runtime.mjs +45 -0
- package/src/channels/feishu/message-utils.mjs +227 -6
- package/src/channels/feishu/multi-bot-controller.mjs +21 -0
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/qq/qq-bridge.mjs +107 -20
- package/src/channels/qq/qq-controller.mjs +18 -0
- package/src/channels/qq/qq-runtime.mjs +26 -0
- package/src/channels/shared/bot-workspace-store.mjs +2 -0
- package/src/channels/shared/connection-test.mjs +54 -0
- package/src/channels/shared/harness-client.mjs +14 -8
- package/src/channels/shared/image-prompt.mjs +268 -0
- package/src/channels/shared/text-harness-bridge.mjs +62 -16
- package/src/channels/shared/token-bot-controller.mjs +21 -0
- package/src/channels/shared/workspace-session.mjs +2 -1
- package/src/channels/slack/manifest.mjs +1 -0
- package/src/channels/slack/slack-api.mjs +95 -0
- package/src/channels/slack/slack-controller.mjs +19 -0
- package/src/channels/slack/slack-runtime.mjs +34 -3
- package/src/channels/telegram/telegram-api.mjs +37 -0
- package/src/channels/telegram/telegram-runtime.mjs +81 -9
- package/src/channels/wecom/wecom-bridge.mjs +160 -23
- package/src/channels/wecom/wecom-controller.mjs +18 -0
- package/src/channels/wecom/wecom-runtime.mjs +18 -0
- package/src/channels/weixin/weixin-api.mjs +98 -2
- package/src/channels/weixin/weixin-bridge.mjs +52 -19
- package/src/channels/weixin/weixin-controller.mjs +18 -0
- package/src/channels/weixin/weixin-runtime.mjs +25 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +20 -0
- package/src/channels/whatsapp/whatsapp-runtime.mjs +160 -1
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { runWorkspaceCommand } from './workspace-command.mjs';
|
|
2
2
|
import { runCompactCommand } from './compact-command.mjs';
|
|
3
|
+
import {
|
|
4
|
+
rememberConnectionTestTarget,
|
|
5
|
+
sendRememberedConnectionTest,
|
|
6
|
+
} from './connection-test.mjs';
|
|
3
7
|
import { askInWorkspaceSession } from './workspace-session.mjs';
|
|
4
8
|
import { HarnessApprovalQueue } from './harness-approval.mjs';
|
|
9
|
+
import {
|
|
10
|
+
hasInboundImages,
|
|
11
|
+
imagePromptUserMessage,
|
|
12
|
+
promptContentForMessage,
|
|
13
|
+
} from './image-prompt.mjs';
|
|
5
14
|
import {
|
|
6
15
|
harnessAnswerForQuestion,
|
|
7
16
|
harnessQuestionText,
|
|
@@ -17,6 +26,7 @@ function cleanText(value) {
|
|
|
17
26
|
function canClaimInteractionReply(message, pending, senderId) {
|
|
18
27
|
return pending.actor === senderId
|
|
19
28
|
&& (message.kind !== 'group' || message.addressed === true)
|
|
29
|
+
&& !hasInboundImages(message)
|
|
20
30
|
&& Boolean(cleanText(message.content));
|
|
21
31
|
}
|
|
22
32
|
|
|
@@ -91,6 +101,12 @@ export class TextHarnessBridge {
|
|
|
91
101
|
return Promise.resolve();
|
|
92
102
|
}
|
|
93
103
|
this.#acceptedMessageIds.add(messageId);
|
|
104
|
+
if (normalized.kind === 'direct') {
|
|
105
|
+
rememberConnectionTestTarget(
|
|
106
|
+
this.#state,
|
|
107
|
+
normalized.connectionTestTarget ?? normalized.replyTarget,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
94
110
|
|
|
95
111
|
const key = `${kind}:${conversationId}`;
|
|
96
112
|
const pending = this.#pendingInteractions.get(key);
|
|
@@ -98,7 +114,7 @@ export class TextHarnessBridge {
|
|
|
98
114
|
key,
|
|
99
115
|
actor: senderId,
|
|
100
116
|
messageId,
|
|
101
|
-
text: normalized.content,
|
|
117
|
+
text: hasInboundImages(normalized) ? '' : normalized.content,
|
|
102
118
|
addressed: normalized.kind !== 'group' || normalized.addressed === true,
|
|
103
119
|
hasPendingQuestion: Boolean(pending),
|
|
104
120
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
@@ -188,6 +204,15 @@ export class TextHarnessBridge {
|
|
|
188
204
|
]);
|
|
189
205
|
}
|
|
190
206
|
|
|
207
|
+
sendConnectionTest(text) {
|
|
208
|
+
return sendRememberedConnectionTest({
|
|
209
|
+
state: this.#state,
|
|
210
|
+
text,
|
|
211
|
+
channelLabel: `${this.#descriptor.label}机器人`,
|
|
212
|
+
send: (target, message) => this.#bot.sendText(target, message),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
191
216
|
async #process(message, messageId, senderId, conversationKey, {
|
|
192
217
|
alreadyRecorded = false,
|
|
193
218
|
} = {}) {
|
|
@@ -208,16 +233,17 @@ export class TextHarnessBridge {
|
|
|
208
233
|
this.#status.lastRejectedAt = new Date().toISOString();
|
|
209
234
|
return;
|
|
210
235
|
}
|
|
211
|
-
|
|
212
|
-
|
|
236
|
+
const hasImages = hasInboundImages(message);
|
|
237
|
+
if (!text && !hasImages) {
|
|
238
|
+
await this.#bot.sendText(target, '目前支持文字和图片消息。');
|
|
213
239
|
return;
|
|
214
240
|
}
|
|
215
241
|
const command = text.toLowerCase();
|
|
216
|
-
if (command === '/help') {
|
|
242
|
+
if (!hasImages && command === '/help') {
|
|
217
243
|
await this.#bot.sendText(target, [
|
|
218
244
|
`${this.#descriptor.label}机器人已连接 DeepSeek Harness。`,
|
|
219
245
|
'',
|
|
220
|
-
'
|
|
246
|
+
'直接发送文字或图片即可继续当前会话。',
|
|
221
247
|
'/new 开启一个全新会话',
|
|
222
248
|
'/compact 压缩当前会话的较早上下文',
|
|
223
249
|
'/workspace 工作区绝对路径 切换工作区',
|
|
@@ -229,30 +255,34 @@ export class TextHarnessBridge {
|
|
|
229
255
|
].join('\n'));
|
|
230
256
|
return;
|
|
231
257
|
}
|
|
232
|
-
if (command === '/status') {
|
|
258
|
+
if (!hasImages && command === '/status') {
|
|
233
259
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
234
260
|
await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
|
|
235
261
|
return;
|
|
236
262
|
}
|
|
237
|
-
const workspaceCommand =
|
|
263
|
+
const workspaceCommand = !hasImages
|
|
264
|
+
? await runWorkspaceCommand(text, this.#harness, conversationKey)
|
|
265
|
+
: null;
|
|
238
266
|
if (workspaceCommand) {
|
|
239
267
|
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
240
268
|
await this.#bot.sendText(target, reply);
|
|
241
269
|
}
|
|
242
270
|
return;
|
|
243
271
|
}
|
|
244
|
-
if (command === '/new') {
|
|
272
|
+
if (!hasImages && command === '/new') {
|
|
245
273
|
await this.#state.clearSession(conversationKey);
|
|
246
274
|
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
|
247
275
|
return;
|
|
248
276
|
}
|
|
249
|
-
const compactCommand =
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
277
|
+
const compactCommand = !hasImages
|
|
278
|
+
? await runCompactCommand(
|
|
279
|
+
text,
|
|
280
|
+
this.#harness,
|
|
281
|
+
this.#state,
|
|
282
|
+
conversationKey,
|
|
283
|
+
{ signal: this.#signal },
|
|
284
|
+
)
|
|
285
|
+
: null;
|
|
256
286
|
if (compactCommand) {
|
|
257
287
|
await this.#bot.sendText(target, compactCommand.message);
|
|
258
288
|
return;
|
|
@@ -272,11 +302,15 @@ export class TextHarnessBridge {
|
|
|
272
302
|
);
|
|
273
303
|
}
|
|
274
304
|
}
|
|
305
|
+
const content = hasImages
|
|
306
|
+
? await promptContentForMessage(message, { signal: this.#signal })
|
|
307
|
+
: undefined;
|
|
275
308
|
const { answer } = await askInWorkspaceSession({
|
|
276
309
|
harness: this.#harness,
|
|
277
310
|
state: this.#state,
|
|
278
311
|
key: conversationKey,
|
|
279
312
|
text,
|
|
313
|
+
content,
|
|
280
314
|
createOptions: this.#signal ? { signal: this.#signal } : undefined,
|
|
281
315
|
existsOptions: this.#signal ? { signal: this.#signal } : undefined,
|
|
282
316
|
askOptions: {
|
|
@@ -316,6 +350,18 @@ export class TextHarnessBridge {
|
|
|
316
350
|
stream?.cancel?.();
|
|
317
351
|
if (this.#signal?.aborted) return;
|
|
318
352
|
this.#status.lastError = error?.message ?? String(error);
|
|
353
|
+
const imageErrorMessage = imagePromptUserMessage(error);
|
|
354
|
+
if (imageErrorMessage) {
|
|
355
|
+
try {
|
|
356
|
+
await this.#bot.sendText(target, imageErrorMessage);
|
|
357
|
+
} catch (sendError) {
|
|
358
|
+
this.#logger.error?.(
|
|
359
|
+
`[dsh-im:${this.#descriptor.key}] failed to send the image error reply:`,
|
|
360
|
+
sendError,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
319
365
|
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
|
|
320
366
|
try {
|
|
321
367
|
await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
|
|
@@ -358,7 +404,7 @@ export class TextHarnessBridge {
|
|
|
358
404
|
|
|
359
405
|
const target = message.replyTarget;
|
|
360
406
|
const text = cleanText(message.content);
|
|
361
|
-
if (!text) {
|
|
407
|
+
if (!text || hasInboundImages(message)) {
|
|
362
408
|
try {
|
|
363
409
|
await this.#bot.sendText(target, '请用文字回答当前问题。');
|
|
364
410
|
} catch (error) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { connectionTestMessage } from './connection-test.mjs';
|
|
2
|
+
|
|
1
3
|
function cleanString(value) {
|
|
2
4
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
3
5
|
}
|
|
@@ -161,6 +163,25 @@ export class TokenBotController {
|
|
|
161
163
|
return this.status();
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
async sendConnectionTest(botId) {
|
|
167
|
+
const config = this.#configStore.get(botId);
|
|
168
|
+
if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
|
|
169
|
+
return this.#withBotTransition(botId, async () => {
|
|
170
|
+
const runtime = this.#runtimes.get(botId);
|
|
171
|
+
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
|
172
|
+
const error = new Error(`${this.#descriptor.label}机器人尚未连接`);
|
|
173
|
+
error.code = 'test-target-unavailable';
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
const cardLabel = `${config.name}(${this.#maskPlatformId(config.platformId)})`;
|
|
177
|
+
await runtime.sendConnectionTest(connectionTestMessage(
|
|
178
|
+
cardLabel,
|
|
179
|
+
`${this.#descriptor.label}机器人`,
|
|
180
|
+
));
|
|
181
|
+
return { sent: true };
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
164
185
|
async deleteBot(botId) {
|
|
165
186
|
const config = this.#configStore.get(botId);
|
|
166
187
|
if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
|
|
@@ -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();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
|
1
2
|
import { deriveSlackBotIdentity, maskSlackBotId } from './config-store.mjs';
|
|
2
3
|
import { inspectSlackCredentials } from './slack-api.mjs';
|
|
3
4
|
import { SLACK_DESCRIPTOR } from './slack-bridge.mjs';
|
|
@@ -168,6 +169,24 @@ export class SlackController {
|
|
|
168
169
|
return this.status();
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
async sendConnectionTest(botId) {
|
|
173
|
+
const config = this.#configStore.get(botId);
|
|
174
|
+
if (!config) throw new Error('Unknown Slack bot');
|
|
175
|
+
return this.#withBotTransition(botId, async () => {
|
|
176
|
+
const runtime = this.#runtimes.get(botId);
|
|
177
|
+
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
|
178
|
+
const error = new Error('Slack机器人尚未连接');
|
|
179
|
+
error.code = 'test-target-unavailable';
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
await runtime.sendConnectionTest(connectionTestMessage(
|
|
183
|
+
`${config.name}(${maskSlackBotId(config.platformId)})`,
|
|
184
|
+
'Slack机器人',
|
|
185
|
+
));
|
|
186
|
+
return { sent: true };
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
171
190
|
async deleteBot(botId) {
|
|
172
191
|
const config = this.#configStore.get(botId);
|
|
173
192
|
if (!config) throw new Error('Unknown Slack bot');
|