@xmanrui/dsh-im 1.4.0 → 2.0.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 +18 -6
- package/README.md +18 -6
- package/lib/index.js +206 -176
- package/package.json +1 -1
- package/plugin-src/host/index.mjs +2 -0
- package/src/channels/dingtalk/config-store.mjs +5 -3
- package/src/channels/dingtalk/dingtalk-api.mjs +105 -82
- package/src/channels/dingtalk/dingtalk-bridge.mjs +79 -110
- package/src/channels/dingtalk/dingtalk-card-stream.mjs +3 -1
- package/src/channels/dingtalk/dingtalk-controller.mjs +24 -23
- package/src/channels/dingtalk/dingtalk-runtime.mjs +4 -3
- package/src/channels/dingtalk/state-store.mjs +3 -1
- package/src/channels/discord/config-store.mjs +2 -1
- package/src/channels/discord/discord-api.mjs +25 -1
- package/src/channels/discord/discord-runtime.mjs +237 -11
- package/src/channels/feishu/bridge.mjs +169 -180
- package/src/channels/feishu/feishu-cards.mjs +62 -54
- package/src/channels/feishu/feishu-channel.mjs +57 -21
- package/src/channels/feishu/feishu-runtime.mjs +10 -9
- package/src/channels/feishu/message-utils.mjs +4 -3
- package/src/channels/office/office-job-executor.mjs +15 -14
- package/src/channels/office/office-runtime.mjs +6 -5
- package/src/channels/qq/config-store.mjs +3 -1
- package/src/channels/qq/markdown-reply.mjs +4 -2
- package/src/channels/qq/qq-bridge.mjs +149 -130
- package/src/channels/qq/qq-controller.mjs +17 -16
- package/src/channels/qq/qq-runtime.mjs +3 -2
- package/src/channels/shared/agent-preset.mjs +3 -1
- package/src/channels/shared/compact-command.mjs +15 -14
- package/src/channels/shared/connection-test.mjs +6 -4
- package/src/channels/shared/control-command.mjs +13 -11
- package/src/channels/shared/editable-message-stream.mjs +4 -2
- package/src/channels/shared/harness-approval.mjs +23 -21
- package/src/channels/shared/harness-client.mjs +3 -2
- package/src/channels/shared/harness-question.mjs +8 -6
- package/src/channels/shared/i18n-en/dingtalk.mjs +52 -0
- package/src/channels/shared/i18n-en/discord.mjs +8 -0
- package/src/channels/shared/i18n-en/feishu.mjs +217 -0
- package/src/channels/shared/i18n-en/office.mjs +23 -0
- package/src/channels/shared/i18n-en/qq.mjs +54 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +91 -0
- package/src/channels/shared/i18n-en/shared-b.mjs +215 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +99 -0
- package/src/channels/shared/i18n-en/slack.mjs +27 -0
- package/src/channels/shared/i18n-en/telegram.mjs +21 -0
- package/src/channels/shared/i18n-en/wecom.mjs +38 -0
- package/src/channels/shared/i18n-en/weixin.mjs +80 -0
- package/src/channels/shared/i18n-en/whatsapp.mjs +17 -0
- package/src/channels/shared/i18n-en.mjs +35 -0
- package/src/channels/shared/i18n.mjs +35 -0
- package/src/channels/shared/image-prompt.mjs +17 -15
- package/src/channels/shared/inbound-file.mjs +5 -3
- package/src/channels/shared/model-command.mjs +35 -31
- package/src/channels/shared/preset-command.mjs +41 -38
- package/src/channels/shared/semantic/artifact-delivery.mjs +170 -0
- package/src/channels/shared/semantic/artifact.mjs +2 -2
- package/src/channels/shared/semantic/delivery.mjs +40 -0
- package/src/channels/shared/text-harness-bridge.mjs +154 -136
- package/src/channels/shared/token-bot-controller.mjs +32 -11
- package/src/channels/shared/workspace-command.mjs +69 -58
- package/src/channels/slack/config-store.mjs +3 -1
- package/src/channels/slack/slack-api.mjs +6 -5
- package/src/channels/slack/slack-controller.mjs +10 -9
- package/src/channels/slack/slack-runtime.mjs +16 -4
- package/src/channels/telegram/config-store.mjs +2 -1
- package/src/channels/telegram/telegram-api.mjs +111 -19
- package/src/channels/telegram/telegram-rich-message.mjs +147 -0
- package/src/channels/telegram/telegram-runtime.mjs +383 -5
- package/src/channels/wecom/config-store.mjs +3 -1
- package/src/channels/wecom/wecom-bridge.mjs +154 -153
- package/src/channels/wecom/wecom-controller.mjs +20 -19
- package/src/channels/wecom/wecom-runtime.mjs +2 -1
- package/src/channels/weixin/config-store.mjs +2 -1
- package/src/channels/weixin/weixin-api.mjs +140 -107
- package/src/channels/weixin/weixin-bridge.mjs +86 -123
- package/src/channels/weixin/weixin-controller.mjs +22 -21
- package/src/channels/weixin/weixin-runtime.mjs +3 -2
- package/src/channels/whatsapp/config-store.mjs +3 -1
- package/src/channels/whatsapp/whatsapp-bridge.mjs +3 -1
- package/src/channels/whatsapp/whatsapp-controller.mjs +15 -14
- package/src/channels/whatsapp/whatsapp-runtime.mjs +47 -13
- package/src/channels/whatsapp/whatsapp-web-session.mjs +3 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { t } from '../shared/i18n.mjs';
|
|
2
|
+
|
|
1
3
|
const DEFAULT_UPDATE_INTERVAL_MS = 500;
|
|
2
4
|
const FAILURE_TEXT = '消息处理失败,请稍后重试。';
|
|
3
5
|
|
|
@@ -100,7 +102,7 @@ export function createDingTalkCardStream({
|
|
|
100
102
|
if (!cleanupPromise) {
|
|
101
103
|
cleanupPromise = api.failAiCard({
|
|
102
104
|
...cardRequest,
|
|
103
|
-
text: FAILURE_TEXT,
|
|
105
|
+
text: t(FAILURE_TEXT),
|
|
104
106
|
signal: AbortSignal.timeout(5_000),
|
|
105
107
|
}).then(
|
|
106
108
|
() => true,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
connectionTestMessage,
|
|
11
11
|
connectionTestTargetUnavailable,
|
|
12
12
|
} from '../shared/connection-test.mjs';
|
|
13
|
+
import { t } from '../shared/i18n.mjs';
|
|
13
14
|
|
|
14
15
|
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
|
|
15
16
|
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
|
|
@@ -84,7 +85,7 @@ function normalizePendingSender(value) {
|
|
|
84
85
|
return {
|
|
85
86
|
requestId: opaqueRequestId,
|
|
86
87
|
staffId,
|
|
87
|
-
displayName: cleanString(value.displayName ?? value.senderName ?? value.senderNick) ?? '钉钉用户',
|
|
88
|
+
displayName: cleanString(value.displayName ?? value.senderName ?? value.senderNick) ?? t('钉钉用户'),
|
|
88
89
|
requestedAt: cleanString(value.requestedAt),
|
|
89
90
|
};
|
|
90
91
|
}
|
|
@@ -114,7 +115,7 @@ function publicPendingSender(sender) {
|
|
|
114
115
|
function publicApprovedSender(sender) {
|
|
115
116
|
return {
|
|
116
117
|
senderKey: sender.senderKey,
|
|
117
|
-
displayName: cleanString(sender.displayName) ?? '钉钉用户',
|
|
118
|
+
displayName: cleanString(sender.displayName) ?? t('钉钉用户'),
|
|
118
119
|
senderIdMasked: maskDingtalkSenderId(sender.staffId),
|
|
119
120
|
approvedAt: cleanString(sender.approvedAt),
|
|
120
121
|
};
|
|
@@ -206,7 +207,7 @@ export class DingtalkController {
|
|
|
206
207
|
if (!clientSecret) {
|
|
207
208
|
this.#errors.set(
|
|
208
209
|
latest.botId,
|
|
209
|
-
safeError('missing-secret', '钉钉机器人凭据缺失,请移除后重新扫码。'),
|
|
210
|
+
safeError('missing-secret', t('钉钉机器人凭据缺失,请移除后重新扫码。')),
|
|
210
211
|
);
|
|
211
212
|
this.#touch();
|
|
212
213
|
return;
|
|
@@ -217,7 +218,7 @@ export class DingtalkController {
|
|
|
217
218
|
} catch {
|
|
218
219
|
this.#errors.set(
|
|
219
220
|
latest.botId,
|
|
220
|
-
safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'),
|
|
221
|
+
safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
|
|
221
222
|
);
|
|
222
223
|
this.#logger.warn?.(`[dsh-dingtalk] bot ${latest.botId} failed to initialize`);
|
|
223
224
|
}
|
|
@@ -270,10 +271,10 @@ export class DingtalkController {
|
|
|
270
271
|
} catch (error) {
|
|
271
272
|
if (record.controller.signal.aborted || error?.name === 'AbortError') {
|
|
272
273
|
record.state = 'cancelled';
|
|
273
|
-
record.error = safeError('cancelled', '扫码接入已取消。');
|
|
274
|
+
record.error = safeError('cancelled', t('扫码接入已取消。'));
|
|
274
275
|
} else {
|
|
275
276
|
record.state = 'failed';
|
|
276
|
-
record.error = safeError('qr-start-failed', '无法生成钉钉二维码,请稍后重试。');
|
|
277
|
+
record.error = safeError('qr-start-failed', t('无法生成钉钉二维码,请稍后重试。'));
|
|
277
278
|
}
|
|
278
279
|
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
279
280
|
this.#touch();
|
|
@@ -319,7 +320,7 @@ export class DingtalkController {
|
|
|
319
320
|
} catch {
|
|
320
321
|
this.#errors.set(
|
|
321
322
|
identity.botId,
|
|
322
|
-
safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'),
|
|
323
|
+
safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
|
|
323
324
|
);
|
|
324
325
|
this.#logger.warn?.('[dsh-dingtalk] credential-bound bot saved but its connection is not ready');
|
|
325
326
|
}
|
|
@@ -337,7 +338,7 @@ export class DingtalkController {
|
|
|
337
338
|
}
|
|
338
339
|
if (nowFrom(this.#clock) >= record.expiresAt) {
|
|
339
340
|
record.state = 'expired';
|
|
340
|
-
record.error = safeError('expired', '二维码已过期,请重新生成。');
|
|
341
|
+
record.error = safeError('expired', t('二维码已过期,请重新生成。'));
|
|
341
342
|
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
342
343
|
this.#touch();
|
|
343
344
|
return publicAttempt(record);
|
|
@@ -360,7 +361,7 @@ export class DingtalkController {
|
|
|
360
361
|
record.controller.abort();
|
|
361
362
|
await record.pollTask?.catch(() => undefined);
|
|
362
363
|
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled';
|
|
363
|
-
record.error ??= safeError('cancelled', '扫码接入已取消。');
|
|
364
|
+
record.error ??= safeError('cancelled', t('扫码接入已取消。'));
|
|
364
365
|
}
|
|
365
366
|
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
366
367
|
this.#touch();
|
|
@@ -380,7 +381,7 @@ export class DingtalkController {
|
|
|
380
381
|
} catch (error) {
|
|
381
382
|
this.#errors.set(
|
|
382
383
|
botId,
|
|
383
|
-
safeError('connection-failed', '钉钉连接仍未就绪,请稍后重试。'),
|
|
384
|
+
safeError('connection-failed', t('钉钉连接仍未就绪,请稍后重试。')),
|
|
384
385
|
);
|
|
385
386
|
throw error;
|
|
386
387
|
} finally {
|
|
@@ -396,10 +397,10 @@ export class DingtalkController {
|
|
|
396
397
|
return this.#withBotTransition(botId, async () => {
|
|
397
398
|
const runtime = this.#runtimes.get(botId);
|
|
398
399
|
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
|
399
|
-
throw connectionTestTargetUnavailable('钉钉机器人');
|
|
400
|
+
throw connectionTestTargetUnavailable(t('钉钉机器人'));
|
|
400
401
|
}
|
|
401
402
|
return runtime.sendConnectionTest(connectionTestMessage(
|
|
402
|
-
|
|
403
|
+
t('钉钉机器人({clientId})', { clientId: maskDingtalkClientId(config.clientId) }),
|
|
403
404
|
));
|
|
404
405
|
});
|
|
405
406
|
}
|
|
@@ -501,14 +502,14 @@ export class DingtalkController {
|
|
|
501
502
|
connected,
|
|
502
503
|
configured: true,
|
|
503
504
|
bot: {
|
|
504
|
-
name: '钉钉机器人',
|
|
505
|
+
name: t('钉钉机器人'),
|
|
505
506
|
clientIdMasked: maskDingtalkClientId(config.clientId),
|
|
506
507
|
},
|
|
507
508
|
health: {
|
|
508
509
|
status: connected ? 'healthy' : accountError ? 'error' : 'offline',
|
|
509
510
|
summary: connected
|
|
510
|
-
? '钉钉 Stream 消息连接运行正常'
|
|
511
|
-
: accountError?.message ?? '钉钉消息连接当前离线',
|
|
511
|
+
? t('钉钉 Stream 消息连接运行正常')
|
|
512
|
+
: accountError?.message ?? t('钉钉消息连接当前离线'),
|
|
512
513
|
lastCheckedAt: currentStatus.lastCheckedAt ?? null,
|
|
513
514
|
},
|
|
514
515
|
stats: {
|
|
@@ -580,32 +581,32 @@ export class DingtalkController {
|
|
|
580
581
|
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
581
582
|
} else if (state === 'EXPIRED') {
|
|
582
583
|
record.state = 'expired';
|
|
583
|
-
record.error = safeError('expired', '二维码已过期,请重新生成。');
|
|
584
|
+
record.error = safeError('expired', t('二维码已过期,请重新生成。'));
|
|
584
585
|
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
585
586
|
} else if (state === 'FAIL') {
|
|
586
587
|
record.state = 'failed';
|
|
587
|
-
record.error = safeError('authorization-failed', '钉钉未完成机器人授权,请重新扫码。');
|
|
588
|
+
record.error = safeError('authorization-failed', t('钉钉未完成机器人授权,请重新扫码。'));
|
|
588
589
|
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
589
590
|
} else {
|
|
590
591
|
record.state = 'pending';
|
|
591
|
-
record.error = safeError('poll-pending', '钉钉授权状态暂时不可用,正在重试。');
|
|
592
|
+
record.error = safeError('poll-pending', t('钉钉授权状态暂时不可用,正在重试。'));
|
|
592
593
|
}
|
|
593
594
|
} catch (error) {
|
|
594
595
|
if (record.controller.signal.aborted || error?.name === 'AbortError') {
|
|
595
596
|
record.state = 'cancelled';
|
|
596
|
-
record.error = safeError('cancelled', '扫码接入已取消。');
|
|
597
|
+
record.error = safeError('cancelled', t('扫码接入已取消。'));
|
|
597
598
|
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
598
599
|
} else if (record.state === 'connecting') {
|
|
599
600
|
record.state = 'failed';
|
|
600
601
|
record.error = safeError(
|
|
601
602
|
'activation-failed',
|
|
602
|
-
'钉钉已授权,但无法安全保存接入配置。',
|
|
603
|
+
t('钉钉已授权,但无法安全保存接入配置。'),
|
|
603
604
|
);
|
|
604
605
|
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
605
606
|
this.#logger.error?.('[dsh-dingtalk] bot activation failed');
|
|
606
607
|
} else {
|
|
607
608
|
record.state = 'pending';
|
|
608
|
-
record.error = safeError('poll-failed', '钉钉授权查询暂时失败,正在重试。');
|
|
609
|
+
record.error = safeError('poll-failed', t('钉钉授权查询暂时失败,正在重试。'));
|
|
609
610
|
}
|
|
610
611
|
} finally {
|
|
611
612
|
this.#touch();
|
|
@@ -660,7 +661,7 @@ export class DingtalkController {
|
|
|
660
661
|
}
|
|
661
662
|
this.#errors.set(
|
|
662
663
|
identity.botId,
|
|
663
|
-
safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'),
|
|
664
|
+
safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
|
|
664
665
|
);
|
|
665
666
|
this.#logger.warn?.('[dsh-dingtalk] authorized bot saved but its connection is not ready');
|
|
666
667
|
}
|
|
@@ -681,7 +682,7 @@ export class DingtalkController {
|
|
|
681
682
|
await this.#startRuntime(previousConfig, clientSecret).catch(() => undefined);
|
|
682
683
|
this.#errors.set(
|
|
683
684
|
previousConfig.botId,
|
|
684
|
-
safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'),
|
|
685
|
+
safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
|
|
685
686
|
);
|
|
686
687
|
throw error;
|
|
687
688
|
} finally {
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
DingtalkHarnessBridge,
|
|
5
5
|
} from './dingtalk-bridge.mjs';
|
|
6
6
|
import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
|
|
7
|
+
import { t } from '../shared/i18n.mjs';
|
|
7
8
|
|
|
8
9
|
function nonEmptyString(value) {
|
|
9
10
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
@@ -275,7 +276,7 @@ export class DingtalkRuntime {
|
|
|
275
276
|
? JSON.parse(response.data)
|
|
276
277
|
: response?.data;
|
|
277
278
|
} catch {
|
|
278
|
-
this.#status.lastError = '钉钉消息格式无效。';
|
|
279
|
+
this.#status.lastError = t('钉钉消息格式无效。');
|
|
279
280
|
this.#logger.warn?.('[dsh-dingtalk] ignored an invalid callback payload');
|
|
280
281
|
return;
|
|
281
282
|
}
|
|
@@ -284,7 +285,7 @@ export class DingtalkRuntime {
|
|
|
284
285
|
await bridge.accept(message);
|
|
285
286
|
}).catch(() => {
|
|
286
287
|
if (signal.aborted || this.#bridge !== bridge) return;
|
|
287
|
-
this.#status.lastError = '钉钉消息处理失败。';
|
|
288
|
+
this.#status.lastError = t('钉钉消息处理失败。');
|
|
288
289
|
this.#logger.error?.('[dsh-dingtalk] callback processing failed');
|
|
289
290
|
}).finally(() => this.#callbackTasks.delete(task));
|
|
290
291
|
this.#callbackTasks.add(task);
|
|
@@ -352,7 +353,7 @@ export class DingtalkRuntime {
|
|
|
352
353
|
return sendRememberedConnectionTest({
|
|
353
354
|
state: this.#state,
|
|
354
355
|
text,
|
|
355
|
-
channelLabel: '钉钉机器人',
|
|
356
|
+
channelLabel: t('钉钉机器人'),
|
|
356
357
|
send: async ({ sessionWebhook }, content) => {
|
|
357
358
|
if (!this.#status.ready || !this.#abortController) {
|
|
358
359
|
throw new Error('DingTalk runtime is not connected');
|
|
@@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
|
|
5
|
+
import { t } from '../shared/i18n.mjs';
|
|
6
|
+
|
|
5
7
|
const EMPTY_STATE = Object.freeze({
|
|
6
8
|
version: 1,
|
|
7
9
|
sessions: {},
|
|
@@ -14,7 +16,7 @@ function nonEmptyString(value) {
|
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
function displayName(value) {
|
|
17
|
-
return (nonEmptyString(value) ?? '钉钉用户').slice(0, 100);
|
|
19
|
+
return (nonEmptyString(value) ?? t('钉钉用户')).slice(0, 100);
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
function normalizePendingSender(value, fallbackRequestId) {
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
maskPlatformId,
|
|
4
4
|
TokenBotConfigStore,
|
|
5
5
|
} from '../shared/token-config-store.mjs';
|
|
6
|
+
import { t } from '../shared/i18n.mjs';
|
|
6
7
|
|
|
7
8
|
const IDENTITY_OPTIONS = Object.freeze({
|
|
8
9
|
botPrefix: 'discord',
|
|
@@ -14,7 +15,7 @@ export function deriveDiscordBotIdentity(platformId) {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function maskDiscordBotId(platformId) {
|
|
17
|
-
return maskPlatformId(platformId, 'Discord机器人');
|
|
18
|
+
return maskPlatformId(platformId, t('Discord机器人'));
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export class DiscordConfigStore extends TokenBotConfigStore {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { t } from '../shared/i18n.mjs';
|
|
2
3
|
|
|
3
4
|
const DEFAULT_BASE_URL = 'https://discord.com/api/v10/';
|
|
4
5
|
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
|
@@ -120,6 +121,29 @@ export class DiscordApi {
|
|
|
120
121
|
return this.#request('gateway/bot', { ...options, method: 'GET' });
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
getChannel({ channelId, signal } = {}) {
|
|
125
|
+
return this.#request(`channels/${snowflake(channelId, 'channel id')}`, {
|
|
126
|
+
method: 'GET',
|
|
127
|
+
signal,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
startThreadFromMessage({ channelId, messageId, name, signal } = {}) {
|
|
132
|
+
const threadName = cleanString(name);
|
|
133
|
+
if (!threadName || [...threadName].length > 100) {
|
|
134
|
+
throw new TypeError('Discord thread name must contain 1-100 characters');
|
|
135
|
+
}
|
|
136
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
137
|
+
return this.#request(
|
|
138
|
+
`channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}/threads`,
|
|
139
|
+
{
|
|
140
|
+
method: 'POST',
|
|
141
|
+
signal,
|
|
142
|
+
body: { name: threadName },
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
123
147
|
createMessage({ channelId, content, replyToMessageId, signal }) {
|
|
124
148
|
return this.#request(`channels/${snowflake(channelId, 'channel id')}/messages`, {
|
|
125
149
|
method: 'POST',
|
|
@@ -275,7 +299,7 @@ export async function inspectDiscordToken(token, options = {}) {
|
|
|
275
299
|
if (!bot?.id || bot?.bot !== true) throw new Error('Discord token does not belong to a bot');
|
|
276
300
|
return {
|
|
277
301
|
platformId: String(bot.id),
|
|
278
|
-
name: cleanString(bot.global_name) ?? cleanString(bot.username) ?? 'Discord机器人',
|
|
302
|
+
name: cleanString(bot.global_name) ?? cleanString(bot.username) ?? t('Discord机器人'),
|
|
279
303
|
username: cleanString(bot.username),
|
|
280
304
|
};
|
|
281
305
|
}
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
|
|
2
2
|
import { fetchFileStream } from '../shared/file-download.mjs';
|
|
3
3
|
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
|
4
|
+
import { t } from '../shared/i18n.mjs';
|
|
4
5
|
import { DiscordApi } from './discord-api.mjs';
|
|
5
6
|
import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
|
|
6
7
|
|
|
7
|
-
const DISCORD_GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12);
|
|
8
|
+
const DISCORD_GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12) | (1 << 15);
|
|
9
|
+
const THREAD_RECOVERY_TIMEOUT_MS = 5_000;
|
|
8
10
|
const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
|
|
11
|
+
const GUILD_TEXT = 0;
|
|
12
|
+
const GUILD_ANNOUNCEMENT = 5;
|
|
13
|
+
const ANNOUNCEMENT_THREAD = 10;
|
|
14
|
+
const PUBLIC_THREAD = 11;
|
|
15
|
+
const PRIVATE_THREAD = 12;
|
|
16
|
+
const THREAD_TYPES = new Set([ANNOUNCEMENT_THREAD, PUBLIC_THREAD, PRIVATE_THREAD]);
|
|
9
17
|
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
10
18
|
const IMAGE_FILE_TYPES = new Map([
|
|
11
19
|
['.jpg', 'image/jpeg'],
|
|
@@ -40,12 +48,12 @@ function eventData(event) {
|
|
|
40
48
|
|
|
41
49
|
function gatewayCloseError(code) {
|
|
42
50
|
if (code === 4004) {
|
|
43
|
-
const error = new Error('Discord Bot Token 无效,请重新填写。');
|
|
51
|
+
const error = new Error(t('Discord Bot Token 无效,请重新填写。'));
|
|
44
52
|
error.code = 'discord-401';
|
|
45
53
|
return error;
|
|
46
54
|
}
|
|
47
55
|
if (code === 4013 || code === 4014) {
|
|
48
|
-
const error = new Error('Discord Gateway Intents 配置不正确,请检查 Developer Portal 的 Bot 设置。');
|
|
56
|
+
const error = new Error(t('Discord Gateway Intents 配置不正确,请检查 Developer Portal 的 Bot 设置。'));
|
|
49
57
|
error.code = 'discord-intents';
|
|
50
58
|
return error;
|
|
51
59
|
}
|
|
@@ -59,6 +67,94 @@ function stripBotMention(text, botId) {
|
|
|
59
67
|
return text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
|
|
60
68
|
}
|
|
61
69
|
|
|
70
|
+
function cleanThreadName(message, botId) {
|
|
71
|
+
const name = stripBotMention(message?.content ?? '', botId).replace(/\s+/g, ' ').trim()
|
|
72
|
+
|| 'DeepSeek Harness';
|
|
73
|
+
return [...name].slice(0, 100).join('');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isThreadChannel(channel) {
|
|
77
|
+
return THREAD_TYPES.has(Number(channel?.type));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isThreadFromMessage(channel, message) {
|
|
81
|
+
return isThreadChannel(channel)
|
|
82
|
+
&& String(channel?.id ?? '') === String(message?.id ?? '')
|
|
83
|
+
&& String(channel?.parent_id ?? '') === String(message?.channel_id ?? '');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function rememberChannel(channel, callback) {
|
|
87
|
+
if (channel?.id) callback?.(channel);
|
|
88
|
+
return channel;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function withConversationRoute(normalized, channel, botId, {
|
|
92
|
+
fromSourceMessage = false,
|
|
93
|
+
fallback = null,
|
|
94
|
+
notice = null,
|
|
95
|
+
} = {}) {
|
|
96
|
+
const channelId = String(channel?.id ?? normalized.conversationId);
|
|
97
|
+
const thread = isThreadChannel(channel);
|
|
98
|
+
const managed = thread && String(channel?.owner_id ?? '') === String(botId);
|
|
99
|
+
const parentId = thread && channel?.parent_id ? String(channel.parent_id) : channelId;
|
|
100
|
+
return {
|
|
101
|
+
...normalized,
|
|
102
|
+
conversationId: channelId,
|
|
103
|
+
addressed: normalized.addressed || managed,
|
|
104
|
+
requiresMention: thread ? !managed : normalized.kind === 'group',
|
|
105
|
+
replyTarget: {
|
|
106
|
+
channelId,
|
|
107
|
+
...(!fromSourceMessage && normalized.replyTarget?.replyToMessageId
|
|
108
|
+
? { replyToMessageId: normalized.replyTarget.replyToMessageId }
|
|
109
|
+
: {}),
|
|
110
|
+
...(notice ? { notice } : {}),
|
|
111
|
+
},
|
|
112
|
+
connectionTestTarget: { channelId },
|
|
113
|
+
conversationRoute: {
|
|
114
|
+
peerId: parentId,
|
|
115
|
+
...(thread ? { threadId: channelId, managed } : {}),
|
|
116
|
+
...(fallback ? { fallback } : {}),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function uncertainThreadCreate(error) {
|
|
122
|
+
const status = Number(error?.status);
|
|
123
|
+
return !Number.isInteger(status) || status >= 500;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function threadCreateUncertain(cause) {
|
|
127
|
+
const error = new Error('Discord Thread creation result is uncertain', { cause });
|
|
128
|
+
error.code = 'discord-thread-create-uncertain';
|
|
129
|
+
return error;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function findCreatedThread(api, message, { signal, onChannel } = {}) {
|
|
133
|
+
try {
|
|
134
|
+
const channel = rememberChannel(await api.getChannel({
|
|
135
|
+
channelId: String(message.id),
|
|
136
|
+
signal,
|
|
137
|
+
}), onChannel);
|
|
138
|
+
return isThreadFromMessage(channel, message) ? channel : null;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error?.status === 404) return null;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function sendThreadUncertainNotice(api, normalized, signal) {
|
|
146
|
+
try {
|
|
147
|
+
await api.createMessage({
|
|
148
|
+
channelId: normalized.replyTarget.channelId,
|
|
149
|
+
replyToMessageId: normalized.replyTarget.replyToMessageId,
|
|
150
|
+
content: 'Thread 创建结果暂时无法确认。若已创建,请在对应 Thread 中重试;若未创建,请稍后重新 @机器人。',
|
|
151
|
+
signal,
|
|
152
|
+
});
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (signal?.aborted) throw signal.reason ?? error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
62
158
|
function attachmentMediaType(attachment) {
|
|
63
159
|
const value = typeof attachment?.content_type === 'string'
|
|
64
160
|
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : '';
|
|
@@ -109,7 +205,8 @@ function discordFileSource(attachment, fetchImpl) {
|
|
|
109
205
|
}
|
|
110
206
|
|
|
111
207
|
export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
|
|
112
|
-
if (!message?.id || !message?.channel_id || !message?.author?.id
|
|
208
|
+
if (!message?.id || !message?.channel_id || !message?.author?.id
|
|
209
|
+
|| Number(message.type) === 21) return null;
|
|
113
210
|
const direct = !message.guild_id;
|
|
114
211
|
const addressed = direct
|
|
115
212
|
|| message.mentions?.some((mention) => String(mention?.id) === String(botId));
|
|
@@ -135,9 +232,84 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
|
|
|
135
232
|
};
|
|
136
233
|
}
|
|
137
234
|
|
|
235
|
+
export async function resolveDiscordMessageRoute(message, botId, {
|
|
236
|
+
api,
|
|
237
|
+
channel,
|
|
238
|
+
fetchImpl = fetch,
|
|
239
|
+
signal,
|
|
240
|
+
onChannel,
|
|
241
|
+
} = {}) {
|
|
242
|
+
const normalized = normalizeDiscordMessage(message, botId, { fetchImpl });
|
|
243
|
+
if (!normalized || normalized.senderIsBot) return normalized;
|
|
244
|
+
signal?.throwIfAborted();
|
|
245
|
+
if (normalized.kind === 'direct') {
|
|
246
|
+
return withConversationRoute(normalized, { id: normalized.conversationId, type: 1 }, botId);
|
|
247
|
+
}
|
|
248
|
+
if (!api || typeof api.getChannel !== 'function') {
|
|
249
|
+
throw new TypeError('Discord route resolution requires the Discord API');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const sourceChannel = rememberChannel(channel ?? await api.getChannel({
|
|
253
|
+
channelId: normalized.conversationId,
|
|
254
|
+
signal,
|
|
255
|
+
}), onChannel);
|
|
256
|
+
if (isThreadChannel(sourceChannel)) {
|
|
257
|
+
return withConversationRoute(normalized, sourceChannel, botId);
|
|
258
|
+
}
|
|
259
|
+
if (!normalized.addressed) return withConversationRoute(normalized, sourceChannel, botId);
|
|
260
|
+
|
|
261
|
+
const sourceType = Number(sourceChannel?.type);
|
|
262
|
+
if (sourceType !== GUILD_TEXT && sourceType !== GUILD_ANNOUNCEMENT) {
|
|
263
|
+
return withConversationRoute(normalized, sourceChannel, botId, {
|
|
264
|
+
fallback: 'unsupported-channel',
|
|
265
|
+
notice: '当前频道不支持自动创建 Thread,已直接在当前频道回复。',
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
signal?.throwIfAborted();
|
|
270
|
+
let created;
|
|
271
|
+
try {
|
|
272
|
+
created = rememberChannel(await api.startThreadFromMessage({
|
|
273
|
+
channelId: normalized.conversationId,
|
|
274
|
+
messageId: normalized.messageId,
|
|
275
|
+
name: cleanThreadName(message, botId),
|
|
276
|
+
signal,
|
|
277
|
+
}), onChannel);
|
|
278
|
+
if (!isThreadFromMessage(created, message)) {
|
|
279
|
+
throw new Error('Discord returned an invalid thread for the source message');
|
|
280
|
+
}
|
|
281
|
+
} catch (error) {
|
|
282
|
+
let recovered;
|
|
283
|
+
try {
|
|
284
|
+
recovered = await findCreatedThread(api, message, {
|
|
285
|
+
signal: AbortSignal.timeout(THREAD_RECOVERY_TIMEOUT_MS),
|
|
286
|
+
onChannel,
|
|
287
|
+
});
|
|
288
|
+
} catch (recoveryError) {
|
|
289
|
+
if (signal?.aborted) throw signal.reason ?? error;
|
|
290
|
+
await sendThreadUncertainNotice(api, normalized, signal);
|
|
291
|
+
throw threadCreateUncertain(recoveryError);
|
|
292
|
+
}
|
|
293
|
+
if (recovered) {
|
|
294
|
+
return withConversationRoute(normalized, recovered, botId, { fromSourceMessage: true });
|
|
295
|
+
}
|
|
296
|
+
if (signal?.aborted) throw signal.reason ?? error;
|
|
297
|
+
if (uncertainThreadCreate(error)) {
|
|
298
|
+
await sendThreadUncertainNotice(api, normalized, signal);
|
|
299
|
+
throw threadCreateUncertain(error);
|
|
300
|
+
}
|
|
301
|
+
return withConversationRoute(normalized, sourceChannel, botId, {
|
|
302
|
+
fallback: 'thread-create-failed',
|
|
303
|
+
notice: '无法创建 Thread,已直接在当前频道回复。',
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return withConversationRoute(normalized, created, botId, { fromSourceMessage: true });
|
|
307
|
+
}
|
|
308
|
+
|
|
138
309
|
export class DiscordBotClient {
|
|
139
310
|
#api;
|
|
140
311
|
#signal;
|
|
312
|
+
#deliveredNotices = new WeakSet();
|
|
141
313
|
|
|
142
314
|
constructor({ api, signal }) {
|
|
143
315
|
this.#api = api;
|
|
@@ -145,7 +317,9 @@ export class DiscordBotClient {
|
|
|
145
317
|
}
|
|
146
318
|
|
|
147
319
|
async sendText(target, text) {
|
|
148
|
-
const
|
|
320
|
+
const notice = !this.#deliveredNotices.has(target) && target?.notice
|
|
321
|
+
? String(target.notice) : null;
|
|
322
|
+
const chunks = splitMessageText(notice ? `${notice}\n\n${text}` : text, 1_900);
|
|
149
323
|
const providerMessageIds = [];
|
|
150
324
|
for (const [index, chunk] of chunks.entries()) {
|
|
151
325
|
const result = await this.#api.createMessage({
|
|
@@ -154,6 +328,7 @@ export class DiscordBotClient {
|
|
|
154
328
|
replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
|
|
155
329
|
signal: this.#signal,
|
|
156
330
|
});
|
|
331
|
+
if (notice && index === 0) this.#deliveredNotices.add(target);
|
|
157
332
|
if (typeof result?.id === 'string' && result.id) providerMessageIds.push(result.id);
|
|
158
333
|
}
|
|
159
334
|
return { providerMessageIds };
|
|
@@ -173,21 +348,25 @@ export class DiscordBotClient {
|
|
|
173
348
|
}
|
|
174
349
|
|
|
175
350
|
async openStream(target) {
|
|
351
|
+
const notice = !this.#deliveredNotices.has(target) && target?.notice
|
|
352
|
+
? String(target.notice) : null;
|
|
353
|
+
const decorate = (content) => notice ? `${notice}\n\n${content}` : content;
|
|
176
354
|
const stream = createEditableMessageStream({
|
|
177
|
-
limit: 1_900,
|
|
355
|
+
limit: notice ? 1_800 : 1_900,
|
|
178
356
|
create: async (content) => {
|
|
179
357
|
const message = await this.#api.createMessage({
|
|
180
358
|
channelId: target.channelId,
|
|
181
|
-
content,
|
|
359
|
+
content: decorate(content),
|
|
182
360
|
replyToMessageId: target.replyToMessageId,
|
|
183
361
|
signal: this.#signal,
|
|
184
362
|
});
|
|
363
|
+
if (notice) this.#deliveredNotices.add(target);
|
|
185
364
|
return message.id;
|
|
186
365
|
},
|
|
187
366
|
edit: (messageId, content) => this.#api.editMessage({
|
|
188
367
|
channelId: target.channelId,
|
|
189
368
|
messageId,
|
|
190
|
-
content,
|
|
369
|
+
content: decorate(content),
|
|
191
370
|
signal: this.#signal,
|
|
192
371
|
}),
|
|
193
372
|
sendRemainder: (content) => this.#api.createMessage({
|
|
@@ -241,6 +420,8 @@ export class DiscordRuntime {
|
|
|
241
420
|
#generation = 0;
|
|
242
421
|
#stopped = true;
|
|
243
422
|
#starting = null;
|
|
423
|
+
#channels = new Map();
|
|
424
|
+
#routing = new Map();
|
|
244
425
|
|
|
245
426
|
constructor({
|
|
246
427
|
config,
|
|
@@ -299,6 +480,8 @@ export class DiscordRuntime {
|
|
|
299
480
|
this.#resumeUrl = null;
|
|
300
481
|
this.#sequence = null;
|
|
301
482
|
this.#reconnectAttempt = 0;
|
|
483
|
+
this.#channels.clear();
|
|
484
|
+
this.#routing.clear();
|
|
302
485
|
this.#status.startedAt = new Date().toISOString();
|
|
303
486
|
this.#status.connectionState = 'connecting';
|
|
304
487
|
this.#status.lastError = null;
|
|
@@ -432,11 +615,21 @@ export class DiscordRuntime {
|
|
|
432
615
|
markReady();
|
|
433
616
|
} else if (packet.t === 'RESUMED') {
|
|
434
617
|
markReady();
|
|
618
|
+
} else if (packet.t === 'GUILD_CREATE') {
|
|
619
|
+
for (const channel of [...(packet.d?.channels ?? []), ...(packet.d?.threads ?? [])]) {
|
|
620
|
+
this.#rememberChannel(channel);
|
|
621
|
+
}
|
|
622
|
+
} else if (packet.t === 'CHANNEL_CREATE' || packet.t === 'CHANNEL_UPDATE'
|
|
623
|
+
|| packet.t === 'THREAD_CREATE' || packet.t === 'THREAD_UPDATE') {
|
|
624
|
+
this.#rememberChannel(packet.d);
|
|
625
|
+
} else if (packet.t === 'CHANNEL_DELETE' || packet.t === 'THREAD_DELETE') {
|
|
626
|
+
if (packet.d?.id) this.#channels.delete(String(packet.d.id));
|
|
627
|
+
} else if (packet.t === 'THREAD_LIST_SYNC') {
|
|
628
|
+
for (const channel of packet.d?.threads ?? []) this.#rememberChannel(channel);
|
|
435
629
|
} else if (packet.t === 'MESSAGE_CREATE') {
|
|
436
|
-
const message = normalizeDiscordMessage(packet.d, this.#config.platformId);
|
|
437
630
|
const bridge = this.#bridge;
|
|
438
|
-
if (
|
|
439
|
-
void bridge
|
|
631
|
+
if (packet.d && bridge) {
|
|
632
|
+
void this.#acceptMessage(packet.d, bridge).catch((error) => {
|
|
440
633
|
if (generation !== this.#generation || this.#stopped) return;
|
|
441
634
|
this.#logger.error?.(
|
|
442
635
|
`[dsh-im:discord] bot ${this.#config.botId} message handling failed:`,
|
|
@@ -475,6 +668,38 @@ export class DiscordRuntime {
|
|
|
475
668
|
});
|
|
476
669
|
}
|
|
477
670
|
|
|
671
|
+
#rememberChannel(channel) {
|
|
672
|
+
if (!channel?.id) return;
|
|
673
|
+
this.#channels.set(String(channel.id), channel);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async #acceptMessage(message, bridge) {
|
|
677
|
+
const messageId = String(message?.id ?? '');
|
|
678
|
+
if (!messageId || this.#state.hasSeen(messageId)) return;
|
|
679
|
+
let route = this.#routing.get(messageId);
|
|
680
|
+
if (!route) {
|
|
681
|
+
route = resolveDiscordMessageRoute(message, this.#config.platformId, {
|
|
682
|
+
api: this.#api,
|
|
683
|
+
channel: this.#channels.get(String(message.channel_id)),
|
|
684
|
+
signal: this.#abortController?.signal,
|
|
685
|
+
onChannel: (resolved) => this.#rememberChannel(resolved),
|
|
686
|
+
});
|
|
687
|
+
this.#routing.set(messageId, route);
|
|
688
|
+
void route.finally(() => {
|
|
689
|
+
if (this.#routing.get(messageId) === route) this.#routing.delete(messageId);
|
|
690
|
+
}).catch(() => undefined);
|
|
691
|
+
}
|
|
692
|
+
try {
|
|
693
|
+
const normalized = await route;
|
|
694
|
+
if (normalized) await bridge.accept(normalized);
|
|
695
|
+
} catch (error) {
|
|
696
|
+
if (error?.code === 'discord-thread-create-uncertain') {
|
|
697
|
+
await this.#state.markSeen(messageId);
|
|
698
|
+
}
|
|
699
|
+
throw error;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
478
703
|
#sendGateway(socket, payload) {
|
|
479
704
|
if (socket.readyState !== 1) return;
|
|
480
705
|
socket.send(JSON.stringify(payload));
|
|
@@ -541,6 +766,7 @@ export class DiscordRuntime {
|
|
|
541
766
|
this.#socket = null;
|
|
542
767
|
this.#bridge = null;
|
|
543
768
|
this.#api = null;
|
|
769
|
+
this.#routing.clear();
|
|
544
770
|
try {
|
|
545
771
|
if (socket && socket.readyState < 2) socket.close(1000, 'Plugin stopped');
|
|
546
772
|
} catch (error) {
|