@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
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import { extractConnectionEvidence, createConnectionDiagnostics, atConnectionStage } from '../shared/connection-error.mjs';
|
|
2
|
+
import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
|
|
3
|
+
import {
|
|
4
|
+
EmailApi,
|
|
5
|
+
normalizeAddress,
|
|
6
|
+
parseMessageIds,
|
|
7
|
+
resolveThreadKey,
|
|
8
|
+
stripQuotedHistory,
|
|
9
|
+
} from './email-api.mjs';
|
|
10
|
+
import { createEmailBridgeStatus, EmailHarnessBridge } from './email-bridge.mjs';
|
|
11
|
+
import { EMAIL_CLIENT_DEFAULTS } from './config-store.mjs';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_POLL_INTERVAL_MS = EMAIL_CLIENT_DEFAULTS.pollIntervalMs;
|
|
14
|
+
|
|
15
|
+
// The Agent mailbox API allows 10 requests a minute and 200 an hour, and a
|
|
16
|
+
// fixed retry interval burns that budget while the limit is already exceeded —
|
|
17
|
+
// so the mailbox never recovers. A rate-limited poll therefore backs off, and
|
|
18
|
+
// the delay doubles on each consecutive failure up to this ceiling.
|
|
19
|
+
const RATE_LIMIT_BACKOFF_MS = 60_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The polling interval to use for a declared request budget.
|
|
23
|
+
*
|
|
24
|
+
* The provider publishes its limits at runtime (see `+me`), so the interval is
|
|
25
|
+
* derived from them rather than hard-coded: a poll costs one request for the
|
|
26
|
+
* list plus one per message read, and the provider's own numbers are the only
|
|
27
|
+
* authoritative source. Half the per-minute budget is left for reads, replies
|
|
28
|
+
* and anything else sharing the token, and the floor keeps a small budget from
|
|
29
|
+
* producing an absurdly long interval.
|
|
30
|
+
*/
|
|
31
|
+
export function pollIntervalForLimits(limits, { perPollRequests = 2, floorMs = 20_000 } = {}) {
|
|
32
|
+
const perMinute = Number(limits?.perMinute);
|
|
33
|
+
if (!Number.isFinite(perMinute) || perMinute <= 0) return null;
|
|
34
|
+
const usable = Math.max(1, Math.floor(perMinute / 2));
|
|
35
|
+
const interval = Math.round((60_000 * perPollRequests) / usable);
|
|
36
|
+
return Math.max(floorMs, interval);
|
|
37
|
+
}
|
|
38
|
+
const MAX_POLL_BACKOFF_MS = 15 * 60_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How far behind the mailbox tip the first poll starts. Leaving a small window
|
|
42
|
+
* means mail that lands while the channel is starting is not skipped, while the
|
|
43
|
+
* rest of the backlog stays untouched.
|
|
44
|
+
*/
|
|
45
|
+
const FIRST_CONNECT_WINDOW = 10;
|
|
46
|
+
|
|
47
|
+
/** Skip auto-generated mail that would otherwise trigger a turn. */
|
|
48
|
+
const IGNORED_SENDER_PATTERNS = [
|
|
49
|
+
/^(no-?reply|do-?not-?reply|mailer-daemon|postmaster|bounce)/i,
|
|
50
|
+
/^(noreply|notification|notifications|newsletter|marketing)/i,
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function isAutomatedSender(address) {
|
|
54
|
+
const local = String(address ?? '').split('@')[0] ?? '';
|
|
55
|
+
return IGNORED_SENDER_PATTERNS.some((pattern) => pattern.test(local));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* True when a message declares itself an automatic reply (RFC 3834). Any value
|
|
60
|
+
* other than "no" counts, matching the standard: "auto-replied",
|
|
61
|
+
* "auto-generated", "auto-notified".
|
|
62
|
+
*/
|
|
63
|
+
export function isAutoSubmitted(parsed) {
|
|
64
|
+
const raw = parsed?.headers?.get?.('auto-submitted')
|
|
65
|
+
?? (Array.isArray(parsed?.headerLines)
|
|
66
|
+
? parsed.headerLines.find((line) => /^auto-submitted:/i.test(line?.line ?? ''))?.line
|
|
67
|
+
?.slice('auto-submitted:'.length)
|
|
68
|
+
: undefined);
|
|
69
|
+
const value = String(raw ?? '').trim().toLowerCase();
|
|
70
|
+
return value !== '' && value !== 'no';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Reply subject: keep one "Re:" prefix so threads stay grouped. */
|
|
74
|
+
export function replySubject(subject) {
|
|
75
|
+
const text = String(subject ?? '').trim();
|
|
76
|
+
if (!text) return 'Re: (no subject)';
|
|
77
|
+
return /^re:/i.test(text) ? text : `Re: ${text}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Format one address list (To/Cc) as a compact "Name <addr>" string. */
|
|
81
|
+
function formatAddressList(value) {
|
|
82
|
+
const entries = Array.isArray(value?.value) ? value.value : [];
|
|
83
|
+
return entries
|
|
84
|
+
.map((entry) => {
|
|
85
|
+
const address = normalizeAddress(entry?.address);
|
|
86
|
+
if (!address) return null;
|
|
87
|
+
const name = String(entry?.name ?? '').trim();
|
|
88
|
+
return name ? `${name} <${address}>` : address;
|
|
89
|
+
})
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Compose the text the model actually receives.
|
|
95
|
+
*
|
|
96
|
+
* Only the body used to be forwarded, so an instruction written in the subject
|
|
97
|
+
* — a natural place for one — was silently dropped, and a message that also
|
|
98
|
+
* went to other recipients looked like a private note. The subject and the
|
|
99
|
+
* recipient lists are therefore prepended as a small header, and the original
|
|
100
|
+
* body is left untouched below it.
|
|
101
|
+
*
|
|
102
|
+
* This form is for the model only. The shared layer parses `content` for
|
|
103
|
+
* control commands and approval decisions, so a decorated string there makes
|
|
104
|
+
* `/help` and "批准" unrecognisable — those read the plain body instead.
|
|
105
|
+
*/
|
|
106
|
+
export function mailHeader({ subject, parsed }) {
|
|
107
|
+
const header = [];
|
|
108
|
+
const cleanSubject = String(subject ?? '').trim();
|
|
109
|
+
if (cleanSubject) header.push(`Subject: ${cleanSubject}`);
|
|
110
|
+
|
|
111
|
+
const from = formatAddressList(parsed?.from)[0];
|
|
112
|
+
if (from) header.push(`From: ${from}`);
|
|
113
|
+
|
|
114
|
+
const to = formatAddressList(parsed?.to);
|
|
115
|
+
if (to.length > 0) header.push(`To: ${to.join(', ')}`);
|
|
116
|
+
|
|
117
|
+
const cc = formatAddressList(parsed?.cc);
|
|
118
|
+
if (cc.length > 0) header.push(`Cc: ${cc.join(', ')}`);
|
|
119
|
+
|
|
120
|
+
return header.length > 0 ? `${header.join('\n')}\n\n` : '';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The mail metadata above the original body, as one prompt string. */
|
|
124
|
+
export function mailPromptContent({ body, subject, parsed }) {
|
|
125
|
+
const header = [];
|
|
126
|
+
const cleanSubject = String(subject ?? '').trim();
|
|
127
|
+
if (cleanSubject) header.push(`Subject: ${cleanSubject}`);
|
|
128
|
+
|
|
129
|
+
const from = formatAddressList(parsed?.from)[0];
|
|
130
|
+
if (from) header.push(`From: ${from}`);
|
|
131
|
+
|
|
132
|
+
const to = formatAddressList(parsed?.to);
|
|
133
|
+
if (to.length) header.push(`To: ${to.join(', ')}`);
|
|
134
|
+
|
|
135
|
+
const cc = formatAddressList(parsed?.cc);
|
|
136
|
+
if (cc.length) header.push(`Cc: ${cc.join(', ')}`);
|
|
137
|
+
|
|
138
|
+
const text = String(body ?? '').trim();
|
|
139
|
+
// A body-less mail (attachment only) still carries its header, so the model
|
|
140
|
+
// sees what the message was about.
|
|
141
|
+
if (header.length === 0) return text;
|
|
142
|
+
return text ? `${header.join('\n')}\n\n${text}` : header.join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Turn one parsed mail into the shared bridge's inbound message shape, or null
|
|
147
|
+
* when the mail must be ignored (self-sent, automated, empty body).
|
|
148
|
+
*/
|
|
149
|
+
export function normalizeEmail(parsed, { address, state } = {}) {
|
|
150
|
+
const messageId = parseMessageIds(parsed?.messageId)[0] ?? null;
|
|
151
|
+
if (!messageId) return null;
|
|
152
|
+
const from = normalizeAddress(parsed?.from?.value?.[0]?.address ?? parsed?.from?.text);
|
|
153
|
+
if (!from) return null;
|
|
154
|
+
// Loop break, per RFC 3834: anything marked as an automatic reply is never
|
|
155
|
+
// answered, so this mailbox can safely be its own sender (writing to itself
|
|
156
|
+
// to drive the Harness) without two bots echoing each other forever.
|
|
157
|
+
if (isAutoSubmitted(parsed)) return null;
|
|
158
|
+
if (isAutomatedSender(from)) return null;
|
|
159
|
+
|
|
160
|
+
const references = parseMessageIds(parsed?.references);
|
|
161
|
+
const inReplyTo = parseMessageIds(parsed?.inReplyTo);
|
|
162
|
+
// A fixed binding pins the conversation, so every message routed to that
|
|
163
|
+
// binding resolves to the same Harness session instead of a per-thread one.
|
|
164
|
+
// Without a binding the thread chain decides, which keeps one Harness session
|
|
165
|
+
// per mail thread.
|
|
166
|
+
const boundSession = state?.boundSessionFor?.(from) ?? null;
|
|
167
|
+
const conversationId = boundSession
|
|
168
|
+
? `bound:${boundSession}`
|
|
169
|
+
: resolveThreadKey({
|
|
170
|
+
messageId,
|
|
171
|
+
references,
|
|
172
|
+
inReplyTo,
|
|
173
|
+
conversationMap: state?.threadMap ?? new Map(),
|
|
174
|
+
});
|
|
175
|
+
const body = stripQuotedHistory(parsed?.text ?? parsed?.html ?? '');
|
|
176
|
+
const attachments = Array.isArray(parsed?.attachments) ? parsed.attachments : [];
|
|
177
|
+
if (!body && attachments.length === 0) return null;
|
|
178
|
+
|
|
179
|
+
const subject = String(parsed?.subject ?? '').trim();
|
|
180
|
+
return {
|
|
181
|
+
messageId,
|
|
182
|
+
conversationId,
|
|
183
|
+
kind: 'direct',
|
|
184
|
+
senderId: from,
|
|
185
|
+
addressed: true,
|
|
186
|
+
// Email has no notion of a display name we can trust; the address is both.
|
|
187
|
+
senderName: parsed?.from?.value?.[0]?.name || from,
|
|
188
|
+
senderAlternateId: undefined,
|
|
189
|
+
// The model needs the subject and the recipient lists; the parser must not
|
|
190
|
+
// see them, or `/help` and "批准" stop being recognised. `content` carries
|
|
191
|
+
// the decorated form and `controlText` the plain body.
|
|
192
|
+
content: mailPromptContent({ body, subject, parsed }),
|
|
193
|
+
controlText: body,
|
|
194
|
+
plainText: typeof parsed?.text === 'string',
|
|
195
|
+
images: [],
|
|
196
|
+
files: attachments.map((attachment) => ({
|
|
197
|
+
name: attachment.filename ?? 'attachment',
|
|
198
|
+
size: attachment.size,
|
|
199
|
+
// The shared inbound-file layer reads `mediaType` (not `mimeType`), so an
|
|
200
|
+
// attachment type under any other key is silently dropped.
|
|
201
|
+
...(attachment.contentType ? { mediaType: String(attachment.contentType) } : {}),
|
|
202
|
+
// The bridge streams files via a loader so large attachments are not
|
|
203
|
+
// held in memory until they are actually needed. Transports differ in
|
|
204
|
+
// what `content` is: IMAP hands over a Buffer (already fetched with the
|
|
205
|
+
// body), while the Agent mailbox can only fetch bytes on demand and so
|
|
206
|
+
// exposes a function. Returning that function unchanged made the loader
|
|
207
|
+
// resolve to a function, which the inbound-file layer rejects as
|
|
208
|
+
// `inbound-file-data-invalid` — the download never happened.
|
|
209
|
+
load: async () => (typeof attachment.content === 'function'
|
|
210
|
+
? attachment.content()
|
|
211
|
+
: attachment.content),
|
|
212
|
+
})),
|
|
213
|
+
reactionTarget: null,
|
|
214
|
+
replyTarget: {
|
|
215
|
+
to: from,
|
|
216
|
+
subject: replySubject(subject),
|
|
217
|
+
messageId,
|
|
218
|
+
// Some transports address a message by their own id rather than the RFC
|
|
219
|
+
// one (the Agent mailbox replies through /messages/{id}); carrying both
|
|
220
|
+
// lets each transport use what it needs.
|
|
221
|
+
transportMessageId: parsed?.uid ?? null,
|
|
222
|
+
references: [...references, ...inReplyTo, messageId].slice(-10),
|
|
223
|
+
},
|
|
224
|
+
connectionTestTarget: { to: from, subject: 'DSH 连接测试' },
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Bot client handed to the shared bridge: only sending is needed here. */
|
|
229
|
+
class EmailBotClient {
|
|
230
|
+
#api;
|
|
231
|
+
#signal;
|
|
232
|
+
constructor(api, signal) {
|
|
233
|
+
this.#api = api;
|
|
234
|
+
this.#signal = signal;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async sendText(target, text) {
|
|
238
|
+
const to = target?.to;
|
|
239
|
+
if (!to) {
|
|
240
|
+
const error = new TypeError('Email reply requires a recipient');
|
|
241
|
+
error.code = 'invalid-target';
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
return this.#api.sendReply({
|
|
245
|
+
to,
|
|
246
|
+
subject: target.subject,
|
|
247
|
+
text,
|
|
248
|
+
inReplyTo: target.messageId,
|
|
249
|
+
transportMessageId: target.transportMessageId,
|
|
250
|
+
references: target.references,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
sendTyping() {
|
|
255
|
+
// Mail has no typing indicator; the shared bridge degrades gracefully.
|
|
256
|
+
return Promise.resolve();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Outbound artifacts arrive as the shared materialized shape
|
|
261
|
+
* ({ fileName, mediaType, bytes }), the same structure every other channel
|
|
262
|
+
* consumes — not the { name, content } form.
|
|
263
|
+
*/
|
|
264
|
+
#attachmentFrom(file, fallbackName) {
|
|
265
|
+
const bytes = file?.bytes ?? file?.data ?? file?.content;
|
|
266
|
+
return {
|
|
267
|
+
filename: file?.fileName ?? file?.name ?? fallbackName,
|
|
268
|
+
content: bytes,
|
|
269
|
+
...(file?.mediaType ? { contentType: file.mediaType } : {}),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async sendFile(target, file) {
|
|
274
|
+
return this.#api.sendReply({
|
|
275
|
+
to: target?.to,
|
|
276
|
+
subject: target?.subject,
|
|
277
|
+
text: '',
|
|
278
|
+
inReplyTo: target?.messageId,
|
|
279
|
+
transportMessageId: target?.transportMessageId,
|
|
280
|
+
references: target?.references,
|
|
281
|
+
attachments: [this.#attachmentFrom(file, 'attachment')],
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async sendImage(target, image) {
|
|
286
|
+
return this.#api.sendReply({
|
|
287
|
+
to: target?.to,
|
|
288
|
+
subject: target?.subject,
|
|
289
|
+
text: '',
|
|
290
|
+
inReplyTo: target?.messageId,
|
|
291
|
+
transportMessageId: target?.transportMessageId,
|
|
292
|
+
references: target?.references,
|
|
293
|
+
attachments: [this.#attachmentFrom(image, 'image')],
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function createEmailRuntimeStatus() {
|
|
299
|
+
return {
|
|
300
|
+
startedAt: null,
|
|
301
|
+
ready: false,
|
|
302
|
+
connectionState: 'idle',
|
|
303
|
+
harnessReachable: false,
|
|
304
|
+
lastCheckedAt: null,
|
|
305
|
+
lastConnectedAt: null,
|
|
306
|
+
lastError: null,
|
|
307
|
+
...createEmailBridgeStatus(),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export class EmailRuntime {
|
|
312
|
+
#config;
|
|
313
|
+
#token;
|
|
314
|
+
#harness;
|
|
315
|
+
#state;
|
|
316
|
+
#contextEnhancement;
|
|
317
|
+
#accessPolicy;
|
|
318
|
+
#logger;
|
|
319
|
+
#diagnostics;
|
|
320
|
+
#replyTimeoutMs;
|
|
321
|
+
#pollIntervalMs;
|
|
322
|
+
// Current delay between polls; grows on failure and resets on success.
|
|
323
|
+
#pollDelayMs;
|
|
324
|
+
#consecutivePollFailures = 0;
|
|
325
|
+
#createApi;
|
|
326
|
+
#credential = null;
|
|
327
|
+
#onTokensRefreshed;
|
|
328
|
+
#status = createEmailRuntimeStatus();
|
|
329
|
+
// Turns started by the poll loop, kept so `stop()` can wait for them and so
|
|
330
|
+
// a failure is reported instead of surfacing as an unhandled rejection.
|
|
331
|
+
#deliveries = new Set();
|
|
332
|
+
// Message keys handed to the bridge but not yet finished, so a re-listing
|
|
333
|
+
// during processing cannot deliver the same message twice.
|
|
334
|
+
#inFlight = new Set();
|
|
335
|
+
#api;
|
|
336
|
+
#bridge;
|
|
337
|
+
#abortController;
|
|
338
|
+
#timer;
|
|
339
|
+
#polling;
|
|
340
|
+
#stopped = true;
|
|
341
|
+
|
|
342
|
+
constructor({
|
|
343
|
+
config, token, harness, state, contextEnhancement, accessPolicy, logger = console,
|
|
344
|
+
replyTimeoutMs = 600_000, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
|
|
345
|
+
// The caller supplies a transport chosen from the mailbox's config; the
|
|
346
|
+
// default exists for direct construction (tests) and assumes IMAP/SMTP.
|
|
347
|
+
createApi = (options) => new EmailApi(options),
|
|
348
|
+
credential = null, onTokensRefreshed = null, createTransport = null,
|
|
349
|
+
}) {
|
|
350
|
+
// `token` is absent for a transport that authenticates another way: the
|
|
351
|
+
// Agent mailbox lets agently-cli hold the credentials in the system
|
|
352
|
+
// keychain, so requiring one here blocked it from ever starting.
|
|
353
|
+
if (!config || !harness || !state) {
|
|
354
|
+
throw new TypeError('EmailRuntime requires config, Harness, and state');
|
|
355
|
+
}
|
|
356
|
+
this.#config = config;
|
|
357
|
+
this.#token = token;
|
|
358
|
+
this.#harness = harness;
|
|
359
|
+
this.#state = state;
|
|
360
|
+
this.#contextEnhancement = contextEnhancement;
|
|
361
|
+
this.#accessPolicy = accessPolicy;
|
|
362
|
+
this.#logger = logger; this.#diagnostics = createConnectionDiagnostics({ channel: 'email', logger });
|
|
363
|
+
this.#replyTimeoutMs = replyTimeoutMs;
|
|
364
|
+
this.#pollIntervalMs = pollIntervalMs;
|
|
365
|
+
this.#pollDelayMs = pollIntervalMs;
|
|
366
|
+
// A caller-supplied transport wins; it is what knows the mailbox's protocol.
|
|
367
|
+
this.#createApi = typeof createTransport === 'function' ? createTransport : createApi;
|
|
368
|
+
this.#credential = credential ?? null;
|
|
369
|
+
this.#onTokensRefreshed = typeof onTokensRefreshed === 'function' ? onTokensRefreshed : null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
get status() {
|
|
373
|
+
return structuredClone(this.#status);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async sendConnectionTest(text) {
|
|
377
|
+
if (!this.#status.ready || !this.#api) {
|
|
378
|
+
const error = new Error('Email mailbox is not connected');
|
|
379
|
+
error.code = 'test-target-unavailable';
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
await sendRememberedConnectionTest({
|
|
383
|
+
state: this.#state,
|
|
384
|
+
text,
|
|
385
|
+
channelLabel: 'Email',
|
|
386
|
+
send: (target, value) => this.#api.sendReply({
|
|
387
|
+
to: target.to,
|
|
388
|
+
subject: target.subject ?? 'DSH 连接测试',
|
|
389
|
+
text: value,
|
|
390
|
+
}),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async sendProactiveText(target, text, options = {}) {
|
|
395
|
+
if (!this.#status.ready || !this.#api) {
|
|
396
|
+
const error = new Error('Email mailbox is not connected');
|
|
397
|
+
error.code = 'bot-not-connected';
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
const to = normalizeAddress(target?.route?.address);
|
|
401
|
+
if (!to) {
|
|
402
|
+
const error = new TypeError('Invalid Email proactive delivery target');
|
|
403
|
+
error.code = 'invalid-target';
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
await this.#api.sendText({ to, subject: target?.route?.subject ?? 'DSH 消息', text });
|
|
407
|
+
return { sent: true };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async start() {
|
|
411
|
+
if (this.#status.ready) return this.status;
|
|
412
|
+
await this.stop();
|
|
413
|
+
this.#stopped = false;
|
|
414
|
+
this.#status.startedAt = new Date().toISOString();
|
|
415
|
+
this.#status.connectionState = 'connecting';
|
|
416
|
+
this.#abortController = new AbortController();
|
|
417
|
+
try {
|
|
418
|
+
await atConnectionStage('harness.check', () => this.#harness.ensureRunning());
|
|
419
|
+
this.#status.harnessReachable = true;
|
|
420
|
+
const api = this.#createApi({
|
|
421
|
+
config: {
|
|
422
|
+
address: this.#config.platformId,
|
|
423
|
+
// Which protocol to speak. Omitting it fell back to IMAP/SMTP, so an
|
|
424
|
+
// Agent mailbox was dialled as if it were a mail server and failed
|
|
425
|
+
// with ECONNREFUSED.
|
|
426
|
+
transport: this.#config.transport,
|
|
427
|
+
// A standard mailbox authenticates with the app password; the Agent
|
|
428
|
+
// mailbox has none and carries an OAuth pair instead.
|
|
429
|
+
password: this.#token,
|
|
430
|
+
...(this.#credential?.accessToken ? { accessToken: this.#credential.accessToken } : {}),
|
|
431
|
+
...(this.#credential?.refreshToken ? { refreshToken: this.#credential.refreshToken } : {}),
|
|
432
|
+
imapHost: this.#config.imapHost,
|
|
433
|
+
imapPort: this.#config.imapPort,
|
|
434
|
+
smtpHost: this.#config.smtpHost,
|
|
435
|
+
smtpPort: this.#config.smtpPort,
|
|
436
|
+
mailbox: this.#config.mailbox ?? EMAIL_CLIENT_DEFAULTS.mailbox,
|
|
437
|
+
},
|
|
438
|
+
signal: this.#abortController.signal,
|
|
439
|
+
// Token refresh must be persisted; the controller owns that write.
|
|
440
|
+
...(typeof this.#onTokensRefreshed === 'function'
|
|
441
|
+
? { onTokensRefreshed: this.#onTokensRefreshed } : {}),
|
|
442
|
+
});
|
|
443
|
+
this.#api = api;
|
|
444
|
+
if (this.#state.cursor() === null) {
|
|
445
|
+
// On first connect the existing backlog must not be replayed as new
|
|
446
|
+
// instructions, so polling starts near the mailbox tip. A small window
|
|
447
|
+
// before the tip is still scanned though: a message that arrives while
|
|
448
|
+
// the channel is starting up would otherwise be skipped forever. Those
|
|
449
|
+
// few older messages are filtered by the sender allowlist and the
|
|
450
|
+
// seen-message set, so the window cannot re-drive old requests.
|
|
451
|
+
const tip = await api.latestUid();
|
|
452
|
+
// IMAP numbers messages, so a small window can be stepped back to pick
|
|
453
|
+
// up mail that arrived during startup.
|
|
454
|
+
//
|
|
455
|
+
// A transport with an opaque cursor treats it as "already handled", so
|
|
456
|
+
// seeding it with the newest id discarded that message forever. Starting
|
|
457
|
+
// with no cursor is safe: the allowlist and the seen-message set still
|
|
458
|
+
// stop history from re-driving old requests.
|
|
459
|
+
if (Number.isSafeInteger(tip)) {
|
|
460
|
+
await this.#state.setCursor(Math.max(0, tip - FIRST_CONNECT_WINDOW));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const client = new EmailBotClient(api, this.#abortController.signal);
|
|
464
|
+
this.#bridge = new EmailHarnessBridge({
|
|
465
|
+
bot: client,
|
|
466
|
+
harness: this.#harness,
|
|
467
|
+
state: this.#state,
|
|
468
|
+
contextEnhancement: this.#contextEnhancement,
|
|
469
|
+
accessPolicy: this.#accessPolicy,
|
|
470
|
+
status: this.#status,
|
|
471
|
+
logger: this.#logger,
|
|
472
|
+
replyTimeoutMs: this.#replyTimeoutMs,
|
|
473
|
+
signal: this.#abortController.signal,
|
|
474
|
+
});
|
|
475
|
+
this.#status.ready = true;
|
|
476
|
+
this.#status.connectionState = 'connected';
|
|
477
|
+
this.#status.lastConnectedAt = Date.now();
|
|
478
|
+
this.#schedulePoll(0);
|
|
479
|
+
return this.status;
|
|
480
|
+
} catch (error) {
|
|
481
|
+
this.#status.ready = false;
|
|
482
|
+
this.#status.connectionState = 'failed';
|
|
483
|
+
this.#status.error = this.#diagnostics.report(error, { operation: 'connection.monitor', botId: this.#config?.botId, automatic: true }).publicError;
|
|
484
|
+
this.#status.lastError = this.#status.error.message;
|
|
485
|
+
await this.stop();
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async stop() {
|
|
491
|
+
this.#stopped = true;
|
|
492
|
+
clearTimeout(this.#timer);
|
|
493
|
+
this.#timer = null;
|
|
494
|
+
this.#abortController?.abort();
|
|
495
|
+
await this.#polling?.catch(() => {});
|
|
496
|
+
this.#polling = null;
|
|
497
|
+
// Deliveries are no longer awaited by the poll loop, so shutdown waits for
|
|
498
|
+
// them here instead of dropping a turn mid-flight.
|
|
499
|
+
await this.whenIdle({ timeoutMs: 1_000 });
|
|
500
|
+
const api = this.#api;
|
|
501
|
+
this.#api = null;
|
|
502
|
+
this.#bridge = null;
|
|
503
|
+
if (api) await api.disconnect().catch(() => {});
|
|
504
|
+
this.#status.ready = false;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
#schedulePoll(delay) {
|
|
508
|
+
if (this.#stopped) return;
|
|
509
|
+
this.#timer = setTimeout(() => {
|
|
510
|
+
this.#polling = this.#poll().finally(() => this.#schedulePoll(this.#pollDelayMs));
|
|
511
|
+
}, delay);
|
|
512
|
+
this.#timer.unref?.();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Start a delivery without blocking the poll loop.
|
|
517
|
+
*
|
|
518
|
+
* The promise is retained rather than dropped: `stop()` waits on the set, and
|
|
519
|
+
* a rejection is logged here so it cannot become an unhandled rejection.
|
|
520
|
+
*/
|
|
521
|
+
#track(promise) {
|
|
522
|
+
if (!promise || typeof promise.then !== 'function') return;
|
|
523
|
+
const task = promise
|
|
524
|
+
.catch((error) => {
|
|
525
|
+
if (this.#stopped) return;
|
|
526
|
+
this.#status.lastMessageError = this.#safeMessageError(error);
|
|
527
|
+
this.#logger.warn?.('[dsh-im:email] delivery failed', extractConnectionEvidence(error).details);
|
|
528
|
+
})
|
|
529
|
+
.finally(() => { this.#deliveries.delete(task); });
|
|
530
|
+
this.#deliveries.add(task);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Hand one message to the bridge, exactly once.
|
|
535
|
+
*
|
|
536
|
+
* The poll loop can re-list a message before the bridge has recorded it: the
|
|
537
|
+
* bridge writes its "already handled" entry only once processing starts, and
|
|
538
|
+
* the loop no longer waits for that. This set is the loop's own guard against
|
|
539
|
+
* handing the same message over twice — the bridge remains the authority on
|
|
540
|
+
* what has actually been processed.
|
|
541
|
+
*
|
|
542
|
+
* Marking the message seen here instead (as the loop used to) ran *ahead* of
|
|
543
|
+
* the bridge's queue: by the time `#process` started, `hasSeen` was already
|
|
544
|
+
* true and the message was dropped silently.
|
|
545
|
+
*/
|
|
546
|
+
#deliver(message, seenKey) {
|
|
547
|
+
const key = seenKey ?? message?.messageId;
|
|
548
|
+
const tracked = key !== undefined && key !== null && key !== '';
|
|
549
|
+
if (tracked) {
|
|
550
|
+
if (this.#inFlight.has(key)) return Promise.resolve();
|
|
551
|
+
this.#inFlight.add(key);
|
|
552
|
+
}
|
|
553
|
+
return Promise.resolve(this.#bridge.accept(message)).finally(() => {
|
|
554
|
+
if (tracked) this.#inFlight.delete(key);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** The bridge in use, so a caller can observe or steer delivery. */
|
|
559
|
+
get bridge() {
|
|
560
|
+
return this.#bridge;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Wait for the deliveries started by the poll loop.
|
|
565
|
+
*
|
|
566
|
+
* Bounded: a delivery whose Harness turn is parked on an approval never
|
|
567
|
+
* settles, and shutdown must not hang on it. The abort signal is raised
|
|
568
|
+
* before this is called, so a parked turn is already being torn down.
|
|
569
|
+
*/
|
|
570
|
+
async whenIdle({ timeoutMs = 5_000 } = {}) {
|
|
571
|
+
const deadline = Date.now() + timeoutMs;
|
|
572
|
+
while (this.#deliveries.size > 0 && Date.now() < deadline) {
|
|
573
|
+
const remaining = Math.max(1, deadline - Date.now());
|
|
574
|
+
const settled = await Promise.race([
|
|
575
|
+
Promise.allSettled([...this.#deliveries]).then(() => true),
|
|
576
|
+
// Deliberately NOT unref'd. An unref'd timer does not hold the event
|
|
577
|
+
// loop open, so when it is the only pending handle the process can
|
|
578
|
+
// finish before it fires — leaving this race unresolved forever and
|
|
579
|
+
// hanging whoever awaited it. That is exactly what wedged CI: the
|
|
580
|
+
// parked-delivery test replaced `accept` with a never-settling promise,
|
|
581
|
+
// `stop()` awaited this race, and the timer never got to run.
|
|
582
|
+
new Promise((resolve) => { setTimeout(() => resolve(false), remaining); }),
|
|
583
|
+
]);
|
|
584
|
+
if (!settled) break;
|
|
585
|
+
}
|
|
586
|
+
// The bound is a deliberate trade-off: a turn parked on an approval never
|
|
587
|
+
// settles, and unload must not hang on it. Work that outlives the bound is
|
|
588
|
+
// ABANDONED — `stop()` nulls the api right after this returns, so such a
|
|
589
|
+
// turn can no longer reply. Report it rather than dropping it silently.
|
|
590
|
+
const abandoned = this.#deliveries.size;
|
|
591
|
+
if (abandoned > 0) {
|
|
592
|
+
this.#logger.warn?.(
|
|
593
|
+
`[dsh-im:email] shutdown abandoned ${abandoned} in-flight delivery(ies); `
|
|
594
|
+
+ 'their replies will not be sent',
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
return abandoned;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** A short, log-safe description of a delivery failure. */
|
|
601
|
+
#safeMessageError(error) {
|
|
602
|
+
const code = typeof error?.code === 'string' ? error.code : null;
|
|
603
|
+
const message = typeof error?.message === 'string' ? error.message : '';
|
|
604
|
+
return { code: code ?? 'delivery-failed', message: message.slice(0, 200) };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async #poll() {
|
|
608
|
+
if (!this.#api || !this.#bridge || this.#stopped) return;
|
|
609
|
+
try {
|
|
610
|
+
const cursor = this.#state.cursor() ?? 0;
|
|
611
|
+
// Pass the mailbox allowlist down so unlisted mail is never downloaded.
|
|
612
|
+
const allowSenders = new Set(
|
|
613
|
+
(this.#config.allowedSenders ?? []).map((a) => String(a).trim().toLowerCase()),
|
|
614
|
+
);
|
|
615
|
+
const messages = await this.#api.listMessages({ afterUid: cursor, limit: 25, allowSenders });
|
|
616
|
+
for (const parsed of messages) {
|
|
617
|
+
// Transports address messages by an integer UID (IMAP) or an opaque
|
|
618
|
+
// string id (Agent mailbox); the cursor follows whichever it is.
|
|
619
|
+
const uid = parsed?.uid;
|
|
620
|
+
const message = normalizeEmail(parsed, { address: this.#config.platformId, state: this.#state });
|
|
621
|
+
// The seen set is the real guard against re-processing. A cursor marks a
|
|
622
|
+
// boundary, but a listing that shifts underneath it (mail moved or
|
|
623
|
+
// deleted) loses that boundary and replays everything. Keyed on the RFC
|
|
624
|
+
// Message-ID, which every transport provides.
|
|
625
|
+
const seenKey = message?.messageId
|
|
626
|
+
?? (uid === undefined || uid === null ? null : String(uid));
|
|
627
|
+
if (seenKey && this.#state.hasSeen(seenKey)) {
|
|
628
|
+
if (uid !== undefined && uid !== null && uid !== '' && uid !== cursor) {
|
|
629
|
+
await this.#state.setCursor(uid);
|
|
630
|
+
}
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (message) {
|
|
634
|
+
// Remember the thread chain before the turn runs so a reply that
|
|
635
|
+
// arrives while the turn is still working still joins this session.
|
|
636
|
+
for (const id of [message.messageId, ...message.replyTarget.references]) {
|
|
637
|
+
this.#state.rememberThreadId(id, message.conversationId);
|
|
638
|
+
}
|
|
639
|
+
// Deliberately not awaited. `accept` resolves only when the whole
|
|
640
|
+
// Harness turn finishes, and a turn that is waiting for an approval
|
|
641
|
+
// or an answer blocks the poll loop — so later mail stayed unread
|
|
642
|
+
// until someone replied. The bridge already serialises per
|
|
643
|
+
// conversation, so ordering is preserved without waiting here.
|
|
644
|
+
//
|
|
645
|
+
// The bridge owns the "already handled" record, and it only writes it
|
|
646
|
+
// once the message is actually being processed. The poll loop must not
|
|
647
|
+
// mark the message seen as well: doing it here runs *ahead* of the
|
|
648
|
+
// bridge's queue, so by the time `#process` starts, `hasSeen` is
|
|
649
|
+
// already true and the message is dropped silently. The loop therefore
|
|
650
|
+
// only rotates the in-flight set — it is the ordering guard that stops
|
|
651
|
+
// a re-listing from handing the same message to the bridge twice.
|
|
652
|
+
this.#track(this.#deliver(message, seenKey));
|
|
653
|
+
if (uid !== undefined && uid !== null && uid !== '' && uid !== cursor) {
|
|
654
|
+
await this.#state.setCursor(uid);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
this.#status.lastCheckedAt = Date.now();
|
|
659
|
+
this.#status.lastError = null; this.#status.error = null; this.#diagnostics.clear();
|
|
660
|
+
// A poll that works is the proof the mailbox is reachable.
|
|
661
|
+
this.#status.connectionState = 'connected';
|
|
662
|
+
this.#consecutivePollFailures = 0;
|
|
663
|
+
this.#pollDelayMs = this.#pollIntervalMs;
|
|
664
|
+
} catch (error) {
|
|
665
|
+
if (!this.#stopped) {
|
|
666
|
+
this.#status.error = this.#diagnostics.report(error, { operation: 'connection.monitor', botId: this.#config?.botId, automatic: true }).publicError;
|
|
667
|
+
this.#status.lastError = this.#status.error.message;
|
|
668
|
+
// A failing poll means the mailbox is NOT usable, even though the
|
|
669
|
+
// transport opened; leaving this as "connected" reported a healthy
|
|
670
|
+
// channel while no mail could be read at all.
|
|
671
|
+
this.#status.connectionState = 'failed';
|
|
672
|
+
this.#consecutivePollFailures += 1;
|
|
673
|
+
|
|
674
|
+
// Retrying a rate-limited endpoint on a fixed interval keeps the limit
|
|
675
|
+
// exceeded, so the mailbox never comes back. Back off instead — and
|
|
676
|
+
// wait longer each time, because the hourly window refills slowly.
|
|
677
|
+
const limited = isRateLimitError(error);
|
|
678
|
+
const base = limited ? RATE_LIMIT_BACKOFF_MS : this.#pollIntervalMs;
|
|
679
|
+
this.#pollDelayMs = Math.min(
|
|
680
|
+
base * (2 ** Math.min(this.#consecutivePollFailures - 1, 5)),
|
|
681
|
+
MAX_POLL_BACKOFF_MS,
|
|
682
|
+
);
|
|
683
|
+
this.#status.retryAt = Date.now() + this.#pollDelayMs;
|
|
684
|
+
if (limited) this.#status.rateLimited = true;
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Whether an error is the provider refusing us for exceeding a rate limit. */
|
|
693
|
+
export function isRateLimitError(error) {
|
|
694
|
+
if (error?.status === 429 || error?.httpStatus === 429) return true;
|
|
695
|
+
const code = String(error?.code ?? '');
|
|
696
|
+
if (code === 'rate-limited' || code === 'RATE_LIMIT_EXCEEDED' || code === '429') return true;
|
|
697
|
+
const text = String(error?.message ?? '');
|
|
698
|
+
return /rate limit|too many requests|429/i.test(text);
|
|
699
|
+
}
|