@xmanrui/dsh-im 4.22.0 → 4.24.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 -4
- package/README.md +5 -4
- package/lib/client.js +2584 -1153
- package/lib/index.js +290 -298
- package/package.json +14 -2
- package/plugin-src/client/channel-card-meta.js +2 -0
- package/plugin-src/client/channel-logos.js +11 -0
- package/plugin-src/client/channels/dingtalk/api.js +5 -1
- package/plugin-src/client/channels/dingtalk/index.js +15 -13
- package/plugin-src/client/channels/email/api.js +52 -0
- package/plugin-src/client/channels/email/index.js +728 -0
- package/plugin-src/client/channels/email/styles.js +51 -0
- package/plugin-src/client/channels/feishu/api.js +4 -2
- package/plugin-src/client/channels/feishu/index.js +23 -7
- package/plugin-src/client/channels/imessage/index.js +2 -1
- package/plugin-src/client/channels/office/api.js +2 -0
- package/plugin-src/client/channels/office/index.js +6 -5
- package/plugin-src/client/channels/qq/api.js +7 -0
- package/plugin-src/client/channels/qq/index.js +16 -3
- package/plugin-src/client/channels/shared/token-api.js +8 -1
- package/plugin-src/client/channels/shared/token-channel.js +28 -3
- package/plugin-src/client/channels/slack/index.js +2 -1
- package/plugin-src/client/channels/telegram/index.js +3 -0
- package/plugin-src/client/channels/telegram/styles.js +12 -0
- package/plugin-src/client/channels/telegram/thinking-traces.js +25 -0
- package/plugin-src/client/channels/wecom/api.js +7 -1
- package/plugin-src/client/channels/wecom/index.js +16 -3
- package/plugin-src/client/channels/wecom-app/api.js +6 -1
- package/plugin-src/client/channels/wecom-app/index.js +16 -3
- package/plugin-src/client/channels/weixin/api.js +2 -1
- package/plugin-src/client/channels/weixin/connection-error.js +1 -71
- package/plugin-src/client/channels/whatsapp/api.js +5 -0
- package/plugin-src/client/channels/whatsapp/index.js +16 -3
- package/plugin-src/client/connection-error.js +87 -0
- package/plugin-src/client/global-settings.js +81 -1
- package/plugin-src/client/i18n.js +69 -1
- package/plugin-src/client/index.js +40 -4
- package/plugin-src/client/last-message-error.js +2 -1
- package/plugin-src/client/styles.js +8 -2
- package/plugin-src/host/build.mjs +4 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +2 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +7 -4
- package/plugin-src/host/channels/email/availability.mjs +60 -0
- package/plugin-src/host/channels/email/index.mjs +36 -0
- package/plugin-src/host/channels/email/production.mjs +58 -0
- package/plugin-src/host/channels/email/rpc.mjs +163 -0
- package/plugin-src/host/channels/feishu/production.mjs +3 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +7 -4
- package/plugin-src/host/channels/imessage/rpc.mjs +13 -6
- package/plugin-src/host/channels/office/rpc.mjs +3 -1
- package/plugin-src/host/channels/qq/production.mjs +2 -0
- package/plugin-src/host/channels/qq/rpc.mjs +5 -3
- package/plugin-src/host/channels/shared/production.mjs +52 -5
- package/plugin-src/host/channels/shared/rpc.mjs +14 -3
- package/plugin-src/host/channels/shared/startup-error.mjs +4 -3
- package/plugin-src/host/channels/shared/startup.mjs +11 -6
- package/plugin-src/host/channels/shared/thinking-traces-rpc.mjs +11 -0
- package/plugin-src/host/channels/slack/production.mjs +2 -0
- package/plugin-src/host/channels/slack/rpc.mjs +5 -3
- package/plugin-src/host/channels/wecom/production.mjs +2 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +5 -3
- package/plugin-src/host/channels/wecom-app/production.mjs +2 -0
- package/plugin-src/host/channels/wecom-app/rpc.mjs +5 -3
- package/plugin-src/host/channels/weixin/production.mjs +2 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +2 -0
- package/plugin-src/host/channels/whatsapp/rpc.mjs +5 -3
- package/plugin-src/host/delivery-adapter.mjs +11 -0
- package/plugin-src/host/image-input-rpc.mjs +24 -0
- package/plugin-src/host/inbound-ttl-rpc.mjs +6 -1
- package/plugin-src/host/index.mjs +3 -0
- package/plugin-src/host/modern-harness-api.mjs +7 -2
- package/plugin-src/management-rpc.mjs +12 -2
- package/scripts/verify-package.mjs +13 -5
- package/src/channels/dingtalk/connection-error.mjs +5 -12
- package/src/channels/dingtalk/device-auth.mjs +4 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -4
- package/src/channels/dingtalk/dingtalk-controller.mjs +60 -45
- package/src/channels/dingtalk/dingtalk-runtime.mjs +20 -4
- package/src/channels/discord/discord-api.mjs +3 -3
- package/src/channels/discord/discord-runtime.mjs +18 -12
- package/src/channels/email/config-store.mjs +194 -0
- package/src/channels/email/email-api.mjs +20 -0
- package/src/channels/email/email-bridge.mjs +17 -0
- package/src/channels/email/email-controller.mjs +824 -0
- package/src/channels/email/email-runtime.mjs +699 -0
- package/src/channels/email/harness-client.mjs +7 -0
- package/src/channels/email/mail-format.mjs +91 -0
- package/src/channels/email/state-store.mjs +176 -0
- package/src/channels/email/transport.mjs +64 -0
- package/src/channels/email/transports/agent-mail.mjs +604 -0
- package/src/channels/email/transports/agently-cli.mjs +288 -0
- package/src/channels/email/transports/imap-smtp.mjs +165 -0
- package/src/channels/feishu/bridge.mjs +39 -11
- package/src/channels/feishu/feishu-channel.mjs +35 -0
- package/src/channels/feishu/feishu-runtime.mjs +15 -8
- package/src/channels/feishu/group-message-permission-manager.mjs +2 -1
- package/src/channels/feishu/live-cot.mjs +260 -0
- package/src/channels/feishu/message-utils.mjs +3 -2
- package/src/channels/feishu/multi-bot-controller.mjs +56 -47
- package/src/channels/feishu/registration-manager.mjs +8 -4
- package/src/channels/feishu/repair-manager.mjs +2 -1
- package/src/channels/feishu/slash-command-registry.mjs +17 -0
- package/src/channels/feishu/step-push-mode.mjs +10 -4
- package/src/channels/imessage/runtime.mjs +9 -5
- package/src/channels/office/office-controller.mjs +23 -12
- package/src/channels/office/office-runtime.mjs +6 -3
- package/src/channels/office/office-transport.mjs +1 -0
- package/src/channels/qq/qq-bridge.mjs +7 -5
- package/src/channels/qq/qq-controller.mjs +56 -42
- package/src/channels/qq/qq-runtime.mjs +16 -11
- package/src/channels/shared/bot-workspace-store.mjs +29 -10
- package/src/channels/shared/connection-error.mjs +191 -0
- package/src/channels/shared/connection-test.mjs +3 -1
- package/src/channels/shared/conversation-state-store.mjs +46 -1
- package/src/channels/shared/diagnostic-details.mjs +95 -0
- package/src/channels/shared/harness-client.mjs +287 -57
- package/src/channels/shared/i18n-en/diagnostics.mjs +55 -0
- package/src/channels/shared/i18n-en/email.mjs +25 -0
- package/src/channels/shared/i18n-en/image-input.mjs +17 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +1 -1
- package/src/channels/shared/i18n-en.mjs +6 -0
- package/src/channels/shared/image-input-policy.mjs +35 -0
- package/src/channels/shared/image-input-settings-store.mjs +60 -0
- package/src/channels/shared/image-input.mjs +124 -0
- package/src/channels/shared/image-prompt.mjs +15 -7
- package/src/channels/shared/message-failure.mjs +12 -0
- package/src/channels/shared/semantic/reply-reference.mjs +3 -3
- package/src/channels/shared/text-harness-bridge.mjs +150 -25
- package/src/channels/shared/token-bot-controller.mjs +52 -47
- package/src/channels/slack/slack-api.mjs +3 -3
- package/src/channels/slack/slack-controller.mjs +59 -51
- package/src/channels/slack/slack-runtime.mjs +18 -12
- package/src/channels/telegram/config-store.mjs +4 -1
- package/src/channels/telegram/telegram-api.mjs +2 -2
- package/src/channels/telegram/telegram-controller.mjs +13 -1
- package/src/channels/telegram/telegram-runtime.mjs +215 -14
- package/src/channels/wecom/wecom-bridge.mjs +15 -6
- package/src/channels/wecom/wecom-controller.mjs +51 -37
- package/src/channels/wecom/wecom-runtime.mjs +10 -6
- package/src/channels/wecom-app/wecom-app-bridge.mjs +7 -5
- package/src/channels/wecom-app/wecom-app-controller.mjs +50 -37
- package/src/channels/wecom-app/wecom-app-runtime.mjs +2 -1
- package/src/channels/weixin/connection-error.mjs +26 -92
- package/src/channels/weixin/diagnostic-details.mjs +1 -63
- package/src/channels/weixin/weixin-bridge.mjs +7 -5
- package/src/channels/whatsapp/whatsapp-controller.mjs +34 -17
- package/src/channels/whatsapp/whatsapp-runtime.mjs +35 -8
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extractConnectionEvidence, atConnectionStage, createConnectionDiagnostics } from '../shared/connection-error.mjs';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -135,6 +136,7 @@ export class DingtalkController {
|
|
|
135
136
|
#createRuntime;
|
|
136
137
|
#deleteState;
|
|
137
138
|
#logger;
|
|
139
|
+
#diagnostics;
|
|
138
140
|
#clock;
|
|
139
141
|
#runtimes = new Map();
|
|
140
142
|
#errors = new Map();
|
|
@@ -193,10 +195,17 @@ export class DingtalkController {
|
|
|
193
195
|
this.#createRuntime = createRuntime;
|
|
194
196
|
this.#deleteState = deleteState;
|
|
195
197
|
this.#logger = logger;
|
|
198
|
+
this.#diagnostics = createConnectionDiagnostics({ channel: 'dingtalk', prefix: 'DT-CONN', logger });
|
|
196
199
|
this.#clock = clock;
|
|
197
200
|
}
|
|
198
201
|
|
|
199
202
|
/** Starts all configured DingTalk runtimes whose secrets are available. */
|
|
203
|
+
get diagnostics() { return this.#diagnostics; }
|
|
204
|
+
#failure(error, code, message) {
|
|
205
|
+
const stage = code.startsWith('poll-') ? 'qr.poll' : code === 'activation-failed' ? 'activation' : 'qr.begin';
|
|
206
|
+
return this.#diagnostics.report(error, { reuse: true, stage, publicError: { code, message } }).publicError;
|
|
207
|
+
}
|
|
208
|
+
|
|
200
209
|
async initialize() {
|
|
201
210
|
if (this.#closed) return this.status();
|
|
202
211
|
for (const config of this.#configStore.list()) {
|
|
@@ -209,16 +218,17 @@ export class DingtalkController {
|
|
|
209
218
|
await this.#withBotTransition(config.botId, async () => {
|
|
210
219
|
const latest = this.#configStore.get(config.botId);
|
|
211
220
|
if (!latest || this.#closed) return;
|
|
212
|
-
|
|
213
|
-
if (!clientSecret) {
|
|
214
|
-
this.#errors.set(
|
|
215
|
-
latest.botId,
|
|
216
|
-
safeError('missing-secret', t('钉钉机器人凭据缺失,请移除后重新扫码。')),
|
|
217
|
-
);
|
|
218
|
-
this.#touch();
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
+
let clientSecret;
|
|
221
222
|
try {
|
|
223
|
+
clientSecret = await this.#resolveSecret(latest.secretRef);
|
|
224
|
+
if (!clientSecret) {
|
|
225
|
+
this.#errors.set(
|
|
226
|
+
latest.botId,
|
|
227
|
+
safeError('missing-secret', t('钉钉机器人凭据缺失,请移除后重新扫码。')),
|
|
228
|
+
);
|
|
229
|
+
this.#touch();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
222
232
|
await this.#startRuntime(latest, clientSecret);
|
|
223
233
|
this.#errors.delete(latest.botId);
|
|
224
234
|
} catch (error) {
|
|
@@ -282,7 +292,7 @@ export class DingtalkController {
|
|
|
282
292
|
record.error = safeError('cancelled', t('扫码接入已取消。'));
|
|
283
293
|
} else {
|
|
284
294
|
record.state = 'failed';
|
|
285
|
-
record.error =
|
|
295
|
+
record.error = this.#failure(error, 'qr-start-failed', t('无法生成钉钉二维码,请稍后重试。'));
|
|
286
296
|
}
|
|
287
297
|
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
288
298
|
this.#touch();
|
|
@@ -307,7 +317,7 @@ export class DingtalkController {
|
|
|
307
317
|
await this.#withBotTransition(identity.botId, async () => {
|
|
308
318
|
if (this.#closed) throw abortError();
|
|
309
319
|
const previousConfig = this.#configStore.getByClientId(normalizedClientId);
|
|
310
|
-
const previousSecret = await this.#credentials.resolve(identity.secretRef)
|
|
320
|
+
const previousSecret = await atConnectionStage('credential.read', () => this.#credentials.resolve(identity.secretRef), 'credential-store');
|
|
311
321
|
if (this.#closed) throw abortError();
|
|
312
322
|
const config = {
|
|
313
323
|
botId: identity.botId,
|
|
@@ -315,9 +325,9 @@ export class DingtalkController {
|
|
|
315
325
|
secretRef: identity.secretRef,
|
|
316
326
|
approvedSenders: previousConfig?.approvedSenders ?? [],
|
|
317
327
|
};
|
|
318
|
-
await this.#credentials.set(identity.secretRef, normalizedSecret);
|
|
328
|
+
await atConnectionStage('credential.save', () => this.#credentials.set(identity.secretRef, normalizedSecret), 'credential-store');
|
|
319
329
|
try {
|
|
320
|
-
await this.#configStore.save(config);
|
|
330
|
+
await atConnectionStage('account.save', () => this.#configStore.save(config), 'account-config');
|
|
321
331
|
} catch (error) {
|
|
322
332
|
await this.#restoreCredential(identity.secretRef, previousSecret);
|
|
323
333
|
throw error;
|
|
@@ -434,30 +444,37 @@ export class DingtalkController {
|
|
|
434
444
|
|
|
435
445
|
/** Removes one bot, its secret, runtime, and local conversation state. */
|
|
436
446
|
async deleteBot(botId) {
|
|
447
|
+
const warnings = [];
|
|
437
448
|
const config = this.#configStore.get(botId);
|
|
438
449
|
if (!config) throw new Error('Unknown DingTalk bot');
|
|
439
450
|
await this.#withBotTransition(botId, async () => {
|
|
440
|
-
const previousSecret = await this.#credentials.resolve(config.secretRef)
|
|
451
|
+
const previousSecret = await atConnectionStage('credential.read', () => this.#credentials.resolve(config.secretRef), 'credential-store');
|
|
441
452
|
await this.#stopRuntime(botId);
|
|
442
453
|
try {
|
|
443
|
-
await this.#credentials.unset(config.secretRef);
|
|
444
|
-
await this.#configStore.remove(botId);
|
|
454
|
+
await atConnectionStage('credential.remove', () => this.#credentials.unset(config.secretRef), 'credential-store');
|
|
455
|
+
await atConnectionStage('account.remove', () => this.#configStore.remove(botId), 'account-config');
|
|
445
456
|
} catch (error) {
|
|
446
|
-
if (
|
|
447
|
-
|
|
448
|
-
|
|
457
|
+
if (!this.#configStore.get(botId)) {
|
|
458
|
+
warnings.push(this.#diagnostics.report(error, { operation: 'bot.delete', stage: 'workspace.cleanup', warning: true,
|
|
459
|
+
publicError: { code: 'workspace-cleanup-failed', message: '账号已移除,但本地状态清理失败。' } }).publicError);
|
|
460
|
+
} else {
|
|
461
|
+
if (cleanString(previousSecret?.value)) {
|
|
462
|
+
await atConnectionStage('credential.save', () => this.#credentials.set(config.secretRef, previousSecret.value), 'credential-store').catch(() => undefined);
|
|
463
|
+
await this.#startRuntime(config, previousSecret.value).catch(() => undefined);
|
|
464
|
+
}
|
|
465
|
+
throw new Error('Unable to remove the DingTalk bot safely.', { cause: error });
|
|
449
466
|
}
|
|
450
|
-
throw new Error('Unable to remove the DingTalk bot safely.', { cause: error });
|
|
451
467
|
}
|
|
452
468
|
try {
|
|
453
469
|
await this.#deleteState({ botId, config });
|
|
454
|
-
} catch {
|
|
455
|
-
this.#
|
|
470
|
+
} catch (error) {
|
|
471
|
+
warnings.push(this.#diagnostics.report(error, { reuse: true, operation: 'bot.delete', stage: 'state.cleanup', warning: true,
|
|
472
|
+
publicError: { code: 'cleanup-failed', message: '账号已移除,但本地状态清理失败。' } }).publicError);
|
|
456
473
|
}
|
|
457
474
|
this.#errors.delete(botId);
|
|
458
475
|
this.#touch();
|
|
459
476
|
});
|
|
460
|
-
return this.status();
|
|
477
|
+
return { ...this.status(), ...(warnings.length ? { warnings } : {}) };
|
|
461
478
|
}
|
|
462
479
|
|
|
463
480
|
/** Approves one opaque pending-sender request for a bot. */
|
|
@@ -517,7 +534,7 @@ export class DingtalkController {
|
|
|
517
534
|
currentStatus = { state: 'error' };
|
|
518
535
|
}
|
|
519
536
|
const connected = isRuntimeConnected(runtime, currentStatus);
|
|
520
|
-
const accountError = this.#errors.get(config.botId);
|
|
537
|
+
const accountError = currentStatus.error ?? this.#errors.get(config.botId);
|
|
521
538
|
const state = connected ? 'connected' : accountError ? 'error' : 'offline';
|
|
522
539
|
const approvedIds = new Set(config.approvedSenders.map((sender) => sender.staffId));
|
|
523
540
|
const pending = internalPendingSenders(currentStatus)
|
|
@@ -626,7 +643,7 @@ export class DingtalkController {
|
|
|
626
643
|
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
627
644
|
} else if (record.state === 'connecting') {
|
|
628
645
|
record.state = 'failed';
|
|
629
|
-
record.error =
|
|
646
|
+
record.error = this.#failure(error,
|
|
630
647
|
'activation-failed',
|
|
631
648
|
t('钉钉已授权,但无法安全保存接入配置。'),
|
|
632
649
|
);
|
|
@@ -634,7 +651,7 @@ export class DingtalkController {
|
|
|
634
651
|
this.#logger.error?.('[dsh-dingtalk] bot activation failed');
|
|
635
652
|
} else {
|
|
636
653
|
record.state = 'pending';
|
|
637
|
-
record.error =
|
|
654
|
+
record.error = this.#failure(error, 'poll-failed', t('钉钉授权查询暂时失败,正在重试。'));
|
|
638
655
|
}
|
|
639
656
|
} finally {
|
|
640
657
|
this.#touch();
|
|
@@ -645,7 +662,7 @@ export class DingtalkController {
|
|
|
645
662
|
async #activateBot(record, { clientId, clientSecret }) {
|
|
646
663
|
const identity = deriveDingtalkBotIdentity(clientId);
|
|
647
664
|
const previousConfig = this.#configStore.getByClientId(clientId);
|
|
648
|
-
const previousSecret = await this.#credentials.resolve(identity.secretRef)
|
|
665
|
+
const previousSecret = await atConnectionStage('credential.read', () => this.#credentials.resolve(identity.secretRef), 'credential-store');
|
|
649
666
|
const config = {
|
|
650
667
|
botId: identity.botId,
|
|
651
668
|
clientId,
|
|
@@ -655,12 +672,12 @@ export class DingtalkController {
|
|
|
655
672
|
return this.#withBotTransition(identity.botId, async () => {
|
|
656
673
|
const rollback = async () => {
|
|
657
674
|
await this.#stopRuntime(identity.botId);
|
|
658
|
-
if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
|
|
675
|
+
if (previousConfig) await atConnectionStage('account.save', () => this.#configStore.save(previousConfig), 'account-config').catch(() => undefined);
|
|
659
676
|
else if (this.#configStore.get(identity.botId)) {
|
|
660
|
-
const removed = await this.#configStore.remove(identity.botId).catch(() => null);
|
|
677
|
+
const removed = await atConnectionStage('account.remove', () => this.#configStore.remove(identity.botId), 'account-config').catch(() => null);
|
|
661
678
|
if (removed) {
|
|
662
679
|
await this.#deleteState({ botId: identity.botId, config }).catch((cleanupError) => {
|
|
663
|
-
this.#logger.warn?.('[dsh-dingtalk] failed to clean up cancelled bot state:', cleanupError);
|
|
680
|
+
this.#logger.warn?.('[dsh-dingtalk] failed to clean up cancelled bot state:', extractConnectionEvidence(cleanupError).details);
|
|
664
681
|
});
|
|
665
682
|
}
|
|
666
683
|
}
|
|
@@ -669,10 +686,10 @@ export class DingtalkController {
|
|
|
669
686
|
await this.#startRuntime(previousConfig, previousSecret.value).catch(() => undefined);
|
|
670
687
|
}
|
|
671
688
|
};
|
|
672
|
-
await this.#credentials.set(identity.secretRef, clientSecret);
|
|
689
|
+
await atConnectionStage('credential.save', () => this.#credentials.set(identity.secretRef, clientSecret), 'credential-store');
|
|
673
690
|
try {
|
|
674
691
|
this.#assertAttemptActive(record);
|
|
675
|
-
await this.#configStore.save(config);
|
|
692
|
+
await atConnectionStage('account.save', () => this.#configStore.save(config), 'account-config');
|
|
676
693
|
this.#assertAttemptActive(record);
|
|
677
694
|
} catch (error) {
|
|
678
695
|
await rollback();
|
|
@@ -703,12 +720,12 @@ export class DingtalkController {
|
|
|
703
720
|
return this.#withBotTransition(previousConfig.botId, async () => {
|
|
704
721
|
const clientSecret = await this.#resolveSecret(previousConfig.secretRef);
|
|
705
722
|
if (!clientSecret) throw new Error('The DingTalk client secret is missing');
|
|
706
|
-
await this.#configStore.save(nextConfig);
|
|
723
|
+
await atConnectionStage('account.save', () => this.#configStore.save(nextConfig), 'account-config');
|
|
707
724
|
try {
|
|
708
725
|
await this.#startRuntime(nextConfig, clientSecret);
|
|
709
726
|
this.#errors.delete(previousConfig.botId);
|
|
710
727
|
} catch (error) {
|
|
711
|
-
await this.#configStore.save(previousConfig).catch(() => undefined);
|
|
728
|
+
await atConnectionStage('account.save', () => this.#configStore.save(previousConfig), 'account-config').catch(() => undefined);
|
|
712
729
|
await this.#startRuntime(previousConfig, clientSecret).catch(() => undefined);
|
|
713
730
|
const publicError = this.#rememberConnectionFailure({
|
|
714
731
|
config: previousConfig,
|
|
@@ -730,11 +747,11 @@ export class DingtalkController {
|
|
|
730
747
|
if (this.#closed) throw abortError();
|
|
731
748
|
let runtime;
|
|
732
749
|
try {
|
|
733
|
-
runtime = await this.#createRuntime({
|
|
750
|
+
runtime = await atConnectionStage('runtime.prepare', () => this.#createRuntime({
|
|
734
751
|
botId: config.botId,
|
|
735
752
|
config: structuredClone(config),
|
|
736
753
|
clientSecret,
|
|
737
|
-
});
|
|
754
|
+
}));
|
|
738
755
|
} catch (error) {
|
|
739
756
|
throw dingtalkRuntimeStartError('dingtalk-runtime-prepare-failed', error);
|
|
740
757
|
}
|
|
@@ -774,12 +791,10 @@ export class DingtalkController {
|
|
|
774
791
|
clientId: config.clientId,
|
|
775
792
|
clientSecret,
|
|
776
793
|
});
|
|
777
|
-
this.#
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
);
|
|
782
|
-
return failure.publicError;
|
|
794
|
+
const reported = this.#diagnostics.report(error, { reuse: true, botId: config.botId, stage: 'connection.start', publicError: { ...failure.publicError, details: { dependencies: failure.diagnostic.dependencies, proxyConfigured: failure.diagnostic.proxy.configured } } }).publicError;
|
|
795
|
+
const visible = { ...failure.publicError, details: reported.details };
|
|
796
|
+
this.#errors.set(config.botId, visible);
|
|
797
|
+
return visible;
|
|
783
798
|
}
|
|
784
799
|
|
|
785
800
|
async #stopRuntime(botId) {
|
|
@@ -791,14 +806,14 @@ export class DingtalkController {
|
|
|
791
806
|
}
|
|
792
807
|
|
|
793
808
|
async #resolveSecret(secretRef) {
|
|
794
|
-
const result = await this.#credentials.resolve(secretRef)
|
|
809
|
+
const result = await atConnectionStage('credential.read', () => this.#credentials.resolve(secretRef), 'credential-store');
|
|
795
810
|
return cleanString(result?.value);
|
|
796
811
|
}
|
|
797
812
|
|
|
798
813
|
async #restoreCredential(secretRef, previous) {
|
|
799
814
|
try {
|
|
800
|
-
if (cleanString(previous?.value)) await this.#credentials.set(secretRef, previous.value);
|
|
801
|
-
else await this.#credentials.unset(secretRef);
|
|
815
|
+
if (cleanString(previous?.value)) await atConnectionStage('credential.save', () => this.#credentials.set(secretRef, previous.value), 'credential-store');
|
|
816
|
+
else await atConnectionStage('credential.remove', () => this.#credentials.unset(secretRef), 'credential-store');
|
|
802
817
|
} catch {
|
|
803
818
|
this.#logger.error?.(`[dsh-dingtalk] failed to restore credential ${secretRef}`);
|
|
804
819
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { atConnectionStage, createConnectionDiagnostics } from '../shared/connection-error.mjs';
|
|
1
2
|
import { createDingtalkApi } from './dingtalk-api.mjs';
|
|
2
3
|
import {
|
|
3
4
|
createDingtalkBridgeStatus,
|
|
@@ -87,7 +88,7 @@ async function connectStream(client, timeoutMs, pollIntervalMs, signal) {
|
|
|
87
88
|
void connectTask.then(() => client.disconnect()).catch(() => undefined);
|
|
88
89
|
}
|
|
89
90
|
if (signal.aborted) throw signal.reason;
|
|
90
|
-
throw new Error(`DingTalk Stream handshake timed out after ${timeoutMs}ms`);
|
|
91
|
+
throw Object.assign(new Error(`DingTalk Stream handshake timed out after ${timeoutMs}ms`), { code: 'ETIMEDOUT', timeoutMs, cause: error });
|
|
91
92
|
}
|
|
92
93
|
throw error;
|
|
93
94
|
}
|
|
@@ -134,6 +135,7 @@ export class DingtalkRuntime {
|
|
|
134
135
|
#contextEnhancement;
|
|
135
136
|
#accessPolicy;
|
|
136
137
|
#logger;
|
|
138
|
+
#diagnostics;
|
|
137
139
|
#replyTimeoutMs;
|
|
138
140
|
#maxMessageChars;
|
|
139
141
|
#connectTimeoutMs;
|
|
@@ -176,6 +178,7 @@ export class DingtalkRuntime {
|
|
|
176
178
|
this.#contextEnhancement = contextEnhancement;
|
|
177
179
|
this.#accessPolicy = accessPolicy;
|
|
178
180
|
this.#logger = logger;
|
|
181
|
+
this.#diagnostics = createConnectionDiagnostics({ channel: 'dingtalk', logger, prefix: 'DT-CONN' });
|
|
179
182
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
180
183
|
this.#maxMessageChars = maxMessageChars;
|
|
181
184
|
this.#connectTimeoutMs = connectTimeoutMs;
|
|
@@ -228,7 +231,7 @@ export class DingtalkRuntime {
|
|
|
228
231
|
let startStage = 'dingtalk-harness-connect-failed';
|
|
229
232
|
|
|
230
233
|
try {
|
|
231
|
-
await this.#harness.ensureRunning({ signal });
|
|
234
|
+
await atConnectionStage('harness.check', () => this.#harness.ensureRunning({ signal }));
|
|
232
235
|
this.#status.harnessReachable = true;
|
|
233
236
|
startStage = 'dingtalk-runtime-prepare-failed';
|
|
234
237
|
if (typeof this.#state.removePendingSenderByStaffId === 'function') {
|
|
@@ -348,12 +351,22 @@ export class DingtalkRuntime {
|
|
|
348
351
|
this.#status.lastConnectedAt = Date.now();
|
|
349
352
|
this.#status.lastCheckedAt = Date.now();
|
|
350
353
|
this.#status.lastError = null;
|
|
354
|
+
this.#status.error = null;
|
|
355
|
+
this.#diagnostics.clear();
|
|
351
356
|
this.#connectionMonitor = setInterval(() => {
|
|
352
357
|
const connected = streamIsOpen(client);
|
|
353
358
|
this.#status.ready = connected;
|
|
354
359
|
this.#status.dingtalkStreamState = connected ? 'connected' : 'reconnecting';
|
|
355
360
|
this.#status.lastCheckedAt = Date.now();
|
|
356
|
-
if (connected)
|
|
361
|
+
if (connected) {
|
|
362
|
+
this.#status.lastError = null;
|
|
363
|
+
this.#status.error = null;
|
|
364
|
+
this.#diagnostics.clear();
|
|
365
|
+
} else {
|
|
366
|
+
this.#status.error = this.#diagnostics.report(null, { operation: 'connection.monitor', automatic: true, botId: this.#config.botId,
|
|
367
|
+
publicError: { code: 'stream-connect-failed', message: t('钉钉连接未就绪,请稍后重试。') } }).publicError;
|
|
368
|
+
this.#status.lastError = this.#status.error.message;
|
|
369
|
+
}
|
|
357
370
|
}, 1_000);
|
|
358
371
|
this.#connectionMonitor.unref?.();
|
|
359
372
|
return this.status;
|
|
@@ -362,7 +375,7 @@ export class DingtalkRuntime {
|
|
|
362
375
|
const failure = aborted ? error : dingtalkRuntimeStartError(startStage, error);
|
|
363
376
|
this.#status.ready = false;
|
|
364
377
|
this.#status.dingtalkStreamState = aborted ? 'idle' : 'failed';
|
|
365
|
-
this.#status.lastError = aborted ? null : (
|
|
378
|
+
this.#status.lastError = aborted ? null : t('钉钉连接未就绪,请稍后重试。');
|
|
366
379
|
await this.stop({ preserveError: !aborted });
|
|
367
380
|
throw failure;
|
|
368
381
|
}
|
|
@@ -370,6 +383,7 @@ export class DingtalkRuntime {
|
|
|
370
383
|
|
|
371
384
|
async stop({ preserveError = false } = {}) {
|
|
372
385
|
const lastError = preserveError ? this.#status.lastError : null;
|
|
386
|
+
const publicError = preserveError ? this.#status.error : null;
|
|
373
387
|
const abortController = this.#abortController;
|
|
374
388
|
this.#abortController = null;
|
|
375
389
|
abortController?.abort(new DOMException('DingTalk runtime stopped', 'AbortError'));
|
|
@@ -393,6 +407,8 @@ export class DingtalkRuntime {
|
|
|
393
407
|
this.#bridge = null;
|
|
394
408
|
this.#status.dingtalkStreamState = preserveError ? 'failed' : 'idle';
|
|
395
409
|
this.#status.lastError = lastError;
|
|
410
|
+
this.#status.error = publicError;
|
|
411
|
+
if (!preserveError) this.#diagnostics.clear();
|
|
396
412
|
return this.status;
|
|
397
413
|
}
|
|
398
414
|
|
|
@@ -295,16 +295,16 @@ export class DiscordApi {
|
|
|
295
295
|
});
|
|
296
296
|
} catch (error) {
|
|
297
297
|
if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
|
|
298
|
-
throw new Error(`Discord ${method} transport failed
|
|
298
|
+
throw new Error(`Discord ${method} transport failed`, { cause: error });
|
|
299
299
|
}
|
|
300
300
|
|
|
301
301
|
let parsed = null;
|
|
302
302
|
if (expectBody || response.status === 429 || !response.ok) {
|
|
303
303
|
try {
|
|
304
304
|
parsed = await response.json();
|
|
305
|
-
} catch {
|
|
305
|
+
} catch (cause) {
|
|
306
306
|
if (expectBody) {
|
|
307
|
-
const error = new Error(`Discord ${method} returned invalid JSON
|
|
307
|
+
const error = new Error(`Discord ${method} returned invalid JSON`, { cause });
|
|
308
308
|
error.status = response?.status;
|
|
309
309
|
throw error;
|
|
310
310
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extractConnectionEvidence, createConnectionDiagnostics, atConnectionStage } from '../shared/connection-error.mjs';
|
|
1
2
|
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
|
|
2
3
|
import { fetchFileStream } from '../shared/file-download.mjs';
|
|
3
4
|
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
|
@@ -534,6 +535,7 @@ export class DiscordRuntime {
|
|
|
534
535
|
#contextEnhancement;
|
|
535
536
|
#accessPolicy;
|
|
536
537
|
#logger;
|
|
538
|
+
#diagnostics;
|
|
537
539
|
#replyTimeoutMs;
|
|
538
540
|
#connectTimeoutMs;
|
|
539
541
|
#createApi;
|
|
@@ -582,7 +584,7 @@ export class DiscordRuntime {
|
|
|
582
584
|
this.#state = state;
|
|
583
585
|
this.#contextEnhancement = contextEnhancement;
|
|
584
586
|
this.#accessPolicy = accessPolicy;
|
|
585
|
-
this.#logger = logger;
|
|
587
|
+
this.#logger = logger; this.#diagnostics = createConnectionDiagnostics({ channel: 'discord', logger });
|
|
586
588
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
587
589
|
this.#connectTimeoutMs = connectTimeoutMs;
|
|
588
590
|
this.#createApi = createApi;
|
|
@@ -639,8 +641,8 @@ export class DiscordRuntime {
|
|
|
639
641
|
this.#routing.clear();
|
|
640
642
|
this.#status.startedAt = new Date().toISOString();
|
|
641
643
|
this.#status.connectionState = 'connecting';
|
|
642
|
-
this.#status.lastError = null;
|
|
643
|
-
await this.#harness.ensureRunning();
|
|
644
|
+
this.#status.lastError = null; this.#status.error = null; this.#diagnostics.clear();
|
|
645
|
+
await atConnectionStage('harness.check', () => this.#harness.ensureRunning());
|
|
644
646
|
this.#status.harnessReachable = true;
|
|
645
647
|
const controller = new AbortController();
|
|
646
648
|
this.#abortController = controller;
|
|
@@ -672,7 +674,7 @@ export class DiscordRuntime {
|
|
|
672
674
|
await Promise.race([
|
|
673
675
|
this.#openSocket(false),
|
|
674
676
|
new Promise((_, reject) => {
|
|
675
|
-
timer = setTimeout(() => reject(new Error('Discord Gateway did not become ready in time')), this.#connectTimeoutMs);
|
|
677
|
+
timer = setTimeout(() => reject(Object.assign(new Error('Discord Gateway did not become ready in time'), { code: 'ETIMEDOUT', timeoutMs: this.#connectTimeoutMs })), this.#connectTimeoutMs);
|
|
676
678
|
}),
|
|
677
679
|
]);
|
|
678
680
|
} finally {
|
|
@@ -682,7 +684,8 @@ export class DiscordRuntime {
|
|
|
682
684
|
} catch (error) {
|
|
683
685
|
this.#status.ready = false;
|
|
684
686
|
this.#status.connectionState = 'failed';
|
|
685
|
-
this.#status.
|
|
687
|
+
this.#status.error = this.#diagnostics.report(error, { operation: 'connection.restore', reuse: true, botId: this.#config?.botId, automatic: true }).publicError;
|
|
688
|
+
this.#status.lastError = this.#status.error.message;
|
|
686
689
|
await this.stop();
|
|
687
690
|
throw error;
|
|
688
691
|
}
|
|
@@ -705,7 +708,7 @@ export class DiscordRuntime {
|
|
|
705
708
|
this.#status.connectionState = 'connected';
|
|
706
709
|
this.#status.lastCheckedAt = now;
|
|
707
710
|
this.#status.lastConnectedAt = now;
|
|
708
|
-
this.#status.lastError = null;
|
|
711
|
+
this.#status.lastError = null; this.#status.error = null; this.#diagnostics.clear();
|
|
709
712
|
resolve();
|
|
710
713
|
};
|
|
711
714
|
addSocketListener(socket, 'message', (event) => {
|
|
@@ -790,7 +793,7 @@ export class DiscordRuntime {
|
|
|
790
793
|
if (generation !== this.#generation || this.#stopped) return;
|
|
791
794
|
this.#logger.error?.(
|
|
792
795
|
`[dsh-im:discord] bot ${this.#config.botId} message handling failed:`,
|
|
793
|
-
error,
|
|
796
|
+
extractConnectionEvidence(error).details,
|
|
794
797
|
);
|
|
795
798
|
});
|
|
796
799
|
}
|
|
@@ -807,7 +810,8 @@ export class DiscordRuntime {
|
|
|
807
810
|
const error = gatewayCloseError(Number(event.code) || 0);
|
|
808
811
|
this.#status.ready = false;
|
|
809
812
|
this.#status.connectionState = 'connecting';
|
|
810
|
-
this.#status.
|
|
813
|
+
this.#status.error = this.#diagnostics.report(error, { operation: 'connection.monitor', botId: this.#config?.botId, automatic: true }).publicError;
|
|
814
|
+
this.#status.lastError = this.#status.error.message;
|
|
811
815
|
if (!settled) {
|
|
812
816
|
settled = true;
|
|
813
817
|
reject(error);
|
|
@@ -818,9 +822,11 @@ export class DiscordRuntime {
|
|
|
818
822
|
}
|
|
819
823
|
this.#scheduleReconnect();
|
|
820
824
|
});
|
|
821
|
-
addSocketListener(socket, 'error', () => {
|
|
825
|
+
addSocketListener(socket, 'error', (event) => {
|
|
826
|
+
const error = event?.error ?? new Error('WebSocket error');
|
|
822
827
|
if (generation !== this.#generation || this.#stopped) return;
|
|
823
|
-
this.#status.
|
|
828
|
+
this.#status.error = this.#diagnostics.report(error, { operation: 'connection.monitor', botId: this.#config?.botId, automatic: true }).publicError;
|
|
829
|
+
this.#status.lastError = this.#status.error.message;
|
|
824
830
|
});
|
|
825
831
|
});
|
|
826
832
|
}
|
|
@@ -931,7 +937,7 @@ export class DiscordRuntime {
|
|
|
931
937
|
this.#reconnectTimer = null;
|
|
932
938
|
void this.#openSocket(Boolean(this.#sessionId)).catch((error) => {
|
|
933
939
|
if (this.#stopped) return;
|
|
934
|
-
this.#logger.warn?.('[dsh-im:discord] Gateway reconnect failed:', error);
|
|
940
|
+
this.#logger.warn?.('[dsh-im:discord] Gateway reconnect failed:', extractConnectionEvidence(error).details);
|
|
935
941
|
this.#scheduleReconnect();
|
|
936
942
|
});
|
|
937
943
|
}, delay);
|
|
@@ -955,7 +961,7 @@ export class DiscordRuntime {
|
|
|
955
961
|
try {
|
|
956
962
|
if (socket && socket.readyState < 2) socket.close(1000, 'Plugin stopped');
|
|
957
963
|
} catch (error) {
|
|
958
|
-
this.#logger.warn?.(`[dsh-im:discord] bot ${this.#config.botId} failed to close Gateway:`, error);
|
|
964
|
+
this.#logger.warn?.(`[dsh-im:discord] bot ${this.#config.botId} failed to close Gateway:`, extractConnectionEvidence(error).details);
|
|
959
965
|
}
|
|
960
966
|
await Promise.race([
|
|
961
967
|
bridge?.waitForIdle() ?? Promise.resolve(),
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { t } from '../shared/i18n.mjs';
|
|
2
|
+
import {
|
|
3
|
+
deriveTokenBotIdentity,
|
|
4
|
+
maskPlatformId,
|
|
5
|
+
TokenBotConfigStore,
|
|
6
|
+
} from '../shared/token-config-store.mjs';
|
|
7
|
+
|
|
8
|
+
const IDENTITY_OPTIONS = Object.freeze({
|
|
9
|
+
botPrefix: 'email',
|
|
10
|
+
tokenRefPrefix: 'DSH_EMAIL_PASSWORD',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const EMAIL_ADDRESS = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/;
|
|
14
|
+
const HOSTNAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How the mailbox is reached. Each value maps to one transport implementation,
|
|
18
|
+
* so adding a mail protocol adds a transport rather than another channel.
|
|
19
|
+
*/
|
|
20
|
+
export const EMAIL_TRANSPORTS = Object.freeze({
|
|
21
|
+
'imap-smtp': Object.freeze({
|
|
22
|
+
key: 'imap-smtp',
|
|
23
|
+
label: 'IMAP / SMTP(任意邮箱)',
|
|
24
|
+
// Needs the address plus an app password, and knows its server hosts.
|
|
25
|
+
fields: Object.freeze(['address', 'password', 'provider', 'hosts', 'allowedSenders']),
|
|
26
|
+
}),
|
|
27
|
+
'agent-mail': Object.freeze({
|
|
28
|
+
key: 'agent-mail',
|
|
29
|
+
label: '腾讯 Agent 邮箱',
|
|
30
|
+
// Authorizes by QR code, so no password and no server hosts.
|
|
31
|
+
fields: Object.freeze(['address', 'allowedSenders']),
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_EMAIL_TRANSPORT = 'imap-smtp';
|
|
36
|
+
|
|
37
|
+
/** Normalize a transport key, defaulting to the standard IMAP/SMTP one. */
|
|
38
|
+
export function normalizeEmailTransport(value) {
|
|
39
|
+
const key = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
40
|
+
if (!key) return DEFAULT_EMAIL_TRANSPORT;
|
|
41
|
+
if (!Object.hasOwn(EMAIL_TRANSPORTS, key)) {
|
|
42
|
+
throw new TypeError(`Unsupported email transport: ${value}`);
|
|
43
|
+
}
|
|
44
|
+
return key;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Well-known provider presets so users do not have to know IMAP/SMTP hosts. */
|
|
48
|
+
export const EMAIL_PROVIDERS = Object.freeze({
|
|
49
|
+
qq: Object.freeze({
|
|
50
|
+
key: 'qq', label: 'QQ 邮箱',
|
|
51
|
+
imapHost: 'imap.qq.com', imapPort: 993,
|
|
52
|
+
smtpHost: 'smtp.qq.com', smtpPort: 465,
|
|
53
|
+
}),
|
|
54
|
+
'163': Object.freeze({
|
|
55
|
+
key: '163', label: '163 邮箱',
|
|
56
|
+
imapHost: 'imap.163.com', imapPort: 993,
|
|
57
|
+
smtpHost: 'smtp.163.com', smtpPort: 465,
|
|
58
|
+
}),
|
|
59
|
+
gmail: Object.freeze({
|
|
60
|
+
key: 'gmail', label: 'Gmail',
|
|
61
|
+
imapHost: 'imap.gmail.com', imapPort: 993,
|
|
62
|
+
smtpHost: 'smtp.gmail.com', smtpPort: 465,
|
|
63
|
+
}),
|
|
64
|
+
custom: Object.freeze({ key: 'custom', label: '自定义' }),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Email access is allowlist-only: a mail address is trivially forgeable, so an
|
|
69
|
+
* open mailbox would let anyone drive the Harness. Unlike other channels the
|
|
70
|
+
* policy is therefore not optional — `allowedSenders` must be non-empty.
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeEmailAllowedSenders(value) {
|
|
73
|
+
if (value === undefined) return Object.freeze([]);
|
|
74
|
+
if (!Array.isArray(value)) throw new TypeError('allowedSenders must be an array of email addresses');
|
|
75
|
+
const normalized = value.map((entry) => {
|
|
76
|
+
const address = typeof entry === 'string' ? entry.trim().toLowerCase() : '';
|
|
77
|
+
if (!EMAIL_ADDRESS.test(address)) {
|
|
78
|
+
throw new TypeError('allowedSenders contains an invalid email address');
|
|
79
|
+
}
|
|
80
|
+
return address;
|
|
81
|
+
});
|
|
82
|
+
return Object.freeze([...new Set(normalized)]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function normalizeEmailAccessPolicy(value = {}) {
|
|
86
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
87
|
+
throw new TypeError('Email access policy must be an object');
|
|
88
|
+
}
|
|
89
|
+
return Object.freeze({ allowedSenders: normalizeEmailAllowedSenders(value.allowedSenders) });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizePort(value, fallback, label) {
|
|
93
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
94
|
+
const port = typeof value === 'number' ? value : Number.parseInt(String(value).trim(), 10);
|
|
95
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
|
|
96
|
+
throw new TypeError(`${label} must be a valid TCP port`);
|
|
97
|
+
}
|
|
98
|
+
return port;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeHost(value, label) {
|
|
102
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
103
|
+
const host = String(value).trim();
|
|
104
|
+
if (!HOSTNAME.test(host)) throw new TypeError(`${label} must be a valid hostname`);
|
|
105
|
+
return host;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Channel-specific fields persisted alongside the shared token identity.
|
|
110
|
+
* Only keys the caller actually supplied are written, so a later partial save
|
|
111
|
+
* spreads over the previous record (see `save`).
|
|
112
|
+
*/
|
|
113
|
+
function normalizeEmailBotExtension(value) {
|
|
114
|
+
const provider = typeof value.provider === 'string' ? value.provider.trim() : undefined;
|
|
115
|
+
const present = [
|
|
116
|
+
'transport', 'provider', 'imapHost', 'imapPort', 'smtpHost', 'smtpPort',
|
|
117
|
+
'allowedSenders',
|
|
118
|
+
];
|
|
119
|
+
if (!present.some((key) => Object.hasOwn(value, key))) return {};
|
|
120
|
+
const preset = EMAIL_PROVIDERS[provider] ?? null;
|
|
121
|
+
try {
|
|
122
|
+
const policy = normalizeEmailAccessPolicy({ allowedSenders: value.allowedSenders ?? [] });
|
|
123
|
+
const transport = Object.hasOwn(value, 'transport')
|
|
124
|
+
? normalizeEmailTransport(value.transport)
|
|
125
|
+
: undefined;
|
|
126
|
+
return {
|
|
127
|
+
...(transport ? { transport } : {}),
|
|
128
|
+
...(provider ? { provider } : {}),
|
|
129
|
+
imapHost: normalizeHost(value.imapHost, 'imapHost') ?? preset?.imapHost,
|
|
130
|
+
imapPort: normalizePort(value.imapPort, preset?.imapPort ?? 993, 'imapPort'),
|
|
131
|
+
smtpHost: normalizeHost(value.smtpHost, 'smtpHost') ?? preset?.smtpHost,
|
|
132
|
+
smtpPort: normalizePort(value.smtpPort, preset?.smtpPort ?? 465, 'smtpPort'),
|
|
133
|
+
allowedSenders: policy.allowedSenders,
|
|
134
|
+
};
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function deriveEmailBotIdentity(address) {
|
|
141
|
+
return deriveTokenBotIdentity(address, IDENTITY_OPTIONS);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function maskEmailBotId(address) {
|
|
145
|
+
const value = String(address ?? '');
|
|
146
|
+
const at = value.indexOf('@');
|
|
147
|
+
if (at <= 0) return maskPlatformId(value, t('邮箱'));
|
|
148
|
+
const name = value.slice(0, at);
|
|
149
|
+
const domain = value.slice(at);
|
|
150
|
+
const head = name.slice(0, Math.min(2, name.length));
|
|
151
|
+
return `${head}${'*'.repeat(Math.max(1, name.length - head.length))}${domain}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The mailbox address is the platform identity; the password lives in the
|
|
156
|
+
* credential store behind `tokenRef`.
|
|
157
|
+
*/
|
|
158
|
+
export function normalizeEmailAddress(value) {
|
|
159
|
+
const address = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
160
|
+
if (!EMAIL_ADDRESS.test(address)) throw new TypeError(t('邮箱地址格式不正确'));
|
|
161
|
+
return address;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const EMAIL_CLIENT_DEFAULTS = Object.freeze({
|
|
165
|
+
mailbox: 'INBOX',
|
|
166
|
+
pollIntervalMs: 20_000,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
export class EmailConfigStore extends TokenBotConfigStore {
|
|
171
|
+
constructor(path) {
|
|
172
|
+
super(path, {
|
|
173
|
+
channel: 'Email',
|
|
174
|
+
...IDENTITY_OPTIONS,
|
|
175
|
+
normalizeBotExtension: normalizeEmailBotExtension,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async save(value) {
|
|
180
|
+
// Merge over the stored record so a settings dialog can patch one field
|
|
181
|
+
// without resending hosts and the allowlist. The bot id and credential ref
|
|
182
|
+
// are derived from the address here, so callers only supply the mailbox.
|
|
183
|
+
const previous = value?.platformId ? this.getByPlatformId(String(value.platformId)) : null;
|
|
184
|
+
const merged = { ...previous, ...value };
|
|
185
|
+
const platformId = typeof merged.platformId === 'string' ? merged.platformId.trim().toLowerCase() : '';
|
|
186
|
+
if (platformId) {
|
|
187
|
+
const identity = deriveEmailBotIdentity(platformId);
|
|
188
|
+
merged.botId = identity.botId;
|
|
189
|
+
merged.tokenRef = identity.tokenRef;
|
|
190
|
+
merged.name = merged.name || platformId;
|
|
191
|
+
}
|
|
192
|
+
return super.save(merged);
|
|
193
|
+
}
|
|
194
|
+
}
|