@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.
Files changed (148) hide show
  1. package/README.en.md +5 -4
  2. package/README.md +5 -4
  3. package/lib/client.js +2584 -1153
  4. package/lib/index.js +290 -298
  5. package/package.json +14 -2
  6. package/plugin-src/client/channel-card-meta.js +2 -0
  7. package/plugin-src/client/channel-logos.js +11 -0
  8. package/plugin-src/client/channels/dingtalk/api.js +5 -1
  9. package/plugin-src/client/channels/dingtalk/index.js +15 -13
  10. package/plugin-src/client/channels/email/api.js +52 -0
  11. package/plugin-src/client/channels/email/index.js +728 -0
  12. package/plugin-src/client/channels/email/styles.js +51 -0
  13. package/plugin-src/client/channels/feishu/api.js +4 -2
  14. package/plugin-src/client/channels/feishu/index.js +23 -7
  15. package/plugin-src/client/channels/imessage/index.js +2 -1
  16. package/plugin-src/client/channels/office/api.js +2 -0
  17. package/plugin-src/client/channels/office/index.js +6 -5
  18. package/plugin-src/client/channels/qq/api.js +7 -0
  19. package/plugin-src/client/channels/qq/index.js +16 -3
  20. package/plugin-src/client/channels/shared/token-api.js +8 -1
  21. package/plugin-src/client/channels/shared/token-channel.js +28 -3
  22. package/plugin-src/client/channels/slack/index.js +2 -1
  23. package/plugin-src/client/channels/telegram/index.js +3 -0
  24. package/plugin-src/client/channels/telegram/styles.js +12 -0
  25. package/plugin-src/client/channels/telegram/thinking-traces.js +25 -0
  26. package/plugin-src/client/channels/wecom/api.js +7 -1
  27. package/plugin-src/client/channels/wecom/index.js +16 -3
  28. package/plugin-src/client/channels/wecom-app/api.js +6 -1
  29. package/plugin-src/client/channels/wecom-app/index.js +16 -3
  30. package/plugin-src/client/channels/weixin/api.js +2 -1
  31. package/plugin-src/client/channels/weixin/connection-error.js +1 -71
  32. package/plugin-src/client/channels/whatsapp/api.js +5 -0
  33. package/plugin-src/client/channels/whatsapp/index.js +16 -3
  34. package/plugin-src/client/connection-error.js +87 -0
  35. package/plugin-src/client/global-settings.js +81 -1
  36. package/plugin-src/client/i18n.js +69 -1
  37. package/plugin-src/client/index.js +40 -4
  38. package/plugin-src/client/last-message-error.js +2 -1
  39. package/plugin-src/client/styles.js +8 -2
  40. package/plugin-src/host/build.mjs +4 -0
  41. package/plugin-src/host/channels/dingtalk/production.mjs +2 -0
  42. package/plugin-src/host/channels/dingtalk/rpc.mjs +7 -4
  43. package/plugin-src/host/channels/email/availability.mjs +60 -0
  44. package/plugin-src/host/channels/email/index.mjs +36 -0
  45. package/plugin-src/host/channels/email/production.mjs +58 -0
  46. package/plugin-src/host/channels/email/rpc.mjs +163 -0
  47. package/plugin-src/host/channels/feishu/production.mjs +3 -0
  48. package/plugin-src/host/channels/feishu/rpc.mjs +7 -4
  49. package/plugin-src/host/channels/imessage/rpc.mjs +13 -6
  50. package/plugin-src/host/channels/office/rpc.mjs +3 -1
  51. package/plugin-src/host/channels/qq/production.mjs +2 -0
  52. package/plugin-src/host/channels/qq/rpc.mjs +5 -3
  53. package/plugin-src/host/channels/shared/production.mjs +52 -5
  54. package/plugin-src/host/channels/shared/rpc.mjs +14 -3
  55. package/plugin-src/host/channels/shared/startup-error.mjs +4 -3
  56. package/plugin-src/host/channels/shared/startup.mjs +11 -6
  57. package/plugin-src/host/channels/shared/thinking-traces-rpc.mjs +11 -0
  58. package/plugin-src/host/channels/slack/production.mjs +2 -0
  59. package/plugin-src/host/channels/slack/rpc.mjs +5 -3
  60. package/plugin-src/host/channels/wecom/production.mjs +2 -0
  61. package/plugin-src/host/channels/wecom/rpc.mjs +5 -3
  62. package/plugin-src/host/channels/wecom-app/production.mjs +2 -0
  63. package/plugin-src/host/channels/wecom-app/rpc.mjs +5 -3
  64. package/plugin-src/host/channels/weixin/production.mjs +2 -0
  65. package/plugin-src/host/channels/whatsapp/production.mjs +2 -0
  66. package/plugin-src/host/channels/whatsapp/rpc.mjs +5 -3
  67. package/plugin-src/host/delivery-adapter.mjs +11 -0
  68. package/plugin-src/host/image-input-rpc.mjs +24 -0
  69. package/plugin-src/host/inbound-ttl-rpc.mjs +6 -1
  70. package/plugin-src/host/index.mjs +3 -0
  71. package/plugin-src/host/modern-harness-api.mjs +7 -2
  72. package/plugin-src/management-rpc.mjs +12 -2
  73. package/scripts/verify-package.mjs +13 -5
  74. package/src/channels/dingtalk/connection-error.mjs +5 -12
  75. package/src/channels/dingtalk/device-auth.mjs +4 -1
  76. package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -4
  77. package/src/channels/dingtalk/dingtalk-controller.mjs +60 -45
  78. package/src/channels/dingtalk/dingtalk-runtime.mjs +20 -4
  79. package/src/channels/discord/discord-api.mjs +3 -3
  80. package/src/channels/discord/discord-runtime.mjs +18 -12
  81. package/src/channels/email/config-store.mjs +194 -0
  82. package/src/channels/email/email-api.mjs +20 -0
  83. package/src/channels/email/email-bridge.mjs +17 -0
  84. package/src/channels/email/email-controller.mjs +824 -0
  85. package/src/channels/email/email-runtime.mjs +699 -0
  86. package/src/channels/email/harness-client.mjs +7 -0
  87. package/src/channels/email/mail-format.mjs +91 -0
  88. package/src/channels/email/state-store.mjs +176 -0
  89. package/src/channels/email/transport.mjs +64 -0
  90. package/src/channels/email/transports/agent-mail.mjs +604 -0
  91. package/src/channels/email/transports/agently-cli.mjs +288 -0
  92. package/src/channels/email/transports/imap-smtp.mjs +165 -0
  93. package/src/channels/feishu/bridge.mjs +39 -11
  94. package/src/channels/feishu/feishu-channel.mjs +35 -0
  95. package/src/channels/feishu/feishu-runtime.mjs +15 -8
  96. package/src/channels/feishu/group-message-permission-manager.mjs +2 -1
  97. package/src/channels/feishu/live-cot.mjs +260 -0
  98. package/src/channels/feishu/message-utils.mjs +3 -2
  99. package/src/channels/feishu/multi-bot-controller.mjs +56 -47
  100. package/src/channels/feishu/registration-manager.mjs +8 -4
  101. package/src/channels/feishu/repair-manager.mjs +2 -1
  102. package/src/channels/feishu/slash-command-registry.mjs +17 -0
  103. package/src/channels/feishu/step-push-mode.mjs +10 -4
  104. package/src/channels/imessage/runtime.mjs +9 -5
  105. package/src/channels/office/office-controller.mjs +23 -12
  106. package/src/channels/office/office-runtime.mjs +6 -3
  107. package/src/channels/office/office-transport.mjs +1 -0
  108. package/src/channels/qq/qq-bridge.mjs +7 -5
  109. package/src/channels/qq/qq-controller.mjs +56 -42
  110. package/src/channels/qq/qq-runtime.mjs +16 -11
  111. package/src/channels/shared/bot-workspace-store.mjs +29 -10
  112. package/src/channels/shared/connection-error.mjs +191 -0
  113. package/src/channels/shared/connection-test.mjs +3 -1
  114. package/src/channels/shared/conversation-state-store.mjs +46 -1
  115. package/src/channels/shared/diagnostic-details.mjs +95 -0
  116. package/src/channels/shared/harness-client.mjs +287 -57
  117. package/src/channels/shared/i18n-en/diagnostics.mjs +55 -0
  118. package/src/channels/shared/i18n-en/email.mjs +25 -0
  119. package/src/channels/shared/i18n-en/image-input.mjs +17 -0
  120. package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
  121. package/src/channels/shared/i18n-en/shared-c.mjs +1 -1
  122. package/src/channels/shared/i18n-en.mjs +6 -0
  123. package/src/channels/shared/image-input-policy.mjs +35 -0
  124. package/src/channels/shared/image-input-settings-store.mjs +60 -0
  125. package/src/channels/shared/image-input.mjs +124 -0
  126. package/src/channels/shared/image-prompt.mjs +15 -7
  127. package/src/channels/shared/message-failure.mjs +12 -0
  128. package/src/channels/shared/semantic/reply-reference.mjs +3 -3
  129. package/src/channels/shared/text-harness-bridge.mjs +150 -25
  130. package/src/channels/shared/token-bot-controller.mjs +52 -47
  131. package/src/channels/slack/slack-api.mjs +3 -3
  132. package/src/channels/slack/slack-controller.mjs +59 -51
  133. package/src/channels/slack/slack-runtime.mjs +18 -12
  134. package/src/channels/telegram/config-store.mjs +4 -1
  135. package/src/channels/telegram/telegram-api.mjs +2 -2
  136. package/src/channels/telegram/telegram-controller.mjs +13 -1
  137. package/src/channels/telegram/telegram-runtime.mjs +215 -14
  138. package/src/channels/wecom/wecom-bridge.mjs +15 -6
  139. package/src/channels/wecom/wecom-controller.mjs +51 -37
  140. package/src/channels/wecom/wecom-runtime.mjs +10 -6
  141. package/src/channels/wecom-app/wecom-app-bridge.mjs +7 -5
  142. package/src/channels/wecom-app/wecom-app-controller.mjs +50 -37
  143. package/src/channels/wecom-app/wecom-app-runtime.mjs +2 -1
  144. package/src/channels/weixin/connection-error.mjs +26 -92
  145. package/src/channels/weixin/diagnostic-details.mjs +1 -63
  146. package/src/channels/weixin/weixin-bridge.mjs +7 -5
  147. package/src/channels/whatsapp/whatsapp-controller.mjs +34 -17
  148. 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
+ }