@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
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createDingtalkBridgeStatus,
|
|
4
4
|
DingtalkHarnessBridge,
|
|
5
5
|
} from './dingtalk-bridge.mjs';
|
|
6
|
+
import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
|
|
6
7
|
|
|
7
8
|
function nonEmptyString(value) {
|
|
8
9
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
@@ -347,6 +348,26 @@ export class DingtalkRuntime {
|
|
|
347
348
|
return this.status;
|
|
348
349
|
}
|
|
349
350
|
|
|
351
|
+
async sendConnectionTest(text) {
|
|
352
|
+
return sendRememberedConnectionTest({
|
|
353
|
+
state: this.#state,
|
|
354
|
+
text,
|
|
355
|
+
channelLabel: '钉钉机器人',
|
|
356
|
+
send: async ({ sessionWebhook }, content) => {
|
|
357
|
+
if (!this.#status.ready || !this.#abortController) {
|
|
358
|
+
throw new Error('DingTalk runtime is not connected');
|
|
359
|
+
}
|
|
360
|
+
await this.#api.sendText({
|
|
361
|
+
clientId: this.#config.clientId,
|
|
362
|
+
clientSecret: this.#clientSecret,
|
|
363
|
+
sessionWebhook,
|
|
364
|
+
text: content,
|
|
365
|
+
signal: this.#abortController.signal,
|
|
366
|
+
});
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
350
371
|
#pendingSenders() {
|
|
351
372
|
return typeof this.#state.pendingSenders === 'function'
|
|
352
373
|
? this.#state.pendingSenders()
|
|
@@ -108,7 +108,7 @@ export class DiscordApi {
|
|
|
108
108
|
headers: {
|
|
109
109
|
authorization: `Bot ${this.#token}`,
|
|
110
110
|
'content-type': 'application/json',
|
|
111
|
-
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.
|
|
111
|
+
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.11.0)',
|
|
112
112
|
},
|
|
113
113
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
114
114
|
signal: requestSignal(signal, timeoutMs),
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
|
|
2
|
+
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
|
2
3
|
import { DiscordApi } from './discord-api.mjs';
|
|
3
4
|
import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
|
|
4
5
|
|
|
5
6
|
const DISCORD_GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12);
|
|
6
7
|
const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
|
|
8
|
+
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
9
|
+
const IMAGE_FILE_TYPES = new Map([
|
|
10
|
+
['.jpg', 'image/jpeg'],
|
|
11
|
+
['.jpeg', 'image/jpeg'],
|
|
12
|
+
['.png', 'image/png'],
|
|
13
|
+
['.webp', 'image/webp'],
|
|
14
|
+
['.gif', 'image/gif'],
|
|
15
|
+
]);
|
|
16
|
+
const DISCORD_IMAGE_HOSTS = Object.freeze(['cdn.discordapp.com']);
|
|
7
17
|
|
|
8
18
|
function socketUrl(value) {
|
|
9
19
|
const url = new URL(value);
|
|
@@ -48,7 +58,37 @@ function stripBotMention(text, botId) {
|
|
|
48
58
|
return text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
|
|
49
59
|
}
|
|
50
60
|
|
|
51
|
-
|
|
61
|
+
function attachmentMediaType(attachment) {
|
|
62
|
+
const value = typeof attachment?.content_type === 'string'
|
|
63
|
+
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : '';
|
|
64
|
+
if (IMAGE_MEDIA_TYPES.has(value)) return value;
|
|
65
|
+
const filename = typeof attachment?.filename === 'string' ? attachment.filename.toLowerCase() : '';
|
|
66
|
+
for (const [extension, mediaType] of IMAGE_FILE_TYPES) {
|
|
67
|
+
if (filename.endsWith(extension)) return mediaType;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function attachmentSize(value) {
|
|
73
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function discordImageSource(attachment, fetchImpl) {
|
|
77
|
+
const mediaType = attachmentMediaType(attachment);
|
|
78
|
+
if (!mediaType || typeof attachment?.url !== 'string') return null;
|
|
79
|
+
return {
|
|
80
|
+
name: typeof attachment.filename === 'string' ? attachment.filename : undefined,
|
|
81
|
+
mediaType,
|
|
82
|
+
size: attachmentSize(attachment.size),
|
|
83
|
+
load: (options) => fetchImageBuffer(attachment.url, {
|
|
84
|
+
...options,
|
|
85
|
+
fetchImpl,
|
|
86
|
+
allowedHosts: DISCORD_IMAGE_HOSTS,
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
|
|
52
92
|
if (!message?.id || !message?.channel_id || !message?.author?.id) return null;
|
|
53
93
|
const direct = !message.guild_id;
|
|
54
94
|
const addressed = direct
|
|
@@ -60,11 +100,15 @@ export function normalizeDiscordMessage(message, botId) {
|
|
|
60
100
|
kind: direct ? 'direct' : 'group',
|
|
61
101
|
conversationId: String(message.channel_id),
|
|
62
102
|
content: stripBotMention(message.content ?? '', botId),
|
|
103
|
+
images: Array.isArray(message.attachments)
|
|
104
|
+
? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
|
|
105
|
+
: [],
|
|
63
106
|
addressed,
|
|
64
107
|
replyTarget: {
|
|
65
108
|
channelId: String(message.channel_id),
|
|
66
109
|
replyToMessageId: String(message.id),
|
|
67
110
|
},
|
|
111
|
+
connectionTestTarget: { channelId: String(message.channel_id) },
|
|
68
112
|
};
|
|
69
113
|
}
|
|
70
114
|
|
|
@@ -196,6 +240,15 @@ export class DiscordRuntime {
|
|
|
196
240
|
return structuredClone(this.#status);
|
|
197
241
|
}
|
|
198
242
|
|
|
243
|
+
async sendConnectionTest(text) {
|
|
244
|
+
if (!this.#status.ready || !this.#bridge) {
|
|
245
|
+
const error = new Error('Discord bot is not connected');
|
|
246
|
+
error.code = 'test-target-unavailable';
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
return this.#bridge.sendConnectionTest(text);
|
|
250
|
+
}
|
|
251
|
+
|
|
199
252
|
async start() {
|
|
200
253
|
if (this.#status.ready && this.#socket) return this.status;
|
|
201
254
|
if (this.#starting) return this.#starting;
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
2
|
conversationKey,
|
|
3
|
+
extractInboundMessage,
|
|
3
4
|
extractText,
|
|
4
5
|
isAllowedSender,
|
|
5
6
|
isBotSender,
|
|
6
7
|
splitText,
|
|
7
8
|
} from './message-utils.mjs';
|
|
9
|
+
import {
|
|
10
|
+
hasInboundImages,
|
|
11
|
+
imagePromptUserMessage,
|
|
12
|
+
promptContentForMessage,
|
|
13
|
+
} from '../shared/image-prompt.mjs';
|
|
8
14
|
import {
|
|
9
15
|
harnessAnswerForQuestion,
|
|
10
16
|
harnessQuestionText,
|
|
@@ -12,6 +18,7 @@ import {
|
|
|
12
18
|
} from '../shared/harness-question.mjs';
|
|
13
19
|
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|
14
20
|
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
21
|
+
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
|
15
22
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
16
23
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
17
24
|
|
|
@@ -21,7 +28,7 @@ const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
|
|
21
28
|
const HELP_TEXT = [
|
|
22
29
|
'北汇星河 AIOS 已连接 DeepSeek Harness。',
|
|
23
30
|
'',
|
|
24
|
-
'
|
|
31
|
+
'直接发送文字或图片即可继续当前会话。',
|
|
25
32
|
'/new 开启一个全新会话',
|
|
26
33
|
'/compact 压缩当前会话的较早上下文',
|
|
27
34
|
'/workspace 工作区绝对路径 切换工作区',
|
|
@@ -127,6 +134,11 @@ export class FeishuHarnessBridge {
|
|
|
127
134
|
return Promise.resolve();
|
|
128
135
|
}
|
|
129
136
|
|
|
137
|
+
if (event.message.chat_type === 'p2p') {
|
|
138
|
+
const chatId = nonEmptyString(event.message.chat_id);
|
|
139
|
+
if (chatId) rememberConnectionTestTarget(this.#state, { chatId });
|
|
140
|
+
}
|
|
141
|
+
|
|
130
142
|
this.#acceptedMessageIds.add(messageId);
|
|
131
143
|
const processingReaction = this.#addReaction(messageId, 'OnIt');
|
|
132
144
|
if (this.#isResolvedQuestionReply(event, key)) {
|
|
@@ -273,7 +285,8 @@ export class FeishuHarnessBridge {
|
|
|
273
285
|
await this.#finishReaction(messageId, processingReaction, 'ERROR');
|
|
274
286
|
await this.#send(
|
|
275
287
|
event.message.chat_id,
|
|
276
|
-
|
|
288
|
+
imagePromptUserMessage(error)
|
|
289
|
+
?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
|
|
277
290
|
).catch(() => undefined);
|
|
278
291
|
}
|
|
279
292
|
|
|
@@ -297,40 +310,47 @@ export class FeishuHarnessBridge {
|
|
|
297
310
|
this.#status.messagesReceived += 1;
|
|
298
311
|
}
|
|
299
312
|
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
313
|
+
const message = extractInboundMessage(event, this.#client);
|
|
314
|
+
const text = message.content;
|
|
315
|
+
const hasImages = hasInboundImages(message);
|
|
316
|
+
const commandText = event.message.message_type === 'text' && !hasImages ? text : null;
|
|
317
|
+
if (!text && !hasImages) {
|
|
318
|
+
await this.#send(event.message.chat_id, '目前支持文字和图片消息。');
|
|
303
319
|
return;
|
|
304
320
|
}
|
|
305
321
|
|
|
306
|
-
if (
|
|
322
|
+
if (commandText === '/help') {
|
|
307
323
|
await this.#send(event.message.chat_id, HELP_TEXT);
|
|
308
324
|
return;
|
|
309
325
|
}
|
|
310
|
-
if (
|
|
326
|
+
if (commandText === '/new') {
|
|
311
327
|
await this.#state.clearSession(key);
|
|
312
328
|
await this.#send(event.message.chat_id, '已开启全新 Harness 会话。');
|
|
313
329
|
return;
|
|
314
330
|
}
|
|
315
|
-
if (
|
|
331
|
+
if (commandText === '/status') {
|
|
316
332
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
317
333
|
await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
|
|
318
334
|
return;
|
|
319
335
|
}
|
|
320
|
-
const workspaceCommand =
|
|
336
|
+
const workspaceCommand = commandText === null
|
|
337
|
+
? null
|
|
338
|
+
: await runWorkspaceCommand(text, this.#harness, key);
|
|
321
339
|
if (workspaceCommand) {
|
|
322
340
|
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
323
341
|
await this.#send(event.message.chat_id, reply);
|
|
324
342
|
}
|
|
325
343
|
return;
|
|
326
344
|
}
|
|
327
|
-
const compactCommand =
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
345
|
+
const compactCommand = commandText === null
|
|
346
|
+
? null
|
|
347
|
+
: await runCompactCommand(
|
|
348
|
+
commandText,
|
|
349
|
+
this.#harness,
|
|
350
|
+
this.#state,
|
|
351
|
+
key,
|
|
352
|
+
{ signal: this.#signal },
|
|
353
|
+
);
|
|
334
354
|
if (compactCommand) {
|
|
335
355
|
await this.#send(event.message.chat_id, compactCommand.message);
|
|
336
356
|
return;
|
|
@@ -338,7 +358,7 @@ export class FeishuHarnessBridge {
|
|
|
338
358
|
|
|
339
359
|
this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
|
|
340
360
|
try {
|
|
341
|
-
await this.#answerWithStream(event, key,
|
|
361
|
+
await this.#answerWithStream(event, key, message);
|
|
342
362
|
this.#status.messagesReplied += 1;
|
|
343
363
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
344
364
|
this.#status.lastError = null;
|
|
@@ -362,15 +382,20 @@ export class FeishuHarnessBridge {
|
|
|
362
382
|
};
|
|
363
383
|
}
|
|
364
384
|
|
|
365
|
-
async #answerWithStream(event, key,
|
|
385
|
+
async #answerWithStream(event, key, message) {
|
|
366
386
|
const chatId = event.message.chat_id;
|
|
367
387
|
const messageId = event.message.message_id;
|
|
388
|
+
const text = message.content;
|
|
389
|
+
const content = hasInboundImages(message)
|
|
390
|
+
? await promptContentForMessage(message, { signal: this.#signal })
|
|
391
|
+
: undefined;
|
|
368
392
|
if (!this.#channel?.stream) {
|
|
369
393
|
const { answer } = await askInWorkspaceSession({
|
|
370
394
|
harness: this.#harness,
|
|
371
395
|
state: this.#state,
|
|
372
396
|
key,
|
|
373
397
|
text,
|
|
398
|
+
content,
|
|
374
399
|
createOptions: { signal: this.#signal },
|
|
375
400
|
existsOptions: { signal: this.#signal },
|
|
376
401
|
askOptions: this.#interactionAskOptions(event, key),
|
|
@@ -398,6 +423,7 @@ export class FeishuHarnessBridge {
|
|
|
398
423
|
state: this.#state,
|
|
399
424
|
key,
|
|
400
425
|
text,
|
|
426
|
+
content,
|
|
401
427
|
createOptions: { signal: this.#signal },
|
|
402
428
|
existsOptions: { signal: this.#signal },
|
|
403
429
|
askOptions,
|
|
@@ -425,6 +451,7 @@ export class FeishuHarnessBridge {
|
|
|
425
451
|
state: this.#state,
|
|
426
452
|
key,
|
|
427
453
|
text,
|
|
454
|
+
content,
|
|
428
455
|
createOptions: { signal: this.#signal },
|
|
429
456
|
existsOptions: { signal: this.#signal },
|
|
430
457
|
askOptions: this.#interactionAskOptions(event, key),
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { FeishuHarnessBridge } from './bridge.mjs';
|
|
2
2
|
import { VerifiedFeishuChannel } from './feishu-channel.mjs';
|
|
3
|
+
import {
|
|
4
|
+
connectionTestTargetUnavailable,
|
|
5
|
+
sendRememberedConnectionTest,
|
|
6
|
+
} from '../shared/connection-test.mjs';
|
|
3
7
|
|
|
4
8
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
5
9
|
|
|
@@ -240,6 +244,47 @@ export class FeishuRuntime {
|
|
|
240
244
|
}
|
|
241
245
|
}
|
|
242
246
|
|
|
247
|
+
async sendConnectionTest(text) {
|
|
248
|
+
if (!this.#status.ready || !this.#client) {
|
|
249
|
+
const error = new Error('飞书机器人尚未连接');
|
|
250
|
+
error.code = 'test-target-unavailable';
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
254
|
+
throw new TypeError('Feishu connection test text is required');
|
|
255
|
+
}
|
|
256
|
+
const send = async (receiveIdType, receiveId, content) => {
|
|
257
|
+
const response = await this.#client.im.v1.message.create({
|
|
258
|
+
params: { receive_id_type: receiveIdType },
|
|
259
|
+
data: {
|
|
260
|
+
receive_id: receiveId,
|
|
261
|
+
msg_type: 'text',
|
|
262
|
+
content: JSON.stringify({ text: content }),
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
if (response?.code && response.code !== 0) {
|
|
266
|
+
throw new Error(`Feishu connection test failed: ${response.msg || response.code}`);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const ownerOpenId = this.#ownerOpenIds.find((value) => value !== '*');
|
|
271
|
+
if (ownerOpenId) {
|
|
272
|
+
await send('open_id', ownerOpenId, text);
|
|
273
|
+
return { sent: true };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return sendRememberedConnectionTest({
|
|
277
|
+
state: this.#state,
|
|
278
|
+
text,
|
|
279
|
+
channelLabel: '飞书机器人',
|
|
280
|
+
send: async (target, content) => {
|
|
281
|
+
const chatId = typeof target?.chatId === 'string' ? target.chatId.trim() : '';
|
|
282
|
+
if (!chatId) throw connectionTestTargetUnavailable('飞书机器人');
|
|
283
|
+
await send('chat_id', chatId, content);
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
243
288
|
async stop({ preserveError = false } = {}) {
|
|
244
289
|
const error = preserveError ? this.#status.lastError : null;
|
|
245
290
|
const abortController = this.#abortController;
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { ImagePromptError } from '../shared/image-prompt.mjs';
|
|
2
|
+
|
|
3
|
+
const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
|
|
4
|
+
const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
|
|
5
|
+
const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000;
|
|
6
|
+
const FEISHU_IMAGE_PERMISSION_MESSAGE =
|
|
7
|
+
'飞书机器人缺少图片读取权限。请在飞书开放平台为该应用添加 im:message:readonly,发布新版本并完成必要的管理员审批后,再重新发送图片。';
|
|
8
|
+
|
|
1
9
|
export function conversationKey(event) {
|
|
2
10
|
const chatType = event?.message?.chat_type;
|
|
3
11
|
if (chatType === 'p2p') {
|
|
@@ -10,19 +18,232 @@ export function conversationKey(event) {
|
|
|
10
18
|
return `group:${chatId}`;
|
|
11
19
|
}
|
|
12
20
|
|
|
21
|
+
function parsedMessageContent(event) {
|
|
22
|
+
const value = event?.message?.content;
|
|
23
|
+
if (value && typeof value === 'object') return value;
|
|
24
|
+
if (typeof value !== 'string') return null;
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(value);
|
|
27
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function withoutMentions(text, event) {
|
|
34
|
+
let result = typeof text === 'string' ? text : '';
|
|
35
|
+
for (const mention of event?.message?.mentions ?? []) {
|
|
36
|
+
if (typeof mention?.key === 'string' && mention.key) {
|
|
37
|
+
result = result.replaceAll(mention.key, '');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
13
43
|
export function extractText(event) {
|
|
14
44
|
if (event?.message?.message_type !== 'text') return null;
|
|
15
|
-
|
|
45
|
+
const parsed = parsedMessageContent(event);
|
|
46
|
+
return parsed ? withoutMentions(parsed.text, event) : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function nonEmptyString(value) {
|
|
50
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function postContent(event, parsed = parsedMessageContent(event)) {
|
|
54
|
+
if (event?.message?.message_type !== 'post') return null;
|
|
55
|
+
if (!parsed) return null;
|
|
56
|
+
|
|
57
|
+
const lines = [];
|
|
58
|
+
const title = nonEmptyString(withoutMentions(parsed.title, event));
|
|
59
|
+
if (title) lines.push(title);
|
|
60
|
+
const imageKeys = [];
|
|
61
|
+
for (const paragraph of Array.isArray(parsed.content) ? parsed.content : []) {
|
|
62
|
+
if (!Array.isArray(paragraph)) continue;
|
|
63
|
+
let visibleText = '';
|
|
64
|
+
for (const element of paragraph) {
|
|
65
|
+
const tag = String(element?.tag ?? '').toLowerCase();
|
|
66
|
+
if (tag === 'img') {
|
|
67
|
+
const key = nonEmptyString(element?.image_key);
|
|
68
|
+
if (key) imageKeys.push(key);
|
|
69
|
+
} else if (tag === 'text' || tag === 'a' || tag === 'link') {
|
|
70
|
+
if (typeof element?.text === 'string') visibleText += element.text;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const line = nonEmptyString(withoutMentions(visibleText, event));
|
|
74
|
+
if (line) lines.push(line);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
text: lines.join('\n'),
|
|
79
|
+
imageKeys,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function headerValue(headers, name) {
|
|
84
|
+
if (typeof headers?.get === 'function') return headers.get(name);
|
|
85
|
+
return headers?.[name] ?? headers?.[name.toLowerCase()] ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function declaredSize(headers) {
|
|
89
|
+
const header = headerValue(headers, 'content-length');
|
|
90
|
+
if (header === null || header === undefined || header === '') return null;
|
|
91
|
+
const value = Number(header);
|
|
92
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readBoundedStream(stream, { signal, maxBytes }) {
|
|
96
|
+
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
|
|
97
|
+
throw new Error('Feishu image download returned no readable stream');
|
|
98
|
+
}
|
|
99
|
+
signal?.throwIfAborted();
|
|
100
|
+
const abort = () => stream.destroy?.(
|
|
101
|
+
signal.reason ?? new DOMException('Feishu image download aborted', 'AbortError'),
|
|
102
|
+
);
|
|
103
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
104
|
+
const chunks = [];
|
|
105
|
+
let size = 0;
|
|
106
|
+
try {
|
|
107
|
+
for await (const chunk of stream) {
|
|
108
|
+
signal?.throwIfAborted();
|
|
109
|
+
const data = Buffer.from(chunk);
|
|
110
|
+
size += data.length;
|
|
111
|
+
if (size > maxBytes) {
|
|
112
|
+
stream.destroy?.();
|
|
113
|
+
throw new ImagePromptError(
|
|
114
|
+
'image-too-large',
|
|
115
|
+
`Feishu image exceeds ${maxBytes} bytes`,
|
|
116
|
+
'图片超过 5 MB,请压缩后重试。',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
chunks.push(data);
|
|
120
|
+
}
|
|
121
|
+
signal?.throwIfAborted();
|
|
122
|
+
return Buffer.concat(chunks, size);
|
|
123
|
+
} finally {
|
|
124
|
+
signal?.removeEventListener('abort', abort);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function providerCode(value) {
|
|
129
|
+
if (!value || typeof value !== 'object') return null;
|
|
130
|
+
const code = value.code ?? value.error?.code;
|
|
131
|
+
return Number.isSafeInteger(Number(code)) ? Number(code) : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function readFeishuErrorBody(stream, signal) {
|
|
135
|
+
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') return null;
|
|
136
|
+
signal?.throwIfAborted();
|
|
137
|
+
const timeout = AbortSignal.timeout(FEISHU_ERROR_BODY_TIMEOUT_MS);
|
|
138
|
+
const readSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
139
|
+
const abort = () => stream.destroy?.(readSignal.reason);
|
|
140
|
+
readSignal.addEventListener('abort', abort, { once: true });
|
|
141
|
+
const chunks = [];
|
|
142
|
+
let size = 0;
|
|
16
143
|
try {
|
|
17
|
-
|
|
144
|
+
for await (const chunk of stream) {
|
|
145
|
+
const data = Buffer.from(chunk);
|
|
146
|
+
size += data.length;
|
|
147
|
+
if (size > FEISHU_ERROR_BODY_LIMIT) {
|
|
148
|
+
stream.destroy?.();
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
chunks.push(data);
|
|
152
|
+
}
|
|
153
|
+
return Buffer.concat(chunks, size).toString('utf8');
|
|
18
154
|
} catch {
|
|
155
|
+
signal?.throwIfAborted();
|
|
19
156
|
return null;
|
|
157
|
+
} finally {
|
|
158
|
+
readSignal.removeEventListener('abort', abort);
|
|
20
159
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function feishuProviderCode(error, signal) {
|
|
163
|
+
const pending = [error];
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
while (pending.length > 0 && seen.size < 8) {
|
|
166
|
+
const value = pending.shift();
|
|
167
|
+
if (!value || (typeof value !== 'object' && typeof value !== 'function') || seen.has(value)) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
seen.add(value);
|
|
171
|
+
const directCode = providerCode(value);
|
|
172
|
+
const data = value.response?.data ?? value.data;
|
|
173
|
+
if (directCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) {
|
|
174
|
+
data?.destroy?.();
|
|
175
|
+
return directCode;
|
|
176
|
+
}
|
|
177
|
+
if (data && typeof data[Symbol.asyncIterator] === 'function') {
|
|
178
|
+
const body = await readFeishuErrorBody(data, signal);
|
|
179
|
+
try {
|
|
180
|
+
const parsedCode = providerCode(JSON.parse(body));
|
|
181
|
+
if (parsedCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) return parsedCode;
|
|
182
|
+
} catch {
|
|
183
|
+
// Non-JSON provider failures keep the generic image download message.
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
const dataCode = providerCode(data);
|
|
187
|
+
if (dataCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) return dataCode;
|
|
188
|
+
}
|
|
189
|
+
pending.push(value.cause);
|
|
24
190
|
}
|
|
25
|
-
return
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function feishuImageDownloadError(error, signal) {
|
|
195
|
+
if (await feishuProviderCode(error, signal) !== FEISHU_MISSING_MESSAGE_SCOPE_CODE) return error;
|
|
196
|
+
return new ImagePromptError(
|
|
197
|
+
'feishu-image-permission-required',
|
|
198
|
+
'Feishu image download requires the im:message:readonly tenant scope',
|
|
199
|
+
FEISHU_IMAGE_PERMISSION_MESSAGE,
|
|
200
|
+
{ cause: error },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function feishuImageSource(event, client, key) {
|
|
205
|
+
return {
|
|
206
|
+
async load({ signal, maxBytes }) {
|
|
207
|
+
signal?.throwIfAborted();
|
|
208
|
+
let resource;
|
|
209
|
+
try {
|
|
210
|
+
resource = await client?.im?.v1?.messageResource?.get?.({
|
|
211
|
+
path: {
|
|
212
|
+
message_id: event.message.message_id,
|
|
213
|
+
file_key: key,
|
|
214
|
+
},
|
|
215
|
+
params: { type: 'image' },
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
throw await feishuImageDownloadError(error, signal);
|
|
219
|
+
}
|
|
220
|
+
signal?.throwIfAborted();
|
|
221
|
+
const size = declaredSize(resource?.headers);
|
|
222
|
+
if (size !== null && size > maxBytes) {
|
|
223
|
+
resource?.getReadableStream?.().destroy?.();
|
|
224
|
+
throw new ImagePromptError(
|
|
225
|
+
'image-too-large',
|
|
226
|
+
`Feishu image declares ${size} bytes; the limit is ${maxBytes}`,
|
|
227
|
+
'图片超过 5 MB,请压缩后重试。',
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return readBoundedStream(resource?.getReadableStream?.(), { signal, maxBytes });
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function extractInboundMessage(event, client) {
|
|
236
|
+
const messageType = event?.message?.message_type;
|
|
237
|
+
const parsed = parsedMessageContent(event);
|
|
238
|
+
const post = postContent(event, parsed);
|
|
239
|
+
const standaloneImageKey = messageType === 'image'
|
|
240
|
+
? nonEmptyString(parsed?.image_key)
|
|
241
|
+
: null;
|
|
242
|
+
const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
|
|
243
|
+
return {
|
|
244
|
+
content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
|
|
245
|
+
images: imageKeys.map((key) => feishuImageSource(event, client, key)),
|
|
246
|
+
};
|
|
26
247
|
}
|
|
27
248
|
|
|
28
249
|
export function splitText(text, maxChars = 9000) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
|
2
3
|
import { RegistrationManager } from './registration-manager.mjs';
|
|
3
4
|
import { REQUIRED_TENANT_SCOPES } from './plugin-controller.mjs';
|
|
4
5
|
|
|
@@ -339,6 +340,26 @@ export class MultiBotDshFeishuController {
|
|
|
339
340
|
});
|
|
340
341
|
}
|
|
341
342
|
|
|
343
|
+
async sendConnectionTest(botId) {
|
|
344
|
+
this.#assertOpen();
|
|
345
|
+
return this.#withBotTransition(botId, async () => {
|
|
346
|
+
const config = this.#requireBot(botId);
|
|
347
|
+
const runtime = this.#runtimes.get(botId);
|
|
348
|
+
if (!isConnected(connectionStatus(runtime))
|
|
349
|
+
|| typeof runtime.sendConnectionTest !== 'function') {
|
|
350
|
+
const error = new Error('飞书机器人尚未连接');
|
|
351
|
+
error.code = 'test-target-unavailable';
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
return runtime.sendConnectionTest(
|
|
355
|
+
connectionTestMessage(
|
|
356
|
+
`${config.botName}(${maskedAppId(config.appId)})`,
|
|
357
|
+
'飞书机器人',
|
|
358
|
+
),
|
|
359
|
+
);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
342
363
|
async disconnectBot(botId) {
|
|
343
364
|
this.#assertOpen();
|
|
344
365
|
// An operational pause only: credentials/config remain durable, so the
|
|
@@ -5,6 +5,7 @@ export const FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
|
|
|
5
5
|
export const REQUIRED_TENANT_SCOPES = Object.freeze([
|
|
6
6
|
'im:message.p2p_msg:readonly',
|
|
7
7
|
'im:message.group_at_msg:readonly',
|
|
8
|
+
'im:message:readonly',
|
|
8
9
|
'im:message:send_as_bot',
|
|
9
10
|
'im:message.reactions:write_only',
|
|
10
11
|
'im:message:recall',
|