@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
package/package.json
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
|
+
import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
|
|
4
|
+
|
|
3
5
|
export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/';
|
|
4
6
|
export const DINGTALK_API_BASE_URL = 'https://api.dingtalk.com/';
|
|
5
7
|
export const DINGTALK_REGISTRATION_SOURCE = 'DING_DWS_CLAW';
|
|
@@ -14,6 +16,7 @@ export class DingtalkApiError extends Error {
|
|
|
14
16
|
this.name = 'DingtalkApiError';
|
|
15
17
|
this.code = code;
|
|
16
18
|
this.status = options.status;
|
|
19
|
+
this.providerCode = options.providerCode;
|
|
17
20
|
}
|
|
18
21
|
}
|
|
19
22
|
|
|
@@ -21,6 +24,25 @@ function nonEmptyString(value) {
|
|
|
21
24
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
22
25
|
}
|
|
23
26
|
|
|
27
|
+
function safeProviderCode(value) {
|
|
28
|
+
const code = nonEmptyString(value);
|
|
29
|
+
return code && /^[A-Za-z0-9_.:-]{1,160}$/.test(code) ? code : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function secureDingtalkDownloadUrl(value) {
|
|
33
|
+
let url;
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(value);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
throw new DingtalkApiError('invalid-image-download', '钉钉服务返回了无效的图片下载地址。', { cause: error });
|
|
38
|
+
}
|
|
39
|
+
if (url.protocol === 'http:' && (!url.port || url.port === '80')) {
|
|
40
|
+
url.protocol = 'https:';
|
|
41
|
+
url.port = '';
|
|
42
|
+
}
|
|
43
|
+
return url;
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
function isDingtalkHost(hostname) {
|
|
25
47
|
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
|
26
48
|
return normalized === 'dingtalk.com' || normalized.endsWith('.dingtalk.com');
|
|
@@ -123,10 +145,17 @@ async function requestJson(fetchImpl, url, {
|
|
|
123
145
|
signal: controller.signal,
|
|
124
146
|
});
|
|
125
147
|
if (!response.ok) {
|
|
148
|
+
let providerCode;
|
|
149
|
+
try {
|
|
150
|
+
const errorBody = await response.json();
|
|
151
|
+
providerCode = safeProviderCode(errorBody?.code ?? errorBody?.errcode);
|
|
152
|
+
} catch {
|
|
153
|
+
// DingTalk occasionally returns an empty or non-JSON error body.
|
|
154
|
+
}
|
|
126
155
|
throw new DingtalkApiError(
|
|
127
156
|
'http-error',
|
|
128
157
|
`钉钉服务请求失败(HTTP ${response.status})。`,
|
|
129
|
-
{ status: response.status },
|
|
158
|
+
{ status: response.status, providerCode },
|
|
130
159
|
);
|
|
131
160
|
}
|
|
132
161
|
try {
|
|
@@ -403,6 +432,58 @@ export function createDingtalkApi({
|
|
|
403
432
|
|
|
404
433
|
accessToken,
|
|
405
434
|
|
|
435
|
+
async downloadImage({
|
|
436
|
+
clientId,
|
|
437
|
+
clientSecret,
|
|
438
|
+
robotCode,
|
|
439
|
+
downloadCode,
|
|
440
|
+
signal,
|
|
441
|
+
maxBytes,
|
|
442
|
+
}) {
|
|
443
|
+
const botCode = nonEmptyString(robotCode);
|
|
444
|
+
const fileCode = nonEmptyString(downloadCode);
|
|
445
|
+
if (!botCode || !fileCode) throw new TypeError('robotCode and downloadCode are required');
|
|
446
|
+
const token = await accessToken({ clientId, clientSecret, signal });
|
|
447
|
+
let response;
|
|
448
|
+
try {
|
|
449
|
+
response = await requestJson(
|
|
450
|
+
fetchImpl,
|
|
451
|
+
endpoint(apiBase, 'v1.0/robot/messageFiles/download'),
|
|
452
|
+
{
|
|
453
|
+
body: { downloadCode: fileCode, robotCode: botCode },
|
|
454
|
+
headers: { 'x-acs-dingtalk-access-token': token },
|
|
455
|
+
signal,
|
|
456
|
+
action: '图片下载地址',
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
} catch (error) {
|
|
460
|
+
if (signal?.aborted) throw error;
|
|
461
|
+
throw new DingtalkApiError(
|
|
462
|
+
'image-download-address-failed',
|
|
463
|
+
'钉钉图片下载地址获取失败。',
|
|
464
|
+
{ cause: error, status: error?.status, providerCode: error?.providerCode },
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
const downloadUrl = nonEmptyString(response?.downloadUrl ?? response?.download_url);
|
|
468
|
+
if (!downloadUrl) {
|
|
469
|
+
throw new DingtalkApiError('invalid-image-download', '钉钉服务没有返回图片下载地址。');
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
return await fetchImageBuffer(secureDingtalkDownloadUrl(downloadUrl), {
|
|
473
|
+
fetchImpl,
|
|
474
|
+
signal,
|
|
475
|
+
maxBytes,
|
|
476
|
+
});
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (signal?.aborted || error instanceof ImagePromptError) throw error;
|
|
479
|
+
throw new DingtalkApiError(
|
|
480
|
+
'image-content-download-failed',
|
|
481
|
+
'钉钉图片内容下载失败。',
|
|
482
|
+
{ cause: error },
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
|
|
406
487
|
async createAiCard({ clientId, clientSecret, target, initialText, signal }) {
|
|
407
488
|
const appKey = nonEmptyString(clientId);
|
|
408
489
|
const appSecret = nonEmptyString(clientSecret);
|
|
@@ -12,6 +12,11 @@ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|
|
12
12
|
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
13
13
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
14
14
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
15
|
+
import {
|
|
16
|
+
hasInboundImages,
|
|
17
|
+
imagePromptUserMessage,
|
|
18
|
+
promptContentForMessage,
|
|
19
|
+
} from '../shared/image-prompt.mjs';
|
|
15
20
|
|
|
16
21
|
const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
|
|
17
22
|
const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
|
|
@@ -20,7 +25,7 @@ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无
|
|
|
20
25
|
const HELP_TEXT = [
|
|
21
26
|
'钉钉机器人已连接 DeepSeek Harness。',
|
|
22
27
|
'',
|
|
23
|
-
'
|
|
28
|
+
'直接发送文字或图片即可继续当前会话。',
|
|
24
29
|
'/new 开启一个全新会话',
|
|
25
30
|
'/compact 压缩当前会话的较早上下文',
|
|
26
31
|
'/workspace 工作区绝对路径 切换工作区',
|
|
@@ -35,10 +40,123 @@ function nonEmptyString(value) {
|
|
|
35
40
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
function safeErrorDiagnostic(error) {
|
|
44
|
+
const chain = [];
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
let current = error;
|
|
47
|
+
while (current && typeof current === 'object' && chain.length < 3 && !seen.has(current)) {
|
|
48
|
+
seen.add(current);
|
|
49
|
+
const name = nonEmptyString(current.name)?.slice(0, 80);
|
|
50
|
+
const code = nonEmptyString(current.code)?.slice(0, 80);
|
|
51
|
+
const providerCode = nonEmptyString(current.providerCode)?.slice(0, 160);
|
|
52
|
+
const status = Number.isInteger(current.status) ? current.status : undefined;
|
|
53
|
+
chain.push({
|
|
54
|
+
...(name ? { name } : {}),
|
|
55
|
+
...(code ? { code } : {}),
|
|
56
|
+
...(providerCode ? { providerCode } : {}),
|
|
57
|
+
...(status ? { status } : {}),
|
|
58
|
+
});
|
|
59
|
+
current = current.cause;
|
|
60
|
+
}
|
|
61
|
+
return chain;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function dingtalkImageErrorUserMessage(error) {
|
|
65
|
+
let current = error;
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
while (current && typeof current === 'object' && !seen.has(current)) {
|
|
68
|
+
seen.add(current);
|
|
69
|
+
if (current.code === 'image-download-address-failed') {
|
|
70
|
+
return '钉钉未能换取图片下载地址,请重新发送;若持续失败,请检查机器人的“企业内机器人发送消息权限”。';
|
|
71
|
+
}
|
|
72
|
+
if (current.code === 'invalid-image-download') {
|
|
73
|
+
return '钉钉没有返回图片下载地址,请重新发送。';
|
|
74
|
+
}
|
|
75
|
+
if (current.code === 'image-content-download-failed') {
|
|
76
|
+
return '钉钉返回的图片临时地址无法读取,请重新发送。';
|
|
77
|
+
}
|
|
78
|
+
current = current.cause;
|
|
79
|
+
}
|
|
80
|
+
return imagePromptUserMessage(error);
|
|
81
|
+
}
|
|
82
|
+
|
|
38
83
|
function senderStaffId(message) {
|
|
39
84
|
return nonEmptyString(message?.senderStaffId) ?? nonEmptyString(message?.senderId);
|
|
40
85
|
}
|
|
41
86
|
|
|
87
|
+
function parsedMessageContent(message) {
|
|
88
|
+
if (message?.content && typeof message.content === 'object') return message.content;
|
|
89
|
+
if (typeof message?.content !== 'string') return null;
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(message.content);
|
|
92
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function richTextEntries(content) {
|
|
99
|
+
const entries = content?.richText ?? content?.rich_text;
|
|
100
|
+
return Array.isArray(entries) ? entries : [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function richTextEntryText(entry) {
|
|
104
|
+
if (typeof entry?.text === 'string') return nonEmptyString(entry.text);
|
|
105
|
+
if (typeof entry?.text?.content === 'string') return nonEmptyString(entry.text.content);
|
|
106
|
+
if (String(entry?.type).toLowerCase() === 'text' && typeof entry?.content === 'string') {
|
|
107
|
+
return nonEmptyString(entry.content);
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function downloadCodeFor(value) {
|
|
113
|
+
return nonEmptyString(value?.downloadCode) ?? nonEmptyString(value?.pictureDownloadCode);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Normalize DingTalk picture and richText callbacks into lazy image references. */
|
|
117
|
+
export function dingtalkInboundMessage(message, {
|
|
118
|
+
api,
|
|
119
|
+
clientId,
|
|
120
|
+
clientSecret,
|
|
121
|
+
} = {}) {
|
|
122
|
+
const msgtype = String(message?.msgtype ?? '').toLowerCase();
|
|
123
|
+
const content = parsedMessageContent(message);
|
|
124
|
+
const richEntries = msgtype === 'richtext' ? richTextEntries(content) : [];
|
|
125
|
+
const text = msgtype === 'text'
|
|
126
|
+
? nonEmptyString(message?.text?.content) ?? ''
|
|
127
|
+
: richEntries.map(richTextEntryText).filter(Boolean).join('\n');
|
|
128
|
+
const imageCodes = [];
|
|
129
|
+
if (msgtype === 'picture') {
|
|
130
|
+
const code = downloadCodeFor(content);
|
|
131
|
+
if (code) imageCodes.push(code);
|
|
132
|
+
} else if (msgtype === 'richtext') {
|
|
133
|
+
for (const entry of richEntries) {
|
|
134
|
+
if (String(entry?.type ?? '').toLowerCase() !== 'picture') continue;
|
|
135
|
+
const code = downloadCodeFor(entry);
|
|
136
|
+
if (code) imageCodes.push(code);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
content: text,
|
|
141
|
+
images: imageCodes.map((downloadCode, index) => ({
|
|
142
|
+
name: index === 0 ? 'image' : `image-${index + 1}`,
|
|
143
|
+
load: ({ signal, maxBytes }) => {
|
|
144
|
+
if (typeof api?.downloadImage !== 'function') {
|
|
145
|
+
throw new Error('DingTalk API does not support image downloads');
|
|
146
|
+
}
|
|
147
|
+
return api.downloadImage({
|
|
148
|
+
clientId,
|
|
149
|
+
clientSecret,
|
|
150
|
+
robotCode: message?.robotCode,
|
|
151
|
+
downloadCode,
|
|
152
|
+
signal,
|
|
153
|
+
maxBytes,
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
})),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
42
160
|
function conversationKey(message, sender) {
|
|
43
161
|
if (String(message?.conversationType) === '2') {
|
|
44
162
|
const conversationId = nonEmptyString(message?.conversationId);
|
|
@@ -317,49 +435,63 @@ export class DingtalkHarnessBridge {
|
|
|
317
435
|
return;
|
|
318
436
|
}
|
|
319
437
|
|
|
320
|
-
const
|
|
438
|
+
const promptMessage = dingtalkInboundMessage(message, {
|
|
439
|
+
api: this.#api,
|
|
440
|
+
clientId: this.#clientId,
|
|
441
|
+
clientSecret: this.#clientSecret,
|
|
442
|
+
});
|
|
443
|
+
const text = promptMessage.content;
|
|
444
|
+
const hasImages = hasInboundImages(promptMessage);
|
|
445
|
+
const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
|
|
321
446
|
let cardStream = null;
|
|
322
447
|
let cardStarted = false;
|
|
323
448
|
try {
|
|
324
|
-
if (!text) {
|
|
325
|
-
await this.#send(sessionWebhook, '
|
|
449
|
+
if (!text && !hasImages) {
|
|
450
|
+
await this.#send(sessionWebhook, '目前支持文字和图片消息。');
|
|
326
451
|
return;
|
|
327
452
|
}
|
|
328
453
|
|
|
329
454
|
const command = text.toLowerCase();
|
|
330
|
-
if (command === '/help') {
|
|
455
|
+
if (isPlainText && !hasImages && command === '/help') {
|
|
331
456
|
await this.#send(sessionWebhook, HELP_TEXT);
|
|
332
457
|
return;
|
|
333
458
|
}
|
|
334
|
-
if (command === '/status') {
|
|
459
|
+
if (isPlainText && !hasImages && command === '/status') {
|
|
335
460
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
336
461
|
await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
|
|
337
462
|
return;
|
|
338
463
|
}
|
|
339
|
-
if (command === '/new') {
|
|
464
|
+
if (isPlainText && !hasImages && command === '/new') {
|
|
340
465
|
await this.#state.clearSession(key);
|
|
341
466
|
await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
|
|
342
467
|
return;
|
|
343
468
|
}
|
|
344
|
-
const workspaceCommand =
|
|
469
|
+
const workspaceCommand = isPlainText && !hasImages
|
|
470
|
+
? await runWorkspaceCommand(text, this.#harness, key)
|
|
471
|
+
: null;
|
|
345
472
|
if (workspaceCommand) {
|
|
346
473
|
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
347
474
|
await this.#send(sessionWebhook, reply);
|
|
348
475
|
}
|
|
349
476
|
return;
|
|
350
477
|
}
|
|
351
|
-
const compactCommand =
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
478
|
+
const compactCommand = isPlainText && !hasImages
|
|
479
|
+
? await runCompactCommand(
|
|
480
|
+
text,
|
|
481
|
+
this.#harness,
|
|
482
|
+
this.#state,
|
|
483
|
+
key,
|
|
484
|
+
{ signal: this.#signal },
|
|
485
|
+
)
|
|
486
|
+
: null;
|
|
358
487
|
if (compactCommand) {
|
|
359
488
|
await this.#send(sessionWebhook, compactCommand.message);
|
|
360
489
|
return;
|
|
361
490
|
}
|
|
362
491
|
|
|
492
|
+
const content = hasImages
|
|
493
|
+
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
|
494
|
+
: undefined;
|
|
363
495
|
if (typeof this.#api.createAiCard === 'function'
|
|
364
496
|
&& typeof this.#api.updateAiCard === 'function'
|
|
365
497
|
&& typeof this.#api.finishAiCard === 'function') {
|
|
@@ -377,7 +509,7 @@ export class DingtalkHarnessBridge {
|
|
|
377
509
|
harness: this.#harness,
|
|
378
510
|
state: this.#state,
|
|
379
511
|
key,
|
|
380
|
-
text,
|
|
512
|
+
...(hasImages ? { content } : { text }),
|
|
381
513
|
createOptions: { signal: this.#signal },
|
|
382
514
|
existsOptions: { signal: this.#signal },
|
|
383
515
|
askOptions: {
|
|
@@ -400,13 +532,17 @@ export class DingtalkHarnessBridge {
|
|
|
400
532
|
increment(this.#status, 'messagesReplied');
|
|
401
533
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
402
534
|
this.#status.lastError = null;
|
|
403
|
-
} catch {
|
|
535
|
+
} catch (error) {
|
|
404
536
|
if (this.#signal?.aborted) return;
|
|
405
537
|
this.#status.lastError = '钉钉消息处理失败。';
|
|
406
|
-
this.#logger.error?.(
|
|
538
|
+
this.#logger.error?.(
|
|
539
|
+
'[dsh-dingtalk] failed to process an inbound message',
|
|
540
|
+
safeErrorDiagnostic(error),
|
|
541
|
+
);
|
|
407
542
|
try {
|
|
408
|
-
const
|
|
409
|
-
|
|
543
|
+
const errorText = dingtalkImageErrorUserMessage(error) ?? CARD_ERROR_TEXT;
|
|
544
|
+
const streamed = cardStarted && await cardStream.finish(errorText);
|
|
545
|
+
if (!streamed) await this.#send(sessionWebhook, errorText);
|
|
410
546
|
} catch {
|
|
411
547
|
this.#logger.error?.('[dsh-dingtalk] failed to send the safe error reply');
|
|
412
548
|
}
|
|
@@ -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.10.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,6 +100,9 @@ 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),
|
|
@@ -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,
|
|
@@ -21,7 +27,7 @@ const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
|
|
21
27
|
const HELP_TEXT = [
|
|
22
28
|
'北汇星河 AIOS 已连接 DeepSeek Harness。',
|
|
23
29
|
'',
|
|
24
|
-
'
|
|
30
|
+
'直接发送文字或图片即可继续当前会话。',
|
|
25
31
|
'/new 开启一个全新会话',
|
|
26
32
|
'/compact 压缩当前会话的较早上下文',
|
|
27
33
|
'/workspace 工作区绝对路径 切换工作区',
|
|
@@ -273,7 +279,8 @@ export class FeishuHarnessBridge {
|
|
|
273
279
|
await this.#finishReaction(messageId, processingReaction, 'ERROR');
|
|
274
280
|
await this.#send(
|
|
275
281
|
event.message.chat_id,
|
|
276
|
-
|
|
282
|
+
imagePromptUserMessage(error)
|
|
283
|
+
?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
|
|
277
284
|
).catch(() => undefined);
|
|
278
285
|
}
|
|
279
286
|
|
|
@@ -297,40 +304,47 @@ export class FeishuHarnessBridge {
|
|
|
297
304
|
this.#status.messagesReceived += 1;
|
|
298
305
|
}
|
|
299
306
|
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
307
|
+
const message = extractInboundMessage(event, this.#client);
|
|
308
|
+
const text = message.content;
|
|
309
|
+
const hasImages = hasInboundImages(message);
|
|
310
|
+
const commandText = event.message.message_type === 'text' && !hasImages ? text : null;
|
|
311
|
+
if (!text && !hasImages) {
|
|
312
|
+
await this.#send(event.message.chat_id, '目前支持文字和图片消息。');
|
|
303
313
|
return;
|
|
304
314
|
}
|
|
305
315
|
|
|
306
|
-
if (
|
|
316
|
+
if (commandText === '/help') {
|
|
307
317
|
await this.#send(event.message.chat_id, HELP_TEXT);
|
|
308
318
|
return;
|
|
309
319
|
}
|
|
310
|
-
if (
|
|
320
|
+
if (commandText === '/new') {
|
|
311
321
|
await this.#state.clearSession(key);
|
|
312
322
|
await this.#send(event.message.chat_id, '已开启全新 Harness 会话。');
|
|
313
323
|
return;
|
|
314
324
|
}
|
|
315
|
-
if (
|
|
325
|
+
if (commandText === '/status') {
|
|
316
326
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
317
327
|
await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
|
|
318
328
|
return;
|
|
319
329
|
}
|
|
320
|
-
const workspaceCommand =
|
|
330
|
+
const workspaceCommand = commandText === null
|
|
331
|
+
? null
|
|
332
|
+
: await runWorkspaceCommand(text, this.#harness, key);
|
|
321
333
|
if (workspaceCommand) {
|
|
322
334
|
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
323
335
|
await this.#send(event.message.chat_id, reply);
|
|
324
336
|
}
|
|
325
337
|
return;
|
|
326
338
|
}
|
|
327
|
-
const compactCommand =
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
339
|
+
const compactCommand = commandText === null
|
|
340
|
+
? null
|
|
341
|
+
: await runCompactCommand(
|
|
342
|
+
commandText,
|
|
343
|
+
this.#harness,
|
|
344
|
+
this.#state,
|
|
345
|
+
key,
|
|
346
|
+
{ signal: this.#signal },
|
|
347
|
+
);
|
|
334
348
|
if (compactCommand) {
|
|
335
349
|
await this.#send(event.message.chat_id, compactCommand.message);
|
|
336
350
|
return;
|
|
@@ -338,7 +352,7 @@ export class FeishuHarnessBridge {
|
|
|
338
352
|
|
|
339
353
|
this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
|
|
340
354
|
try {
|
|
341
|
-
await this.#answerWithStream(event, key,
|
|
355
|
+
await this.#answerWithStream(event, key, message);
|
|
342
356
|
this.#status.messagesReplied += 1;
|
|
343
357
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
344
358
|
this.#status.lastError = null;
|
|
@@ -362,15 +376,20 @@ export class FeishuHarnessBridge {
|
|
|
362
376
|
};
|
|
363
377
|
}
|
|
364
378
|
|
|
365
|
-
async #answerWithStream(event, key,
|
|
379
|
+
async #answerWithStream(event, key, message) {
|
|
366
380
|
const chatId = event.message.chat_id;
|
|
367
381
|
const messageId = event.message.message_id;
|
|
382
|
+
const text = message.content;
|
|
383
|
+
const content = hasInboundImages(message)
|
|
384
|
+
? await promptContentForMessage(message, { signal: this.#signal })
|
|
385
|
+
: undefined;
|
|
368
386
|
if (!this.#channel?.stream) {
|
|
369
387
|
const { answer } = await askInWorkspaceSession({
|
|
370
388
|
harness: this.#harness,
|
|
371
389
|
state: this.#state,
|
|
372
390
|
key,
|
|
373
391
|
text,
|
|
392
|
+
content,
|
|
374
393
|
createOptions: { signal: this.#signal },
|
|
375
394
|
existsOptions: { signal: this.#signal },
|
|
376
395
|
askOptions: this.#interactionAskOptions(event, key),
|
|
@@ -398,6 +417,7 @@ export class FeishuHarnessBridge {
|
|
|
398
417
|
state: this.#state,
|
|
399
418
|
key,
|
|
400
419
|
text,
|
|
420
|
+
content,
|
|
401
421
|
createOptions: { signal: this.#signal },
|
|
402
422
|
existsOptions: { signal: this.#signal },
|
|
403
423
|
askOptions,
|
|
@@ -425,6 +445,7 @@ export class FeishuHarnessBridge {
|
|
|
425
445
|
state: this.#state,
|
|
426
446
|
key,
|
|
427
447
|
text,
|
|
448
|
+
content,
|
|
428
449
|
createOptions: { signal: this.#signal },
|
|
429
450
|
existsOptions: { signal: this.#signal },
|
|
430
451
|
askOptions: this.#interactionAskOptions(event, key),
|