@agenticmail/core 0.9.20 → 0.9.22
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 +3995 -3622
- package/dist/index.d.cts +770 -706
- package/dist/index.d.ts +770 -706
- package/dist/index.js +3995 -3622
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1274,822 +1274,886 @@ declare class TunnelManager {
|
|
|
1274
1274
|
}>;
|
|
1275
1275
|
}
|
|
1276
1276
|
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1277
|
+
/**
|
|
1278
|
+
* Telegram Bot API client — dependency-free (global `fetch`, Node 22+).
|
|
1279
|
+
*
|
|
1280
|
+
* Ported and MERGED from two already-tuned internal sources (licensing
|
|
1281
|
+
* confirmed by Ope, 2026-05-19 — plan §13.5):
|
|
1282
|
+
* - enterprise `agent-tools/tools/messaging/telegram.ts`
|
|
1283
|
+
* (`tgApi`, `stripMarkdown`, webhook management)
|
|
1284
|
+
* - agent-harness `fola-lib/telegram-api.mjs`
|
|
1285
|
+
* (auto-splitting `sendMessage`, the long-poll timeout discipline)
|
|
1286
|
+
*
|
|
1287
|
+
* Host-specific pieces are intentionally dropped to fit the single-tenant
|
|
1288
|
+
* open-source product: the local Bot API server auto-detection
|
|
1289
|
+
* (`http://localhost:8081`) and the filesystem media-download paths are
|
|
1290
|
+
* Fola-host infrastructure, not channel logic.
|
|
1291
|
+
*
|
|
1292
|
+
* Secrets: the bot token rides in the request URL path. Every error this
|
|
1293
|
+
* module surfaces is scrubbed with {@link redactBotToken} so a token can
|
|
1294
|
+
* never reach a log line or an API response — matching the SMS/phone
|
|
1295
|
+
* credential-handling bar.
|
|
1296
|
+
*/
|
|
1297
|
+
/** Official Telegram Bot API base. */
|
|
1298
|
+
declare const TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
1299
|
+
/** Telegram's hard per-message ceiling is 4096 characters. */
|
|
1300
|
+
declare const TELEGRAM_MESSAGE_LIMIT = 4096;
|
|
1301
|
+
/** We split below the hard limit, leaving headroom for safety. */
|
|
1302
|
+
declare const TELEGRAM_CHUNK_SIZE = 4000;
|
|
1303
|
+
/** A failed Telegram Bot API call. `description` is the provider message. */
|
|
1304
|
+
declare class TelegramApiError extends Error {
|
|
1305
|
+
readonly isTelegramApiError = true;
|
|
1306
|
+
readonly description: string;
|
|
1307
|
+
readonly errorCode?: number;
|
|
1308
|
+
constructor(method: string, description: string, errorCode?: number);
|
|
1291
1309
|
}
|
|
1292
1310
|
/**
|
|
1293
|
-
*
|
|
1294
|
-
*
|
|
1295
|
-
*
|
|
1311
|
+
* Scrub bot tokens from any string before it can be logged or returned.
|
|
1312
|
+
*
|
|
1313
|
+
* A token looks like `<digits>:<35 url-safe chars>`. We redact both an
|
|
1314
|
+
* explicitly-known token (exact match) and anything matching the generic
|
|
1315
|
+
* token shape — so a token leaks neither when the caller knows it nor
|
|
1316
|
+
* when it is buried in, say, a `fetch` failure message carrying the URL.
|
|
1296
1317
|
*/
|
|
1297
|
-
declare
|
|
1298
|
-
|
|
1299
|
-
private db;
|
|
1300
|
-
private stalwart;
|
|
1301
|
-
private accountManager;
|
|
1302
|
-
private relay;
|
|
1303
|
-
private config;
|
|
1304
|
-
private cfClient;
|
|
1305
|
-
private tunnel;
|
|
1306
|
-
private dnsConfigurator;
|
|
1307
|
-
private domainPurchaser;
|
|
1308
|
-
private smsManager;
|
|
1309
|
-
private smsPollers;
|
|
1310
|
-
private encryptionKey;
|
|
1311
|
-
constructor(options: GatewayManagerOptions);
|
|
1312
|
-
/**
|
|
1313
|
-
* Check if a message has already been delivered to an agent (deduplication).
|
|
1314
|
-
*/
|
|
1315
|
-
isAlreadyDelivered(messageId: string, agentName: string): boolean;
|
|
1316
|
-
/**
|
|
1317
|
-
* Record that a message was delivered to an agent.
|
|
1318
|
-
*/
|
|
1319
|
-
recordDelivery(messageId: string, agentName: string): void;
|
|
1320
|
-
/**
|
|
1321
|
-
* Built-in inbound mail handler: delivers relay inbound mail to agent's local Stalwart mailbox.
|
|
1322
|
-
* Authenticates as the agent to send to their own mailbox (Stalwart requires sender = auth user).
|
|
1323
|
-
*
|
|
1324
|
-
* Also intercepts owner replies to approval notification emails — if the reply says
|
|
1325
|
-
* "approve" or "reject", the pending outbound email is automatically processed.
|
|
1326
|
-
*/
|
|
1327
|
-
private deliverInboundLocally;
|
|
1328
|
-
/**
|
|
1329
|
-
* Check if an inbound email is a reply to a pending approval notification.
|
|
1330
|
-
* If the reply body starts with "approve"/"yes" or "reject"/"no", automatically
|
|
1331
|
-
* process the pending email (send it or discard it) and confirm to the owner.
|
|
1332
|
-
*/
|
|
1333
|
-
private tryProcessApprovalReply;
|
|
1334
|
-
/**
|
|
1335
|
-
* Execute approval of a pending outbound email: look up the agent, reconstitute
|
|
1336
|
-
* attachments, and send the email via gateway routing or local SMTP.
|
|
1337
|
-
*/
|
|
1338
|
-
private executeApproval;
|
|
1339
|
-
/**
|
|
1340
|
-
* Send a confirmation email back to the owner after processing an approval reply.
|
|
1341
|
-
*/
|
|
1342
|
-
private sendApprovalConfirmation;
|
|
1343
|
-
setupRelay(config: RelayConfig, options?: {
|
|
1344
|
-
defaultAgentName?: string;
|
|
1345
|
-
defaultAgentRole?: AgentRole;
|
|
1346
|
-
skipDefaultAgent?: boolean;
|
|
1347
|
-
}): Promise<{
|
|
1348
|
-
agent?: Agent;
|
|
1349
|
-
}>;
|
|
1350
|
-
setupDomain(options: {
|
|
1351
|
-
cloudflareToken: string;
|
|
1352
|
-
cloudflareAccountId: string;
|
|
1353
|
-
domain?: string;
|
|
1354
|
-
purchase?: {
|
|
1355
|
-
keywords: string[];
|
|
1356
|
-
tld?: string;
|
|
1357
|
-
};
|
|
1358
|
-
outboundWorkerUrl?: string;
|
|
1359
|
-
outboundSecret?: string;
|
|
1360
|
-
gmailRelay?: {
|
|
1361
|
-
email: string;
|
|
1362
|
-
appPassword: string;
|
|
1363
|
-
};
|
|
1364
|
-
}): Promise<{
|
|
1365
|
-
domain: string;
|
|
1366
|
-
dnsConfigured: boolean;
|
|
1367
|
-
tunnelId: string;
|
|
1368
|
-
outboundRelay?: {
|
|
1369
|
-
configured: boolean;
|
|
1370
|
-
provider: string;
|
|
1371
|
-
};
|
|
1372
|
-
nextSteps?: string[];
|
|
1373
|
-
}>;
|
|
1374
|
-
/**
|
|
1375
|
-
* Send a test email through the gateway without requiring a real agent.
|
|
1376
|
-
* In relay mode, uses "test" as the sub-address.
|
|
1377
|
-
* In domain mode, uses the first available agent (Stalwart needs real credentials).
|
|
1378
|
-
*/
|
|
1379
|
-
sendTestEmail(to: string): Promise<SendResultWithRaw | null>;
|
|
1380
|
-
/**
|
|
1381
|
-
* Route an outbound email. If the destination is external and a gateway
|
|
1382
|
-
* is configured, send via the appropriate channel.
|
|
1383
|
-
* Returns null if the mail should be sent via local Stalwart.
|
|
1384
|
-
*/
|
|
1385
|
-
routeOutbound(agentName: string, mail: SendMailOptions): Promise<SendResultWithRaw | null>;
|
|
1386
|
-
/**
|
|
1387
|
-
* Send email by submitting to local Stalwart via SMTP (port 587).
|
|
1388
|
-
* Stalwart handles DKIM signing and delivery (direct or via relay).
|
|
1389
|
-
* Reply-To is set to the agent's domain email so replies come back
|
|
1390
|
-
* to the domain (handled by Cloudflare Email Routing → inbound Worker).
|
|
1391
|
-
*/
|
|
1392
|
-
private sendViaStalwart;
|
|
1393
|
-
getStatus(): GatewayStatus;
|
|
1394
|
-
getMode(): GatewayMode;
|
|
1395
|
-
getConfig(): GatewayConfig;
|
|
1396
|
-
getStalwart(): StalwartAdmin;
|
|
1397
|
-
getDomainPurchaser(): DomainPurchaser | null;
|
|
1398
|
-
getDNSConfigurator(): DNSConfigurator | null;
|
|
1399
|
-
getTunnelManager(): TunnelManager | null;
|
|
1400
|
-
getRelay(): RelayGateway;
|
|
1401
|
-
/**
|
|
1402
|
-
* Search the connected relay account (Gmail/Outlook) for emails matching criteria.
|
|
1403
|
-
* Returns empty array if relay is not configured.
|
|
1404
|
-
*/
|
|
1405
|
-
searchRelay(criteria: {
|
|
1406
|
-
from?: string;
|
|
1407
|
-
to?: string;
|
|
1408
|
-
subject?: string;
|
|
1409
|
-
text?: string;
|
|
1410
|
-
since?: Date;
|
|
1411
|
-
before?: Date;
|
|
1412
|
-
seen?: boolean;
|
|
1413
|
-
}, maxResults?: number): Promise<RelaySearchResult[]>;
|
|
1318
|
+
declare function redactBotToken(text: string, token?: string): string;
|
|
1319
|
+
interface TelegramApiOptions {
|
|
1414
1320
|
/**
|
|
1415
|
-
*
|
|
1416
|
-
*
|
|
1417
|
-
*
|
|
1321
|
+
* Long-poll requests (`getUpdates` with a non-zero `timeout`) need an
|
|
1322
|
+
* HTTP timeout longer than the server-side poll window, or the socket
|
|
1323
|
+
* is torn down before Telegram replies.
|
|
1418
1324
|
*/
|
|
1419
|
-
|
|
1420
|
-
success: boolean;
|
|
1421
|
-
error?: string;
|
|
1422
|
-
}>;
|
|
1325
|
+
longPoll?: boolean;
|
|
1423
1326
|
/**
|
|
1424
|
-
*
|
|
1425
|
-
*
|
|
1327
|
+
* External abort — lets a long-running poll loop cancel the in-flight
|
|
1328
|
+
* request when the caller wants to shut down without waiting for the
|
|
1329
|
+
* Telegram timeout. Composed with the internal request-timeout signal
|
|
1330
|
+
* via {@link AbortSignal.any}.
|
|
1426
1331
|
*/
|
|
1427
|
-
|
|
1428
|
-
|
|
1332
|
+
signal?: AbortSignal;
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* POST a Telegram Bot API method with a JSON body and return `result`.
|
|
1336
|
+
* Throws {@link TelegramApiError} (token-scrubbed) on any failure.
|
|
1337
|
+
*/
|
|
1338
|
+
declare function callTelegramApi<T = unknown>(token: string, method: string, body?: Record<string, unknown>, options?: TelegramApiOptions): Promise<T>;
|
|
1339
|
+
/**
|
|
1340
|
+
* Strip Markdown so agent replies arrive as clean plain text. Telegram's
|
|
1341
|
+
* Markdown parse modes are bypassed entirely (no `parse_mode`) to avoid
|
|
1342
|
+
* formatting collisions on arbitrary agent output.
|
|
1343
|
+
*/
|
|
1344
|
+
declare function stripTelegramMarkdown(text: string): string;
|
|
1345
|
+
/**
|
|
1346
|
+
* Split text into <= `maxLen` chunks, cutting on a newline boundary when
|
|
1347
|
+
* one is reasonably close so messages do not break mid-word.
|
|
1348
|
+
*/
|
|
1349
|
+
declare function splitTelegramMessage(text: string, maxLen?: number): string[];
|
|
1350
|
+
interface SendTelegramMessageOptions {
|
|
1351
|
+
/** Reply to a specific message id in the chat. */
|
|
1352
|
+
replyToMessageId?: number;
|
|
1353
|
+
/** Deliver silently (no push notification). */
|
|
1354
|
+
disableNotification?: boolean;
|
|
1355
|
+
}
|
|
1356
|
+
interface SendTelegramMessageResult {
|
|
1357
|
+
/** message_id of each chunk Telegram accepted, in order. */
|
|
1358
|
+
messageIds: number[];
|
|
1359
|
+
/** Number of chunks the text was split into. */
|
|
1360
|
+
chunks: number;
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Send a text message, auto-splitting anything over the per-message
|
|
1364
|
+
* ceiling. The reply target (if any) is attached only to the first
|
|
1365
|
+
* chunk so a long reply still threads correctly.
|
|
1366
|
+
*/
|
|
1367
|
+
declare function sendTelegramMessage(token: string, chatId: string | number, text: string, options?: SendTelegramMessageOptions): Promise<SendTelegramMessageResult>;
|
|
1368
|
+
interface TelegramBotInfo {
|
|
1369
|
+
id: number;
|
|
1370
|
+
is_bot: boolean;
|
|
1371
|
+
username?: string;
|
|
1372
|
+
first_name?: string;
|
|
1373
|
+
}
|
|
1374
|
+
/** `getMe` — used to validate a token and capture the bot identity. */
|
|
1375
|
+
declare function getTelegramMe(token: string): Promise<TelegramBotInfo>;
|
|
1376
|
+
/** `getChat` — chat metadata (title, type, member count, ...). */
|
|
1377
|
+
declare function getTelegramChat(token: string, chatId: string | number): Promise<Record<string, unknown>>;
|
|
1378
|
+
interface GetUpdatesOptions {
|
|
1379
|
+
/** Max updates per call (1-100). */
|
|
1380
|
+
limit?: number;
|
|
1381
|
+
/** Long-poll window in seconds (0 = short poll). */
|
|
1382
|
+
timeoutSec?: number;
|
|
1383
|
+
/** Cancel the in-flight long-poll (used by `TelegramPoller.stop()`). */
|
|
1384
|
+
signal?: AbortSignal;
|
|
1385
|
+
}
|
|
1386
|
+
/** `getUpdates` long-poll — the poll-mode transport. */
|
|
1387
|
+
declare function getTelegramUpdates(token: string, offset: number, options?: GetUpdatesOptions): Promise<Array<Record<string, unknown>>>;
|
|
1388
|
+
interface SetWebhookOptions {
|
|
1429
1389
|
/**
|
|
1430
|
-
*
|
|
1431
|
-
*
|
|
1432
|
-
* Issue #31 — On a Docker container restart the API can come up
|
|
1433
|
-
* before Stalwart / Gmail IMAP / DNS is reachable, so the very first
|
|
1434
|
-
* setup() can fail with a transient network error. Previously that
|
|
1435
|
-
* single failure was logged and never retried, leaving polling
|
|
1436
|
-
* permanently dead until someone noticed and manually revived the
|
|
1437
|
-
* relay. We now schedule background retries with exponential backoff
|
|
1438
|
-
* (5s, 10s, 20s, 40s, 60s cap, indefinite) so the relay
|
|
1439
|
-
* self-recovers as soon as the dependency is reachable again.
|
|
1390
|
+
* Shared secret echoed by Telegram in the
|
|
1391
|
+
* `X-Telegram-Bot-Api-Secret-Token` header on every webhook delivery.
|
|
1440
1392
|
*/
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
private _resumeRelayOnce;
|
|
1445
|
-
private _scheduleRelayResumeRetry;
|
|
1446
|
-
private loadConfig;
|
|
1447
|
-
private saveConfig;
|
|
1448
|
-
private saveLastSeenUid;
|
|
1449
|
-
private loadLastSeenUid;
|
|
1393
|
+
secretToken?: string;
|
|
1394
|
+
/** Drop updates that queued up while no webhook was set. */
|
|
1395
|
+
dropPendingUpdates?: boolean;
|
|
1450
1396
|
}
|
|
1397
|
+
/** `setWebhook` — register the inbound webhook URL (webhook-mode transport). */
|
|
1398
|
+
declare function setTelegramWebhook(token: string, url: string, options?: SetWebhookOptions): Promise<boolean>;
|
|
1399
|
+
/** `deleteWebhook` — switch back to poll mode. */
|
|
1400
|
+
declare function deleteTelegramWebhook(token: string): Promise<boolean>;
|
|
1401
|
+
/** `getWebhookInfo` — current webhook status. */
|
|
1402
|
+
declare function getTelegramWebhookInfo(token: string): Promise<Record<string, unknown>>;
|
|
1451
1403
|
|
|
1452
|
-
interface RelayBridgeOptions {
|
|
1453
|
-
/** Port for the HTTP bridge server */
|
|
1454
|
-
port: number;
|
|
1455
|
-
/** Shared secret for authenticating requests */
|
|
1456
|
-
secret: string;
|
|
1457
|
-
/** Local Stalwart SMTP host (default: 127.0.0.1) */
|
|
1458
|
-
smtpHost?: string;
|
|
1459
|
-
/** Local Stalwart SMTP submission port (default: 587) */
|
|
1460
|
-
smtpPort?: number;
|
|
1461
|
-
/** Stalwart auth credentials for the sending agent */
|
|
1462
|
-
smtpUser: string;
|
|
1463
|
-
smtpPass: string;
|
|
1464
|
-
}
|
|
1465
1404
|
/**
|
|
1466
|
-
*
|
|
1467
|
-
*
|
|
1468
|
-
* Stalwart then handles DKIM signing, MX resolution, and direct delivery
|
|
1469
|
-
* to the recipient's mail server on port 25. FROM is preserved exactly.
|
|
1470
|
-
*
|
|
1471
|
-
* This bridge is exposed via Cloudflare Tunnel so Cloudflare Workers
|
|
1472
|
-
* (which can't connect to port 25) can trigger outbound email through it.
|
|
1405
|
+
* Telegram update parsing — pure, dependency-free.
|
|
1473
1406
|
*
|
|
1474
|
-
*
|
|
1475
|
-
*
|
|
1407
|
+
* Ported from the inbound-message handling in the agent-harness Fola
|
|
1408
|
+
* bridge (`fola-telegram-bridge.mjs` — the `formatPrompt` header fields
|
|
1409
|
+
* and the `STOP_WORDS` abort set), stripped of every Fola-host specific
|
|
1410
|
+
* (session routing, the fola-claude prompt envelope, media downloads).
|
|
1476
1411
|
*/
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1412
|
+
type TelegramChatType = 'private' | 'group' | 'supergroup' | 'channel' | 'unknown';
|
|
1413
|
+
/** A normalized inbound Telegram text message. */
|
|
1414
|
+
interface ParsedTelegramMessage {
|
|
1415
|
+
/** Telegram `update_id` — drives the poll offset. */
|
|
1416
|
+
updateId: number;
|
|
1417
|
+
/** `message_id` within the chat. */
|
|
1418
|
+
messageId: number;
|
|
1419
|
+
/** Chat id as a string (Telegram ids are 64-bit; strings avoid loss). */
|
|
1420
|
+
chatId: string;
|
|
1421
|
+
chatType: TelegramChatType;
|
|
1422
|
+
chatTitle?: string;
|
|
1423
|
+
/** Sender id as a string; falls back to the chat id for channel posts. */
|
|
1424
|
+
fromId: string;
|
|
1425
|
+
fromName: string;
|
|
1426
|
+
fromUsername?: string;
|
|
1427
|
+
/** Message text (or media caption). Always non-empty for a parsed result. */
|
|
1428
|
+
text: string;
|
|
1429
|
+
/** `message_id` of the message this one replies to, if any. */
|
|
1430
|
+
replyToMessageId?: number;
|
|
1431
|
+
/** Text of the replied-to message, when Telegram included it. */
|
|
1432
|
+
replyToText?: string;
|
|
1433
|
+
/** ISO-8601 timestamp derived from the Telegram `date` epoch. */
|
|
1434
|
+
date: string;
|
|
1485
1435
|
}
|
|
1486
|
-
|
|
1436
|
+
/**
|
|
1437
|
+
* Normalize a raw Telegram update into a {@link ParsedTelegramMessage},
|
|
1438
|
+
* or `null` when it carries no usable text (callback queries, service
|
|
1439
|
+
* messages, edits with no text, non-text media without a caption).
|
|
1440
|
+
* Handles both `message` and `channel_post` envelopes.
|
|
1441
|
+
*/
|
|
1442
|
+
declare function parseTelegramUpdate(update: unknown): ParsedTelegramMessage | null;
|
|
1443
|
+
/**
|
|
1444
|
+
* Words that, sent alone (case-insensitive, optional trailing
|
|
1445
|
+
* punctuation), signal "abort whatever you are doing for me". Kept
|
|
1446
|
+
* deliberately narrow — anything longer or ambiguous is a normal
|
|
1447
|
+
* message. Ported verbatim from the Fola bridge.
|
|
1448
|
+
*/
|
|
1449
|
+
declare const TELEGRAM_STOP_WORDS: ReadonlySet<string>;
|
|
1450
|
+
/** True when `text` is a bare stop command. */
|
|
1451
|
+
declare function isTelegramStopCommand(text: string): boolean;
|
|
1452
|
+
/**
|
|
1453
|
+
* Compute the next `getUpdates` offset from a batch: one past the
|
|
1454
|
+
* highest `update_id` seen. Advancing the offset is what acknowledges
|
|
1455
|
+
* updates to Telegram, so this must run on the raw batch even if
|
|
1456
|
+
* individual updates fail to parse.
|
|
1457
|
+
*/
|
|
1458
|
+
declare function nextTelegramOffset(currentOffset: number, updates: Array<{
|
|
1459
|
+
update_id?: unknown;
|
|
1460
|
+
}>): number;
|
|
1487
1461
|
|
|
1488
1462
|
/**
|
|
1489
|
-
*
|
|
1463
|
+
* Telegram Manager — per-agent Telegram bot configuration + message log.
|
|
1490
1464
|
*
|
|
1491
|
-
*
|
|
1492
|
-
*
|
|
1493
|
-
*
|
|
1494
|
-
*
|
|
1495
|
-
*
|
|
1465
|
+
* Models the existing {@link import('../sms/manager.js').SmsManager} — the
|
|
1466
|
+
* closest existing channel — so the hardening bar matches:
|
|
1467
|
+
* - Config lives in agent metadata under the `telegram` key.
|
|
1468
|
+
* - Credential fields (`botToken`, `webhookSecret`) are encrypted at
|
|
1469
|
+
* rest with the same AES-256-GCM scheme (`encryptSecret`) and
|
|
1470
|
+
* redacted on every read that leaves the process.
|
|
1471
|
+
* - Inbound/outbound messages are stored in a `telegram_messages` table.
|
|
1496
1472
|
*
|
|
1497
|
-
*
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
/**
|
|
1506
|
-
|
|
1507
|
-
/**
|
|
1508
|
-
|
|
1509
|
-
/**
|
|
1510
|
-
|
|
1511
|
-
/**
|
|
1512
|
-
|
|
1513
|
-
/**
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1473
|
+
* Single-tenant: there is no org / multi-tenant scoping and nothing is
|
|
1474
|
+
* hardcoded — every bot token, chat id and operator identity is
|
|
1475
|
+
* per-agent config supplied by the user.
|
|
1476
|
+
*/
|
|
1477
|
+
|
|
1478
|
+
/** Transport mode: long-poll `getUpdates`, or a registered webhook. */
|
|
1479
|
+
type TelegramMode = 'poll' | 'webhook';
|
|
1480
|
+
interface TelegramConfig {
|
|
1481
|
+
/** Whether the Telegram channel is active for this agent. */
|
|
1482
|
+
enabled: boolean;
|
|
1483
|
+
/** Bot API token from @BotFather. SECRET — encrypted at rest, redacted on read. */
|
|
1484
|
+
botToken: string;
|
|
1485
|
+
/** Bot @username (non-secret, for display). Captured from `getMe` at setup. */
|
|
1486
|
+
botUsername?: string;
|
|
1487
|
+
/** Numeric bot id (non-secret). Captured from `getMe` at setup. */
|
|
1488
|
+
botId?: number;
|
|
1489
|
+
/**
|
|
1490
|
+
* Chat ids permitted to message the agent. EMPTY = nobody (fail-closed,
|
|
1491
|
+
* same posture as the Fola bridge). The operator chat is always allowed.
|
|
1492
|
+
*/
|
|
1493
|
+
allowedChatIds: string[];
|
|
1494
|
+
/** Chat that receives `ask_operator` notifications + can approve (plan §13.4). */
|
|
1495
|
+
operatorChatId?: string;
|
|
1496
|
+
/** Inbound transport. */
|
|
1497
|
+
mode: TelegramMode;
|
|
1498
|
+
/** Public webhook URL (webhook mode only). */
|
|
1499
|
+
webhookUrl?: string;
|
|
1500
|
+
/**
|
|
1501
|
+
* Shared secret echoed by Telegram in the
|
|
1502
|
+
* `X-Telegram-Bot-Api-Secret-Token` header. SECRET — encrypted at
|
|
1503
|
+
* rest, redacted on read. Doubles as the webhook routing key.
|
|
1504
|
+
*/
|
|
1520
1505
|
webhookSecret?: string;
|
|
1521
|
-
/**
|
|
1506
|
+
/** Persisted `getUpdates` offset (poll mode). */
|
|
1507
|
+
pollOffset?: number;
|
|
1508
|
+
/** When the channel was configured. */
|
|
1522
1509
|
configuredAt: string;
|
|
1523
1510
|
}
|
|
1524
|
-
interface
|
|
1525
|
-
from: string;
|
|
1526
|
-
body: string;
|
|
1527
|
-
timestamp: string;
|
|
1528
|
-
raw?: string;
|
|
1529
|
-
}
|
|
1530
|
-
interface SmsMessage {
|
|
1511
|
+
interface TelegramMessage {
|
|
1531
1512
|
id: string;
|
|
1532
1513
|
agentId: string;
|
|
1533
1514
|
direction: 'inbound' | 'outbound';
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1515
|
+
chatId: string;
|
|
1516
|
+
/** Telegram `message_id` (may be absent for a failed outbound attempt). */
|
|
1517
|
+
telegramMessageId?: number;
|
|
1518
|
+
/** Sender id (inbound only). */
|
|
1519
|
+
fromId?: string;
|
|
1520
|
+
text: string;
|
|
1521
|
+
status: 'received' | 'sent' | 'failed' | 'pending';
|
|
1537
1522
|
createdAt: string;
|
|
1538
1523
|
metadata?: Record<string, unknown>;
|
|
1539
1524
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
dryRun?: boolean;
|
|
1544
|
-
}
|
|
1545
|
-
interface SendSmsResult {
|
|
1546
|
-
provider: SmsConfig['provider'];
|
|
1547
|
-
id?: string;
|
|
1548
|
-
status: string;
|
|
1549
|
-
from: string;
|
|
1550
|
-
to: string;
|
|
1551
|
-
body: string;
|
|
1552
|
-
raw?: unknown;
|
|
1553
|
-
}
|
|
1554
|
-
interface InboundSmsEvent {
|
|
1555
|
-
provider: SmsConfig['provider'];
|
|
1556
|
-
id?: string;
|
|
1557
|
-
from: string;
|
|
1558
|
-
to: string;
|
|
1559
|
-
body: string;
|
|
1560
|
-
timestamp: string;
|
|
1561
|
-
raw?: unknown;
|
|
1562
|
-
}
|
|
1563
|
-
interface SmsProvider {
|
|
1564
|
-
id: SmsConfig['provider'];
|
|
1565
|
-
sendSms(config: SmsConfig, input: SendSmsInput): Promise<SendSmsResult>;
|
|
1566
|
-
parseInboundSms(payload: Record<string, unknown>): InboundSmsEvent | null;
|
|
1567
|
-
}
|
|
1568
|
-
declare function redactSmsConfig(config: SmsConfig): SmsConfig;
|
|
1569
|
-
declare function getSmsProvider(provider: SmsConfig['provider']): SmsProvider;
|
|
1570
|
-
declare function mapProviderSmsStatus(status: string): SmsMessage['status'];
|
|
1571
|
-
/** Normalize a phone number to E.164-ish format (+1XXXXXXXXXX) */
|
|
1572
|
-
declare function normalizePhoneNumber(raw: string): string | null;
|
|
1573
|
-
/** Validate a phone number (basic) */
|
|
1574
|
-
declare function isValidPhoneNumber(phone: string): boolean;
|
|
1525
|
+
/** Telegram's `secret_token` charset, and our minimum entropy floor. */
|
|
1526
|
+
declare const TELEGRAM_WEBHOOK_SECRET_RE: RegExp;
|
|
1527
|
+
declare const TELEGRAM_MIN_WEBHOOK_SECRET_LENGTH = 16;
|
|
1575
1528
|
/**
|
|
1576
|
-
*
|
|
1577
|
-
*
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
* - voice-noreply@google.com
|
|
1581
|
-
* - *@txt.voice.google.com
|
|
1582
|
-
* - Google Voice <voice-noreply@google.com>
|
|
1529
|
+
* Redact the credential fields of a Telegram config for any value that
|
|
1530
|
+
* leaves the process (API responses, logs). The bot token is collapsed
|
|
1531
|
+
* to `***` entirely — never partially shown — matching the SMS bar and
|
|
1532
|
+
* the plan §13.5 rule "no token ever in a log line or API response".
|
|
1583
1533
|
*/
|
|
1584
|
-
declare function
|
|
1534
|
+
declare function redactTelegramConfig(config: TelegramConfig): TelegramConfig;
|
|
1585
1535
|
/**
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1536
|
+
* Allow-list gate for INBOUND messages. A chat may talk to the agent
|
|
1537
|
+
* only if it is on `allowedChatIds` OR it is the configured operator
|
|
1538
|
+
* chat. An empty allow-list means nobody — fail closed.
|
|
1588
1539
|
*/
|
|
1589
|
-
declare function
|
|
1590
|
-
declare class
|
|
1540
|
+
declare function isTelegramChatAllowed(config: TelegramConfig, chatId: string): boolean;
|
|
1541
|
+
declare class TelegramManager {
|
|
1591
1542
|
private db;
|
|
1592
1543
|
private encryptionKey?;
|
|
1593
1544
|
private initialized;
|
|
1594
1545
|
/**
|
|
1595
|
-
* Optional master key used to encrypt
|
|
1596
|
-
* AES-256-GCM scheme
|
|
1597
|
-
*
|
|
1598
|
-
*
|
|
1599
|
-
* downgrades both stay safe.
|
|
1546
|
+
* Optional master key used to encrypt Telegram credentials at rest
|
|
1547
|
+
* (the same AES-256-GCM scheme SMS/phone use). When absent (tests, or
|
|
1548
|
+
* a deployment with no master key) configs are stored as-is and reads
|
|
1549
|
+
* tolerate plaintext — upgrades and downgrades both stay safe.
|
|
1600
1550
|
*/
|
|
1601
1551
|
constructor(db: Database, encryptionKey?: string | undefined);
|
|
1602
|
-
|
|
1552
|
+
private ensureTable;
|
|
1553
|
+
/** Encrypt the credential fields of a config before persisting. */
|
|
1603
1554
|
private encryptConfig;
|
|
1604
|
-
/** Decrypt the credential fields of
|
|
1555
|
+
/** Decrypt the credential fields of a config after loading. */
|
|
1605
1556
|
private decryptConfig;
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1557
|
+
/** Normalize a stored/loaded config object, defaulting missing fields. */
|
|
1558
|
+
private normalizeConfig;
|
|
1559
|
+
/** Get the Telegram config from agent metadata (credentials decrypted). */
|
|
1560
|
+
getConfig(agentId: string): TelegramConfig | null;
|
|
1561
|
+
/** Save the Telegram config to agent metadata (credentials encrypted). */
|
|
1562
|
+
saveConfig(agentId: string, config: TelegramConfig): void;
|
|
1563
|
+
/** Remove the Telegram config from agent metadata. */
|
|
1564
|
+
removeConfig(agentId: string): void;
|
|
1565
|
+
/** Persist a new poll offset without touching the rest of the config. */
|
|
1566
|
+
updatePollOffset(agentId: string, offset: number): void;
|
|
1611
1567
|
/**
|
|
1612
|
-
* Resolve the
|
|
1613
|
-
*
|
|
1614
|
-
*
|
|
1615
|
-
*
|
|
1616
|
-
*
|
|
1617
|
-
*
|
|
1618
|
-
*
|
|
1619
|
-
* Returns the configured `forwardingEmail` (the same Gmail Google
|
|
1620
|
-
* Voice forwards inbound SMS to, which the operator already has
|
|
1621
|
-
* push notifications enabled for) when SMS is configured AND
|
|
1622
|
-
* enabled. Returns null otherwise — caller falls through to a
|
|
1623
|
-
* silent log + system event.
|
|
1624
|
-
*
|
|
1625
|
-
* Why we don't try real-SMS delivery yet: Google Voice's
|
|
1626
|
-
* `<number>@txt.voice.google.com` email-to-SMS gateway was
|
|
1627
|
-
* deprecated by Google years ago. A future `carrier` field on
|
|
1628
|
-
* SmsConfig (Verizon vtext.com / AT&T txt.att.net / etc) will let
|
|
1629
|
-
* the operator opt into actual SMS, but that's a follow-up — the
|
|
1630
|
-
* email path already gets the operator a phone notification.
|
|
1568
|
+
* Resolve the agent that owns a webhook secret. Used to authenticate +
|
|
1569
|
+
* route an inbound Telegram webhook delivery: a webhook carries no bot
|
|
1570
|
+
* identity, so the `X-Telegram-Bot-Api-Secret-Token` header is the
|
|
1571
|
+
* routing key. The comparison is constant-time, and a non-match
|
|
1572
|
+
* returns `null` so the route can answer with a single uniform 403
|
|
1573
|
+
* (no enumeration oracle — same posture as the SMS webhook).
|
|
1631
1574
|
*/
|
|
1632
|
-
|
|
1633
|
-
/** Remove SMS config from agent metadata */
|
|
1634
|
-
removeSmsConfig(agentId: string): void;
|
|
1635
|
-
/** Find the agent whose SMS config owns a phone number. */
|
|
1636
|
-
findAgentBySmsNumber(phoneNumber: string, provider?: SmsConfig['provider']): {
|
|
1575
|
+
findAgentByWebhookSecret(secret: string): {
|
|
1637
1576
|
agentId: string;
|
|
1638
|
-
config:
|
|
1577
|
+
config: TelegramConfig;
|
|
1639
1578
|
} | null;
|
|
1640
|
-
/**
|
|
1641
|
-
|
|
1642
|
-
/** Record an
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1579
|
+
/** True if an inbound message with this Telegram id is already stored. */
|
|
1580
|
+
inboundMessageExists(agentId: string, chatId: string, telegramMessageId: number): boolean;
|
|
1581
|
+
/** Record an inbound Telegram message. */
|
|
1582
|
+
recordInbound(agentId: string, input: {
|
|
1583
|
+
chatId: string;
|
|
1584
|
+
telegramMessageId: number;
|
|
1585
|
+
fromId?: string;
|
|
1586
|
+
text: string;
|
|
1587
|
+
createdAt?: string;
|
|
1588
|
+
}, metadata?: Record<string, unknown>): TelegramMessage;
|
|
1589
|
+
/** Record an outbound Telegram message attempt. */
|
|
1590
|
+
recordOutbound(agentId: string, input: {
|
|
1591
|
+
chatId: string;
|
|
1592
|
+
text: string;
|
|
1593
|
+
telegramMessageId?: number;
|
|
1594
|
+
status?: TelegramMessage['status'];
|
|
1595
|
+
}, metadata?: Record<string, unknown>): TelegramMessage;
|
|
1596
|
+
/** Update the status (+ optional metadata) of a stored message. */
|
|
1597
|
+
updateStatus(id: string, status: TelegramMessage['status'], metadata?: Record<string, unknown>): void;
|
|
1598
|
+
/** List stored Telegram messages for an agent, newest first. */
|
|
1647
1599
|
listMessages(agentId: string, opts?: {
|
|
1648
1600
|
direction?: 'inbound' | 'outbound';
|
|
1601
|
+
chatId?: string;
|
|
1649
1602
|
limit?: number;
|
|
1650
1603
|
offset?: number;
|
|
1651
|
-
}):
|
|
1652
|
-
/** Check for recent verification codes in inbound SMS */
|
|
1653
|
-
checkForVerificationCode(agentId: string, minutesBack?: number): {
|
|
1654
|
-
code: string;
|
|
1655
|
-
from: string;
|
|
1656
|
-
body: string;
|
|
1657
|
-
receivedAt: string;
|
|
1658
|
-
} | null;
|
|
1659
|
-
}
|
|
1660
|
-
/**
|
|
1661
|
-
* SmsPoller — Polls for Google Voice SMS forwarded emails.
|
|
1662
|
-
*
|
|
1663
|
-
* Two modes:
|
|
1664
|
-
* 1. **Same email** (sameAsRelay=true): Hooks into the relay's onInboundMail callback.
|
|
1665
|
-
* The relay poll already fetches emails; SmsPoller filters for GV forwarded SMS.
|
|
1666
|
-
* 2. **Separate email** (sameAsRelay=false): Runs its own IMAP poll against the GV Gmail
|
|
1667
|
-
* using the separate credentials (forwardingEmail + forwardingPassword).
|
|
1668
|
-
*
|
|
1669
|
-
* Parsed SMS messages are stored in the sms_messages table.
|
|
1670
|
-
*/
|
|
1671
|
-
declare class SmsPoller {
|
|
1672
|
-
private smsManager;
|
|
1673
|
-
private agentId;
|
|
1674
|
-
private config;
|
|
1675
|
-
private pollTimer;
|
|
1676
|
-
private polling;
|
|
1677
|
-
private lastSeenUid;
|
|
1678
|
-
private firstPollDone;
|
|
1679
|
-
private consecutiveFailures;
|
|
1680
|
-
private readonly POLL_INTERVAL_MS;
|
|
1681
|
-
private readonly MAX_BACKOFF_MS;
|
|
1682
|
-
private readonly CONNECT_TIMEOUT_MS;
|
|
1683
|
-
/** Callback for new inbound SMS */
|
|
1684
|
-
onSmsReceived: ((agentId: string, sms: ParsedSms) => void | Promise<void>) | null;
|
|
1685
|
-
constructor(smsManager: SmsManager, agentId: string, config: SmsConfig);
|
|
1686
|
-
/** Whether this poller needs its own IMAP connection (separate Gmail) */
|
|
1687
|
-
get needsSeparatePoll(): boolean;
|
|
1688
|
-
/**
|
|
1689
|
-
* Process an email from the relay poll (same-email mode).
|
|
1690
|
-
* Called by the relay gateway's onInboundMail when it detects a GV email.
|
|
1691
|
-
* Returns true if the email was an SMS and was processed.
|
|
1692
|
-
*/
|
|
1693
|
-
processRelayEmail(from: string, subject: string, body: string): boolean;
|
|
1694
|
-
/**
|
|
1695
|
-
* Start polling the separate GV Gmail for SMS (separate-email mode).
|
|
1696
|
-
* Only call this if needsSeparatePoll is true.
|
|
1697
|
-
*/
|
|
1698
|
-
startPolling(): Promise<void>;
|
|
1699
|
-
stopPolling(): void;
|
|
1700
|
-
private scheduleNext;
|
|
1701
|
-
private pollOnce;
|
|
1604
|
+
}): TelegramMessage[];
|
|
1702
1605
|
}
|
|
1703
1606
|
|
|
1704
1607
|
/**
|
|
1705
|
-
* Telegram
|
|
1608
|
+
* Telegram ⇄ `ask_operator` bridge (plan §13.4 / §13.5) — pure helpers.
|
|
1706
1609
|
*
|
|
1707
|
-
*
|
|
1708
|
-
*
|
|
1709
|
-
*
|
|
1710
|
-
*
|
|
1711
|
-
*
|
|
1712
|
-
*
|
|
1610
|
+
* The realtime voice agent's `ask_operator` tool records an *operator
|
|
1611
|
+
* query* on the phone mission; the API exposes it through the
|
|
1612
|
+
* operator-query endpoints. Email is the channel-agnostic default
|
|
1613
|
+
* notifier (plan §5). This module makes Telegram a first-class
|
|
1614
|
+
* notification + approval channel WITHOUT duplicating any of that
|
|
1615
|
+
* machinery — it only:
|
|
1713
1616
|
*
|
|
1714
|
-
*
|
|
1715
|
-
*
|
|
1716
|
-
*
|
|
1717
|
-
*
|
|
1617
|
+
* 1. {@link formatOperatorQueryTelegramMessage} — renders the
|
|
1618
|
+
* notification text the operator receives.
|
|
1619
|
+
* 2. {@link parseTelegramOperatorReply} — parses the operator's
|
|
1620
|
+
* Telegram reply back into a `{ queryId, answer }`.
|
|
1718
1621
|
*
|
|
1719
|
-
*
|
|
1720
|
-
*
|
|
1721
|
-
* never reach a log line or an API response — matching the SMS/phone
|
|
1722
|
-
* credential-handling bar.
|
|
1622
|
+
* The caller feeds the parsed result straight into the SAME
|
|
1623
|
+
* `PhoneManager.answerOperatorQuery` the inbound email-reply hook uses.
|
|
1723
1624
|
*/
|
|
1724
|
-
/**
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
constructor(method: string, description: string, errorCode?: number);
|
|
1625
|
+
/**
|
|
1626
|
+
* Token embedded in a notification so the operator's reply can be
|
|
1627
|
+
* matched back to a query. Kept short — the operator may see it.
|
|
1628
|
+
*/
|
|
1629
|
+
declare const TELEGRAM_OPERATOR_QUERY_TAG = "AMQ";
|
|
1630
|
+
interface OperatorQueryNotificationInput {
|
|
1631
|
+
queryId: string;
|
|
1632
|
+
question: string;
|
|
1633
|
+
callContext?: string;
|
|
1634
|
+
urgency?: string;
|
|
1635
|
+
missionId?: string;
|
|
1736
1636
|
}
|
|
1737
1637
|
/**
|
|
1738
|
-
*
|
|
1739
|
-
*
|
|
1740
|
-
*
|
|
1741
|
-
* explicitly-known token (exact match) and anything matching the generic
|
|
1742
|
-
* token shape — so a token leaks neither when the caller knows it nor
|
|
1743
|
-
* when it is buried in, say, a `fetch` failure message carrying the URL.
|
|
1638
|
+
* Render the Telegram message body for an `ask_operator` notification.
|
|
1639
|
+
* The trailing `[AMQ <id>]` token lets a reply be matched to the query
|
|
1640
|
+
* even when the operator does not use Telegram's native reply gesture.
|
|
1744
1641
|
*/
|
|
1745
|
-
declare function
|
|
1746
|
-
|
|
1642
|
+
declare function formatOperatorQueryTelegramMessage(input: OperatorQueryNotificationInput): string;
|
|
1643
|
+
/** A `kind` of `approve`/`deny` is a decision; `answer` is free-form. */
|
|
1644
|
+
type OperatorReplyKind = 'answer' | 'approve' | 'deny';
|
|
1645
|
+
interface ParsedOperatorReply {
|
|
1747
1646
|
/**
|
|
1748
|
-
*
|
|
1749
|
-
*
|
|
1750
|
-
*
|
|
1647
|
+
* Query id when the reply names one — explicitly (`/answer <id>`,
|
|
1648
|
+
* an inline `oq_…` token) or implicitly (a native Telegram reply
|
|
1649
|
+
* quoting a `[AMQ <id>]`-tagged notification). `undefined` when the
|
|
1650
|
+
* reply is a bare message and the caller must resolve the target.
|
|
1751
1651
|
*/
|
|
1752
|
-
|
|
1652
|
+
queryId?: string;
|
|
1653
|
+
/** The answer text to record against the query. Always non-empty. */
|
|
1654
|
+
answer: string;
|
|
1655
|
+
kind: OperatorReplyKind;
|
|
1753
1656
|
}
|
|
1754
1657
|
/**
|
|
1755
|
-
*
|
|
1756
|
-
*
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
*
|
|
1761
|
-
*
|
|
1762
|
-
*
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
*
|
|
1767
|
-
* one is reasonably close so messages do not break mid-word.
|
|
1658
|
+
* Parse an operator's Telegram message into a {@link ParsedOperatorReply},
|
|
1659
|
+
* or `null` when it carries no usable answer.
|
|
1660
|
+
*
|
|
1661
|
+
* Recognized forms (most explicit first):
|
|
1662
|
+
* - `/answer <queryId> <text>`
|
|
1663
|
+
* - `/approve [<queryId>] [note]` · `/deny [<queryId>] [note]`
|
|
1664
|
+
* - a plain message — `queryId` is taken from a quoted tagged
|
|
1665
|
+
* notification (`replyToText`) or an inline `oq_…` / `[AMQ …]` token,
|
|
1666
|
+
* and is otherwise left `undefined` for the caller to resolve.
|
|
1667
|
+
*
|
|
1668
|
+
* The `@botname` suffix Telegram appends to commands in groups
|
|
1669
|
+
* (`/approve@mybot`) is tolerated.
|
|
1768
1670
|
*/
|
|
1769
|
-
declare function
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1671
|
+
declare function parseTelegramOperatorReply(input: {
|
|
1672
|
+
text: string;
|
|
1673
|
+
replyToText?: string;
|
|
1674
|
+
}): ParsedOperatorReply | null;
|
|
1675
|
+
|
|
1676
|
+
interface LocalSmtpConfig {
|
|
1677
|
+
host: string;
|
|
1678
|
+
port: number;
|
|
1679
|
+
user: string;
|
|
1680
|
+
pass: string;
|
|
1775
1681
|
}
|
|
1776
|
-
interface
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1682
|
+
interface GatewayManagerOptions {
|
|
1683
|
+
db: Database;
|
|
1684
|
+
stalwart: StalwartAdmin;
|
|
1685
|
+
accountManager?: AccountManager;
|
|
1686
|
+
localSmtp?: LocalSmtpConfig;
|
|
1687
|
+
onInboundMail?: (agentName: string, mail: InboundEmail) => void | Promise<void>;
|
|
1688
|
+
/** Master key used to encrypt credentials at rest in SQLite. */
|
|
1689
|
+
encryptionKey?: string;
|
|
1781
1690
|
}
|
|
1782
1691
|
/**
|
|
1783
|
-
*
|
|
1784
|
-
*
|
|
1785
|
-
*
|
|
1692
|
+
* GatewayManager orchestrates relay and domain modes for sending/receiving
|
|
1693
|
+
* real internet email. It coordinates between the relay gateway, Cloudflare
|
|
1694
|
+
* services (DNS, tunnels, registrar), and the local Stalwart instance.
|
|
1786
1695
|
*/
|
|
1787
|
-
declare
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
/**
|
|
1805
|
-
|
|
1806
|
-
|
|
1696
|
+
declare class GatewayManager {
|
|
1697
|
+
private options;
|
|
1698
|
+
private db;
|
|
1699
|
+
private stalwart;
|
|
1700
|
+
private accountManager;
|
|
1701
|
+
private relay;
|
|
1702
|
+
private config;
|
|
1703
|
+
private cfClient;
|
|
1704
|
+
private tunnel;
|
|
1705
|
+
private dnsConfigurator;
|
|
1706
|
+
private domainPurchaser;
|
|
1707
|
+
private smsManager;
|
|
1708
|
+
private smsPollers;
|
|
1709
|
+
private telegramManager;
|
|
1710
|
+
private telegramPollers;
|
|
1711
|
+
private encryptionKey;
|
|
1712
|
+
constructor(options: GatewayManagerOptions);
|
|
1713
|
+
/**
|
|
1714
|
+
* Check if a message has already been delivered to an agent (deduplication).
|
|
1715
|
+
*/
|
|
1716
|
+
isAlreadyDelivered(messageId: string, agentName: string): boolean;
|
|
1717
|
+
/**
|
|
1718
|
+
* Record that a message was delivered to an agent.
|
|
1719
|
+
*/
|
|
1720
|
+
recordDelivery(messageId: string, agentName: string): void;
|
|
1721
|
+
/**
|
|
1722
|
+
* Built-in inbound mail handler: delivers relay inbound mail to agent's local Stalwart mailbox.
|
|
1723
|
+
* Authenticates as the agent to send to their own mailbox (Stalwart requires sender = auth user).
|
|
1724
|
+
*
|
|
1725
|
+
* Also intercepts owner replies to approval notification emails — if the reply says
|
|
1726
|
+
* "approve" or "reject", the pending outbound email is automatically processed.
|
|
1727
|
+
*/
|
|
1728
|
+
private deliverInboundLocally;
|
|
1729
|
+
/**
|
|
1730
|
+
* Check if an inbound email is a reply to a pending approval notification.
|
|
1731
|
+
* If the reply body starts with "approve"/"yes" or "reject"/"no", automatically
|
|
1732
|
+
* process the pending email (send it or discard it) and confirm to the owner.
|
|
1733
|
+
*/
|
|
1734
|
+
private tryProcessApprovalReply;
|
|
1735
|
+
/**
|
|
1736
|
+
* Execute approval of a pending outbound email: look up the agent, reconstitute
|
|
1737
|
+
* attachments, and send the email via gateway routing or local SMTP.
|
|
1738
|
+
*/
|
|
1739
|
+
private executeApproval;
|
|
1740
|
+
/**
|
|
1741
|
+
* Send a confirmation email back to the owner after processing an approval reply.
|
|
1742
|
+
*/
|
|
1743
|
+
private sendApprovalConfirmation;
|
|
1744
|
+
setupRelay(config: RelayConfig, options?: {
|
|
1745
|
+
defaultAgentName?: string;
|
|
1746
|
+
defaultAgentRole?: AgentRole;
|
|
1747
|
+
skipDefaultAgent?: boolean;
|
|
1748
|
+
}): Promise<{
|
|
1749
|
+
agent?: Agent;
|
|
1750
|
+
}>;
|
|
1751
|
+
setupDomain(options: {
|
|
1752
|
+
cloudflareToken: string;
|
|
1753
|
+
cloudflareAccountId: string;
|
|
1754
|
+
domain?: string;
|
|
1755
|
+
purchase?: {
|
|
1756
|
+
keywords: string[];
|
|
1757
|
+
tld?: string;
|
|
1758
|
+
};
|
|
1759
|
+
outboundWorkerUrl?: string;
|
|
1760
|
+
outboundSecret?: string;
|
|
1761
|
+
gmailRelay?: {
|
|
1762
|
+
email: string;
|
|
1763
|
+
appPassword: string;
|
|
1764
|
+
};
|
|
1765
|
+
}): Promise<{
|
|
1766
|
+
domain: string;
|
|
1767
|
+
dnsConfigured: boolean;
|
|
1768
|
+
tunnelId: string;
|
|
1769
|
+
outboundRelay?: {
|
|
1770
|
+
configured: boolean;
|
|
1771
|
+
provider: string;
|
|
1772
|
+
};
|
|
1773
|
+
nextSteps?: string[];
|
|
1774
|
+
}>;
|
|
1775
|
+
/**
|
|
1776
|
+
* Send a test email through the gateway without requiring a real agent.
|
|
1777
|
+
* In relay mode, uses "test" as the sub-address.
|
|
1778
|
+
* In domain mode, uses the first available agent (Stalwart needs real credentials).
|
|
1779
|
+
*/
|
|
1780
|
+
sendTestEmail(to: string): Promise<SendResultWithRaw | null>;
|
|
1781
|
+
/**
|
|
1782
|
+
* Route an outbound email. If the destination is external and a gateway
|
|
1783
|
+
* is configured, send via the appropriate channel.
|
|
1784
|
+
* Returns null if the mail should be sent via local Stalwart.
|
|
1785
|
+
*/
|
|
1786
|
+
routeOutbound(agentName: string, mail: SendMailOptions): Promise<SendResultWithRaw | null>;
|
|
1787
|
+
/**
|
|
1788
|
+
* Send email by submitting to local Stalwart via SMTP (port 587).
|
|
1789
|
+
* Stalwart handles DKIM signing and delivery (direct or via relay).
|
|
1790
|
+
* Reply-To is set to the agent's domain email so replies come back
|
|
1791
|
+
* to the domain (handled by Cloudflare Email Routing → inbound Worker).
|
|
1792
|
+
*/
|
|
1793
|
+
private sendViaStalwart;
|
|
1794
|
+
getStatus(): GatewayStatus;
|
|
1795
|
+
getMode(): GatewayMode;
|
|
1796
|
+
getConfig(): GatewayConfig;
|
|
1797
|
+
getStalwart(): StalwartAdmin;
|
|
1798
|
+
getDomainPurchaser(): DomainPurchaser | null;
|
|
1799
|
+
getDNSConfigurator(): DNSConfigurator | null;
|
|
1800
|
+
getTunnelManager(): TunnelManager | null;
|
|
1801
|
+
getRelay(): RelayGateway;
|
|
1807
1802
|
/**
|
|
1808
|
-
*
|
|
1809
|
-
*
|
|
1803
|
+
* Search the connected relay account (Gmail/Outlook) for emails matching criteria.
|
|
1804
|
+
* Returns empty array if relay is not configured.
|
|
1810
1805
|
*/
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1806
|
+
searchRelay(criteria: {
|
|
1807
|
+
from?: string;
|
|
1808
|
+
to?: string;
|
|
1809
|
+
subject?: string;
|
|
1810
|
+
text?: string;
|
|
1811
|
+
since?: Date;
|
|
1812
|
+
before?: Date;
|
|
1813
|
+
seen?: boolean;
|
|
1814
|
+
}, maxResults?: number): Promise<RelaySearchResult[]>;
|
|
1815
|
+
/**
|
|
1816
|
+
* Import an email from the connected relay account into an agent's local inbox.
|
|
1817
|
+
* Fetches the full message from relay IMAP and delivers it locally, preserving
|
|
1818
|
+
* all headers (Message-ID, In-Reply-To, References) for thread continuity.
|
|
1819
|
+
*/
|
|
1820
|
+
importRelayMessage(relayUid: number, agentName: string): Promise<{
|
|
1821
|
+
success: boolean;
|
|
1822
|
+
error?: string;
|
|
1823
|
+
}>;
|
|
1824
|
+
/**
|
|
1825
|
+
* Start SMS pollers for all agents that have separate GV Gmail credentials.
|
|
1826
|
+
* Agents with sameAsRelay=true are handled in deliverInboundLocally.
|
|
1827
|
+
*/
|
|
1828
|
+
private startSmsPollers;
|
|
1829
|
+
/**
|
|
1830
|
+
* Start a long-poll loop for every agent whose Telegram channel is
|
|
1831
|
+
* configured + enabled + in poll mode. Webhook-mode agents skip — the
|
|
1832
|
+
* webhook route already calls back into the same agent-wake bridge.
|
|
1833
|
+
*
|
|
1834
|
+
* Each new inbound Telegram message (that isn't an operator-query
|
|
1835
|
+
* reply) is converted to a synthetic email and delivered into the
|
|
1836
|
+
* agent's INBOX via the existing local-SMTP path — the very same
|
|
1837
|
+
* delivery the relay uses for real email. This makes the existing
|
|
1838
|
+
* IMAP IDLE → claudecode dispatcher path light up exactly as it
|
|
1839
|
+
* would for a real inbound mail, so the agent gets a Claude turn
|
|
1840
|
+
* without any new dispatcher plumbing. The body of the synthetic
|
|
1841
|
+
* mail tells the agent the message came from Telegram and that it
|
|
1842
|
+
* MUST reply via the `telegram_send` MCP tool, not via email.
|
|
1843
|
+
*/
|
|
1844
|
+
private startTelegramPollers;
|
|
1845
|
+
/**
|
|
1846
|
+
* Start (or restart) the Telegram poller for one agent. Idempotent —
|
|
1847
|
+
* a prior poller is stopped first so re-running `/telegram/setup`
|
|
1848
|
+
* picks up the new token / allow-list cleanly.
|
|
1849
|
+
*
|
|
1850
|
+
* Public so the API layer can poke the gateway after a successful
|
|
1851
|
+
* `/telegram/setup` without waiting for the next server restart.
|
|
1852
|
+
*/
|
|
1853
|
+
startTelegramPollerForAgent(agentId: string, agentName?: string): Promise<void>;
|
|
1854
|
+
/** Stop a single agent's Telegram poller (called on disable). */
|
|
1855
|
+
stopTelegramPollerForAgent(agentId: string): Promise<void>;
|
|
1856
|
+
/**
|
|
1857
|
+
* Convert one new inbound Telegram message into a synthetic email
|
|
1858
|
+
* landing in the agent's INBOX, so the dispatcher wakes the agent.
|
|
1859
|
+
*
|
|
1860
|
+
* Two short-circuits before delivery:
|
|
1861
|
+
*
|
|
1862
|
+
* 1. If the message is from the configured operator's chat AND
|
|
1863
|
+
* looks like an operator-query reply (parsed by
|
|
1864
|
+
* `parseTelegramOperatorReply`), it's an answer to an in-flight
|
|
1865
|
+
* voice mission, not free-form chat — the HTTP webhook/poll
|
|
1866
|
+
* route already handles those by calling into the phone
|
|
1867
|
+
* manager, and the route does NOT need an agent turn. The poller
|
|
1868
|
+
* hands them off the same way: we just skip the wake here.
|
|
1869
|
+
*
|
|
1870
|
+
* 2. Plain `/start` (BotFather's default first DM) is a Telegram
|
|
1871
|
+
* housekeeping nudge — replying with an LLM turn for "/start"
|
|
1872
|
+
* would be embarrassing. Skip it.
|
|
1873
|
+
*
|
|
1874
|
+
* Everything else: synthesise the email and deliver.
|
|
1875
|
+
*/
|
|
1876
|
+
/**
|
|
1877
|
+
* Public wrapper around the bridge — the Telegram webhook route calls
|
|
1878
|
+
* this directly so push-mode and poll-mode share the wake path.
|
|
1879
|
+
*/
|
|
1880
|
+
bridgeTelegramInbound(agentId: string, parsed: ParsedTelegramMessage, config: TelegramConfig): Promise<void>;
|
|
1881
|
+
private bridgeInboundTelegram;
|
|
1882
|
+
shutdown(): Promise<void>;
|
|
1883
|
+
/**
|
|
1884
|
+
* Resume gateway from saved config (e.g., after server restart).
|
|
1885
|
+
*
|
|
1886
|
+
* Issue #31 — On a Docker container restart the API can come up
|
|
1887
|
+
* before Stalwart / Gmail IMAP / DNS is reachable, so the very first
|
|
1888
|
+
* setup() can fail with a transient network error. Previously that
|
|
1889
|
+
* single failure was logged and never retried, leaving polling
|
|
1890
|
+
* permanently dead until someone noticed and manually revived the
|
|
1891
|
+
* relay. We now schedule background retries with exponential backoff
|
|
1892
|
+
* (5s, 10s, 20s, 40s, 60s cap, indefinite) so the relay
|
|
1893
|
+
* self-recovers as soon as the dependency is reachable again.
|
|
1894
|
+
*/
|
|
1895
|
+
resume(): Promise<void>;
|
|
1896
|
+
private _resumeRetryTimer;
|
|
1897
|
+
private _resumeRetryAttempt;
|
|
1898
|
+
private _resumeRelayOnce;
|
|
1899
|
+
private _scheduleRelayResumeRetry;
|
|
1900
|
+
private loadConfig;
|
|
1901
|
+
private saveConfig;
|
|
1902
|
+
private saveLastSeenUid;
|
|
1903
|
+
private loadLastSeenUid;
|
|
1814
1904
|
}
|
|
1815
|
-
/** `setWebhook` — register the inbound webhook URL (webhook-mode transport). */
|
|
1816
|
-
declare function setTelegramWebhook(token: string, url: string, options?: SetWebhookOptions): Promise<boolean>;
|
|
1817
|
-
/** `deleteWebhook` — switch back to poll mode. */
|
|
1818
|
-
declare function deleteTelegramWebhook(token: string): Promise<boolean>;
|
|
1819
|
-
/** `getWebhookInfo` — current webhook status. */
|
|
1820
|
-
declare function getTelegramWebhookInfo(token: string): Promise<Record<string, unknown>>;
|
|
1821
1905
|
|
|
1906
|
+
interface RelayBridgeOptions {
|
|
1907
|
+
/** Port for the HTTP bridge server */
|
|
1908
|
+
port: number;
|
|
1909
|
+
/** Shared secret for authenticating requests */
|
|
1910
|
+
secret: string;
|
|
1911
|
+
/** Local Stalwart SMTP host (default: 127.0.0.1) */
|
|
1912
|
+
smtpHost?: string;
|
|
1913
|
+
/** Local Stalwart SMTP submission port (default: 587) */
|
|
1914
|
+
smtpPort?: number;
|
|
1915
|
+
/** Stalwart auth credentials for the sending agent */
|
|
1916
|
+
smtpUser: string;
|
|
1917
|
+
smtpPass: string;
|
|
1918
|
+
}
|
|
1822
1919
|
/**
|
|
1823
|
-
*
|
|
1920
|
+
* RelayBridge — A local HTTP-to-SMTP bridge that submits email to Stalwart.
|
|
1824
1921
|
*
|
|
1825
|
-
*
|
|
1826
|
-
*
|
|
1827
|
-
*
|
|
1828
|
-
*
|
|
1922
|
+
* Stalwart then handles DKIM signing, MX resolution, and direct delivery
|
|
1923
|
+
* to the recipient's mail server on port 25. FROM is preserved exactly.
|
|
1924
|
+
*
|
|
1925
|
+
* This bridge is exposed via Cloudflare Tunnel so Cloudflare Workers
|
|
1926
|
+
* (which can't connect to port 25) can trigger outbound email through it.
|
|
1927
|
+
*
|
|
1928
|
+
* For production, deploy on a VPS with proper PTR/FCrDNS for reliable
|
|
1929
|
+
* delivery to all providers (Gmail, Outlook, etc.).
|
|
1829
1930
|
*/
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
chatId: string;
|
|
1839
|
-
chatType: TelegramChatType;
|
|
1840
|
-
chatTitle?: string;
|
|
1841
|
-
/** Sender id as a string; falls back to the chat id for channel posts. */
|
|
1842
|
-
fromId: string;
|
|
1843
|
-
fromName: string;
|
|
1844
|
-
fromUsername?: string;
|
|
1845
|
-
/** Message text (or media caption). Always non-empty for a parsed result. */
|
|
1846
|
-
text: string;
|
|
1847
|
-
/** `message_id` of the message this one replies to, if any. */
|
|
1848
|
-
replyToMessageId?: number;
|
|
1849
|
-
/** Text of the replied-to message, when Telegram included it. */
|
|
1850
|
-
replyToText?: string;
|
|
1851
|
-
/** ISO-8601 timestamp derived from the Telegram `date` epoch. */
|
|
1852
|
-
date: string;
|
|
1931
|
+
declare class RelayBridge {
|
|
1932
|
+
private server;
|
|
1933
|
+
private options;
|
|
1934
|
+
constructor(options: RelayBridgeOptions);
|
|
1935
|
+
start(): Promise<void>;
|
|
1936
|
+
stop(): void;
|
|
1937
|
+
private handleRequest;
|
|
1938
|
+
private submitToStalwart;
|
|
1853
1939
|
}
|
|
1854
|
-
|
|
1855
|
-
* Normalize a raw Telegram update into a {@link ParsedTelegramMessage},
|
|
1856
|
-
* or `null` when it carries no usable text (callback queries, service
|
|
1857
|
-
* messages, edits with no text, non-text media without a caption).
|
|
1858
|
-
* Handles both `message` and `channel_post` envelopes.
|
|
1859
|
-
*/
|
|
1860
|
-
declare function parseTelegramUpdate(update: unknown): ParsedTelegramMessage | null;
|
|
1861
|
-
/**
|
|
1862
|
-
* Words that, sent alone (case-insensitive, optional trailing
|
|
1863
|
-
* punctuation), signal "abort whatever you are doing for me". Kept
|
|
1864
|
-
* deliberately narrow — anything longer or ambiguous is a normal
|
|
1865
|
-
* message. Ported verbatim from the Fola bridge.
|
|
1866
|
-
*/
|
|
1867
|
-
declare const TELEGRAM_STOP_WORDS: ReadonlySet<string>;
|
|
1868
|
-
/** True when `text` is a bare stop command. */
|
|
1869
|
-
declare function isTelegramStopCommand(text: string): boolean;
|
|
1870
|
-
/**
|
|
1871
|
-
* Compute the next `getUpdates` offset from a batch: one past the
|
|
1872
|
-
* highest `update_id` seen. Advancing the offset is what acknowledges
|
|
1873
|
-
* updates to Telegram, so this must run on the raw batch even if
|
|
1874
|
-
* individual updates fail to parse.
|
|
1875
|
-
*/
|
|
1876
|
-
declare function nextTelegramOffset(currentOffset: number, updates: Array<{
|
|
1877
|
-
update_id?: unknown;
|
|
1878
|
-
}>): number;
|
|
1940
|
+
declare function startRelayBridge(options: RelayBridgeOptions): RelayBridge;
|
|
1879
1941
|
|
|
1880
1942
|
/**
|
|
1881
|
-
*
|
|
1943
|
+
* SMS Manager - provider-backed SMS integration
|
|
1882
1944
|
*
|
|
1883
|
-
*
|
|
1884
|
-
*
|
|
1885
|
-
*
|
|
1886
|
-
*
|
|
1887
|
-
*
|
|
1888
|
-
* redacted on every read that leaves the process.
|
|
1889
|
-
* - Inbound/outbound messages are stored in a `telegram_messages` table.
|
|
1945
|
+
* How it works:
|
|
1946
|
+
* 1. User chooses a provider for the agent phone number
|
|
1947
|
+
* 2. Google Voice uses email forwarding/web instructions
|
|
1948
|
+
* 3. 46elks uses direct API sends and inbound webhooks
|
|
1949
|
+
* 4. All inbound/outbound messages are stored in the SMS table
|
|
1890
1950
|
*
|
|
1891
|
-
*
|
|
1892
|
-
* hardcoded — every bot token, chat id and operator identity is
|
|
1893
|
-
* per-agent config supplied by the user.
|
|
1951
|
+
* SMS config is stored in agent metadata under the "sms" key.
|
|
1894
1952
|
*/
|
|
1895
1953
|
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
interface TelegramConfig {
|
|
1899
|
-
/** Whether the Telegram channel is active for this agent. */
|
|
1954
|
+
interface SmsConfig {
|
|
1955
|
+
/** Whether SMS is enabled for this agent */
|
|
1900
1956
|
enabled: boolean;
|
|
1901
|
-
/**
|
|
1902
|
-
|
|
1903
|
-
/**
|
|
1904
|
-
|
|
1905
|
-
/**
|
|
1906
|
-
|
|
1907
|
-
/**
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
/**
|
|
1919
|
-
* Shared secret echoed by Telegram in the
|
|
1920
|
-
* `X-Telegram-Bot-Api-Secret-Token` header. SECRET — encrypted at
|
|
1921
|
-
* rest, redacted on read. Doubles as the webhook routing key.
|
|
1922
|
-
*/
|
|
1957
|
+
/** Phone number in E.164 format where possible */
|
|
1958
|
+
phoneNumber: string;
|
|
1959
|
+
/** The email address Google Voice forwards SMS to (the Gmail used for GV signup) */
|
|
1960
|
+
forwardingEmail?: string;
|
|
1961
|
+
/** App password for forwarding email (only needed if different from relay email) */
|
|
1962
|
+
forwardingPassword?: string;
|
|
1963
|
+
/** Whether the GV Gmail is the same as the relay email */
|
|
1964
|
+
sameAsRelay?: boolean;
|
|
1965
|
+
/** SMS provider */
|
|
1966
|
+
provider: 'google_voice' | '46elks';
|
|
1967
|
+
/** 46elks API username */
|
|
1968
|
+
username?: string;
|
|
1969
|
+
/** 46elks API password */
|
|
1970
|
+
password?: string;
|
|
1971
|
+
/** Provider API base URL override */
|
|
1972
|
+
apiUrl?: string;
|
|
1973
|
+
/** Secret required on inbound provider webhooks */
|
|
1923
1974
|
webhookSecret?: string;
|
|
1924
|
-
/**
|
|
1925
|
-
pollOffset?: number;
|
|
1926
|
-
/** When the channel was configured. */
|
|
1975
|
+
/** When SMS was configured */
|
|
1927
1976
|
configuredAt: string;
|
|
1928
1977
|
}
|
|
1929
|
-
interface
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1978
|
+
interface ParsedSms {
|
|
1979
|
+
from: string;
|
|
1980
|
+
body: string;
|
|
1981
|
+
timestamp: string;
|
|
1982
|
+
raw?: string;
|
|
1983
|
+
}
|
|
1984
|
+
interface SmsMessage {
|
|
1985
|
+
id: string;
|
|
1986
|
+
agentId: string;
|
|
1987
|
+
direction: 'inbound' | 'outbound';
|
|
1988
|
+
phoneNumber: string;
|
|
1989
|
+
body: string;
|
|
1990
|
+
status: 'pending' | 'sent' | 'delivered' | 'failed' | 'received';
|
|
1991
|
+
createdAt: string;
|
|
1992
|
+
metadata?: Record<string, unknown>;
|
|
1993
|
+
}
|
|
1994
|
+
interface SendSmsInput {
|
|
1995
|
+
to: string;
|
|
1996
|
+
body: string;
|
|
1997
|
+
dryRun?: boolean;
|
|
1998
|
+
}
|
|
1999
|
+
interface SendSmsResult {
|
|
2000
|
+
provider: SmsConfig['provider'];
|
|
2001
|
+
id?: string;
|
|
2002
|
+
status: string;
|
|
2003
|
+
from: string;
|
|
2004
|
+
to: string;
|
|
2005
|
+
body: string;
|
|
2006
|
+
raw?: unknown;
|
|
2007
|
+
}
|
|
2008
|
+
interface InboundSmsEvent {
|
|
2009
|
+
provider: SmsConfig['provider'];
|
|
2010
|
+
id?: string;
|
|
2011
|
+
from: string;
|
|
2012
|
+
to: string;
|
|
2013
|
+
body: string;
|
|
2014
|
+
timestamp: string;
|
|
2015
|
+
raw?: unknown;
|
|
1942
2016
|
}
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
2017
|
+
interface SmsProvider {
|
|
2018
|
+
id: SmsConfig['provider'];
|
|
2019
|
+
sendSms(config: SmsConfig, input: SendSmsInput): Promise<SendSmsResult>;
|
|
2020
|
+
parseInboundSms(payload: Record<string, unknown>): InboundSmsEvent | null;
|
|
2021
|
+
}
|
|
2022
|
+
declare function redactSmsConfig(config: SmsConfig): SmsConfig;
|
|
2023
|
+
declare function getSmsProvider(provider: SmsConfig['provider']): SmsProvider;
|
|
2024
|
+
declare function mapProviderSmsStatus(status: string): SmsMessage['status'];
|
|
2025
|
+
/** Normalize a phone number to E.164-ish format (+1XXXXXXXXXX) */
|
|
2026
|
+
declare function normalizePhoneNumber(raw: string): string | null;
|
|
2027
|
+
/** Validate a phone number (basic) */
|
|
2028
|
+
declare function isValidPhoneNumber(phone: string): boolean;
|
|
1946
2029
|
/**
|
|
1947
|
-
*
|
|
1948
|
-
*
|
|
1949
|
-
*
|
|
1950
|
-
*
|
|
2030
|
+
* Parse an SMS forwarded from Google Voice via email.
|
|
2031
|
+
* Google Voice forwards SMS with a specific format.
|
|
2032
|
+
*
|
|
2033
|
+
* Known sender addresses:
|
|
2034
|
+
* - voice-noreply@google.com
|
|
2035
|
+
* - *@txt.voice.google.com
|
|
2036
|
+
* - Google Voice <voice-noreply@google.com>
|
|
1951
2037
|
*/
|
|
1952
|
-
declare function
|
|
2038
|
+
declare function parseGoogleVoiceSms(emailBody: string, emailFrom: string): ParsedSms | null;
|
|
1953
2039
|
/**
|
|
1954
|
-
*
|
|
1955
|
-
*
|
|
1956
|
-
* chat. An empty allow-list means nobody — fail closed.
|
|
2040
|
+
* Extract verification codes from SMS body.
|
|
2041
|
+
* Supports common formats: 6-digit, 4-digit, alphanumeric codes.
|
|
1957
2042
|
*/
|
|
1958
|
-
declare function
|
|
1959
|
-
declare class
|
|
2043
|
+
declare function extractVerificationCode(smsBody: string): string | null;
|
|
2044
|
+
declare class SmsManager {
|
|
1960
2045
|
private db;
|
|
1961
2046
|
private encryptionKey?;
|
|
1962
2047
|
private initialized;
|
|
1963
2048
|
/**
|
|
1964
|
-
* Optional master key used to encrypt
|
|
1965
|
-
*
|
|
1966
|
-
* a deployment with no master key) configs
|
|
1967
|
-
* tolerate plaintext — upgrades and
|
|
2049
|
+
* Optional master key used to encrypt SMS credentials at rest (same
|
|
2050
|
+
* AES-256-GCM scheme GatewayManager uses for relay/domain secrets).
|
|
2051
|
+
* When absent (e.g. tests, or a deployment with no master key) configs
|
|
2052
|
+
* are stored as-is and reads tolerate plaintext — so upgrades and
|
|
2053
|
+
* downgrades both stay safe.
|
|
1968
2054
|
*/
|
|
1969
2055
|
constructor(db: Database, encryptionKey?: string | undefined);
|
|
1970
|
-
|
|
1971
|
-
/** Encrypt the credential fields of a config before persisting. */
|
|
2056
|
+
/** Encrypt the credential fields of an SMS config before persisting. */
|
|
1972
2057
|
private encryptConfig;
|
|
1973
|
-
/** Decrypt the credential fields of
|
|
2058
|
+
/** Decrypt the credential fields of an SMS config after loading. */
|
|
1974
2059
|
private decryptConfig;
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
saveConfig(agentId: string, config: TelegramConfig): void;
|
|
1981
|
-
/** Remove the Telegram config from agent metadata. */
|
|
1982
|
-
removeConfig(agentId: string): void;
|
|
1983
|
-
/** Persist a new poll offset without touching the rest of the config. */
|
|
1984
|
-
updatePollOffset(agentId: string, offset: number): void;
|
|
2060
|
+
private ensureTable;
|
|
2061
|
+
/** Get SMS config from agent metadata (credential fields decrypted). */
|
|
2062
|
+
getSmsConfig(agentId: string): SmsConfig | null;
|
|
2063
|
+
/** Save SMS config to agent metadata (credential fields encrypted). */
|
|
2064
|
+
saveSmsConfig(agentId: string, config: SmsConfig): void;
|
|
1985
2065
|
/**
|
|
1986
|
-
* Resolve the
|
|
1987
|
-
*
|
|
1988
|
-
*
|
|
1989
|
-
*
|
|
1990
|
-
*
|
|
1991
|
-
*
|
|
2066
|
+
* Resolve the operator's "where do I get pinged" address from an
|
|
2067
|
+
* agent's SMS config. Used by the dispatcher's bridge-escalation
|
|
2068
|
+
* path: when sub-agents mail a bridge with no fresh host session
|
|
2069
|
+
* available, we email the operator a digest at this address. Their
|
|
2070
|
+
* phone's Gmail push notification surfaces it within seconds —
|
|
2071
|
+
* effectively a free, programmatic alert channel.
|
|
2072
|
+
*
|
|
2073
|
+
* Returns the configured `forwardingEmail` (the same Gmail Google
|
|
2074
|
+
* Voice forwards inbound SMS to, which the operator already has
|
|
2075
|
+
* push notifications enabled for) when SMS is configured AND
|
|
2076
|
+
* enabled. Returns null otherwise — caller falls through to a
|
|
2077
|
+
* silent log + system event.
|
|
2078
|
+
*
|
|
2079
|
+
* Why we don't try real-SMS delivery yet: Google Voice's
|
|
2080
|
+
* `<number>@txt.voice.google.com` email-to-SMS gateway was
|
|
2081
|
+
* deprecated by Google years ago. A future `carrier` field on
|
|
2082
|
+
* SmsConfig (Verizon vtext.com / AT&T txt.att.net / etc) will let
|
|
2083
|
+
* the operator opt into actual SMS, but that's a follow-up — the
|
|
2084
|
+
* email path already gets the operator a phone notification.
|
|
1992
2085
|
*/
|
|
1993
|
-
|
|
2086
|
+
getAlertEmail(agentId: string): string | null;
|
|
2087
|
+
/** Remove SMS config from agent metadata */
|
|
2088
|
+
removeSmsConfig(agentId: string): void;
|
|
2089
|
+
/** Find the agent whose SMS config owns a phone number. */
|
|
2090
|
+
findAgentBySmsNumber(phoneNumber: string, provider?: SmsConfig['provider']): {
|
|
1994
2091
|
agentId: string;
|
|
1995
|
-
config:
|
|
2092
|
+
config: SmsConfig;
|
|
1996
2093
|
} | null;
|
|
1997
|
-
/**
|
|
1998
|
-
|
|
1999
|
-
/** Record an
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
text: string;
|
|
2005
|
-
createdAt?: string;
|
|
2006
|
-
}, metadata?: Record<string, unknown>): TelegramMessage;
|
|
2007
|
-
/** Record an outbound Telegram message attempt. */
|
|
2008
|
-
recordOutbound(agentId: string, input: {
|
|
2009
|
-
chatId: string;
|
|
2010
|
-
text: string;
|
|
2011
|
-
telegramMessageId?: number;
|
|
2012
|
-
status?: TelegramMessage['status'];
|
|
2013
|
-
}, metadata?: Record<string, unknown>): TelegramMessage;
|
|
2014
|
-
/** Update the status (+ optional metadata) of a stored message. */
|
|
2015
|
-
updateStatus(id: string, status: TelegramMessage['status'], metadata?: Record<string, unknown>): void;
|
|
2016
|
-
/** List stored Telegram messages for an agent, newest first. */
|
|
2094
|
+
/** Record an inbound SMS (parsed from email or provider webhook) */
|
|
2095
|
+
recordInbound(agentId: string, parsed: ParsedSms, metadata?: Record<string, unknown>): SmsMessage;
|
|
2096
|
+
/** Record an outbound SMS attempt */
|
|
2097
|
+
recordOutbound(agentId: string, phoneNumber: string, body: string, status?: 'pending' | 'sent' | 'failed', metadata?: Record<string, unknown>): SmsMessage;
|
|
2098
|
+
/** Update SMS status and optional provider metadata */
|
|
2099
|
+
updateStatus(id: string, status: SmsMessage['status'], metadata?: Record<string, unknown>): void;
|
|
2100
|
+
/** List SMS messages for an agent */
|
|
2017
2101
|
listMessages(agentId: string, opts?: {
|
|
2018
2102
|
direction?: 'inbound' | 'outbound';
|
|
2019
|
-
chatId?: string;
|
|
2020
2103
|
limit?: number;
|
|
2021
2104
|
offset?: number;
|
|
2022
|
-
}):
|
|
2105
|
+
}): SmsMessage[];
|
|
2106
|
+
/** Check for recent verification codes in inbound SMS */
|
|
2107
|
+
checkForVerificationCode(agentId: string, minutesBack?: number): {
|
|
2108
|
+
code: string;
|
|
2109
|
+
from: string;
|
|
2110
|
+
body: string;
|
|
2111
|
+
receivedAt: string;
|
|
2112
|
+
} | null;
|
|
2023
2113
|
}
|
|
2024
|
-
|
|
2025
2114
|
/**
|
|
2026
|
-
*
|
|
2027
|
-
*
|
|
2028
|
-
* The realtime voice agent's `ask_operator` tool records an *operator
|
|
2029
|
-
* query* on the phone mission; the API exposes it through the
|
|
2030
|
-
* operator-query endpoints. Email is the channel-agnostic default
|
|
2031
|
-
* notifier (plan §5). This module makes Telegram a first-class
|
|
2032
|
-
* notification + approval channel WITHOUT duplicating any of that
|
|
2033
|
-
* machinery — it only:
|
|
2115
|
+
* SmsPoller — Polls for Google Voice SMS forwarded emails.
|
|
2034
2116
|
*
|
|
2035
|
-
*
|
|
2036
|
-
*
|
|
2037
|
-
*
|
|
2038
|
-
*
|
|
2117
|
+
* Two modes:
|
|
2118
|
+
* 1. **Same email** (sameAsRelay=true): Hooks into the relay's onInboundMail callback.
|
|
2119
|
+
* The relay poll already fetches emails; SmsPoller filters for GV forwarded SMS.
|
|
2120
|
+
* 2. **Separate email** (sameAsRelay=false): Runs its own IMAP poll against the GV Gmail
|
|
2121
|
+
* using the separate credentials (forwardingEmail + forwardingPassword).
|
|
2039
2122
|
*
|
|
2040
|
-
*
|
|
2041
|
-
* `PhoneManager.answerOperatorQuery` the inbound email-reply hook uses.
|
|
2042
|
-
*/
|
|
2043
|
-
/**
|
|
2044
|
-
* Token embedded in a notification so the operator's reply can be
|
|
2045
|
-
* matched back to a query. Kept short — the operator may see it.
|
|
2046
|
-
*/
|
|
2047
|
-
declare const TELEGRAM_OPERATOR_QUERY_TAG = "AMQ";
|
|
2048
|
-
interface OperatorQueryNotificationInput {
|
|
2049
|
-
queryId: string;
|
|
2050
|
-
question: string;
|
|
2051
|
-
callContext?: string;
|
|
2052
|
-
urgency?: string;
|
|
2053
|
-
missionId?: string;
|
|
2054
|
-
}
|
|
2055
|
-
/**
|
|
2056
|
-
* Render the Telegram message body for an `ask_operator` notification.
|
|
2057
|
-
* The trailing `[AMQ <id>]` token lets a reply be matched to the query
|
|
2058
|
-
* even when the operator does not use Telegram's native reply gesture.
|
|
2123
|
+
* Parsed SMS messages are stored in the sms_messages table.
|
|
2059
2124
|
*/
|
|
2060
|
-
declare
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2125
|
+
declare class SmsPoller {
|
|
2126
|
+
private smsManager;
|
|
2127
|
+
private agentId;
|
|
2128
|
+
private config;
|
|
2129
|
+
private pollTimer;
|
|
2130
|
+
private polling;
|
|
2131
|
+
private lastSeenUid;
|
|
2132
|
+
private firstPollDone;
|
|
2133
|
+
private consecutiveFailures;
|
|
2134
|
+
private readonly POLL_INTERVAL_MS;
|
|
2135
|
+
private readonly MAX_BACKOFF_MS;
|
|
2136
|
+
private readonly CONNECT_TIMEOUT_MS;
|
|
2137
|
+
/** Callback for new inbound SMS */
|
|
2138
|
+
onSmsReceived: ((agentId: string, sms: ParsedSms) => void | Promise<void>) | null;
|
|
2139
|
+
constructor(smsManager: SmsManager, agentId: string, config: SmsConfig);
|
|
2140
|
+
/** Whether this poller needs its own IMAP connection (separate Gmail) */
|
|
2141
|
+
get needsSeparatePoll(): boolean;
|
|
2064
2142
|
/**
|
|
2065
|
-
*
|
|
2066
|
-
*
|
|
2067
|
-
*
|
|
2068
|
-
* reply is a bare message and the caller must resolve the target.
|
|
2143
|
+
* Process an email from the relay poll (same-email mode).
|
|
2144
|
+
* Called by the relay gateway's onInboundMail when it detects a GV email.
|
|
2145
|
+
* Returns true if the email was an SMS and was processed.
|
|
2069
2146
|
*/
|
|
2070
|
-
|
|
2071
|
-
/**
|
|
2072
|
-
|
|
2073
|
-
|
|
2147
|
+
processRelayEmail(from: string, subject: string, body: string): boolean;
|
|
2148
|
+
/**
|
|
2149
|
+
* Start polling the separate GV Gmail for SMS (separate-email mode).
|
|
2150
|
+
* Only call this if needsSeparatePoll is true.
|
|
2151
|
+
*/
|
|
2152
|
+
startPolling(): Promise<void>;
|
|
2153
|
+
stopPolling(): void;
|
|
2154
|
+
private scheduleNext;
|
|
2155
|
+
private pollOnce;
|
|
2074
2156
|
}
|
|
2075
|
-
/**
|
|
2076
|
-
* Parse an operator's Telegram message into a {@link ParsedOperatorReply},
|
|
2077
|
-
* or `null` when it carries no usable answer.
|
|
2078
|
-
*
|
|
2079
|
-
* Recognized forms (most explicit first):
|
|
2080
|
-
* - `/answer <queryId> <text>`
|
|
2081
|
-
* - `/approve [<queryId>] [note]` · `/deny [<queryId>] [note]`
|
|
2082
|
-
* - a plain message — `queryId` is taken from a quoted tagged
|
|
2083
|
-
* notification (`replyToText`) or an inline `oq_…` / `[AMQ …]` token,
|
|
2084
|
-
* and is otherwise left `undefined` for the caller to resolve.
|
|
2085
|
-
*
|
|
2086
|
-
* The `@botname` suffix Telegram appends to commands in groups
|
|
2087
|
-
* (`/approve@mybot`) is tolerated.
|
|
2088
|
-
*/
|
|
2089
|
-
declare function parseTelegramOperatorReply(input: {
|
|
2090
|
-
text: string;
|
|
2091
|
-
replyToText?: string;
|
|
2092
|
-
}): ParsedOperatorReply | null;
|
|
2093
2157
|
|
|
2094
2158
|
declare const ELKS_REALTIME_AUDIO_FORMATS: readonly ["ulaw", "pcm_16000", "pcm_24000", "wav"];
|
|
2095
2159
|
type ElksRealtimeAudioFormat = typeof ELKS_REALTIME_AUDIO_FORMATS[number];
|
|
@@ -3728,14 +3792,14 @@ declare function debugWarn(tag: string, message: string): void;
|
|
|
3728
3792
|
* # Examples
|
|
3729
3793
|
*
|
|
3730
3794
|
* ```ts
|
|
3731
|
-
* // Safe: resolves to /home/
|
|
3732
|
-
* safeJoin('/home/
|
|
3795
|
+
* // Safe: resolves to /home/alice/.codex/agents/agenticmail-cli.toml
|
|
3796
|
+
* safeJoin('/home/alice/.codex/agents', 'agenticmail-cli.toml');
|
|
3733
3797
|
*
|
|
3734
3798
|
* // Throws: '../etc/passwd' resolves outside the base dir
|
|
3735
|
-
* safeJoin('/home/
|
|
3799
|
+
* safeJoin('/home/alice/.codex/agents', '../etc/passwd');
|
|
3736
3800
|
*
|
|
3737
3801
|
* // Throws: an absolute path bypasses the base dir
|
|
3738
|
-
* safeJoin('/home/
|
|
3802
|
+
* safeJoin('/home/alice/.codex/agents', '/etc/passwd');
|
|
3739
3803
|
* ```
|
|
3740
3804
|
*/
|
|
3741
3805
|
|