aamp-wechat-bridge 0.1.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.md +75 -0
- package/dist/index.js +3541 -0
- package/dist/index.js.map +7 -0
- package/package.json +32 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3541 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import readline2 from "node:readline/promises";
|
|
5
|
+
import { stdin as input2, stdout as output2, argv, exit } from "node:process";
|
|
6
|
+
import qrcodeTerminal from "qrcode-terminal";
|
|
7
|
+
|
|
8
|
+
// src/config.ts
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import readline from "node:readline/promises";
|
|
14
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
15
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
16
|
+
|
|
17
|
+
// ../sdk/dist/jmap-push.js
|
|
18
|
+
import WebSocket from "ws";
|
|
19
|
+
|
|
20
|
+
// ../sdk/dist/types.js
|
|
21
|
+
var AAMP_PROTOCOL_VERSION = "1.1";
|
|
22
|
+
var AAMP_HEADER = {
|
|
23
|
+
VERSION: "X-AAMP-Version",
|
|
24
|
+
INTENT: "X-AAMP-Intent",
|
|
25
|
+
TASK_ID: "X-AAMP-TaskId",
|
|
26
|
+
SESSION_KEY: "X-AAMP-Session-Key",
|
|
27
|
+
CONTEXT_LINKS: "X-AAMP-ContextLinks",
|
|
28
|
+
DISPATCH_CONTEXT: "X-AAMP-Dispatch-Context",
|
|
29
|
+
PRIORITY: "X-AAMP-Priority",
|
|
30
|
+
EXPIRES_AT: "X-AAMP-Expires-At",
|
|
31
|
+
STATUS: "X-AAMP-Status",
|
|
32
|
+
OUTPUT: "X-AAMP-Output",
|
|
33
|
+
ERROR_MSG: "X-AAMP-ErrorMsg",
|
|
34
|
+
STRUCTURED_RESULT: "X-AAMP-StructuredResult",
|
|
35
|
+
QUESTION: "X-AAMP-Question",
|
|
36
|
+
BLOCKED_REASON: "X-AAMP-BlockedReason",
|
|
37
|
+
SUGGESTED_OPTIONS: "X-AAMP-SuggestedOptions",
|
|
38
|
+
STREAM_ID: "X-AAMP-Stream-Id",
|
|
39
|
+
PARENT_TASK_ID: "X-AAMP-ParentTaskId",
|
|
40
|
+
CARD_SUMMARY: "X-AAMP-Card-Summary"
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// ../sdk/dist/parser.js
|
|
44
|
+
function normalizeBodyText(value) {
|
|
45
|
+
return value?.replace(/\r\n/g, "\n").trim() ?? "";
|
|
46
|
+
}
|
|
47
|
+
function escapeRegex(value) {
|
|
48
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
49
|
+
}
|
|
50
|
+
function extractBodySection(bodyText, label, nextLabels) {
|
|
51
|
+
if (!bodyText)
|
|
52
|
+
return "";
|
|
53
|
+
const nextPattern = nextLabels.length ? `(?=\\n(?:${nextLabels.map(escapeRegex).join("|")}):|$)` : "$";
|
|
54
|
+
const pattern = new RegExp(`(?:^|\\n)${escapeRegex(label)}:\\s*([\\s\\S]*?)${nextPattern}`, "i");
|
|
55
|
+
const match = pattern.exec(bodyText);
|
|
56
|
+
return match?.[1]?.trim() ?? "";
|
|
57
|
+
}
|
|
58
|
+
function parseSuggestedOptionsBlock(block) {
|
|
59
|
+
if (!block.trim())
|
|
60
|
+
return [];
|
|
61
|
+
return block.split("\n").map((line) => line.replace(/^\s*(?:[-*]|\d+\.)\s*/, "").trim()).filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
function parseTaskResultBody(bodyText) {
|
|
64
|
+
const normalized = normalizeBodyText(bodyText);
|
|
65
|
+
if (!normalized)
|
|
66
|
+
return { output: "" };
|
|
67
|
+
const output3 = extractBodySection(normalized, "Output", ["Error"]);
|
|
68
|
+
const errorMsg = extractBodySection(normalized, "Error", []);
|
|
69
|
+
if (output3 || errorMsg) {
|
|
70
|
+
return { output: output3, ...errorMsg ? { errorMsg } : {} };
|
|
71
|
+
}
|
|
72
|
+
return { output: normalized };
|
|
73
|
+
}
|
|
74
|
+
function parseTaskHelpBody(bodyText) {
|
|
75
|
+
const normalized = normalizeBodyText(bodyText);
|
|
76
|
+
if (!normalized) {
|
|
77
|
+
return { question: "", blockedReason: "", suggestedOptions: [] };
|
|
78
|
+
}
|
|
79
|
+
const question = extractBodySection(normalized, "Question", ["Blocked reason", "Suggested options"]);
|
|
80
|
+
const blockedReason = extractBodySection(normalized, "Blocked reason", ["Suggested options"]);
|
|
81
|
+
const suggestedOptions = parseSuggestedOptionsBlock(extractBodySection(normalized, "Suggested options", []));
|
|
82
|
+
if (question || blockedReason || suggestedOptions.length) {
|
|
83
|
+
return { question, blockedReason, suggestedOptions };
|
|
84
|
+
}
|
|
85
|
+
return { question: normalized, blockedReason: "", suggestedOptions: [] };
|
|
86
|
+
}
|
|
87
|
+
function decodeMimeEncodedWordSegment(segment) {
|
|
88
|
+
const match = /^=\?([^?]+)\?([bBqQ])\?([^?]*)\?=$/.exec(segment);
|
|
89
|
+
if (!match)
|
|
90
|
+
return segment;
|
|
91
|
+
const [, charsetRaw, encodingRaw, body] = match;
|
|
92
|
+
const charset = charsetRaw.toLowerCase();
|
|
93
|
+
const encoding = encodingRaw.toUpperCase();
|
|
94
|
+
try {
|
|
95
|
+
if (encoding === "B") {
|
|
96
|
+
const buf = Buffer.from(body, "base64");
|
|
97
|
+
return buf.toString(charset === "utf-8" || charset === "utf8" ? "utf8" : "utf8");
|
|
98
|
+
}
|
|
99
|
+
const normalized = body.replace(/_/g, " ").replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
100
|
+
const bytes = Buffer.from(normalized, "binary");
|
|
101
|
+
return bytes.toString(charset === "utf-8" || charset === "utf8" ? "utf8" : "utf8");
|
|
102
|
+
} catch {
|
|
103
|
+
return segment;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function decodeMimeEncodedWords(value) {
|
|
107
|
+
if (!value || !value.includes("=?"))
|
|
108
|
+
return value ?? "";
|
|
109
|
+
const collapsed = value.replace(/\r?\n[ \t]+/g, " ");
|
|
110
|
+
const decoded = collapsed.replace(/=\?[^?]+\?[bBqQ]\?[^?]*\?=/g, (segment) => decodeMimeEncodedWordSegment(segment));
|
|
111
|
+
return decoded.replace(/\s{2,}/g, " ").trim();
|
|
112
|
+
}
|
|
113
|
+
function normalizeHeaders(headers) {
|
|
114
|
+
return Object.fromEntries(Object.entries(headers).map(([k, v]) => [
|
|
115
|
+
k.toLowerCase(),
|
|
116
|
+
Array.isArray(v) ? v[0] : v
|
|
117
|
+
]));
|
|
118
|
+
}
|
|
119
|
+
function getAampHeader(headers, headerName) {
|
|
120
|
+
return headers[headerName.toLowerCase()];
|
|
121
|
+
}
|
|
122
|
+
var DISPATCH_CONTEXT_KEY_RE = /^[a-z0-9_-]+$/;
|
|
123
|
+
function parseDispatchContextHeader(value) {
|
|
124
|
+
if (!value)
|
|
125
|
+
return void 0;
|
|
126
|
+
const context = {};
|
|
127
|
+
for (const part of value.split(";")) {
|
|
128
|
+
const segment = part.trim();
|
|
129
|
+
if (!segment)
|
|
130
|
+
continue;
|
|
131
|
+
const eqIdx = segment.indexOf("=");
|
|
132
|
+
if (eqIdx <= 0)
|
|
133
|
+
continue;
|
|
134
|
+
const rawKey = segment.slice(0, eqIdx).trim();
|
|
135
|
+
const rawValue = segment.slice(eqIdx + 1).trim();
|
|
136
|
+
if (!DISPATCH_CONTEXT_KEY_RE.test(rawKey))
|
|
137
|
+
continue;
|
|
138
|
+
try {
|
|
139
|
+
context[rawKey] = decodeURIComponent(rawValue);
|
|
140
|
+
} catch {
|
|
141
|
+
context[rawKey] = rawValue;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return Object.keys(context).length ? context : void 0;
|
|
145
|
+
}
|
|
146
|
+
function serializeDispatchContextHeader(context) {
|
|
147
|
+
if (!context)
|
|
148
|
+
return void 0;
|
|
149
|
+
const parts = Object.entries(context).flatMap(([key, value]) => {
|
|
150
|
+
const normalizedKey = key.trim().toLowerCase();
|
|
151
|
+
if (!DISPATCH_CONTEXT_KEY_RE.test(normalizedKey))
|
|
152
|
+
return [];
|
|
153
|
+
const normalizedValue = String(value ?? "").trim();
|
|
154
|
+
if (!normalizedValue)
|
|
155
|
+
return [];
|
|
156
|
+
return `${normalizedKey}=${encodeURIComponent(normalizedValue)}`;
|
|
157
|
+
});
|
|
158
|
+
return parts.length ? parts.join("; ") : void 0;
|
|
159
|
+
}
|
|
160
|
+
function decodeStructuredResult(value) {
|
|
161
|
+
if (!value)
|
|
162
|
+
return void 0;
|
|
163
|
+
try {
|
|
164
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
165
|
+
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - normalized.length % 4);
|
|
166
|
+
const decoded = Buffer.from(normalized + padding, "base64").toString("utf-8");
|
|
167
|
+
return JSON.parse(decoded);
|
|
168
|
+
} catch {
|
|
169
|
+
return void 0;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function encodeStructuredResult(value) {
|
|
173
|
+
if (!value)
|
|
174
|
+
return void 0;
|
|
175
|
+
const json = JSON.stringify(value);
|
|
176
|
+
return Buffer.from(json, "utf-8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
177
|
+
}
|
|
178
|
+
function parseAampHeaders(meta) {
|
|
179
|
+
const headers = normalizeHeaders(meta.headers);
|
|
180
|
+
const intent = getAampHeader(headers, AAMP_HEADER.INTENT);
|
|
181
|
+
const taskId = getAampHeader(headers, AAMP_HEADER.TASK_ID);
|
|
182
|
+
const protocolVersion = getAampHeader(headers, AAMP_HEADER.VERSION) ?? AAMP_PROTOCOL_VERSION;
|
|
183
|
+
if (!intent || !taskId)
|
|
184
|
+
return null;
|
|
185
|
+
const from = meta.from.replace(/^<|>$/g, "");
|
|
186
|
+
const to = meta.to.replace(/^<|>$/g, "");
|
|
187
|
+
const decodedSubject = decodeMimeEncodedWords(meta.subject);
|
|
188
|
+
if (intent === "task.dispatch") {
|
|
189
|
+
const contextLinksStr = getAampHeader(headers, AAMP_HEADER.CONTEXT_LINKS) ?? "";
|
|
190
|
+
const dispatchContext = parseDispatchContextHeader(getAampHeader(headers, AAMP_HEADER.DISPATCH_CONTEXT));
|
|
191
|
+
const sessionKey = getAampHeader(headers, AAMP_HEADER.SESSION_KEY);
|
|
192
|
+
const parentTaskId = getAampHeader(headers, AAMP_HEADER.PARENT_TASK_ID);
|
|
193
|
+
const priority = getAampHeader(headers, AAMP_HEADER.PRIORITY) ?? "normal";
|
|
194
|
+
const expiresAt = getAampHeader(headers, AAMP_HEADER.EXPIRES_AT);
|
|
195
|
+
const dispatch = {
|
|
196
|
+
protocolVersion,
|
|
197
|
+
intent: "task.dispatch",
|
|
198
|
+
taskId,
|
|
199
|
+
...sessionKey ? { sessionKey } : {},
|
|
200
|
+
title: decodedSubject.replace(/^\[AAMP Task\]\s*/, "").trim() || "Untitled Task",
|
|
201
|
+
priority: priority === "urgent" || priority === "high" ? priority : "normal",
|
|
202
|
+
...expiresAt ? { expiresAt } : {},
|
|
203
|
+
contextLinks: contextLinksStr ? contextLinksStr.split(",").map((s) => s.trim()).filter(Boolean) : [],
|
|
204
|
+
...dispatchContext ? { dispatchContext } : {},
|
|
205
|
+
...parentTaskId ? { parentTaskId } : {},
|
|
206
|
+
from,
|
|
207
|
+
to,
|
|
208
|
+
messageId: meta.messageId,
|
|
209
|
+
subject: meta.subject,
|
|
210
|
+
bodyText: ""
|
|
211
|
+
// filled in by jmap-push.ts after parsing
|
|
212
|
+
};
|
|
213
|
+
return dispatch;
|
|
214
|
+
}
|
|
215
|
+
if (intent === "task.cancel") {
|
|
216
|
+
const cancel = {
|
|
217
|
+
protocolVersion,
|
|
218
|
+
intent: "task.cancel",
|
|
219
|
+
taskId,
|
|
220
|
+
from,
|
|
221
|
+
to,
|
|
222
|
+
messageId: meta.messageId,
|
|
223
|
+
subject: meta.subject,
|
|
224
|
+
bodyText: ""
|
|
225
|
+
};
|
|
226
|
+
return cancel;
|
|
227
|
+
}
|
|
228
|
+
if (intent === "task.result") {
|
|
229
|
+
const parsedBody = parseTaskResultBody(meta.bodyText);
|
|
230
|
+
const status = getAampHeader(headers, AAMP_HEADER.STATUS) ?? "completed";
|
|
231
|
+
const output3 = getAampHeader(headers, AAMP_HEADER.OUTPUT) ?? parsedBody.output;
|
|
232
|
+
const errorMsg = getAampHeader(headers, AAMP_HEADER.ERROR_MSG) ?? parsedBody.errorMsg;
|
|
233
|
+
const structuredResult = decodeStructuredResult(getAampHeader(headers, AAMP_HEADER.STRUCTURED_RESULT));
|
|
234
|
+
const result = {
|
|
235
|
+
protocolVersion,
|
|
236
|
+
intent: "task.result",
|
|
237
|
+
taskId,
|
|
238
|
+
status,
|
|
239
|
+
output: decodeMimeEncodedWords(output3),
|
|
240
|
+
errorMsg: errorMsg ? decodeMimeEncodedWords(errorMsg) : errorMsg,
|
|
241
|
+
structuredResult,
|
|
242
|
+
from,
|
|
243
|
+
to,
|
|
244
|
+
messageId: meta.messageId
|
|
245
|
+
};
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
if (intent === "task.help_needed") {
|
|
249
|
+
const parsedBody = parseTaskHelpBody(meta.bodyText);
|
|
250
|
+
const question = getAampHeader(headers, AAMP_HEADER.QUESTION) ?? parsedBody.question;
|
|
251
|
+
const blockedReason = getAampHeader(headers, AAMP_HEADER.BLOCKED_REASON) ?? parsedBody.blockedReason;
|
|
252
|
+
const suggestedOptionsStr = getAampHeader(headers, AAMP_HEADER.SUGGESTED_OPTIONS) ?? "";
|
|
253
|
+
const help = {
|
|
254
|
+
protocolVersion,
|
|
255
|
+
intent: "task.help_needed",
|
|
256
|
+
taskId,
|
|
257
|
+
question: decodeMimeEncodedWords(question),
|
|
258
|
+
blockedReason: decodeMimeEncodedWords(blockedReason),
|
|
259
|
+
suggestedOptions: suggestedOptionsStr ? suggestedOptionsStr.split("|").map((s) => decodeMimeEncodedWords(s.trim())).filter(Boolean) : parsedBody.suggestedOptions,
|
|
260
|
+
from,
|
|
261
|
+
to,
|
|
262
|
+
messageId: meta.messageId
|
|
263
|
+
};
|
|
264
|
+
return help;
|
|
265
|
+
}
|
|
266
|
+
if (intent === "task.ack") {
|
|
267
|
+
const ack = {
|
|
268
|
+
protocolVersion,
|
|
269
|
+
intent: "task.ack",
|
|
270
|
+
taskId,
|
|
271
|
+
from,
|
|
272
|
+
to,
|
|
273
|
+
messageId: meta.messageId
|
|
274
|
+
};
|
|
275
|
+
return ack;
|
|
276
|
+
}
|
|
277
|
+
if (intent === "task.stream.opened") {
|
|
278
|
+
const streamId = getAampHeader(headers, AAMP_HEADER.STREAM_ID) ?? "";
|
|
279
|
+
if (!streamId)
|
|
280
|
+
return null;
|
|
281
|
+
const streamOpened = {
|
|
282
|
+
protocolVersion,
|
|
283
|
+
intent: "task.stream.opened",
|
|
284
|
+
taskId,
|
|
285
|
+
streamId,
|
|
286
|
+
from,
|
|
287
|
+
to,
|
|
288
|
+
messageId: meta.messageId
|
|
289
|
+
};
|
|
290
|
+
return streamOpened;
|
|
291
|
+
}
|
|
292
|
+
if (intent === "card.query") {
|
|
293
|
+
const cardQuery = {
|
|
294
|
+
protocolVersion,
|
|
295
|
+
intent: "card.query",
|
|
296
|
+
taskId,
|
|
297
|
+
from,
|
|
298
|
+
to,
|
|
299
|
+
messageId: meta.messageId,
|
|
300
|
+
subject: meta.subject,
|
|
301
|
+
bodyText: ""
|
|
302
|
+
};
|
|
303
|
+
return cardQuery;
|
|
304
|
+
}
|
|
305
|
+
if (intent === "card.response") {
|
|
306
|
+
const summary = getAampHeader(headers, AAMP_HEADER.CARD_SUMMARY) ?? "";
|
|
307
|
+
const cardResponse = {
|
|
308
|
+
protocolVersion,
|
|
309
|
+
intent: "card.response",
|
|
310
|
+
taskId,
|
|
311
|
+
summary: decodeMimeEncodedWords(summary) || decodedSubject.replace(/^\[AAMP Card\]\s*/i, "").trim(),
|
|
312
|
+
from,
|
|
313
|
+
to,
|
|
314
|
+
messageId: meta.messageId,
|
|
315
|
+
subject: meta.subject,
|
|
316
|
+
bodyText: ""
|
|
317
|
+
};
|
|
318
|
+
return cardResponse;
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
function buildDispatchHeaders(params) {
|
|
323
|
+
const headers = {
|
|
324
|
+
[AAMP_HEADER.VERSION]: AAMP_PROTOCOL_VERSION,
|
|
325
|
+
[AAMP_HEADER.INTENT]: "task.dispatch",
|
|
326
|
+
[AAMP_HEADER.TASK_ID]: params.taskId,
|
|
327
|
+
[AAMP_HEADER.PRIORITY]: params.priority ?? "normal"
|
|
328
|
+
};
|
|
329
|
+
if (params.expiresAt) {
|
|
330
|
+
headers[AAMP_HEADER.EXPIRES_AT] = params.expiresAt;
|
|
331
|
+
}
|
|
332
|
+
if (params.sessionKey?.trim()) {
|
|
333
|
+
headers[AAMP_HEADER.SESSION_KEY] = params.sessionKey.trim();
|
|
334
|
+
}
|
|
335
|
+
if (params.contextLinks.length > 0) {
|
|
336
|
+
headers[AAMP_HEADER.CONTEXT_LINKS] = params.contextLinks.join(",");
|
|
337
|
+
}
|
|
338
|
+
const dispatchContext = serializeDispatchContextHeader(params.dispatchContext);
|
|
339
|
+
if (dispatchContext) {
|
|
340
|
+
headers[AAMP_HEADER.DISPATCH_CONTEXT] = dispatchContext;
|
|
341
|
+
}
|
|
342
|
+
if (params.parentTaskId) {
|
|
343
|
+
headers[AAMP_HEADER.PARENT_TASK_ID] = params.parentTaskId;
|
|
344
|
+
}
|
|
345
|
+
return headers;
|
|
346
|
+
}
|
|
347
|
+
function buildCancelHeaders(params) {
|
|
348
|
+
return {
|
|
349
|
+
[AAMP_HEADER.VERSION]: AAMP_PROTOCOL_VERSION,
|
|
350
|
+
[AAMP_HEADER.INTENT]: "task.cancel",
|
|
351
|
+
[AAMP_HEADER.TASK_ID]: params.taskId
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function buildAckHeaders(opts) {
|
|
355
|
+
return {
|
|
356
|
+
[AAMP_HEADER.VERSION]: AAMP_PROTOCOL_VERSION,
|
|
357
|
+
[AAMP_HEADER.INTENT]: "task.ack",
|
|
358
|
+
[AAMP_HEADER.TASK_ID]: opts.taskId
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
function buildStreamOpenedHeaders(opts) {
|
|
362
|
+
return {
|
|
363
|
+
[AAMP_HEADER.VERSION]: AAMP_PROTOCOL_VERSION,
|
|
364
|
+
[AAMP_HEADER.INTENT]: "task.stream.opened",
|
|
365
|
+
[AAMP_HEADER.TASK_ID]: opts.taskId,
|
|
366
|
+
[AAMP_HEADER.STREAM_ID]: opts.streamId
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function buildResultHeaders(params) {
|
|
370
|
+
const headers = {
|
|
371
|
+
[AAMP_HEADER.VERSION]: AAMP_PROTOCOL_VERSION,
|
|
372
|
+
[AAMP_HEADER.INTENT]: "task.result",
|
|
373
|
+
[AAMP_HEADER.TASK_ID]: params.taskId,
|
|
374
|
+
[AAMP_HEADER.STATUS]: params.status
|
|
375
|
+
};
|
|
376
|
+
const structuredResult = encodeStructuredResult(params.structuredResult);
|
|
377
|
+
if (structuredResult) {
|
|
378
|
+
headers[AAMP_HEADER.STRUCTURED_RESULT] = structuredResult;
|
|
379
|
+
}
|
|
380
|
+
return headers;
|
|
381
|
+
}
|
|
382
|
+
function buildHelpHeaders(params) {
|
|
383
|
+
return {
|
|
384
|
+
[AAMP_HEADER.VERSION]: AAMP_PROTOCOL_VERSION,
|
|
385
|
+
[AAMP_HEADER.INTENT]: "task.help_needed",
|
|
386
|
+
[AAMP_HEADER.TASK_ID]: params.taskId,
|
|
387
|
+
[AAMP_HEADER.SUGGESTED_OPTIONS]: params.suggestedOptions.join("|")
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function buildCardQueryHeaders(params) {
|
|
391
|
+
return {
|
|
392
|
+
[AAMP_HEADER.VERSION]: AAMP_PROTOCOL_VERSION,
|
|
393
|
+
[AAMP_HEADER.INTENT]: "card.query",
|
|
394
|
+
[AAMP_HEADER.TASK_ID]: params.taskId
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function buildCardResponseHeaders(params) {
|
|
398
|
+
return {
|
|
399
|
+
[AAMP_HEADER.VERSION]: AAMP_PROTOCOL_VERSION,
|
|
400
|
+
[AAMP_HEADER.INTENT]: "card.response",
|
|
401
|
+
[AAMP_HEADER.TASK_ID]: params.taskId,
|
|
402
|
+
[AAMP_HEADER.CARD_SUMMARY]: params.summary
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ../sdk/dist/tiny-emitter.js
|
|
407
|
+
var TinyEmitter = class {
|
|
408
|
+
listeners = /* @__PURE__ */ new Map();
|
|
409
|
+
onceWrappers = /* @__PURE__ */ new WeakMap();
|
|
410
|
+
on(event, listener) {
|
|
411
|
+
const bucket = this.listeners.get(event) ?? /* @__PURE__ */ new Set();
|
|
412
|
+
bucket.add(listener);
|
|
413
|
+
this.listeners.set(event, bucket);
|
|
414
|
+
return this;
|
|
415
|
+
}
|
|
416
|
+
once(event, listener) {
|
|
417
|
+
const wrapped = (...args) => {
|
|
418
|
+
this.off(event, listener);
|
|
419
|
+
listener(...args);
|
|
420
|
+
};
|
|
421
|
+
this.onceWrappers.set(listener, wrapped);
|
|
422
|
+
return this.on(event, wrapped);
|
|
423
|
+
}
|
|
424
|
+
off(event, listener) {
|
|
425
|
+
const bucket = this.listeners.get(event);
|
|
426
|
+
if (!bucket)
|
|
427
|
+
return this;
|
|
428
|
+
const original = listener;
|
|
429
|
+
const wrapped = this.onceWrappers.get(original);
|
|
430
|
+
bucket.delete(wrapped ?? original);
|
|
431
|
+
if (wrapped)
|
|
432
|
+
this.onceWrappers.delete(original);
|
|
433
|
+
if (bucket.size === 0)
|
|
434
|
+
this.listeners.delete(event);
|
|
435
|
+
return this;
|
|
436
|
+
}
|
|
437
|
+
emit(event, ...args) {
|
|
438
|
+
const bucket = this.listeners.get(event);
|
|
439
|
+
if (!bucket || bucket.size === 0)
|
|
440
|
+
return false;
|
|
441
|
+
for (const listener of [...bucket]) {
|
|
442
|
+
listener(...args);
|
|
443
|
+
}
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
async emitAsync(event, ...args) {
|
|
447
|
+
const bucket = this.listeners.get(event);
|
|
448
|
+
if (!bucket || bucket.size === 0)
|
|
449
|
+
return false;
|
|
450
|
+
const settled = await Promise.allSettled([...bucket].map((listener) => Promise.resolve(listener(...args))));
|
|
451
|
+
const rejected = settled.find((result) => result.status === "rejected");
|
|
452
|
+
if (rejected) {
|
|
453
|
+
throw rejected.reason;
|
|
454
|
+
}
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
// ../sdk/dist/jmap-push.js
|
|
460
|
+
function describeError(err) {
|
|
461
|
+
if (!(err instanceof Error))
|
|
462
|
+
return String(err);
|
|
463
|
+
const parts = [err.message];
|
|
464
|
+
const details = err;
|
|
465
|
+
if (details.code)
|
|
466
|
+
parts.push(`code=${details.code}`);
|
|
467
|
+
if (details.errno !== void 0)
|
|
468
|
+
parts.push(`errno=${details.errno}`);
|
|
469
|
+
if (details.syscall)
|
|
470
|
+
parts.push(`syscall=${details.syscall}`);
|
|
471
|
+
if (details.address)
|
|
472
|
+
parts.push(`address=${details.address}`);
|
|
473
|
+
if (details.port !== void 0)
|
|
474
|
+
parts.push(`port=${details.port}`);
|
|
475
|
+
if (details.cause instanceof Error) {
|
|
476
|
+
parts.push(`cause=${describeError(details.cause)}`);
|
|
477
|
+
} else if (details.cause !== void 0) {
|
|
478
|
+
parts.push(`cause=${String(details.cause)}`);
|
|
479
|
+
}
|
|
480
|
+
return parts.join(" | ");
|
|
481
|
+
}
|
|
482
|
+
function sleep(ms) {
|
|
483
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
484
|
+
}
|
|
485
|
+
function shouldRetrySessionFetch(status) {
|
|
486
|
+
return status === 429 || status >= 500;
|
|
487
|
+
}
|
|
488
|
+
function shouldRetryBlobDownload(status) {
|
|
489
|
+
return status === 404 || status === 429 || status === 503;
|
|
490
|
+
}
|
|
491
|
+
function rewriteUrlToConfiguredOrigin(rawUrl, configuredBaseUrl) {
|
|
492
|
+
const parsed = new URL(rawUrl);
|
|
493
|
+
const configured = new URL(configuredBaseUrl);
|
|
494
|
+
parsed.protocol = configured.protocol;
|
|
495
|
+
parsed.username = configured.username;
|
|
496
|
+
parsed.password = configured.password;
|
|
497
|
+
parsed.hostname = configured.hostname;
|
|
498
|
+
parsed.port = configured.port;
|
|
499
|
+
return parsed.toString();
|
|
500
|
+
}
|
|
501
|
+
var SESSION_FETCH_MAX_ATTEMPTS = 3;
|
|
502
|
+
var SESSION_FETCH_RETRY_BASE_DELAY_MS = 250;
|
|
503
|
+
var JmapPushClient = class extends TinyEmitter {
|
|
504
|
+
ws = null;
|
|
505
|
+
session = null;
|
|
506
|
+
reconnectTimer = null;
|
|
507
|
+
pollTimer = null;
|
|
508
|
+
pingTimer = null;
|
|
509
|
+
safetySyncTimer = null;
|
|
510
|
+
seenMessageIds = /* @__PURE__ */ new Set();
|
|
511
|
+
connected = false;
|
|
512
|
+
pollingActive = false;
|
|
513
|
+
running = false;
|
|
514
|
+
connecting = false;
|
|
515
|
+
/** JMAP Email state — tracks processed position; null = not yet initialized */
|
|
516
|
+
emailState = null;
|
|
517
|
+
startedAtMs = Date.now();
|
|
518
|
+
email;
|
|
519
|
+
password;
|
|
520
|
+
jmapUrl;
|
|
521
|
+
reconnectInterval;
|
|
522
|
+
rejectUnauthorized;
|
|
523
|
+
pingIntervalMs = 5e3;
|
|
524
|
+
safetySyncIntervalMs = 5e3;
|
|
525
|
+
constructor(opts) {
|
|
526
|
+
super();
|
|
527
|
+
this.email = opts.email;
|
|
528
|
+
this.password = opts.password;
|
|
529
|
+
this.jmapUrl = opts.jmapUrl.replace(/\/$/, "");
|
|
530
|
+
this.reconnectInterval = opts.reconnectInterval ?? 5e3;
|
|
531
|
+
this.rejectUnauthorized = opts.rejectUnauthorized ?? true;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Start the JMAP Push listener
|
|
535
|
+
*/
|
|
536
|
+
async start() {
|
|
537
|
+
this.running = true;
|
|
538
|
+
this.startSafetySync();
|
|
539
|
+
await this.connect();
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Stop the JMAP Push listener
|
|
543
|
+
*/
|
|
544
|
+
stop() {
|
|
545
|
+
this.running = false;
|
|
546
|
+
if (this.reconnectTimer) {
|
|
547
|
+
clearTimeout(this.reconnectTimer);
|
|
548
|
+
this.reconnectTimer = null;
|
|
549
|
+
}
|
|
550
|
+
if (this.pollTimer) {
|
|
551
|
+
clearTimeout(this.pollTimer);
|
|
552
|
+
this.pollTimer = null;
|
|
553
|
+
}
|
|
554
|
+
if (this.pingTimer) {
|
|
555
|
+
clearInterval(this.pingTimer);
|
|
556
|
+
this.pingTimer = null;
|
|
557
|
+
}
|
|
558
|
+
if (this.safetySyncTimer) {
|
|
559
|
+
clearInterval(this.safetySyncTimer);
|
|
560
|
+
this.safetySyncTimer = null;
|
|
561
|
+
}
|
|
562
|
+
if (this.ws) {
|
|
563
|
+
this.ws.close();
|
|
564
|
+
this.ws = null;
|
|
565
|
+
}
|
|
566
|
+
this.connected = false;
|
|
567
|
+
this.pollingActive = false;
|
|
568
|
+
this.connecting = false;
|
|
569
|
+
}
|
|
570
|
+
getAuthHeader() {
|
|
571
|
+
const creds = `${this.email}:${this.password}`;
|
|
572
|
+
return `Basic ${Buffer.from(creds).toString("base64")}`;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Fetch the JMAP session object
|
|
576
|
+
*/
|
|
577
|
+
async fetchSession() {
|
|
578
|
+
const url = `${this.jmapUrl}/.well-known/jmap`;
|
|
579
|
+
let lastError = null;
|
|
580
|
+
for (let attempt = 1; attempt <= SESSION_FETCH_MAX_ATTEMPTS; attempt += 1) {
|
|
581
|
+
let res;
|
|
582
|
+
try {
|
|
583
|
+
res = await fetch(url, {
|
|
584
|
+
headers: { Authorization: this.getAuthHeader() }
|
|
585
|
+
});
|
|
586
|
+
} catch (err) {
|
|
587
|
+
lastError = new Error(`fetchSession ${url} failed: ${describeError(err)}`);
|
|
588
|
+
if (attempt >= SESSION_FETCH_MAX_ATTEMPTS)
|
|
589
|
+
throw lastError;
|
|
590
|
+
await sleep(SESSION_FETCH_RETRY_BASE_DELAY_MS * attempt);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (res.ok) {
|
|
594
|
+
return res.json();
|
|
595
|
+
}
|
|
596
|
+
lastError = new Error(attempt >= SESSION_FETCH_MAX_ATTEMPTS || !shouldRetrySessionFetch(res.status) ? `Failed to fetch JMAP session: ${res.status} ${res.statusText}` : `Failed to fetch JMAP session after ${attempt} attempt(s): ${res.status} ${res.statusText}`);
|
|
597
|
+
if (attempt >= SESSION_FETCH_MAX_ATTEMPTS || !shouldRetrySessionFetch(res.status)) {
|
|
598
|
+
throw lastError;
|
|
599
|
+
}
|
|
600
|
+
await sleep(SESSION_FETCH_RETRY_BASE_DELAY_MS * attempt);
|
|
601
|
+
}
|
|
602
|
+
throw lastError ?? new Error("Failed to fetch JMAP session");
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Perform a JMAP API call
|
|
606
|
+
*/
|
|
607
|
+
async jmapCall(methods) {
|
|
608
|
+
if (!this.session)
|
|
609
|
+
throw new Error("No JMAP session");
|
|
610
|
+
const apiUrl = `${this.jmapUrl}/jmap/`;
|
|
611
|
+
let res;
|
|
612
|
+
try {
|
|
613
|
+
res = await fetch(apiUrl, {
|
|
614
|
+
method: "POST",
|
|
615
|
+
headers: {
|
|
616
|
+
Authorization: this.getAuthHeader(),
|
|
617
|
+
"Content-Type": "application/json"
|
|
618
|
+
},
|
|
619
|
+
body: JSON.stringify({
|
|
620
|
+
using: [
|
|
621
|
+
"urn:ietf:params:jmap:core",
|
|
622
|
+
"urn:ietf:params:jmap:mail"
|
|
623
|
+
],
|
|
624
|
+
methodCalls: methods
|
|
625
|
+
})
|
|
626
|
+
});
|
|
627
|
+
} catch (err) {
|
|
628
|
+
throw new Error(`jmapCall ${apiUrl} failed: ${describeError(err)}`);
|
|
629
|
+
}
|
|
630
|
+
if (!res.ok) {
|
|
631
|
+
throw new Error(`JMAP API call failed: ${res.status}`);
|
|
632
|
+
}
|
|
633
|
+
return res.json();
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Initialize emailState by fetching the current state without loading any emails.
|
|
637
|
+
* Called on first connect so we only process emails that arrive AFTER this point.
|
|
638
|
+
*/
|
|
639
|
+
async initEmailState(accountId) {
|
|
640
|
+
const response = await this.jmapCall([
|
|
641
|
+
["Email/get", { accountId, ids: [] }, "g0"]
|
|
642
|
+
]);
|
|
643
|
+
const getResp = response.methodResponses.find(([name]) => name === "Email/get");
|
|
644
|
+
if (getResp) {
|
|
645
|
+
this.emailState = getResp[1].state ?? null;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Fetch only emails created since `sinceState` using Email/changes.
|
|
650
|
+
* Updates `this.emailState` to the new state after fetching.
|
|
651
|
+
* Returns [] and resets state if the server cannot calculate changes (state too old).
|
|
652
|
+
*/
|
|
653
|
+
async fetchEmailsSince(accountId, sinceState) {
|
|
654
|
+
const changesResp = await this.jmapCall([
|
|
655
|
+
["Email/changes", { accountId, sinceState, maxChanges: 50 }, "c1"]
|
|
656
|
+
]);
|
|
657
|
+
const changesResult = changesResp.methodResponses.find(([name]) => name === "Email/changes");
|
|
658
|
+
if (!changesResult || changesResult[0] === "error") {
|
|
659
|
+
await this.initEmailState(accountId);
|
|
660
|
+
return [];
|
|
661
|
+
}
|
|
662
|
+
const changes = changesResult[1];
|
|
663
|
+
if (changes.newState) {
|
|
664
|
+
this.emailState = changes.newState;
|
|
665
|
+
}
|
|
666
|
+
const newIds = changes.created ?? [];
|
|
667
|
+
if (newIds.length === 0)
|
|
668
|
+
return [];
|
|
669
|
+
const emailResp = await this.jmapCall([
|
|
670
|
+
[
|
|
671
|
+
"Email/get",
|
|
672
|
+
{
|
|
673
|
+
accountId,
|
|
674
|
+
ids: newIds,
|
|
675
|
+
properties: ["id", "subject", "from", "to", "headers", "messageId", "receivedAt", "textBody", "bodyValues", "attachments"],
|
|
676
|
+
fetchTextBodyValues: true,
|
|
677
|
+
maxBodyValueBytes: 262144
|
|
678
|
+
},
|
|
679
|
+
"g1"
|
|
680
|
+
]
|
|
681
|
+
]);
|
|
682
|
+
const getResult = emailResp.methodResponses.find(([name]) => name === "Email/get");
|
|
683
|
+
if (!getResult)
|
|
684
|
+
return [];
|
|
685
|
+
const data = getResult[1];
|
|
686
|
+
return data.list ?? [];
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Process a received email.
|
|
690
|
+
*
|
|
691
|
+
* Priority:
|
|
692
|
+
* 1. If X-AAMP-Intent is present → emit typed AAMP event (task.dispatch / task.cancel / task.result / task.help_needed)
|
|
693
|
+
* 2. If In-Reply-To is present → emit 'reply' event so the application layer can
|
|
694
|
+
* resolve the thread (inReplyTo → taskId via Redis/DB) and handle human replies.
|
|
695
|
+
* 3. Otherwise → ignore (not an AAMP-related email)
|
|
696
|
+
*/
|
|
697
|
+
processEmail(email) {
|
|
698
|
+
const headerMap = {};
|
|
699
|
+
for (const h of email.headers ?? []) {
|
|
700
|
+
headerMap[h.name.toLowerCase()] = h.value.trim();
|
|
701
|
+
}
|
|
702
|
+
const fromAddr = email.from?.[0]?.email ?? "";
|
|
703
|
+
const toAddr = email.to?.[0]?.email ?? "";
|
|
704
|
+
const messageId = email.messageId?.[0] ?? email.id;
|
|
705
|
+
if (this.seenMessageIds.has(messageId))
|
|
706
|
+
return;
|
|
707
|
+
this.seenMessageIds.add(messageId);
|
|
708
|
+
const aampTextPartId = email.textBody?.[0]?.partId;
|
|
709
|
+
const aampBodyText = aampTextPartId ? (email.bodyValues?.[aampTextPartId]?.value ?? "").trim() : "";
|
|
710
|
+
const msg = parseAampHeaders({
|
|
711
|
+
from: fromAddr,
|
|
712
|
+
to: toAddr,
|
|
713
|
+
messageId,
|
|
714
|
+
subject: email.subject ?? "",
|
|
715
|
+
headers: headerMap,
|
|
716
|
+
bodyText: aampBodyText
|
|
717
|
+
});
|
|
718
|
+
if (msg && "intent" in msg) {
|
|
719
|
+
;
|
|
720
|
+
msg.bodyText = aampBodyText;
|
|
721
|
+
const receivedAttachments = (email.attachments ?? []).map((a) => ({
|
|
722
|
+
filename: a.name ?? "attachment",
|
|
723
|
+
contentType: a.type,
|
|
724
|
+
size: a.size,
|
|
725
|
+
blobId: a.blobId
|
|
726
|
+
}));
|
|
727
|
+
if (receivedAttachments.length > 0) {
|
|
728
|
+
;
|
|
729
|
+
msg.attachments = receivedAttachments;
|
|
730
|
+
}
|
|
731
|
+
if (msg.intent === "task.dispatch") {
|
|
732
|
+
this.emit("_autoAck", { to: fromAddr, taskId: msg.taskId, messageId });
|
|
733
|
+
}
|
|
734
|
+
const aampMsg = msg;
|
|
735
|
+
switch (aampMsg.intent) {
|
|
736
|
+
case "task.dispatch":
|
|
737
|
+
this.emit("task.dispatch", aampMsg);
|
|
738
|
+
break;
|
|
739
|
+
case "task.cancel":
|
|
740
|
+
this.emit("task.cancel", aampMsg);
|
|
741
|
+
break;
|
|
742
|
+
case "task.result":
|
|
743
|
+
this.emit("task.result", aampMsg);
|
|
744
|
+
break;
|
|
745
|
+
case "task.help_needed":
|
|
746
|
+
this.emit("task.help_needed", aampMsg);
|
|
747
|
+
break;
|
|
748
|
+
case "task.ack":
|
|
749
|
+
this.emit("task.ack", aampMsg);
|
|
750
|
+
break;
|
|
751
|
+
case "task.stream.opened":
|
|
752
|
+
this.emit("task.stream.opened", aampMsg);
|
|
753
|
+
break;
|
|
754
|
+
case "card.query":
|
|
755
|
+
this.emit("card.query", aampMsg);
|
|
756
|
+
break;
|
|
757
|
+
case "card.response":
|
|
758
|
+
this.emit("card.response", aampMsg);
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
const rawInReplyTo = headerMap["in-reply-to"] ?? "";
|
|
764
|
+
if (!rawInReplyTo)
|
|
765
|
+
return;
|
|
766
|
+
const rawReferences = headerMap["references"] ?? "";
|
|
767
|
+
const referencesIds = rawReferences.split(/\s+/).map((s) => s.replace(/[<>]/g, "").trim()).filter(Boolean);
|
|
768
|
+
const inReplyTo = rawInReplyTo.replace(/[<>]/g, "").trim();
|
|
769
|
+
const textPartId = email.textBody?.[0]?.partId;
|
|
770
|
+
const bodyText = textPartId ? (email.bodyValues?.[textPartId]?.value ?? "").trim() : "";
|
|
771
|
+
const reply = {
|
|
772
|
+
inReplyTo,
|
|
773
|
+
messageId,
|
|
774
|
+
from: fromAddr,
|
|
775
|
+
to: toAddr,
|
|
776
|
+
subject: email.subject ?? "",
|
|
777
|
+
bodyText
|
|
778
|
+
};
|
|
779
|
+
if (referencesIds.length > 0) {
|
|
780
|
+
Object.assign(reply, { references: referencesIds });
|
|
781
|
+
}
|
|
782
|
+
this.emit("reply", reply);
|
|
783
|
+
}
|
|
784
|
+
async fetchRecentEmails(accountId) {
|
|
785
|
+
const queryResp = await this.jmapCall([
|
|
786
|
+
[
|
|
787
|
+
"Email/query",
|
|
788
|
+
{
|
|
789
|
+
accountId,
|
|
790
|
+
sort: [{ property: "receivedAt", isAscending: false }],
|
|
791
|
+
limit: 20
|
|
792
|
+
},
|
|
793
|
+
"q1"
|
|
794
|
+
]
|
|
795
|
+
]);
|
|
796
|
+
const queryResult = queryResp.methodResponses.find(([name]) => name === "Email/query");
|
|
797
|
+
if (!queryResult)
|
|
798
|
+
return [];
|
|
799
|
+
const ids = (queryResult[1].ids ?? []).slice(0, 20);
|
|
800
|
+
if (ids.length === 0)
|
|
801
|
+
return [];
|
|
802
|
+
const emailResp = await this.jmapCall([
|
|
803
|
+
[
|
|
804
|
+
"Email/get",
|
|
805
|
+
{
|
|
806
|
+
accountId,
|
|
807
|
+
ids,
|
|
808
|
+
properties: ["id", "subject", "from", "to", "headers", "messageId", "receivedAt", "textBody", "bodyValues", "attachments"],
|
|
809
|
+
fetchTextBodyValues: true,
|
|
810
|
+
maxBodyValueBytes: 262144
|
|
811
|
+
},
|
|
812
|
+
"gRecent"
|
|
813
|
+
]
|
|
814
|
+
]);
|
|
815
|
+
const getResult = emailResp.methodResponses.find(([name]) => name === "Email/get");
|
|
816
|
+
if (!getResult)
|
|
817
|
+
return [];
|
|
818
|
+
return getResult[1].list ?? [];
|
|
819
|
+
}
|
|
820
|
+
shouldProcessBootstrapEmail(email) {
|
|
821
|
+
const receivedAtMs = new Date(email.receivedAt).getTime();
|
|
822
|
+
return Number.isFinite(receivedAtMs) && receivedAtMs >= this.startedAtMs - 15e3;
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Connect to JMAP WebSocket
|
|
826
|
+
*/
|
|
827
|
+
async connect() {
|
|
828
|
+
if (this.connecting || !this.running)
|
|
829
|
+
return;
|
|
830
|
+
this.connecting = true;
|
|
831
|
+
try {
|
|
832
|
+
this.session = await this.fetchSession();
|
|
833
|
+
} catch (err) {
|
|
834
|
+
this.connecting = false;
|
|
835
|
+
this.emit("error", new Error(`Failed to get JMAP session: ${err.message}`));
|
|
836
|
+
this.startPolling("session fetch failed");
|
|
837
|
+
this.scheduleReconnect();
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const stalwartWsUrl = `${this.jmapUrl}/jmap/ws`.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
|
|
841
|
+
this.ws = new WebSocket(stalwartWsUrl, "jmap", {
|
|
842
|
+
headers: {
|
|
843
|
+
Authorization: this.getAuthHeader()
|
|
844
|
+
},
|
|
845
|
+
perMessageDeflate: false,
|
|
846
|
+
rejectUnauthorized: this.rejectUnauthorized
|
|
847
|
+
});
|
|
848
|
+
this.ws.on("unexpected-response", (_req, res) => {
|
|
849
|
+
this.connecting = false;
|
|
850
|
+
const headerSummary = Object.entries(res.headers).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value ?? ""}`).join("; ");
|
|
851
|
+
this.startPolling(`websocket handshake failed: ${res.statusCode ?? "unknown"}`);
|
|
852
|
+
this.emit("error", new Error(`JMAP WebSocket handshake failed: ${res.statusCode ?? "unknown"} ${res.statusMessage ?? ""}${headerSummary ? ` | headers: ${headerSummary}` : ""}`));
|
|
853
|
+
this.scheduleReconnect();
|
|
854
|
+
});
|
|
855
|
+
this.ws.on("open", async () => {
|
|
856
|
+
this.connecting = false;
|
|
857
|
+
this.connected = true;
|
|
858
|
+
this.stopPolling();
|
|
859
|
+
this.startPingHeartbeat();
|
|
860
|
+
const accountId = this.session?.primaryAccounts["urn:ietf:params:jmap:mail"];
|
|
861
|
+
if (accountId && this.emailState === null) {
|
|
862
|
+
await this.initEmailState(accountId);
|
|
863
|
+
}
|
|
864
|
+
this.ws.send(JSON.stringify({
|
|
865
|
+
"@type": "WebSocketPushEnable",
|
|
866
|
+
dataTypes: ["Email"],
|
|
867
|
+
pushState: null
|
|
868
|
+
}));
|
|
869
|
+
this.emit("connected");
|
|
870
|
+
});
|
|
871
|
+
this.ws.on("pong", () => {
|
|
872
|
+
});
|
|
873
|
+
this.ws.on("message", async (rawData) => {
|
|
874
|
+
try {
|
|
875
|
+
const msg = JSON.parse(rawData.toString());
|
|
876
|
+
if (msg["@type"] === "StateChange") {
|
|
877
|
+
await this.handleStateChange(msg);
|
|
878
|
+
}
|
|
879
|
+
} catch (err) {
|
|
880
|
+
this.emit("error", new Error(`Failed to process JMAP push message: ${err.message}`));
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
this.ws.on("close", (code, reason) => {
|
|
884
|
+
this.connecting = false;
|
|
885
|
+
this.connected = false;
|
|
886
|
+
this.stopPingHeartbeat();
|
|
887
|
+
const reasonStr = reason?.toString() ?? "connection closed";
|
|
888
|
+
this.startPolling(reasonStr);
|
|
889
|
+
this.emit("disconnected", reasonStr);
|
|
890
|
+
if (this.running) {
|
|
891
|
+
this.scheduleReconnect();
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
this.ws.on("error", (err) => {
|
|
895
|
+
this.connecting = false;
|
|
896
|
+
this.stopPingHeartbeat();
|
|
897
|
+
this.startPolling(err.message);
|
|
898
|
+
this.emit("error", err);
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
startPingHeartbeat() {
|
|
902
|
+
if (this.pingTimer) {
|
|
903
|
+
clearInterval(this.pingTimer);
|
|
904
|
+
this.pingTimer = null;
|
|
905
|
+
}
|
|
906
|
+
this.pingTimer = setInterval(() => {
|
|
907
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
908
|
+
return;
|
|
909
|
+
try {
|
|
910
|
+
this.ws.ping();
|
|
911
|
+
} catch (err) {
|
|
912
|
+
this.emit("error", new Error(`Failed to send WebSocket ping: ${err.message}`));
|
|
913
|
+
}
|
|
914
|
+
}, this.pingIntervalMs);
|
|
915
|
+
}
|
|
916
|
+
stopPingHeartbeat() {
|
|
917
|
+
if (this.pingTimer) {
|
|
918
|
+
clearInterval(this.pingTimer);
|
|
919
|
+
this.pingTimer = null;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
startSafetySync() {
|
|
923
|
+
if (this.safetySyncTimer)
|
|
924
|
+
return;
|
|
925
|
+
this.safetySyncTimer = setInterval(() => {
|
|
926
|
+
if (!this.running)
|
|
927
|
+
return;
|
|
928
|
+
void this.reconcileRecentEmails(20).catch((err) => {
|
|
929
|
+
this.emit("error", new Error(`Safety reconcile failed: ${err.message}`));
|
|
930
|
+
});
|
|
931
|
+
}, this.safetySyncIntervalMs);
|
|
932
|
+
}
|
|
933
|
+
async handleStateChange(stateChange) {
|
|
934
|
+
if (!this.session)
|
|
935
|
+
return;
|
|
936
|
+
const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
|
|
937
|
+
if (!accountId)
|
|
938
|
+
return;
|
|
939
|
+
const changedAccount = stateChange.changed[accountId];
|
|
940
|
+
if (!changedAccount?.Email)
|
|
941
|
+
return;
|
|
942
|
+
try {
|
|
943
|
+
if (this.emailState === null) {
|
|
944
|
+
await this.initEmailState(accountId);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
const emails = await this.fetchEmailsSince(accountId, this.emailState);
|
|
948
|
+
for (const email of emails) {
|
|
949
|
+
this.processEmail(email);
|
|
950
|
+
}
|
|
951
|
+
} catch (err) {
|
|
952
|
+
this.emit("error", new Error(`Failed to fetch emails: ${err.message}`));
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
scheduleReconnect() {
|
|
956
|
+
if (this.reconnectTimer)
|
|
957
|
+
return;
|
|
958
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
959
|
+
this.reconnectTimer = null;
|
|
960
|
+
if (this.running) {
|
|
961
|
+
await this.connect();
|
|
962
|
+
}
|
|
963
|
+
}, this.reconnectInterval);
|
|
964
|
+
}
|
|
965
|
+
isConnected() {
|
|
966
|
+
return this.connected || this.pollingActive;
|
|
967
|
+
}
|
|
968
|
+
isUsingPollingFallback() {
|
|
969
|
+
return this.pollingActive && !this.connected;
|
|
970
|
+
}
|
|
971
|
+
stopPolling() {
|
|
972
|
+
if (this.pollTimer) {
|
|
973
|
+
clearTimeout(this.pollTimer);
|
|
974
|
+
this.pollTimer = null;
|
|
975
|
+
}
|
|
976
|
+
this.pollingActive = false;
|
|
977
|
+
}
|
|
978
|
+
startPolling(reason) {
|
|
979
|
+
if (!this.running || this.pollingActive)
|
|
980
|
+
return;
|
|
981
|
+
this.pollingActive = true;
|
|
982
|
+
this.emit("error", new Error(`JMAP WebSocket unavailable, falling back to polling: ${reason}`));
|
|
983
|
+
this.emit("connected");
|
|
984
|
+
const poll = async () => {
|
|
985
|
+
if (!this.running || this.connected) {
|
|
986
|
+
this.stopPolling();
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
try {
|
|
990
|
+
if (!this.session) {
|
|
991
|
+
this.session = await this.fetchSession();
|
|
992
|
+
}
|
|
993
|
+
const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"] ?? Object.keys(this.session.accounts)[0];
|
|
994
|
+
if (!accountId) {
|
|
995
|
+
throw new Error("No mail account available in JMAP session");
|
|
996
|
+
}
|
|
997
|
+
if (this.emailState === null) {
|
|
998
|
+
const recentEmails = await this.fetchRecentEmails(accountId);
|
|
999
|
+
for (const email of recentEmails.sort((a, b) => {
|
|
1000
|
+
const aTs = new Date(a.receivedAt).getTime();
|
|
1001
|
+
const bTs = new Date(b.receivedAt).getTime();
|
|
1002
|
+
return aTs - bTs;
|
|
1003
|
+
})) {
|
|
1004
|
+
if (!this.shouldProcessBootstrapEmail(email))
|
|
1005
|
+
continue;
|
|
1006
|
+
this.processEmail(email);
|
|
1007
|
+
}
|
|
1008
|
+
await this.initEmailState(accountId);
|
|
1009
|
+
} else {
|
|
1010
|
+
const emails = await this.fetchEmailsSince(accountId, this.emailState);
|
|
1011
|
+
for (const email of emails) {
|
|
1012
|
+
this.processEmail(email);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
this.emit("error", new Error(`Polling fallback failed: ${err.message}`));
|
|
1017
|
+
} finally {
|
|
1018
|
+
if (this.running && !this.connected) {
|
|
1019
|
+
this.pollTimer = setTimeout(poll, this.reconnectInterval);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
this.pollTimer = setTimeout(poll, 0);
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Download a blob (attachment) by its JMAP blobId.
|
|
1027
|
+
* Returns the raw binary content as a Buffer.
|
|
1028
|
+
*/
|
|
1029
|
+
async downloadBlob(blobId, filename) {
|
|
1030
|
+
if (!this.session) {
|
|
1031
|
+
this.session = await this.fetchSession();
|
|
1032
|
+
}
|
|
1033
|
+
const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"] ?? Object.keys(this.session.accounts)[0];
|
|
1034
|
+
let downloadUrl = this.session.downloadUrl ?? `${this.jmapUrl}/jmap/download/{accountId}/{blobId}/{name}`;
|
|
1035
|
+
try {
|
|
1036
|
+
downloadUrl = rewriteUrlToConfiguredOrigin(downloadUrl, this.jmapUrl);
|
|
1037
|
+
} catch {
|
|
1038
|
+
}
|
|
1039
|
+
const safeFilename = filename ?? "attachment";
|
|
1040
|
+
downloadUrl = downloadUrl.replace(/\{accountId\}|%7BaccountId%7D/gi, encodeURIComponent(accountId)).replace(/\{blobId\}|%7BblobId%7D/gi, encodeURIComponent(blobId)).replace(/\{name\}|%7Bname%7D/gi, encodeURIComponent(safeFilename)).replace(/\{type\}|%7Btype%7D/gi, "application/octet-stream");
|
|
1041
|
+
const maxAttempts = 8;
|
|
1042
|
+
let lastStatus = null;
|
|
1043
|
+
let lastError = null;
|
|
1044
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1045
|
+
let res;
|
|
1046
|
+
try {
|
|
1047
|
+
res = await fetch(downloadUrl, {
|
|
1048
|
+
headers: { Authorization: this.getAuthHeader() }
|
|
1049
|
+
});
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
lastError = new Error(`Blob download fetch failed: attempt=${attempt}/${maxAttempts} blobId=${blobId} filename=${filename ?? "attachment"} url=${downloadUrl} error=${describeError(err)}`);
|
|
1052
|
+
if (attempt < maxAttempts) {
|
|
1053
|
+
console.warn(`[AAMP-SDK] blob download retry fetch-error attempt=${attempt}/${maxAttempts} url=${downloadUrl} error=${describeError(err)}`);
|
|
1054
|
+
const delay = Math.min(1e3 * Math.pow(2, attempt - 1), 15e3);
|
|
1055
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
console.error(`[AAMP-SDK] blob download fetch-error attempt=${attempt}/${maxAttempts} url=${downloadUrl} error=${describeError(err)}`);
|
|
1059
|
+
throw lastError;
|
|
1060
|
+
}
|
|
1061
|
+
lastStatus = res.status;
|
|
1062
|
+
if (res.ok) {
|
|
1063
|
+
const arrayBuffer = await res.arrayBuffer();
|
|
1064
|
+
return Buffer.from(arrayBuffer);
|
|
1065
|
+
}
|
|
1066
|
+
if (attempt < maxAttempts && shouldRetryBlobDownload(res.status)) {
|
|
1067
|
+
console.warn(`[AAMP-SDK] blob download retry status=${res.status} attempt=${attempt}/${maxAttempts} url=${downloadUrl}`);
|
|
1068
|
+
const delay = Math.min(1e3 * Math.pow(2, attempt - 1), 15e3);
|
|
1069
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
console.error(`[AAMP-SDK] blob download failed status=${res.status} attempt=${attempt}/${maxAttempts} url=${downloadUrl}`);
|
|
1073
|
+
throw new Error(`Blob download failed: status=${res.status} attempt=${attempt}/${maxAttempts} blobId=${blobId} filename=${filename ?? "attachment"} url=${downloadUrl}`);
|
|
1074
|
+
}
|
|
1075
|
+
if (lastError)
|
|
1076
|
+
throw lastError;
|
|
1077
|
+
throw new Error(`Blob download failed after retries: status=${lastStatus ?? "unknown"} attempt=${maxAttempts}/${maxAttempts} blobId=${blobId} filename=${filename ?? "attachment"} url=${downloadUrl}`);
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Actively reconcile recent mailbox contents via JMAP HTTP.
|
|
1081
|
+
* Useful as a safety net when the WebSocket stays "connected"
|
|
1082
|
+
* but a notification is missed by an intermediate layer.
|
|
1083
|
+
*/
|
|
1084
|
+
async reconcileRecentEmails(limit = 20, opts) {
|
|
1085
|
+
if (!this.session) {
|
|
1086
|
+
this.session = await this.fetchSession();
|
|
1087
|
+
}
|
|
1088
|
+
const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"] ?? Object.keys(this.session.accounts)[0];
|
|
1089
|
+
if (!accountId) {
|
|
1090
|
+
throw new Error("No mail account available in JMAP session");
|
|
1091
|
+
}
|
|
1092
|
+
const queryResp = await this.jmapCall([
|
|
1093
|
+
[
|
|
1094
|
+
"Email/query",
|
|
1095
|
+
{
|
|
1096
|
+
accountId,
|
|
1097
|
+
sort: [{ property: "receivedAt", isAscending: false }],
|
|
1098
|
+
limit
|
|
1099
|
+
},
|
|
1100
|
+
"qReconcile"
|
|
1101
|
+
]
|
|
1102
|
+
]);
|
|
1103
|
+
const queryResult = queryResp.methodResponses.find(([name]) => name === "Email/query");
|
|
1104
|
+
if (!queryResult)
|
|
1105
|
+
return 0;
|
|
1106
|
+
const ids = (queryResult[1].ids ?? []).slice(0, limit);
|
|
1107
|
+
if (ids.length === 0)
|
|
1108
|
+
return 0;
|
|
1109
|
+
const emailResp = await this.jmapCall([
|
|
1110
|
+
[
|
|
1111
|
+
"Email/get",
|
|
1112
|
+
{
|
|
1113
|
+
accountId,
|
|
1114
|
+
ids,
|
|
1115
|
+
properties: ["id", "subject", "from", "to", "headers", "messageId", "receivedAt", "textBody", "bodyValues", "attachments"],
|
|
1116
|
+
fetchTextBodyValues: true,
|
|
1117
|
+
maxBodyValueBytes: 262144
|
|
1118
|
+
},
|
|
1119
|
+
"gReconcile"
|
|
1120
|
+
]
|
|
1121
|
+
]);
|
|
1122
|
+
const getResult = emailResp.methodResponses.find(([name]) => name === "Email/get");
|
|
1123
|
+
if (!getResult)
|
|
1124
|
+
return 0;
|
|
1125
|
+
const emails = getResult[1].list ?? [];
|
|
1126
|
+
for (const email of emails.sort((a, b) => {
|
|
1127
|
+
const aTs = new Date(a.receivedAt).getTime();
|
|
1128
|
+
const bTs = new Date(b.receivedAt).getTime();
|
|
1129
|
+
return aTs - bTs;
|
|
1130
|
+
})) {
|
|
1131
|
+
if (!opts?.includeHistorical && !this.shouldProcessBootstrapEmail(email))
|
|
1132
|
+
continue;
|
|
1133
|
+
this.processEmail(email);
|
|
1134
|
+
}
|
|
1135
|
+
return emails.length;
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1139
|
+
// ../sdk/dist/smtp-sender.js
|
|
1140
|
+
import { createTransport } from "nodemailer";
|
|
1141
|
+
import { randomUUID } from "crypto";
|
|
1142
|
+
var sanitize = (s) => s.replace(/[\r\n]/g, " ").trim();
|
|
1143
|
+
function deriveMailboxServiceDefaults(email, baseUrl) {
|
|
1144
|
+
const domain = email.split("@")[1]?.trim();
|
|
1145
|
+
const resolvedBaseUrl = baseUrl?.trim() || (domain ? `https://${domain}` : void 0);
|
|
1146
|
+
const smtpHost = domain || (resolvedBaseUrl ? new URL(resolvedBaseUrl).hostname : "localhost");
|
|
1147
|
+
return {
|
|
1148
|
+
smtpHost,
|
|
1149
|
+
httpBaseUrl: resolvedBaseUrl
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
var SmtpSender = class _SmtpSender {
|
|
1153
|
+
config;
|
|
1154
|
+
transport;
|
|
1155
|
+
discoveredApiUrlPromise = null;
|
|
1156
|
+
jmapSessionPromise = null;
|
|
1157
|
+
sentMailboxIdPromise = null;
|
|
1158
|
+
static fromMailboxIdentity(config) {
|
|
1159
|
+
const derived = deriveMailboxServiceDefaults(config.email, config.baseUrl);
|
|
1160
|
+
return new _SmtpSender({
|
|
1161
|
+
host: derived.smtpHost,
|
|
1162
|
+
port: config.smtpPort ?? 587,
|
|
1163
|
+
user: config.email,
|
|
1164
|
+
password: config.password,
|
|
1165
|
+
httpBaseUrl: derived.httpBaseUrl,
|
|
1166
|
+
authToken: Buffer.from(`${config.email}:${config.password}`).toString("base64"),
|
|
1167
|
+
secure: config.secure,
|
|
1168
|
+
rejectUnauthorized: config.rejectUnauthorized
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
constructor(config) {
|
|
1172
|
+
this.config = config;
|
|
1173
|
+
this.transport = createTransport({
|
|
1174
|
+
host: config.host,
|
|
1175
|
+
port: config.port,
|
|
1176
|
+
secure: config.secure ?? false,
|
|
1177
|
+
auth: {
|
|
1178
|
+
user: config.user,
|
|
1179
|
+
pass: config.password
|
|
1180
|
+
},
|
|
1181
|
+
tls: {
|
|
1182
|
+
rejectUnauthorized: config.rejectUnauthorized ?? true
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
senderDomain() {
|
|
1187
|
+
return this.config.user.split("@")[1]?.toLowerCase() ?? "";
|
|
1188
|
+
}
|
|
1189
|
+
recipientDomain(email) {
|
|
1190
|
+
return email.split("@")[1]?.toLowerCase() ?? "";
|
|
1191
|
+
}
|
|
1192
|
+
shouldUseHttpFallback(to) {
|
|
1193
|
+
return Boolean(this.config.httpBaseUrl && this.config.authToken && this.senderDomain() && this.senderDomain() === this.recipientDomain(to));
|
|
1194
|
+
}
|
|
1195
|
+
async resolveAampApiUrl() {
|
|
1196
|
+
const base = this.config.httpBaseUrl?.replace(/\/$/, "");
|
|
1197
|
+
if (!base) {
|
|
1198
|
+
throw new Error("HTTP send fallback is not configured");
|
|
1199
|
+
}
|
|
1200
|
+
if (!this.discoveredApiUrlPromise) {
|
|
1201
|
+
this.discoveredApiUrlPromise = (async () => {
|
|
1202
|
+
const discoveryRes = await fetch(`${base}/.well-known/aamp`);
|
|
1203
|
+
if (!discoveryRes.ok) {
|
|
1204
|
+
throw new Error(`AAMP discovery failed: ${discoveryRes.status}`);
|
|
1205
|
+
}
|
|
1206
|
+
const discovery = await discoveryRes.json();
|
|
1207
|
+
if (!discovery.api?.url) {
|
|
1208
|
+
throw new Error("AAMP discovery did not return api.url");
|
|
1209
|
+
}
|
|
1210
|
+
return new URL(discovery.api.url, `${base}/`).toString();
|
|
1211
|
+
})();
|
|
1212
|
+
}
|
|
1213
|
+
try {
|
|
1214
|
+
return await this.discoveredApiUrlPromise;
|
|
1215
|
+
} catch (err) {
|
|
1216
|
+
this.discoveredApiUrlPromise = null;
|
|
1217
|
+
throw err;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
async sendViaHttp(opts) {
|
|
1221
|
+
if (!this.config.authToken) {
|
|
1222
|
+
throw new Error("HTTP send fallback is not configured");
|
|
1223
|
+
}
|
|
1224
|
+
const apiUrl = new URL(await this.resolveAampApiUrl());
|
|
1225
|
+
apiUrl.searchParams.set("action", "aamp.mailbox.send");
|
|
1226
|
+
const res = await fetch(apiUrl, {
|
|
1227
|
+
method: "POST",
|
|
1228
|
+
headers: {
|
|
1229
|
+
Authorization: `Basic ${this.config.authToken}`,
|
|
1230
|
+
"Content-Type": "application/json"
|
|
1231
|
+
},
|
|
1232
|
+
body: JSON.stringify({
|
|
1233
|
+
to: opts.to,
|
|
1234
|
+
subject: opts.subject,
|
|
1235
|
+
text: opts.text,
|
|
1236
|
+
aampHeaders: opts.aampHeaders,
|
|
1237
|
+
attachments: opts.attachments?.map((a) => ({
|
|
1238
|
+
filename: a.filename,
|
|
1239
|
+
contentType: a.contentType,
|
|
1240
|
+
content: typeof a.content === "string" ? a.content : a.content.toString("base64")
|
|
1241
|
+
}))
|
|
1242
|
+
})
|
|
1243
|
+
});
|
|
1244
|
+
const data = await res.json().catch(() => ({}));
|
|
1245
|
+
if (!res.ok) {
|
|
1246
|
+
throw new Error(data.details || `HTTP send failed: ${res.status}`);
|
|
1247
|
+
}
|
|
1248
|
+
return { messageId: data.messageId };
|
|
1249
|
+
}
|
|
1250
|
+
canPersistSentCopy() {
|
|
1251
|
+
return Boolean(this.config.httpBaseUrl && this.config.authToken);
|
|
1252
|
+
}
|
|
1253
|
+
getJmapAuthHeader() {
|
|
1254
|
+
if (!this.config.authToken) {
|
|
1255
|
+
throw new Error("JMAP auth token is not configured");
|
|
1256
|
+
}
|
|
1257
|
+
return `Basic ${this.config.authToken}`;
|
|
1258
|
+
}
|
|
1259
|
+
async resolveJmapSession() {
|
|
1260
|
+
const base = this.config.httpBaseUrl?.replace(/\/$/, "");
|
|
1261
|
+
if (!base) {
|
|
1262
|
+
throw new Error("JMAP base URL is not configured");
|
|
1263
|
+
}
|
|
1264
|
+
if (!this.jmapSessionPromise) {
|
|
1265
|
+
this.jmapSessionPromise = (async () => {
|
|
1266
|
+
const res = await fetch(`${base}/.well-known/jmap`, {
|
|
1267
|
+
headers: { Authorization: this.getJmapAuthHeader() }
|
|
1268
|
+
});
|
|
1269
|
+
if (!res.ok) {
|
|
1270
|
+
throw new Error(`JMAP session failed: ${res.status} ${res.statusText}`);
|
|
1271
|
+
}
|
|
1272
|
+
const session = await res.json();
|
|
1273
|
+
const accountId = session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? Object.keys(session.accounts ?? {})[0];
|
|
1274
|
+
if (!accountId) {
|
|
1275
|
+
throw new Error("No JMAP mail account available");
|
|
1276
|
+
}
|
|
1277
|
+
return {
|
|
1278
|
+
accountId,
|
|
1279
|
+
apiUrl: `${base}/jmap/`
|
|
1280
|
+
};
|
|
1281
|
+
})();
|
|
1282
|
+
}
|
|
1283
|
+
try {
|
|
1284
|
+
return await this.jmapSessionPromise;
|
|
1285
|
+
} catch (err) {
|
|
1286
|
+
this.jmapSessionPromise = null;
|
|
1287
|
+
throw err;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
async jmapCall(methodCalls) {
|
|
1291
|
+
const session = await this.resolveJmapSession();
|
|
1292
|
+
const res = await fetch(session.apiUrl, {
|
|
1293
|
+
method: "POST",
|
|
1294
|
+
headers: {
|
|
1295
|
+
Authorization: this.getJmapAuthHeader(),
|
|
1296
|
+
"Content-Type": "application/json"
|
|
1297
|
+
},
|
|
1298
|
+
body: JSON.stringify({
|
|
1299
|
+
using: [
|
|
1300
|
+
"urn:ietf:params:jmap:core",
|
|
1301
|
+
"urn:ietf:params:jmap:mail"
|
|
1302
|
+
],
|
|
1303
|
+
methodCalls: methodCalls.map(([name, args, tag]) => [
|
|
1304
|
+
name,
|
|
1305
|
+
{ accountId: session.accountId, ...args },
|
|
1306
|
+
tag
|
|
1307
|
+
])
|
|
1308
|
+
})
|
|
1309
|
+
});
|
|
1310
|
+
if (!res.ok) {
|
|
1311
|
+
throw new Error(`JMAP API call failed: ${res.status}`);
|
|
1312
|
+
}
|
|
1313
|
+
const data = await res.json();
|
|
1314
|
+
return data.methodResponses ?? [];
|
|
1315
|
+
}
|
|
1316
|
+
async getSentMailboxId() {
|
|
1317
|
+
if (!this.sentMailboxIdPromise) {
|
|
1318
|
+
this.sentMailboxIdPromise = (async () => {
|
|
1319
|
+
const responses = await this.jmapCall([
|
|
1320
|
+
["Mailbox/get", { ids: null }, "mb1"]
|
|
1321
|
+
]);
|
|
1322
|
+
const result = responses.find(([name]) => name === "Mailbox/get")?.[1];
|
|
1323
|
+
const mailboxes = result?.list ?? [];
|
|
1324
|
+
return mailboxes.find((mailbox) => mailbox.role === "sent")?.id ?? mailboxes[0]?.id ?? null;
|
|
1325
|
+
})();
|
|
1326
|
+
}
|
|
1327
|
+
try {
|
|
1328
|
+
return await this.sentMailboxIdPromise;
|
|
1329
|
+
} catch (err) {
|
|
1330
|
+
this.sentMailboxIdPromise = null;
|
|
1331
|
+
throw err;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
async saveToSent(params) {
|
|
1335
|
+
if (!this.canPersistSentCopy())
|
|
1336
|
+
return;
|
|
1337
|
+
const sentMailboxId = await this.getSentMailboxId();
|
|
1338
|
+
if (!sentMailboxId)
|
|
1339
|
+
return;
|
|
1340
|
+
const emailCreate = {
|
|
1341
|
+
mailboxIds: { [sentMailboxId]: true },
|
|
1342
|
+
from: [{ email: params.from }],
|
|
1343
|
+
to: [{ email: params.to }],
|
|
1344
|
+
subject: params.subject,
|
|
1345
|
+
bodyValues: {
|
|
1346
|
+
body: {
|
|
1347
|
+
value: params.text,
|
|
1348
|
+
charset: "utf-8"
|
|
1349
|
+
}
|
|
1350
|
+
},
|
|
1351
|
+
textBody: [{ partId: "body", type: "text/plain" }],
|
|
1352
|
+
keywords: { "$seen": true }
|
|
1353
|
+
};
|
|
1354
|
+
if (params.inReplyTo) {
|
|
1355
|
+
emailCreate["header:In-Reply-To:asText"] = ` ${sanitize(params.inReplyTo)}`;
|
|
1356
|
+
}
|
|
1357
|
+
if (params.messageId) {
|
|
1358
|
+
emailCreate["header:Message-ID:asText"] = ` ${sanitize(params.messageId)}`;
|
|
1359
|
+
}
|
|
1360
|
+
if (params.references) {
|
|
1361
|
+
emailCreate["header:References:asText"] = ` ${sanitize(params.references)}`;
|
|
1362
|
+
}
|
|
1363
|
+
for (const [name, value] of Object.entries(params.aampHeaders)) {
|
|
1364
|
+
emailCreate[`header:${name}:asText`] = ` ${value}`;
|
|
1365
|
+
}
|
|
1366
|
+
await this.jmapCall([
|
|
1367
|
+
["Email/set", { create: { sent1: emailCreate } }, "sent1"]
|
|
1368
|
+
]);
|
|
1369
|
+
}
|
|
1370
|
+
async saveToSentBestEffort(params) {
|
|
1371
|
+
if (!this.canPersistSentCopy())
|
|
1372
|
+
return;
|
|
1373
|
+
try {
|
|
1374
|
+
await this.saveToSent(params);
|
|
1375
|
+
} catch {
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Send a task.dispatch email.
|
|
1380
|
+
* Returns both the generated taskId and the SMTP Message-ID so callers can
|
|
1381
|
+
* store a reverse-index (messageId → taskId) for In-Reply-To thread routing.
|
|
1382
|
+
*/
|
|
1383
|
+
async sendTask(opts) {
|
|
1384
|
+
const taskId = opts.taskId ?? randomUUID();
|
|
1385
|
+
const aampHeaders = buildDispatchHeaders({
|
|
1386
|
+
taskId,
|
|
1387
|
+
priority: opts.priority,
|
|
1388
|
+
expiresAt: opts.expiresAt,
|
|
1389
|
+
contextLinks: opts.contextLinks ?? [],
|
|
1390
|
+
dispatchContext: opts.dispatchContext,
|
|
1391
|
+
parentTaskId: opts.parentTaskId
|
|
1392
|
+
});
|
|
1393
|
+
const sendMailOpts = {
|
|
1394
|
+
from: this.config.user,
|
|
1395
|
+
to: opts.to,
|
|
1396
|
+
subject: `[AAMP Task] ${sanitize(opts.title)}`,
|
|
1397
|
+
text: opts.rawBodyText ?? [
|
|
1398
|
+
`Task: ${opts.title}`,
|
|
1399
|
+
`Task ID: ${taskId}`,
|
|
1400
|
+
`Priority: ${opts.priority ?? "normal"}`,
|
|
1401
|
+
opts.expiresAt ? `Expires At: ${opts.expiresAt}` : `Expires At: none`,
|
|
1402
|
+
opts.contextLinks?.length ? `Context:
|
|
1403
|
+
${opts.contextLinks.map((l) => ` ${l}`).join("\n")}` : "",
|
|
1404
|
+
opts.bodyText ?? "",
|
|
1405
|
+
``,
|
|
1406
|
+
`--- This email was sent by AAMP. Reply directly to submit your result. ---`
|
|
1407
|
+
].filter(Boolean).join("\n"),
|
|
1408
|
+
headers: aampHeaders
|
|
1409
|
+
};
|
|
1410
|
+
if (opts.attachments?.length) {
|
|
1411
|
+
sendMailOpts.attachments = opts.attachments.map((a) => ({
|
|
1412
|
+
filename: a.filename,
|
|
1413
|
+
content: typeof a.content === "string" ? Buffer.from(a.content, "base64") : a.content,
|
|
1414
|
+
contentType: a.contentType
|
|
1415
|
+
}));
|
|
1416
|
+
}
|
|
1417
|
+
if (this.shouldUseHttpFallback(opts.to)) {
|
|
1418
|
+
const info2 = await this.sendViaHttp({
|
|
1419
|
+
to: opts.to,
|
|
1420
|
+
subject: sendMailOpts.subject,
|
|
1421
|
+
text: sendMailOpts.text,
|
|
1422
|
+
aampHeaders,
|
|
1423
|
+
attachments: opts.attachments?.map((a) => ({
|
|
1424
|
+
filename: a.filename,
|
|
1425
|
+
contentType: a.contentType,
|
|
1426
|
+
content: typeof a.content === "string" ? Buffer.from(a.content, "base64") : a.content
|
|
1427
|
+
}))
|
|
1428
|
+
});
|
|
1429
|
+
await this.saveToSentBestEffort({
|
|
1430
|
+
from: this.config.user,
|
|
1431
|
+
to: opts.to,
|
|
1432
|
+
subject: sendMailOpts.subject,
|
|
1433
|
+
text: sendMailOpts.text,
|
|
1434
|
+
aampHeaders,
|
|
1435
|
+
messageId: info2.messageId
|
|
1436
|
+
});
|
|
1437
|
+
return { taskId, messageId: info2.messageId ?? "" };
|
|
1438
|
+
}
|
|
1439
|
+
const info = await this.transport.sendMail(sendMailOpts);
|
|
1440
|
+
await this.saveToSentBestEffort({
|
|
1441
|
+
from: this.config.user,
|
|
1442
|
+
to: opts.to,
|
|
1443
|
+
subject: sendMailOpts.subject,
|
|
1444
|
+
text: sendMailOpts.text,
|
|
1445
|
+
aampHeaders,
|
|
1446
|
+
messageId: info.messageId
|
|
1447
|
+
});
|
|
1448
|
+
return { taskId, messageId: info.messageId ?? "" };
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Send a task.result email back to the dispatcher
|
|
1452
|
+
*/
|
|
1453
|
+
async sendResult(opts) {
|
|
1454
|
+
const aampHeaders = buildResultHeaders({
|
|
1455
|
+
taskId: opts.taskId,
|
|
1456
|
+
status: opts.status,
|
|
1457
|
+
output: opts.output,
|
|
1458
|
+
errorMsg: opts.errorMsg,
|
|
1459
|
+
structuredResult: opts.structuredResult
|
|
1460
|
+
});
|
|
1461
|
+
const mailOpts = {
|
|
1462
|
+
from: this.config.user,
|
|
1463
|
+
to: opts.to,
|
|
1464
|
+
subject: `[AAMP Result] Task ${opts.taskId} \u2014 ${opts.status}`,
|
|
1465
|
+
text: opts.rawBodyText ?? [
|
|
1466
|
+
`AAMP Task Result`,
|
|
1467
|
+
``,
|
|
1468
|
+
`Task ID: ${opts.taskId}`,
|
|
1469
|
+
`Status: ${opts.status}`,
|
|
1470
|
+
``,
|
|
1471
|
+
`Output:`,
|
|
1472
|
+
opts.output,
|
|
1473
|
+
opts.errorMsg ? `
|
|
1474
|
+
Error: ${opts.errorMsg}` : ""
|
|
1475
|
+
].filter((s) => s !== "").join("\n"),
|
|
1476
|
+
headers: aampHeaders
|
|
1477
|
+
};
|
|
1478
|
+
if (opts.inReplyTo) {
|
|
1479
|
+
mailOpts.inReplyTo = opts.inReplyTo;
|
|
1480
|
+
mailOpts.references = opts.inReplyTo;
|
|
1481
|
+
}
|
|
1482
|
+
if (opts.attachments?.length) {
|
|
1483
|
+
mailOpts.attachments = opts.attachments.map((a) => ({
|
|
1484
|
+
filename: a.filename,
|
|
1485
|
+
content: typeof a.content === "string" ? Buffer.from(a.content, "base64") : a.content,
|
|
1486
|
+
contentType: a.contentType
|
|
1487
|
+
}));
|
|
1488
|
+
}
|
|
1489
|
+
if (this.shouldUseHttpFallback(opts.to)) {
|
|
1490
|
+
const info2 = await this.sendViaHttp({
|
|
1491
|
+
to: opts.to,
|
|
1492
|
+
subject: mailOpts.subject,
|
|
1493
|
+
text: mailOpts.text,
|
|
1494
|
+
aampHeaders,
|
|
1495
|
+
attachments: opts.attachments?.map((a) => ({
|
|
1496
|
+
filename: a.filename,
|
|
1497
|
+
contentType: a.contentType,
|
|
1498
|
+
content: typeof a.content === "string" ? Buffer.from(a.content, "base64") : a.content
|
|
1499
|
+
}))
|
|
1500
|
+
});
|
|
1501
|
+
await this.saveToSentBestEffort({
|
|
1502
|
+
from: this.config.user,
|
|
1503
|
+
to: opts.to,
|
|
1504
|
+
subject: mailOpts.subject,
|
|
1505
|
+
text: mailOpts.text,
|
|
1506
|
+
aampHeaders,
|
|
1507
|
+
messageId: info2.messageId,
|
|
1508
|
+
inReplyTo: opts.inReplyTo,
|
|
1509
|
+
references: opts.inReplyTo
|
|
1510
|
+
});
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
const info = await this.transport.sendMail(mailOpts);
|
|
1514
|
+
await this.saveToSentBestEffort({
|
|
1515
|
+
from: this.config.user,
|
|
1516
|
+
to: opts.to,
|
|
1517
|
+
subject: mailOpts.subject,
|
|
1518
|
+
text: mailOpts.text,
|
|
1519
|
+
aampHeaders,
|
|
1520
|
+
messageId: info.messageId,
|
|
1521
|
+
inReplyTo: opts.inReplyTo,
|
|
1522
|
+
references: opts.inReplyTo
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Send a task.help_needed email when the agent is blocked
|
|
1527
|
+
*/
|
|
1528
|
+
async sendHelp(opts) {
|
|
1529
|
+
const aampHeaders = buildHelpHeaders({
|
|
1530
|
+
taskId: opts.taskId,
|
|
1531
|
+
question: opts.question,
|
|
1532
|
+
blockedReason: opts.blockedReason,
|
|
1533
|
+
suggestedOptions: opts.suggestedOptions
|
|
1534
|
+
});
|
|
1535
|
+
const helpMailOpts = {
|
|
1536
|
+
from: this.config.user,
|
|
1537
|
+
to: opts.to,
|
|
1538
|
+
subject: `[AAMP Help] Task ${opts.taskId} needs assistance`,
|
|
1539
|
+
text: opts.rawBodyText ?? [
|
|
1540
|
+
`AAMP Task Help Request`,
|
|
1541
|
+
``,
|
|
1542
|
+
`Task ID: ${opts.taskId}`,
|
|
1543
|
+
``,
|
|
1544
|
+
`Question: ${opts.question}`,
|
|
1545
|
+
``,
|
|
1546
|
+
`Blocked reason: ${opts.blockedReason}`,
|
|
1547
|
+
``,
|
|
1548
|
+
opts.suggestedOptions.length ? `Suggested options:
|
|
1549
|
+
${opts.suggestedOptions.map((o, i) => ` ${i + 1}. ${o}`).join("\n")}` : ""
|
|
1550
|
+
].filter(Boolean).join("\n"),
|
|
1551
|
+
headers: aampHeaders
|
|
1552
|
+
};
|
|
1553
|
+
if (opts.inReplyTo) {
|
|
1554
|
+
helpMailOpts.inReplyTo = opts.inReplyTo;
|
|
1555
|
+
helpMailOpts.references = opts.inReplyTo;
|
|
1556
|
+
}
|
|
1557
|
+
if (opts.attachments?.length) {
|
|
1558
|
+
helpMailOpts.attachments = opts.attachments.map((a) => ({
|
|
1559
|
+
filename: a.filename,
|
|
1560
|
+
content: typeof a.content === "string" ? Buffer.from(a.content, "base64") : a.content,
|
|
1561
|
+
contentType: a.contentType
|
|
1562
|
+
}));
|
|
1563
|
+
}
|
|
1564
|
+
if (this.shouldUseHttpFallback(opts.to)) {
|
|
1565
|
+
const info2 = await this.sendViaHttp({
|
|
1566
|
+
to: opts.to,
|
|
1567
|
+
subject: helpMailOpts.subject,
|
|
1568
|
+
text: helpMailOpts.text,
|
|
1569
|
+
aampHeaders,
|
|
1570
|
+
attachments: opts.attachments?.map((a) => ({
|
|
1571
|
+
filename: a.filename,
|
|
1572
|
+
contentType: a.contentType,
|
|
1573
|
+
content: typeof a.content === "string" ? Buffer.from(a.content, "base64") : a.content
|
|
1574
|
+
}))
|
|
1575
|
+
});
|
|
1576
|
+
await this.saveToSentBestEffort({
|
|
1577
|
+
from: this.config.user,
|
|
1578
|
+
to: opts.to,
|
|
1579
|
+
subject: helpMailOpts.subject,
|
|
1580
|
+
text: helpMailOpts.text,
|
|
1581
|
+
aampHeaders,
|
|
1582
|
+
messageId: info2.messageId,
|
|
1583
|
+
inReplyTo: opts.inReplyTo,
|
|
1584
|
+
references: opts.inReplyTo
|
|
1585
|
+
});
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
const info = await this.transport.sendMail(helpMailOpts);
|
|
1589
|
+
await this.saveToSentBestEffort({
|
|
1590
|
+
from: this.config.user,
|
|
1591
|
+
to: opts.to,
|
|
1592
|
+
subject: helpMailOpts.subject,
|
|
1593
|
+
text: helpMailOpts.text,
|
|
1594
|
+
aampHeaders,
|
|
1595
|
+
messageId: info.messageId,
|
|
1596
|
+
inReplyTo: opts.inReplyTo,
|
|
1597
|
+
references: opts.inReplyTo
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Send a task.cancel email to stop a previously dispatched task.
|
|
1602
|
+
*/
|
|
1603
|
+
async sendCancel(opts) {
|
|
1604
|
+
const aampHeaders = buildCancelHeaders({
|
|
1605
|
+
taskId: opts.taskId
|
|
1606
|
+
});
|
|
1607
|
+
const mailOpts = {
|
|
1608
|
+
from: this.config.user,
|
|
1609
|
+
to: opts.to,
|
|
1610
|
+
subject: `[AAMP Cancel] Task ${opts.taskId}`,
|
|
1611
|
+
text: opts.bodyText ?? "The dispatcher cancelled this task.",
|
|
1612
|
+
headers: aampHeaders
|
|
1613
|
+
};
|
|
1614
|
+
if (opts.inReplyTo) {
|
|
1615
|
+
mailOpts.inReplyTo = opts.inReplyTo;
|
|
1616
|
+
mailOpts.references = opts.inReplyTo;
|
|
1617
|
+
}
|
|
1618
|
+
if (this.shouldUseHttpFallback(opts.to)) {
|
|
1619
|
+
const info2 = await this.sendViaHttp({
|
|
1620
|
+
to: opts.to,
|
|
1621
|
+
subject: mailOpts.subject,
|
|
1622
|
+
text: mailOpts.text,
|
|
1623
|
+
aampHeaders
|
|
1624
|
+
});
|
|
1625
|
+
await this.saveToSentBestEffort({
|
|
1626
|
+
from: this.config.user,
|
|
1627
|
+
to: opts.to,
|
|
1628
|
+
subject: mailOpts.subject,
|
|
1629
|
+
text: mailOpts.text,
|
|
1630
|
+
aampHeaders,
|
|
1631
|
+
messageId: info2.messageId,
|
|
1632
|
+
inReplyTo: opts.inReplyTo,
|
|
1633
|
+
references: opts.inReplyTo
|
|
1634
|
+
});
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
const info = await this.transport.sendMail(mailOpts);
|
|
1638
|
+
await this.saveToSentBestEffort({
|
|
1639
|
+
from: this.config.user,
|
|
1640
|
+
to: opts.to,
|
|
1641
|
+
subject: mailOpts.subject,
|
|
1642
|
+
text: mailOpts.text,
|
|
1643
|
+
aampHeaders,
|
|
1644
|
+
messageId: info.messageId,
|
|
1645
|
+
inReplyTo: opts.inReplyTo,
|
|
1646
|
+
references: opts.inReplyTo
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Send a task.ack email to confirm receipt of a dispatch
|
|
1651
|
+
*/
|
|
1652
|
+
async sendAck(opts) {
|
|
1653
|
+
const aampHeaders = buildAckHeaders({ taskId: opts.taskId });
|
|
1654
|
+
const mailOpts = {
|
|
1655
|
+
from: this.config.user,
|
|
1656
|
+
to: opts.to,
|
|
1657
|
+
subject: `[AAMP ACK] Task ${opts.taskId}`,
|
|
1658
|
+
text: "",
|
|
1659
|
+
headers: aampHeaders
|
|
1660
|
+
};
|
|
1661
|
+
if (opts.inReplyTo) {
|
|
1662
|
+
mailOpts.inReplyTo = opts.inReplyTo;
|
|
1663
|
+
mailOpts.references = opts.inReplyTo;
|
|
1664
|
+
}
|
|
1665
|
+
if (this.shouldUseHttpFallback(opts.to)) {
|
|
1666
|
+
const info2 = await this.sendViaHttp({
|
|
1667
|
+
to: opts.to,
|
|
1668
|
+
subject: mailOpts.subject,
|
|
1669
|
+
text: mailOpts.text,
|
|
1670
|
+
aampHeaders
|
|
1671
|
+
});
|
|
1672
|
+
await this.saveToSentBestEffort({
|
|
1673
|
+
from: this.config.user,
|
|
1674
|
+
to: opts.to,
|
|
1675
|
+
subject: mailOpts.subject,
|
|
1676
|
+
text: mailOpts.text,
|
|
1677
|
+
aampHeaders,
|
|
1678
|
+
messageId: info2.messageId,
|
|
1679
|
+
inReplyTo: opts.inReplyTo,
|
|
1680
|
+
references: opts.inReplyTo
|
|
1681
|
+
});
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const info = await this.transport.sendMail(mailOpts);
|
|
1685
|
+
await this.saveToSentBestEffort({
|
|
1686
|
+
from: this.config.user,
|
|
1687
|
+
to: opts.to,
|
|
1688
|
+
subject: mailOpts.subject,
|
|
1689
|
+
text: mailOpts.text,
|
|
1690
|
+
aampHeaders,
|
|
1691
|
+
messageId: info.messageId,
|
|
1692
|
+
inReplyTo: opts.inReplyTo,
|
|
1693
|
+
references: opts.inReplyTo
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
async sendStreamOpened(opts) {
|
|
1697
|
+
const aampHeaders = buildStreamOpenedHeaders({
|
|
1698
|
+
taskId: opts.taskId,
|
|
1699
|
+
streamId: opts.streamId
|
|
1700
|
+
});
|
|
1701
|
+
const mailOpts = {
|
|
1702
|
+
from: this.config.user,
|
|
1703
|
+
to: opts.to,
|
|
1704
|
+
subject: `[AAMP Stream] Task ${opts.taskId}`,
|
|
1705
|
+
text: `AAMP task stream is ready.
|
|
1706
|
+
|
|
1707
|
+
Task ID: ${opts.taskId}
|
|
1708
|
+
Stream ID: ${opts.streamId}`,
|
|
1709
|
+
headers: aampHeaders
|
|
1710
|
+
};
|
|
1711
|
+
if (opts.inReplyTo) {
|
|
1712
|
+
mailOpts.inReplyTo = opts.inReplyTo;
|
|
1713
|
+
mailOpts.references = opts.inReplyTo;
|
|
1714
|
+
}
|
|
1715
|
+
if (this.shouldUseHttpFallback(opts.to)) {
|
|
1716
|
+
const info2 = await this.sendViaHttp({
|
|
1717
|
+
to: opts.to,
|
|
1718
|
+
subject: mailOpts.subject,
|
|
1719
|
+
text: mailOpts.text,
|
|
1720
|
+
aampHeaders
|
|
1721
|
+
});
|
|
1722
|
+
await this.saveToSentBestEffort({
|
|
1723
|
+
from: this.config.user,
|
|
1724
|
+
to: opts.to,
|
|
1725
|
+
subject: mailOpts.subject,
|
|
1726
|
+
text: mailOpts.text,
|
|
1727
|
+
aampHeaders,
|
|
1728
|
+
messageId: info2.messageId,
|
|
1729
|
+
inReplyTo: opts.inReplyTo,
|
|
1730
|
+
references: opts.inReplyTo
|
|
1731
|
+
});
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
const info = await this.transport.sendMail(mailOpts);
|
|
1735
|
+
await this.saveToSentBestEffort({
|
|
1736
|
+
from: this.config.user,
|
|
1737
|
+
to: opts.to,
|
|
1738
|
+
subject: mailOpts.subject,
|
|
1739
|
+
text: mailOpts.text,
|
|
1740
|
+
aampHeaders,
|
|
1741
|
+
messageId: info.messageId,
|
|
1742
|
+
inReplyTo: opts.inReplyTo,
|
|
1743
|
+
references: opts.inReplyTo
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
async sendCardQuery(opts) {
|
|
1747
|
+
const taskId = opts.taskId ?? randomUUID();
|
|
1748
|
+
const aampHeaders = buildCardQueryHeaders({ taskId });
|
|
1749
|
+
const mailOpts = {
|
|
1750
|
+
from: this.config.user,
|
|
1751
|
+
to: opts.to,
|
|
1752
|
+
subject: `[AAMP Card Query] ${taskId}`,
|
|
1753
|
+
text: opts.bodyText?.trim() || "Please share your agent card and capability details.",
|
|
1754
|
+
headers: aampHeaders
|
|
1755
|
+
};
|
|
1756
|
+
if (opts.inReplyTo) {
|
|
1757
|
+
mailOpts.inReplyTo = opts.inReplyTo;
|
|
1758
|
+
mailOpts.references = opts.inReplyTo;
|
|
1759
|
+
}
|
|
1760
|
+
if (this.shouldUseHttpFallback(opts.to)) {
|
|
1761
|
+
const info2 = await this.sendViaHttp({
|
|
1762
|
+
to: opts.to,
|
|
1763
|
+
subject: mailOpts.subject,
|
|
1764
|
+
text: mailOpts.text,
|
|
1765
|
+
aampHeaders
|
|
1766
|
+
});
|
|
1767
|
+
await this.saveToSentBestEffort({
|
|
1768
|
+
from: this.config.user,
|
|
1769
|
+
to: opts.to,
|
|
1770
|
+
subject: mailOpts.subject,
|
|
1771
|
+
text: mailOpts.text,
|
|
1772
|
+
aampHeaders,
|
|
1773
|
+
messageId: info2.messageId,
|
|
1774
|
+
inReplyTo: opts.inReplyTo,
|
|
1775
|
+
references: opts.inReplyTo
|
|
1776
|
+
});
|
|
1777
|
+
return { taskId, messageId: info2.messageId ?? "" };
|
|
1778
|
+
}
|
|
1779
|
+
const info = await this.transport.sendMail(mailOpts);
|
|
1780
|
+
await this.saveToSentBestEffort({
|
|
1781
|
+
from: this.config.user,
|
|
1782
|
+
to: opts.to,
|
|
1783
|
+
subject: mailOpts.subject,
|
|
1784
|
+
text: mailOpts.text,
|
|
1785
|
+
aampHeaders,
|
|
1786
|
+
messageId: info.messageId,
|
|
1787
|
+
inReplyTo: opts.inReplyTo,
|
|
1788
|
+
references: opts.inReplyTo
|
|
1789
|
+
});
|
|
1790
|
+
return { taskId, messageId: info.messageId ?? "" };
|
|
1791
|
+
}
|
|
1792
|
+
async sendCardResponse(opts) {
|
|
1793
|
+
const aampHeaders = buildCardResponseHeaders({
|
|
1794
|
+
taskId: opts.taskId,
|
|
1795
|
+
summary: opts.summary
|
|
1796
|
+
});
|
|
1797
|
+
const mailOpts = {
|
|
1798
|
+
from: this.config.user,
|
|
1799
|
+
to: opts.to,
|
|
1800
|
+
subject: `[AAMP Card] ${sanitize(opts.summary)}`,
|
|
1801
|
+
text: opts.bodyText,
|
|
1802
|
+
headers: aampHeaders
|
|
1803
|
+
};
|
|
1804
|
+
if (opts.inReplyTo) {
|
|
1805
|
+
mailOpts.inReplyTo = opts.inReplyTo;
|
|
1806
|
+
mailOpts.references = opts.inReplyTo;
|
|
1807
|
+
}
|
|
1808
|
+
if (this.shouldUseHttpFallback(opts.to)) {
|
|
1809
|
+
const info2 = await this.sendViaHttp({
|
|
1810
|
+
to: opts.to,
|
|
1811
|
+
subject: mailOpts.subject,
|
|
1812
|
+
text: mailOpts.text,
|
|
1813
|
+
aampHeaders
|
|
1814
|
+
});
|
|
1815
|
+
await this.saveToSentBestEffort({
|
|
1816
|
+
from: this.config.user,
|
|
1817
|
+
to: opts.to,
|
|
1818
|
+
subject: mailOpts.subject,
|
|
1819
|
+
text: mailOpts.text,
|
|
1820
|
+
aampHeaders,
|
|
1821
|
+
messageId: info2.messageId,
|
|
1822
|
+
inReplyTo: opts.inReplyTo,
|
|
1823
|
+
references: opts.inReplyTo
|
|
1824
|
+
});
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
const info = await this.transport.sendMail(mailOpts);
|
|
1828
|
+
await this.saveToSentBestEffort({
|
|
1829
|
+
from: this.config.user,
|
|
1830
|
+
to: opts.to,
|
|
1831
|
+
subject: mailOpts.subject,
|
|
1832
|
+
text: mailOpts.text,
|
|
1833
|
+
aampHeaders,
|
|
1834
|
+
messageId: info.messageId,
|
|
1835
|
+
inReplyTo: opts.inReplyTo,
|
|
1836
|
+
references: opts.inReplyTo
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* Verify SMTP connection
|
|
1841
|
+
*/
|
|
1842
|
+
async verify() {
|
|
1843
|
+
try {
|
|
1844
|
+
await this.transport.verify();
|
|
1845
|
+
return true;
|
|
1846
|
+
} catch {
|
|
1847
|
+
return false;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
close() {
|
|
1851
|
+
this.transport.close();
|
|
1852
|
+
}
|
|
1853
|
+
};
|
|
1854
|
+
|
|
1855
|
+
// ../sdk/dist/thread.js
|
|
1856
|
+
function singleLine(value, maxLength = 220) {
|
|
1857
|
+
const normalized = (value ?? "").replace(/\s+/g, " ").trim();
|
|
1858
|
+
if (!normalized)
|
|
1859
|
+
return "";
|
|
1860
|
+
if (normalized.length <= maxLength)
|
|
1861
|
+
return normalized;
|
|
1862
|
+
return `${normalized.slice(0, maxLength - 1)}\u2026`;
|
|
1863
|
+
}
|
|
1864
|
+
function formatTimestamp(value) {
|
|
1865
|
+
const date = new Date(value);
|
|
1866
|
+
if (Number.isNaN(date.getTime()))
|
|
1867
|
+
return value;
|
|
1868
|
+
return date.toISOString().slice(0, 16).replace("T", " ");
|
|
1869
|
+
}
|
|
1870
|
+
function renderEventLine(event) {
|
|
1871
|
+
const from = event.from.split("@")[0] || event.from;
|
|
1872
|
+
const timestamp = formatTimestamp(event.createdAt);
|
|
1873
|
+
if (event.intent === "task.dispatch") {
|
|
1874
|
+
const summary = singleLine(event.bodyText) || singleLine(event.title) || "Task dispatched";
|
|
1875
|
+
return `[${timestamp}] ${from} dispatched: ${summary}`;
|
|
1876
|
+
}
|
|
1877
|
+
if (event.intent === "task.help_needed") {
|
|
1878
|
+
const question = singleLine(event.question) || "Asked for help";
|
|
1879
|
+
const reason = singleLine(event.blockedReason);
|
|
1880
|
+
return `[${timestamp}] ${from} asked for help: ${question}${reason ? ` (reason: ${reason})` : ""}`;
|
|
1881
|
+
}
|
|
1882
|
+
if (event.intent === "task.result") {
|
|
1883
|
+
const output3 = singleLine(event.output) || singleLine(event.bodyText) || "Sent a result";
|
|
1884
|
+
return `[${timestamp}] ${from} replied: ${output3}`;
|
|
1885
|
+
}
|
|
1886
|
+
if (event.intent === "task.cancel") {
|
|
1887
|
+
const body = singleLine(event.bodyText) || "Cancelled the task";
|
|
1888
|
+
return `[${timestamp}] ${from} cancelled the task: ${body}`;
|
|
1889
|
+
}
|
|
1890
|
+
if (event.intent === "task.ack") {
|
|
1891
|
+
return `[${timestamp}] ${from} acknowledged the task`;
|
|
1892
|
+
}
|
|
1893
|
+
return `[${timestamp}] ${from}: ${singleLine(event.bodyText) || event.intent}`;
|
|
1894
|
+
}
|
|
1895
|
+
function renderThreadHistoryForAgent(events, options = {}) {
|
|
1896
|
+
const filtered = events.filter((event) => event.intent !== "task.stream.opened");
|
|
1897
|
+
if (filtered.length === 0)
|
|
1898
|
+
return "";
|
|
1899
|
+
const maxEvents = Math.max(1, options.maxEvents ?? 8);
|
|
1900
|
+
const visible = filtered.slice(-maxEvents);
|
|
1901
|
+
const omitted = filtered.length - visible.length;
|
|
1902
|
+
return [
|
|
1903
|
+
"Prior thread context:",
|
|
1904
|
+
...omitted > 0 ? [`(${omitted} earlier event(s) omitted)`] : [],
|
|
1905
|
+
...visible.map((event) => `- ${renderEventLine(event)}`)
|
|
1906
|
+
].join("\n");
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// ../sdk/dist/client.js
|
|
1910
|
+
function buildRegisteredCommandDispatchPayload(opts) {
|
|
1911
|
+
const command = opts.command.trim();
|
|
1912
|
+
if (!command) {
|
|
1913
|
+
throw new Error("Registered command name cannot be empty.");
|
|
1914
|
+
}
|
|
1915
|
+
if (opts.args != null && (typeof opts.args !== "object" || Array.isArray(opts.args))) {
|
|
1916
|
+
throw new Error("Registered command args must be an object when provided.");
|
|
1917
|
+
}
|
|
1918
|
+
if (opts.inputs) {
|
|
1919
|
+
for (const input3 of opts.inputs) {
|
|
1920
|
+
if (!input3.slot?.trim() || !input3.attachmentName?.trim()) {
|
|
1921
|
+
throw new Error("Each registered command input must include slot and attachmentName.");
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
return {
|
|
1926
|
+
kind: "registered-command/v1",
|
|
1927
|
+
command,
|
|
1928
|
+
...opts.args && Object.keys(opts.args).length > 0 ? { args: opts.args } : {},
|
|
1929
|
+
...opts.inputs?.length ? { inputs: opts.inputs } : {},
|
|
1930
|
+
stream: { mode: opts.streamMode ?? "full" }
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
var DEFAULT_TASK_DISPATCH_CONCURRENCY = 10;
|
|
1934
|
+
function normalizeTaskDispatchConcurrency(value) {
|
|
1935
|
+
if (value == null)
|
|
1936
|
+
return DEFAULT_TASK_DISPATCH_CONCURRENCY;
|
|
1937
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
|
|
1938
|
+
throw new Error("taskDispatchConcurrency must be a positive integer");
|
|
1939
|
+
}
|
|
1940
|
+
return value;
|
|
1941
|
+
}
|
|
1942
|
+
var AampClient = class _AampClient extends TinyEmitter {
|
|
1943
|
+
jmapClient;
|
|
1944
|
+
smtpSender;
|
|
1945
|
+
config;
|
|
1946
|
+
taskDispatchConcurrency;
|
|
1947
|
+
pendingTaskDispatches = [];
|
|
1948
|
+
activeTaskDispatchCount = 0;
|
|
1949
|
+
streamAppendQueues = /* @__PURE__ */ new Map();
|
|
1950
|
+
constructor(config) {
|
|
1951
|
+
super();
|
|
1952
|
+
this.config = config;
|
|
1953
|
+
this.taskDispatchConcurrency = normalizeTaskDispatchConcurrency(config.taskDispatchConcurrency);
|
|
1954
|
+
const mailboxToken = config.mailboxToken;
|
|
1955
|
+
const resolvedBaseUrl = config.baseUrl;
|
|
1956
|
+
const derived = deriveMailboxServiceDefaults(config.email, resolvedBaseUrl);
|
|
1957
|
+
const smtpHost = config.smtpHost ?? derived.smtpHost;
|
|
1958
|
+
let password;
|
|
1959
|
+
try {
|
|
1960
|
+
const decoded = Buffer.from(mailboxToken, "base64").toString("utf-8");
|
|
1961
|
+
const colonIdx = decoded.indexOf(":");
|
|
1962
|
+
if (colonIdx < 0)
|
|
1963
|
+
throw new Error("Invalid mailboxToken format: expected base64(email:password)");
|
|
1964
|
+
password = decoded.slice(colonIdx + 1);
|
|
1965
|
+
if (!password)
|
|
1966
|
+
throw new Error("Invalid mailboxToken: empty password");
|
|
1967
|
+
} catch (err) {
|
|
1968
|
+
if (err instanceof Error && err.message.startsWith("Invalid mailboxToken"))
|
|
1969
|
+
throw err;
|
|
1970
|
+
throw new Error(`Failed to decode mailboxToken: ${err.message}`);
|
|
1971
|
+
}
|
|
1972
|
+
this.jmapClient = new JmapPushClient({
|
|
1973
|
+
email: config.email,
|
|
1974
|
+
password: password ?? config.smtpPassword,
|
|
1975
|
+
jmapUrl: resolvedBaseUrl,
|
|
1976
|
+
reconnectInterval: config.reconnectInterval ?? 5e3,
|
|
1977
|
+
rejectUnauthorized: config.rejectUnauthorized
|
|
1978
|
+
});
|
|
1979
|
+
this.smtpSender = new SmtpSender({
|
|
1980
|
+
host: smtpHost,
|
|
1981
|
+
port: config.smtpPort ?? 587,
|
|
1982
|
+
user: config.email,
|
|
1983
|
+
password: config.smtpPassword,
|
|
1984
|
+
httpBaseUrl: config.httpSendBaseUrl ?? resolvedBaseUrl,
|
|
1985
|
+
authToken: mailboxToken,
|
|
1986
|
+
rejectUnauthorized: config.rejectUnauthorized
|
|
1987
|
+
});
|
|
1988
|
+
this.jmapClient.on("task.dispatch", (task) => {
|
|
1989
|
+
this.enqueueTaskDispatch(task);
|
|
1990
|
+
});
|
|
1991
|
+
this.jmapClient.on("task.cancel", (task) => {
|
|
1992
|
+
this.emit("task.cancel", task);
|
|
1993
|
+
});
|
|
1994
|
+
this.jmapClient.on("task.result", (result) => {
|
|
1995
|
+
this.emit("task.result", result);
|
|
1996
|
+
});
|
|
1997
|
+
this.jmapClient.on("task.help_needed", (help) => {
|
|
1998
|
+
this.emit("task.help_needed", help);
|
|
1999
|
+
});
|
|
2000
|
+
this.jmapClient.on("task.ack", (ack) => {
|
|
2001
|
+
this.emit("task.ack", ack);
|
|
2002
|
+
});
|
|
2003
|
+
this.jmapClient.on("task.stream.opened", (stream) => {
|
|
2004
|
+
this.emit("task.stream.opened", stream);
|
|
2005
|
+
});
|
|
2006
|
+
this.jmapClient.on("card.query", (query) => {
|
|
2007
|
+
this.emit("card.query", query);
|
|
2008
|
+
});
|
|
2009
|
+
this.jmapClient.on("card.response", (response) => {
|
|
2010
|
+
this.emit("card.response", response);
|
|
2011
|
+
});
|
|
2012
|
+
this.jmapClient.on("_autoAck", async ({ to, taskId, messageId }) => {
|
|
2013
|
+
try {
|
|
2014
|
+
await this.smtpSender.sendAck({ to, taskId, inReplyTo: messageId });
|
|
2015
|
+
} catch (err) {
|
|
2016
|
+
console.warn(`[AAMP] Failed to send ACK for task ${taskId}: ${err.message}`);
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
this.jmapClient.on("reply", (reply) => {
|
|
2020
|
+
this.emit("reply", reply);
|
|
2021
|
+
});
|
|
2022
|
+
this.jmapClient.on("connected", () => {
|
|
2023
|
+
this.emit("connected");
|
|
2024
|
+
});
|
|
2025
|
+
this.jmapClient.on("disconnected", (reason) => {
|
|
2026
|
+
this.emit("disconnected", reason);
|
|
2027
|
+
});
|
|
2028
|
+
this.jmapClient.on("error", (err) => {
|
|
2029
|
+
this.emit("error", err);
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
static fromMailboxIdentity(config) {
|
|
2033
|
+
const derived = deriveMailboxServiceDefaults(config.email, config.baseUrl);
|
|
2034
|
+
return new _AampClient({
|
|
2035
|
+
email: config.email,
|
|
2036
|
+
mailboxToken: Buffer.from(`${config.email}:${config.smtpPassword}`).toString("base64"),
|
|
2037
|
+
baseUrl: derived.httpBaseUrl ?? `https://${config.email.split("@")[1] ?? "localhost"}`,
|
|
2038
|
+
smtpHost: derived.smtpHost,
|
|
2039
|
+
smtpPort: config.smtpPort ?? 587,
|
|
2040
|
+
smtpPassword: config.smtpPassword,
|
|
2041
|
+
reconnectInterval: config.reconnectInterval,
|
|
2042
|
+
taskDispatchConcurrency: config.taskDispatchConcurrency,
|
|
2043
|
+
rejectUnauthorized: config.rejectUnauthorized
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
static async discoverAampService(aampHost) {
|
|
2047
|
+
const base = aampHost.replace(/\/$/, "");
|
|
2048
|
+
const res = await fetch(`${base}/.well-known/aamp`);
|
|
2049
|
+
if (!res.ok) {
|
|
2050
|
+
throw new Error(`AAMP discovery failed: ${res.status} ${res.statusText}`);
|
|
2051
|
+
}
|
|
2052
|
+
const discovery = await res.json();
|
|
2053
|
+
if (!discovery.api?.url) {
|
|
2054
|
+
throw new Error("AAMP discovery did not return api.url");
|
|
2055
|
+
}
|
|
2056
|
+
return discovery;
|
|
2057
|
+
}
|
|
2058
|
+
static async callDiscoveredApi(base, opts) {
|
|
2059
|
+
const discovery = await _AampClient.discoverAampService(base);
|
|
2060
|
+
const apiUrl = new URL(discovery.api.url, `${base}/`);
|
|
2061
|
+
apiUrl.searchParams.set("action", opts.action);
|
|
2062
|
+
for (const [key, value] of Object.entries(opts.query ?? {})) {
|
|
2063
|
+
if (value == null)
|
|
2064
|
+
continue;
|
|
2065
|
+
apiUrl.searchParams.set(key, String(value));
|
|
2066
|
+
}
|
|
2067
|
+
return fetch(apiUrl, {
|
|
2068
|
+
method: opts.method ?? "GET",
|
|
2069
|
+
headers: {
|
|
2070
|
+
...opts.authToken ? { Authorization: `Basic ${opts.authToken}` } : {},
|
|
2071
|
+
...opts.body ? { "Content-Type": "application/json" } : {}
|
|
2072
|
+
},
|
|
2073
|
+
...opts.body ? { body: JSON.stringify(opts.body) } : {}
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
static async registerMailbox(opts) {
|
|
2077
|
+
const base = opts.aampHost.replace(/\/$/, "");
|
|
2078
|
+
const registerRes = await _AampClient.callDiscoveredApi(base, {
|
|
2079
|
+
action: "aamp.mailbox.register",
|
|
2080
|
+
method: "POST",
|
|
2081
|
+
body: {
|
|
2082
|
+
slug: opts.slug,
|
|
2083
|
+
description: opts.description
|
|
2084
|
+
}
|
|
2085
|
+
});
|
|
2086
|
+
if (!registerRes.ok) {
|
|
2087
|
+
const body = await registerRes.text().catch(() => "");
|
|
2088
|
+
throw new Error(`Mailbox registration failed: ${registerRes.status} ${body || registerRes.statusText}`);
|
|
2089
|
+
}
|
|
2090
|
+
const registerData = await registerRes.json();
|
|
2091
|
+
if (!registerData.registrationCode) {
|
|
2092
|
+
throw new Error("Mailbox registration succeeded but no registrationCode was returned");
|
|
2093
|
+
}
|
|
2094
|
+
const credsRes = await _AampClient.callDiscoveredApi(base, {
|
|
2095
|
+
action: "aamp.mailbox.credentials",
|
|
2096
|
+
query: { code: registerData.registrationCode }
|
|
2097
|
+
});
|
|
2098
|
+
if (!credsRes.ok) {
|
|
2099
|
+
const body = await credsRes.text().catch(() => "");
|
|
2100
|
+
throw new Error(`Mailbox credential exchange failed: ${credsRes.status} ${body || credsRes.statusText}`);
|
|
2101
|
+
}
|
|
2102
|
+
const creds = await credsRes.json();
|
|
2103
|
+
if (!creds.email || !creds.mailbox?.token || !creds.smtp?.password) {
|
|
2104
|
+
throw new Error("Mailbox credential exchange returned an incomplete identity payload");
|
|
2105
|
+
}
|
|
2106
|
+
return {
|
|
2107
|
+
email: creds.email,
|
|
2108
|
+
mailboxToken: creds.mailbox.token,
|
|
2109
|
+
smtpPassword: creds.smtp.password,
|
|
2110
|
+
baseUrl: base
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
static async checkMailbox(opts) {
|
|
2114
|
+
const base = opts.aampHost.replace(/\/$/, "");
|
|
2115
|
+
const res = await _AampClient.callDiscoveredApi(base, {
|
|
2116
|
+
action: "aamp.mailbox.check",
|
|
2117
|
+
query: { email: opts.email }
|
|
2118
|
+
});
|
|
2119
|
+
if (!res.ok) {
|
|
2120
|
+
const body = await res.text().catch(() => "");
|
|
2121
|
+
throw new Error(`Mailbox check failed: ${res.status} ${body || res.statusText}`);
|
|
2122
|
+
}
|
|
2123
|
+
const payload = await res.json();
|
|
2124
|
+
return {
|
|
2125
|
+
aamp: Boolean(payload.aamp),
|
|
2126
|
+
...payload.domain ? { domain: payload.domain } : {}
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
// =====================================================
|
|
2130
|
+
// Lifecycle
|
|
2131
|
+
// =====================================================
|
|
2132
|
+
/**
|
|
2133
|
+
* Connect to JMAP and start listening for tasks
|
|
2134
|
+
*/
|
|
2135
|
+
async connect() {
|
|
2136
|
+
await this.jmapClient.start();
|
|
2137
|
+
}
|
|
2138
|
+
/**
|
|
2139
|
+
* Disconnect and clean up
|
|
2140
|
+
*/
|
|
2141
|
+
disconnect() {
|
|
2142
|
+
this.jmapClient.stop();
|
|
2143
|
+
this.smtpSender.close();
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Returns true if the JMAP connection is active
|
|
2147
|
+
*/
|
|
2148
|
+
isConnected() {
|
|
2149
|
+
return this.jmapClient.isConnected();
|
|
2150
|
+
}
|
|
2151
|
+
isUsingPollingFallback() {
|
|
2152
|
+
return this.jmapClient.isUsingPollingFallback();
|
|
2153
|
+
}
|
|
2154
|
+
// =====================================================
|
|
2155
|
+
// Sending
|
|
2156
|
+
// =====================================================
|
|
2157
|
+
/**
|
|
2158
|
+
* Send a task.dispatch email to an agent.
|
|
2159
|
+
* Returns the generated taskId and the SMTP Message-ID.
|
|
2160
|
+
* Store messageId → taskId in Redis/DB to support In-Reply-To thread routing
|
|
2161
|
+
* for human replies that arrive without X-AAMP headers.
|
|
2162
|
+
*/
|
|
2163
|
+
async sendTask(opts) {
|
|
2164
|
+
return this.smtpSender.sendTask(opts);
|
|
2165
|
+
}
|
|
2166
|
+
async sendRegisteredCommand(opts) {
|
|
2167
|
+
const payload = buildRegisteredCommandDispatchPayload(opts);
|
|
2168
|
+
return this.smtpSender.sendTask({
|
|
2169
|
+
to: opts.to,
|
|
2170
|
+
taskId: opts.taskId,
|
|
2171
|
+
title: opts.title?.trim() || `Registered command: ${payload.command}`,
|
|
2172
|
+
rawBodyText: JSON.stringify(payload, null, 2),
|
|
2173
|
+
priority: opts.priority,
|
|
2174
|
+
expiresAt: opts.expiresAt,
|
|
2175
|
+
sessionKey: opts.sessionKey,
|
|
2176
|
+
contextLinks: opts.contextLinks,
|
|
2177
|
+
dispatchContext: opts.dispatchContext,
|
|
2178
|
+
parentTaskId: opts.parentTaskId,
|
|
2179
|
+
attachments: opts.attachments
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
2182
|
+
async sendCancel(opts) {
|
|
2183
|
+
return this.smtpSender.sendCancel(opts);
|
|
2184
|
+
}
|
|
2185
|
+
/**
|
|
2186
|
+
* Send a task.result email (agent → system/dispatcher)
|
|
2187
|
+
*/
|
|
2188
|
+
async sendResult(opts) {
|
|
2189
|
+
return this.smtpSender.sendResult(opts);
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* Send a task.help_needed email when the agent needs human assistance
|
|
2193
|
+
*/
|
|
2194
|
+
async sendHelp(opts) {
|
|
2195
|
+
return this.smtpSender.sendHelp(opts);
|
|
2196
|
+
}
|
|
2197
|
+
async sendStreamOpened(opts) {
|
|
2198
|
+
return this.smtpSender.sendStreamOpened(opts);
|
|
2199
|
+
}
|
|
2200
|
+
async sendCardQuery(opts) {
|
|
2201
|
+
return this.smtpSender.sendCardQuery(opts);
|
|
2202
|
+
}
|
|
2203
|
+
async sendCardResponse(opts) {
|
|
2204
|
+
return this.smtpSender.sendCardResponse(opts);
|
|
2205
|
+
}
|
|
2206
|
+
async updateDirectoryProfile(opts) {
|
|
2207
|
+
const base = this.config.baseUrl;
|
|
2208
|
+
const mailboxToken = this.config.mailboxToken;
|
|
2209
|
+
const res = await _AampClient.callDiscoveredApi(base, {
|
|
2210
|
+
action: "aamp.directory.upsert",
|
|
2211
|
+
method: "POST",
|
|
2212
|
+
authToken: mailboxToken,
|
|
2213
|
+
body: opts
|
|
2214
|
+
});
|
|
2215
|
+
if (!res.ok) {
|
|
2216
|
+
const body = await res.text().catch(() => "");
|
|
2217
|
+
throw new Error(`Directory profile update failed: ${res.status} ${body || res.statusText}`);
|
|
2218
|
+
}
|
|
2219
|
+
const data = await res.json();
|
|
2220
|
+
return data.profile;
|
|
2221
|
+
}
|
|
2222
|
+
async listDirectory(opts = {}) {
|
|
2223
|
+
const base = this.config.baseUrl;
|
|
2224
|
+
const mailboxToken = this.config.mailboxToken;
|
|
2225
|
+
const res = await _AampClient.callDiscoveredApi(base, {
|
|
2226
|
+
action: "aamp.directory.list",
|
|
2227
|
+
authToken: mailboxToken,
|
|
2228
|
+
query: {
|
|
2229
|
+
scope: opts.scope,
|
|
2230
|
+
includeSelf: opts.includeSelf,
|
|
2231
|
+
limit: opts.limit
|
|
2232
|
+
}
|
|
2233
|
+
});
|
|
2234
|
+
if (!res.ok) {
|
|
2235
|
+
const body = await res.text().catch(() => "");
|
|
2236
|
+
throw new Error(`Directory list failed: ${res.status} ${body || res.statusText}`);
|
|
2237
|
+
}
|
|
2238
|
+
const data = await res.json();
|
|
2239
|
+
return data.agents;
|
|
2240
|
+
}
|
|
2241
|
+
async searchDirectory(opts) {
|
|
2242
|
+
const base = this.config.baseUrl;
|
|
2243
|
+
const mailboxToken = this.config.mailboxToken;
|
|
2244
|
+
const res = await _AampClient.callDiscoveredApi(base, {
|
|
2245
|
+
action: "aamp.directory.search",
|
|
2246
|
+
authToken: mailboxToken,
|
|
2247
|
+
query: {
|
|
2248
|
+
q: opts.query,
|
|
2249
|
+
scope: opts.scope,
|
|
2250
|
+
includeSelf: opts.includeSelf,
|
|
2251
|
+
limit: opts.limit
|
|
2252
|
+
}
|
|
2253
|
+
});
|
|
2254
|
+
if (!res.ok) {
|
|
2255
|
+
const body = await res.text().catch(() => "");
|
|
2256
|
+
throw new Error(`Directory search failed: ${res.status} ${body || res.statusText}`);
|
|
2257
|
+
}
|
|
2258
|
+
const data = await res.json();
|
|
2259
|
+
return data.agents;
|
|
2260
|
+
}
|
|
2261
|
+
async getThreadHistory(taskId, opts = {}) {
|
|
2262
|
+
const base = this.config.baseUrl;
|
|
2263
|
+
const mailboxToken = this.config.mailboxToken;
|
|
2264
|
+
const res = await _AampClient.callDiscoveredApi(base, {
|
|
2265
|
+
action: "aamp.mailbox.thread",
|
|
2266
|
+
authToken: mailboxToken,
|
|
2267
|
+
query: {
|
|
2268
|
+
taskId,
|
|
2269
|
+
includeStreamOpened: opts.includeStreamOpened
|
|
2270
|
+
}
|
|
2271
|
+
});
|
|
2272
|
+
if (!res.ok) {
|
|
2273
|
+
const body = await res.text().catch(() => "");
|
|
2274
|
+
throw new Error(`Thread history fetch failed: ${res.status} ${body || res.statusText}`);
|
|
2275
|
+
}
|
|
2276
|
+
const data = await res.json();
|
|
2277
|
+
return {
|
|
2278
|
+
taskId: data.taskId,
|
|
2279
|
+
events: Array.isArray(data.events) ? data.events : []
|
|
2280
|
+
};
|
|
2281
|
+
}
|
|
2282
|
+
async hydrateTaskDispatch(task) {
|
|
2283
|
+
const history = await this.getThreadHistory(task.taskId);
|
|
2284
|
+
const priorEvents = history.events.filter((event) => event.messageId !== task.messageId);
|
|
2285
|
+
return {
|
|
2286
|
+
...task,
|
|
2287
|
+
threadHistory: priorEvents,
|
|
2288
|
+
threadContextText: renderThreadHistoryForAgent(priorEvents)
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2291
|
+
async resolveStreamCapability() {
|
|
2292
|
+
const discovery = await _AampClient.discoverAampService(this.config.baseUrl);
|
|
2293
|
+
const stream = discovery.capabilities?.stream;
|
|
2294
|
+
if (!stream?.transport) {
|
|
2295
|
+
throw new Error("AAMP stream capability is not available on this service");
|
|
2296
|
+
}
|
|
2297
|
+
return stream;
|
|
2298
|
+
}
|
|
2299
|
+
async createStream(opts) {
|
|
2300
|
+
const stream = await this.resolveStreamCapability();
|
|
2301
|
+
const res = await _AampClient.callDiscoveredApi(this.config.baseUrl, {
|
|
2302
|
+
action: stream.createAction ?? "aamp.stream.create",
|
|
2303
|
+
method: "POST",
|
|
2304
|
+
authToken: this.config.mailboxToken,
|
|
2305
|
+
body: opts
|
|
2306
|
+
});
|
|
2307
|
+
if (!res.ok) {
|
|
2308
|
+
const body = await res.text().catch(() => "");
|
|
2309
|
+
throw new Error(`AAMP stream create failed: ${res.status} ${body || res.statusText}`);
|
|
2310
|
+
}
|
|
2311
|
+
return res.json();
|
|
2312
|
+
}
|
|
2313
|
+
enqueueTaskDispatch(task) {
|
|
2314
|
+
this.pendingTaskDispatches.push(task);
|
|
2315
|
+
this.drainTaskDispatchQueue();
|
|
2316
|
+
}
|
|
2317
|
+
drainTaskDispatchQueue() {
|
|
2318
|
+
while (this.activeTaskDispatchCount < this.taskDispatchConcurrency && this.pendingTaskDispatches.length > 0) {
|
|
2319
|
+
const nextTask = this.pendingTaskDispatches.shift();
|
|
2320
|
+
if (!nextTask)
|
|
2321
|
+
return;
|
|
2322
|
+
this.activeTaskDispatchCount += 1;
|
|
2323
|
+
void this.runTaskDispatch(nextTask);
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
async runTaskDispatch(task) {
|
|
2327
|
+
try {
|
|
2328
|
+
await this.emitAsync("task.dispatch", task);
|
|
2329
|
+
} catch (err) {
|
|
2330
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
2331
|
+
this.emit("error", error);
|
|
2332
|
+
} finally {
|
|
2333
|
+
this.activeTaskDispatchCount = Math.max(0, this.activeTaskDispatchCount - 1);
|
|
2334
|
+
this.drainTaskDispatchQueue();
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
getStreamAppendQueue(streamId) {
|
|
2338
|
+
let queue = this.streamAppendQueues.get(streamId);
|
|
2339
|
+
if (!queue) {
|
|
2340
|
+
queue = { running: false, operations: [] };
|
|
2341
|
+
this.streamAppendQueues.set(streamId, queue);
|
|
2342
|
+
}
|
|
2343
|
+
return queue;
|
|
2344
|
+
}
|
|
2345
|
+
async dispatchStreamAppend(opts) {
|
|
2346
|
+
const stream = await this.resolveStreamCapability();
|
|
2347
|
+
const res = await _AampClient.callDiscoveredApi(this.config.baseUrl, {
|
|
2348
|
+
action: stream.appendAction ?? "aamp.stream.append",
|
|
2349
|
+
method: "POST",
|
|
2350
|
+
authToken: this.config.mailboxToken,
|
|
2351
|
+
body: opts
|
|
2352
|
+
});
|
|
2353
|
+
if (!res.ok) {
|
|
2354
|
+
const body = await res.text().catch(() => "");
|
|
2355
|
+
throw new Error(`AAMP stream append failed: ${res.status} ${body || res.statusText}`);
|
|
2356
|
+
}
|
|
2357
|
+
return res.json();
|
|
2358
|
+
}
|
|
2359
|
+
enqueueStreamAppend(streamId, operation) {
|
|
2360
|
+
const queue = this.getStreamAppendQueue(streamId);
|
|
2361
|
+
queue.operations.push(operation);
|
|
2362
|
+
void this.drainStreamAppendQueue(streamId);
|
|
2363
|
+
}
|
|
2364
|
+
async drainStreamAppendQueue(streamId) {
|
|
2365
|
+
const queue = this.streamAppendQueues.get(streamId);
|
|
2366
|
+
if (!queue || queue.running)
|
|
2367
|
+
return;
|
|
2368
|
+
queue.running = true;
|
|
2369
|
+
try {
|
|
2370
|
+
while (queue.operations.length) {
|
|
2371
|
+
const operation = queue.operations.shift();
|
|
2372
|
+
if (!operation)
|
|
2373
|
+
continue;
|
|
2374
|
+
if (operation.kind === "text-delta-batch") {
|
|
2375
|
+
try {
|
|
2376
|
+
const event = await this.dispatchStreamAppend({
|
|
2377
|
+
streamId,
|
|
2378
|
+
type: "text.delta",
|
|
2379
|
+
payload: {
|
|
2380
|
+
...operation.payload,
|
|
2381
|
+
text: operation.text
|
|
2382
|
+
}
|
|
2383
|
+
});
|
|
2384
|
+
for (const resolve of operation.resolvers)
|
|
2385
|
+
resolve(event);
|
|
2386
|
+
} catch (error) {
|
|
2387
|
+
for (const reject of operation.rejecters)
|
|
2388
|
+
reject(error);
|
|
2389
|
+
}
|
|
2390
|
+
continue;
|
|
2391
|
+
}
|
|
2392
|
+
try {
|
|
2393
|
+
const event = await this.dispatchStreamAppend(operation.opts);
|
|
2394
|
+
operation.resolve(event);
|
|
2395
|
+
} catch (error) {
|
|
2396
|
+
operation.reject(error);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
} finally {
|
|
2400
|
+
queue.running = false;
|
|
2401
|
+
if (queue.operations.length === 0) {
|
|
2402
|
+
this.streamAppendQueues.delete(streamId);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
async flushStreamAppendQueue(streamId) {
|
|
2407
|
+
while (true) {
|
|
2408
|
+
const queue = this.streamAppendQueues.get(streamId);
|
|
2409
|
+
if (!queue)
|
|
2410
|
+
return;
|
|
2411
|
+
if (!queue.running && queue.operations.length === 0) {
|
|
2412
|
+
this.streamAppendQueues.delete(streamId);
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
async appendStreamEvent(opts) {
|
|
2419
|
+
if (opts.type === "text.delta" && typeof opts.payload.text === "string") {
|
|
2420
|
+
return await new Promise((resolve, reject) => {
|
|
2421
|
+
const queue = this.getStreamAppendQueue(opts.streamId);
|
|
2422
|
+
const lastOperation = queue.operations.at(-1);
|
|
2423
|
+
if (lastOperation?.kind === "text-delta-batch") {
|
|
2424
|
+
lastOperation.text += String(opts.payload.text ?? "");
|
|
2425
|
+
lastOperation.resolvers.push(resolve);
|
|
2426
|
+
lastOperation.rejecters.push(reject);
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
this.enqueueStreamAppend(opts.streamId, {
|
|
2430
|
+
kind: "text-delta-batch",
|
|
2431
|
+
text: String(opts.payload.text ?? ""),
|
|
2432
|
+
payload: {
|
|
2433
|
+
...opts.payload
|
|
2434
|
+
},
|
|
2435
|
+
resolvers: [resolve],
|
|
2436
|
+
rejecters: [reject]
|
|
2437
|
+
});
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
2440
|
+
return await new Promise((resolve, reject) => {
|
|
2441
|
+
this.enqueueStreamAppend(opts.streamId, {
|
|
2442
|
+
kind: "single-event",
|
|
2443
|
+
opts,
|
|
2444
|
+
resolve,
|
|
2445
|
+
reject
|
|
2446
|
+
});
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
async closeStream(opts) {
|
|
2450
|
+
await this.flushStreamAppendQueue(opts.streamId);
|
|
2451
|
+
const stream = await this.resolveStreamCapability();
|
|
2452
|
+
const res = await _AampClient.callDiscoveredApi(this.config.baseUrl, {
|
|
2453
|
+
action: stream.closeAction ?? "aamp.stream.close",
|
|
2454
|
+
method: "POST",
|
|
2455
|
+
authToken: this.config.mailboxToken,
|
|
2456
|
+
body: opts
|
|
2457
|
+
});
|
|
2458
|
+
if (!res.ok) {
|
|
2459
|
+
const body = await res.text().catch(() => "");
|
|
2460
|
+
throw new Error(`AAMP stream close failed: ${res.status} ${body || res.statusText}`);
|
|
2461
|
+
}
|
|
2462
|
+
return res.json();
|
|
2463
|
+
}
|
|
2464
|
+
async getTaskStream(opts) {
|
|
2465
|
+
const stream = await this.resolveStreamCapability();
|
|
2466
|
+
const res = await _AampClient.callDiscoveredApi(this.config.baseUrl, {
|
|
2467
|
+
action: stream.getAction ?? "aamp.stream.get",
|
|
2468
|
+
authToken: this.config.mailboxToken,
|
|
2469
|
+
query: {
|
|
2470
|
+
...opts.taskId ? { taskId: opts.taskId } : {},
|
|
2471
|
+
...opts.streamId ? { streamId: opts.streamId } : {}
|
|
2472
|
+
}
|
|
2473
|
+
});
|
|
2474
|
+
if (res.status === 404)
|
|
2475
|
+
return null;
|
|
2476
|
+
if (!res.ok) {
|
|
2477
|
+
const body = await res.text().catch(() => "");
|
|
2478
|
+
throw new Error(`AAMP stream get failed: ${res.status} ${body || res.statusText}`);
|
|
2479
|
+
}
|
|
2480
|
+
return res.json();
|
|
2481
|
+
}
|
|
2482
|
+
async subscribeStream(streamId, handlers, opts = {}) {
|
|
2483
|
+
const stream = await this.resolveStreamCapability();
|
|
2484
|
+
const template = stream.subscribeUrlTemplate;
|
|
2485
|
+
if (!template)
|
|
2486
|
+
throw new Error("AAMP stream subscribeUrlTemplate is missing");
|
|
2487
|
+
const url = new URL(template.replace("{streamId}", encodeURIComponent(streamId)), this.config.baseUrl);
|
|
2488
|
+
if (opts.lastEventId) {
|
|
2489
|
+
url.searchParams.set("lastEventId", opts.lastEventId);
|
|
2490
|
+
}
|
|
2491
|
+
const controller = new AbortController();
|
|
2492
|
+
if (opts.signal) {
|
|
2493
|
+
opts.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
2494
|
+
}
|
|
2495
|
+
const res = await fetch(url, {
|
|
2496
|
+
headers: {
|
|
2497
|
+
Authorization: `Basic ${this.config.mailboxToken}`,
|
|
2498
|
+
Accept: "text/event-stream"
|
|
2499
|
+
},
|
|
2500
|
+
signal: controller.signal
|
|
2501
|
+
});
|
|
2502
|
+
if (!res.ok || !res.body) {
|
|
2503
|
+
throw new Error(`AAMP stream subscribe failed: ${res.status} ${res.statusText}`);
|
|
2504
|
+
}
|
|
2505
|
+
handlers.onOpen?.();
|
|
2506
|
+
const reader = res.body.getReader();
|
|
2507
|
+
const decoder = new TextDecoder();
|
|
2508
|
+
let buffer = "";
|
|
2509
|
+
let currentEvent = "message";
|
|
2510
|
+
let currentId = "";
|
|
2511
|
+
let currentData = [];
|
|
2512
|
+
const flush = () => {
|
|
2513
|
+
if (!currentData.length)
|
|
2514
|
+
return;
|
|
2515
|
+
try {
|
|
2516
|
+
const parsed = JSON.parse(currentData.join("\n"));
|
|
2517
|
+
handlers.onEvent({
|
|
2518
|
+
...parsed,
|
|
2519
|
+
...currentId ? { id: currentId } : {},
|
|
2520
|
+
type: parsed.type ?? currentEvent
|
|
2521
|
+
});
|
|
2522
|
+
} catch (err) {
|
|
2523
|
+
handlers.onError?.(err);
|
|
2524
|
+
} finally {
|
|
2525
|
+
currentEvent = "message";
|
|
2526
|
+
currentId = "";
|
|
2527
|
+
currentData = [];
|
|
2528
|
+
}
|
|
2529
|
+
};
|
|
2530
|
+
void (async () => {
|
|
2531
|
+
try {
|
|
2532
|
+
while (true) {
|
|
2533
|
+
const { value, done } = await reader.read();
|
|
2534
|
+
if (done)
|
|
2535
|
+
break;
|
|
2536
|
+
buffer += decoder.decode(value, { stream: true });
|
|
2537
|
+
let index = buffer.indexOf("\n\n");
|
|
2538
|
+
while (index >= 0) {
|
|
2539
|
+
const frame = buffer.slice(0, index);
|
|
2540
|
+
buffer = buffer.slice(index + 2);
|
|
2541
|
+
for (const rawLine of frame.split("\n")) {
|
|
2542
|
+
const line = rawLine.replace(/\r$/, "");
|
|
2543
|
+
if (!line || line.startsWith(":"))
|
|
2544
|
+
continue;
|
|
2545
|
+
if (line.startsWith("event:")) {
|
|
2546
|
+
currentEvent = line.slice(6).trim();
|
|
2547
|
+
} else if (line.startsWith("id:")) {
|
|
2548
|
+
currentId = line.slice(3).trim();
|
|
2549
|
+
} else if (line.startsWith("data:")) {
|
|
2550
|
+
currentData.push(line.slice(5).trimStart());
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
flush();
|
|
2554
|
+
index = buffer.indexOf("\n\n");
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
} catch (err) {
|
|
2558
|
+
if (!controller.signal.aborted) {
|
|
2559
|
+
handlers.onError?.(err);
|
|
2560
|
+
}
|
|
2561
|
+
} finally {
|
|
2562
|
+
buffer += decoder.decode();
|
|
2563
|
+
controller.abort();
|
|
2564
|
+
}
|
|
2565
|
+
})();
|
|
2566
|
+
return {
|
|
2567
|
+
close() {
|
|
2568
|
+
controller.abort();
|
|
2569
|
+
}
|
|
2570
|
+
};
|
|
2571
|
+
}
|
|
2572
|
+
/**
|
|
2573
|
+
* Download a blob (attachment) by its JMAP blobId.
|
|
2574
|
+
* Use this to retrieve attachment content from received TaskDispatch or TaskResult messages.
|
|
2575
|
+
* Returns the raw binary content as a Buffer.
|
|
2576
|
+
*/
|
|
2577
|
+
async downloadBlob(blobId, filename) {
|
|
2578
|
+
return this.jmapClient.downloadBlob(blobId, filename);
|
|
2579
|
+
}
|
|
2580
|
+
/**
|
|
2581
|
+
* Reconcile recent mailbox contents via JMAP HTTP to catch messages missed by
|
|
2582
|
+
* a flaky WebSocket path. Safe to call periodically; duplicate processing is
|
|
2583
|
+
* suppressed by the JMAP push client.
|
|
2584
|
+
*/
|
|
2585
|
+
async reconcileRecentEmails(limit, opts) {
|
|
2586
|
+
return this.jmapClient.reconcileRecentEmails(limit, opts);
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2589
|
+
* Verify SMTP connectivity
|
|
2590
|
+
*/
|
|
2591
|
+
async verifySmtp() {
|
|
2592
|
+
return this.smtpSender.verify();
|
|
2593
|
+
}
|
|
2594
|
+
get email() {
|
|
2595
|
+
return this.config.email;
|
|
2596
|
+
}
|
|
2597
|
+
};
|
|
2598
|
+
|
|
2599
|
+
// src/config.ts
|
|
2600
|
+
var CONFIG_FILENAME = "config.json";
|
|
2601
|
+
var STATE_FILENAME = "state.json";
|
|
2602
|
+
function getBridgeHomeDir(customDir) {
|
|
2603
|
+
return customDir ? path.resolve(customDir) : path.join(os.homedir(), ".aamp", "wechat-bridge");
|
|
2604
|
+
}
|
|
2605
|
+
function getConfigPath(customDir) {
|
|
2606
|
+
return path.join(getBridgeHomeDir(customDir), CONFIG_FILENAME);
|
|
2607
|
+
}
|
|
2608
|
+
function getStatePath(customDir) {
|
|
2609
|
+
return path.join(getBridgeHomeDir(customDir), STATE_FILENAME);
|
|
2610
|
+
}
|
|
2611
|
+
async function ensureBridgeHomeDir(customDir) {
|
|
2612
|
+
const dir = getBridgeHomeDir(customDir);
|
|
2613
|
+
await mkdir(dir, { recursive: true });
|
|
2614
|
+
return dir;
|
|
2615
|
+
}
|
|
2616
|
+
async function writeJsonAtomic(filePath, value) {
|
|
2617
|
+
const parentDir = path.dirname(filePath);
|
|
2618
|
+
await mkdir(parentDir, { recursive: true });
|
|
2619
|
+
const tempPath = path.join(parentDir, `.${path.basename(filePath)}.${randomUUID2()}.tmp`);
|
|
2620
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
|
|
2621
|
+
`, "utf8");
|
|
2622
|
+
await rename(tempPath, filePath);
|
|
2623
|
+
}
|
|
2624
|
+
function createDefaultBridgeState() {
|
|
2625
|
+
return {
|
|
2626
|
+
version: 1,
|
|
2627
|
+
processedMessageIds: [],
|
|
2628
|
+
contextTokens: {},
|
|
2629
|
+
conversations: {},
|
|
2630
|
+
tasks: {}
|
|
2631
|
+
};
|
|
2632
|
+
}
|
|
2633
|
+
async function loadBridgeConfig(customDir) {
|
|
2634
|
+
const filePath = getConfigPath(customDir);
|
|
2635
|
+
if (!existsSync(filePath))
|
|
2636
|
+
return null;
|
|
2637
|
+
const raw = await readFile(filePath, "utf8");
|
|
2638
|
+
return JSON.parse(raw);
|
|
2639
|
+
}
|
|
2640
|
+
async function saveBridgeConfig(config, customDir) {
|
|
2641
|
+
await writeJsonAtomic(getConfigPath(customDir), config);
|
|
2642
|
+
}
|
|
2643
|
+
async function loadBridgeState(customDir) {
|
|
2644
|
+
const filePath = getStatePath(customDir);
|
|
2645
|
+
if (!existsSync(filePath))
|
|
2646
|
+
return createDefaultBridgeState();
|
|
2647
|
+
const raw = await readFile(filePath, "utf8");
|
|
2648
|
+
const parsed = JSON.parse(raw);
|
|
2649
|
+
return {
|
|
2650
|
+
...createDefaultBridgeState(),
|
|
2651
|
+
...parsed,
|
|
2652
|
+
processedMessageIds: Array.isArray(parsed.processedMessageIds) ? parsed.processedMessageIds : [],
|
|
2653
|
+
contextTokens: parsed.contextTokens ?? {},
|
|
2654
|
+
conversations: parsed.conversations ?? {},
|
|
2655
|
+
tasks: parsed.tasks ?? {}
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
async function saveBridgeState(state, customDir) {
|
|
2659
|
+
await writeJsonAtomic(getStatePath(customDir), state);
|
|
2660
|
+
}
|
|
2661
|
+
function normalizeBaseUrl(url) {
|
|
2662
|
+
if (url.startsWith("http://") || url.startsWith("https://"))
|
|
2663
|
+
return url.replace(/\/$/, "");
|
|
2664
|
+
return `https://${url.replace(/\/$/, "")}`;
|
|
2665
|
+
}
|
|
2666
|
+
function normalizeSlug(rawValue) {
|
|
2667
|
+
return rawValue.toLowerCase().replace(/[\s_]+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 32) || "wechat-bridge";
|
|
2668
|
+
}
|
|
2669
|
+
async function prompt(question, defaultValue = "") {
|
|
2670
|
+
const rl = readline.createInterface({ input, output });
|
|
2671
|
+
try {
|
|
2672
|
+
const suffix = defaultValue ? ` (${defaultValue})` : "";
|
|
2673
|
+
const answer = await rl.question(`${question}${suffix}: `);
|
|
2674
|
+
return answer.trim() || defaultValue;
|
|
2675
|
+
} finally {
|
|
2676
|
+
rl.close();
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
function toMailboxIdentity(mailbox) {
|
|
2680
|
+
return {
|
|
2681
|
+
email: mailbox.email,
|
|
2682
|
+
mailboxToken: mailbox.mailboxToken,
|
|
2683
|
+
smtpPassword: mailbox.smtpPassword,
|
|
2684
|
+
baseUrl: mailbox.baseUrl
|
|
2685
|
+
};
|
|
2686
|
+
}
|
|
2687
|
+
async function initializeBridgeConfig(options) {
|
|
2688
|
+
const existing = await loadBridgeConfig(options.configDir);
|
|
2689
|
+
const aampHost = (options.aampHost ?? existing?.aampHost ?? await prompt("AAMP host", "https://meshmail.ai")).trim();
|
|
2690
|
+
const targetAgentEmail = (options.targetAgentEmail ?? existing?.targetAgentEmail ?? await prompt("Target AAMP agent email")).trim();
|
|
2691
|
+
const slug = normalizeSlug(options.slug ?? existing?.slug ?? await prompt("Bridge mailbox slug", "wechat-bridge"));
|
|
2692
|
+
const summary = (options.summary ?? existing?.summary ?? "").trim() || void 0;
|
|
2693
|
+
const botAgent = (options.botAgent ?? existing?.wechat.botAgent ?? await prompt("WeChat bot agent", "AAMP-WeChat-Bridge/0.1.0")).trim();
|
|
2694
|
+
const dispatchTimeoutMs = Math.max(1e3, Math.trunc(options.dispatchTimeoutMs ?? existing?.behavior.dispatchTimeoutMs ?? 18e4));
|
|
2695
|
+
const pollTimeoutMs = Math.max(5e3, Math.trunc(options.pollTimeoutMs ?? existing?.behavior.pollTimeoutMs ?? 35e3));
|
|
2696
|
+
if (!aampHost)
|
|
2697
|
+
throw new Error("AAMP host is required.");
|
|
2698
|
+
if (!targetAgentEmail)
|
|
2699
|
+
throw new Error("Target AAMP agent email is required.");
|
|
2700
|
+
const mailbox = existing?.mailbox ?? toMailboxIdentity(await AampClient.registerMailbox({
|
|
2701
|
+
aampHost,
|
|
2702
|
+
slug,
|
|
2703
|
+
description: `WeChat bridge for ${targetAgentEmail}`
|
|
2704
|
+
}));
|
|
2705
|
+
const config = {
|
|
2706
|
+
version: 1,
|
|
2707
|
+
aampHost: normalizeBaseUrl(aampHost),
|
|
2708
|
+
targetAgentEmail,
|
|
2709
|
+
slug,
|
|
2710
|
+
...summary ? { summary } : {},
|
|
2711
|
+
mailbox: toMailboxIdentity(mailbox),
|
|
2712
|
+
wechat: {
|
|
2713
|
+
apiBaseUrl: existing?.wechat.apiBaseUrl ?? "https://ilinkai.weixin.qq.com",
|
|
2714
|
+
botType: existing?.wechat.botType ?? "3",
|
|
2715
|
+
botAgent: botAgent || "AAMP-WeChat-Bridge/0.1.0"
|
|
2716
|
+
},
|
|
2717
|
+
behavior: {
|
|
2718
|
+
dispatchTimeoutMs,
|
|
2719
|
+
pollTimeoutMs
|
|
2720
|
+
}
|
|
2721
|
+
};
|
|
2722
|
+
await ensureBridgeHomeDir(options.configDir);
|
|
2723
|
+
await saveBridgeConfig(config, options.configDir);
|
|
2724
|
+
return config;
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
// src/wechat-api.ts
|
|
2728
|
+
import crypto from "node:crypto";
|
|
2729
|
+
var DEFAULT_APP_ID = "bot";
|
|
2730
|
+
var DEFAULT_CHANNEL_VERSION = "0.1.0";
|
|
2731
|
+
function normalizeWechatApiBaseUrl(url) {
|
|
2732
|
+
if (url.startsWith("http://") || url.startsWith("https://"))
|
|
2733
|
+
return url.replace(/\/$/, "");
|
|
2734
|
+
return `https://${url.replace(/\/$/, "")}`;
|
|
2735
|
+
}
|
|
2736
|
+
function buildClientVersion(version) {
|
|
2737
|
+
const parts = version.split(".").map((part) => Number.parseInt(part, 10));
|
|
2738
|
+
const major = parts[0] ?? 0;
|
|
2739
|
+
const minor = parts[1] ?? 0;
|
|
2740
|
+
const patch = parts[2] ?? 0;
|
|
2741
|
+
return (major & 255) << 16 | (minor & 255) << 8 | patch & 255;
|
|
2742
|
+
}
|
|
2743
|
+
function sanitizeBotAgent(raw) {
|
|
2744
|
+
const trimmed = raw.trim();
|
|
2745
|
+
if (!trimmed)
|
|
2746
|
+
return "AAMP-WeChat-Bridge/0.1.0";
|
|
2747
|
+
return trimmed.slice(0, 256);
|
|
2748
|
+
}
|
|
2749
|
+
function buildBaseInfo(botAgent) {
|
|
2750
|
+
return {
|
|
2751
|
+
channel_version: DEFAULT_CHANNEL_VERSION,
|
|
2752
|
+
bot_agent: sanitizeBotAgent(botAgent)
|
|
2753
|
+
};
|
|
2754
|
+
}
|
|
2755
|
+
function buildHeaders(token) {
|
|
2756
|
+
const headers = {
|
|
2757
|
+
"Content-Type": "application/json",
|
|
2758
|
+
AuthorizationType: "ilink_bot_token",
|
|
2759
|
+
"X-WECHAT-UIN": Buffer.from(String(crypto.randomBytes(4).readUInt32BE(0)), "utf8").toString("base64"),
|
|
2760
|
+
"iLink-App-Id": DEFAULT_APP_ID,
|
|
2761
|
+
"iLink-App-ClientVersion": String(buildClientVersion(DEFAULT_CHANNEL_VERSION))
|
|
2762
|
+
};
|
|
2763
|
+
if (token?.trim()) {
|
|
2764
|
+
headers.Authorization = `Bearer ${token.trim()}`;
|
|
2765
|
+
}
|
|
2766
|
+
return headers;
|
|
2767
|
+
}
|
|
2768
|
+
async function postJson(endpoint, body, opts) {
|
|
2769
|
+
const controller = opts.timeoutMs ? new AbortController() : void 0;
|
|
2770
|
+
const timeout = opts.timeoutMs ? setTimeout(() => controller?.abort(), opts.timeoutMs) : void 0;
|
|
2771
|
+
try {
|
|
2772
|
+
const response = await fetch(`${normalizeWechatApiBaseUrl(opts.apiBaseUrl)}/${endpoint}`, {
|
|
2773
|
+
method: "POST",
|
|
2774
|
+
headers: buildHeaders(opts.token),
|
|
2775
|
+
body: JSON.stringify(body),
|
|
2776
|
+
...controller ? { signal: controller.signal } : {}
|
|
2777
|
+
});
|
|
2778
|
+
const text = await response.text();
|
|
2779
|
+
if (!response.ok) {
|
|
2780
|
+
throw new Error(`${endpoint} ${response.status}: ${text || response.statusText}`);
|
|
2781
|
+
}
|
|
2782
|
+
return JSON.parse(text);
|
|
2783
|
+
} finally {
|
|
2784
|
+
if (timeout)
|
|
2785
|
+
clearTimeout(timeout);
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
async function getJson(endpoint, opts) {
|
|
2789
|
+
const controller = opts.timeoutMs ? new AbortController() : void 0;
|
|
2790
|
+
const timeout = opts.timeoutMs ? setTimeout(() => controller?.abort(), opts.timeoutMs) : void 0;
|
|
2791
|
+
try {
|
|
2792
|
+
const response = await fetch(`${normalizeWechatApiBaseUrl(opts.apiBaseUrl)}/${endpoint}`, {
|
|
2793
|
+
method: "GET",
|
|
2794
|
+
headers: buildHeaders(opts.token),
|
|
2795
|
+
...controller ? { signal: controller.signal } : {}
|
|
2796
|
+
});
|
|
2797
|
+
const text = await response.text();
|
|
2798
|
+
if (!response.ok) {
|
|
2799
|
+
throw new Error(`${endpoint} ${response.status}: ${text || response.statusText}`);
|
|
2800
|
+
}
|
|
2801
|
+
return JSON.parse(text);
|
|
2802
|
+
} finally {
|
|
2803
|
+
if (timeout)
|
|
2804
|
+
clearTimeout(timeout);
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
async function startQrLogin(opts) {
|
|
2808
|
+
const response = await postJson(
|
|
2809
|
+
`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(opts.botType)}`,
|
|
2810
|
+
{
|
|
2811
|
+
local_token_list: [],
|
|
2812
|
+
base_info: buildBaseInfo(opts.botAgent)
|
|
2813
|
+
},
|
|
2814
|
+
{ apiBaseUrl: opts.apiBaseUrl, botAgent: opts.botAgent, timeoutMs: 15e3 }
|
|
2815
|
+
);
|
|
2816
|
+
if (!response.qrcode || !response.qrcode_img_content) {
|
|
2817
|
+
throw new Error("\u5FAE\u4FE1\u767B\u5F55\u4E8C\u7EF4\u7801\u83B7\u53D6\u5931\u8D25\u3002");
|
|
2818
|
+
}
|
|
2819
|
+
return {
|
|
2820
|
+
qrCode: response.qrcode,
|
|
2821
|
+
qrCodeUrl: response.qrcode_img_content
|
|
2822
|
+
};
|
|
2823
|
+
}
|
|
2824
|
+
async function pollQrStatus(opts) {
|
|
2825
|
+
const query = new URLSearchParams({ qrcode: opts.qrCode });
|
|
2826
|
+
if (opts.verifyCode)
|
|
2827
|
+
query.set("verify_code", opts.verifyCode);
|
|
2828
|
+
const response = await getJson(`ilink/bot/get_qrcode_status?${query.toString()}`, {
|
|
2829
|
+
apiBaseUrl: opts.apiBaseUrl,
|
|
2830
|
+
botAgent: opts.botAgent,
|
|
2831
|
+
timeoutMs: 35e3
|
|
2832
|
+
});
|
|
2833
|
+
return {
|
|
2834
|
+
status: response.status ?? "wait",
|
|
2835
|
+
botToken: response.bot_token,
|
|
2836
|
+
ilinkUserId: response.ilink_user_id,
|
|
2837
|
+
baseUrl: response.baseurl ? normalizeWechatApiBaseUrl(response.baseurl) : void 0,
|
|
2838
|
+
redirectHost: response.redirect_host
|
|
2839
|
+
};
|
|
2840
|
+
}
|
|
2841
|
+
async function getUpdates(opts) {
|
|
2842
|
+
try {
|
|
2843
|
+
return await postJson(
|
|
2844
|
+
"ilink/bot/getupdates",
|
|
2845
|
+
{
|
|
2846
|
+
get_updates_buf: opts.syncCursor ?? "",
|
|
2847
|
+
base_info: buildBaseInfo(opts.botAgent)
|
|
2848
|
+
},
|
|
2849
|
+
opts
|
|
2850
|
+
);
|
|
2851
|
+
} catch (error) {
|
|
2852
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
2853
|
+
return {
|
|
2854
|
+
ret: 0,
|
|
2855
|
+
msgs: [],
|
|
2856
|
+
get_updates_buf: opts.syncCursor
|
|
2857
|
+
};
|
|
2858
|
+
}
|
|
2859
|
+
throw error;
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
async function sendTextMessage(opts) {
|
|
2863
|
+
await postJson(
|
|
2864
|
+
"ilink/bot/sendmessage",
|
|
2865
|
+
{
|
|
2866
|
+
msg: {
|
|
2867
|
+
from_user_id: "",
|
|
2868
|
+
to_user_id: opts.toUserId,
|
|
2869
|
+
client_id: crypto.randomUUID(),
|
|
2870
|
+
message_type: 2,
|
|
2871
|
+
message_state: 2,
|
|
2872
|
+
item_list: opts.text ? [{ type: 1, text_item: { text: opts.text } }] : void 0,
|
|
2873
|
+
context_token: opts.contextToken ?? void 0
|
|
2874
|
+
},
|
|
2875
|
+
base_info: buildBaseInfo(opts.botAgent)
|
|
2876
|
+
},
|
|
2877
|
+
opts
|
|
2878
|
+
);
|
|
2879
|
+
}
|
|
2880
|
+
async function getTypingTicket(opts) {
|
|
2881
|
+
const response = await postJson(
|
|
2882
|
+
"ilink/bot/getconfig",
|
|
2883
|
+
{
|
|
2884
|
+
ilink_user_id: opts.ilinkUserId,
|
|
2885
|
+
context_token: opts.contextToken ?? void 0,
|
|
2886
|
+
base_info: buildBaseInfo(opts.botAgent)
|
|
2887
|
+
},
|
|
2888
|
+
opts
|
|
2889
|
+
);
|
|
2890
|
+
if (response.ret && response.ret !== 0)
|
|
2891
|
+
return void 0;
|
|
2892
|
+
return response.typing_ticket;
|
|
2893
|
+
}
|
|
2894
|
+
async function sendTypingStatus(opts) {
|
|
2895
|
+
await postJson(
|
|
2896
|
+
"ilink/bot/sendtyping",
|
|
2897
|
+
{
|
|
2898
|
+
ilink_user_id: opts.ilinkUserId,
|
|
2899
|
+
typing_ticket: opts.typingTicket,
|
|
2900
|
+
status: opts.status === "typing" ? 1 : 2,
|
|
2901
|
+
base_info: buildBaseInfo(opts.botAgent)
|
|
2902
|
+
},
|
|
2903
|
+
opts
|
|
2904
|
+
);
|
|
2905
|
+
}
|
|
2906
|
+
async function notifyStart(opts) {
|
|
2907
|
+
await postJson(
|
|
2908
|
+
"ilink/bot/msg/notifystart",
|
|
2909
|
+
{ base_info: buildBaseInfo(opts.botAgent) },
|
|
2910
|
+
opts
|
|
2911
|
+
);
|
|
2912
|
+
}
|
|
2913
|
+
async function notifyStop(opts) {
|
|
2914
|
+
await postJson(
|
|
2915
|
+
"ilink/bot/msg/notifystop",
|
|
2916
|
+
{ base_info: buildBaseInfo(opts.botAgent) },
|
|
2917
|
+
opts
|
|
2918
|
+
);
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
// src/runtime.ts
|
|
2922
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
2923
|
+
var MAX_PROCESSED_MESSAGE_IDS = 200;
|
|
2924
|
+
var WechatBridgeRuntime = class {
|
|
2925
|
+
aamp;
|
|
2926
|
+
config;
|
|
2927
|
+
configDir;
|
|
2928
|
+
logger;
|
|
2929
|
+
activeStreamSubscriptions = /* @__PURE__ */ new Map();
|
|
2930
|
+
liveTaskIds = /* @__PURE__ */ new Set();
|
|
2931
|
+
typingTickets = /* @__PURE__ */ new Map();
|
|
2932
|
+
state = createDefaultBridgeState();
|
|
2933
|
+
stopping = false;
|
|
2934
|
+
pollLoopPromise;
|
|
2935
|
+
constructor(config, options = {}) {
|
|
2936
|
+
this.config = config;
|
|
2937
|
+
this.configDir = options.configDir;
|
|
2938
|
+
this.logger = options.logger ?? console;
|
|
2939
|
+
this.aamp = new AampClient({
|
|
2940
|
+
email: config.mailbox.email,
|
|
2941
|
+
mailboxToken: config.mailbox.mailboxToken,
|
|
2942
|
+
smtpPassword: config.mailbox.smtpPassword,
|
|
2943
|
+
baseUrl: config.mailbox.baseUrl
|
|
2944
|
+
});
|
|
2945
|
+
}
|
|
2946
|
+
async start() {
|
|
2947
|
+
this.state = await loadBridgeState(this.configDir);
|
|
2948
|
+
if (!this.state.account?.token) {
|
|
2949
|
+
throw new Error("\u5C1A\u672A\u767B\u5F55\u5FAE\u4FE1\uFF0C\u8BF7\u5148\u6267\u884C `aamp-wechat-bridge login`\u3002");
|
|
2950
|
+
}
|
|
2951
|
+
this.state.tasks = {};
|
|
2952
|
+
this.state.lastStartedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2953
|
+
this.state.lastError = void 0;
|
|
2954
|
+
await this.persistState();
|
|
2955
|
+
this.registerAampHandlers();
|
|
2956
|
+
await this.aamp.connect();
|
|
2957
|
+
await notifyStart({
|
|
2958
|
+
apiBaseUrl: this.state.account.baseUrl,
|
|
2959
|
+
token: this.state.account.token,
|
|
2960
|
+
botAgent: this.config.wechat.botAgent,
|
|
2961
|
+
timeoutMs: 1e4
|
|
2962
|
+
}).catch(() => {
|
|
2963
|
+
});
|
|
2964
|
+
this.pollLoopPromise = this.pollLoop();
|
|
2965
|
+
await this.pollLoopPromise;
|
|
2966
|
+
}
|
|
2967
|
+
async stop() {
|
|
2968
|
+
if (this.stopping)
|
|
2969
|
+
return;
|
|
2970
|
+
this.stopping = true;
|
|
2971
|
+
for (const subscription of this.activeStreamSubscriptions.values()) {
|
|
2972
|
+
subscription.close();
|
|
2973
|
+
}
|
|
2974
|
+
this.activeStreamSubscriptions.clear();
|
|
2975
|
+
this.liveTaskIds.clear();
|
|
2976
|
+
if (this.state.account?.token) {
|
|
2977
|
+
await notifyStop({
|
|
2978
|
+
apiBaseUrl: this.state.account.baseUrl,
|
|
2979
|
+
token: this.state.account.token,
|
|
2980
|
+
botAgent: this.config.wechat.botAgent,
|
|
2981
|
+
timeoutMs: 1e4
|
|
2982
|
+
}).catch(() => {
|
|
2983
|
+
});
|
|
2984
|
+
}
|
|
2985
|
+
this.aamp.disconnect();
|
|
2986
|
+
this.state.lastStoppedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2987
|
+
await this.persistState();
|
|
2988
|
+
}
|
|
2989
|
+
registerAampHandlers() {
|
|
2990
|
+
this.aamp.on("task.ack", (task) => {
|
|
2991
|
+
void this.handleTaskAck(task);
|
|
2992
|
+
});
|
|
2993
|
+
this.aamp.on("task.stream.opened", (task) => {
|
|
2994
|
+
void this.handleTaskStreamOpened(task);
|
|
2995
|
+
});
|
|
2996
|
+
this.aamp.on("task.result", (task) => {
|
|
2997
|
+
void this.handleTaskResult(task);
|
|
2998
|
+
});
|
|
2999
|
+
this.aamp.on("task.help_needed", (task) => {
|
|
3000
|
+
void this.handleTaskHelp(task);
|
|
3001
|
+
});
|
|
3002
|
+
this.aamp.on("error", (error) => {
|
|
3003
|
+
this.state.lastError = error.message;
|
|
3004
|
+
this.logger.error(`[aamp] ${error.message}`);
|
|
3005
|
+
void this.persistState();
|
|
3006
|
+
});
|
|
3007
|
+
}
|
|
3008
|
+
async pollLoop() {
|
|
3009
|
+
while (!this.stopping) {
|
|
3010
|
+
try {
|
|
3011
|
+
await this.pollOnce();
|
|
3012
|
+
} catch (error) {
|
|
3013
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3014
|
+
this.state.lastError = message;
|
|
3015
|
+
this.logger.error(`[wechat] ${message}`);
|
|
3016
|
+
await this.persistState();
|
|
3017
|
+
if (message.includes("session timeout") || message.includes("errcode=-14")) {
|
|
3018
|
+
throw error;
|
|
3019
|
+
}
|
|
3020
|
+
await new Promise((resolve) => setTimeout(resolve, 3e3));
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
async pollOnce() {
|
|
3025
|
+
if (!this.state.account?.token)
|
|
3026
|
+
return;
|
|
3027
|
+
const response = await getUpdates({
|
|
3028
|
+
apiBaseUrl: this.state.account.baseUrl,
|
|
3029
|
+
token: this.state.account.token,
|
|
3030
|
+
botAgent: this.config.wechat.botAgent,
|
|
3031
|
+
timeoutMs: this.config.behavior.pollTimeoutMs,
|
|
3032
|
+
syncCursor: this.state.syncCursor
|
|
3033
|
+
});
|
|
3034
|
+
if (response.errcode === -14) {
|
|
3035
|
+
throw new Error("WeChat session timeout (errcode=-14). Please run `aamp-wechat-bridge login` again.");
|
|
3036
|
+
}
|
|
3037
|
+
if (response.ret && response.ret !== 0) {
|
|
3038
|
+
throw new Error(`WeChat getupdates failed: errcode=${response.errcode ?? response.ret} ${response.errmsg ?? ""}`.trim());
|
|
3039
|
+
}
|
|
3040
|
+
if (response.get_updates_buf && response.get_updates_buf !== this.state.syncCursor) {
|
|
3041
|
+
this.state.syncCursor = response.get_updates_buf;
|
|
3042
|
+
await this.persistState();
|
|
3043
|
+
}
|
|
3044
|
+
for (const message of response.msgs ?? []) {
|
|
3045
|
+
await this.handleInboundWechatMessage(message);
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
async handleInboundWechatMessage(message) {
|
|
3049
|
+
if (message.group_id)
|
|
3050
|
+
return;
|
|
3051
|
+
if (message.message_type === 2)
|
|
3052
|
+
return;
|
|
3053
|
+
const senderId = message.from_user_id?.trim();
|
|
3054
|
+
if (!senderId)
|
|
3055
|
+
return;
|
|
3056
|
+
const messageKey = String(message.message_id ?? "");
|
|
3057
|
+
if (messageKey && this.state.processedMessageIds.includes(messageKey)) {
|
|
3058
|
+
return;
|
|
3059
|
+
}
|
|
3060
|
+
const text = this.extractMessageText(message.item_list ?? []);
|
|
3061
|
+
const mediaNote = this.extractMediaNote(message.item_list ?? []);
|
|
3062
|
+
const body = [text, mediaNote].filter(Boolean).join("\n\n").trim();
|
|
3063
|
+
if (!body)
|
|
3064
|
+
return;
|
|
3065
|
+
const taskId = randomUUID3();
|
|
3066
|
+
const sessionKey = this.buildSessionKey(senderId);
|
|
3067
|
+
const contextToken = message.context_token?.trim() || void 0;
|
|
3068
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3069
|
+
this.liveTaskIds.add(taskId);
|
|
3070
|
+
this.state.tasks[taskId] = {
|
|
3071
|
+
taskId,
|
|
3072
|
+
senderId,
|
|
3073
|
+
sessionKey,
|
|
3074
|
+
...contextToken ? { contextToken } : {},
|
|
3075
|
+
status: "received",
|
|
3076
|
+
createdAt: now,
|
|
3077
|
+
updatedAt: now
|
|
3078
|
+
};
|
|
3079
|
+
this.state.conversations[sessionKey] = {
|
|
3080
|
+
senderId,
|
|
3081
|
+
sessionKey,
|
|
3082
|
+
lastTaskId: taskId,
|
|
3083
|
+
...contextToken ? { lastContextToken: contextToken } : {},
|
|
3084
|
+
updatedAt: now
|
|
3085
|
+
};
|
|
3086
|
+
if (contextToken) {
|
|
3087
|
+
this.state.contextTokens[senderId] = contextToken;
|
|
3088
|
+
}
|
|
3089
|
+
if (messageKey) {
|
|
3090
|
+
this.rememberProcessedMessageId(messageKey);
|
|
3091
|
+
}
|
|
3092
|
+
await this.persistState();
|
|
3093
|
+
await this.aamp.sendTask({
|
|
3094
|
+
to: this.config.targetAgentEmail,
|
|
3095
|
+
taskId,
|
|
3096
|
+
sessionKey,
|
|
3097
|
+
title: `WeChat DM from ${senderId}`,
|
|
3098
|
+
bodyText: body,
|
|
3099
|
+
dispatchContext: {
|
|
3100
|
+
source: "wechat",
|
|
3101
|
+
wechat_account_id: this.state.account?.accountId ?? "default",
|
|
3102
|
+
wechat_sender_id: senderId,
|
|
3103
|
+
...contextToken ? { wechat_context_token: contextToken } : {},
|
|
3104
|
+
...message.session_id ? { wechat_session_id: message.session_id } : {},
|
|
3105
|
+
...message.message_id != null ? { wechat_message_id: String(message.message_id) } : {}
|
|
3106
|
+
}
|
|
3107
|
+
});
|
|
3108
|
+
}
|
|
3109
|
+
extractMessageText(items) {
|
|
3110
|
+
const parts = [];
|
|
3111
|
+
for (const item of items) {
|
|
3112
|
+
const text = item.text_item?.text?.trim();
|
|
3113
|
+
if (text)
|
|
3114
|
+
parts.push(text);
|
|
3115
|
+
const voiceText = item.voice_item?.text?.trim();
|
|
3116
|
+
if (voiceText)
|
|
3117
|
+
parts.push(voiceText);
|
|
3118
|
+
}
|
|
3119
|
+
return parts.join("\n").trim();
|
|
3120
|
+
}
|
|
3121
|
+
extractMediaNote(items) {
|
|
3122
|
+
const labels = [];
|
|
3123
|
+
for (const item of items) {
|
|
3124
|
+
if (item.type === 2)
|
|
3125
|
+
labels.push("image");
|
|
3126
|
+
if (item.type === 3)
|
|
3127
|
+
labels.push("voice");
|
|
3128
|
+
if (item.type === 4)
|
|
3129
|
+
labels.push(`file${item.file_item?.file_name ? ` (${item.file_item.file_name})` : ""}`);
|
|
3130
|
+
if (item.type === 5)
|
|
3131
|
+
labels.push("video");
|
|
3132
|
+
}
|
|
3133
|
+
if (labels.length === 0)
|
|
3134
|
+
return "";
|
|
3135
|
+
return `User also sent WeChat media: ${labels.join(", ")}. Native media relay is not implemented yet, so please answer based on the textual context only.`;
|
|
3136
|
+
}
|
|
3137
|
+
buildSessionKey(senderId) {
|
|
3138
|
+
return `wechat:${this.state.account?.accountId ?? "default"}:${senderId}`;
|
|
3139
|
+
}
|
|
3140
|
+
rememberProcessedMessageId(messageId) {
|
|
3141
|
+
this.state.processedMessageIds.push(messageId);
|
|
3142
|
+
if (this.state.processedMessageIds.length > MAX_PROCESSED_MESSAGE_IDS) {
|
|
3143
|
+
this.state.processedMessageIds.splice(0, this.state.processedMessageIds.length - MAX_PROCESSED_MESSAGE_IDS);
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
async handleTaskAck(task) {
|
|
3147
|
+
const state = this.state.tasks[task.taskId];
|
|
3148
|
+
if (!state || !this.liveTaskIds.has(task.taskId))
|
|
3149
|
+
return;
|
|
3150
|
+
state.status = "pending";
|
|
3151
|
+
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3152
|
+
await this.ensureTyping(state, "typing");
|
|
3153
|
+
await this.persistState();
|
|
3154
|
+
}
|
|
3155
|
+
async handleTaskStreamOpened(task) {
|
|
3156
|
+
const state = this.state.tasks[task.taskId];
|
|
3157
|
+
if (!state || !this.liveTaskIds.has(task.taskId))
|
|
3158
|
+
return;
|
|
3159
|
+
state.status = "streaming";
|
|
3160
|
+
state.streamId = task.streamId;
|
|
3161
|
+
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3162
|
+
await this.subscribeToTaskStream(state);
|
|
3163
|
+
await this.persistState();
|
|
3164
|
+
}
|
|
3165
|
+
async subscribeToTaskStream(task) {
|
|
3166
|
+
if (!task.streamId || this.activeStreamSubscriptions.has(task.taskId))
|
|
3167
|
+
return;
|
|
3168
|
+
const subscription = await this.aamp.subscribeStream(
|
|
3169
|
+
task.streamId,
|
|
3170
|
+
{
|
|
3171
|
+
onEvent: (event) => {
|
|
3172
|
+
void this.handleStreamEvent(task.taskId, event);
|
|
3173
|
+
},
|
|
3174
|
+
onError: (error) => {
|
|
3175
|
+
this.logger.error(`[stream ${task.taskId}] ${error.message}`);
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
);
|
|
3179
|
+
this.activeStreamSubscriptions.set(task.taskId, subscription);
|
|
3180
|
+
}
|
|
3181
|
+
async handleStreamEvent(taskId, event) {
|
|
3182
|
+
const state = this.state.tasks[taskId];
|
|
3183
|
+
if (!state || !this.liveTaskIds.has(taskId))
|
|
3184
|
+
return;
|
|
3185
|
+
if (event.type === "text.delta") {
|
|
3186
|
+
state.streamText = (state.streamText ?? "") + String(event.payload.text ?? "");
|
|
3187
|
+
state.status = "streaming";
|
|
3188
|
+
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3189
|
+
await this.persistState();
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
3192
|
+
if (event.type === "error") {
|
|
3193
|
+
state.resultError = String(event.payload.message ?? event.payload.error ?? "Unknown stream error");
|
|
3194
|
+
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3195
|
+
await this.persistState();
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
async handleTaskResult(task) {
|
|
3199
|
+
const state = this.state.tasks[task.taskId];
|
|
3200
|
+
if (!state || !this.liveTaskIds.has(task.taskId))
|
|
3201
|
+
return;
|
|
3202
|
+
state.status = task.status === "completed" ? "completed" : "rejected";
|
|
3203
|
+
state.outputText = task.output || state.streamText || state.outputText;
|
|
3204
|
+
state.resultError = task.errorMsg;
|
|
3205
|
+
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3206
|
+
this.closeActiveStream(task.taskId);
|
|
3207
|
+
await this.ensureTyping(state, "cancel");
|
|
3208
|
+
await this.replyToWechat(state, this.buildReplyTextFromResult(task, state));
|
|
3209
|
+
this.finishTask(task.taskId);
|
|
3210
|
+
}
|
|
3211
|
+
async handleTaskHelp(task) {
|
|
3212
|
+
const state = this.state.tasks[task.taskId];
|
|
3213
|
+
if (!state || !this.liveTaskIds.has(task.taskId))
|
|
3214
|
+
return;
|
|
3215
|
+
state.status = "help_needed";
|
|
3216
|
+
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3217
|
+
this.closeActiveStream(task.taskId);
|
|
3218
|
+
await this.ensureTyping(state, "cancel");
|
|
3219
|
+
await this.replyToWechat(state, this.buildReplyTextFromHelp(task));
|
|
3220
|
+
this.finishTask(task.taskId);
|
|
3221
|
+
}
|
|
3222
|
+
buildReplyTextFromResult(task, state) {
|
|
3223
|
+
const body = (task.output || state.streamText || "").trim();
|
|
3224
|
+
const base = body || (task.status === "rejected" ? "\u5F53\u524D\u8BF7\u6C42\u88AB\u76EE\u6807 Agent \u62D2\u7EDD\u4E86\u3002" : "\u5DF2\u7ECF\u5904\u7406\u5B8C\u6210\u3002");
|
|
3225
|
+
const attachmentLines = (task.attachments ?? []).map((attachment) => `- ${attachment.filename} (${attachment.size} bytes)`);
|
|
3226
|
+
const senderPolicyHint = this.buildSenderPolicyHint(task.errorMsg, state);
|
|
3227
|
+
return [
|
|
3228
|
+
base,
|
|
3229
|
+
...attachmentLines.length ? ["", "\u8FD4\u56DE\u4E2D\u8FD8\u5305\u542B\u9644\u4EF6\uFF0C\u76EE\u524D\u5FAE\u4FE1 bridge \u5148\u53EA\u63D0\u793A\u6587\u4EF6\u540D\uFF1A", ...attachmentLines] : [],
|
|
3230
|
+
...senderPolicyHint ? ["", senderPolicyHint] : []
|
|
3231
|
+
].join("\n");
|
|
3232
|
+
}
|
|
3233
|
+
buildReplyTextFromHelp(task) {
|
|
3234
|
+
return [
|
|
3235
|
+
task.question.trim() || "\u6211\u8FD8\u9700\u8981\u4E00\u4E9B\u4FE1\u606F\u624D\u80FD\u7EE7\u7EED\u3002",
|
|
3236
|
+
...task.blockedReason?.trim() ? ["", `\u539F\u56E0\uFF1A${task.blockedReason.trim()}`] : [],
|
|
3237
|
+
...task.suggestedOptions?.length ? ["", "\u4F60\u53EF\u4EE5\u8FD9\u6837\u8865\u5145\uFF1A", ...task.suggestedOptions.map((item) => `- ${item}`)] : []
|
|
3238
|
+
].join("\n");
|
|
3239
|
+
}
|
|
3240
|
+
buildSenderPolicyHint(errorMsg, state) {
|
|
3241
|
+
if (!errorMsg?.includes("senderPolicies"))
|
|
3242
|
+
return "";
|
|
3243
|
+
return [
|
|
3244
|
+
"\u76EE\u6807 Agent \u5F53\u524D\u6CA1\u6709\u653E\u884C\u8FD9\u4E2A\u5FAE\u4FE1\u6865\u3002",
|
|
3245
|
+
"\u8BF7\u5728 target agent \u7684 senderPolicies \u4E2D\u5141\u8BB8\u5F53\u524D bridge \u90AE\u7BB1\uFF0C\u5E76\u628A `wechat_sender_id` \u4F5C\u4E3A dispatchContext \u767D\u540D\u5355\u6761\u4EF6\u3002",
|
|
3246
|
+
"",
|
|
3247
|
+
"\u793A\u4F8B\uFF1A",
|
|
3248
|
+
"[",
|
|
3249
|
+
` {"sender":"${this.config.mailbox.email}","dispatchContextRules":{"wechat_sender_id":["${state.senderId}"]}}`,
|
|
3250
|
+
"]"
|
|
3251
|
+
].join("\n");
|
|
3252
|
+
}
|
|
3253
|
+
async replyToWechat(task, text) {
|
|
3254
|
+
if (!this.state.account?.token)
|
|
3255
|
+
return;
|
|
3256
|
+
await sendTextMessage({
|
|
3257
|
+
apiBaseUrl: this.state.account.baseUrl,
|
|
3258
|
+
token: this.state.account.token,
|
|
3259
|
+
botAgent: this.config.wechat.botAgent,
|
|
3260
|
+
timeoutMs: 15e3,
|
|
3261
|
+
toUserId: task.senderId,
|
|
3262
|
+
text,
|
|
3263
|
+
contextToken: task.contextToken ?? this.state.contextTokens[task.senderId]
|
|
3264
|
+
});
|
|
3265
|
+
}
|
|
3266
|
+
async ensureTyping(task, status) {
|
|
3267
|
+
if (!this.state.account?.token)
|
|
3268
|
+
return;
|
|
3269
|
+
const cachedTicket = this.typingTickets.get(task.senderId);
|
|
3270
|
+
const ticket = cachedTicket || await getTypingTicket({
|
|
3271
|
+
apiBaseUrl: this.state.account.baseUrl,
|
|
3272
|
+
token: this.state.account.token,
|
|
3273
|
+
botAgent: this.config.wechat.botAgent,
|
|
3274
|
+
timeoutMs: 1e4,
|
|
3275
|
+
ilinkUserId: task.senderId,
|
|
3276
|
+
contextToken: task.contextToken ?? this.state.contextTokens[task.senderId]
|
|
3277
|
+
}).catch(() => void 0);
|
|
3278
|
+
if (!ticket)
|
|
3279
|
+
return;
|
|
3280
|
+
this.typingTickets.set(task.senderId, ticket);
|
|
3281
|
+
await sendTypingStatus({
|
|
3282
|
+
apiBaseUrl: this.state.account.baseUrl,
|
|
3283
|
+
token: this.state.account.token,
|
|
3284
|
+
botAgent: this.config.wechat.botAgent,
|
|
3285
|
+
timeoutMs: 1e4,
|
|
3286
|
+
ilinkUserId: task.senderId,
|
|
3287
|
+
typingTicket: ticket,
|
|
3288
|
+
status
|
|
3289
|
+
}).catch(() => {
|
|
3290
|
+
});
|
|
3291
|
+
task.typingActive = status === "typing";
|
|
3292
|
+
}
|
|
3293
|
+
closeActiveStream(taskId) {
|
|
3294
|
+
const subscription = this.activeStreamSubscriptions.get(taskId);
|
|
3295
|
+
if (!subscription)
|
|
3296
|
+
return;
|
|
3297
|
+
subscription.close();
|
|
3298
|
+
this.activeStreamSubscriptions.delete(taskId);
|
|
3299
|
+
}
|
|
3300
|
+
finishTask(taskId) {
|
|
3301
|
+
this.liveTaskIds.delete(taskId);
|
|
3302
|
+
delete this.state.tasks[taskId];
|
|
3303
|
+
void this.persistState();
|
|
3304
|
+
}
|
|
3305
|
+
async persistState() {
|
|
3306
|
+
await saveBridgeState(this.state, this.configDir);
|
|
3307
|
+
}
|
|
3308
|
+
};
|
|
3309
|
+
|
|
3310
|
+
// src/index.ts
|
|
3311
|
+
function printUsage() {
|
|
3312
|
+
console.log([
|
|
3313
|
+
"Usage: aamp-wechat-bridge <command> [options]",
|
|
3314
|
+
"",
|
|
3315
|
+
"Commands:",
|
|
3316
|
+
" init Create or update local bridge config and AAMP mailbox credentials",
|
|
3317
|
+
" login Start QR login and persist the WeChat bot token locally",
|
|
3318
|
+
" run Start the local WeChat bridge daemon",
|
|
3319
|
+
" status Show local config, login state, and target agent information",
|
|
3320
|
+
"",
|
|
3321
|
+
"Options:",
|
|
3322
|
+
" --config-dir <path> Override bridge config directory",
|
|
3323
|
+
"",
|
|
3324
|
+
"Examples:",
|
|
3325
|
+
" aamp-wechat-bridge init",
|
|
3326
|
+
" aamp-wechat-bridge login",
|
|
3327
|
+
" aamp-wechat-bridge run"
|
|
3328
|
+
].join("\n"));
|
|
3329
|
+
}
|
|
3330
|
+
function parseCliArgs(rawArgs) {
|
|
3331
|
+
let command = "run";
|
|
3332
|
+
let commandAssigned = false;
|
|
3333
|
+
let configDir;
|
|
3334
|
+
const options = {};
|
|
3335
|
+
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
3336
|
+
const token = rawArgs[index];
|
|
3337
|
+
if (!commandAssigned && !token.startsWith("-")) {
|
|
3338
|
+
command = token;
|
|
3339
|
+
commandAssigned = true;
|
|
3340
|
+
continue;
|
|
3341
|
+
}
|
|
3342
|
+
if (token === "--config-dir") {
|
|
3343
|
+
const value = rawArgs[index + 1];
|
|
3344
|
+
if (!value)
|
|
3345
|
+
throw new Error("--config-dir requires a value");
|
|
3346
|
+
configDir = value;
|
|
3347
|
+
index += 1;
|
|
3348
|
+
continue;
|
|
3349
|
+
}
|
|
3350
|
+
if (token.startsWith("--")) {
|
|
3351
|
+
const key = token.slice(2);
|
|
3352
|
+
const next = rawArgs[index + 1];
|
|
3353
|
+
if (!next || next.startsWith("--")) {
|
|
3354
|
+
options[key] = true;
|
|
3355
|
+
continue;
|
|
3356
|
+
}
|
|
3357
|
+
options[key] = next;
|
|
3358
|
+
index += 1;
|
|
3359
|
+
continue;
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
return { command, configDir, options };
|
|
3363
|
+
}
|
|
3364
|
+
async function requireConfig(configDir) {
|
|
3365
|
+
const config = await loadBridgeConfig(configDir);
|
|
3366
|
+
if (!config) {
|
|
3367
|
+
throw new Error(`Bridge config not found at ${getBridgeHomeDir(configDir)}/config.json. Run \`aamp-wechat-bridge init\` first.`);
|
|
3368
|
+
}
|
|
3369
|
+
return config;
|
|
3370
|
+
}
|
|
3371
|
+
function printQrCode(url) {
|
|
3372
|
+
qrcodeTerminal.generate(url, { small: true });
|
|
3373
|
+
console.log(`\u626B\u7801\u94FE\u63A5: ${url}`);
|
|
3374
|
+
}
|
|
3375
|
+
async function promptVerifyCode() {
|
|
3376
|
+
const rl = readline2.createInterface({ input: input2, output: output2 });
|
|
3377
|
+
try {
|
|
3378
|
+
const value = await rl.question("\u8BF7\u8F93\u5165\u5FAE\u4FE1\u8FD4\u56DE\u7684\u9A8C\u8BC1\u7801: ");
|
|
3379
|
+
return value.trim();
|
|
3380
|
+
} finally {
|
|
3381
|
+
rl.close();
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
async function waitForQrLogin(config, state) {
|
|
3385
|
+
const started = await startQrLogin({
|
|
3386
|
+
apiBaseUrl: config.wechat.apiBaseUrl,
|
|
3387
|
+
botType: config.wechat.botType,
|
|
3388
|
+
botAgent: config.wechat.botAgent
|
|
3389
|
+
});
|
|
3390
|
+
console.log("\u8BF7\u4F7F\u7528\u5FAE\u4FE1\u626B\u7801\u767B\u5F55\u3002");
|
|
3391
|
+
printQrCode(started.qrCodeUrl);
|
|
3392
|
+
let currentBaseUrl = config.wechat.apiBaseUrl;
|
|
3393
|
+
let pendingVerifyCode;
|
|
3394
|
+
for (; ; ) {
|
|
3395
|
+
const status = await pollQrStatus({
|
|
3396
|
+
apiBaseUrl: currentBaseUrl,
|
|
3397
|
+
qrCode: started.qrCode,
|
|
3398
|
+
botAgent: config.wechat.botAgent,
|
|
3399
|
+
...pendingVerifyCode ? { verifyCode: pendingVerifyCode } : {}
|
|
3400
|
+
});
|
|
3401
|
+
pendingVerifyCode = void 0;
|
|
3402
|
+
if (status.status === "wait") {
|
|
3403
|
+
continue;
|
|
3404
|
+
}
|
|
3405
|
+
if (status.status === "scaned") {
|
|
3406
|
+
console.log("\u4E8C\u7EF4\u7801\u5DF2\u626B\u63CF\uFF0C\u8BF7\u5728\u624B\u673A\u4E0A\u786E\u8BA4\u767B\u5F55\u3002");
|
|
3407
|
+
continue;
|
|
3408
|
+
}
|
|
3409
|
+
if (status.status === "scaned_but_redirect") {
|
|
3410
|
+
if (status.redirectHost) {
|
|
3411
|
+
currentBaseUrl = `https://${status.redirectHost.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
|
|
3412
|
+
}
|
|
3413
|
+
continue;
|
|
3414
|
+
}
|
|
3415
|
+
if (status.status === "need_verifycode") {
|
|
3416
|
+
pendingVerifyCode = await promptVerifyCode();
|
|
3417
|
+
continue;
|
|
3418
|
+
}
|
|
3419
|
+
if (status.status === "verify_code_blocked") {
|
|
3420
|
+
throw new Error("\u5FAE\u4FE1\u767B\u5F55\u9A8C\u8BC1\u5931\u8D25\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7\u91CD\u65B0\u6267\u884C `aamp-wechat-bridge login`\u3002");
|
|
3421
|
+
}
|
|
3422
|
+
if (status.status === "expired") {
|
|
3423
|
+
throw new Error("\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u6267\u884C `aamp-wechat-bridge login`\u3002");
|
|
3424
|
+
}
|
|
3425
|
+
if (status.status === "binded_redirect") {
|
|
3426
|
+
console.log("\u8BE5\u5FAE\u4FE1\u8D26\u53F7\u5DF2\u7ECF\u7ED1\u5B9A\u5230\u5F53\u524D\u672C\u5730 bridge\uFF0C\u7EE7\u7EED\u590D\u7528\u73B0\u6709\u767B\u5F55\u6001\u3002");
|
|
3427
|
+
return state;
|
|
3428
|
+
}
|
|
3429
|
+
if (status.status === "confirmed") {
|
|
3430
|
+
if (!status.botToken) {
|
|
3431
|
+
throw new Error("\u5FAE\u4FE1\u767B\u5F55\u5DF2\u786E\u8BA4\uFF0C\u4F46\u6CA1\u6709\u8FD4\u56DE bot token\u3002");
|
|
3432
|
+
}
|
|
3433
|
+
const accountId = status.ilinkUserId?.trim() || "default";
|
|
3434
|
+
state.account = {
|
|
3435
|
+
accountId,
|
|
3436
|
+
token: status.botToken,
|
|
3437
|
+
baseUrl: status.baseUrl?.trim() || currentBaseUrl,
|
|
3438
|
+
ilinkUserId: status.ilinkUserId?.trim() || void 0,
|
|
3439
|
+
connectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3440
|
+
};
|
|
3441
|
+
state.lastLoginAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3442
|
+
return state;
|
|
3443
|
+
}
|
|
3444
|
+
const exhaustive = status.status;
|
|
3445
|
+
throw new Error(`Unexpected QR login state: ${exhaustive}`);
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
async function handleInit(configDir, options = {}) {
|
|
3449
|
+
const config = await initializeBridgeConfig({
|
|
3450
|
+
configDir,
|
|
3451
|
+
aampHost: typeof options.aampHost === "string" ? options.aampHost : void 0,
|
|
3452
|
+
targetAgentEmail: typeof options.targetAgentEmail === "string" ? options.targetAgentEmail : void 0,
|
|
3453
|
+
slug: typeof options.slug === "string" ? options.slug : void 0,
|
|
3454
|
+
summary: typeof options.summary === "string" ? options.summary : void 0,
|
|
3455
|
+
botAgent: typeof options.botAgent === "string" ? options.botAgent : void 0,
|
|
3456
|
+
dispatchTimeoutMs: typeof options.dispatchTimeoutMs === "string" ? Number(options.dispatchTimeoutMs) : void 0,
|
|
3457
|
+
pollTimeoutMs: typeof options.pollTimeoutMs === "string" ? Number(options.pollTimeoutMs) : void 0
|
|
3458
|
+
});
|
|
3459
|
+
console.log(`Bridge config saved to ${getBridgeHomeDir(configDir)}/config.json`);
|
|
3460
|
+
console.log(`Mailbox: ${config.mailbox.email}`);
|
|
3461
|
+
console.log(`Target agent: ${config.targetAgentEmail}`);
|
|
3462
|
+
}
|
|
3463
|
+
async function handleLogin(configDir) {
|
|
3464
|
+
const config = await requireConfig(configDir);
|
|
3465
|
+
const state = await loadBridgeState(configDir);
|
|
3466
|
+
const nextState = await waitForQrLogin(config, state);
|
|
3467
|
+
await saveBridgeState(nextState, configDir);
|
|
3468
|
+
console.log(`\u5FAE\u4FE1\u767B\u5F55\u6210\u529F\uFF0C\u8D26\u53F7\u6807\u8BC6: ${nextState.account?.accountId ?? "default"}`);
|
|
3469
|
+
}
|
|
3470
|
+
async function handleRun(configDir) {
|
|
3471
|
+
const config = await requireConfig(configDir);
|
|
3472
|
+
const state = await loadBridgeState(configDir);
|
|
3473
|
+
if (!state.account?.token) {
|
|
3474
|
+
throw new Error("\u5C1A\u672A\u767B\u5F55\u5FAE\u4FE1\uFF0C\u8BF7\u5148\u6267\u884C `aamp-wechat-bridge login`\u3002");
|
|
3475
|
+
}
|
|
3476
|
+
const runtime = new WechatBridgeRuntime(config, {
|
|
3477
|
+
configDir,
|
|
3478
|
+
logger: console
|
|
3479
|
+
});
|
|
3480
|
+
const shutdown = async () => {
|
|
3481
|
+
await runtime.stop().catch(() => {
|
|
3482
|
+
});
|
|
3483
|
+
exit(0);
|
|
3484
|
+
};
|
|
3485
|
+
process.once("SIGINT", () => {
|
|
3486
|
+
void shutdown();
|
|
3487
|
+
});
|
|
3488
|
+
process.once("SIGTERM", () => {
|
|
3489
|
+
void shutdown();
|
|
3490
|
+
});
|
|
3491
|
+
console.log(`WeChat bridge is running for ${config.targetAgentEmail}`);
|
|
3492
|
+
console.log(`Mailbox: ${config.mailbox.email}`);
|
|
3493
|
+
await runtime.start();
|
|
3494
|
+
}
|
|
3495
|
+
async function handleStatus(configDir) {
|
|
3496
|
+
const config = await requireConfig(configDir);
|
|
3497
|
+
const state = await loadBridgeState(configDir);
|
|
3498
|
+
console.log([
|
|
3499
|
+
`Bridge home: ${getBridgeHomeDir(configDir)}`,
|
|
3500
|
+
`AAMP host: ${config.aampHost}`,
|
|
3501
|
+
`Target agent: ${config.targetAgentEmail}`,
|
|
3502
|
+
`Mailbox: ${config.mailbox.email}`,
|
|
3503
|
+
`WeChat API base: ${config.wechat.apiBaseUrl}`,
|
|
3504
|
+
`Bot agent: ${config.wechat.botAgent}`,
|
|
3505
|
+
`Logged in: ${state.account?.token ? "yes" : "no"}`,
|
|
3506
|
+
...state.account?.token ? [
|
|
3507
|
+
`WeChat account: ${state.account.accountId}`,
|
|
3508
|
+
`Login base URL: ${state.account.baseUrl}`,
|
|
3509
|
+
`Last login: ${state.lastLoginAt ?? state.account.connectedAt}`
|
|
3510
|
+
] : []
|
|
3511
|
+
].join("\n"));
|
|
3512
|
+
}
|
|
3513
|
+
async function main() {
|
|
3514
|
+
const parsed = parseCliArgs(argv.slice(2));
|
|
3515
|
+
switch (parsed.command) {
|
|
3516
|
+
case "help":
|
|
3517
|
+
case "--help":
|
|
3518
|
+
case "-h":
|
|
3519
|
+
printUsage();
|
|
3520
|
+
return;
|
|
3521
|
+
case "init":
|
|
3522
|
+
await handleInit(parsed.configDir, parsed.options);
|
|
3523
|
+
return;
|
|
3524
|
+
case "login":
|
|
3525
|
+
await handleLogin(parsed.configDir);
|
|
3526
|
+
return;
|
|
3527
|
+
case "run":
|
|
3528
|
+
await handleRun(parsed.configDir);
|
|
3529
|
+
return;
|
|
3530
|
+
case "status":
|
|
3531
|
+
await handleStatus(parsed.configDir);
|
|
3532
|
+
return;
|
|
3533
|
+
default:
|
|
3534
|
+
throw new Error(`Unknown command: ${parsed.command}`);
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
main().catch((error) => {
|
|
3538
|
+
console.error(error.message);
|
|
3539
|
+
exit(1);
|
|
3540
|
+
});
|
|
3541
|
+
//# sourceMappingURL=index.js.map
|