@agenticmail/core 0.9.19 → 0.9.21
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/dist/index.cjs +1774 -1431
- package/dist/index.d.cts +766 -705
- package/dist/index.d.ts +766 -705
- package/dist/index.js +1774 -1431
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5137,1605 +5137,1944 @@ var SmsPoller = class {
|
|
|
5137
5137
|
}
|
|
5138
5138
|
};
|
|
5139
5139
|
|
|
5140
|
-
// src/
|
|
5141
|
-
var
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5140
|
+
// src/telegram/client.ts
|
|
5141
|
+
var TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
5142
|
+
var TELEGRAM_MESSAGE_LIMIT = 4096;
|
|
5143
|
+
var TELEGRAM_CHUNK_SIZE = 4e3;
|
|
5144
|
+
var TelegramApiError = class extends Error {
|
|
5145
|
+
isTelegramApiError = true;
|
|
5146
|
+
description;
|
|
5147
|
+
errorCode;
|
|
5148
|
+
constructor(method, description, errorCode) {
|
|
5149
|
+
super(`Telegram ${method} failed: ${description}${errorCode ? ` (code ${errorCode})` : ""}`);
|
|
5150
|
+
this.name = "TelegramApiError";
|
|
5151
|
+
this.description = description;
|
|
5152
|
+
this.errorCode = errorCode;
|
|
5153
|
+
}
|
|
5154
|
+
};
|
|
5155
|
+
function redactBotToken(text, token) {
|
|
5156
|
+
let out = typeof text === "string" ? text : String(text);
|
|
5157
|
+
if (token) out = out.split(token).join("bot***");
|
|
5158
|
+
return out.replace(/\d{6,}:[A-Za-z0-9_-]{30,}/g, "bot***");
|
|
5159
|
+
}
|
|
5160
|
+
async function callTelegramApi(token, method, body, options = {}) {
|
|
5161
|
+
if (!token || typeof token !== "string") {
|
|
5162
|
+
throw new TelegramApiError(method, "bot token is required");
|
|
5163
|
+
}
|
|
5164
|
+
const pollTimeout = typeof body?.timeout === "number" ? body.timeout : 0;
|
|
5165
|
+
const timeoutMs = options.longPoll && pollTimeout > 0 ? (pollTimeout + 15) * 1e3 : 3e4;
|
|
5166
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
5167
|
+
const signal = options.signal ? AbortSignal.any([timeoutSignal, options.signal]) : timeoutSignal;
|
|
5168
|
+
let response;
|
|
5169
|
+
try {
|
|
5170
|
+
response = await fetch(`${TELEGRAM_API_BASE}/bot${token}/${method}`, {
|
|
5171
|
+
method: "POST",
|
|
5172
|
+
headers: { "Content-Type": "application/json" },
|
|
5173
|
+
body: body ? JSON.stringify(body) : void 0,
|
|
5174
|
+
signal
|
|
5152
5175
|
});
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5176
|
+
} catch (err) {
|
|
5177
|
+
throw new TelegramApiError(method, redactBotToken(err?.message ?? String(err), token));
|
|
5178
|
+
}
|
|
5179
|
+
let json;
|
|
5180
|
+
try {
|
|
5181
|
+
json = await response.json();
|
|
5182
|
+
} catch {
|
|
5183
|
+
throw new TelegramApiError(method, `non-JSON response (HTTP ${response.status})`);
|
|
5184
|
+
}
|
|
5185
|
+
if (!json || json.ok !== true) {
|
|
5186
|
+
throw new TelegramApiError(
|
|
5187
|
+
method,
|
|
5188
|
+
redactBotToken(String(json?.description || `HTTP ${response.status}`), token),
|
|
5189
|
+
typeof json?.error_code === "number" ? json.error_code : void 0
|
|
5190
|
+
);
|
|
5191
|
+
}
|
|
5192
|
+
return json.result;
|
|
5193
|
+
}
|
|
5194
|
+
function stripTelegramMarkdown(text) {
|
|
5195
|
+
if (!text) return text;
|
|
5196
|
+
return text.replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1").replace(/__(.+?)__/g, "$1").replace(/~~(.+?)~~/g, "$1").replace(/^#{1,6}\s+/gm, "").replace(/```[\s\S]*?```/g, (m) => m.replace(/```\w*\n?/g, "").trim()).replace(/`([^`]+)`/g, "$1").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim();
|
|
5197
|
+
}
|
|
5198
|
+
function splitTelegramMessage(text, maxLen = TELEGRAM_CHUNK_SIZE) {
|
|
5199
|
+
const chunks = [];
|
|
5200
|
+
let rest = text || "";
|
|
5201
|
+
while (rest.length > maxLen) {
|
|
5202
|
+
let cut = rest.lastIndexOf("\n", maxLen);
|
|
5203
|
+
if (cut < maxLen / 2) cut = maxLen;
|
|
5204
|
+
chunks.push(rest.slice(0, cut));
|
|
5205
|
+
rest = rest.slice(cut).replace(/^\n+/, "");
|
|
5206
|
+
}
|
|
5207
|
+
if (rest) chunks.push(rest);
|
|
5208
|
+
return chunks;
|
|
5209
|
+
}
|
|
5210
|
+
async function sendTelegramMessage(token, chatId, text, options = {}) {
|
|
5211
|
+
const clean = stripTelegramMarkdown(text);
|
|
5212
|
+
const chunks = splitTelegramMessage(clean);
|
|
5213
|
+
if (chunks.length === 0) chunks.push("");
|
|
5214
|
+
const messageIds = [];
|
|
5215
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
5216
|
+
const body = { chat_id: String(chatId), text: chunks[i] };
|
|
5217
|
+
if (i === 0 && options.replyToMessageId) {
|
|
5218
|
+
body.reply_parameters = { message_id: options.replyToMessageId };
|
|
5161
5219
|
}
|
|
5220
|
+
if (options.disableNotification) body.disable_notification = true;
|
|
5221
|
+
const result = await callTelegramApi(token, "sendMessage", body);
|
|
5222
|
+
messageIds.push(result.message_id);
|
|
5162
5223
|
}
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5224
|
+
return { messageIds, chunks: chunks.length };
|
|
5225
|
+
}
|
|
5226
|
+
function getTelegramMe(token) {
|
|
5227
|
+
return callTelegramApi(token, "getMe");
|
|
5228
|
+
}
|
|
5229
|
+
function getTelegramChat(token, chatId) {
|
|
5230
|
+
return callTelegramApi(token, "getChat", { chat_id: String(chatId) });
|
|
5231
|
+
}
|
|
5232
|
+
function getTelegramUpdates(token, offset, options = {}) {
|
|
5233
|
+
const timeoutSec = Math.max(options.timeoutSec ?? 0, 0);
|
|
5234
|
+
return callTelegramApi(token, "getUpdates", {
|
|
5235
|
+
offset,
|
|
5236
|
+
limit: Math.min(Math.max(options.limit ?? 100, 1), 100),
|
|
5237
|
+
timeout: timeoutSec,
|
|
5238
|
+
allowed_updates: ["message"]
|
|
5239
|
+
}, { longPoll: timeoutSec > 0, signal: options.signal });
|
|
5240
|
+
}
|
|
5241
|
+
function setTelegramWebhook(token, url, options = {}) {
|
|
5242
|
+
return callTelegramApi(token, "setWebhook", {
|
|
5243
|
+
url,
|
|
5244
|
+
secret_token: options.secretToken,
|
|
5245
|
+
allowed_updates: ["message"],
|
|
5246
|
+
drop_pending_updates: options.dropPendingUpdates ?? false
|
|
5247
|
+
});
|
|
5248
|
+
}
|
|
5249
|
+
function deleteTelegramWebhook(token) {
|
|
5250
|
+
return callTelegramApi(token, "deleteWebhook", {});
|
|
5251
|
+
}
|
|
5252
|
+
function getTelegramWebhookInfo(token) {
|
|
5253
|
+
return callTelegramApi(token, "getWebhookInfo");
|
|
5254
|
+
}
|
|
5255
|
+
|
|
5256
|
+
// src/telegram/update.ts
|
|
5257
|
+
function asTrimmed(value) {
|
|
5258
|
+
return typeof value === "string" ? value.trim() : "";
|
|
5259
|
+
}
|
|
5260
|
+
function normalizeChatType(type) {
|
|
5261
|
+
return type === "private" || type === "group" || type === "supergroup" || type === "channel" ? type : "unknown";
|
|
5262
|
+
}
|
|
5263
|
+
function parseTelegramUpdate(update) {
|
|
5264
|
+
if (!update || typeof update !== "object") return null;
|
|
5265
|
+
const u = update;
|
|
5266
|
+
if (typeof u.update_id !== "number") return null;
|
|
5267
|
+
const msg = u.message || u.channel_post;
|
|
5268
|
+
if (!msg || typeof msg !== "object") return null;
|
|
5269
|
+
if (typeof msg.message_id !== "number") return null;
|
|
5270
|
+
const chat = msg.chat || {};
|
|
5271
|
+
if (typeof chat.id !== "number" && typeof chat.id !== "string") return null;
|
|
5272
|
+
const text = asTrimmed(msg.text) || asTrimmed(msg.caption);
|
|
5273
|
+
if (!text) return null;
|
|
5274
|
+
const from = msg.from || {};
|
|
5275
|
+
const fromName = [from.first_name, from.last_name].filter((p) => typeof p === "string" && p).join(" ") || asTrimmed(from.username) || asTrimmed(chat.title) || "User";
|
|
5276
|
+
const replyTo = msg.reply_to_message;
|
|
5277
|
+
return {
|
|
5278
|
+
updateId: u.update_id,
|
|
5279
|
+
messageId: msg.message_id,
|
|
5280
|
+
chatId: String(chat.id),
|
|
5281
|
+
chatType: normalizeChatType(chat.type),
|
|
5282
|
+
chatTitle: asTrimmed(chat.title) || void 0,
|
|
5283
|
+
fromId: from.id != null ? String(from.id) : String(chat.id),
|
|
5284
|
+
fromName,
|
|
5285
|
+
fromUsername: asTrimmed(from.username) || void 0,
|
|
5286
|
+
text,
|
|
5287
|
+
replyToMessageId: replyTo && typeof replyTo.message_id === "number" ? replyTo.message_id : void 0,
|
|
5288
|
+
replyToText: replyTo ? asTrimmed(replyTo.text) || asTrimmed(replyTo.caption) || void 0 : void 0,
|
|
5289
|
+
date: typeof msg.date === "number" ? new Date(msg.date * 1e3).toISOString() : (/* @__PURE__ */ new Date()).toISOString()
|
|
5290
|
+
};
|
|
5291
|
+
}
|
|
5292
|
+
var TELEGRAM_STOP_WORDS = /* @__PURE__ */ new Set([
|
|
5293
|
+
"stop",
|
|
5294
|
+
"abort",
|
|
5295
|
+
"kill",
|
|
5296
|
+
"cancel",
|
|
5297
|
+
"halt"
|
|
5298
|
+
]);
|
|
5299
|
+
function isTelegramStopCommand(text) {
|
|
5300
|
+
if (!text) return false;
|
|
5301
|
+
const cleaned = text.trim().toLowerCase().replace(/[!.?]+$/, "");
|
|
5302
|
+
return TELEGRAM_STOP_WORDS.has(cleaned);
|
|
5303
|
+
}
|
|
5304
|
+
function nextTelegramOffset(currentOffset, updates) {
|
|
5305
|
+
let next = currentOffset;
|
|
5306
|
+
for (const u of updates) {
|
|
5307
|
+
if (u && typeof u.update_id === "number" && u.update_id >= next) {
|
|
5308
|
+
next = u.update_id + 1;
|
|
5309
|
+
}
|
|
5182
5310
|
}
|
|
5311
|
+
return next;
|
|
5312
|
+
}
|
|
5313
|
+
|
|
5314
|
+
// src/telegram/manager.ts
|
|
5315
|
+
import { timingSafeEqual } from "crypto";
|
|
5316
|
+
var TELEGRAM_WEBHOOK_SECRET_RE = /^[A-Za-z0-9_-]+$/;
|
|
5317
|
+
var TELEGRAM_MIN_WEBHOOK_SECRET_LENGTH = 16;
|
|
5318
|
+
var TELEGRAM_SECRET_FIELDS = ["botToken", "webhookSecret"];
|
|
5319
|
+
function redactTelegramConfig(config) {
|
|
5320
|
+
return {
|
|
5321
|
+
...config,
|
|
5322
|
+
botToken: config.botToken ? "***" : config.botToken,
|
|
5323
|
+
webhookSecret: config.webhookSecret ? "***" : void 0
|
|
5324
|
+
};
|
|
5325
|
+
}
|
|
5326
|
+
function isTelegramChatAllowed(config, chatId) {
|
|
5327
|
+
const id = String(chatId ?? "").trim();
|
|
5328
|
+
if (!id) return false;
|
|
5329
|
+
if (config.operatorChatId && String(config.operatorChatId).trim() === id) return true;
|
|
5330
|
+
return Array.isArray(config.allowedChatIds) && config.allowedChatIds.some((c) => String(c).trim() === id);
|
|
5331
|
+
}
|
|
5332
|
+
function safeEqual(a, b) {
|
|
5333
|
+
const bufA = Buffer.from(a, "utf8");
|
|
5334
|
+
const bufB = Buffer.from(b, "utf8");
|
|
5335
|
+
if (bufA.length !== bufB.length) return false;
|
|
5336
|
+
return timingSafeEqual(bufA, bufB);
|
|
5337
|
+
}
|
|
5338
|
+
var TelegramManager = class {
|
|
5183
5339
|
/**
|
|
5184
|
-
*
|
|
5340
|
+
* Optional master key used to encrypt Telegram credentials at rest
|
|
5341
|
+
* (the same AES-256-GCM scheme SMS/phone use). When absent (tests, or
|
|
5342
|
+
* a deployment with no master key) configs are stored as-is and reads
|
|
5343
|
+
* tolerate plaintext — upgrades and downgrades both stay safe.
|
|
5185
5344
|
*/
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
this.
|
|
5345
|
+
constructor(db2, encryptionKey) {
|
|
5346
|
+
this.db = db2;
|
|
5347
|
+
this.encryptionKey = encryptionKey;
|
|
5348
|
+
this.ensureTable();
|
|
5189
5349
|
}
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5350
|
+
initialized = false;
|
|
5351
|
+
ensureTable() {
|
|
5352
|
+
if (this.initialized) return;
|
|
5353
|
+
try {
|
|
5354
|
+
this.db.exec(`
|
|
5355
|
+
CREATE TABLE IF NOT EXISTS telegram_messages (
|
|
5356
|
+
id TEXT PRIMARY KEY,
|
|
5357
|
+
agent_id TEXT NOT NULL,
|
|
5358
|
+
direction TEXT NOT NULL CHECK(direction IN ('inbound', 'outbound')),
|
|
5359
|
+
chat_id TEXT NOT NULL,
|
|
5360
|
+
telegram_message_id INTEGER,
|
|
5361
|
+
from_id TEXT,
|
|
5362
|
+
text TEXT NOT NULL,
|
|
5363
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
5364
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
5365
|
+
metadata TEXT DEFAULT '{}'
|
|
5366
|
+
)
|
|
5367
|
+
`);
|
|
5204
5368
|
try {
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
if (parsedSms) {
|
|
5208
|
-
const agent2 = this.accountManager ? await this.accountManager.getByName(agentName) : null;
|
|
5209
|
-
const agentId = agent2?.id;
|
|
5210
|
-
if (agentId) {
|
|
5211
|
-
const smsConfig = this.smsManager.getSmsConfig(agentId);
|
|
5212
|
-
if (smsConfig?.enabled && smsConfig.sameAsRelay) {
|
|
5213
|
-
this.smsManager.recordInbound(agentId, parsedSms);
|
|
5214
|
-
console.log(`[GatewayManager] SMS received from ${parsedSms.from}: "${parsedSms.body.slice(0, 50)}..." \u2192 agent ${agentName}`);
|
|
5215
|
-
if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
|
|
5216
|
-
}
|
|
5217
|
-
}
|
|
5218
|
-
}
|
|
5219
|
-
} catch (err) {
|
|
5220
|
-
debug("GatewayManager", `SMS detection error: ${err.message}`);
|
|
5369
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_telegram_agent ON telegram_messages(agent_id)");
|
|
5370
|
+
} catch {
|
|
5221
5371
|
}
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
} catch (err) {
|
|
5226
|
-
console.warn(`[GatewayManager] Approval reply check failed: ${err.message}`);
|
|
5227
|
-
}
|
|
5228
|
-
const parsed = inboundToParsedEmail(mail);
|
|
5229
|
-
const { isInternalEmail: isInternalEmail2 } = await import("./spam-filter-L6KNZ7QI.js");
|
|
5230
|
-
if (!isInternalEmail2(parsed)) {
|
|
5231
|
-
const spamResult = scoreEmail(parsed);
|
|
5232
|
-
if (spamResult.isSpam) {
|
|
5233
|
-
console.warn(`[GatewayManager] Spam blocked (score=${spamResult.score}, category=${spamResult.topCategory}): "${mail.subject}" from ${mail.from}`);
|
|
5234
|
-
if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
|
|
5235
|
-
return;
|
|
5372
|
+
try {
|
|
5373
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_telegram_chat ON telegram_messages(chat_id)");
|
|
5374
|
+
} catch {
|
|
5236
5375
|
}
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
agent = await this.accountManager.getByName(DEFAULT_AGENT_NAME);
|
|
5241
|
-
if (agent) {
|
|
5242
|
-
console.warn(`[GatewayManager] Agent "${agentName}" not found, delivering to default agent "${DEFAULT_AGENT_NAME}"`);
|
|
5376
|
+
try {
|
|
5377
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_telegram_created ON telegram_messages(created_at)");
|
|
5378
|
+
} catch {
|
|
5243
5379
|
}
|
|
5380
|
+
this.initialized = true;
|
|
5381
|
+
} catch {
|
|
5382
|
+
this.initialized = true;
|
|
5244
5383
|
}
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
const
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5384
|
+
}
|
|
5385
|
+
/** Encrypt the credential fields of a config before persisting. */
|
|
5386
|
+
encryptConfig(config) {
|
|
5387
|
+
if (!this.encryptionKey) return config;
|
|
5388
|
+
const out = { ...config };
|
|
5389
|
+
for (const field of TELEGRAM_SECRET_FIELDS) {
|
|
5390
|
+
const value = out[field];
|
|
5391
|
+
if (typeof value === "string" && value && !isEncryptedSecret(value)) {
|
|
5392
|
+
out[field] = encryptSecret(value, this.encryptionKey);
|
|
5393
|
+
}
|
|
5253
5394
|
}
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
|
|
5268
|
-
await transport.sendMail({
|
|
5269
|
-
from: `${mail.from} <${agent.email}>`,
|
|
5270
|
-
to: agent.email,
|
|
5271
|
-
subject: mail.subject,
|
|
5272
|
-
text: mail.text,
|
|
5273
|
-
html: mail.html || void 0,
|
|
5274
|
-
replyTo: mail.from,
|
|
5275
|
-
inReplyTo: mail.inReplyTo,
|
|
5276
|
-
references: Array.isArray(mail.references) ? mail.references.join(" ") : mail.references,
|
|
5277
|
-
headers: {
|
|
5278
|
-
"X-AgenticMail-Relay": "inbound",
|
|
5279
|
-
"X-Original-From": mail.from,
|
|
5280
|
-
...mail.messageId ? { "X-Original-Message-Id": mail.messageId } : {}
|
|
5281
|
-
},
|
|
5282
|
-
attachments: mail.attachments?.map((a) => ({
|
|
5283
|
-
filename: a.filename,
|
|
5284
|
-
content: a.content,
|
|
5285
|
-
contentType: a.contentType
|
|
5286
|
-
}))
|
|
5287
|
-
});
|
|
5288
|
-
if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
|
|
5289
|
-
} catch (err) {
|
|
5290
|
-
console.error(`[GatewayManager] Failed to deliver to ${agent.email}: ${err.message}`);
|
|
5291
|
-
throw err;
|
|
5292
|
-
} finally {
|
|
5293
|
-
transport.close();
|
|
5395
|
+
return out;
|
|
5396
|
+
}
|
|
5397
|
+
/** Decrypt the credential fields of a config after loading. */
|
|
5398
|
+
decryptConfig(config) {
|
|
5399
|
+
if (!this.encryptionKey) return config;
|
|
5400
|
+
const out = { ...config };
|
|
5401
|
+
for (const field of TELEGRAM_SECRET_FIELDS) {
|
|
5402
|
+
const value = out[field];
|
|
5403
|
+
if (typeof value === "string" && isEncryptedSecret(value)) {
|
|
5404
|
+
try {
|
|
5405
|
+
out[field] = decryptSecret(value, this.encryptionKey);
|
|
5406
|
+
} catch {
|
|
5407
|
+
}
|
|
5408
|
+
}
|
|
5294
5409
|
}
|
|
5410
|
+
return out;
|
|
5411
|
+
}
|
|
5412
|
+
/** Normalize a stored/loaded config object, defaulting missing fields. */
|
|
5413
|
+
normalizeConfig(raw) {
|
|
5414
|
+
return {
|
|
5415
|
+
enabled: raw.enabled === true,
|
|
5416
|
+
botToken: typeof raw.botToken === "string" ? raw.botToken : "",
|
|
5417
|
+
botUsername: typeof raw.botUsername === "string" ? raw.botUsername : void 0,
|
|
5418
|
+
botId: typeof raw.botId === "number" ? raw.botId : void 0,
|
|
5419
|
+
allowedChatIds: Array.isArray(raw.allowedChatIds) ? raw.allowedChatIds.map((c) => String(c).trim()).filter(Boolean) : [],
|
|
5420
|
+
operatorChatId: typeof raw.operatorChatId === "string" && raw.operatorChatId.trim() ? raw.operatorChatId.trim() : void 0,
|
|
5421
|
+
mode: raw.mode === "webhook" ? "webhook" : "poll",
|
|
5422
|
+
webhookUrl: typeof raw.webhookUrl === "string" ? raw.webhookUrl : void 0,
|
|
5423
|
+
webhookSecret: typeof raw.webhookSecret === "string" ? raw.webhookSecret : void 0,
|
|
5424
|
+
pollOffset: typeof raw.pollOffset === "number" ? raw.pollOffset : 0,
|
|
5425
|
+
configuredAt: typeof raw.configuredAt === "string" ? raw.configuredAt : (/* @__PURE__ */ new Date()).toISOString()
|
|
5426
|
+
};
|
|
5295
5427
|
}
|
|
5296
|
-
/**
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
let row = null;
|
|
5307
|
-
for (const id of candidateIds) {
|
|
5308
|
-
row = this.db.prepare(
|
|
5309
|
-
`SELECT * FROM pending_outbound WHERE notification_message_id = ? AND status = 'pending'`
|
|
5310
|
-
).get(id);
|
|
5311
|
-
if (row) break;
|
|
5428
|
+
/** Get the Telegram config from agent metadata (credentials decrypted). */
|
|
5429
|
+
getConfig(agentId) {
|
|
5430
|
+
const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
|
|
5431
|
+
if (!row) return null;
|
|
5432
|
+
try {
|
|
5433
|
+
const meta = JSON.parse(row.metadata || "{}");
|
|
5434
|
+
if (!meta.telegram || typeof meta.telegram !== "object") return null;
|
|
5435
|
+
return this.decryptConfig(this.normalizeConfig(meta.telegram));
|
|
5436
|
+
} catch {
|
|
5437
|
+
return null;
|
|
5312
5438
|
}
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
const
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
action = "reject";
|
|
5439
|
+
}
|
|
5440
|
+
/** Save the Telegram config to agent metadata (credentials encrypted). */
|
|
5441
|
+
saveConfig(agentId, config) {
|
|
5442
|
+
const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
|
|
5443
|
+
if (!row) throw new Error(`Agent ${agentId} not found`);
|
|
5444
|
+
let meta;
|
|
5445
|
+
try {
|
|
5446
|
+
meta = JSON.parse(row.metadata || "{}");
|
|
5447
|
+
} catch {
|
|
5448
|
+
meta = {};
|
|
5324
5449
|
}
|
|
5325
|
-
|
|
5450
|
+
meta.telegram = this.encryptConfig(config);
|
|
5451
|
+
this.db.prepare("UPDATE agents SET metadata = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(meta), agentId);
|
|
5452
|
+
}
|
|
5453
|
+
/** Remove the Telegram config from agent metadata. */
|
|
5454
|
+
removeConfig(agentId) {
|
|
5455
|
+
const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
|
|
5456
|
+
if (!row) return;
|
|
5457
|
+
let meta;
|
|
5326
5458
|
try {
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
this.db.prepare(
|
|
5331
|
-
`UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = 'owner-reply' WHERE id = ?`
|
|
5332
|
-
).run(row.id);
|
|
5333
|
-
}
|
|
5334
|
-
await this.sendApprovalConfirmation(row, action, mail.messageId);
|
|
5335
|
-
} catch (err) {
|
|
5336
|
-
console.error(`[GatewayManager] Failed to process approval reply for ${row.id}:`, err);
|
|
5459
|
+
meta = JSON.parse(row.metadata || "{}");
|
|
5460
|
+
} catch {
|
|
5461
|
+
meta = {};
|
|
5337
5462
|
}
|
|
5338
|
-
|
|
5463
|
+
delete meta.telegram;
|
|
5464
|
+
this.db.prepare("UPDATE agents SET metadata = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(meta), agentId);
|
|
5465
|
+
}
|
|
5466
|
+
/** Persist a new poll offset without touching the rest of the config. */
|
|
5467
|
+
updatePollOffset(agentId, offset) {
|
|
5468
|
+
const config = this.getConfig(agentId);
|
|
5469
|
+
if (!config) return;
|
|
5470
|
+
config.pollOffset = offset;
|
|
5471
|
+
this.saveConfig(agentId, config);
|
|
5339
5472
|
}
|
|
5340
5473
|
/**
|
|
5341
|
-
*
|
|
5342
|
-
*
|
|
5474
|
+
* Resolve the agent that owns a webhook secret. Used to authenticate +
|
|
5475
|
+
* route an inbound Telegram webhook delivery: a webhook carries no bot
|
|
5476
|
+
* identity, so the `X-Telegram-Bot-Api-Secret-Token` header is the
|
|
5477
|
+
* routing key. The comparison is constant-time, and a non-match
|
|
5478
|
+
* returns `null` so the route can answer with a single uniform 403
|
|
5479
|
+
* (no enumeration oracle — same posture as the SMS webhook).
|
|
5343
5480
|
*/
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
const
|
|
5349
|
-
|
|
5350
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
const mailOpts = JSON.parse(row.mail_options);
|
|
5357
|
-
const ownerName = agent.metadata?.ownerName;
|
|
5358
|
-
mailOpts.fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
|
|
5359
|
-
if (Array.isArray(mailOpts.attachments)) {
|
|
5360
|
-
for (const att of mailOpts.attachments) {
|
|
5361
|
-
if (att.content && typeof att.content === "object" && att.content.type === "Buffer" && Array.isArray(att.content.data)) {
|
|
5362
|
-
att.content = Buffer.from(att.content.data);
|
|
5481
|
+
findAgentByWebhookSecret(secret) {
|
|
5482
|
+
const provided = String(secret ?? "");
|
|
5483
|
+
if (!provided) return null;
|
|
5484
|
+
const rows = this.db.prepare("SELECT id, metadata FROM agents").all();
|
|
5485
|
+
for (const row of rows) {
|
|
5486
|
+
try {
|
|
5487
|
+
const meta = JSON.parse(row.metadata || "{}");
|
|
5488
|
+
if (!meta.telegram || typeof meta.telegram !== "object") continue;
|
|
5489
|
+
const config = this.decryptConfig(this.normalizeConfig(meta.telegram));
|
|
5490
|
+
if (!config.enabled || !config.webhookSecret) continue;
|
|
5491
|
+
if (safeEqual(provided, config.webhookSecret)) {
|
|
5492
|
+
return { agentId: row.id, config };
|
|
5363
5493
|
}
|
|
5494
|
+
} catch {
|
|
5364
5495
|
}
|
|
5365
5496
|
}
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5497
|
+
return null;
|
|
5498
|
+
}
|
|
5499
|
+
/** True if an inbound message with this Telegram id is already stored. */
|
|
5500
|
+
inboundMessageExists(agentId, chatId, telegramMessageId) {
|
|
5501
|
+
const row = this.db.prepare(
|
|
5502
|
+
"SELECT 1 FROM telegram_messages WHERE agent_id = ? AND direction = ? AND chat_id = ? AND telegram_message_id = ? LIMIT 1"
|
|
5503
|
+
).get(agentId, "inbound", String(chatId), telegramMessageId);
|
|
5504
|
+
return !!row;
|
|
5505
|
+
}
|
|
5506
|
+
/** Record an inbound Telegram message. */
|
|
5507
|
+
recordInbound(agentId, input, metadata) {
|
|
5508
|
+
const id = `tg_in_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
5509
|
+
const createdAt = input.createdAt || (/* @__PURE__ */ new Date()).toISOString();
|
|
5510
|
+
this.db.prepare(
|
|
5511
|
+
"INSERT INTO telegram_messages (id, agent_id, direction, chat_id, telegram_message_id, from_id, text, status, created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
5512
|
+
).run(
|
|
5513
|
+
id,
|
|
5514
|
+
agentId,
|
|
5515
|
+
"inbound",
|
|
5516
|
+
String(input.chatId),
|
|
5517
|
+
input.telegramMessageId,
|
|
5518
|
+
input.fromId ?? null,
|
|
5519
|
+
input.text,
|
|
5520
|
+
"received",
|
|
5521
|
+
createdAt,
|
|
5522
|
+
JSON.stringify(metadata ?? {})
|
|
5523
|
+
);
|
|
5524
|
+
return {
|
|
5525
|
+
id,
|
|
5526
|
+
agentId,
|
|
5527
|
+
direction: "inbound",
|
|
5528
|
+
chatId: String(input.chatId),
|
|
5529
|
+
telegramMessageId: input.telegramMessageId,
|
|
5530
|
+
fromId: input.fromId,
|
|
5531
|
+
text: input.text,
|
|
5532
|
+
status: "received",
|
|
5533
|
+
createdAt,
|
|
5534
|
+
metadata
|
|
5535
|
+
};
|
|
5536
|
+
}
|
|
5537
|
+
/** Record an outbound Telegram message attempt. */
|
|
5538
|
+
recordOutbound(agentId, input, metadata) {
|
|
5539
|
+
const id = `tg_out_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
5540
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5541
|
+
const status = input.status ?? "sent";
|
|
5542
|
+
this.db.prepare(
|
|
5543
|
+
"INSERT INTO telegram_messages (id, agent_id, direction, chat_id, telegram_message_id, from_id, text, status, created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
5544
|
+
).run(
|
|
5545
|
+
id,
|
|
5546
|
+
agentId,
|
|
5547
|
+
"outbound",
|
|
5548
|
+
String(input.chatId),
|
|
5549
|
+
input.telegramMessageId ?? null,
|
|
5550
|
+
null,
|
|
5551
|
+
input.text,
|
|
5552
|
+
status,
|
|
5553
|
+
createdAt,
|
|
5554
|
+
JSON.stringify(metadata ?? {})
|
|
5555
|
+
);
|
|
5556
|
+
return {
|
|
5557
|
+
id,
|
|
5558
|
+
agentId,
|
|
5559
|
+
direction: "outbound",
|
|
5560
|
+
chatId: String(input.chatId),
|
|
5561
|
+
telegramMessageId: input.telegramMessageId,
|
|
5562
|
+
text: input.text,
|
|
5563
|
+
status,
|
|
5564
|
+
createdAt,
|
|
5565
|
+
metadata
|
|
5566
|
+
};
|
|
5567
|
+
}
|
|
5568
|
+
/** Update the status (+ optional metadata) of a stored message. */
|
|
5569
|
+
updateStatus(id, status, metadata) {
|
|
5570
|
+
if (metadata) {
|
|
5571
|
+
this.db.prepare("UPDATE telegram_messages SET status = ?, metadata = ? WHERE id = ?").run(status, JSON.stringify(metadata), id);
|
|
5572
|
+
return;
|
|
5573
|
+
}
|
|
5574
|
+
this.db.prepare("UPDATE telegram_messages SET status = ? WHERE id = ?").run(status, id);
|
|
5575
|
+
}
|
|
5576
|
+
/** List stored Telegram messages for an agent, newest first. */
|
|
5577
|
+
listMessages(agentId, opts) {
|
|
5578
|
+
const limit = Math.min(Math.max(opts?.limit ?? 20, 1), 100);
|
|
5579
|
+
const offset = Math.max(opts?.offset ?? 0, 0);
|
|
5580
|
+
let query = "SELECT * FROM telegram_messages WHERE agent_id = ?";
|
|
5581
|
+
const params = [agentId];
|
|
5582
|
+
if (opts?.direction === "inbound" || opts?.direction === "outbound") {
|
|
5583
|
+
query += " AND direction = ?";
|
|
5584
|
+
params.push(opts.direction);
|
|
5404
5585
|
}
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5586
|
+
if (opts?.chatId) {
|
|
5587
|
+
query += " AND chat_id = ?";
|
|
5588
|
+
params.push(String(opts.chatId));
|
|
5589
|
+
}
|
|
5590
|
+
query += " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?";
|
|
5591
|
+
params.push(limit, offset);
|
|
5592
|
+
return this.db.prepare(query).all(...params).map((row) => ({
|
|
5593
|
+
id: row.id,
|
|
5594
|
+
agentId: row.agent_id,
|
|
5595
|
+
direction: row.direction,
|
|
5596
|
+
chatId: row.chat_id,
|
|
5597
|
+
telegramMessageId: row.telegram_message_id ?? void 0,
|
|
5598
|
+
fromId: row.from_id ?? void 0,
|
|
5599
|
+
text: row.text,
|
|
5600
|
+
status: row.status,
|
|
5601
|
+
createdAt: row.created_at,
|
|
5602
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : void 0
|
|
5603
|
+
}));
|
|
5408
5604
|
}
|
|
5605
|
+
};
|
|
5606
|
+
|
|
5607
|
+
// src/telegram/poller.ts
|
|
5608
|
+
var TELEGRAM_LONG_POLL_TIMEOUT_SEC = 25;
|
|
5609
|
+
var ERROR_BACKOFF_MAX_MS = 6e4;
|
|
5610
|
+
var ERROR_BACKOFF_BASE_MS = 2e3;
|
|
5611
|
+
var TelegramPoller = class {
|
|
5612
|
+
constructor(telegramManager, agentId, options = {}) {
|
|
5613
|
+
this.telegramManager = telegramManager;
|
|
5614
|
+
this.agentId = agentId;
|
|
5615
|
+
this.options = options;
|
|
5616
|
+
}
|
|
5617
|
+
running = false;
|
|
5618
|
+
currentAbort = null;
|
|
5619
|
+
/** Wakes a sleeping backoff so `stop()` returns quickly. */
|
|
5620
|
+
wakeStop = null;
|
|
5621
|
+
lastErrorLogAt = 0;
|
|
5622
|
+
lastErrorMessage = "";
|
|
5409
5623
|
/**
|
|
5410
|
-
*
|
|
5624
|
+
* Set by the caller. Fired for every new inbound message that isn't a
|
|
5625
|
+
* duplicate and is in the allow-list. The callback's return value
|
|
5626
|
+
* gates record-as-handled (errors propagate as failures the loop
|
|
5627
|
+
* tolerates — the poll offset is STILL advanced so a single bad
|
|
5628
|
+
* message doesn't wedge the agent forever).
|
|
5411
5629
|
*/
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
|
|
5425
|
-
` To: ${Array.isArray(mailOpts.to) ? mailOpts.to.join(", ") : mailOpts.to}`,
|
|
5426
|
-
` Subject: ${mailOpts.subject}`,
|
|
5427
|
-
"",
|
|
5428
|
-
action === "approve" ? "The email has been delivered to the recipient." : "The email has been discarded and will not be sent."
|
|
5429
|
-
].join("\n"),
|
|
5430
|
-
fromName: "Agentic Mail",
|
|
5431
|
-
inReplyTo: replyMessageId
|
|
5432
|
-
}).catch((err) => {
|
|
5433
|
-
console.warn(`[GatewayManager] Failed to send approval confirmation: ${err.message}`);
|
|
5630
|
+
onInbound = null;
|
|
5631
|
+
/** Has `start()` been called and is the loop still running? */
|
|
5632
|
+
get isRunning() {
|
|
5633
|
+
return this.running;
|
|
5634
|
+
}
|
|
5635
|
+
/** Resolves when the background loop has fully exited. */
|
|
5636
|
+
loopPromise = null;
|
|
5637
|
+
async start() {
|
|
5638
|
+
if (this.running) return;
|
|
5639
|
+
this.running = true;
|
|
5640
|
+
this.loopPromise = this.loop().catch((err) => {
|
|
5641
|
+
this.running = false;
|
|
5642
|
+
console.warn(`[TelegramPoller:${this.agentId.slice(0, 8)}] loop crashed: ${err?.message ?? err}`);
|
|
5434
5643
|
});
|
|
5435
5644
|
}
|
|
5436
|
-
|
|
5437
|
-
async
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
if (!options?.skipDefaultAgent && this.accountManager) {
|
|
5443
|
-
const agentName = options?.defaultAgentName ?? DEFAULT_AGENT_NAME;
|
|
5444
|
-
const agentRole = options?.defaultAgentRole ?? DEFAULT_AGENT_ROLE;
|
|
5445
|
-
const existing = await this.accountManager.getByName(agentName);
|
|
5446
|
-
if (existing) {
|
|
5447
|
-
agent = existing;
|
|
5448
|
-
} else {
|
|
5449
|
-
agent = await this.accountManager.create({
|
|
5450
|
-
name: agentName,
|
|
5451
|
-
role: agentRole,
|
|
5452
|
-
gateway: "relay"
|
|
5453
|
-
});
|
|
5454
|
-
}
|
|
5645
|
+
/** Cancel the in-flight long-poll and wait for the loop to exit. */
|
|
5646
|
+
async stop() {
|
|
5647
|
+
this.running = false;
|
|
5648
|
+
try {
|
|
5649
|
+
this.currentAbort?.abort();
|
|
5650
|
+
} catch {
|
|
5455
5651
|
}
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
// --- Domain Mode ---
|
|
5461
|
-
async setupDomain(options) {
|
|
5462
|
-
this.cfClient = new CloudflareClient(options.cloudflareToken, options.cloudflareAccountId);
|
|
5463
|
-
this.dnsConfigurator = new DNSConfigurator(this.cfClient);
|
|
5464
|
-
this.tunnel = new TunnelManager(this.cfClient);
|
|
5465
|
-
this.domainPurchaser = new DomainPurchaser(this.cfClient);
|
|
5466
|
-
let domain = options.domain;
|
|
5467
|
-
if (!domain && options.purchase) {
|
|
5468
|
-
const available = await this.domainPurchaser.searchAvailable(
|
|
5469
|
-
options.purchase.keywords,
|
|
5470
|
-
options.purchase.tld ? [options.purchase.tld] : void 0
|
|
5471
|
-
);
|
|
5472
|
-
const first = available.find((d) => d.available && !d.premium);
|
|
5473
|
-
if (!first) {
|
|
5474
|
-
throw new Error("No available domains found for the given keywords");
|
|
5652
|
+
if (this.wakeStop) {
|
|
5653
|
+
try {
|
|
5654
|
+
this.wakeStop();
|
|
5655
|
+
} catch {
|
|
5475
5656
|
}
|
|
5476
|
-
await this.domainPurchaser.purchase(first.domain);
|
|
5477
|
-
domain = first.domain;
|
|
5478
|
-
this.db.prepare(`
|
|
5479
|
-
INSERT OR REPLACE INTO purchased_domains (domain, registrar) VALUES (?, ?)
|
|
5480
|
-
`).run(domain, "cloudflare");
|
|
5481
|
-
}
|
|
5482
|
-
if (!domain) {
|
|
5483
|
-
throw new Error("No domain specified and no purchase keywords provided");
|
|
5484
|
-
}
|
|
5485
|
-
let zone = await this.cfClient.getZone(domain);
|
|
5486
|
-
if (!zone) {
|
|
5487
|
-
zone = await this.cfClient.createZone(domain);
|
|
5488
5657
|
}
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
const { writeFileSync: writeFileSync11, mkdirSync: mkdirSync12 } = await import("fs");
|
|
5494
|
-
mkdirSync12(backupDir, { recursive: true });
|
|
5495
|
-
writeFileSync11(backupPath, JSON.stringify({
|
|
5496
|
-
domain,
|
|
5497
|
-
zoneId: zone.id,
|
|
5498
|
-
backedUpAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5499
|
-
records: existingRecords
|
|
5500
|
-
}, null, 2));
|
|
5501
|
-
console.log(`[GatewayManager] DNS backup saved to ${backupPath} (${existingRecords.length} records)`);
|
|
5502
|
-
const rootRecords = existingRecords.filter(
|
|
5503
|
-
(r) => r.name === domain && (r.type === "A" || r.type === "AAAA" || r.type === "CNAME" || r.type === "MX")
|
|
5504
|
-
);
|
|
5505
|
-
if (rootRecords.length > 0) {
|
|
5506
|
-
console.warn(`[GatewayManager] \u26A0\uFE0F WARNING: ${rootRecords.length} existing root DNS record(s) for ${domain} will be modified:`);
|
|
5507
|
-
for (const r of rootRecords) {
|
|
5508
|
-
console.warn(`[GatewayManager] ${r.type} ${r.name} \u2192 ${r.content}`);
|
|
5658
|
+
if (this.loopPromise) {
|
|
5659
|
+
try {
|
|
5660
|
+
await this.loopPromise;
|
|
5661
|
+
} catch {
|
|
5509
5662
|
}
|
|
5510
|
-
|
|
5511
|
-
}
|
|
5512
|
-
const tunnelConfig = await this.tunnel.create(`agenticmail-${domain}`);
|
|
5513
|
-
console.log(`[GatewayManager] Configuring mail server hostname: ${domain}`);
|
|
5514
|
-
try {
|
|
5515
|
-
await this.stalwart.setHostname(domain);
|
|
5516
|
-
console.log(`[GatewayManager] Mail server hostname set to ${domain}`);
|
|
5517
|
-
} catch (err) {
|
|
5518
|
-
console.warn(`[GatewayManager] Failed to set hostname (EHLO may show "localhost"): ${err.message}`);
|
|
5519
|
-
}
|
|
5520
|
-
console.log("[GatewayManager] Setting up DKIM signing...");
|
|
5521
|
-
let dkimPublicKey;
|
|
5522
|
-
let dkimSelector = "agenticmail";
|
|
5523
|
-
try {
|
|
5524
|
-
const dkim = await this.stalwart.createDkimSignature(domain, dkimSelector);
|
|
5525
|
-
dkimPublicKey = dkim.publicKey;
|
|
5526
|
-
console.log(`[GatewayManager] DKIM signature created (selector: ${dkimSelector})`);
|
|
5527
|
-
} catch (err) {
|
|
5528
|
-
console.warn(`[GatewayManager] DKIM setup failed (email may land in spam): ${err.message}`);
|
|
5663
|
+
this.loopPromise = null;
|
|
5529
5664
|
}
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5665
|
+
}
|
|
5666
|
+
async loop() {
|
|
5667
|
+
const timeoutSec = Math.max(1, this.options.timeoutSec ?? TELEGRAM_LONG_POLL_TIMEOUT_SEC);
|
|
5668
|
+
let backoff = ERROR_BACKOFF_BASE_MS;
|
|
5669
|
+
while (this.running) {
|
|
5670
|
+
const config = this.telegramManager.getConfig(this.agentId);
|
|
5671
|
+
if (!config?.enabled || config.mode !== "poll" || !config.botToken) {
|
|
5672
|
+
this.running = false;
|
|
5673
|
+
return;
|
|
5674
|
+
}
|
|
5675
|
+
const offset = config.pollOffset ?? 0;
|
|
5676
|
+
const controller = new AbortController();
|
|
5677
|
+
this.currentAbort = controller;
|
|
5678
|
+
try {
|
|
5679
|
+
const updates = await getTelegramUpdates(config.botToken, offset, {
|
|
5680
|
+
timeoutSec,
|
|
5681
|
+
signal: controller.signal
|
|
5682
|
+
});
|
|
5683
|
+
backoff = ERROR_BACKOFF_BASE_MS;
|
|
5684
|
+
if (updates.length === 0 && timeoutSec > 0) {
|
|
5685
|
+
await new Promise((r) => setImmediate(r));
|
|
5686
|
+
}
|
|
5687
|
+
for (const update of updates) {
|
|
5688
|
+
if (!this.running) break;
|
|
5689
|
+
const parsed = parseTelegramUpdate(update);
|
|
5690
|
+
if (!parsed) continue;
|
|
5691
|
+
if (!isTelegramChatAllowed(config, parsed.chatId)) continue;
|
|
5692
|
+
if (this.telegramManager.inboundMessageExists(this.agentId, parsed.chatId, parsed.messageId)) {
|
|
5693
|
+
continue;
|
|
5694
|
+
}
|
|
5695
|
+
this.telegramManager.recordInbound(this.agentId, {
|
|
5696
|
+
chatId: parsed.chatId,
|
|
5697
|
+
telegramMessageId: parsed.messageId,
|
|
5698
|
+
fromId: parsed.fromId,
|
|
5699
|
+
text: parsed.text,
|
|
5700
|
+
createdAt: parsed.date
|
|
5701
|
+
}, {
|
|
5702
|
+
chatType: parsed.chatType,
|
|
5703
|
+
fromName: parsed.fromName,
|
|
5704
|
+
fromUsername: parsed.fromUsername,
|
|
5705
|
+
updateId: parsed.updateId
|
|
5706
|
+
});
|
|
5707
|
+
if (this.onInbound) {
|
|
5708
|
+
try {
|
|
5709
|
+
await this.onInbound({ agentId: this.agentId, message: parsed, config });
|
|
5710
|
+
} catch (err) {
|
|
5711
|
+
this.logError("inbound bridge failed", err);
|
|
5712
|
+
}
|
|
5713
|
+
}
|
|
5714
|
+
}
|
|
5715
|
+
const newOffset = nextTelegramOffset(offset, updates);
|
|
5716
|
+
if (newOffset !== offset) {
|
|
5717
|
+
this.telegramManager.updatePollOffset(this.agentId, newOffset);
|
|
5718
|
+
}
|
|
5719
|
+
} catch (err) {
|
|
5720
|
+
if (!this.running) return;
|
|
5721
|
+
if (controller.signal.aborted) return;
|
|
5722
|
+
if (err instanceof TelegramApiError && (err.errorCode === 401 || err.errorCode === 404)) {
|
|
5723
|
+
this.logError("bot token rejected \u2014 stopping poller", err);
|
|
5724
|
+
this.running = false;
|
|
5725
|
+
return;
|
|
5726
|
+
}
|
|
5727
|
+
this.logError("getUpdates failed", err);
|
|
5728
|
+
await this.backoff(backoff);
|
|
5729
|
+
backoff = Math.min(backoff * 2, ERROR_BACKOFF_MAX_MS);
|
|
5730
|
+
} finally {
|
|
5731
|
+
if (this.currentAbort === controller) this.currentAbort = null;
|
|
5732
|
+
}
|
|
5533
5733
|
}
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5734
|
+
}
|
|
5735
|
+
/** Sleep that returns early on `stop()`. */
|
|
5736
|
+
backoff(ms) {
|
|
5737
|
+
return new Promise((resolve2) => {
|
|
5738
|
+
const t = setTimeout(() => {
|
|
5739
|
+
this.wakeStop = null;
|
|
5740
|
+
resolve2();
|
|
5741
|
+
}, ms);
|
|
5742
|
+
this.wakeStop = () => {
|
|
5743
|
+
clearTimeout(t);
|
|
5744
|
+
this.wakeStop = null;
|
|
5745
|
+
resolve2();
|
|
5746
|
+
};
|
|
5747
|
+
});
|
|
5748
|
+
}
|
|
5749
|
+
/** Collapse identical errors fired in close succession to one log line. */
|
|
5750
|
+
logError(prefix, err) {
|
|
5751
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5752
|
+
const suppressMs = this.options.suppressDuplicateLogsMs ?? 3e4;
|
|
5753
|
+
const now = Date.now();
|
|
5754
|
+
if (msg === this.lastErrorMessage && now - this.lastErrorLogAt < suppressMs) return;
|
|
5755
|
+
this.lastErrorLogAt = now;
|
|
5756
|
+
this.lastErrorMessage = msg;
|
|
5757
|
+
console.warn(`[TelegramPoller:${this.agentId.slice(0, 8)}] ${prefix}: ${msg}`);
|
|
5758
|
+
}
|
|
5759
|
+
};
|
|
5760
|
+
|
|
5761
|
+
// src/telegram/operator-query.ts
|
|
5762
|
+
var TELEGRAM_OPERATOR_QUERY_TAG = "AMQ";
|
|
5763
|
+
var QUERY_ID_RE = /(oq_[A-Za-z0-9-]+)/;
|
|
5764
|
+
var QUERY_TAG_RE = new RegExp(`\\[${TELEGRAM_OPERATOR_QUERY_TAG}\\s+(oq_[A-Za-z0-9-]+)\\]`);
|
|
5765
|
+
function formatOperatorQueryTelegramMessage(input) {
|
|
5766
|
+
const lines = [];
|
|
5767
|
+
lines.push(input.urgency === "high" ? "\u{1F534} Your agent needs an answer to continue a live call (URGENT)." : "\u{1F7E1} Your agent needs an answer to continue a live call.");
|
|
5768
|
+
lines.push("");
|
|
5769
|
+
lines.push(`Question: ${input.question}`);
|
|
5770
|
+
if (input.callContext) lines.push(`Context: ${input.callContext}`);
|
|
5771
|
+
lines.push("");
|
|
5772
|
+
lines.push("Reply to this message with your answer. You can also send:");
|
|
5773
|
+
lines.push(` /answer ${input.queryId} <your answer>`);
|
|
5774
|
+
lines.push(` /approve ${input.queryId} \xB7 /deny ${input.queryId}`);
|
|
5775
|
+
lines.push("");
|
|
5776
|
+
lines.push(`[${TELEGRAM_OPERATOR_QUERY_TAG} ${input.queryId}]`);
|
|
5777
|
+
return lines.join("\n");
|
|
5778
|
+
}
|
|
5779
|
+
function parseTelegramOperatorReply(input) {
|
|
5780
|
+
const text = (input.text ?? "").trim();
|
|
5781
|
+
if (!text) return null;
|
|
5782
|
+
const quotedTag = input.replyToText ? QUERY_TAG_RE.exec(input.replyToText) : null;
|
|
5783
|
+
const quotedQueryId = quotedTag?.[1];
|
|
5784
|
+
const answerCmd = /^\/answer(?:@\w+)?\s+(oq_[A-Za-z0-9-]+)\s+([\s\S]+)$/i.exec(text);
|
|
5785
|
+
if (answerCmd) {
|
|
5786
|
+
return { queryId: answerCmd[1], answer: answerCmd[2].trim(), kind: "answer" };
|
|
5787
|
+
}
|
|
5788
|
+
const decisionCmd = /^\/(approve|deny)(?:@\w+)?\b([\s\S]*)$/i.exec(text);
|
|
5789
|
+
if (decisionCmd) {
|
|
5790
|
+
const kind = decisionCmd[1].toLowerCase() === "approve" ? "approve" : "deny";
|
|
5791
|
+
const rest = decisionCmd[2].trim();
|
|
5792
|
+
const inlineId2 = QUERY_ID_RE.exec(rest)?.[1];
|
|
5793
|
+
const note = rest.replace(QUERY_ID_RE, "").trim();
|
|
5794
|
+
const answer2 = (kind === "approve" ? "Approved" : "Denied") + (note ? `: ${note}` : ".");
|
|
5795
|
+
return { queryId: inlineId2 ?? quotedQueryId, answer: answer2, kind };
|
|
5796
|
+
}
|
|
5797
|
+
const inlineId = QUERY_TAG_RE.exec(text)?.[1] ?? QUERY_ID_RE.exec(text)?.[1];
|
|
5798
|
+
const answer = text.replace(QUERY_TAG_RE, "").trim();
|
|
5799
|
+
if (!answer) return null;
|
|
5800
|
+
return { queryId: quotedQueryId ?? inlineId, answer, kind: "answer" };
|
|
5801
|
+
}
|
|
5802
|
+
|
|
5803
|
+
// src/gateway/manager.ts
|
|
5804
|
+
var GatewayManager = class {
|
|
5805
|
+
constructor(options) {
|
|
5806
|
+
this.options = options;
|
|
5807
|
+
this.db = options.db;
|
|
5808
|
+
this.stalwart = options.stalwart;
|
|
5809
|
+
this.accountManager = options.accountManager ?? null;
|
|
5810
|
+
this.encryptionKey = options.encryptionKey ?? process.env.AGENTICMAIL_MASTER_KEY ?? null;
|
|
5811
|
+
const inboundHandler = options.onInboundMail ?? (this.accountManager && options.localSmtp ? this.deliverInboundLocally.bind(this) : void 0);
|
|
5812
|
+
this.relay = new RelayGateway({
|
|
5813
|
+
onInboundMail: inboundHandler,
|
|
5814
|
+
defaultAgentName: DEFAULT_AGENT_NAME
|
|
5537
5815
|
});
|
|
5538
|
-
if (emailDns.removed.length > 0) {
|
|
5539
|
-
console.log(`[GatewayManager] Replaced ${emailDns.removed.length} old DNS record(s) for email`);
|
|
5540
|
-
}
|
|
5541
|
-
await this.tunnel.start(tunnelConfig.tunnelToken);
|
|
5542
|
-
await this.tunnel.createIngress(tunnelConfig.tunnelId, domain);
|
|
5543
|
-
console.log("[GatewayManager] Enabling Cloudflare Email Routing...");
|
|
5544
|
-
try {
|
|
5545
|
-
await this.cfClient.enableEmailRouting(zone.id);
|
|
5546
|
-
console.log("[GatewayManager] Email Routing enabled");
|
|
5547
|
-
} catch (err) {
|
|
5548
|
-
console.warn(`[GatewayManager] Email Routing enable failed (may already be active): ${err.message}`);
|
|
5549
|
-
}
|
|
5550
|
-
const workerName = `agenticmail-inbound-${domain.replace(/\./g, "-")}`;
|
|
5551
|
-
const inboundUrl = `https://${domain}/api/agenticmail/mail/inbound`;
|
|
5552
|
-
const inboundSecret = options.outboundSecret || crypto.randomUUID();
|
|
5553
|
-
console.log(`[GatewayManager] Deploying Email Worker "${workerName}"...`);
|
|
5554
|
-
console.log(`[GatewayManager] Set AGENTICMAIL_INBOUND_SECRET="${inboundSecret}" in your environment to match the worker`);
|
|
5555
5816
|
try {
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
INBOUND_SECRET: inboundSecret
|
|
5560
|
-
});
|
|
5561
|
-
console.log(`[GatewayManager] Email Worker deployed: ${workerName}`);
|
|
5562
|
-
} catch (err) {
|
|
5563
|
-
console.warn(`[GatewayManager] Email Worker deployment failed: ${err.message}`);
|
|
5564
|
-
console.warn("[GatewayManager] You may need to deploy the worker manually or check Workers permissions on your API token");
|
|
5817
|
+
this.loadConfig();
|
|
5818
|
+
} catch {
|
|
5819
|
+
this.config = { mode: "none" };
|
|
5565
5820
|
}
|
|
5566
|
-
console.log("[GatewayManager] Configuring catch-all Email Routing rule...");
|
|
5567
5821
|
try {
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
} catch (err) {
|
|
5571
|
-
console.warn(`[GatewayManager] Catch-all rule failed: ${err.message}`);
|
|
5822
|
+
this.smsManager = new SmsManager(options.db);
|
|
5823
|
+
} catch {
|
|
5572
5824
|
}
|
|
5573
5825
|
try {
|
|
5574
|
-
|
|
5575
|
-
type: "domain",
|
|
5576
|
-
name: domain,
|
|
5577
|
-
description: `AgenticMail gateway domain: ${domain}`
|
|
5578
|
-
});
|
|
5826
|
+
this.telegramManager = new TelegramManager(options.db, this.encryptionKey ?? void 0);
|
|
5579
5827
|
} catch {
|
|
5828
|
+
this.telegramManager = null;
|
|
5580
5829
|
}
|
|
5581
|
-
if (this.accountManager) {
|
|
5582
|
-
try {
|
|
5583
|
-
const agents = await this.accountManager.list();
|
|
5584
|
-
for (const agent of agents) {
|
|
5585
|
-
const domainEmail = `${agent.name.toLowerCase()}@${domain}`;
|
|
5586
|
-
try {
|
|
5587
|
-
const principal = await this.stalwart.getPrincipal(agent.stalwartPrincipal);
|
|
5588
|
-
const emails = principal.emails ?? [];
|
|
5589
|
-
if (!emails.includes(domainEmail)) {
|
|
5590
|
-
await this.stalwart.addEmailAlias(agent.stalwartPrincipal, domainEmail);
|
|
5591
|
-
console.log(`[GatewayManager] Added ${domainEmail} to Stalwart principal "${agent.stalwartPrincipal}"`);
|
|
5592
|
-
}
|
|
5593
|
-
} catch (err) {
|
|
5594
|
-
console.warn(`[GatewayManager] Could not update principal for ${agent.name}: ${err.message}`);
|
|
5595
|
-
}
|
|
5596
|
-
}
|
|
5597
|
-
} catch (err) {
|
|
5598
|
-
console.warn(`[GatewayManager] Could not update agent email aliases: ${err.message}`);
|
|
5599
|
-
}
|
|
5600
|
-
}
|
|
5601
|
-
const domainConfig = {
|
|
5602
|
-
domain,
|
|
5603
|
-
cloudflareApiToken: options.cloudflareToken,
|
|
5604
|
-
cloudflareAccountId: options.cloudflareAccountId,
|
|
5605
|
-
tunnelId: tunnelConfig.tunnelId,
|
|
5606
|
-
tunnelToken: tunnelConfig.tunnelToken,
|
|
5607
|
-
outboundWorkerUrl: options.outboundWorkerUrl,
|
|
5608
|
-
outboundSecret: options.outboundSecret,
|
|
5609
|
-
inboundSecret,
|
|
5610
|
-
emailWorkerName: workerName
|
|
5611
|
-
};
|
|
5612
|
-
this.config = { mode: "domain", domain: domainConfig };
|
|
5613
|
-
this.saveConfig();
|
|
5614
|
-
this.db.prepare(`
|
|
5615
|
-
INSERT OR REPLACE INTO purchased_domains (domain, registrar, cloudflare_zone_id, tunnel_id, dns_configured, tunnel_active)
|
|
5616
|
-
VALUES (?, 'cloudflare', ?, ?, 1, 1)
|
|
5617
|
-
`).run(domain, zone.id, tunnelConfig.tunnelId);
|
|
5618
|
-
let outboundRelay;
|
|
5619
|
-
const nextSteps = [];
|
|
5620
|
-
if (options.gmailRelay) {
|
|
5621
|
-
console.log("[GatewayManager] Configuring outbound relay through Gmail SMTP...");
|
|
5622
|
-
try {
|
|
5623
|
-
await this.stalwart.configureOutboundRelay({
|
|
5624
|
-
smtpHost: "smtp.gmail.com",
|
|
5625
|
-
smtpPort: 465,
|
|
5626
|
-
username: options.gmailRelay.email,
|
|
5627
|
-
password: options.gmailRelay.appPassword
|
|
5628
|
-
});
|
|
5629
|
-
outboundRelay = { configured: true, provider: "gmail" };
|
|
5630
|
-
console.log("[GatewayManager] Outbound relay configured: all external mail routes through Gmail SMTP");
|
|
5631
|
-
const gmailSettingsUrl = "https://mail.google.com/mail/u/0/#settings/accounts";
|
|
5632
|
-
nextSteps.push(
|
|
5633
|
-
`IMPORTANT: To send emails showing your domain (not ${options.gmailRelay.email}), add each agent email as a "Send mail as" alias in Gmail:`,
|
|
5634
|
-
`1. Open: ${gmailSettingsUrl}`,
|
|
5635
|
-
`2. Under "Send mail as" click "Add another email address"`,
|
|
5636
|
-
`3. Enter agent name and email (e.g. "Secretary" / secretary@${domain}), uncheck "Treat as alias"`,
|
|
5637
|
-
`4. On the SMTP screen, Gmail will auto-fill WRONG values. You MUST change them to:`,
|
|
5638
|
-
` SMTP Server: smtp.gmail.com | Port: 465 | Username: ${options.gmailRelay.email} | Password: [your app password] | Select "Secured connection using SSL"`,
|
|
5639
|
-
`5. Click "Add Account". Gmail sends a verification email to the agent's @${domain} address`,
|
|
5640
|
-
`6. Check AgenticMail inbox for the code/link from gmail-noreply@google.com, then confirm`,
|
|
5641
|
-
`7. Repeat for each agent. Or ask your OpenClaw agent to automate this via the browser tool.`
|
|
5642
|
-
);
|
|
5643
|
-
} catch (err) {
|
|
5644
|
-
outboundRelay = { configured: false, provider: "gmail" };
|
|
5645
|
-
console.warn(`[GatewayManager] Outbound relay setup failed: ${err.message}`);
|
|
5646
|
-
nextSteps.push(`Outbound relay setup failed: ${err.message}. You can configure it manually later.`);
|
|
5647
|
-
}
|
|
5648
|
-
} else {
|
|
5649
|
-
nextSteps.push(
|
|
5650
|
-
"Outbound email: Your server sends directly from your IP. If your IP lacks a PTR record (common for residential connections), emails may be rejected.",
|
|
5651
|
-
'To fix this, re-run setup with gmailRelay: { email: "you@gmail.com", appPassword: "xxxx xxxx xxxx xxxx" } to relay outbound mail through Gmail SMTP.',
|
|
5652
|
-
"You will need a Gmail app password: https://myaccount.google.com/apppasswords"
|
|
5653
|
-
);
|
|
5654
|
-
}
|
|
5655
|
-
return {
|
|
5656
|
-
domain,
|
|
5657
|
-
dnsConfigured: true,
|
|
5658
|
-
tunnelId: tunnelConfig.tunnelId,
|
|
5659
|
-
outboundRelay,
|
|
5660
|
-
nextSteps: nextSteps.length > 0 ? nextSteps : void 0
|
|
5661
|
-
};
|
|
5662
5830
|
}
|
|
5663
|
-
|
|
5831
|
+
db;
|
|
5832
|
+
stalwart;
|
|
5833
|
+
accountManager;
|
|
5834
|
+
relay;
|
|
5835
|
+
config = { mode: "none" };
|
|
5836
|
+
cfClient = null;
|
|
5837
|
+
tunnel = null;
|
|
5838
|
+
dnsConfigurator = null;
|
|
5839
|
+
domainPurchaser = null;
|
|
5840
|
+
smsManager = null;
|
|
5841
|
+
smsPollers = /* @__PURE__ */ new Map();
|
|
5842
|
+
telegramManager = null;
|
|
5843
|
+
telegramPollers = /* @__PURE__ */ new Map();
|
|
5844
|
+
encryptionKey = null;
|
|
5664
5845
|
/**
|
|
5665
|
-
*
|
|
5666
|
-
* In relay mode, uses "test" as the sub-address.
|
|
5667
|
-
* In domain mode, uses the first available agent (Stalwart needs real credentials).
|
|
5846
|
+
* Check if a message has already been delivered to an agent (deduplication).
|
|
5668
5847
|
*/
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
text: "This is a test email sent via the AgenticMail gateway to verify your configuration is working."
|
|
5674
|
-
};
|
|
5675
|
-
if (this.config.mode === "relay") {
|
|
5676
|
-
return this.routeOutbound("test", mail);
|
|
5677
|
-
}
|
|
5678
|
-
if (this.config.mode === "domain" && this.accountManager) {
|
|
5679
|
-
const agents = await this.accountManager.list();
|
|
5680
|
-
if (agents.length === 0) {
|
|
5681
|
-
throw new Error("No agents exist yet. Create an agent first, then send a test email.");
|
|
5682
|
-
}
|
|
5683
|
-
const primary = agents.find((a) => a.metadata?.persistent) ?? agents[0];
|
|
5684
|
-
return this.routeOutbound(primary.name, mail);
|
|
5685
|
-
}
|
|
5686
|
-
return null;
|
|
5848
|
+
isAlreadyDelivered(messageId, agentName) {
|
|
5849
|
+
if (!messageId) return false;
|
|
5850
|
+
const row = this.db.prepare("SELECT 1 FROM delivered_messages WHERE message_id = ? AND agent_name = ?").get(messageId, agentName);
|
|
5851
|
+
return !!row;
|
|
5687
5852
|
}
|
|
5688
|
-
// --- Routing ---
|
|
5689
5853
|
/**
|
|
5690
|
-
*
|
|
5691
|
-
* is configured, send via the appropriate channel.
|
|
5692
|
-
* Returns null if the mail should be sent via local Stalwart.
|
|
5854
|
+
* Record that a message was delivered to an agent.
|
|
5693
5855
|
*/
|
|
5694
|
-
|
|
5695
|
-
if (
|
|
5696
|
-
|
|
5697
|
-
if (!field) return [];
|
|
5698
|
-
if (Array.isArray(field)) return field;
|
|
5699
|
-
return field.split(",").map((s) => s.trim()).filter(Boolean);
|
|
5700
|
-
};
|
|
5701
|
-
const allRecipients = [
|
|
5702
|
-
...collect(mail.to),
|
|
5703
|
-
...collect(mail.cc),
|
|
5704
|
-
...collect(mail.bcc)
|
|
5705
|
-
];
|
|
5706
|
-
const localDomain = this.config.domain?.domain?.toLowerCase();
|
|
5707
|
-
const isExternal = allRecipients.some((addr) => {
|
|
5708
|
-
const domain = (addr.split("@")[1] ?? "localhost").toLowerCase();
|
|
5709
|
-
return domain !== "localhost" && domain !== localDomain;
|
|
5710
|
-
});
|
|
5711
|
-
if (!isExternal) return null;
|
|
5712
|
-
if (this.config.mode === "relay") {
|
|
5713
|
-
return this.relay.sendViaRelay(agentName, mail);
|
|
5714
|
-
}
|
|
5715
|
-
if (this.config.mode === "domain" && this.config.domain) {
|
|
5716
|
-
return this.sendViaStalwart(agentName, mail);
|
|
5717
|
-
}
|
|
5718
|
-
return null;
|
|
5856
|
+
recordDelivery(messageId, agentName) {
|
|
5857
|
+
if (!messageId) return;
|
|
5858
|
+
this.db.prepare("INSERT OR IGNORE INTO delivered_messages (message_id, agent_name) VALUES (?, ?)").run(messageId, agentName);
|
|
5719
5859
|
}
|
|
5720
5860
|
/**
|
|
5721
|
-
*
|
|
5722
|
-
*
|
|
5723
|
-
*
|
|
5724
|
-
*
|
|
5861
|
+
* Built-in inbound mail handler: delivers relay inbound mail to agent's local Stalwart mailbox.
|
|
5862
|
+
* Authenticates as the agent to send to their own mailbox (Stalwart requires sender = auth user).
|
|
5863
|
+
*
|
|
5864
|
+
* Also intercepts owner replies to approval notification emails — if the reply says
|
|
5865
|
+
* "approve" or "reject", the pending outbound email is automatically processed.
|
|
5725
5866
|
*/
|
|
5726
|
-
async
|
|
5727
|
-
if (!this.accountManager) {
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
const agent = await this.accountManager.getByName(agentName);
|
|
5731
|
-
if (!agent) {
|
|
5732
|
-
throw new Error(`Agent "${agentName}" not found`);
|
|
5733
|
-
}
|
|
5734
|
-
const agentPassword = agent.metadata?._password;
|
|
5735
|
-
if (!agentPassword) {
|
|
5736
|
-
throw new Error(`No password for agent "${agentName}"`);
|
|
5867
|
+
async deliverInboundLocally(agentName, mail) {
|
|
5868
|
+
if (!this.accountManager || !this.options.localSmtp) {
|
|
5869
|
+
console.warn("[GatewayManager] Cannot deliver inbound: no accountManager or localSmtp config");
|
|
5870
|
+
return;
|
|
5737
5871
|
}
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
const displayName = mail.fromName || agentName;
|
|
5741
|
-
const from = `${displayName} <${fromAddr}>`;
|
|
5742
|
-
if (domainName && fromAddr !== agent.email) {
|
|
5872
|
+
if (mail.messageId && this.isAlreadyDelivered(mail.messageId, agentName)) return;
|
|
5873
|
+
if (this.smsManager) {
|
|
5743
5874
|
try {
|
|
5744
|
-
const
|
|
5745
|
-
const
|
|
5746
|
-
if (
|
|
5747
|
-
await this.
|
|
5748
|
-
|
|
5875
|
+
const smsBody = mail.text || mail.html || "";
|
|
5876
|
+
const parsedSms = parseGoogleVoiceSms(smsBody, mail.from);
|
|
5877
|
+
if (parsedSms) {
|
|
5878
|
+
const agent2 = this.accountManager ? await this.accountManager.getByName(agentName) : null;
|
|
5879
|
+
const agentId = agent2?.id;
|
|
5880
|
+
if (agentId) {
|
|
5881
|
+
const smsConfig = this.smsManager.getSmsConfig(agentId);
|
|
5882
|
+
if (smsConfig?.enabled && smsConfig.sameAsRelay) {
|
|
5883
|
+
this.smsManager.recordInbound(agentId, parsedSms);
|
|
5884
|
+
console.log(`[GatewayManager] SMS received from ${parsedSms.from}: "${parsedSms.body.slice(0, 50)}..." \u2192 agent ${agentName}`);
|
|
5885
|
+
if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
|
|
5886
|
+
}
|
|
5887
|
+
}
|
|
5749
5888
|
}
|
|
5750
5889
|
} catch (err) {
|
|
5751
|
-
|
|
5890
|
+
debug("GatewayManager", `SMS detection error: ${err.message}`);
|
|
5891
|
+
}
|
|
5892
|
+
}
|
|
5893
|
+
try {
|
|
5894
|
+
await this.tryProcessApprovalReply(mail);
|
|
5895
|
+
} catch (err) {
|
|
5896
|
+
console.warn(`[GatewayManager] Approval reply check failed: ${err.message}`);
|
|
5897
|
+
}
|
|
5898
|
+
const parsed = inboundToParsedEmail(mail);
|
|
5899
|
+
const { isInternalEmail: isInternalEmail2 } = await import("./spam-filter-L6KNZ7QI.js");
|
|
5900
|
+
if (!isInternalEmail2(parsed)) {
|
|
5901
|
+
const spamResult = scoreEmail(parsed);
|
|
5902
|
+
if (spamResult.isSpam) {
|
|
5903
|
+
console.warn(`[GatewayManager] Spam blocked (score=${spamResult.score}, category=${spamResult.topCategory}): "${mail.subject}" from ${mail.from}`);
|
|
5904
|
+
if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
|
|
5905
|
+
return;
|
|
5906
|
+
}
|
|
5907
|
+
}
|
|
5908
|
+
let agent = await this.accountManager.getByName(agentName);
|
|
5909
|
+
if (!agent && agentName !== DEFAULT_AGENT_NAME) {
|
|
5910
|
+
agent = await this.accountManager.getByName(DEFAULT_AGENT_NAME);
|
|
5911
|
+
if (agent) {
|
|
5912
|
+
console.warn(`[GatewayManager] Agent "${agentName}" not found, delivering to default agent "${DEFAULT_AGENT_NAME}"`);
|
|
5752
5913
|
}
|
|
5753
5914
|
}
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
// mail — by design it is whatever the sender chose to compose.
|
|
5764
|
-
// CodeQL `js/xss` flags this because the value flows from user
|
|
5765
|
-
// input, but nodemailer is the SMTP serializer, not an HTML
|
|
5766
|
-
// renderer; XSS would only occur if the recipient's MUA
|
|
5767
|
-
// executed the body, which is outside our trust boundary.
|
|
5768
|
-
// The outbound-guard (packages/core/src/mail/outbound-guard.ts)
|
|
5769
|
-
// already scores HTML bodies for suspicious patterns at the
|
|
5770
|
-
// pre-send step. lgtm[js/xss]
|
|
5771
|
-
html: mail.html || void 0,
|
|
5772
|
-
replyTo: mail.replyTo || from,
|
|
5773
|
-
inReplyTo: mail.inReplyTo || void 0,
|
|
5774
|
-
references: Array.isArray(mail.references) ? mail.references.join(" ") : mail.references || void 0,
|
|
5775
|
-
headers: {
|
|
5776
|
-
"X-Mailer": "AgenticMail/1.0"
|
|
5777
|
-
},
|
|
5778
|
-
attachments: mail.attachments?.map((a) => ({
|
|
5779
|
-
filename: a.filename,
|
|
5780
|
-
content: a.content,
|
|
5781
|
-
contentType: a.contentType,
|
|
5782
|
-
encoding: a.encoding
|
|
5783
|
-
}))
|
|
5784
|
-
};
|
|
5785
|
-
const composer = new MailComposer3(mailOpts);
|
|
5786
|
-
const raw = await composer.compile().build();
|
|
5787
|
-
const smtpHost = this.options.localSmtp?.host ?? "127.0.0.1";
|
|
5788
|
-
const smtpPort = this.options.localSmtp?.port ?? 587;
|
|
5915
|
+
if (!agent) {
|
|
5916
|
+
console.warn(`[GatewayManager] No agent to deliver inbound mail (target: "${agentName}")`);
|
|
5917
|
+
return;
|
|
5918
|
+
}
|
|
5919
|
+
const agentPassword = agent.metadata?._password;
|
|
5920
|
+
if (!agentPassword) {
|
|
5921
|
+
console.warn(`[GatewayManager] No password for agent "${agentName}", cannot deliver`);
|
|
5922
|
+
return;
|
|
5923
|
+
}
|
|
5789
5924
|
const transport = nodemailer3.createTransport({
|
|
5790
|
-
host:
|
|
5791
|
-
port:
|
|
5925
|
+
host: this.options.localSmtp.host,
|
|
5926
|
+
port: this.options.localSmtp.port,
|
|
5792
5927
|
secure: false,
|
|
5793
5928
|
auth: {
|
|
5794
5929
|
user: agent.stalwartPrincipal,
|
|
5795
5930
|
pass: agentPassword
|
|
5796
5931
|
},
|
|
5797
|
-
tls: { rejectUnauthorized: false }
|
|
5932
|
+
tls: { rejectUnauthorized: false },
|
|
5933
|
+
connectionTimeout: 1e4,
|
|
5934
|
+
greetingTimeout: 1e4,
|
|
5935
|
+
socketTimeout: 15e3
|
|
5798
5936
|
});
|
|
5799
5937
|
try {
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
|
|
5806
|
-
|
|
5938
|
+
await transport.sendMail({
|
|
5939
|
+
from: `${mail.from} <${agent.email}>`,
|
|
5940
|
+
to: agent.email,
|
|
5941
|
+
subject: mail.subject,
|
|
5942
|
+
text: mail.text,
|
|
5943
|
+
html: mail.html || void 0,
|
|
5944
|
+
replyTo: mail.from,
|
|
5945
|
+
inReplyTo: mail.inReplyTo,
|
|
5946
|
+
references: Array.isArray(mail.references) ? mail.references.join(" ") : mail.references,
|
|
5947
|
+
headers: {
|
|
5948
|
+
"X-AgenticMail-Relay": "inbound",
|
|
5949
|
+
"X-Original-From": mail.from,
|
|
5950
|
+
...mail.messageId ? { "X-Original-Message-Id": mail.messageId } : {}
|
|
5951
|
+
},
|
|
5952
|
+
attachments: mail.attachments?.map((a) => ({
|
|
5953
|
+
filename: a.filename,
|
|
5954
|
+
content: a.content,
|
|
5955
|
+
contentType: a.contentType
|
|
5956
|
+
}))
|
|
5957
|
+
});
|
|
5958
|
+
if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
|
|
5959
|
+
} catch (err) {
|
|
5960
|
+
console.error(`[GatewayManager] Failed to deliver to ${agent.email}: ${err.message}`);
|
|
5961
|
+
throw err;
|
|
5807
5962
|
} finally {
|
|
5808
5963
|
transport.close();
|
|
5809
5964
|
}
|
|
5810
5965
|
}
|
|
5811
|
-
// --- Status ---
|
|
5812
|
-
getStatus() {
|
|
5813
|
-
const status = {
|
|
5814
|
-
mode: this.config.mode,
|
|
5815
|
-
healthy: false
|
|
5816
|
-
};
|
|
5817
|
-
if (this.config.mode === "relay" && this.config.relay) {
|
|
5818
|
-
status.relay = {
|
|
5819
|
-
provider: this.config.relay.provider,
|
|
5820
|
-
email: this.config.relay.email,
|
|
5821
|
-
polling: this.relay.isPolling()
|
|
5822
|
-
};
|
|
5823
|
-
status.healthy = this.relay.isConfigured();
|
|
5824
|
-
}
|
|
5825
|
-
if (this.config.mode === "domain" && this.config.domain) {
|
|
5826
|
-
const tunnelStatus = this.tunnel?.status();
|
|
5827
|
-
status.domain = {
|
|
5828
|
-
domain: this.config.domain.domain,
|
|
5829
|
-
dnsConfigured: true,
|
|
5830
|
-
tunnelActive: tunnelStatus?.running ?? false
|
|
5831
|
-
};
|
|
5832
|
-
status.healthy = tunnelStatus?.running ?? false;
|
|
5833
|
-
}
|
|
5834
|
-
if (this.config.mode === "none") {
|
|
5835
|
-
status.healthy = true;
|
|
5836
|
-
}
|
|
5837
|
-
return status;
|
|
5838
|
-
}
|
|
5839
|
-
getMode() {
|
|
5840
|
-
return this.config.mode;
|
|
5841
|
-
}
|
|
5842
|
-
getConfig() {
|
|
5843
|
-
return this.config;
|
|
5844
|
-
}
|
|
5845
|
-
// --- Domain Purchase ---
|
|
5846
|
-
getStalwart() {
|
|
5847
|
-
return this.stalwart;
|
|
5848
|
-
}
|
|
5849
|
-
getDomainPurchaser() {
|
|
5850
|
-
return this.domainPurchaser;
|
|
5851
|
-
}
|
|
5852
|
-
getDNSConfigurator() {
|
|
5853
|
-
return this.dnsConfigurator;
|
|
5854
|
-
}
|
|
5855
|
-
getTunnelManager() {
|
|
5856
|
-
return this.tunnel;
|
|
5857
|
-
}
|
|
5858
|
-
getRelay() {
|
|
5859
|
-
return this.relay;
|
|
5860
|
-
}
|
|
5861
|
-
/**
|
|
5862
|
-
* Search the connected relay account (Gmail/Outlook) for emails matching criteria.
|
|
5863
|
-
* Returns empty array if relay is not configured.
|
|
5864
|
-
*/
|
|
5865
|
-
async searchRelay(criteria, maxResults = 50) {
|
|
5866
|
-
if (this.config.mode !== "relay" || !this.relay.isConfigured()) return [];
|
|
5867
|
-
return this.relay.searchRelay(criteria, maxResults);
|
|
5868
|
-
}
|
|
5869
5966
|
/**
|
|
5870
|
-
*
|
|
5871
|
-
*
|
|
5872
|
-
*
|
|
5967
|
+
* Check if an inbound email is a reply to a pending approval notification.
|
|
5968
|
+
* If the reply body starts with "approve"/"yes" or "reject"/"no", automatically
|
|
5969
|
+
* process the pending email (send it or discard it) and confirm to the owner.
|
|
5873
5970
|
*/
|
|
5874
|
-
async
|
|
5875
|
-
|
|
5876
|
-
|
|
5971
|
+
async tryProcessApprovalReply(mail) {
|
|
5972
|
+
const candidateIds = [];
|
|
5973
|
+
if (mail.inReplyTo) candidateIds.push(mail.inReplyTo);
|
|
5974
|
+
if (mail.references) candidateIds.push(...mail.references);
|
|
5975
|
+
if (candidateIds.length === 0) return false;
|
|
5976
|
+
let row = null;
|
|
5977
|
+
for (const id of candidateIds) {
|
|
5978
|
+
row = this.db.prepare(
|
|
5979
|
+
`SELECT * FROM pending_outbound WHERE notification_message_id = ? AND status = 'pending'`
|
|
5980
|
+
).get(id);
|
|
5981
|
+
if (row) break;
|
|
5877
5982
|
}
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5983
|
+
if (!row) return false;
|
|
5984
|
+
const body = (mail.text || "").trim();
|
|
5985
|
+
const lines = body.split("\n").filter((l) => !l.startsWith(">") && l.trim().length > 0);
|
|
5986
|
+
const firstLine = (lines[0] || "").trim().toLowerCase();
|
|
5987
|
+
const approvePattern = /^(approve[d]?|yes|send\s*it|send|go\s*ahead|lgtm|ok(?:ay)?)\b/;
|
|
5988
|
+
const rejectPattern = /^(reject(?:ed)?|no|den(?:y|ied)|don'?t\s*send|do\s*not\s*send|cancel|block(?:ed)?)\b/;
|
|
5989
|
+
let action = null;
|
|
5990
|
+
if (approvePattern.test(firstLine)) {
|
|
5991
|
+
action = "approve";
|
|
5992
|
+
} else if (rejectPattern.test(firstLine)) {
|
|
5993
|
+
action = "reject";
|
|
5881
5994
|
}
|
|
5995
|
+
if (!action) return false;
|
|
5882
5996
|
try {
|
|
5883
|
-
|
|
5884
|
-
|
|
5997
|
+
if (action === "approve") {
|
|
5998
|
+
await this.executeApproval(row);
|
|
5999
|
+
} else {
|
|
6000
|
+
this.db.prepare(
|
|
6001
|
+
`UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = 'owner-reply' WHERE id = ?`
|
|
6002
|
+
).run(row.id);
|
|
6003
|
+
}
|
|
6004
|
+
await this.sendApprovalConfirmation(row, action, mail.messageId);
|
|
5885
6005
|
} catch (err) {
|
|
5886
|
-
|
|
6006
|
+
console.error(`[GatewayManager] Failed to process approval reply for ${row.id}:`, err);
|
|
5887
6007
|
}
|
|
6008
|
+
return true;
|
|
5888
6009
|
}
|
|
5889
|
-
// --- SMS Polling ---
|
|
5890
6010
|
/**
|
|
5891
|
-
*
|
|
5892
|
-
*
|
|
6011
|
+
* Execute approval of a pending outbound email: look up the agent, reconstitute
|
|
6012
|
+
* attachments, and send the email via gateway routing or local SMTP.
|
|
5893
6013
|
*/
|
|
5894
|
-
async
|
|
5895
|
-
if (!this.
|
|
5896
|
-
|
|
5897
|
-
|
|
6014
|
+
async executeApproval(row) {
|
|
6015
|
+
if (!this.accountManager) {
|
|
6016
|
+
throw new Error("AccountManager required for approval processing");
|
|
6017
|
+
}
|
|
6018
|
+
const agent = await this.accountManager.getById(row.agent_id);
|
|
6019
|
+
if (!agent) {
|
|
6020
|
+
console.warn(`[GatewayManager] Cannot approve pending ${row.id}: agent ${row.agent_id} no longer exists`);
|
|
6021
|
+
this.db.prepare(
|
|
6022
|
+
`UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = 'owner-reply', error = 'Agent no longer exists' WHERE id = ?`
|
|
6023
|
+
).run(row.id);
|
|
6024
|
+
return;
|
|
6025
|
+
}
|
|
6026
|
+
const mailOpts = JSON.parse(row.mail_options);
|
|
6027
|
+
const ownerName = agent.metadata?.ownerName;
|
|
6028
|
+
mailOpts.fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
|
|
6029
|
+
if (Array.isArray(mailOpts.attachments)) {
|
|
6030
|
+
for (const att of mailOpts.attachments) {
|
|
6031
|
+
if (att.content && typeof att.content === "object" && att.content.type === "Buffer" && Array.isArray(att.content.data)) {
|
|
6032
|
+
att.content = Buffer.from(att.content.data);
|
|
6033
|
+
}
|
|
6034
|
+
}
|
|
6035
|
+
}
|
|
6036
|
+
const gatewayResult = await this.routeOutbound(agent.name, mailOpts);
|
|
6037
|
+
if (!gatewayResult && this.options.localSmtp) {
|
|
6038
|
+
const agentPassword = agent.metadata?._password;
|
|
6039
|
+
if (!agentPassword) {
|
|
6040
|
+
throw new Error(`No password for agent "${agent.name}"`);
|
|
6041
|
+
}
|
|
6042
|
+
const transport = nodemailer3.createTransport({
|
|
6043
|
+
host: this.options.localSmtp.host,
|
|
6044
|
+
port: this.options.localSmtp.port,
|
|
6045
|
+
secure: false,
|
|
6046
|
+
auth: {
|
|
6047
|
+
user: agent.stalwartPrincipal,
|
|
6048
|
+
pass: agentPassword
|
|
6049
|
+
},
|
|
6050
|
+
tls: { rejectUnauthorized: false }
|
|
6051
|
+
});
|
|
5898
6052
|
try {
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
|
|
6053
|
+
await transport.sendMail({
|
|
6054
|
+
from: mailOpts.fromName ? `${mailOpts.fromName} <${agent.email}>` : agent.email,
|
|
6055
|
+
to: Array.isArray(mailOpts.to) ? mailOpts.to.join(", ") : mailOpts.to,
|
|
6056
|
+
subject: mailOpts.subject,
|
|
6057
|
+
text: mailOpts.text || void 0,
|
|
6058
|
+
html: mailOpts.html || void 0,
|
|
6059
|
+
cc: mailOpts.cc || void 0,
|
|
6060
|
+
bcc: mailOpts.bcc || void 0,
|
|
6061
|
+
replyTo: mailOpts.replyTo || void 0,
|
|
6062
|
+
inReplyTo: mailOpts.inReplyTo || void 0,
|
|
6063
|
+
references: Array.isArray(mailOpts.references) ? mailOpts.references.join(" ") : mailOpts.references || void 0,
|
|
6064
|
+
attachments: mailOpts.attachments?.map((a) => ({
|
|
6065
|
+
filename: a.filename,
|
|
6066
|
+
content: a.content,
|
|
6067
|
+
contentType: a.contentType,
|
|
6068
|
+
encoding: a.encoding
|
|
6069
|
+
}))
|
|
6070
|
+
});
|
|
6071
|
+
} finally {
|
|
6072
|
+
transport.close();
|
|
5910
6073
|
}
|
|
5911
6074
|
}
|
|
5912
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
await this.relay.shutdown();
|
|
5916
|
-
this.tunnel?.stop();
|
|
5917
|
-
for (const poller of this.smsPollers.values()) {
|
|
5918
|
-
poller.stopPolling();
|
|
5919
|
-
}
|
|
5920
|
-
this.smsPollers.clear();
|
|
6075
|
+
this.db.prepare(
|
|
6076
|
+
`UPDATE pending_outbound SET status = 'approved', resolved_at = datetime('now'), resolved_by = 'owner-reply' WHERE id = ?`
|
|
6077
|
+
).run(row.id);
|
|
5921
6078
|
}
|
|
5922
6079
|
/**
|
|
5923
|
-
*
|
|
5924
|
-
*
|
|
5925
|
-
* Issue #31 — On a Docker container restart the API can come up
|
|
5926
|
-
* before Stalwart / Gmail IMAP / DNS is reachable, so the very first
|
|
5927
|
-
* setup() can fail with a transient network error. Previously that
|
|
5928
|
-
* single failure was logged and never retried, leaving polling
|
|
5929
|
-
* permanently dead until someone noticed and manually revived the
|
|
5930
|
-
* relay. We now schedule background retries with exponential backoff
|
|
5931
|
-
* (5s, 10s, 20s, 40s, 60s cap, indefinite) so the relay
|
|
5932
|
-
* self-recovers as soon as the dependency is reachable again.
|
|
6080
|
+
* Send a confirmation email back to the owner after processing an approval reply.
|
|
5933
6081
|
*/
|
|
5934
|
-
async
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
|
|
6082
|
+
async sendApprovalConfirmation(row, action, replyMessageId) {
|
|
6083
|
+
const ownerEmail = this.config.relay?.email;
|
|
6084
|
+
if (!ownerEmail || !this.accountManager) return;
|
|
6085
|
+
const mailOpts = JSON.parse(row.mail_options);
|
|
6086
|
+
const agent = await this.accountManager.getById(row.agent_id);
|
|
6087
|
+
const agentName = agent?.name || "unknown agent";
|
|
6088
|
+
const statusText = action === "approve" ? "APPROVED and sent" : "REJECTED and discarded";
|
|
6089
|
+
this.routeOutbound(agentName, {
|
|
6090
|
+
to: ownerEmail,
|
|
6091
|
+
subject: `Re: [Approval Required] Blocked email from "${agentName}" \u2014 ${statusText}`,
|
|
6092
|
+
text: [
|
|
6093
|
+
`The blocked email has been ${statusText}.`,
|
|
6094
|
+
"",
|
|
6095
|
+
` To: ${Array.isArray(mailOpts.to) ? mailOpts.to.join(", ") : mailOpts.to}`,
|
|
6096
|
+
` Subject: ${mailOpts.subject}`,
|
|
6097
|
+
"",
|
|
6098
|
+
action === "approve" ? "The email has been delivered to the recipient." : "The email has been discarded and will not be sent."
|
|
6099
|
+
].join("\n"),
|
|
6100
|
+
fromName: "Agentic Mail",
|
|
6101
|
+
inReplyTo: replyMessageId
|
|
6102
|
+
}).catch((err) => {
|
|
6103
|
+
console.warn(`[GatewayManager] Failed to send approval confirmation: ${err.message}`);
|
|
6104
|
+
});
|
|
6105
|
+
}
|
|
6106
|
+
// --- Relay Mode ---
|
|
6107
|
+
async setupRelay(config, options) {
|
|
6108
|
+
await this.relay.setup(config);
|
|
6109
|
+
this.config = { mode: "relay", relay: config };
|
|
6110
|
+
this.saveConfig();
|
|
6111
|
+
let agent;
|
|
6112
|
+
if (!options?.skipDefaultAgent && this.accountManager) {
|
|
6113
|
+
const agentName = options?.defaultAgentName ?? DEFAULT_AGENT_NAME;
|
|
6114
|
+
const agentRole = options?.defaultAgentRole ?? DEFAULT_AGENT_ROLE;
|
|
6115
|
+
const existing = await this.accountManager.getByName(agentName);
|
|
6116
|
+
if (existing) {
|
|
6117
|
+
agent = existing;
|
|
6118
|
+
} else {
|
|
6119
|
+
agent = await this.accountManager.create({
|
|
6120
|
+
name: agentName,
|
|
6121
|
+
role: agentRole,
|
|
6122
|
+
gateway: "relay"
|
|
6123
|
+
});
|
|
5941
6124
|
}
|
|
5942
6125
|
}
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
6126
|
+
this.relay.onUidAdvance = (uid) => this.saveLastSeenUid(uid);
|
|
6127
|
+
await this.relay.startPolling();
|
|
6128
|
+
return { agent };
|
|
6129
|
+
}
|
|
6130
|
+
// --- Domain Mode ---
|
|
6131
|
+
async setupDomain(options) {
|
|
6132
|
+
this.cfClient = new CloudflareClient(options.cloudflareToken, options.cloudflareAccountId);
|
|
6133
|
+
this.dnsConfigurator = new DNSConfigurator(this.cfClient);
|
|
6134
|
+
this.tunnel = new TunnelManager(this.cfClient);
|
|
6135
|
+
this.domainPurchaser = new DomainPurchaser(this.cfClient);
|
|
6136
|
+
let domain = options.domain;
|
|
6137
|
+
if (!domain && options.purchase) {
|
|
6138
|
+
const available = await this.domainPurchaser.searchAvailable(
|
|
6139
|
+
options.purchase.keywords,
|
|
6140
|
+
options.purchase.tld ? [options.purchase.tld] : void 0
|
|
6141
|
+
);
|
|
6142
|
+
const first = available.find((d) => d.available && !d.premium);
|
|
6143
|
+
if (!first) {
|
|
6144
|
+
throw new Error("No available domains found for the given keywords");
|
|
5948
6145
|
}
|
|
6146
|
+
await this.domainPurchaser.purchase(first.domain);
|
|
6147
|
+
domain = first.domain;
|
|
6148
|
+
this.db.prepare(`
|
|
6149
|
+
INSERT OR REPLACE INTO purchased_domains (domain, registrar) VALUES (?, ?)
|
|
6150
|
+
`).run(domain, "cloudflare");
|
|
5949
6151
|
}
|
|
5950
|
-
if (
|
|
5951
|
-
|
|
5952
|
-
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
6152
|
+
if (!domain) {
|
|
6153
|
+
throw new Error("No domain specified and no purchase keywords provided");
|
|
6154
|
+
}
|
|
6155
|
+
let zone = await this.cfClient.getZone(domain);
|
|
6156
|
+
if (!zone) {
|
|
6157
|
+
zone = await this.cfClient.createZone(domain);
|
|
6158
|
+
}
|
|
6159
|
+
const existingRecords = await this.cfClient.listDnsRecords(zone.id);
|
|
6160
|
+
const { homedir: homedir13 } = await import("os");
|
|
6161
|
+
const backupDir = join4(homedir13(), ".agenticmail");
|
|
6162
|
+
const backupPath = join4(backupDir, `dns-backup-${domain}-${Date.now()}.json`);
|
|
6163
|
+
const { writeFileSync: writeFileSync11, mkdirSync: mkdirSync12 } = await import("fs");
|
|
6164
|
+
mkdirSync12(backupDir, { recursive: true });
|
|
6165
|
+
writeFileSync11(backupPath, JSON.stringify({
|
|
6166
|
+
domain,
|
|
6167
|
+
zoneId: zone.id,
|
|
6168
|
+
backedUpAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6169
|
+
records: existingRecords
|
|
6170
|
+
}, null, 2));
|
|
6171
|
+
console.log(`[GatewayManager] DNS backup saved to ${backupPath} (${existingRecords.length} records)`);
|
|
6172
|
+
const rootRecords = existingRecords.filter(
|
|
6173
|
+
(r) => r.name === domain && (r.type === "A" || r.type === "AAAA" || r.type === "CNAME" || r.type === "MX")
|
|
6174
|
+
);
|
|
6175
|
+
if (rootRecords.length > 0) {
|
|
6176
|
+
console.warn(`[GatewayManager] \u26A0\uFE0F WARNING: ${rootRecords.length} existing root DNS record(s) for ${domain} will be modified:`);
|
|
6177
|
+
for (const r of rootRecords) {
|
|
6178
|
+
console.warn(`[GatewayManager] ${r.type} ${r.name} \u2192 ${r.content}`);
|
|
5964
6179
|
}
|
|
6180
|
+
console.warn(`[GatewayManager] Backup saved at: ${backupPath}`);
|
|
5965
6181
|
}
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
|
|
5969
|
-
|
|
5970
|
-
|
|
5971
|
-
|
|
5972
|
-
|
|
5973
|
-
const savedUid = this.loadLastSeenUid();
|
|
5974
|
-
if (savedUid > 0) {
|
|
5975
|
-
this.relay.setLastSeenUid(savedUid);
|
|
5976
|
-
console.log(`[GatewayManager] Restored lastSeenUid=${savedUid} from database`);
|
|
6182
|
+
const tunnelConfig = await this.tunnel.create(`agenticmail-${domain}`);
|
|
6183
|
+
console.log(`[GatewayManager] Configuring mail server hostname: ${domain}`);
|
|
6184
|
+
try {
|
|
6185
|
+
await this.stalwart.setHostname(domain);
|
|
6186
|
+
console.log(`[GatewayManager] Mail server hostname set to ${domain}`);
|
|
6187
|
+
} catch (err) {
|
|
6188
|
+
console.warn(`[GatewayManager] Failed to set hostname (EHLO may show "localhost"): ${err.message}`);
|
|
5977
6189
|
}
|
|
5978
|
-
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
6190
|
+
console.log("[GatewayManager] Setting up DKIM signing...");
|
|
6191
|
+
let dkimPublicKey;
|
|
6192
|
+
let dkimSelector = "agenticmail";
|
|
6193
|
+
try {
|
|
6194
|
+
const dkim = await this.stalwart.createDkimSignature(domain, dkimSelector);
|
|
6195
|
+
dkimPublicKey = dkim.publicKey;
|
|
6196
|
+
console.log(`[GatewayManager] DKIM signature created (selector: ${dkimSelector})`);
|
|
6197
|
+
} catch (err) {
|
|
6198
|
+
console.warn(`[GatewayManager] DKIM setup failed (email may land in spam): ${err.message}`);
|
|
5982
6199
|
}
|
|
5983
|
-
this.
|
|
5984
|
-
|
|
5985
|
-
|
|
5986
|
-
|
|
5987
|
-
this.
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
5998
|
-
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
|
|
6002
|
-
|
|
6003
|
-
|
|
6004
|
-
|
|
6005
|
-
const
|
|
6006
|
-
|
|
6200
|
+
const tunnelRemoved = await this.dnsConfigurator.configureForTunnel(domain, zone.id, tunnelConfig.tunnelId);
|
|
6201
|
+
if (tunnelRemoved.length > 0) {
|
|
6202
|
+
console.log(`[GatewayManager] Removed ${tunnelRemoved.length} conflicting DNS record(s) for tunnel`);
|
|
6203
|
+
}
|
|
6204
|
+
const emailDns = await this.dnsConfigurator.configureForEmail(domain, zone.id, {
|
|
6205
|
+
dkimSelector,
|
|
6206
|
+
dkimPublicKey
|
|
6207
|
+
});
|
|
6208
|
+
if (emailDns.removed.length > 0) {
|
|
6209
|
+
console.log(`[GatewayManager] Replaced ${emailDns.removed.length} old DNS record(s) for email`);
|
|
6210
|
+
}
|
|
6211
|
+
await this.tunnel.start(tunnelConfig.tunnelToken);
|
|
6212
|
+
await this.tunnel.createIngress(tunnelConfig.tunnelId, domain);
|
|
6213
|
+
console.log("[GatewayManager] Enabling Cloudflare Email Routing...");
|
|
6214
|
+
try {
|
|
6215
|
+
await this.cfClient.enableEmailRouting(zone.id);
|
|
6216
|
+
console.log("[GatewayManager] Email Routing enabled");
|
|
6217
|
+
} catch (err) {
|
|
6218
|
+
console.warn(`[GatewayManager] Email Routing enable failed (may already be active): ${err.message}`);
|
|
6219
|
+
}
|
|
6220
|
+
const workerName = `agenticmail-inbound-${domain.replace(/\./g, "-")}`;
|
|
6221
|
+
const inboundUrl = `https://${domain}/api/agenticmail/mail/inbound`;
|
|
6222
|
+
const inboundSecret = options.outboundSecret || crypto.randomUUID();
|
|
6223
|
+
console.log(`[GatewayManager] Deploying Email Worker "${workerName}"...`);
|
|
6224
|
+
console.log(`[GatewayManager] Set AGENTICMAIL_INBOUND_SECRET="${inboundSecret}" in your environment to match the worker`);
|
|
6225
|
+
try {
|
|
6226
|
+
const { EMAIL_WORKER_SCRIPT } = await import("./email-worker-template-BOJPKCVB.js");
|
|
6227
|
+
await this.cfClient.deployEmailWorker(workerName, EMAIL_WORKER_SCRIPT, {
|
|
6228
|
+
INBOUND_URL: inboundUrl,
|
|
6229
|
+
INBOUND_SECRET: inboundSecret
|
|
6230
|
+
});
|
|
6231
|
+
console.log(`[GatewayManager] Email Worker deployed: ${workerName}`);
|
|
6232
|
+
} catch (err) {
|
|
6233
|
+
console.warn(`[GatewayManager] Email Worker deployment failed: ${err.message}`);
|
|
6234
|
+
console.warn("[GatewayManager] You may need to deploy the worker manually or check Workers permissions on your API token");
|
|
6235
|
+
}
|
|
6236
|
+
console.log("[GatewayManager] Configuring catch-all Email Routing rule...");
|
|
6237
|
+
try {
|
|
6238
|
+
await this.cfClient.setCatchAllWorkerRule(zone.id, workerName);
|
|
6239
|
+
console.log("[GatewayManager] Catch-all rule set: all emails \u2192 Worker \u2192 AgenticMail");
|
|
6240
|
+
} catch (err) {
|
|
6241
|
+
console.warn(`[GatewayManager] Catch-all rule failed: ${err.message}`);
|
|
6242
|
+
}
|
|
6243
|
+
try {
|
|
6244
|
+
await this.stalwart.createPrincipal({
|
|
6245
|
+
type: "domain",
|
|
6246
|
+
name: domain,
|
|
6247
|
+
description: `AgenticMail gateway domain: ${domain}`
|
|
6248
|
+
});
|
|
6249
|
+
} catch {
|
|
6250
|
+
}
|
|
6251
|
+
if (this.accountManager) {
|
|
6007
6252
|
try {
|
|
6008
|
-
const
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
6017
|
-
try {
|
|
6018
|
-
parsed.relay.appPassword = decryptSecret(parsed.relay.appPassword, this.encryptionKey);
|
|
6019
|
-
} catch {
|
|
6020
|
-
}
|
|
6021
|
-
}
|
|
6022
|
-
if (parsed.domain?.cloudflareApiToken) {
|
|
6023
|
-
try {
|
|
6024
|
-
parsed.domain.cloudflareApiToken = decryptSecret(parsed.domain.cloudflareApiToken, this.encryptionKey);
|
|
6025
|
-
} catch {
|
|
6026
|
-
}
|
|
6027
|
-
}
|
|
6028
|
-
if (parsed.domain?.tunnelToken) {
|
|
6029
|
-
try {
|
|
6030
|
-
parsed.domain.tunnelToken = decryptSecret(parsed.domain.tunnelToken, this.encryptionKey);
|
|
6031
|
-
} catch {
|
|
6032
|
-
}
|
|
6033
|
-
}
|
|
6034
|
-
if (parsed.domain?.inboundSecret) {
|
|
6035
|
-
try {
|
|
6036
|
-
parsed.domain.inboundSecret = decryptSecret(parsed.domain.inboundSecret, this.encryptionKey);
|
|
6037
|
-
} catch {
|
|
6038
|
-
}
|
|
6039
|
-
}
|
|
6040
|
-
if (parsed.domain?.outboundSecret) {
|
|
6041
|
-
try {
|
|
6042
|
-
parsed.domain.outboundSecret = decryptSecret(parsed.domain.outboundSecret, this.encryptionKey);
|
|
6043
|
-
} catch {
|
|
6253
|
+
const agents = await this.accountManager.list();
|
|
6254
|
+
for (const agent of agents) {
|
|
6255
|
+
const domainEmail = `${agent.name.toLowerCase()}@${domain}`;
|
|
6256
|
+
try {
|
|
6257
|
+
const principal = await this.stalwart.getPrincipal(agent.stalwartPrincipal);
|
|
6258
|
+
const emails = principal.emails ?? [];
|
|
6259
|
+
if (!emails.includes(domainEmail)) {
|
|
6260
|
+
await this.stalwart.addEmailAlias(agent.stalwartPrincipal, domainEmail);
|
|
6261
|
+
console.log(`[GatewayManager] Added ${domainEmail} to Stalwart principal "${agent.stalwartPrincipal}"`);
|
|
6044
6262
|
}
|
|
6263
|
+
} catch (err) {
|
|
6264
|
+
console.warn(`[GatewayManager] Could not update principal for ${agent.name}: ${err.message}`);
|
|
6045
6265
|
}
|
|
6046
6266
|
}
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
...parsed
|
|
6050
|
-
};
|
|
6051
|
-
} catch {
|
|
6052
|
-
this.config = { mode: "none" };
|
|
6267
|
+
} catch (err) {
|
|
6268
|
+
console.warn(`[GatewayManager] Could not update agent email aliases: ${err.message}`);
|
|
6053
6269
|
}
|
|
6054
6270
|
}
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
|
|
6058
|
-
|
|
6059
|
-
|
|
6060
|
-
|
|
6061
|
-
|
|
6062
|
-
|
|
6063
|
-
|
|
6064
|
-
|
|
6065
|
-
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
|
|
6075
|
-
|
|
6076
|
-
|
|
6271
|
+
const domainConfig = {
|
|
6272
|
+
domain,
|
|
6273
|
+
cloudflareApiToken: options.cloudflareToken,
|
|
6274
|
+
cloudflareAccountId: options.cloudflareAccountId,
|
|
6275
|
+
tunnelId: tunnelConfig.tunnelId,
|
|
6276
|
+
tunnelToken: tunnelConfig.tunnelToken,
|
|
6277
|
+
outboundWorkerUrl: options.outboundWorkerUrl,
|
|
6278
|
+
outboundSecret: options.outboundSecret,
|
|
6279
|
+
inboundSecret,
|
|
6280
|
+
emailWorkerName: workerName
|
|
6281
|
+
};
|
|
6282
|
+
this.config = { mode: "domain", domain: domainConfig };
|
|
6283
|
+
this.saveConfig();
|
|
6284
|
+
this.db.prepare(`
|
|
6285
|
+
INSERT OR REPLACE INTO purchased_domains (domain, registrar, cloudflare_zone_id, tunnel_id, dns_configured, tunnel_active)
|
|
6286
|
+
VALUES (?, 'cloudflare', ?, ?, 1, 1)
|
|
6287
|
+
`).run(domain, zone.id, tunnelConfig.tunnelId);
|
|
6288
|
+
let outboundRelay;
|
|
6289
|
+
const nextSteps = [];
|
|
6290
|
+
if (options.gmailRelay) {
|
|
6291
|
+
console.log("[GatewayManager] Configuring outbound relay through Gmail SMTP...");
|
|
6292
|
+
try {
|
|
6293
|
+
await this.stalwart.configureOutboundRelay({
|
|
6294
|
+
smtpHost: "smtp.gmail.com",
|
|
6295
|
+
smtpPort: 465,
|
|
6296
|
+
username: options.gmailRelay.email,
|
|
6297
|
+
password: options.gmailRelay.appPassword
|
|
6298
|
+
});
|
|
6299
|
+
outboundRelay = { configured: true, provider: "gmail" };
|
|
6300
|
+
console.log("[GatewayManager] Outbound relay configured: all external mail routes through Gmail SMTP");
|
|
6301
|
+
const gmailSettingsUrl = "https://mail.google.com/mail/u/0/#settings/accounts";
|
|
6302
|
+
nextSteps.push(
|
|
6303
|
+
`IMPORTANT: To send emails showing your domain (not ${options.gmailRelay.email}), add each agent email as a "Send mail as" alias in Gmail:`,
|
|
6304
|
+
`1. Open: ${gmailSettingsUrl}`,
|
|
6305
|
+
`2. Under "Send mail as" click "Add another email address"`,
|
|
6306
|
+
`3. Enter agent name and email (e.g. "Secretary" / secretary@${domain}), uncheck "Treat as alias"`,
|
|
6307
|
+
`4. On the SMTP screen, Gmail will auto-fill WRONG values. You MUST change them to:`,
|
|
6308
|
+
` SMTP Server: smtp.gmail.com | Port: 465 | Username: ${options.gmailRelay.email} | Password: [your app password] | Select "Secured connection using SSL"`,
|
|
6309
|
+
`5. Click "Add Account". Gmail sends a verification email to the agent's @${domain} address`,
|
|
6310
|
+
`6. Check AgenticMail inbox for the code/link from gmail-noreply@google.com, then confirm`,
|
|
6311
|
+
`7. Repeat for each agent. Or ask your OpenClaw agent to automate this via the browser tool.`
|
|
6312
|
+
);
|
|
6313
|
+
} catch (err) {
|
|
6314
|
+
outboundRelay = { configured: false, provider: "gmail" };
|
|
6315
|
+
console.warn(`[GatewayManager] Outbound relay setup failed: ${err.message}`);
|
|
6316
|
+
nextSteps.push(`Outbound relay setup failed: ${err.message}. You can configure it manually later.`);
|
|
6077
6317
|
}
|
|
6318
|
+
} else {
|
|
6319
|
+
nextSteps.push(
|
|
6320
|
+
"Outbound email: Your server sends directly from your IP. If your IP lacks a PTR record (common for residential connections), emails may be rejected.",
|
|
6321
|
+
'To fix this, re-run setup with gmailRelay: { email: "you@gmail.com", appPassword: "xxxx xxxx xxxx xxxx" } to relay outbound mail through Gmail SMTP.',
|
|
6322
|
+
"You will need a Gmail app password: https://myaccount.google.com/apppasswords"
|
|
6323
|
+
);
|
|
6078
6324
|
}
|
|
6079
|
-
|
|
6080
|
-
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
|
|
6086
|
-
INSERT OR REPLACE INTO config (key, value) VALUES ('relay_last_seen_uid', ?)
|
|
6087
|
-
`).run(String(uid));
|
|
6088
|
-
}
|
|
6089
|
-
loadLastSeenUid() {
|
|
6090
|
-
const row = this.db.prepare("SELECT value FROM config WHERE key = ?").get("relay_last_seen_uid");
|
|
6091
|
-
return row ? parseInt(row.value, 10) || 0 : 0;
|
|
6092
|
-
}
|
|
6093
|
-
};
|
|
6094
|
-
function parseAddressString(addr) {
|
|
6095
|
-
const match = addr.match(/^(.+?)\s*<([^>]+)>$/);
|
|
6096
|
-
if (match) {
|
|
6097
|
-
return { name: match[1].trim(), address: match[2].trim() };
|
|
6325
|
+
return {
|
|
6326
|
+
domain,
|
|
6327
|
+
dnsConfigured: true,
|
|
6328
|
+
tunnelId: tunnelConfig.tunnelId,
|
|
6329
|
+
outboundRelay,
|
|
6330
|
+
nextSteps: nextSteps.length > 0 ? nextSteps : void 0
|
|
6331
|
+
};
|
|
6098
6332
|
}
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
import { createServer } from "http";
|
|
6124
|
-
import { createTransport } from "nodemailer";
|
|
6125
|
-
var RelayBridge = class {
|
|
6126
|
-
server = null;
|
|
6127
|
-
options;
|
|
6128
|
-
constructor(options) {
|
|
6129
|
-
this.options = options;
|
|
6333
|
+
// --- Test ---
|
|
6334
|
+
/**
|
|
6335
|
+
* Send a test email through the gateway without requiring a real agent.
|
|
6336
|
+
* In relay mode, uses "test" as the sub-address.
|
|
6337
|
+
* In domain mode, uses the first available agent (Stalwart needs real credentials).
|
|
6338
|
+
*/
|
|
6339
|
+
async sendTestEmail(to) {
|
|
6340
|
+
const mail = {
|
|
6341
|
+
to,
|
|
6342
|
+
subject: "AgenticMail Gateway Test",
|
|
6343
|
+
text: "This is a test email sent via the AgenticMail gateway to verify your configuration is working."
|
|
6344
|
+
};
|
|
6345
|
+
if (this.config.mode === "relay") {
|
|
6346
|
+
return this.routeOutbound("test", mail);
|
|
6347
|
+
}
|
|
6348
|
+
if (this.config.mode === "domain" && this.accountManager) {
|
|
6349
|
+
const agents = await this.accountManager.list();
|
|
6350
|
+
if (agents.length === 0) {
|
|
6351
|
+
throw new Error("No agents exist yet. Create an agent first, then send a test email.");
|
|
6352
|
+
}
|
|
6353
|
+
const primary = agents.find((a) => a.metadata?.persistent) ?? agents[0];
|
|
6354
|
+
return this.routeOutbound(primary.name, mail);
|
|
6355
|
+
}
|
|
6356
|
+
return null;
|
|
6130
6357
|
}
|
|
6131
|
-
|
|
6132
|
-
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
6358
|
+
// --- Routing ---
|
|
6359
|
+
/**
|
|
6360
|
+
* Route an outbound email. If the destination is external and a gateway
|
|
6361
|
+
* is configured, send via the appropriate channel.
|
|
6362
|
+
* Returns null if the mail should be sent via local Stalwart.
|
|
6363
|
+
*/
|
|
6364
|
+
async routeOutbound(agentName, mail) {
|
|
6365
|
+
if (this.config.mode === "none") return null;
|
|
6366
|
+
const collect = (field) => {
|
|
6367
|
+
if (!field) return [];
|
|
6368
|
+
if (Array.isArray(field)) return field;
|
|
6369
|
+
return field.split(",").map((s) => s.trim()).filter(Boolean);
|
|
6370
|
+
};
|
|
6371
|
+
const allRecipients = [
|
|
6372
|
+
...collect(mail.to),
|
|
6373
|
+
...collect(mail.cc),
|
|
6374
|
+
...collect(mail.bcc)
|
|
6375
|
+
];
|
|
6376
|
+
const localDomain = this.config.domain?.domain?.toLowerCase();
|
|
6377
|
+
const isExternal = allRecipients.some((addr) => {
|
|
6378
|
+
const domain = (addr.split("@")[1] ?? "localhost").toLowerCase();
|
|
6379
|
+
return domain !== "localhost" && domain !== localDomain;
|
|
6139
6380
|
});
|
|
6381
|
+
if (!isExternal) return null;
|
|
6382
|
+
if (this.config.mode === "relay") {
|
|
6383
|
+
return this.relay.sendViaRelay(agentName, mail);
|
|
6384
|
+
}
|
|
6385
|
+
if (this.config.mode === "domain" && this.config.domain) {
|
|
6386
|
+
return this.sendViaStalwart(agentName, mail);
|
|
6387
|
+
}
|
|
6388
|
+
return null;
|
|
6140
6389
|
}
|
|
6141
|
-
|
|
6142
|
-
|
|
6143
|
-
|
|
6144
|
-
|
|
6145
|
-
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
|
|
6390
|
+
/**
|
|
6391
|
+
* Send email by submitting to local Stalwart via SMTP (port 587).
|
|
6392
|
+
* Stalwart handles DKIM signing and delivery (direct or via relay).
|
|
6393
|
+
* Reply-To is set to the agent's domain email so replies come back
|
|
6394
|
+
* to the domain (handled by Cloudflare Email Routing → inbound Worker).
|
|
6395
|
+
*/
|
|
6396
|
+
async sendViaStalwart(agentName, mail) {
|
|
6397
|
+
if (!this.accountManager) {
|
|
6398
|
+
throw new Error("AccountManager required for domain mode outbound");
|
|
6150
6399
|
}
|
|
6151
|
-
const
|
|
6152
|
-
if (
|
|
6153
|
-
|
|
6154
|
-
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
6155
|
-
return;
|
|
6400
|
+
const agent = await this.accountManager.getByName(agentName);
|
|
6401
|
+
if (!agent) {
|
|
6402
|
+
throw new Error(`Agent "${agentName}" not found`);
|
|
6156
6403
|
}
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
const payload = JSON.parse(body);
|
|
6161
|
-
const result = await this.submitToStalwart(payload);
|
|
6162
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
6163
|
-
res.end(JSON.stringify(result));
|
|
6164
|
-
} catch (err) {
|
|
6165
|
-
console.error("[RelayBridge] Delivery failed:", err.message);
|
|
6166
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
6167
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
6404
|
+
const agentPassword = agent.metadata?._password;
|
|
6405
|
+
if (!agentPassword) {
|
|
6406
|
+
throw new Error(`No password for agent "${agentName}"`);
|
|
6168
6407
|
}
|
|
6169
|
-
|
|
6170
|
-
|
|
6171
|
-
const
|
|
6172
|
-
const
|
|
6173
|
-
|
|
6174
|
-
|
|
6175
|
-
|
|
6176
|
-
|
|
6408
|
+
const domainName = this.config.domain?.domain;
|
|
6409
|
+
const fromAddr = domainName ? agent.email.replace(/@localhost$/, `@${domainName}`) : agent.email;
|
|
6410
|
+
const displayName = mail.fromName || agentName;
|
|
6411
|
+
const from = `${displayName} <${fromAddr}>`;
|
|
6412
|
+
if (domainName && fromAddr !== agent.email) {
|
|
6413
|
+
try {
|
|
6414
|
+
const principal = await this.stalwart.getPrincipal(agent.stalwartPrincipal);
|
|
6415
|
+
const emails = principal.emails ?? [];
|
|
6416
|
+
if (!emails.includes(fromAddr)) {
|
|
6417
|
+
await this.stalwart.addEmailAlias(agent.stalwartPrincipal, fromAddr);
|
|
6418
|
+
console.log(`[GatewayManager] Auto-added ${fromAddr} to Stalwart principal "${agent.stalwartPrincipal}"`);
|
|
6419
|
+
}
|
|
6420
|
+
} catch (err) {
|
|
6421
|
+
console.warn(`[GatewayManager] Could not auto-add domain email alias: ${err.message}`);
|
|
6422
|
+
}
|
|
6423
|
+
}
|
|
6424
|
+
const recipients = Array.isArray(mail.to) ? mail.to : [mail.to];
|
|
6425
|
+
const mailOpts = {
|
|
6426
|
+
from,
|
|
6427
|
+
to: recipients.join(", "),
|
|
6428
|
+
cc: mail.cc ? Array.isArray(mail.cc) ? mail.cc.join(", ") : mail.cc : void 0,
|
|
6429
|
+
bcc: mail.bcc ? Array.isArray(mail.bcc) ? mail.bcc.join(", ") : mail.bcc : void 0,
|
|
6430
|
+
subject: mail.subject,
|
|
6431
|
+
text: mail.text || void 0,
|
|
6432
|
+
// The `html` field is the literal HTML body of the outbound
|
|
6433
|
+
// mail — by design it is whatever the sender chose to compose.
|
|
6434
|
+
// CodeQL `js/xss` flags this because the value flows from user
|
|
6435
|
+
// input, but nodemailer is the SMTP serializer, not an HTML
|
|
6436
|
+
// renderer; XSS would only occur if the recipient's MUA
|
|
6437
|
+
// executed the body, which is outside our trust boundary.
|
|
6438
|
+
// The outbound-guard (packages/core/src/mail/outbound-guard.ts)
|
|
6439
|
+
// already scores HTML bodies for suspicious patterns at the
|
|
6440
|
+
// pre-send step. lgtm[js/xss]
|
|
6441
|
+
html: mail.html || void 0,
|
|
6442
|
+
replyTo: mail.replyTo || from,
|
|
6443
|
+
inReplyTo: mail.inReplyTo || void 0,
|
|
6444
|
+
references: Array.isArray(mail.references) ? mail.references.join(" ") : mail.references || void 0,
|
|
6445
|
+
headers: {
|
|
6446
|
+
"X-Mailer": "AgenticMail/1.0"
|
|
6447
|
+
},
|
|
6448
|
+
attachments: mail.attachments?.map((a) => ({
|
|
6449
|
+
filename: a.filename,
|
|
6450
|
+
content: a.content,
|
|
6451
|
+
contentType: a.contentType,
|
|
6452
|
+
encoding: a.encoding
|
|
6453
|
+
}))
|
|
6454
|
+
};
|
|
6455
|
+
const composer = new MailComposer3(mailOpts);
|
|
6456
|
+
const raw = await composer.compile().build();
|
|
6457
|
+
const smtpHost = this.options.localSmtp?.host ?? "127.0.0.1";
|
|
6458
|
+
const smtpPort = this.options.localSmtp?.port ?? 587;
|
|
6459
|
+
const transport = nodemailer3.createTransport({
|
|
6460
|
+
host: smtpHost,
|
|
6461
|
+
port: smtpPort,
|
|
6177
6462
|
secure: false,
|
|
6178
6463
|
auth: {
|
|
6179
|
-
user:
|
|
6180
|
-
pass:
|
|
6464
|
+
user: agent.stalwartPrincipal,
|
|
6465
|
+
pass: agentPassword
|
|
6181
6466
|
},
|
|
6182
6467
|
tls: { rejectUnauthorized: false }
|
|
6183
6468
|
});
|
|
6184
6469
|
try {
|
|
6185
|
-
const info = await transport.sendMail(
|
|
6186
|
-
|
|
6187
|
-
to: recipients.join(", "),
|
|
6188
|
-
subject,
|
|
6189
|
-
text: text || void 0,
|
|
6190
|
-
html: html || void 0,
|
|
6191
|
-
replyTo: replyTo || void 0,
|
|
6192
|
-
inReplyTo: inReplyTo || void 0,
|
|
6193
|
-
references: references || void 0,
|
|
6194
|
-
headers: {
|
|
6195
|
-
"X-Mailer": "AgenticMail/1.0"
|
|
6196
|
-
}
|
|
6197
|
-
});
|
|
6198
|
-
debug("RelayBridge", `Queued: ${info.messageId} \u2192 ${info.response}`);
|
|
6470
|
+
const info = await transport.sendMail(mailOpts);
|
|
6471
|
+
debug("GatewayManager", `Sent via Stalwart: ${info.messageId} \u2192 ${info.response}`);
|
|
6199
6472
|
return {
|
|
6200
|
-
ok: true,
|
|
6201
6473
|
messageId: info.messageId,
|
|
6202
|
-
|
|
6474
|
+
envelope: { from, to: recipients },
|
|
6475
|
+
raw
|
|
6203
6476
|
};
|
|
6204
6477
|
} finally {
|
|
6205
6478
|
transport.close();
|
|
6206
6479
|
}
|
|
6207
6480
|
}
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
6481
|
+
// --- Status ---
|
|
6482
|
+
getStatus() {
|
|
6483
|
+
const status = {
|
|
6484
|
+
mode: this.config.mode,
|
|
6485
|
+
healthy: false
|
|
6486
|
+
};
|
|
6487
|
+
if (this.config.mode === "relay" && this.config.relay) {
|
|
6488
|
+
status.relay = {
|
|
6489
|
+
provider: this.config.relay.provider,
|
|
6490
|
+
email: this.config.relay.email,
|
|
6491
|
+
polling: this.relay.isPolling()
|
|
6492
|
+
};
|
|
6493
|
+
status.healthy = this.relay.isConfigured();
|
|
6494
|
+
}
|
|
6495
|
+
if (this.config.mode === "domain" && this.config.domain) {
|
|
6496
|
+
const tunnelStatus = this.tunnel?.status();
|
|
6497
|
+
status.domain = {
|
|
6498
|
+
domain: this.config.domain.domain,
|
|
6499
|
+
dnsConfigured: true,
|
|
6500
|
+
tunnelActive: tunnelStatus?.running ?? false
|
|
6501
|
+
};
|
|
6502
|
+
status.healthy = tunnelStatus?.running ?? false;
|
|
6503
|
+
}
|
|
6504
|
+
if (this.config.mode === "none") {
|
|
6505
|
+
status.healthy = true;
|
|
6506
|
+
}
|
|
6507
|
+
return status;
|
|
6230
6508
|
}
|
|
6231
|
-
|
|
6232
|
-
|
|
6233
|
-
// src/telegram/client.ts
|
|
6234
|
-
var TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
6235
|
-
var TELEGRAM_MESSAGE_LIMIT = 4096;
|
|
6236
|
-
var TELEGRAM_CHUNK_SIZE = 4e3;
|
|
6237
|
-
var TelegramApiError = class extends Error {
|
|
6238
|
-
isTelegramApiError = true;
|
|
6239
|
-
description;
|
|
6240
|
-
errorCode;
|
|
6241
|
-
constructor(method, description, errorCode) {
|
|
6242
|
-
super(`Telegram ${method} failed: ${description}${errorCode ? ` (code ${errorCode})` : ""}`);
|
|
6243
|
-
this.name = "TelegramApiError";
|
|
6244
|
-
this.description = description;
|
|
6245
|
-
this.errorCode = errorCode;
|
|
6509
|
+
getMode() {
|
|
6510
|
+
return this.config.mode;
|
|
6246
6511
|
}
|
|
6247
|
-
|
|
6248
|
-
|
|
6249
|
-
let out = typeof text === "string" ? text : String(text);
|
|
6250
|
-
if (token) out = out.split(token).join("bot***");
|
|
6251
|
-
return out.replace(/\d{6,}:[A-Za-z0-9_-]{30,}/g, "bot***");
|
|
6252
|
-
}
|
|
6253
|
-
async function callTelegramApi(token, method, body, options = {}) {
|
|
6254
|
-
if (!token || typeof token !== "string") {
|
|
6255
|
-
throw new TelegramApiError(method, "bot token is required");
|
|
6512
|
+
getConfig() {
|
|
6513
|
+
return this.config;
|
|
6256
6514
|
}
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
try {
|
|
6261
|
-
response = await fetch(`${TELEGRAM_API_BASE}/bot${token}/${method}`, {
|
|
6262
|
-
method: "POST",
|
|
6263
|
-
headers: { "Content-Type": "application/json" },
|
|
6264
|
-
body: body ? JSON.stringify(body) : void 0,
|
|
6265
|
-
signal: AbortSignal.timeout(timeoutMs)
|
|
6266
|
-
});
|
|
6267
|
-
} catch (err) {
|
|
6268
|
-
throw new TelegramApiError(method, redactBotToken(err?.message ?? String(err), token));
|
|
6515
|
+
// --- Domain Purchase ---
|
|
6516
|
+
getStalwart() {
|
|
6517
|
+
return this.stalwart;
|
|
6269
6518
|
}
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
|
|
6519
|
+
getDomainPurchaser() {
|
|
6520
|
+
return this.domainPurchaser;
|
|
6521
|
+
}
|
|
6522
|
+
getDNSConfigurator() {
|
|
6523
|
+
return this.dnsConfigurator;
|
|
6524
|
+
}
|
|
6525
|
+
getTunnelManager() {
|
|
6526
|
+
return this.tunnel;
|
|
6527
|
+
}
|
|
6528
|
+
getRelay() {
|
|
6529
|
+
return this.relay;
|
|
6530
|
+
}
|
|
6531
|
+
/**
|
|
6532
|
+
* Search the connected relay account (Gmail/Outlook) for emails matching criteria.
|
|
6533
|
+
* Returns empty array if relay is not configured.
|
|
6534
|
+
*/
|
|
6535
|
+
async searchRelay(criteria, maxResults = 50) {
|
|
6536
|
+
if (this.config.mode !== "relay" || !this.relay.isConfigured()) return [];
|
|
6537
|
+
return this.relay.searchRelay(criteria, maxResults);
|
|
6538
|
+
}
|
|
6539
|
+
/**
|
|
6540
|
+
* Import an email from the connected relay account into an agent's local inbox.
|
|
6541
|
+
* Fetches the full message from relay IMAP and delivers it locally, preserving
|
|
6542
|
+
* all headers (Message-ID, In-Reply-To, References) for thread continuity.
|
|
6543
|
+
*/
|
|
6544
|
+
async importRelayMessage(relayUid, agentName) {
|
|
6545
|
+
if (this.config.mode !== "relay" || !this.relay.isConfigured()) {
|
|
6546
|
+
return { success: false, error: "Relay not configured" };
|
|
6547
|
+
}
|
|
6548
|
+
const mail = await this.relay.fetchRelayMessage(relayUid);
|
|
6549
|
+
if (!mail) {
|
|
6550
|
+
return { success: false, error: "Could not fetch message from relay account" };
|
|
6551
|
+
}
|
|
6552
|
+
try {
|
|
6553
|
+
await this.deliverInboundLocally(agentName, mail);
|
|
6554
|
+
return { success: true };
|
|
6555
|
+
} catch (err) {
|
|
6556
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
6557
|
+
}
|
|
6275
6558
|
}
|
|
6276
|
-
|
|
6277
|
-
|
|
6278
|
-
|
|
6279
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6559
|
+
// --- SMS Polling ---
|
|
6560
|
+
/**
|
|
6561
|
+
* Start SMS pollers for all agents that have separate GV Gmail credentials.
|
|
6562
|
+
* Agents with sameAsRelay=true are handled in deliverInboundLocally.
|
|
6563
|
+
*/
|
|
6564
|
+
async startSmsPollers() {
|
|
6565
|
+
if (!this.smsManager || !this.accountManager) return;
|
|
6566
|
+
const agents = this.db.prepare("SELECT id, name, metadata FROM agents").all();
|
|
6567
|
+
for (const agent of agents) {
|
|
6568
|
+
try {
|
|
6569
|
+
const meta = JSON.parse(agent.metadata || "{}");
|
|
6570
|
+
const smsConfig = meta.sms;
|
|
6571
|
+
if (!smsConfig?.enabled || !smsConfig.forwardingPassword || smsConfig.sameAsRelay) continue;
|
|
6572
|
+
const poller = new SmsPoller(this.smsManager, agent.id, smsConfig);
|
|
6573
|
+
poller.onSmsReceived = (agentId, sms) => {
|
|
6574
|
+
console.log(`[SmsPoller] SMS received for agent ${agent.name}: from ${sms.from}, body="${sms.body.slice(0, 50)}..."`);
|
|
6575
|
+
};
|
|
6576
|
+
this.smsPollers.set(agent.id, poller);
|
|
6577
|
+
await poller.startPolling();
|
|
6578
|
+
console.log(`[GatewayManager] SMS poller started for agent "${agent.name}" (${smsConfig.forwardingEmail})`);
|
|
6579
|
+
} catch {
|
|
6580
|
+
}
|
|
6581
|
+
}
|
|
6282
6582
|
}
|
|
6283
|
-
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
6583
|
+
// --- Telegram Polling ---
|
|
6584
|
+
/**
|
|
6585
|
+
* Start a long-poll loop for every agent whose Telegram channel is
|
|
6586
|
+
* configured + enabled + in poll mode. Webhook-mode agents skip — the
|
|
6587
|
+
* webhook route already calls back into the same agent-wake bridge.
|
|
6588
|
+
*
|
|
6589
|
+
* Each new inbound Telegram message (that isn't an operator-query
|
|
6590
|
+
* reply) is converted to a synthetic email and delivered into the
|
|
6591
|
+
* agent's INBOX via the existing local-SMTP path — the very same
|
|
6592
|
+
* delivery the relay uses for real email. This makes the existing
|
|
6593
|
+
* IMAP IDLE → claudecode dispatcher path light up exactly as it
|
|
6594
|
+
* would for a real inbound mail, so the agent gets a Claude turn
|
|
6595
|
+
* without any new dispatcher plumbing. The body of the synthetic
|
|
6596
|
+
* mail tells the agent the message came from Telegram and that it
|
|
6597
|
+
* MUST reply via the `telegram_send` MCP tool, not via email.
|
|
6598
|
+
*/
|
|
6599
|
+
async startTelegramPollers() {
|
|
6600
|
+
if (!this.telegramManager || !this.accountManager) return;
|
|
6601
|
+
const agents = this.db.prepare("SELECT id, name FROM agents").all();
|
|
6602
|
+
for (const agent of agents) {
|
|
6603
|
+
try {
|
|
6604
|
+
const config = this.telegramManager.getConfig(agent.id);
|
|
6605
|
+
if (!config?.enabled || config.mode !== "poll" || !config.botToken) continue;
|
|
6606
|
+
await this.startTelegramPollerForAgent(agent.id, agent.name);
|
|
6607
|
+
} catch (err) {
|
|
6608
|
+
console.warn(`[GatewayManager] Could not start Telegram poller for ${agent.name}: ${err.message}`);
|
|
6609
|
+
}
|
|
6610
|
+
}
|
|
6297
6611
|
}
|
|
6298
|
-
|
|
6299
|
-
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
|
|
6303
|
-
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
6612
|
+
/**
|
|
6613
|
+
* Start (or restart) the Telegram poller for one agent. Idempotent —
|
|
6614
|
+
* a prior poller is stopped first so re-running `/telegram/setup`
|
|
6615
|
+
* picks up the new token / allow-list cleanly.
|
|
6616
|
+
*
|
|
6617
|
+
* Public so the API layer can poke the gateway after a successful
|
|
6618
|
+
* `/telegram/setup` without waiting for the next server restart.
|
|
6619
|
+
*/
|
|
6620
|
+
async startTelegramPollerForAgent(agentId, agentName) {
|
|
6621
|
+
if (!this.telegramManager) return;
|
|
6622
|
+
const existing = this.telegramPollers.get(agentId);
|
|
6623
|
+
if (existing) {
|
|
6624
|
+
try {
|
|
6625
|
+
await existing.stop();
|
|
6626
|
+
} catch {
|
|
6627
|
+
}
|
|
6628
|
+
this.telegramPollers.delete(agentId);
|
|
6310
6629
|
}
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
6630
|
+
const config = this.telegramManager.getConfig(agentId);
|
|
6631
|
+
if (!config?.enabled || config.mode !== "poll" || !config.botToken) return;
|
|
6632
|
+
const poller = new TelegramPoller(this.telegramManager, agentId);
|
|
6633
|
+
poller.onInbound = async (event) => {
|
|
6634
|
+
await this.bridgeInboundTelegram(event.agentId, event.message, event.config, agentName);
|
|
6635
|
+
};
|
|
6636
|
+
this.telegramPollers.set(agentId, poller);
|
|
6637
|
+
await poller.start();
|
|
6638
|
+
const botName = config.botUsername ? `@${config.botUsername}` : `bot ${config.botId ?? "(unknown)"}`;
|
|
6639
|
+
console.log(`[GatewayManager] Telegram poller started for agent "${agentName ?? agentId.slice(0, 8)}" (${botName})`);
|
|
6314
6640
|
}
|
|
6315
|
-
|
|
6316
|
-
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
}
|
|
6323
|
-
function getTelegramUpdates(token, offset, options = {}) {
|
|
6324
|
-
const timeoutSec = Math.max(options.timeoutSec ?? 0, 0);
|
|
6325
|
-
return callTelegramApi(token, "getUpdates", {
|
|
6326
|
-
offset,
|
|
6327
|
-
limit: Math.min(Math.max(options.limit ?? 100, 1), 100),
|
|
6328
|
-
timeout: timeoutSec,
|
|
6329
|
-
allowed_updates: ["message"]
|
|
6330
|
-
}, { longPoll: timeoutSec > 0 });
|
|
6331
|
-
}
|
|
6332
|
-
function setTelegramWebhook(token, url, options = {}) {
|
|
6333
|
-
return callTelegramApi(token, "setWebhook", {
|
|
6334
|
-
url,
|
|
6335
|
-
secret_token: options.secretToken,
|
|
6336
|
-
allowed_updates: ["message"],
|
|
6337
|
-
drop_pending_updates: options.dropPendingUpdates ?? false
|
|
6338
|
-
});
|
|
6339
|
-
}
|
|
6340
|
-
function deleteTelegramWebhook(token) {
|
|
6341
|
-
return callTelegramApi(token, "deleteWebhook", {});
|
|
6342
|
-
}
|
|
6343
|
-
function getTelegramWebhookInfo(token) {
|
|
6344
|
-
return callTelegramApi(token, "getWebhookInfo");
|
|
6345
|
-
}
|
|
6346
|
-
|
|
6347
|
-
// src/telegram/update.ts
|
|
6348
|
-
function asTrimmed(value) {
|
|
6349
|
-
return typeof value === "string" ? value.trim() : "";
|
|
6350
|
-
}
|
|
6351
|
-
function normalizeChatType(type) {
|
|
6352
|
-
return type === "private" || type === "group" || type === "supergroup" || type === "channel" ? type : "unknown";
|
|
6353
|
-
}
|
|
6354
|
-
function parseTelegramUpdate(update) {
|
|
6355
|
-
if (!update || typeof update !== "object") return null;
|
|
6356
|
-
const u = update;
|
|
6357
|
-
if (typeof u.update_id !== "number") return null;
|
|
6358
|
-
const msg = u.message || u.channel_post;
|
|
6359
|
-
if (!msg || typeof msg !== "object") return null;
|
|
6360
|
-
if (typeof msg.message_id !== "number") return null;
|
|
6361
|
-
const chat = msg.chat || {};
|
|
6362
|
-
if (typeof chat.id !== "number" && typeof chat.id !== "string") return null;
|
|
6363
|
-
const text = asTrimmed(msg.text) || asTrimmed(msg.caption);
|
|
6364
|
-
if (!text) return null;
|
|
6365
|
-
const from = msg.from || {};
|
|
6366
|
-
const fromName = [from.first_name, from.last_name].filter((p) => typeof p === "string" && p).join(" ") || asTrimmed(from.username) || asTrimmed(chat.title) || "User";
|
|
6367
|
-
const replyTo = msg.reply_to_message;
|
|
6368
|
-
return {
|
|
6369
|
-
updateId: u.update_id,
|
|
6370
|
-
messageId: msg.message_id,
|
|
6371
|
-
chatId: String(chat.id),
|
|
6372
|
-
chatType: normalizeChatType(chat.type),
|
|
6373
|
-
chatTitle: asTrimmed(chat.title) || void 0,
|
|
6374
|
-
fromId: from.id != null ? String(from.id) : String(chat.id),
|
|
6375
|
-
fromName,
|
|
6376
|
-
fromUsername: asTrimmed(from.username) || void 0,
|
|
6377
|
-
text,
|
|
6378
|
-
replyToMessageId: replyTo && typeof replyTo.message_id === "number" ? replyTo.message_id : void 0,
|
|
6379
|
-
replyToText: replyTo ? asTrimmed(replyTo.text) || asTrimmed(replyTo.caption) || void 0 : void 0,
|
|
6380
|
-
date: typeof msg.date === "number" ? new Date(msg.date * 1e3).toISOString() : (/* @__PURE__ */ new Date()).toISOString()
|
|
6381
|
-
};
|
|
6382
|
-
}
|
|
6383
|
-
var TELEGRAM_STOP_WORDS = /* @__PURE__ */ new Set([
|
|
6384
|
-
"stop",
|
|
6385
|
-
"abort",
|
|
6386
|
-
"kill",
|
|
6387
|
-
"cancel",
|
|
6388
|
-
"halt"
|
|
6389
|
-
]);
|
|
6390
|
-
function isTelegramStopCommand(text) {
|
|
6391
|
-
if (!text) return false;
|
|
6392
|
-
const cleaned = text.trim().toLowerCase().replace(/[!.?]+$/, "");
|
|
6393
|
-
return TELEGRAM_STOP_WORDS.has(cleaned);
|
|
6394
|
-
}
|
|
6395
|
-
function nextTelegramOffset(currentOffset, updates) {
|
|
6396
|
-
let next = currentOffset;
|
|
6397
|
-
for (const u of updates) {
|
|
6398
|
-
if (u && typeof u.update_id === "number" && u.update_id >= next) {
|
|
6399
|
-
next = u.update_id + 1;
|
|
6641
|
+
/** Stop a single agent's Telegram poller (called on disable). */
|
|
6642
|
+
async stopTelegramPollerForAgent(agentId) {
|
|
6643
|
+
const poller = this.telegramPollers.get(agentId);
|
|
6644
|
+
if (!poller) return;
|
|
6645
|
+
try {
|
|
6646
|
+
await poller.stop();
|
|
6647
|
+
} catch {
|
|
6400
6648
|
}
|
|
6649
|
+
this.telegramPollers.delete(agentId);
|
|
6401
6650
|
}
|
|
6402
|
-
return next;
|
|
6403
|
-
}
|
|
6404
|
-
|
|
6405
|
-
// src/telegram/manager.ts
|
|
6406
|
-
import { timingSafeEqual } from "crypto";
|
|
6407
|
-
var TELEGRAM_WEBHOOK_SECRET_RE = /^[A-Za-z0-9_-]+$/;
|
|
6408
|
-
var TELEGRAM_MIN_WEBHOOK_SECRET_LENGTH = 16;
|
|
6409
|
-
var TELEGRAM_SECRET_FIELDS = ["botToken", "webhookSecret"];
|
|
6410
|
-
function redactTelegramConfig(config) {
|
|
6411
|
-
return {
|
|
6412
|
-
...config,
|
|
6413
|
-
botToken: config.botToken ? "***" : config.botToken,
|
|
6414
|
-
webhookSecret: config.webhookSecret ? "***" : void 0
|
|
6415
|
-
};
|
|
6416
|
-
}
|
|
6417
|
-
function isTelegramChatAllowed(config, chatId) {
|
|
6418
|
-
const id = String(chatId ?? "").trim();
|
|
6419
|
-
if (!id) return false;
|
|
6420
|
-
if (config.operatorChatId && String(config.operatorChatId).trim() === id) return true;
|
|
6421
|
-
return Array.isArray(config.allowedChatIds) && config.allowedChatIds.some((c) => String(c).trim() === id);
|
|
6422
|
-
}
|
|
6423
|
-
function safeEqual(a, b) {
|
|
6424
|
-
const bufA = Buffer.from(a, "utf8");
|
|
6425
|
-
const bufB = Buffer.from(b, "utf8");
|
|
6426
|
-
if (bufA.length !== bufB.length) return false;
|
|
6427
|
-
return timingSafeEqual(bufA, bufB);
|
|
6428
|
-
}
|
|
6429
|
-
var TelegramManager = class {
|
|
6430
6651
|
/**
|
|
6431
|
-
*
|
|
6432
|
-
*
|
|
6433
|
-
*
|
|
6434
|
-
*
|
|
6652
|
+
* Convert one new inbound Telegram message into a synthetic email
|
|
6653
|
+
* landing in the agent's INBOX, so the dispatcher wakes the agent.
|
|
6654
|
+
*
|
|
6655
|
+
* Two short-circuits before delivery:
|
|
6656
|
+
*
|
|
6657
|
+
* 1. If the message is from the configured operator's chat AND
|
|
6658
|
+
* looks like an operator-query reply (parsed by
|
|
6659
|
+
* `parseTelegramOperatorReply`), it's an answer to an in-flight
|
|
6660
|
+
* voice mission, not free-form chat — the HTTP webhook/poll
|
|
6661
|
+
* route already handles those by calling into the phone
|
|
6662
|
+
* manager, and the route does NOT need an agent turn. The poller
|
|
6663
|
+
* hands them off the same way: we just skip the wake here.
|
|
6664
|
+
*
|
|
6665
|
+
* 2. Plain `/start` (BotFather's default first DM) is a Telegram
|
|
6666
|
+
* housekeeping nudge — replying with an LLM turn for "/start"
|
|
6667
|
+
* would be embarrassing. Skip it.
|
|
6668
|
+
*
|
|
6669
|
+
* Everything else: synthesise the email and deliver.
|
|
6435
6670
|
*/
|
|
6436
|
-
|
|
6437
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
6671
|
+
/**
|
|
6672
|
+
* Public wrapper around the bridge — the Telegram webhook route calls
|
|
6673
|
+
* this directly so push-mode and poll-mode share the wake path.
|
|
6674
|
+
*/
|
|
6675
|
+
async bridgeTelegramInbound(agentId, parsed, config) {
|
|
6676
|
+
return this.bridgeInboundTelegram(agentId, parsed, config);
|
|
6440
6677
|
}
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
if (
|
|
6678
|
+
async bridgeInboundTelegram(agentId, parsed, config, agentNameHint) {
|
|
6679
|
+
const operatorChatId = config.operatorChatId?.toString().trim() || "";
|
|
6680
|
+
if (operatorChatId && parsed.chatId === operatorChatId) {
|
|
6681
|
+
const reply = parseTelegramOperatorReply({ text: parsed.text, replyToText: parsed.replyToText });
|
|
6682
|
+
if (reply) return;
|
|
6683
|
+
}
|
|
6684
|
+
const trimmedText = (parsed.text ?? "").trim();
|
|
6685
|
+
if (trimmedText === "/start" || trimmedText === "/help" || trimmedText === "/stop") {
|
|
6686
|
+
return;
|
|
6687
|
+
}
|
|
6688
|
+
if (!trimmedText) return;
|
|
6689
|
+
if (!this.accountManager) return;
|
|
6690
|
+
const agent = await this.accountManager.getById(agentId);
|
|
6691
|
+
if (!agent) return;
|
|
6692
|
+
const agentName = agentNameHint ?? agent.name;
|
|
6693
|
+
const fromLabel = parsed.fromName ? `${parsed.fromName} (Telegram chat ${parsed.chatId})` : `Telegram chat ${parsed.chatId}`;
|
|
6694
|
+
const senderName = parsed.fromName || parsed.fromUsername || "User";
|
|
6695
|
+
const subject = `[Telegram] ${trimmedText.slice(0, 80)}${trimmedText.length > 80 ? "\u2026" : ""}`;
|
|
6696
|
+
const body = [
|
|
6697
|
+
`[Incoming Telegram message \u2014 via AgenticMail Telegram bridge]`,
|
|
6698
|
+
`from_name: ${senderName}`,
|
|
6699
|
+
parsed.fromId ? `from_id: ${parsed.fromId}` : null,
|
|
6700
|
+
`chat_id: ${parsed.chatId}`,
|
|
6701
|
+
`chat_type: ${parsed.chatType}`,
|
|
6702
|
+
`telegram_message_id: ${parsed.messageId}`,
|
|
6703
|
+
`received_at: ${parsed.date}`,
|
|
6704
|
+
``,
|
|
6705
|
+
`=== REPLY ROUTING (important, read before responding) ===`,
|
|
6706
|
+
`This message arrived via Telegram, NOT email. To reply to ${senderName}`,
|
|
6707
|
+
`you must use the telegram_send MCP tool \u2014 replying by email will go`,
|
|
6708
|
+
`nowhere they can see it.`,
|
|
6709
|
+
``,
|
|
6710
|
+
` telegram_send({ chatId: "${parsed.chatId}", text: "<your reply>" })`,
|
|
6711
|
+
``,
|
|
6712
|
+
`Send EXACTLY ONE telegram_send call per response \u2014 do not also narrate`,
|
|
6713
|
+
`or summarise what you sent in a separate reply / email, that just shows`,
|
|
6714
|
+
`up to the user as a duplicate. Keep replies concise and plain text`,
|
|
6715
|
+
`(Telegram strips markdown formatting in transit). No preamble like`,
|
|
6716
|
+
`"sure, here you go" \u2014 just answer the question.`,
|
|
6717
|
+
``,
|
|
6718
|
+
`If the user is asking you to do an errand (call someone, look up info,`,
|
|
6719
|
+
`send something), do the work FIRST, then telegram_send a single clear`,
|
|
6720
|
+
`update back to chat_id ${parsed.chatId} when done \u2014 that becomes the`,
|
|
6721
|
+
`whole reply.`,
|
|
6722
|
+
`=== END REPLY ROUTING ===`,
|
|
6723
|
+
``,
|
|
6724
|
+
`--- User's message ---`,
|
|
6725
|
+
trimmedText,
|
|
6726
|
+
`---`
|
|
6727
|
+
].filter((l) => l !== null).join("\n");
|
|
6728
|
+
const inbound = {
|
|
6729
|
+
from: `telegram-bridge@telegram.local`,
|
|
6730
|
+
to: agent.email,
|
|
6731
|
+
subject,
|
|
6732
|
+
text: body,
|
|
6733
|
+
html: void 0,
|
|
6734
|
+
// Use the Telegram-provided send time so the inbox ordering matches
|
|
6735
|
+
// when the user actually pressed Send, not when the bridge ran.
|
|
6736
|
+
date: parsed.date ? new Date(parsed.date) : /* @__PURE__ */ new Date(),
|
|
6737
|
+
messageId: `<tg-${parsed.chatId}-${parsed.messageId}@telegram.local>`
|
|
6738
|
+
};
|
|
6444
6739
|
try {
|
|
6445
|
-
this.
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
6740
|
+
await this.deliverInboundLocally(agentName, inbound);
|
|
6741
|
+
} catch (err) {
|
|
6742
|
+
console.warn(`[GatewayManager] Telegram \u2192 inbox bridge failed for ${agentName}: ${err.message}`);
|
|
6743
|
+
}
|
|
6744
|
+
}
|
|
6745
|
+
// --- Lifecycle ---
|
|
6746
|
+
async shutdown() {
|
|
6747
|
+
await this.relay.shutdown();
|
|
6748
|
+
this.tunnel?.stop();
|
|
6749
|
+
for (const poller of this.smsPollers.values()) {
|
|
6750
|
+
poller.stopPolling();
|
|
6751
|
+
}
|
|
6752
|
+
this.smsPollers.clear();
|
|
6753
|
+
for (const poller of this.telegramPollers.values()) {
|
|
6459
6754
|
try {
|
|
6460
|
-
|
|
6755
|
+
void poller.stop();
|
|
6461
6756
|
} catch {
|
|
6462
6757
|
}
|
|
6758
|
+
}
|
|
6759
|
+
this.telegramPollers.clear();
|
|
6760
|
+
}
|
|
6761
|
+
/**
|
|
6762
|
+
* Resume gateway from saved config (e.g., after server restart).
|
|
6763
|
+
*
|
|
6764
|
+
* Issue #31 — On a Docker container restart the API can come up
|
|
6765
|
+
* before Stalwart / Gmail IMAP / DNS is reachable, so the very first
|
|
6766
|
+
* setup() can fail with a transient network error. Previously that
|
|
6767
|
+
* single failure was logged and never retried, leaving polling
|
|
6768
|
+
* permanently dead until someone noticed and manually revived the
|
|
6769
|
+
* relay. We now schedule background retries with exponential backoff
|
|
6770
|
+
* (5s, 10s, 20s, 40s, 60s cap, indefinite) so the relay
|
|
6771
|
+
* self-recovers as soon as the dependency is reachable again.
|
|
6772
|
+
*/
|
|
6773
|
+
async resume() {
|
|
6774
|
+
if (this.config.mode === "relay" && this.config.relay) {
|
|
6463
6775
|
try {
|
|
6464
|
-
this.
|
|
6465
|
-
} catch {
|
|
6776
|
+
await this._resumeRelayOnce();
|
|
6777
|
+
} catch (err) {
|
|
6778
|
+
console.error("[GatewayManager] Initial relay resume failed; scheduling retries:", formatPollError(err));
|
|
6779
|
+
this._scheduleRelayResumeRetry();
|
|
6466
6780
|
}
|
|
6781
|
+
}
|
|
6782
|
+
if (this.smsManager && this.accountManager) {
|
|
6467
6783
|
try {
|
|
6468
|
-
this.
|
|
6469
|
-
} catch {
|
|
6784
|
+
await this.startSmsPollers();
|
|
6785
|
+
} catch (err) {
|
|
6786
|
+
console.error("[GatewayManager] Failed to start SMS pollers:", err);
|
|
6470
6787
|
}
|
|
6471
|
-
this.initialized = true;
|
|
6472
|
-
} catch {
|
|
6473
|
-
this.initialized = true;
|
|
6474
6788
|
}
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
for (const field of TELEGRAM_SECRET_FIELDS) {
|
|
6481
|
-
const value = out[field];
|
|
6482
|
-
if (typeof value === "string" && value && !isEncryptedSecret(value)) {
|
|
6483
|
-
out[field] = encryptSecret(value, this.encryptionKey);
|
|
6789
|
+
if (this.telegramManager && this.accountManager) {
|
|
6790
|
+
try {
|
|
6791
|
+
await this.startTelegramPollers();
|
|
6792
|
+
} catch (err) {
|
|
6793
|
+
console.error("[GatewayManager] Failed to start Telegram pollers:", err);
|
|
6484
6794
|
}
|
|
6485
6795
|
}
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
} catch {
|
|
6796
|
+
if (this.config.mode === "domain" && this.config.domain) {
|
|
6797
|
+
try {
|
|
6798
|
+
this.cfClient = new CloudflareClient(
|
|
6799
|
+
this.config.domain.cloudflareApiToken,
|
|
6800
|
+
this.config.domain.cloudflareAccountId
|
|
6801
|
+
);
|
|
6802
|
+
this.dnsConfigurator = new DNSConfigurator(this.cfClient);
|
|
6803
|
+
this.tunnel = new TunnelManager(this.cfClient);
|
|
6804
|
+
this.domainPurchaser = new DomainPurchaser(this.cfClient);
|
|
6805
|
+
if (this.config.domain.tunnelToken) {
|
|
6806
|
+
await this.tunnel.start(this.config.domain.tunnelToken);
|
|
6498
6807
|
}
|
|
6808
|
+
} catch (err) {
|
|
6809
|
+
console.error("[GatewayManager] Failed to resume domain mode:", err);
|
|
6499
6810
|
}
|
|
6500
6811
|
}
|
|
6501
|
-
return out;
|
|
6502
|
-
}
|
|
6503
|
-
/** Normalize a stored/loaded config object, defaulting missing fields. */
|
|
6504
|
-
normalizeConfig(raw) {
|
|
6505
|
-
return {
|
|
6506
|
-
enabled: raw.enabled === true,
|
|
6507
|
-
botToken: typeof raw.botToken === "string" ? raw.botToken : "",
|
|
6508
|
-
botUsername: typeof raw.botUsername === "string" ? raw.botUsername : void 0,
|
|
6509
|
-
botId: typeof raw.botId === "number" ? raw.botId : void 0,
|
|
6510
|
-
allowedChatIds: Array.isArray(raw.allowedChatIds) ? raw.allowedChatIds.map((c) => String(c).trim()).filter(Boolean) : [],
|
|
6511
|
-
operatorChatId: typeof raw.operatorChatId === "string" && raw.operatorChatId.trim() ? raw.operatorChatId.trim() : void 0,
|
|
6512
|
-
mode: raw.mode === "webhook" ? "webhook" : "poll",
|
|
6513
|
-
webhookUrl: typeof raw.webhookUrl === "string" ? raw.webhookUrl : void 0,
|
|
6514
|
-
webhookSecret: typeof raw.webhookSecret === "string" ? raw.webhookSecret : void 0,
|
|
6515
|
-
pollOffset: typeof raw.pollOffset === "number" ? raw.pollOffset : 0,
|
|
6516
|
-
configuredAt: typeof raw.configuredAt === "string" ? raw.configuredAt : (/* @__PURE__ */ new Date()).toISOString()
|
|
6517
|
-
};
|
|
6518
|
-
}
|
|
6519
|
-
/** Get the Telegram config from agent metadata (credentials decrypted). */
|
|
6520
|
-
getConfig(agentId) {
|
|
6521
|
-
const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
|
|
6522
|
-
if (!row) return null;
|
|
6523
|
-
try {
|
|
6524
|
-
const meta = JSON.parse(row.metadata || "{}");
|
|
6525
|
-
if (!meta.telegram || typeof meta.telegram !== "object") return null;
|
|
6526
|
-
return this.decryptConfig(this.normalizeConfig(meta.telegram));
|
|
6527
|
-
} catch {
|
|
6528
|
-
return null;
|
|
6529
|
-
}
|
|
6530
6812
|
}
|
|
6531
|
-
|
|
6532
|
-
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
6537
|
-
|
|
6538
|
-
|
|
6539
|
-
|
|
6813
|
+
// ─── Issue #31 helpers — resume retry with backoff ───
|
|
6814
|
+
_resumeRetryTimer = null;
|
|
6815
|
+
_resumeRetryAttempt = 0;
|
|
6816
|
+
async _resumeRelayOnce() {
|
|
6817
|
+
if (!this.config.relay) throw new Error("No relay config to resume");
|
|
6818
|
+
await this.relay.setup(this.config.relay);
|
|
6819
|
+
const savedUid = this.loadLastSeenUid();
|
|
6820
|
+
if (savedUid > 0) {
|
|
6821
|
+
this.relay.setLastSeenUid(savedUid);
|
|
6822
|
+
console.log(`[GatewayManager] Restored lastSeenUid=${savedUid} from database`);
|
|
6540
6823
|
}
|
|
6541
|
-
|
|
6542
|
-
this.
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
removeConfig(agentId) {
|
|
6546
|
-
const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
|
|
6547
|
-
if (!row) return;
|
|
6548
|
-
let meta;
|
|
6549
|
-
try {
|
|
6550
|
-
meta = JSON.parse(row.metadata || "{}");
|
|
6551
|
-
} catch {
|
|
6552
|
-
meta = {};
|
|
6824
|
+
this.relay.onUidAdvance = (uid) => this.saveLastSeenUid(uid);
|
|
6825
|
+
await this.relay.startPolling();
|
|
6826
|
+
if (this._resumeRetryAttempt > 0) {
|
|
6827
|
+
console.log(`[GatewayManager] Relay polling resumed after ${this._resumeRetryAttempt} retry attempt${this._resumeRetryAttempt !== 1 ? "s" : ""}`);
|
|
6553
6828
|
}
|
|
6554
|
-
|
|
6555
|
-
this.db.prepare("UPDATE agents SET metadata = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(meta), agentId);
|
|
6829
|
+
this._resumeRetryAttempt = 0;
|
|
6556
6830
|
}
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
|
|
6831
|
+
_scheduleRelayResumeRetry() {
|
|
6832
|
+
if (this._resumeRetryTimer) return;
|
|
6833
|
+
this._resumeRetryAttempt++;
|
|
6834
|
+
const base = Math.min(5e3 * Math.pow(2, this._resumeRetryAttempt - 1), 6e4);
|
|
6835
|
+
const jitter = base * (0.8 + Math.random() * 0.4);
|
|
6836
|
+
const delay = Math.round(jitter);
|
|
6837
|
+
console.log(`[GatewayManager] Will retry relay resume in ${(delay / 1e3).toFixed(1)}s (attempt ${this._resumeRetryAttempt + 1})`);
|
|
6838
|
+
this._resumeRetryTimer = setTimeout(async () => {
|
|
6839
|
+
this._resumeRetryTimer = null;
|
|
6840
|
+
if (this.config.mode !== "relay" || !this.config.relay) return;
|
|
6841
|
+
try {
|
|
6842
|
+
await this._resumeRelayOnce();
|
|
6843
|
+
} catch (err) {
|
|
6844
|
+
console.error(`[GatewayManager] Relay resume retry ${this._resumeRetryAttempt} failed:`, formatPollError(err));
|
|
6845
|
+
this._scheduleRelayResumeRetry();
|
|
6846
|
+
}
|
|
6847
|
+
}, delay);
|
|
6563
6848
|
}
|
|
6564
|
-
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
* routing key. The comparison is constant-time, and a non-match
|
|
6569
|
-
* returns `null` so the route can answer with a single uniform 403
|
|
6570
|
-
* (no enumeration oracle — same posture as the SMS webhook).
|
|
6571
|
-
*/
|
|
6572
|
-
findAgentByWebhookSecret(secret) {
|
|
6573
|
-
const provided = String(secret ?? "");
|
|
6574
|
-
if (!provided) return null;
|
|
6575
|
-
const rows = this.db.prepare("SELECT id, metadata FROM agents").all();
|
|
6576
|
-
for (const row of rows) {
|
|
6849
|
+
// --- Persistence ---
|
|
6850
|
+
loadConfig() {
|
|
6851
|
+
const row = this.db.prepare("SELECT * FROM gateway_config WHERE id = ?").get("default");
|
|
6852
|
+
if (row) {
|
|
6577
6853
|
try {
|
|
6578
|
-
const
|
|
6579
|
-
if (
|
|
6580
|
-
|
|
6581
|
-
|
|
6582
|
-
|
|
6583
|
-
|
|
6854
|
+
const parsed = JSON.parse(row.config);
|
|
6855
|
+
if (this.encryptionKey) {
|
|
6856
|
+
if (parsed.relay?.password) {
|
|
6857
|
+
try {
|
|
6858
|
+
parsed.relay.password = decryptSecret(parsed.relay.password, this.encryptionKey);
|
|
6859
|
+
} catch {
|
|
6860
|
+
}
|
|
6861
|
+
}
|
|
6862
|
+
if (parsed.relay?.appPassword) {
|
|
6863
|
+
try {
|
|
6864
|
+
parsed.relay.appPassword = decryptSecret(parsed.relay.appPassword, this.encryptionKey);
|
|
6865
|
+
} catch {
|
|
6866
|
+
}
|
|
6867
|
+
}
|
|
6868
|
+
if (parsed.domain?.cloudflareApiToken) {
|
|
6869
|
+
try {
|
|
6870
|
+
parsed.domain.cloudflareApiToken = decryptSecret(parsed.domain.cloudflareApiToken, this.encryptionKey);
|
|
6871
|
+
} catch {
|
|
6872
|
+
}
|
|
6873
|
+
}
|
|
6874
|
+
if (parsed.domain?.tunnelToken) {
|
|
6875
|
+
try {
|
|
6876
|
+
parsed.domain.tunnelToken = decryptSecret(parsed.domain.tunnelToken, this.encryptionKey);
|
|
6877
|
+
} catch {
|
|
6878
|
+
}
|
|
6879
|
+
}
|
|
6880
|
+
if (parsed.domain?.inboundSecret) {
|
|
6881
|
+
try {
|
|
6882
|
+
parsed.domain.inboundSecret = decryptSecret(parsed.domain.inboundSecret, this.encryptionKey);
|
|
6883
|
+
} catch {
|
|
6884
|
+
}
|
|
6885
|
+
}
|
|
6886
|
+
if (parsed.domain?.outboundSecret) {
|
|
6887
|
+
try {
|
|
6888
|
+
parsed.domain.outboundSecret = decryptSecret(parsed.domain.outboundSecret, this.encryptionKey);
|
|
6889
|
+
} catch {
|
|
6890
|
+
}
|
|
6891
|
+
}
|
|
6584
6892
|
}
|
|
6893
|
+
this.config = {
|
|
6894
|
+
mode: row.mode,
|
|
6895
|
+
...parsed
|
|
6896
|
+
};
|
|
6585
6897
|
} catch {
|
|
6898
|
+
this.config = { mode: "none" };
|
|
6586
6899
|
}
|
|
6587
6900
|
}
|
|
6588
|
-
return null;
|
|
6589
6901
|
}
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
const
|
|
6593
|
-
|
|
6594
|
-
|
|
6595
|
-
|
|
6902
|
+
saveConfig() {
|
|
6903
|
+
const { mode, ...rest } = this.config;
|
|
6904
|
+
const toStore = JSON.parse(JSON.stringify(rest));
|
|
6905
|
+
if (this.encryptionKey) {
|
|
6906
|
+
if (toStore.relay?.password) {
|
|
6907
|
+
toStore.relay.password = encryptSecret(toStore.relay.password, this.encryptionKey);
|
|
6908
|
+
}
|
|
6909
|
+
if (toStore.relay?.appPassword) {
|
|
6910
|
+
toStore.relay.appPassword = encryptSecret(toStore.relay.appPassword, this.encryptionKey);
|
|
6911
|
+
}
|
|
6912
|
+
if (toStore.domain?.cloudflareApiToken) {
|
|
6913
|
+
toStore.domain.cloudflareApiToken = encryptSecret(toStore.domain.cloudflareApiToken, this.encryptionKey);
|
|
6914
|
+
}
|
|
6915
|
+
if (toStore.domain?.tunnelToken) {
|
|
6916
|
+
toStore.domain.tunnelToken = encryptSecret(toStore.domain.tunnelToken, this.encryptionKey);
|
|
6917
|
+
}
|
|
6918
|
+
if (toStore.domain?.inboundSecret) {
|
|
6919
|
+
toStore.domain.inboundSecret = encryptSecret(toStore.domain.inboundSecret, this.encryptionKey);
|
|
6920
|
+
}
|
|
6921
|
+
if (toStore.domain?.outboundSecret) {
|
|
6922
|
+
toStore.domain.outboundSecret = encryptSecret(toStore.domain.outboundSecret, this.encryptionKey);
|
|
6923
|
+
}
|
|
6924
|
+
}
|
|
6925
|
+
this.db.prepare(`
|
|
6926
|
+
INSERT OR REPLACE INTO gateway_config (id, mode, config)
|
|
6927
|
+
VALUES ('default', ?, ?)
|
|
6928
|
+
`).run(mode, JSON.stringify(toStore));
|
|
6929
|
+
}
|
|
6930
|
+
saveLastSeenUid(uid) {
|
|
6931
|
+
this.db.prepare(`
|
|
6932
|
+
INSERT OR REPLACE INTO config (key, value) VALUES ('relay_last_seen_uid', ?)
|
|
6933
|
+
`).run(String(uid));
|
|
6934
|
+
}
|
|
6935
|
+
loadLastSeenUid() {
|
|
6936
|
+
const row = this.db.prepare("SELECT value FROM config WHERE key = ?").get("relay_last_seen_uid");
|
|
6937
|
+
return row ? parseInt(row.value, 10) || 0 : 0;
|
|
6938
|
+
}
|
|
6939
|
+
};
|
|
6940
|
+
function parseAddressString(addr) {
|
|
6941
|
+
const match = addr.match(/^(.+?)\s*<([^>]+)>$/);
|
|
6942
|
+
if (match) {
|
|
6943
|
+
return { name: match[1].trim(), address: match[2].trim() };
|
|
6944
|
+
}
|
|
6945
|
+
return { address: addr.trim() };
|
|
6946
|
+
}
|
|
6947
|
+
function inboundToParsedEmail(mail) {
|
|
6948
|
+
return {
|
|
6949
|
+
messageId: mail.messageId || "",
|
|
6950
|
+
subject: mail.subject || "",
|
|
6951
|
+
from: [parseAddressString(mail.from)],
|
|
6952
|
+
to: [parseAddressString(mail.to)],
|
|
6953
|
+
date: mail.date || /* @__PURE__ */ new Date(),
|
|
6954
|
+
text: mail.text,
|
|
6955
|
+
html: mail.html,
|
|
6956
|
+
inReplyTo: mail.inReplyTo,
|
|
6957
|
+
references: mail.references,
|
|
6958
|
+
attachments: (mail.attachments ?? []).map((a) => ({
|
|
6959
|
+
filename: a.filename,
|
|
6960
|
+
contentType: a.contentType,
|
|
6961
|
+
size: a.size,
|
|
6962
|
+
content: a.content
|
|
6963
|
+
})),
|
|
6964
|
+
headers: /* @__PURE__ */ new Map()
|
|
6965
|
+
};
|
|
6966
|
+
}
|
|
6967
|
+
|
|
6968
|
+
// src/gateway/relay-bridge.ts
|
|
6969
|
+
import { createServer } from "http";
|
|
6970
|
+
import { createTransport } from "nodemailer";
|
|
6971
|
+
var RelayBridge = class {
|
|
6972
|
+
server = null;
|
|
6973
|
+
options;
|
|
6974
|
+
constructor(options) {
|
|
6975
|
+
this.options = options;
|
|
6596
6976
|
}
|
|
6597
|
-
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
|
|
6604
|
-
|
|
6605
|
-
|
|
6606
|
-
"inbound",
|
|
6607
|
-
String(input.chatId),
|
|
6608
|
-
input.telegramMessageId,
|
|
6609
|
-
input.fromId ?? null,
|
|
6610
|
-
input.text,
|
|
6611
|
-
"received",
|
|
6612
|
-
createdAt,
|
|
6613
|
-
JSON.stringify(metadata ?? {})
|
|
6614
|
-
);
|
|
6615
|
-
return {
|
|
6616
|
-
id,
|
|
6617
|
-
agentId,
|
|
6618
|
-
direction: "inbound",
|
|
6619
|
-
chatId: String(input.chatId),
|
|
6620
|
-
telegramMessageId: input.telegramMessageId,
|
|
6621
|
-
fromId: input.fromId,
|
|
6622
|
-
text: input.text,
|
|
6623
|
-
status: "received",
|
|
6624
|
-
createdAt,
|
|
6625
|
-
metadata
|
|
6626
|
-
};
|
|
6977
|
+
async start() {
|
|
6978
|
+
return new Promise((resolve2, reject) => {
|
|
6979
|
+
this.server = createServer((req, res) => this.handleRequest(req, res));
|
|
6980
|
+
this.server.listen(this.options.port, "127.0.0.1", () => {
|
|
6981
|
+
console.log(`[RelayBridge] Listening on 127.0.0.1:${this.options.port}`);
|
|
6982
|
+
resolve2();
|
|
6983
|
+
});
|
|
6984
|
+
this.server.on("error", reject);
|
|
6985
|
+
});
|
|
6627
6986
|
}
|
|
6628
|
-
|
|
6629
|
-
|
|
6630
|
-
|
|
6631
|
-
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6632
|
-
const status = input.status ?? "sent";
|
|
6633
|
-
this.db.prepare(
|
|
6634
|
-
"INSERT INTO telegram_messages (id, agent_id, direction, chat_id, telegram_message_id, from_id, text, status, created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
6635
|
-
).run(
|
|
6636
|
-
id,
|
|
6637
|
-
agentId,
|
|
6638
|
-
"outbound",
|
|
6639
|
-
String(input.chatId),
|
|
6640
|
-
input.telegramMessageId ?? null,
|
|
6641
|
-
null,
|
|
6642
|
-
input.text,
|
|
6643
|
-
status,
|
|
6644
|
-
createdAt,
|
|
6645
|
-
JSON.stringify(metadata ?? {})
|
|
6646
|
-
);
|
|
6647
|
-
return {
|
|
6648
|
-
id,
|
|
6649
|
-
agentId,
|
|
6650
|
-
direction: "outbound",
|
|
6651
|
-
chatId: String(input.chatId),
|
|
6652
|
-
telegramMessageId: input.telegramMessageId,
|
|
6653
|
-
text: input.text,
|
|
6654
|
-
status,
|
|
6655
|
-
createdAt,
|
|
6656
|
-
metadata
|
|
6657
|
-
};
|
|
6987
|
+
stop() {
|
|
6988
|
+
this.server?.close();
|
|
6989
|
+
this.server = null;
|
|
6658
6990
|
}
|
|
6659
|
-
|
|
6660
|
-
|
|
6661
|
-
|
|
6662
|
-
|
|
6991
|
+
async handleRequest(req, res) {
|
|
6992
|
+
if (req.method !== "POST" || req.url !== "/send") {
|
|
6993
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
6994
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
6663
6995
|
return;
|
|
6664
6996
|
}
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
|
|
6670
|
-
const offset = Math.max(opts?.offset ?? 0, 0);
|
|
6671
|
-
let query = "SELECT * FROM telegram_messages WHERE agent_id = ?";
|
|
6672
|
-
const params = [agentId];
|
|
6673
|
-
if (opts?.direction === "inbound" || opts?.direction === "outbound") {
|
|
6674
|
-
query += " AND direction = ?";
|
|
6675
|
-
params.push(opts.direction);
|
|
6997
|
+
const secret = req.headers["x-relay-secret"];
|
|
6998
|
+
if (secret !== this.options.secret) {
|
|
6999
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
7000
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
7001
|
+
return;
|
|
6676
7002
|
}
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
7003
|
+
let body = "";
|
|
7004
|
+
for await (const chunk of req) body += chunk;
|
|
7005
|
+
try {
|
|
7006
|
+
const payload = JSON.parse(body);
|
|
7007
|
+
const result = await this.submitToStalwart(payload);
|
|
7008
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
7009
|
+
res.end(JSON.stringify(result));
|
|
7010
|
+
} catch (err) {
|
|
7011
|
+
console.error("[RelayBridge] Delivery failed:", err.message);
|
|
7012
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
7013
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
7014
|
+
}
|
|
7015
|
+
}
|
|
7016
|
+
async submitToStalwart(payload) {
|
|
7017
|
+
const { from, to, subject, text, html, replyTo, inReplyTo, references } = payload;
|
|
7018
|
+
const recipients = Array.isArray(to) ? to : [to];
|
|
7019
|
+
debug("RelayBridge", `Submitting to Stalwart: ${from} \u2192 ${recipients.join(", ")}`);
|
|
7020
|
+
const transport = createTransport({
|
|
7021
|
+
host: this.options.smtpHost ?? "127.0.0.1",
|
|
7022
|
+
port: this.options.smtpPort ?? 587,
|
|
7023
|
+
secure: false,
|
|
7024
|
+
auth: {
|
|
7025
|
+
user: this.options.smtpUser,
|
|
7026
|
+
pass: this.options.smtpPass
|
|
7027
|
+
},
|
|
7028
|
+
tls: { rejectUnauthorized: false }
|
|
7029
|
+
});
|
|
7030
|
+
try {
|
|
7031
|
+
const info = await transport.sendMail({
|
|
7032
|
+
from,
|
|
7033
|
+
to: recipients.join(", "),
|
|
7034
|
+
subject,
|
|
7035
|
+
text: text || void 0,
|
|
7036
|
+
html: html || void 0,
|
|
7037
|
+
replyTo: replyTo || void 0,
|
|
7038
|
+
inReplyTo: inReplyTo || void 0,
|
|
7039
|
+
references: references || void 0,
|
|
7040
|
+
headers: {
|
|
7041
|
+
"X-Mailer": "AgenticMail/1.0"
|
|
7042
|
+
}
|
|
7043
|
+
});
|
|
7044
|
+
debug("RelayBridge", `Queued: ${info.messageId} \u2192 ${info.response}`);
|
|
7045
|
+
return {
|
|
7046
|
+
ok: true,
|
|
7047
|
+
messageId: info.messageId,
|
|
7048
|
+
response: info.response
|
|
7049
|
+
};
|
|
7050
|
+
} finally {
|
|
7051
|
+
transport.close();
|
|
6680
7052
|
}
|
|
6681
|
-
query += " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?";
|
|
6682
|
-
params.push(limit, offset);
|
|
6683
|
-
return this.db.prepare(query).all(...params).map((row) => ({
|
|
6684
|
-
id: row.id,
|
|
6685
|
-
agentId: row.agent_id,
|
|
6686
|
-
direction: row.direction,
|
|
6687
|
-
chatId: row.chat_id,
|
|
6688
|
-
telegramMessageId: row.telegram_message_id ?? void 0,
|
|
6689
|
-
fromId: row.from_id ?? void 0,
|
|
6690
|
-
text: row.text,
|
|
6691
|
-
status: row.status,
|
|
6692
|
-
createdAt: row.created_at,
|
|
6693
|
-
metadata: row.metadata ? JSON.parse(row.metadata) : void 0
|
|
6694
|
-
}));
|
|
6695
7053
|
}
|
|
6696
7054
|
};
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
|
|
6703
|
-
const lines = [];
|
|
6704
|
-
lines.push(input.urgency === "high" ? "\u{1F534} Your agent needs an answer to continue a live call (URGENT)." : "\u{1F7E1} Your agent needs an answer to continue a live call.");
|
|
6705
|
-
lines.push("");
|
|
6706
|
-
lines.push(`Question: ${input.question}`);
|
|
6707
|
-
if (input.callContext) lines.push(`Context: ${input.callContext}`);
|
|
6708
|
-
lines.push("");
|
|
6709
|
-
lines.push("Reply to this message with your answer. You can also send:");
|
|
6710
|
-
lines.push(` /answer ${input.queryId} <your answer>`);
|
|
6711
|
-
lines.push(` /approve ${input.queryId} \xB7 /deny ${input.queryId}`);
|
|
6712
|
-
lines.push("");
|
|
6713
|
-
lines.push(`[${TELEGRAM_OPERATOR_QUERY_TAG} ${input.queryId}]`);
|
|
6714
|
-
return lines.join("\n");
|
|
7055
|
+
function startRelayBridge(options) {
|
|
7056
|
+
const bridge = new RelayBridge(options);
|
|
7057
|
+
bridge.start().catch((err) => {
|
|
7058
|
+
console.error("[RelayBridge] Failed to start:", err);
|
|
7059
|
+
});
|
|
7060
|
+
return bridge;
|
|
6715
7061
|
}
|
|
6716
|
-
|
|
6717
|
-
|
|
6718
|
-
|
|
6719
|
-
|
|
6720
|
-
|
|
6721
|
-
|
|
6722
|
-
|
|
6723
|
-
|
|
6724
|
-
}
|
|
6725
|
-
|
|
6726
|
-
|
|
6727
|
-
|
|
6728
|
-
|
|
6729
|
-
|
|
6730
|
-
const note = rest.replace(QUERY_ID_RE, "").trim();
|
|
6731
|
-
const answer2 = (kind === "approve" ? "Approved" : "Denied") + (note ? `: ${note}` : ".");
|
|
6732
|
-
return { queryId: inlineId2 ?? quotedQueryId, answer: answer2, kind };
|
|
7062
|
+
|
|
7063
|
+
// src/gateway/types.ts
|
|
7064
|
+
var RELAY_PRESETS = {
|
|
7065
|
+
gmail: {
|
|
7066
|
+
smtpHost: "smtp.gmail.com",
|
|
7067
|
+
smtpPort: 587,
|
|
7068
|
+
imapHost: "imap.gmail.com",
|
|
7069
|
+
imapPort: 993
|
|
7070
|
+
},
|
|
7071
|
+
outlook: {
|
|
7072
|
+
smtpHost: "smtp.office365.com",
|
|
7073
|
+
smtpPort: 587,
|
|
7074
|
+
imapHost: "outlook.office365.com",
|
|
7075
|
+
imapPort: 993
|
|
6733
7076
|
}
|
|
6734
|
-
|
|
6735
|
-
const answer = text.replace(QUERY_TAG_RE, "").trim();
|
|
6736
|
-
if (!answer) return null;
|
|
6737
|
-
return { queryId: quotedQueryId ?? inlineId, answer, kind: "answer" };
|
|
6738
|
-
}
|
|
7077
|
+
};
|
|
6739
7078
|
|
|
6740
7079
|
// src/phone/realtime.ts
|
|
6741
7080
|
var ELKS_REALTIME_AUDIO_FORMATS = ["ulaw", "pcm_16000", "pcm_24000", "wav"];
|
|
@@ -6942,7 +7281,9 @@ var ElksRealtimeTransport = class {
|
|
|
6942
7281
|
// Historical prefix — `elks-bye` / `elks-closed` etc. are matched by
|
|
6943
7282
|
// long-standing call sites and tests; do not change.
|
|
6944
7283
|
endReasonPrefix = "elks";
|
|
6945
|
-
|
|
7284
|
+
// OpenAI rejects `format.rate` as an unknown parameter — `audio/pcm` is
|
|
7285
|
+
// implicitly 24 kHz mono PCM16 in the current Realtime API.
|
|
7286
|
+
openaiAudioFormat = { type: "audio/pcm" };
|
|
6946
7287
|
parseInbound(raw) {
|
|
6947
7288
|
const msg = parseElksRealtimeMessage(raw);
|
|
6948
7289
|
if (msg.t === "hello") {
|
|
@@ -6973,9 +7314,9 @@ var TwilioRealtimeTransport = class {
|
|
|
6973
7314
|
provider = "twilio";
|
|
6974
7315
|
endReasonPrefix = "twilio";
|
|
6975
7316
|
// µ-law @ 8 kHz — Twilio's native format; no transcode end to end.
|
|
6976
|
-
//
|
|
6977
|
-
//
|
|
6978
|
-
openaiAudioFormat = { type: "audio/pcmu"
|
|
7317
|
+
// OpenAI rejects `format.rate` as an unknown parameter; `audio/pcmu` is
|
|
7318
|
+
// implicitly 8 kHz G.711 µ-law in the current Realtime API.
|
|
7319
|
+
openaiAudioFormat = { type: "audio/pcmu" };
|
|
6979
7320
|
/** Latched from the Twilio `start` frame; required on every outbound. */
|
|
6980
7321
|
streamSid = "";
|
|
6981
7322
|
/** The active `streamSid`, once the `start` frame has been seen. */
|
|
@@ -7335,7 +7676,7 @@ ${task}`);
|
|
|
7335
7676
|
}
|
|
7336
7677
|
return sections.join("\n\n");
|
|
7337
7678
|
}
|
|
7338
|
-
var DEFAULT_REALTIME_AUDIO_FORMAT = { type: "audio/pcm"
|
|
7679
|
+
var DEFAULT_REALTIME_AUDIO_FORMAT = { type: "audio/pcm" };
|
|
7339
7680
|
function buildRealtimeSessionConfig(opts) {
|
|
7340
7681
|
const tools = opts.tools ?? [];
|
|
7341
7682
|
const instructions = opts.instructions?.trim() || buildRealtimeInstructions({
|
|
@@ -7438,6 +7779,7 @@ var RealtimeVoiceBridge = class {
|
|
|
7438
7779
|
if (this.ended || this.openaiReady) return;
|
|
7439
7780
|
this.openaiReady = true;
|
|
7440
7781
|
this.safeSend(this.openai, this.sessionConfig);
|
|
7782
|
+
this.safeSend(this.openai, { type: "response.create" });
|
|
7441
7783
|
for (const audio of this.pendingAudio.splice(0)) {
|
|
7442
7784
|
this.safeSend(this.openai, { type: "input_audio_buffer.append", audio });
|
|
7443
7785
|
}
|
|
@@ -8170,7 +8512,8 @@ function buildWebhookUrl(config, path2, missionId) {
|
|
|
8170
8512
|
return url.toString();
|
|
8171
8513
|
}
|
|
8172
8514
|
function buildRealtimeStreamUrl(webhookBaseUrl, missionId, token) {
|
|
8173
|
-
const
|
|
8515
|
+
const root = webhookBaseUrl.replace(/\/+$/, "");
|
|
8516
|
+
const url = new URL(`${root}${TWILIO_REALTIME_WS_PATH}`);
|
|
8174
8517
|
url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
|
|
8175
8518
|
url.searchParams.set("missionId", missionId);
|
|
8176
8519
|
url.searchParams.set("token", token);
|