@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.d.cts CHANGED
@@ -1274,822 +1274,886 @@ declare class TunnelManager {
1274
1274
  }>;
1275
1275
  }
1276
1276
 
1277
- interface LocalSmtpConfig {
1278
- host: string;
1279
- port: number;
1280
- user: string;
1281
- pass: string;
1282
- }
1283
- interface GatewayManagerOptions {
1284
- db: Database;
1285
- stalwart: StalwartAdmin;
1286
- accountManager?: AccountManager;
1287
- localSmtp?: LocalSmtpConfig;
1288
- onInboundMail?: (agentName: string, mail: InboundEmail) => void | Promise<void>;
1289
- /** Master key used to encrypt credentials at rest in SQLite. */
1290
- encryptionKey?: string;
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
- * GatewayManager orchestrates relay and domain modes for sending/receiving
1294
- * real internet email. It coordinates between the relay gateway, Cloudflare
1295
- * services (DNS, tunnels, registrar), and the local Stalwart instance.
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 class GatewayManager {
1298
- private options;
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
- * Import an email from the connected relay account into an agent's local inbox.
1416
- * Fetches the full message from relay IMAP and delivers it locally, preserving
1417
- * all headers (Message-ID, In-Reply-To, References) for thread continuity.
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
- importRelayMessage(relayUid: number, agentName: string): Promise<{
1420
- success: boolean;
1421
- error?: string;
1422
- }>;
1325
+ longPoll?: boolean;
1423
1326
  /**
1424
- * Start SMS pollers for all agents that have separate GV Gmail credentials.
1425
- * Agents with sameAsRelay=true are handled in deliverInboundLocally.
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
- private startSmsPollers;
1428
- shutdown(): Promise<void>;
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
- * Resume gateway from saved config (e.g., after server restart).
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
- resume(): Promise<void>;
1442
- private _resumeRetryTimer;
1443
- private _resumeRetryAttempt;
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
- * RelayBridgeA local HTTP-to-SMTP bridge that submits email to Stalwart.
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
- * For production, deploy on a VPS with proper PTR/FCrDNS for reliable
1475
- * delivery to all providers (Gmail, Outlook, etc.).
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
- declare class RelayBridge {
1478
- private server;
1479
- private options;
1480
- constructor(options: RelayBridgeOptions);
1481
- start(): Promise<void>;
1482
- stop(): void;
1483
- private handleRequest;
1484
- private submitToStalwart;
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
- declare function startRelayBridge(options: RelayBridgeOptions): RelayBridge;
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
- * SMS Manager - provider-backed SMS integration
1463
+ * Telegram Manager per-agent Telegram bot configuration + message log.
1490
1464
  *
1491
- * How it works:
1492
- * 1. User chooses a provider for the agent phone number
1493
- * 2. Google Voice uses email forwarding/web instructions
1494
- * 3. 46elks uses direct API sends and inbound webhooks
1495
- * 4. All inbound/outbound messages are stored in the SMS table
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
- * SMS config is stored in agent metadata under the "sms" key.
1498
- */
1499
-
1500
- interface SmsConfig {
1501
- /** Whether SMS is enabled for this agent */
1502
- enabled: boolean;
1503
- /** Phone number in E.164 format where possible */
1504
- phoneNumber: string;
1505
- /** The email address Google Voice forwards SMS to (the Gmail used for GV signup) */
1506
- forwardingEmail?: string;
1507
- /** App password for forwarding email (only needed if different from relay email) */
1508
- forwardingPassword?: string;
1509
- /** Whether the GV Gmail is the same as the relay email */
1510
- sameAsRelay?: boolean;
1511
- /** SMS provider */
1512
- provider: 'google_voice' | '46elks';
1513
- /** 46elks API username */
1514
- username?: string;
1515
- /** 46elks API password */
1516
- password?: string;
1517
- /** Provider API base URL override */
1518
- apiUrl?: string;
1519
- /** Secret required on inbound provider webhooks */
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
- /** When SMS was configured */
1506
+ /** Persisted `getUpdates` offset (poll mode). */
1507
+ pollOffset?: number;
1508
+ /** When the channel was configured. */
1522
1509
  configuredAt: string;
1523
1510
  }
1524
- interface ParsedSms {
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
- phoneNumber: string;
1535
- body: string;
1536
- status: 'pending' | 'sent' | 'delivered' | 'failed' | 'received';
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
- interface SendSmsInput {
1541
- to: string;
1542
- body: string;
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
- * Parse an SMS forwarded from Google Voice via email.
1577
- * Google Voice forwards SMS with a specific format.
1578
- *
1579
- * Known sender addresses:
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 parseGoogleVoiceSms(emailBody: string, emailFrom: string): ParsedSms | null;
1534
+ declare function redactTelegramConfig(config: TelegramConfig): TelegramConfig;
1585
1535
  /**
1586
- * Extract verification codes from SMS body.
1587
- * Supports common formats: 6-digit, 4-digit, alphanumeric codes.
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 extractVerificationCode(smsBody: string): string | null;
1590
- declare class SmsManager {
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 SMS credentials at rest (same
1596
- * AES-256-GCM scheme GatewayManager uses for relay/domain secrets).
1597
- * When absent (e.g. tests, or a deployment with no master key) configs
1598
- * are stored as-is and reads tolerate plaintext — so upgrades and
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
- /** Encrypt the credential fields of an SMS config before persisting. */
1552
+ private ensureTable;
1553
+ /** Encrypt the credential fields of a config before persisting. */
1603
1554
  private encryptConfig;
1604
- /** Decrypt the credential fields of an SMS config after loading. */
1555
+ /** Decrypt the credential fields of a config after loading. */
1605
1556
  private decryptConfig;
1606
- private ensureTable;
1607
- /** Get SMS config from agent metadata (credential fields decrypted). */
1608
- getSmsConfig(agentId: string): SmsConfig | null;
1609
- /** Save SMS config to agent metadata (credential fields encrypted). */
1610
- saveSmsConfig(agentId: string, config: SmsConfig): void;
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 operator's "where do I get pinged" address from an
1613
- * agent's SMS config. Used by the dispatcher's bridge-escalation
1614
- * path: when sub-agents mail a bridge with no fresh host session
1615
- * available, we email the operator a digest at this address. Their
1616
- * phone's Gmail push notification surfaces it within seconds
1617
- * effectively a free, programmatic alert channel.
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
- getAlertEmail(agentId: string): string | null;
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: SmsConfig;
1577
+ config: TelegramConfig;
1639
1578
  } | null;
1640
- /** Record an inbound SMS (parsed from email or provider webhook) */
1641
- recordInbound(agentId: string, parsed: ParsedSms, metadata?: Record<string, unknown>): SmsMessage;
1642
- /** Record an outbound SMS attempt */
1643
- recordOutbound(agentId: string, phoneNumber: string, body: string, status?: 'pending' | 'sent' | 'failed', metadata?: Record<string, unknown>): SmsMessage;
1644
- /** Update SMS status and optional provider metadata */
1645
- updateStatus(id: string, status: SmsMessage['status'], metadata?: Record<string, unknown>): void;
1646
- /** List SMS messages for an agent */
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
- }): SmsMessage[];
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 Bot API client dependency-free (global `fetch`, Node 22+).
1608
+ * Telegram `ask_operator` bridge (plan §13.4 / §13.5) pure helpers.
1706
1609
  *
1707
- * Ported and MERGED from two already-tuned internal sources (licensing
1708
- * confirmed by Ope, 2026-05-19 plan §13.5):
1709
- * - enterprise `agent-tools/tools/messaging/telegram.ts`
1710
- * (`tgApi`, `stripMarkdown`, webhook management)
1711
- * - agent-harness `fola-lib/telegram-api.mjs`
1712
- * (auto-splitting `sendMessage`, the long-poll timeout discipline)
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
- * Host-specific pieces are intentionally dropped to fit the single-tenant
1715
- * open-source product: the local Bot API server auto-detection
1716
- * (`http://localhost:8081`) and the filesystem media-download paths are
1717
- * Fola-host infrastructure, not channel logic.
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
- * Secrets: the bot token rides in the request URL path. Every error this
1720
- * module surfaces is scrubbed with {@link redactBotToken} so a token can
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
- /** Official Telegram Bot API base. */
1725
- declare const TELEGRAM_API_BASE = "https://api.telegram.org";
1726
- /** Telegram's hard per-message ceiling is 4096 characters. */
1727
- declare const TELEGRAM_MESSAGE_LIMIT = 4096;
1728
- /** We split below the hard limit, leaving headroom for safety. */
1729
- declare const TELEGRAM_CHUNK_SIZE = 4000;
1730
- /** A failed Telegram Bot API call. `description` is the provider message. */
1731
- declare class TelegramApiError extends Error {
1732
- readonly isTelegramApiError = true;
1733
- readonly description: string;
1734
- readonly errorCode?: number;
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
- * Scrub bot tokens from any string before it can be logged or returned.
1739
- *
1740
- * A token looks like `<digits>:<35 url-safe chars>`. We redact both an
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 redactBotToken(text: string, token?: string): string;
1746
- interface TelegramApiOptions {
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
- * Long-poll requests (`getUpdates` with a non-zero `timeout`) need an
1749
- * HTTP timeout longer than the server-side poll window, or the socket
1750
- * is torn down before Telegram replies.
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
- longPoll?: boolean;
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
- * POST a Telegram Bot API method with a JSON body and return `result`.
1756
- * Throws {@link TelegramApiError} (token-scrubbed) on any failure.
1757
- */
1758
- declare function callTelegramApi<T = unknown>(token: string, method: string, body?: Record<string, unknown>, options?: TelegramApiOptions): Promise<T>;
1759
- /**
1760
- * Strip Markdown so agent replies arrive as clean plain text. Telegram's
1761
- * Markdown parse modes are bypassed entirely (no `parse_mode`) to avoid
1762
- * formatting collisions on arbitrary agent output.
1763
- */
1764
- declare function stripTelegramMarkdown(text: string): string;
1765
- /**
1766
- * Split text into <= `maxLen` chunks, cutting on a newline boundary when
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 splitTelegramMessage(text: string, maxLen?: number): string[];
1770
- interface SendTelegramMessageOptions {
1771
- /** Reply to a specific message id in the chat. */
1772
- replyToMessageId?: number;
1773
- /** Deliver silently (no push notification). */
1774
- disableNotification?: boolean;
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 SendTelegramMessageResult {
1777
- /** message_id of each chunk Telegram accepted, in order. */
1778
- messageIds: number[];
1779
- /** Number of chunks the text was split into. */
1780
- chunks: number;
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
- * Send a text message, auto-splitting anything over the per-message
1784
- * ceiling. The reply target (if any) is attached only to the first
1785
- * chunk so a long reply still threads correctly.
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 function sendTelegramMessage(token: string, chatId: string | number, text: string, options?: SendTelegramMessageOptions): Promise<SendTelegramMessageResult>;
1788
- interface TelegramBotInfo {
1789
- id: number;
1790
- is_bot: boolean;
1791
- username?: string;
1792
- first_name?: string;
1793
- }
1794
- /** `getMe` — used to validate a token and capture the bot identity. */
1795
- declare function getTelegramMe(token: string): Promise<TelegramBotInfo>;
1796
- /** `getChat` — chat metadata (title, type, member count, ...). */
1797
- declare function getTelegramChat(token: string, chatId: string | number): Promise<Record<string, unknown>>;
1798
- interface GetUpdatesOptions {
1799
- /** Max updates per call (1-100). */
1800
- limit?: number;
1801
- /** Long-poll window in seconds (0 = short poll). */
1802
- timeoutSec?: number;
1803
- }
1804
- /** `getUpdates` long-poll — the poll-mode transport. */
1805
- declare function getTelegramUpdates(token: string, offset: number, options?: GetUpdatesOptions): Promise<Array<Record<string, unknown>>>;
1806
- interface SetWebhookOptions {
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
- * Shared secret echoed by Telegram in the
1809
- * `X-Telegram-Bot-Api-Secret-Token` header on every webhook delivery.
1803
+ * Search the connected relay account (Gmail/Outlook) for emails matching criteria.
1804
+ * Returns empty array if relay is not configured.
1810
1805
  */
1811
- secretToken?: string;
1812
- /** Drop updates that queued up while no webhook was set. */
1813
- dropPendingUpdates?: boolean;
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
- * Telegram update parsing pure, dependency-free.
1920
+ * RelayBridge A local HTTP-to-SMTP bridge that submits email to Stalwart.
1824
1921
  *
1825
- * Ported from the inbound-message handling in the agent-harness Fola
1826
- * bridge (`fola-telegram-bridge.mjs` the `formatPrompt` header fields
1827
- * and the `STOP_WORDS` abort set), stripped of every Fola-host specific
1828
- * (session routing, the fola-claude prompt envelope, media downloads).
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
- type TelegramChatType = 'private' | 'group' | 'supergroup' | 'channel' | 'unknown';
1831
- /** A normalized inbound Telegram text message. */
1832
- interface ParsedTelegramMessage {
1833
- /** Telegram `update_id` — drives the poll offset. */
1834
- updateId: number;
1835
- /** `message_id` within the chat. */
1836
- messageId: number;
1837
- /** Chat id as a string (Telegram ids are 64-bit; strings avoid loss). */
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
- * Telegram Manager per-agent Telegram bot configuration + message log.
1943
+ * SMS Manager - provider-backed SMS integration
1882
1944
  *
1883
- * Models the existing {@link import('../sms/manager.js').SmsManager} — the
1884
- * closest existing channel so the hardening bar matches:
1885
- * - Config lives in agent metadata under the `telegram` key.
1886
- * - Credential fields (`botToken`, `webhookSecret`) are encrypted at
1887
- * rest with the same AES-256-GCM scheme (`encryptSecret`) and
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
- * Single-tenant: there is no org / multi-tenant scoping and nothing is
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
- /** Transport mode: long-poll `getUpdates`, or a registered webhook. */
1897
- type TelegramMode = 'poll' | 'webhook';
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
- /** Bot API token from @BotFather. SECRET encrypted at rest, redacted on read. */
1902
- botToken: string;
1903
- /** Bot @username (non-secret, for display). Captured from `getMe` at setup. */
1904
- botUsername?: string;
1905
- /** Numeric bot id (non-secret). Captured from `getMe` at setup. */
1906
- botId?: number;
1907
- /**
1908
- * Chat ids permitted to message the agent. EMPTY = nobody (fail-closed,
1909
- * same posture as the Fola bridge). The operator chat is always allowed.
1910
- */
1911
- allowedChatIds: string[];
1912
- /** Chat that receives `ask_operator` notifications + can approve (plan §13.4). */
1913
- operatorChatId?: string;
1914
- /** Inbound transport. */
1915
- mode: TelegramMode;
1916
- /** Public webhook URL (webhook mode only). */
1917
- webhookUrl?: string;
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
- /** Persisted `getUpdates` offset (poll mode). */
1925
- pollOffset?: number;
1926
- /** When the channel was configured. */
1975
+ /** When SMS was configured */
1927
1976
  configuredAt: string;
1928
1977
  }
1929
- interface TelegramMessage {
1930
- id: string;
1931
- agentId: string;
1932
- direction: 'inbound' | 'outbound';
1933
- chatId: string;
1934
- /** Telegram `message_id` (may be absent for a failed outbound attempt). */
1935
- telegramMessageId?: number;
1936
- /** Sender id (inbound only). */
1937
- fromId?: string;
1938
- text: string;
1939
- status: 'received' | 'sent' | 'failed' | 'pending';
1940
- createdAt: string;
1941
- metadata?: Record<string, unknown>;
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
- /** Telegram's `secret_token` charset, and our minimum entropy floor. */
1944
- declare const TELEGRAM_WEBHOOK_SECRET_RE: RegExp;
1945
- declare const TELEGRAM_MIN_WEBHOOK_SECRET_LENGTH = 16;
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
- * Redact the credential fields of a Telegram config for any value that
1948
- * leaves the process (API responses, logs). The bot token is collapsed
1949
- * to `***` entirely — never partially shown — matching the SMS bar and
1950
- * the plan §13.5 rule "no token ever in a log line or API response".
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 redactTelegramConfig(config: TelegramConfig): TelegramConfig;
2038
+ declare function parseGoogleVoiceSms(emailBody: string, emailFrom: string): ParsedSms | null;
1953
2039
  /**
1954
- * Allow-list gate for INBOUND messages. A chat may talk to the agent
1955
- * only if it is on `allowedChatIds` OR it is the configured operator
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 isTelegramChatAllowed(config: TelegramConfig, chatId: string): boolean;
1959
- declare class TelegramManager {
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 Telegram credentials at rest
1965
- * (the same AES-256-GCM scheme SMS/phone use). When absent (tests, or
1966
- * a deployment with no master key) configs are stored as-is and reads
1967
- * tolerate plaintext — upgrades and downgrades both stay safe.
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
- private ensureTable;
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 a config after loading. */
2058
+ /** Decrypt the credential fields of an SMS config after loading. */
1974
2059
  private decryptConfig;
1975
- /** Normalize a stored/loaded config object, defaulting missing fields. */
1976
- private normalizeConfig;
1977
- /** Get the Telegram config from agent metadata (credentials decrypted). */
1978
- getConfig(agentId: string): TelegramConfig | null;
1979
- /** Save the Telegram config to agent metadata (credentials encrypted). */
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 agent that owns a webhook secret. Used to authenticate +
1987
- * route an inbound Telegram webhook delivery: a webhook carries no bot
1988
- * identity, so the `X-Telegram-Bot-Api-Secret-Token` header is the
1989
- * routing key. The comparison is constant-time, and a non-match
1990
- * returns `null` so the route can answer with a single uniform 403
1991
- * (no enumeration oracle same posture as the SMS webhook).
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
- findAgentByWebhookSecret(secret: string): {
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: TelegramConfig;
2092
+ config: SmsConfig;
1996
2093
  } | null;
1997
- /** True if an inbound message with this Telegram id is already stored. */
1998
- inboundMessageExists(agentId: string, chatId: string, telegramMessageId: number): boolean;
1999
- /** Record an inbound Telegram message. */
2000
- recordInbound(agentId: string, input: {
2001
- chatId: string;
2002
- telegramMessageId: number;
2003
- fromId?: string;
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
- }): TelegramMessage[];
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
- * Telegram `ask_operator` bridge (plan §13.4 / §13.5) — pure helpers.
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
- * 1. {@link formatOperatorQueryTelegramMessage} — renders the
2036
- * notification text the operator receives.
2037
- * 2. {@link parseTelegramOperatorReply} parses the operator's
2038
- * Telegram reply back into a `{ queryId, answer }`.
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
- * The caller feeds the parsed result straight into the SAME
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 function formatOperatorQueryTelegramMessage(input: OperatorQueryNotificationInput): string;
2061
- /** A `kind` of `approve`/`deny` is a decision; `answer` is free-form. */
2062
- type OperatorReplyKind = 'answer' | 'approve' | 'deny';
2063
- interface ParsedOperatorReply {
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
- * Query id when the reply names one — explicitly (`/answer <id>`,
2066
- * an inline `oq_…` token) or implicitly (a native Telegram reply
2067
- * quoting a `[AMQ <id>]`-tagged notification). `undefined` when the
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
- queryId?: string;
2071
- /** The answer text to record against the query. Always non-empty. */
2072
- answer: string;
2073
- kind: OperatorReplyKind;
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/ope/.codex/agents/agenticmail-fola.toml
3732
- * safeJoin('/home/ope/.codex/agents', 'agenticmail-fola.toml');
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/ope/.codex/agents', '../etc/passwd');
3799
+ * safeJoin('/home/alice/.codex/agents', '../etc/passwd');
3736
3800
  *
3737
3801
  * // Throws: an absolute path bypasses the base dir
3738
- * safeJoin('/home/ope/.codex/agents', '/etc/passwd');
3802
+ * safeJoin('/home/alice/.codex/agents', '/etc/passwd');
3739
3803
  * ```
3740
3804
  */
3741
3805