@xmanrui/dsh-im 4.21.2 → 4.23.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 +10 -5
- package/README.md +10 -5
- package/docs/client-integration.md +81 -0
- package/lib/client.js +2565 -1134
- package/lib/index.js +292 -301
- package/package.json +9 -1
- 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 +16 -3
- 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 +5 -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/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 +62 -0
- package/plugin-src/client/index.js +122 -28
- package/plugin-src/client/last-message-error.js +2 -1
- package/plugin-src/client/panel-error-boundary.js +21 -0
- package/plugin-src/client/styles.js +6 -0
- package/plugin-src/host/build.mjs +4 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +5 -1
- 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 +6 -1
- 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 +5 -1
- package/plugin-src/host/channels/qq/rpc.mjs +5 -3
- package/plugin-src/host/channels/shared/production.mjs +55 -6
- package/plugin-src/host/channels/shared/rpc.mjs +5 -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/slack/production.mjs +5 -1
- package/plugin-src/host/channels/slack/rpc.mjs +5 -3
- package/plugin-src/host/channels/wecom/production.mjs +5 -1
- package/plugin-src/host/channels/wecom/rpc.mjs +5 -3
- package/plugin-src/host/channels/wecom-app/production.mjs +5 -1
- package/plugin-src/host/channels/wecom-app/rpc.mjs +5 -3
- package/plugin-src/host/channels/weixin/production.mjs +5 -1
- package/plugin-src/host/channels/whatsapp/production.mjs +5 -1
- 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/management-rpc.mjs +12 -2
- package/scripts/verify-package.mjs +3 -1
- 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 +692 -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 +593 -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 +8 -7
- package/src/channels/feishu/feishu-runtime.mjs +15 -8
- package/src/channels/feishu/group-message-permission-manager.mjs +2 -1
- 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/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 +37 -21
- 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/default-workspace.mjs +28 -0
- package/src/channels/shared/diagnostic-details.mjs +95 -0
- package/src/channels/shared/harness-client.mjs +111 -20
- package/src/channels/shared/harness-session-binding.mjs +13 -8
- 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 +90 -11
- 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/telegram-api.mjs +2 -2
- package/src/channels/telegram/telegram-runtime.mjs +17 -13
- 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
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
import { extractConnectionEvidence, atConnectionStage, createConnectionDiagnostics } from '../shared/connection-error.mjs';
|
|
2
|
+
import { t } from '../shared/i18n.mjs';
|
|
3
|
+
import {
|
|
4
|
+
createAccessPolicy,
|
|
5
|
+
createAccessPolicyScope,
|
|
6
|
+
} from '../shared/access-policy.mjs';
|
|
7
|
+
import { assertTransport } from './transport.mjs';
|
|
8
|
+
import { ImapSmtpTransport } from './transports/imap-smtp.mjs';
|
|
9
|
+
import {
|
|
10
|
+
AgentMailTransport,
|
|
11
|
+
agentMailAuthorizationStatus,
|
|
12
|
+
fetchAgentMailIdentity,
|
|
13
|
+
startAgentMailAuthorization,
|
|
14
|
+
} from './transports/agent-mail.mjs';
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_EMAIL_TRANSPORT,
|
|
17
|
+
EMAIL_TRANSPORTS,
|
|
18
|
+
normalizeEmailTransport,
|
|
19
|
+
} from './config-store.mjs';
|
|
20
|
+
import {
|
|
21
|
+
EMAIL_PROVIDERS,
|
|
22
|
+
EmailConfigStore,
|
|
23
|
+
deriveEmailBotIdentity,
|
|
24
|
+
maskEmailBotId,
|
|
25
|
+
normalizeEmailAddress,
|
|
26
|
+
normalizeEmailAccessPolicy,
|
|
27
|
+
} from './config-store.mjs';
|
|
28
|
+
import { EmailStateStore } from './state-store.mjs';
|
|
29
|
+
import { EMAIL_DESCRIPTOR } from './email-bridge.mjs';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Fallback validity window for a QR authorization, used only when the server
|
|
33
|
+
* does not state one. Normally the server's `expires_in` governs.
|
|
34
|
+
*/
|
|
35
|
+
const AGENT_MAIL_AUTH_TTL_MS = 600_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Credential payload for one mailbox.
|
|
39
|
+
*
|
|
40
|
+
* The whole payload lives under one credential ref as JSON — the channel's
|
|
41
|
+
* established shape, unlike the single-secret channels — because a mailbox
|
|
42
|
+
* needs its address alongside its secret.
|
|
43
|
+
*
|
|
44
|
+
* A standard mailbox carries an app password; the Agent mailbox carries an
|
|
45
|
+
* OAuth pair instead, since it has no password at all.
|
|
46
|
+
*/
|
|
47
|
+
function normalizeCredential(value) {
|
|
48
|
+
if (!value || typeof value !== 'object') return null;
|
|
49
|
+
const address = typeof value.address === 'string' ? value.address.trim().toLowerCase() : '';
|
|
50
|
+
if (!address) return null;
|
|
51
|
+
const password = typeof value.password === 'string' ? value.password : '';
|
|
52
|
+
const accessToken = typeof value.accessToken === 'string' ? value.accessToken : '';
|
|
53
|
+
const refreshToken = typeof value.refreshToken === 'string' ? value.refreshToken : '';
|
|
54
|
+
if (!password && !accessToken) return null;
|
|
55
|
+
return {
|
|
56
|
+
address,
|
|
57
|
+
...(password ? { password } : {}),
|
|
58
|
+
...(accessToken ? { accessToken } : {}),
|
|
59
|
+
...(refreshToken ? { refreshToken } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class EmailController {
|
|
64
|
+
#credentials;
|
|
65
|
+
#configStore;
|
|
66
|
+
#createRuntime;
|
|
67
|
+
#transports;
|
|
68
|
+
#pendingAuth = null;
|
|
69
|
+
#syncAccessPolicy;
|
|
70
|
+
#ensureWorkspace;
|
|
71
|
+
#stateFor;
|
|
72
|
+
#listWorkspaceSessions;
|
|
73
|
+
#botWorkspaceFor;
|
|
74
|
+
#defaultWorkspace;
|
|
75
|
+
#deleteState;
|
|
76
|
+
#logger;
|
|
77
|
+
#diagnostics;
|
|
78
|
+
#runtimes = new Map();
|
|
79
|
+
#errors = new Map();
|
|
80
|
+
#revision = 0;
|
|
81
|
+
#closed = false;
|
|
82
|
+
#transitions = new Map();
|
|
83
|
+
|
|
84
|
+
constructor({
|
|
85
|
+
credentials,
|
|
86
|
+
configStore,
|
|
87
|
+
createRuntime,
|
|
88
|
+
deleteState = async () => {},
|
|
89
|
+
logger = console,
|
|
90
|
+
// Transport implementations by key. A new mail protocol is a new entry
|
|
91
|
+
// here, not another channel.
|
|
92
|
+
transports = {
|
|
93
|
+
'imap-smtp': (options) => new ImapSmtpTransport(options),
|
|
94
|
+
'agent-mail': (options) => new AgentMailTransport(options),
|
|
95
|
+
},
|
|
96
|
+
// Host-owned hook: the mailbox allowlist and the Harness access policy are
|
|
97
|
+
// two separate stores, so a changed allowlist must be pushed into the
|
|
98
|
+
// policy or the channel keeps rejecting the new senders.
|
|
99
|
+
syncAccessPolicy = null,
|
|
100
|
+
// Host-owned hook: create the bot's workspace record if it does not exist
|
|
101
|
+
// yet. The access policy lives *inside* that record, so a freshly bound
|
|
102
|
+
// mailbox has nowhere to push its allowlist until this has run.
|
|
103
|
+
ensureWorkspace = null,
|
|
104
|
+
// Optional: supplied when the channel supports pinning a chat to an
|
|
105
|
+
// existing session (the settings page session picker).
|
|
106
|
+
stateFor = null,
|
|
107
|
+
listWorkspaceSessions = null,
|
|
108
|
+
botWorkspaceFor = null,
|
|
109
|
+
defaultWorkspace = null,
|
|
110
|
+
}) {
|
|
111
|
+
if (!credentials || typeof credentials.resolve !== 'function'
|
|
112
|
+
|| typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') {
|
|
113
|
+
throw new TypeError('EmailController requires the DSH credential provider');
|
|
114
|
+
}
|
|
115
|
+
if (!configStore || typeof configStore.list !== 'function'
|
|
116
|
+
|| typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
|
|
117
|
+
throw new TypeError('EmailController requires a config store');
|
|
118
|
+
}
|
|
119
|
+
if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required');
|
|
120
|
+
this.#credentials = credentials;
|
|
121
|
+
this.#configStore = configStore;
|
|
122
|
+
this.#createRuntime = createRuntime;
|
|
123
|
+
if (!transports || typeof transports !== 'object') {
|
|
124
|
+
throw new TypeError('EmailController requires a transport registry');
|
|
125
|
+
}
|
|
126
|
+
this.#transports = transports;
|
|
127
|
+
this.#syncAccessPolicy = typeof syncAccessPolicy === 'function' ? syncAccessPolicy : null;
|
|
128
|
+
this.#ensureWorkspace = typeof ensureWorkspace === 'function' ? ensureWorkspace : null;
|
|
129
|
+
this.#stateFor = typeof stateFor === 'function' ? stateFor : null;
|
|
130
|
+
this.#listWorkspaceSessions = typeof listWorkspaceSessions === 'function'
|
|
131
|
+
? listWorkspaceSessions : null;
|
|
132
|
+
this.#botWorkspaceFor = typeof botWorkspaceFor === 'function' ? botWorkspaceFor : null;
|
|
133
|
+
this.#defaultWorkspace = typeof defaultWorkspace === 'string' ? defaultWorkspace : null;
|
|
134
|
+
this.#deleteState = deleteState;
|
|
135
|
+
this.#logger = logger;
|
|
136
|
+
this.#diagnostics = createConnectionDiagnostics({ channel: 'email', logger });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async initialize() {
|
|
140
|
+
if (this.#closed) return this.status();
|
|
141
|
+
for (const config of this.#configStore.list()) {
|
|
142
|
+
await this.#withBotTransition(config.botId, async () => {
|
|
143
|
+
// initialize() runs on every supervisor health check. Without this
|
|
144
|
+
// guard each check tore down the running runtime and started a new
|
|
145
|
+
// one, so its poll loop never completed a single pass and no mail was
|
|
146
|
+
// ever read.
|
|
147
|
+
if (this.#runtimes.get(config.botId)?.status?.ready) return;
|
|
148
|
+
try {
|
|
149
|
+
const secrets = await this.#resolveSecrets(config);
|
|
150
|
+
if (!secrets) return;
|
|
151
|
+
await this.#startRuntime(config, secrets);
|
|
152
|
+
this.#errors.delete(config.botId);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
this.#errors.set(config.botId, this.#safeError('connection-failed', error));
|
|
155
|
+
this.#logger.warn?.(`[dsh-im:email] bot ${config.botId} failed to start:`, extractConnectionEvidence(error).details);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return this.status();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Connect one mailbox. The password is verified against IMAP/SMTP before it
|
|
163
|
+
* is persisted, so a bad app password fails here rather than silently
|
|
164
|
+
* producing a bot that never receives mail. */
|
|
165
|
+
async bindMailbox({
|
|
166
|
+
address, password, accessToken, refreshToken, provider, transport,
|
|
167
|
+
imapHost, imapPort, smtpHost, smtpPort, allowedSenders,
|
|
168
|
+
} = {}) {
|
|
169
|
+
if (this.#closed) throw new Error(`${EMAIL_DESCRIPTOR.label} controller is closed`);
|
|
170
|
+
const normalizedAddress = normalizeEmailAddress(address);
|
|
171
|
+
const transportKey = normalizeEmailTransport(transport);
|
|
172
|
+
const definition = EMAIL_TRANSPORTS[transportKey];
|
|
173
|
+
// Only the IMAP/SMTP transport needs a password; others authorize another
|
|
174
|
+
// way (the Agent mailbox does so by QR code).
|
|
175
|
+
const needsPassword = definition.fields.includes('password');
|
|
176
|
+
// A standard mailbox carries an app password. The Agent mailbox carries
|
|
177
|
+
// nothing: agently-cli keeps its own credentials in the system keychain, so
|
|
178
|
+
// only the address is stored here (to name and bind the mailbox).
|
|
179
|
+
const credential = needsPassword
|
|
180
|
+
? normalizeCredential({ address: normalizedAddress, password })
|
|
181
|
+
: Object.freeze({ address: normalizedAddress });
|
|
182
|
+
if (needsPassword && !credential) {
|
|
183
|
+
throw new TypeError(t('邮箱地址与应用密码均为必填'));
|
|
184
|
+
}
|
|
185
|
+
// Server hosts only apply to transports that speak a mail protocol.
|
|
186
|
+
const needsHosts = definition.fields.includes('hosts');
|
|
187
|
+
const preset = needsHosts ? EMAIL_PROVIDERS[provider] ?? null : null;
|
|
188
|
+
const security = {
|
|
189
|
+
transport: transportKey,
|
|
190
|
+
...(needsHosts ? { provider: preset?.key ?? 'custom' } : {}),
|
|
191
|
+
...(needsHosts ? {
|
|
192
|
+
imapHost: imapHost || preset?.imapHost,
|
|
193
|
+
imapPort: imapPort ?? preset?.imapPort ?? 993,
|
|
194
|
+
smtpHost: smtpHost || preset?.smtpHost,
|
|
195
|
+
smtpPort: smtpPort ?? preset?.smtpPort ?? 465,
|
|
196
|
+
} : {}),
|
|
197
|
+
allowedSenders: normalizeEmailAccessPolicy({ allowedSenders }).allowedSenders,
|
|
198
|
+
};
|
|
199
|
+
if (needsHosts && (!security.imapHost || !security.smtpHost)) {
|
|
200
|
+
throw new TypeError(t('请选择邮箱服务商或填写 IMAP/SMTP 服务器地址'));
|
|
201
|
+
}
|
|
202
|
+
// An empty allowlist is allowed so a mailbox can be connected first and
|
|
203
|
+
// authorized later. It stays fail-closed: the access policy derived from an
|
|
204
|
+
// empty list admits nobody, so the mailbox can be read but not driven.
|
|
205
|
+
const identity = deriveEmailBotIdentity(normalizedAddress);
|
|
206
|
+
await this.#withBotTransition(identity.botId, async () => {
|
|
207
|
+
const previousConfig = this.#configStore.getByPlatformId(normalizedAddress);
|
|
208
|
+
// resolve() returns a wrapper; the plain secret lives on .value.
|
|
209
|
+
const previousResult = await atConnectionStage('credential.read', () => this.#credentials.resolve(identity.tokenRef), 'credential-store');
|
|
210
|
+
const previousCredential = previousResult?.value;
|
|
211
|
+
const probe = this.#createTransport({
|
|
212
|
+
address: normalizedAddress,
|
|
213
|
+
...(credential.password ? { password: credential.password } : {}),
|
|
214
|
+
...(credential.accessToken ? { accessToken: credential.accessToken } : {}),
|
|
215
|
+
...(credential.refreshToken ? { refreshToken: credential.refreshToken } : {}),
|
|
216
|
+
...security,
|
|
217
|
+
});
|
|
218
|
+
try {
|
|
219
|
+
await probe.connect();
|
|
220
|
+
await probe.disconnect();
|
|
221
|
+
} catch (error) {
|
|
222
|
+
throw this.#diagnostics.report(error, { reuse: true, stage: 'credential.verify', operation: 'bot.bind-mailbox',
|
|
223
|
+
publicError: { code: 'email-bind-failed', message: t('邮箱连接失败,请检查地址、应用密码与服务器设置') } });
|
|
224
|
+
}
|
|
225
|
+
const config = {
|
|
226
|
+
botId: identity.botId,
|
|
227
|
+
platformId: normalizedAddress,
|
|
228
|
+
tokenRef: identity.tokenRef,
|
|
229
|
+
name: normalizedAddress,
|
|
230
|
+
username: normalizedAddress,
|
|
231
|
+
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
|
232
|
+
connectedAt: new Date().toISOString(),
|
|
233
|
+
...security,
|
|
234
|
+
};
|
|
235
|
+
await atConnectionStage('credential.save', () => this.#credentials.set(identity.tokenRef, JSON.stringify(credential)), 'credential-store');
|
|
236
|
+
try {
|
|
237
|
+
await atConnectionStage('account.save', () => this.#configStore.save(config), 'account-config');
|
|
238
|
+
} catch (error) {
|
|
239
|
+
await this.#restoreCredential(identity.tokenRef, previousCredential);
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
// From here the mailbox is on disk, so every later failure must either
|
|
243
|
+
// undo that write or leave state a retry can recover from. A mailbox saved
|
|
244
|
+
// but neither reachable nor authorized is exactly the broken state this
|
|
245
|
+
// guards against.
|
|
246
|
+
try {
|
|
247
|
+
// The access policy lives *inside* the bot's workspace record, so that
|
|
248
|
+
// record has to exist before the allowlist can be pushed into it. The
|
|
249
|
+
// runtime used to be the only thing that created it — and the runtime
|
|
250
|
+
// starts after this — so on a brand-new mailbox the push failed with
|
|
251
|
+
// "workspace-bot-not-found", leaving the config on disk with no runtime
|
|
252
|
+
// and no policy.
|
|
253
|
+
await this.#ensureBotWorkspace(identity.botId, config);
|
|
254
|
+
// The allowlist has to reach the Harness now, not only when the settings
|
|
255
|
+
// are edited later: without it a freshly bound mailbox admits nobody, so
|
|
256
|
+
// its first mail is refused for the wrong reason.
|
|
257
|
+
await this.#applyAllowlistToPolicy(identity.botId, config);
|
|
258
|
+
await this.#stopRuntime(identity.botId);
|
|
259
|
+
try {
|
|
260
|
+
await this.#startRuntime(config, credential);
|
|
261
|
+
this.#errors.delete(identity.botId);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
this.#errors.set(identity.botId, this.#safeError('connection-failed', error));
|
|
264
|
+
}
|
|
265
|
+
} catch (error) {
|
|
266
|
+
// Roll the mailbox back instead of persisting a half-bound one, so the
|
|
267
|
+
// user sees a clean failure and a plain retry works.
|
|
268
|
+
await this.#rollbackBind(config, previousConfig, previousCredential);
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
this.#touch();
|
|
272
|
+
});
|
|
273
|
+
return this.status();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Patch a subset of settings (hosts, ports, allowlist) without reconnecting. */
|
|
277
|
+
async updateMailboxSettings(botId, update = {}) {
|
|
278
|
+
if (this.#closed) throw new Error(`${EMAIL_DESCRIPTOR.label} controller is closed`);
|
|
279
|
+
return this.#withBotTransition(botId, async () => {
|
|
280
|
+
const config = this.#requireConfig(botId);
|
|
281
|
+
const next = { ...config };
|
|
282
|
+
// A mailbox can move between protocols, so the transport is patchable
|
|
283
|
+
// like any other setting rather than fixed at bind time.
|
|
284
|
+
if (update.transport !== undefined) {
|
|
285
|
+
next.transport = normalizeEmailTransport(update.transport);
|
|
286
|
+
}
|
|
287
|
+
if (update.provider !== undefined) next.provider = update.provider;
|
|
288
|
+
if (update.imapHost !== undefined) next.imapHost = update.imapHost;
|
|
289
|
+
if (update.imapPort !== undefined) next.imapPort = update.imapPort;
|
|
290
|
+
if (update.smtpHost !== undefined) next.smtpHost = update.smtpHost;
|
|
291
|
+
if (update.smtpPort !== undefined) next.smtpPort = update.smtpPort;
|
|
292
|
+
if (update.allowedSenders !== undefined) {
|
|
293
|
+
next.allowedSenders = normalizeEmailAccessPolicy({ allowedSenders: update.allowedSenders }).allowedSenders;
|
|
294
|
+
}
|
|
295
|
+
const allowlistChanged = update.allowedSenders !== undefined;
|
|
296
|
+
const saved = await atConnectionStage('account.save', () => this.#configStore.save(next), 'account-config');
|
|
297
|
+
if (allowlistChanged) await this.#applyAllowlistToPolicy(botId, saved);
|
|
298
|
+
// Host, allowlist and approval changes take effect immediately.
|
|
299
|
+
await this.#stopRuntime(botId);
|
|
300
|
+
const secrets = await this.#resolveSecrets(saved);
|
|
301
|
+
if (secrets) {
|
|
302
|
+
try {
|
|
303
|
+
await this.#startRuntime(saved, secrets);
|
|
304
|
+
this.#errors.delete(botId);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
this.#errors.set(botId, this.#safeError('connection-failed', error));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
this.#touch();
|
|
310
|
+
return this.status();
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async reconnectBot(botId) {
|
|
315
|
+
if (this.#closed) throw new Error(`${EMAIL_DESCRIPTOR.label} controller is closed`);
|
|
316
|
+
return this.#withBotTransition(botId, async () => {
|
|
317
|
+
const config = this.#requireConfig(botId);
|
|
318
|
+
await this.#stopRuntime(botId);
|
|
319
|
+
const secrets = await this.#resolveSecrets(config);
|
|
320
|
+
if (!secrets) throw new Error(t('邮箱凭据已丢失,请重新绑定'));
|
|
321
|
+
await this.#startRuntime(config, secrets);
|
|
322
|
+
this.#errors.delete(botId);
|
|
323
|
+
this.#touch();
|
|
324
|
+
return this.status();
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async sendConnectionTest(botId) {
|
|
329
|
+
const runtime = this.#runtimes.get(botId);
|
|
330
|
+
if (!runtime) throw new Error(t('邮箱尚未连接'));
|
|
331
|
+
await runtime.sendConnectionTest(t('邮箱通道连接正常。'));
|
|
332
|
+
return { sent: true };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async sendProactiveText(botId, target, text, options = {}) {
|
|
336
|
+
const runtime = this.#runtimes.get(botId);
|
|
337
|
+
if (!runtime) {
|
|
338
|
+
const error = new Error(t('邮箱尚未连接'));
|
|
339
|
+
error.code = 'bot-not-connected';
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
return runtime.sendProactiveText(target, text, options);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async deleteBot(botId) {
|
|
346
|
+
const warnings = [];
|
|
347
|
+
return this.#withBotTransition(botId, async () => {
|
|
348
|
+
const config = this.#configStore.get(botId);
|
|
349
|
+
await this.#stopRuntime(botId);
|
|
350
|
+
let removed;
|
|
351
|
+
try { removed = await atConnectionStage('account.remove', () => this.#configStore.remove(botId), 'account-config'); }
|
|
352
|
+
catch (error) {
|
|
353
|
+
if (this.#configStore.get(botId)) throw error;
|
|
354
|
+
removed = config;
|
|
355
|
+
warnings.push(this.#diagnostics.report(error, { operation: 'bot.delete', stage: 'workspace.cleanup', warning: true,
|
|
356
|
+
publicError: { code: 'workspace-cleanup-failed', message: '账号已移除,但本地状态清理失败。' } }).publicError);
|
|
357
|
+
}
|
|
358
|
+
if (removed?.tokenRef) await atConnectionStage('credential.remove', () => this.#credentials.unset(removed.tokenRef), 'credential-store').catch(error => { warnings.push(this.#diagnostics.report(error, { reuse: true, operation: 'bot.delete', stage: 'credential.remove', warning: true, publicError: { code: 'cleanup-failed', message: '账号已移除,但登录凭据清理失败。' } }).publicError); });
|
|
359
|
+
await this.#deleteState(botId).catch(error => { warnings.push(this.#diagnostics.report(error, { reuse: true, operation: 'bot.delete', stage: 'state.cleanup', resource: 'account-state', warning: true, publicError: { code: 'cleanup-failed', message: '账号已移除,但本地状态清理失败。' } }).publicError); });
|
|
360
|
+
this.#errors.delete(botId);
|
|
361
|
+
this.#touch();
|
|
362
|
+
return { ...this.status(), ...(warnings.length ? { warnings } : {}) };
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
status() {
|
|
367
|
+
const bots = this.#configStore.list().map((config) => {
|
|
368
|
+
const runtime = this.#runtimes.get(config.botId);
|
|
369
|
+
const runtimeStatus = runtime?.status ?? null;
|
|
370
|
+
const error = runtimeStatus?.error ?? this.#errors.get(config.botId);
|
|
371
|
+
// The connection supervisor and the settings client both read these
|
|
372
|
+
// fields, so a mailbox reports connectivity the same way every other
|
|
373
|
+
// token channel does.
|
|
374
|
+
const connected = runtimeStatus?.ready === true
|
|
375
|
+
&& runtimeStatus.connectionState === 'connected';
|
|
376
|
+
const state = connected ? 'connected'
|
|
377
|
+
: error ? 'error'
|
|
378
|
+
: runtimeStatus?.connectionState === 'connecting' ? 'connecting' : 'disconnected';
|
|
379
|
+
return {
|
|
380
|
+
botId: config.botId,
|
|
381
|
+
// The shared client reads the identity from `bot`, so the name must live
|
|
382
|
+
// there — a top-level name is ignored and the UI falls back to
|
|
383
|
+
// "<channel>机器人". The address doubles as the display name, which
|
|
384
|
+
// beats a generic label.
|
|
385
|
+
bot: {
|
|
386
|
+
name: config.name || config.platformId,
|
|
387
|
+
username: config.username || config.platformId,
|
|
388
|
+
idMasked: maskEmailBotId(config.platformId),
|
|
389
|
+
},
|
|
390
|
+
// The raw address is already semi-public, but the UI shows the masked
|
|
391
|
+
// form for consistency with other channels.
|
|
392
|
+
platformId: maskEmailBotId(config.platformId),
|
|
393
|
+
// Which protocol this mailbox speaks. The settings page needs it to
|
|
394
|
+
// show the right form; without it an Agent mailbox was described with
|
|
395
|
+
// IMAP/SMTP fields it does not use.
|
|
396
|
+
transport: config.transport ?? DEFAULT_EMAIL_TRANSPORT,
|
|
397
|
+
provider: config.provider,
|
|
398
|
+
imapHost: config.imapHost,
|
|
399
|
+
imapPort: config.imapPort,
|
|
400
|
+
smtpHost: config.smtpHost,
|
|
401
|
+
smtpPort: config.smtpPort,
|
|
402
|
+
allowedSenders: config.allowedSenders ?? [],
|
|
403
|
+
createdAt: config.createdAt,
|
|
404
|
+
connectedAt: config.connectedAt,
|
|
405
|
+
connected,
|
|
406
|
+
configured: true,
|
|
407
|
+
state,
|
|
408
|
+
health: {
|
|
409
|
+
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
|
|
410
|
+
summary: connected
|
|
411
|
+
? t('邮箱通道运行正常')
|
|
412
|
+
: error?.message ?? t('邮箱通道尚未连接'),
|
|
413
|
+
},
|
|
414
|
+
...(error ? { error } : {}),
|
|
415
|
+
runtime: runtimeStatus,
|
|
416
|
+
};
|
|
417
|
+
});
|
|
418
|
+
const connectedCount = bots.filter((bot) => bot.connected).length;
|
|
419
|
+
return {
|
|
420
|
+
revision: this.#revision,
|
|
421
|
+
bots,
|
|
422
|
+
totals: { configured: bots.length, connected: connectedCount },
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Current session bindings for one mailbox, plus the senders known to this
|
|
428
|
+
* mailbox so the settings page can offer a per-sender override.
|
|
429
|
+
*/
|
|
430
|
+
async getSessionBinding(botId) {
|
|
431
|
+
const config = this.#requireConfig(botId);
|
|
432
|
+
const state = await this.#stateFor?.(botId);
|
|
433
|
+
const bindings = state?.emailBindings?.() ?? { account: null, senders: {} };
|
|
434
|
+
const senders = Array.isArray(config.allowedSenders) ? config.allowedSenders : [];
|
|
435
|
+
return {
|
|
436
|
+
botId,
|
|
437
|
+
account: bindings.account ?? null,
|
|
438
|
+
senders: bindings.senders ?? {},
|
|
439
|
+
// The picker offers these as the per-sender rows.
|
|
440
|
+
knownSenders: senders,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Replace the bindings. An empty value clears them, which restores the
|
|
446
|
+
* default behaviour of one Harness session per mail thread.
|
|
447
|
+
*/
|
|
448
|
+
async setSessionBinding(botId, value = {}) {
|
|
449
|
+
this.#requireConfig(botId);
|
|
450
|
+
if (!this.#stateFor) throw new Error(t('当前环境不支持会话绑定'));
|
|
451
|
+
const state = await this.#stateFor(botId);
|
|
452
|
+
if (typeof state?.setEmailBindings !== 'function') {
|
|
453
|
+
throw new Error(t('当前环境不支持会话绑定'));
|
|
454
|
+
}
|
|
455
|
+
const account = typeof value.account === 'string' && value.account.trim()
|
|
456
|
+
? value.account.trim() : null;
|
|
457
|
+
const senders = {};
|
|
458
|
+
if (value.senders && typeof value.senders === 'object' && !Array.isArray(value.senders)) {
|
|
459
|
+
for (const [address, sessionId] of Object.entries(value.senders)) {
|
|
460
|
+
const key = typeof address === 'string' ? address.trim().toLowerCase() : '';
|
|
461
|
+
const session = typeof sessionId === 'string' ? sessionId.trim() : '';
|
|
462
|
+
if (key && session) senders[key] = session;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
await state.setEmailBindings({ account, senders });
|
|
466
|
+
this.#touch();
|
|
467
|
+
return this.getSessionBinding(botId);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The bot's workspace, tolerating both a synchronous accessor and an async
|
|
472
|
+
* one, and treating a failure as "unknown" rather than failing the listing.
|
|
473
|
+
*/
|
|
474
|
+
async #resolveBotWorkspace(botId) {
|
|
475
|
+
if (!this.#botWorkspaceFor) return null;
|
|
476
|
+
try {
|
|
477
|
+
return (await this.#botWorkspaceFor(botId)) ?? null;
|
|
478
|
+
} catch {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Sessions available to bind to, for the settings picker. */
|
|
484
|
+
async listSessions(botId) {
|
|
485
|
+
const config = this.#requireConfig(botId);
|
|
486
|
+
// The bot's own workspace is authoritative; defaultWorkspace is only a
|
|
487
|
+
// fallback for hosts that do not expose a per-bot workspace.
|
|
488
|
+
const workspace = (await this.#resolveBotWorkspace(botId)) ?? this.#defaultWorkspace;
|
|
489
|
+
if (!workspace || !this.#listWorkspaceSessions) {
|
|
490
|
+
return { botId, workspace: workspace ?? null, sessions: [] };
|
|
491
|
+
}
|
|
492
|
+
const listed = await this.#listWorkspaceSessions(workspace);
|
|
493
|
+
const sessions = Array.isArray(listed?.sessions) ? listed.sessions : [];
|
|
494
|
+
return {
|
|
495
|
+
botId,
|
|
496
|
+
workspace: listed?.workspace ?? workspace,
|
|
497
|
+
sessions: sessions.map((session) => ({
|
|
498
|
+
sessionId: session.sessionId ?? session.id ?? '',
|
|
499
|
+
title: String(session.title ?? '').slice(0, 120),
|
|
500
|
+
updatedAt: session.updatedAt ?? null,
|
|
501
|
+
})).filter((session) => session.sessionId),
|
|
502
|
+
config,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* The shared token-bot RPC handler addresses mailbox binding through the
|
|
508
|
+
* generic `bindCredentials` endpoint, so the mailbox-specific method is
|
|
509
|
+
* exposed under that name as well.
|
|
510
|
+
*/
|
|
511
|
+
bindCredentials(payload) {
|
|
512
|
+
return this.bindMailbox(payload);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async close() {
|
|
516
|
+
if (this.#closed) return;
|
|
517
|
+
this.#closed = true;
|
|
518
|
+
for (const botId of [...this.#runtimes.keys()]) {
|
|
519
|
+
await this.#stopRuntime(botId).catch(() => {});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Build the Harness access policy for a mailbox: both scopes are allowlists
|
|
527
|
+
* seeded from the configured senders, so mail from anyone else is refused.
|
|
528
|
+
*/
|
|
529
|
+
#accessPolicyFor(config) {
|
|
530
|
+
const senders = Array.isArray(config?.allowedSenders) ? config.allowedSenders : [];
|
|
531
|
+
const scope = createAccessPolicyScope({
|
|
532
|
+
mode: 'allowlist',
|
|
533
|
+
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
|
534
|
+
allowlist: { users: senders.map((id) => ({ id, canExecuteCommands: true })) },
|
|
535
|
+
});
|
|
536
|
+
return createAccessPolicy({ direct: scope, group: scope });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Make sure the bot's workspace record exists before anything is written to
|
|
541
|
+
* it. The access policy is stored inside that record, so on a brand-new
|
|
542
|
+
* mailbox `#applyAllowlistToPolicy` has no bot to address yet and the store
|
|
543
|
+
* refuses it with "workspace-bot-not-found". Ordering the policy push after
|
|
544
|
+
* the runtime would fix that too, but it would also make the runtime — and
|
|
545
|
+
* the whole transport handshake behind it — a precondition for a purely
|
|
546
|
+
* local write, and would leave a mailbox briefly bound with no policy.
|
|
547
|
+
*
|
|
548
|
+
* Hosts without the hook (and the unit tests) skip this: their syncAccessPolicy
|
|
549
|
+
* has its own storage and never needed the record.
|
|
550
|
+
*/
|
|
551
|
+
async #ensureBotWorkspace(botId, config) {
|
|
552
|
+
if (!this.#ensureWorkspace) return;
|
|
553
|
+
try {
|
|
554
|
+
await this.#ensureWorkspace(botId, config);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
this.#logger.warn?.('[dsh-im:email] failed to prepare the bot workspace:', extractConnectionEvidence(error).details);
|
|
557
|
+
throw new Error(t('邮箱访问策略同步失败,请重试'));
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Undo a bind that failed after its config was written. Restoring the
|
|
563
|
+
* credential first, then the config, keeps the pair consistent: a config
|
|
564
|
+
* pointing at a token ref that no longer holds a secret is the one state the
|
|
565
|
+
* channel cannot recover from on its own.
|
|
566
|
+
*
|
|
567
|
+
* A brand-new mailbox is removed outright; a re-bind of an existing one is
|
|
568
|
+
* restored to the config it had, so the user's previous working mailbox
|
|
569
|
+
* survives a failed re-bind.
|
|
570
|
+
*/
|
|
571
|
+
async #rollbackBind(config, previousConfig, previousCredential) {
|
|
572
|
+
await this.#restoreCredential(config.tokenRef, previousCredential);
|
|
573
|
+
try {
|
|
574
|
+
if (previousConfig) await atConnectionStage('account.save', () => this.#configStore.save(previousConfig), 'account-config');
|
|
575
|
+
else await atConnectionStage('account.remove', () => this.#configStore.remove(config.botId), 'account-config');
|
|
576
|
+
} catch (error) {
|
|
577
|
+
// The bind failure is the error the user needs to see; a rollback that
|
|
578
|
+
// could not complete is reported alongside it rather than replacing it.
|
|
579
|
+
this.#logger.warn?.('[dsh-im:email] failed to roll back the mailbox config:', extractConnectionEvidence(error).details);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Push the mailbox allowlist into the Harness access policy. */
|
|
584
|
+
async #applyAllowlistToPolicy(botId, config) {
|
|
585
|
+
if (!this.#syncAccessPolicy) return;
|
|
586
|
+
try {
|
|
587
|
+
await this.#syncAccessPolicy(botId, this.#accessPolicyFor(config));
|
|
588
|
+
} catch (error) {
|
|
589
|
+
// The mailbox itself is already saved and connected; a policy push
|
|
590
|
+
// failure must not undo that, but it is surfaced for diagnosis.
|
|
591
|
+
this.#logger.warn?.('[dsh-im:email] failed to sync the access policy:', extractConnectionEvidence(error).details);
|
|
592
|
+
throw new Error(t('邮箱访问策略同步失败,请重试'));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Instantiate the transport a mailbox is configured to use. The registry is
|
|
598
|
+
* injected so tests can substitute one, and an unknown key falls back to the
|
|
599
|
+
* default rather than failing the whole channel.
|
|
600
|
+
*/
|
|
601
|
+
#createTransport(config) {
|
|
602
|
+
const key = normalizeEmailTransport(config?.transport ?? DEFAULT_EMAIL_TRANSPORT);
|
|
603
|
+
const factory = this.#transports[key] ?? this.#transports[DEFAULT_EMAIL_TRANSPORT];
|
|
604
|
+
if (typeof factory !== 'function') {
|
|
605
|
+
throw new TypeError(`No transport registered for ${key}`);
|
|
606
|
+
}
|
|
607
|
+
return assertTransport(factory({ config }), `email transport ${key}`);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
#requireConfig(botId) {
|
|
611
|
+
const config = this.#configStore.get(botId);
|
|
612
|
+
if (!config) throw new Error(t('未找到该邮箱配置'));
|
|
613
|
+
return config;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* The credential provider resolves a ref to a wrapper object whose payload
|
|
618
|
+
* lives on `.value` (the same contract the shared token controller uses), so
|
|
619
|
+
* the stored JSON is unwrapped from there rather than parsed directly.
|
|
620
|
+
*/
|
|
621
|
+
async #resolveSecrets(config) {
|
|
622
|
+
const result = await atConnectionStage('credential.read', () => this.#credentials.resolve(config.tokenRef), 'credential-store');
|
|
623
|
+
const stored = result?.value;
|
|
624
|
+
if (typeof stored === 'string' && stored) {
|
|
625
|
+
try {
|
|
626
|
+
// The mailbox secret is stored as JSON so one ref carries the address
|
|
627
|
+
// and the app password together.
|
|
628
|
+
const parsed = normalizeCredential(JSON.parse(stored));
|
|
629
|
+
if (parsed) return parsed;
|
|
630
|
+
} catch {
|
|
631
|
+
// Fall through to the transport-specific default below.
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// The Agent mailbox needs no secret of ours: agently-cli keeps its own
|
|
635
|
+
// credentials in the system keychain. Returning null here used to skip the
|
|
636
|
+
// mailbox entirely, so it silently never started.
|
|
637
|
+
if (normalizeEmailTransport(config.transport) === 'agent-mail') {
|
|
638
|
+
return Object.freeze({ address: config.platformId });
|
|
639
|
+
}
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async #startRuntime(config, credential) {
|
|
644
|
+
// Production owns state/workspace resolution; the controller only passes
|
|
645
|
+
// the identity and the mailbox secret through.
|
|
646
|
+
const runtime = await atConnectionStage('runtime.prepare', () => this.#createRuntime({
|
|
647
|
+
botId: config.botId,
|
|
648
|
+
config,
|
|
649
|
+
// `token` stays for the shared runtime shape; the full credential is what
|
|
650
|
+
// a transport actually needs, since a mailbox may carry tokens instead of
|
|
651
|
+
// a password.
|
|
652
|
+
token: credential.password ?? credential.accessToken,
|
|
653
|
+
credential,
|
|
654
|
+
// The runtime must build the transport the mailbox is configured for.
|
|
655
|
+
// Its own default is IMAP/SMTP, so without this an Agent mailbox was
|
|
656
|
+
// dialled as a mail server and failed with ECONNREFUSED on port 993.
|
|
657
|
+
createTransport: (options) => this.#createTransport(options.config ?? options),
|
|
658
|
+
}));
|
|
659
|
+
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
|
660
|
+
throw new TypeError('createRuntime returned an invalid Email runtime');
|
|
661
|
+
}
|
|
662
|
+
this.#runtimes.set(config.botId, runtime);
|
|
663
|
+
try {
|
|
664
|
+
await runtime.start();
|
|
665
|
+
} catch (error) {
|
|
666
|
+
this.#runtimes.delete(config.botId);
|
|
667
|
+
await runtime.stop().catch(() => {});
|
|
668
|
+
throw error;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async #stopRuntime(botId) {
|
|
673
|
+
const runtime = this.#runtimes.get(botId);
|
|
674
|
+
this.#runtimes.delete(botId);
|
|
675
|
+
await runtime?.stop().catch((error) => {
|
|
676
|
+
this.#logger.warn?.(`[dsh-im:email] bot ${botId} failed to stop cleanly:`, extractConnectionEvidence(error).details);
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Roll a credential ref back to its previous plain value, or clear it. */
|
|
681
|
+
/**
|
|
682
|
+
* Begin an out-of-band authorization for a transport that needs one (the
|
|
683
|
+
* Agent mailbox authorizes by WeChat QR code rather than a password). The
|
|
684
|
+
* pending device code is held until the matching poll completes it.
|
|
685
|
+
*/
|
|
686
|
+
async startAuthorization({ transport, hostname, workspace } = {}) {
|
|
687
|
+
const key = normalizeEmailTransport(transport ?? DEFAULT_EMAIL_TRANSPORT);
|
|
688
|
+
if (key !== 'agent-mail') {
|
|
689
|
+
throw new TypeError(t('该接入方式不需要扫码授权'));
|
|
690
|
+
}
|
|
691
|
+
// The official CLI owns the Agent mailbox protocol, including the token
|
|
692
|
+
// refresh: a hand-written client was refused one (invalid_grant), so the
|
|
693
|
+
// mailbox died an hour after every authorization.
|
|
694
|
+
//
|
|
695
|
+
// The CLI isolates accounts per workspace, so a mailbox authorizes into its
|
|
696
|
+
// own. Sharing one workspace made every Agent mailbox read the first
|
|
697
|
+
// account that logged in.
|
|
698
|
+
const scope = String(workspace ?? '').trim();
|
|
699
|
+
const device = await startAgentMailAuthorization({ workspace: scope });
|
|
700
|
+
const expiresAt = Date.now() + (device.expiresInMs ?? AGENT_MAIL_AUTH_TTL_MS);
|
|
701
|
+
const pending = { transport: key, workspace: scope, startedAt: Date.now(), expiresAt };
|
|
702
|
+
this.#pendingAuth = pending;
|
|
703
|
+
// Persisted too: the code outlives a reload, and losing it would strand an
|
|
704
|
+
// authorization the user already completed.
|
|
705
|
+
await this.#storePendingAuth(pending);
|
|
706
|
+
return {
|
|
707
|
+
transport: key,
|
|
708
|
+
// The authorization page embeds its own WeChat QR, so the URL is what the
|
|
709
|
+
// user opens or scans; there is no one-shot scan payload to render.
|
|
710
|
+
browserUrl: device.browserUrl,
|
|
711
|
+
inputCode: device.inputCode,
|
|
712
|
+
expiresAt,
|
|
713
|
+
expiresInMs: expiresAt - Date.now(),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Check a pending authorization once.
|
|
719
|
+
*
|
|
720
|
+
* The CLI stores the credentials itself, so nothing is returned to bind —
|
|
721
|
+
* only the address, which the panel shows and names the mailbox after.
|
|
722
|
+
*/
|
|
723
|
+
async pollAuthorization() {
|
|
724
|
+
// The in-memory copy is authoritative; the stored one survives a restart so
|
|
725
|
+
// a completed scan can still be redeemed.
|
|
726
|
+
const pending = this.#pendingAuth ?? await this.#loadPendingAuth();
|
|
727
|
+
if (!pending) throw new TypeError(t('扫码授权尚未开始'));
|
|
728
|
+
if (Date.now() > pending.expiresAt) {
|
|
729
|
+
this.#pendingAuth = null;
|
|
730
|
+
await this.#storePendingAuth(null);
|
|
731
|
+
throw new TypeError(t('扫码授权已超时,请重新发起'));
|
|
732
|
+
}
|
|
733
|
+
const status = await agentMailAuthorizationStatus({ workspace: pending.workspace });
|
|
734
|
+
if (!status.loggedIn) {
|
|
735
|
+
return { status: status.status || 'pending', authorized: false };
|
|
736
|
+
}
|
|
737
|
+
this.#pendingAuth = null;
|
|
738
|
+
await this.#storePendingAuth(null);
|
|
739
|
+
// The mailbox address is what the account is named and bound as, and the
|
|
740
|
+
// server already knows it — so it is read here rather than typed.
|
|
741
|
+
let identity = null;
|
|
742
|
+
try {
|
|
743
|
+
identity = await fetchAgentMailIdentity({ workspace: pending.workspace });
|
|
744
|
+
} catch (error) {
|
|
745
|
+
this.#logger.warn?.('[dsh-im:email] unable to resolve the mailbox identity:', extractConnectionEvidence(error).details);
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
status: 'authorized',
|
|
749
|
+
authorized: true,
|
|
750
|
+
...(identity ? { address: identity.address, name: identity.name } : {}),
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Find the mailbox state that owns a pending authorization. */
|
|
755
|
+
async #stateForPendingAuth() {
|
|
756
|
+
if (!this.#stateFor) return null;
|
|
757
|
+
const [first] = this.#configStore.list();
|
|
758
|
+
if (!first) return null;
|
|
759
|
+
return this.#stateFor(first.botId).catch(() => null);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async #storePendingAuth(pending) {
|
|
763
|
+
const state = await this.#stateForPendingAuth();
|
|
764
|
+
await state?.setPendingAuth?.(pending).catch(() => {});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async #loadPendingAuth() {
|
|
768
|
+
const state = await this.#stateForPendingAuth();
|
|
769
|
+
const stored = state?.pendingAuth?.() ?? null;
|
|
770
|
+
if (!stored) return null;
|
|
771
|
+
if (Date.now() > stored.expiresAt) return null;
|
|
772
|
+
return stored;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Persist tokens a transport rotated mid-flight. The Agent mailbox hands back
|
|
777
|
+
* a new refresh token on every refresh, so losing the write would break the
|
|
778
|
+
* next refresh and eventually sign the mailbox out.
|
|
779
|
+
*/
|
|
780
|
+
async persistTokens(botId, tokens = {}) {
|
|
781
|
+
const config = this.#configStore.list().find((entry) => entry.botId === botId);
|
|
782
|
+
if (!config) return false;
|
|
783
|
+
const accessToken = typeof tokens.accessToken === 'string' ? tokens.accessToken : '';
|
|
784
|
+
if (!accessToken) return false;
|
|
785
|
+
const current = (await this.#resolveSecrets(config)) ?? {};
|
|
786
|
+
const next = {
|
|
787
|
+
...current,
|
|
788
|
+
accessToken,
|
|
789
|
+
...(typeof tokens.refreshToken === 'string' && tokens.refreshToken
|
|
790
|
+
? { refreshToken: tokens.refreshToken } : {}),
|
|
791
|
+
};
|
|
792
|
+
await atConnectionStage('credential.save', () => this.#credentials.set(config.tokenRef, JSON.stringify(next)), 'credential-store');
|
|
793
|
+
this.#touch();
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async #restoreCredential(tokenRef, previous) {
|
|
798
|
+
if (typeof previous !== 'string' || !previous) {
|
|
799
|
+
await atConnectionStage('credential.remove', () => this.#credentials.unset(tokenRef), 'credential-store').catch(() => {});
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
await atConnectionStage('credential.save', () => this.#credentials.set(tokenRef, previous), 'credential-store').catch(() => {});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
get diagnostics() { return this.#diagnostics; }
|
|
806
|
+
#safeError(code, error) {
|
|
807
|
+
return this.#diagnostics.report(error, { reuse: true, stage: 'connection.start', code }).publicError;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
#touch() {
|
|
811
|
+
this.#revision += 1;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async #withBotTransition(botId, task) {
|
|
815
|
+
const previous = this.#transitions.get(botId) ?? Promise.resolve();
|
|
816
|
+
const next = previous.then(task, task).finally(() => {
|
|
817
|
+
if (this.#transitions.get(botId) === next) this.#transitions.delete(botId);
|
|
818
|
+
});
|
|
819
|
+
this.#transitions.set(botId, next);
|
|
820
|
+
return next;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
export { EmailConfigStore, EmailStateStore };
|