From 5d0ef3e8c9a550a17184a51db9144facefc281e6 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 21 Feb 2026 15:19:00 -0500 Subject: [PATCH 01/12] fix: remove assistantId dependency from Telegram attachment delivery (#6210) Co-authored-by: Claude --- .../__tests__/telegram-deliver-auth.test.ts | 106 ++++++++++++++++++ .../telegram-send-attachments.test.ts | 39 +++++++ gateway/src/http/routes/telegram-deliver.ts | 2 +- gateway/src/runtime/client.ts | 24 ++++ gateway/src/schema.ts | 2 +- gateway/src/telegram/send.ts | 10 +- 6 files changed, 178 insertions(+), 5 deletions(-) diff --git a/gateway/src/__tests__/telegram-deliver-auth.test.ts b/gateway/src/__tests__/telegram-deliver-auth.test.ts index 28c9d386a74..c67e310fe50 100644 --- a/gateway/src/__tests__/telegram-deliver-auth.test.ts +++ b/gateway/src/__tests__/telegram-deliver-auth.test.ts @@ -51,6 +51,112 @@ function mockTelegramApi() { }) as any; } +describe("/deliver/telegram attachment delivery without assistantId", () => { + test("delivers attachments without assistantId using assistant-less download path", async () => { + const calls: string[] = []; + globalThis.fetch = mock(async (url: string | URL | Request) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; + calls.push(urlStr); + // Runtime attachment download (assistant-less path) + if (urlStr.includes("/v1/attachments/att-1")) { + return new Response( + JSON.stringify({ + id: "att-1", + filename: "photo.png", + mimeType: "image/png", + sizeBytes: 100, + kind: "generated_image", + data: "iVBORw0KGgo=", + }), + ); + } + // Telegram API calls + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as any; + + const handler = createTelegramDeliverHandler( + makeConfig({ runtimeProxyBearerToken: undefined }), + ); + const req = new Request("http://localhost:7830/deliver/telegram", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + chatId: "123", + attachments: [ + { id: "att-1", filename: "photo.png", mimeType: "image/png", sizeBytes: 100, kind: "generated_image" }, + ], + }), + }); + const res = await handler(req); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + + // Should have downloaded via /v1/attachments/att-1 (no assistantId in URL) + const downloadCall = calls.find((u) => u.includes("/attachments/att-1")); + expect(downloadCall).toBeDefined(); + expect(downloadCall).not.toContain("/assistants/"); + + // Should have sent the photo via Telegram + const telegramCall = calls.find((u) => u.includes("sendPhoto")); + expect(telegramCall).toBeDefined(); + }); + + test("delivers attachments with assistantId using legacy download path", async () => { + const calls: string[] = []; + globalThis.fetch = mock(async (url: string | URL | Request) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; + calls.push(urlStr); + // Runtime attachment download (legacy path) + if (urlStr.includes("/attachments/att-2")) { + return new Response( + JSON.stringify({ + id: "att-2", + filename: "doc.pdf", + mimeType: "application/pdf", + sizeBytes: 200, + kind: "filesystem", + data: "JVBER", + }), + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as any; + + const handler = createTelegramDeliverHandler( + makeConfig({ runtimeProxyBearerToken: undefined }), + ); + const req = new Request("http://localhost:7830/deliver/telegram", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + chatId: "456", + assistantId: "my-assistant", + attachments: [ + { id: "att-2", filename: "doc.pdf", mimeType: "application/pdf", sizeBytes: 200, kind: "filesystem" }, + ], + }), + }); + const res = await handler(req); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + + // Should have downloaded via legacy /v1/assistants/my-assistant/attachments/att-2 + const downloadCall = calls.find((u) => u.includes("/attachments/att-2")); + expect(downloadCall).toBeDefined(); + expect(downloadCall).toContain("/assistants/my-assistant/"); + }); +}); + describe("/deliver/telegram bearer auth enforcement", () => { test("rejects request without Authorization header with 401", async () => { const handler = createTelegramDeliverHandler(makeConfig()); diff --git a/gateway/src/__tests__/telegram-send-attachments.test.ts b/gateway/src/__tests__/telegram-send-attachments.test.ts index 09f8c641f72..9162ce5eb2e 100644 --- a/gateway/src/__tests__/telegram-send-attachments.test.ts +++ b/gateway/src/__tests__/telegram-send-attachments.test.ts @@ -148,6 +148,45 @@ describe("sendTelegramAttachments", () => { expect(calls[0]).toContain("sendMessage"); }); + test("downloads via assistant-less path when assistantId is undefined", async () => { + const calls: string[] = []; + + mockFetch(async (url: string) => { + calls.push(url); + if (url.includes("/attachments/att-no-assist")) { + return new Response( + JSON.stringify({ + id: "att-no-assist", + filename: "image.jpg", + mimeType: "image/jpeg", + sizeBytes: 80, + kind: "generated_image", + data: "/9j/4AAQ", + }), + ); + } + return new Response(JSON.stringify(telegramOk)); + }); + + const config = makeConfig(); + const meta: RuntimeAttachmentMeta = { + id: "att-no-assist", + filename: "image.jpg", + mimeType: "image/jpeg", + sizeBytes: 80, + kind: "generated_image", + }; + + await sendTelegramAttachments(config, "chat-1", undefined, [meta]); + + expect(calls).toHaveLength(2); + // Should use /v1/attachments/ path (no assistantId in URL) + const downloadUrl = calls[0]; + expect(downloadUrl).toContain("/v1/attachments/att-no-assist"); + expect(downloadUrl).not.toContain("/assistants/"); + expect(calls[1]).toContain("sendPhoto"); + }); + test("continues sending remaining attachments on individual failure", async () => { const calls: string[] = []; diff --git a/gateway/src/http/routes/telegram-deliver.ts b/gateway/src/http/routes/telegram-deliver.ts index c872fc3b05c..6ac0d7cb05f 100644 --- a/gateway/src/http/routes/telegram-deliver.ts +++ b/gateway/src/http/routes/telegram-deliver.ts @@ -51,7 +51,7 @@ export function createTelegramDeliverHandler(config: GatewayConfig) { await sendTelegramReply(config, chatId, text); } - if (attachments && attachments.length > 0 && assistantId) { + if (attachments && attachments.length > 0) { await sendTelegramAttachments(config, chatId, assistantId, attachments); } } catch (err) { diff --git a/gateway/src/runtime/client.ts b/gateway/src/runtime/client.ts index dd99e98c12e..7945e1bc6e8 100644 --- a/gateway/src/runtime/client.ts +++ b/gateway/src/runtime/client.ts @@ -182,6 +182,30 @@ export async function downloadAttachment( return (await response.json()) as RuntimeAttachmentPayload; } +/** + * Download an attachment without requiring an assistantId. + * Uses the assistant-less /v1/attachments/:attachmentId endpoint. + */ +export async function downloadAttachmentById( + config: GatewayConfig, + attachmentId: string, +): Promise { + const url = `${config.assistantRuntimeBaseUrl}/v1/attachments/${encodeURIComponent(attachmentId)}`; + + const response = await fetch(url, { + method: "GET", + headers: runtimeHeaders(config), + signal: AbortSignal.timeout(config.runtimeTimeoutMs), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Attachment download failed (${response.status}): ${body}`); + } + + return (await response.json()) as RuntimeAttachmentPayload; +} + // ── Twilio webhook forwarding ──────────────────────────────────────── export type TwilioForwardResponse = { diff --git a/gateway/src/schema.ts b/gateway/src/schema.ts index 7be6e938311..d071c7a5143 100644 --- a/gateway/src/schema.ts +++ b/gateway/src/schema.ts @@ -689,7 +689,7 @@ export function buildSchema(): Record { properties: { chatId: { type: "string", description: "Telegram chat ID to deliver the message to" }, text: { type: "string", description: "Text content to send", minLength: 1 }, - assistantId: { type: "string", description: "Assistant ID (required when sending attachments)" }, + assistantId: { type: "string", description: "Assistant ID (optional — attachments are downloaded via the assistant-less endpoint when omitted)" }, attachments: { type: "array", description: "Attachments to deliver (images sent via sendPhoto, others via sendDocument)", diff --git a/gateway/src/telegram/send.ts b/gateway/src/telegram/send.ts index 68ba2708bcc..5d057e0aefa 100644 --- a/gateway/src/telegram/send.ts +++ b/gateway/src/telegram/send.ts @@ -1,6 +1,6 @@ import type { GatewayConfig } from "../config.js"; import { getLogger } from "../logger.js"; -import { downloadAttachment, type RuntimeAttachmentMeta } from "../runtime/client.js"; +import { downloadAttachment, downloadAttachmentById, type RuntimeAttachmentMeta } from "../runtime/client.js"; import { callTelegramApi, callTelegramApiMultipart } from "./api.js"; const log = getLogger("telegram-send"); @@ -48,7 +48,7 @@ export async function sendTelegramReply( export async function sendTelegramAttachments( config: GatewayConfig, chatId: string, - assistantId: string, + assistantId: string | undefined, attachments: RuntimeAttachmentMeta[], ): Promise { const failures: string[] = []; @@ -61,7 +61,11 @@ export async function sendTelegramAttachments( } try { - const payload = await downloadAttachment(config, assistantId, meta.id); + // Prefer the assistant-less download path; fall back to the legacy + // assistant-scoped path when assistantId is available. + const payload = assistantId + ? await downloadAttachment(config, assistantId, meta.id) + : await downloadAttachmentById(config, meta.id); const buffer = Buffer.from(payload.data, "base64"); const blob = new Blob([buffer], { type: meta.mimeType }); From fb2ea998bbd95c678789968d863f96f6946c1105 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 21 Feb 2026 15:19:02 -0500 Subject: [PATCH 02/12] feat: add Telegram webhook lifecycle reconciliation (#6211) Co-authored-by: Claude --- .../vellum-skills/telegram-setup/SKILL.md | 25 +-- .../telegram-webhook-manager.test.ts | 191 ++++++++++++++++++ gateway/src/index.ts | 7 + gateway/src/telegram/webhook-manager.ts | 75 +++++++ 4 files changed, 279 insertions(+), 19 deletions(-) create mode 100644 gateway/src/__tests__/telegram-webhook-manager.test.ts create mode 100644 gateway/src/telegram/webhook-manager.ts diff --git a/assistant/src/config/vellum-skills/telegram-setup/SKILL.md b/assistant/src/config/vellum-skills/telegram-setup/SKILL.md index 4dc81a4c1d9..68d5fd51e24 100644 --- a/assistant/src/config/vellum-skills/telegram-setup/SKILL.md +++ b/assistant/src/config/vellum-skills/telegram-setup/SKILL.md @@ -43,26 +43,13 @@ export default () => ({ secret: randomUUID() }); Save this value for the next steps. -### Step 3: Register the Webhook +### Step 3: Webhook Registration (Automatic) -Use `evaluate_typescript_code` to register the webhook with Telegram: +Manual webhook registration is no longer required. The gateway automatically reconciles the Telegram webhook on startup and whenever credentials change. It compares the current webhook URL against `${INGRESS_PUBLIC_BASE_URL}/webhooks/telegram` and updates it if needed, including the webhook secret and allowed updates. -```typescript -export default async (input: { token: string; url: string; secret: string }) => { - const res = await fetch(`https://api.telegram.org/bot${input.token}/setWebhook`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - url: input.url, - secret_token: input.secret, - allowed_updates: ['message', 'edited_message'], - }), - }); - return res.json(); -}; -``` +If the ingress URL or webhook secret changes (e.g., tunnel restart, secret rotation), the gateway will detect the drift and re-register the webhook automatically. -Verify the response has `ok: true`. +You can skip directly to storing credentials. ### Step 4: Register Bot Commands @@ -95,8 +82,8 @@ Use `credential_store` twice to securely save the credentials: Summarize what was done: - Bot verified: @username (ID: nnn) -- Webhook registered at the provided URL +- Webhook registration: handled automatically by the gateway - Bot commands registered: /new - Credentials stored securely in the vault -The gateway automatically detects credentials from the vault and will begin accepting Telegram webhooks shortly. No manual environment variable configuration is needed. +The gateway automatically detects credentials from the vault, reconciles the Telegram webhook registration, and begins accepting Telegram webhooks shortly. No manual environment variable configuration or webhook registration is needed. If the ingress URL or secret changes later, the gateway will automatically re-register the webhook. diff --git a/gateway/src/__tests__/telegram-webhook-manager.test.ts b/gateway/src/__tests__/telegram-webhook-manager.test.ts new file mode 100644 index 00000000000..7a2fea348cc --- /dev/null +++ b/gateway/src/__tests__/telegram-webhook-manager.test.ts @@ -0,0 +1,191 @@ +import { describe, test, expect, mock, afterEach } from "bun:test"; +import { reconcileTelegramWebhook } from "../telegram/webhook-manager.js"; +import type { GatewayConfig } from "../config.js"; + +function makeConfig(overrides: Partial = {}): GatewayConfig { + return { + telegramBotToken: "test-bot-token", + telegramWebhookSecret: "test-webhook-secret", + telegramApiBaseUrl: "https://api.telegram.org", + assistantRuntimeBaseUrl: "http://localhost:7821", + routingEntries: [], + defaultAssistantId: undefined, + unmappedPolicy: "reject", + port: 7830, + runtimeBearerToken: undefined, + runtimeProxyEnabled: false, + runtimeProxyRequireAuth: false, + runtimeProxyBearerToken: undefined, + shutdownDrainMs: 5000, + runtimeTimeoutMs: 30000, + runtimeMaxRetries: 2, + runtimeInitialBackoffMs: 500, + telegramInitialBackoffMs: 1000, + telegramMaxRetries: 0, + telegramTimeoutMs: 15000, + maxWebhookPayloadBytes: 1048576, + logFile: { dir: undefined, retentionDays: 30 }, + maxAttachmentBytes: 20971520, + maxAttachmentConcurrency: 3, + twilioAuthToken: undefined, + ingressPublicBaseUrl: "https://example.ngrok.io", + publicUrl: undefined, + ...overrides, + }; +} + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function makeTelegramResponse(result: unknown) { + return new Response(JSON.stringify({ ok: true, result }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +describe("reconcileTelegramWebhook", () => { + test("calls setWebhook when URL does not match", async () => { + const calls: { method: string; body: unknown }[] = []; + + globalThis.fetch = mock(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/getWebhookInfo")) { + calls.push({ method: "getWebhookInfo", body: null }); + return makeTelegramResponse({ + url: "https://old-url.example.com/webhooks/telegram", + has_custom_certificate: false, + pending_update_count: 0, + }); + } + if (url.includes("/setWebhook")) { + const req = typeof input === "object" && "json" in input ? input : null; + const body = req ? await (req as Request).json() : null; + calls.push({ method: "setWebhook", body }); + return makeTelegramResponse(true); + } + return new Response("Not found", { status: 404 }); + }) as any; + + const config = makeConfig(); + await reconcileTelegramWebhook(config); + + expect(calls).toHaveLength(2); + expect(calls[0].method).toBe("getWebhookInfo"); + expect(calls[1].method).toBe("setWebhook"); + expect((calls[1].body as any).url).toBe("https://example.ngrok.io/webhooks/telegram"); + expect((calls[1].body as any).secret_token).toBe("test-webhook-secret"); + expect((calls[1].body as any).allowed_updates).toEqual(["message", "edited_message"]); + }); + + test("does not call setWebhook when URL already matches", async () => { + const calls: string[] = []; + + globalThis.fetch = mock(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/getWebhookInfo")) { + calls.push("getWebhookInfo"); + return makeTelegramResponse({ + url: "https://example.ngrok.io/webhooks/telegram", + has_custom_certificate: false, + pending_update_count: 0, + }); + } + if (url.includes("/setWebhook")) { + calls.push("setWebhook"); + return makeTelegramResponse(true); + } + return new Response("Not found", { status: 404 }); + }) as any; + + const config = makeConfig(); + await reconcileTelegramWebhook(config); + + expect(calls).toEqual(["getWebhookInfo"]); + }); + + test("calls setWebhook when secret may have changed (forceUpdate)", async () => { + const calls: string[] = []; + + globalThis.fetch = mock(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/getWebhookInfo")) { + calls.push("getWebhookInfo"); + return makeTelegramResponse({ + url: "https://example.ngrok.io/webhooks/telegram", + has_custom_certificate: false, + pending_update_count: 0, + }); + } + if (url.includes("/setWebhook")) { + calls.push("setWebhook"); + return makeTelegramResponse(true); + } + return new Response("Not found", { status: 404 }); + }) as any; + + const config = makeConfig(); + await reconcileTelegramWebhook(config, { forceUpdate: true }); + + expect(calls).toEqual(["getWebhookInfo", "setWebhook"]); + }); + + test("skips reconciliation when bot token is not configured", async () => { + const fetchMock = mock(async () => new Response("", { status: 200 })); + globalThis.fetch = fetchMock as any; + + const config = makeConfig({ telegramBotToken: undefined }); + await reconcileTelegramWebhook(config); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("skips reconciliation when webhook secret is not configured", async () => { + const fetchMock = mock(async () => new Response("", { status: 200 })); + globalThis.fetch = fetchMock as any; + + const config = makeConfig({ telegramWebhookSecret: undefined }); + await reconcileTelegramWebhook(config); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("skips reconciliation when ingress URL is not configured", async () => { + const fetchMock = mock(async () => new Response("", { status: 200 })); + globalThis.fetch = fetchMock as any; + + const config = makeConfig({ ingressPublicBaseUrl: undefined }); + await reconcileTelegramWebhook(config); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("calls setWebhook when current URL is empty", async () => { + const calls: string[] = []; + + globalThis.fetch = mock(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/getWebhookInfo")) { + calls.push("getWebhookInfo"); + return makeTelegramResponse({ + url: "", + has_custom_certificate: false, + pending_update_count: 0, + }); + } + if (url.includes("/setWebhook")) { + calls.push("setWebhook"); + return makeTelegramResponse(true); + } + return new Response("Not found", { status: 404 }); + }) as any; + + const config = makeConfig(); + await reconcileTelegramWebhook(config); + + expect(calls).toEqual(["getWebhookInfo", "setWebhook"]); + }); +}); diff --git a/gateway/src/index.ts b/gateway/src/index.ts index 93e1b39195a..d3283fbaf46 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -11,6 +11,7 @@ import { createOAuthCallbackHandler } from "./http/routes/oauth-callback.js"; import { getLogger, initLogger } from "./logger.js"; import { buildSchema } from "./schema.js"; import { callTelegramApi } from "./telegram/api.js"; +import { reconcileTelegramWebhook } from "./telegram/webhook-manager.js"; const log = getLogger("main"); @@ -131,6 +132,9 @@ function main() { if (isTelegramConfigured()) { registerTelegramCommands(); + reconcileTelegramWebhook(config).catch((err) => { + log.error({ err }, "Failed to reconcile Telegram webhook on startup"); + }); } const credentialWatcher = new CredentialWatcher((credentials) => { @@ -139,6 +143,9 @@ function main() { config.telegramWebhookSecret = credentials.webhookSecret; log.info("Telegram credentials loaded from credential vault"); registerTelegramCommands(); + reconcileTelegramWebhook(config, { forceUpdate: true }).catch((err) => { + log.error({ err }, "Failed to reconcile Telegram webhook after credential change"); + }); } else { config.telegramBotToken = undefined; config.telegramWebhookSecret = undefined; diff --git a/gateway/src/telegram/webhook-manager.ts b/gateway/src/telegram/webhook-manager.ts new file mode 100644 index 00000000000..f97b772aea6 --- /dev/null +++ b/gateway/src/telegram/webhook-manager.ts @@ -0,0 +1,75 @@ +import type { GatewayConfig } from "../config.js"; +import { callTelegramApi } from "./api.js"; +import { getLogger } from "../logger.js"; + +const log = getLogger("webhook-manager"); + +interface WebhookInfo { + url: string; + has_custom_certificate: boolean; + pending_update_count: number; + /** Telegram does not return the secret itself, but we can detect a mismatch by re-setting. */ +} + +const ALLOWED_UPDATES = ["message", "edited_message"]; + +/** + * Reconciles the Telegram webhook registration against the expected state + * derived from the gateway's ingress URL and current webhook secret. + * + * If the currently registered webhook URL differs from the expected URL, + * or if the secret may have changed (we always re-set when the URL matches + * but we can't verify the secret from getWebhookInfo), the webhook is + * re-registered via setWebhook. + * + * This is safe to call repeatedly; Telegram treats setWebhook as idempotent. + */ +export async function reconcileTelegramWebhook( + config: GatewayConfig, + options?: { forceUpdate?: boolean }, +): Promise { + if (!config.telegramBotToken || !config.telegramWebhookSecret) { + log.debug("Skipping webhook reconciliation: Telegram credentials not configured"); + return; + } + + if (!config.ingressPublicBaseUrl) { + log.debug("Skipping webhook reconciliation: INGRESS_PUBLIC_BASE_URL not set"); + return; + } + + const expectedUrl = `${config.ingressPublicBaseUrl}/webhooks/telegram`; + + const info = await callTelegramApi(config, "getWebhookInfo", {}); + + const urlMatches = info.url === expectedUrl; + + // Telegram does not expose the current secret_token via getWebhookInfo, + // so we cannot compare it directly. When credentials are refreshed + // (forceUpdate), we always re-set to ensure the secret is current. + if (urlMatches && !options?.forceUpdate) { + log.info( + { currentUrl: info.url, expectedUrl }, + "Telegram webhook URL matches expected state, no update needed", + ); + return; + } + + log.info( + { + currentUrl: info.url || "(none)", + expectedUrl, + forceUpdate: !!options?.forceUpdate, + urlMatches, + }, + "Telegram webhook state differs from expected, updating", + ); + + await callTelegramApi(config, "setWebhook", { + url: expectedUrl, + secret_token: config.telegramWebhookSecret, + allowed_updates: ALLOWED_UPDATES, + }); + + log.info({ url: expectedUrl }, "Telegram webhook registered successfully"); +} From 781c60a81c799c2dadc92af9d33163b9058e1b39 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 21 Feb 2026 15:19:22 -0500 Subject: [PATCH 03/12] feat: auto-configure gateway routing for single-assistant mode and add rejection visibility (#6212) Co-authored-by: Claude --- .../vellum-skills/telegram-setup/SKILL.md | 14 ++++++-- cli/src/lib/local.ts | 12 ++++++- gateway/src/http/routes/telegram-webhook.ts | 34 ++++++++++++++++++- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/assistant/src/config/vellum-skills/telegram-setup/SKILL.md b/assistant/src/config/vellum-skills/telegram-setup/SKILL.md index 68d5fd51e24..64155fa2a84 100644 --- a/assistant/src/config/vellum-skills/telegram-setup/SKILL.md +++ b/assistant/src/config/vellum-skills/telegram-setup/SKILL.md @@ -78,12 +78,22 @@ Use `credential_store` twice to securely save the credentials: 2. **Store the webhook secret:** - action: `store`, service: `telegram`, field: `webhook_secret`, value: the generated secret -### Step 6: Report Success +### Step 6: Validate Routing Configuration + +Verify that the gateway routing is configured to deliver inbound messages to the assistant: + +- In **single-assistant mode** (the default local deployment), routing is automatically configured. The CLI sets `GATEWAY_UNMAPPED_POLICY=default` and `GATEWAY_DEFAULT_ASSISTANT_ID` to the current assistant's ID when starting the gateway, so no manual routing configuration is needed. +- In **multi-assistant mode**, the operator must set `GATEWAY_ASSISTANT_ROUTING_JSON` to map specific chat IDs or user IDs to assistant IDs, or configure a default assistant via `GATEWAY_DEFAULT_ASSISTANT_ID` with `GATEWAY_UNMAPPED_POLICY=default`. + +If routing is misconfigured, inbound Telegram messages will be rejected and the gateway will send a visible notice to the chat explaining the issue (rate-limited to once per 5 minutes per chat). + +### Step 7: Report Success Summarize what was done: - Bot verified: @username (ID: nnn) - Webhook registration: handled automatically by the gateway - Bot commands registered: /new - Credentials stored securely in the vault +- Routing configuration validated -The gateway automatically detects credentials from the vault, reconciles the Telegram webhook registration, and begins accepting Telegram webhooks shortly. No manual environment variable configuration or webhook registration is needed. If the ingress URL or secret changes later, the gateway will automatically re-register the webhook. +The gateway automatically detects credentials from the vault, reconciles the Telegram webhook registration, and begins accepting Telegram webhooks shortly. In single-assistant mode, routing is automatically configured — no manual environment variable configuration or webhook registration is needed. If the ingress URL or secret changes later, the gateway will automatically re-register the webhook. diff --git a/cli/src/lib/local.ts b/cli/src/lib/local.ts index ba0d12cb33c..315671ac3e8 100644 --- a/cli/src/lib/local.ts +++ b/cli/src/lib/local.ts @@ -4,7 +4,8 @@ import { createRequire } from "module"; import { homedir } from "os"; import { dirname, join } from "path"; -import { GATEWAY_PORT } from "../lib/constants"; +import { loadLatestAssistant } from "./assistant-config.js"; +import { GATEWAY_PORT } from "./constants.js"; const _require = createRequire(import.meta.url); @@ -286,11 +287,20 @@ export async function startGateway(): Promise { console.log("🌐 Starting gateway..."); const gatewayDir = resolveGatewayDir(); + // Auto-configure routing for single-assistant local deployments so that + // inbound Telegram messages are forwarded without manual env var setup. + const defaultAssistantId = + process.env.GATEWAY_DEFAULT_ASSISTANT_ID + || loadLatestAssistant()?.assistantId + || "default"; + const gatewayEnv: Record = { ...process.env as Record, GATEWAY_RUNTIME_PROXY_ENABLED: "true", GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "false", RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821", + GATEWAY_UNMAPPED_POLICY: process.env.GATEWAY_UNMAPPED_POLICY || "default", + GATEWAY_DEFAULT_ASSISTANT_ID: defaultAssistantId, }; const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl(); const ingressPublicBaseUrl = diff --git a/gateway/src/http/routes/telegram-webhook.ts b/gateway/src/http/routes/telegram-webhook.ts index dac2bf06f73..b1a4ab08447 100644 --- a/gateway/src/http/routes/telegram-webhook.ts +++ b/gateway/src/http/routes/telegram-webhook.ts @@ -26,6 +26,21 @@ export function buildTelegramTransportMetadata(): { hints: string[]; uxBrief: st }; } +// Rate limiter for routing rejection notices — at most one reply per chat +// within the cooldown window to avoid spamming the user. +const REJECTION_NOTICE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes +const rejectionNoticeTimestamps = new Map(); + +function shouldSendRejectionNotice(chatId: string): boolean { + const now = Date.now(); + const lastSent = rejectionNoticeTimestamps.get(chatId); + if (lastSent !== undefined && now - lastSent < REJECTION_NOTICE_COOLDOWN_MS) { + return false; + } + rejectionNoticeTimestamps.set(chatId, now); + return true; +} + export function createTelegramWebhookHandler(config: GatewayConfig) { const dedupCache = new DedupCache(); @@ -207,7 +222,24 @@ export function createTelegramWebhookHandler(config: GatewayConfig) { replyCallbackUrl: `http://127.0.0.1:${config.port}/deliver/telegram`, }); - if (!result.forwarded && !result.rejected) { + if (result.rejected) { + log.warn( + { chatId, reason: result.rejectionReason }, + "Routing rejected inbound Telegram message", + ); + if (shouldSendRejectionNotice(chatId)) { + sendTelegramReply( + config, + chatId, + "\u26a0\ufe0f This message could not be routed to an assistant. Please check your gateway routing configuration.", + ).catch((err) => { + log.error({ err, chatId }, "Failed to send routing rejection notice"); + }); + } + return respond({ ok: true }); + } + + if (!result.forwarded) { log.error({ updateId: payload.update_id }, "Failed to forward inbound event"); if (updateId !== undefined) dedupCache.unreserve(updateId); return Response.json({ error: "Internal error" }, { status: 500 }); From a46ddf98a82b203a8d2ffa537d071a7ebb4d0da3 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 21 Feb 2026 16:22:37 -0500 Subject: [PATCH 04/12] feat: add Telegram Bot messaging provider for proactive outbound sends (#6222) Co-authored-by: Claude --- assistant/src/daemon/lifecycle.ts | 2 + assistant/src/messaging/provider.ts | 9 ++ .../providers/telegram-bot/adapter.ts | 128 ++++++++++++++++++ .../providers/telegram-bot/client.ts | 104 ++++++++++++++ .../messaging/providers/telegram-bot/types.ts | 15 ++ assistant/src/messaging/registry.ts | 1 + 6 files changed, 259 insertions(+) create mode 100644 assistant/src/messaging/providers/telegram-bot/adapter.ts create mode 100644 assistant/src/messaging/providers/telegram-bot/client.ts create mode 100644 assistant/src/messaging/providers/telegram-bot/types.ts diff --git a/assistant/src/daemon/lifecycle.ts b/assistant/src/daemon/lifecycle.ts index 8a22d5a0fe4..57b6d4c2b21 100644 --- a/assistant/src/daemon/lifecycle.ts +++ b/assistant/src/daemon/lifecycle.ts @@ -41,6 +41,7 @@ import { slackProvider as slackWatcherProvider } from '../watcher/providers/slac import { registerMessagingProvider } from '../messaging/registry.js'; import { slackProvider as slackMessagingProvider } from '../messaging/providers/slack/adapter.js'; import { gmailMessagingProvider } from '../messaging/providers/gmail/adapter.js'; +import { telegramBotMessagingProvider } from '../messaging/providers/telegram-bot/adapter.js'; import { browserManager } from '../tools/browser/browser-manager.js'; import { RuntimeHttpServer } from '../runtime/http-server.js'; import { getHookManager } from '../hooks/manager.js'; @@ -384,6 +385,7 @@ export async function runDaemon(): Promise { // Register messaging providers registerMessagingProvider(slackMessagingProvider); registerMessagingProvider(gmailMessagingProvider); + registerMessagingProvider(telegramBotMessagingProvider); const scheduler = startScheduler( async (conversationId, message) => { diff --git a/assistant/src/messaging/provider.ts b/assistant/src/messaging/provider.ts index 898c91d58cd..f856e617a0b 100644 --- a/assistant/src/messaging/provider.ts +++ b/assistant/src/messaging/provider.ts @@ -38,6 +38,15 @@ export interface MessagingProvider { getThreadReplies?(token: string, conversationId: string, threadId: string, options?: HistoryOptions): Promise; markRead?(token: string, conversationId: string, messageId?: string): Promise; + /** + * Override the default credential check used by getConnectedProviders(). + * When present, the registry calls this instead of looking for + * credential:${credentialService}:access_token. Useful for providers + * that don't use OAuth (e.g. Telegram bot tokens stored under a + * non-standard key). + */ + isConnected?(): boolean; + /** Platform-specific capabilities for tool routing (e.g. 'reactions', 'threads', 'labels'). */ capabilities: Set; } diff --git a/assistant/src/messaging/providers/telegram-bot/adapter.ts b/assistant/src/messaging/providers/telegram-bot/adapter.ts new file mode 100644 index 00000000000..cb293a36915 --- /dev/null +++ b/assistant/src/messaging/providers/telegram-bot/adapter.ts @@ -0,0 +1,128 @@ +/** + * Telegram Bot messaging provider adapter. + * + * Enables proactive outbound messaging to Telegram chats via the gateway's + * /deliver/telegram endpoint. Unlike Slack/Gmail which use direct API calls + * with OAuth tokens, Telegram delivery is proxied through the gateway which + * owns the bot token and handles Telegram API retries. + * + * The `token` parameter in MessagingProvider methods is unused for Telegram + * because delivery is authenticated via the gateway's bearer token, not + * a per-user OAuth token. + */ + +import type { MessagingProvider } from '../../provider.js'; +import type { + Conversation, + Message, + SearchResult, + SendResult, + ConnectionInfo, + ListOptions, + HistoryOptions, + SearchOptions, + SendOptions, +} from '../../provider-types.js'; +import { getSecureKey } from '../../../security/secure-keys.js'; +import { readHttpToken } from '../../../util/platform.js'; +import * as telegram from './client.js'; + +/** Resolve the local gateway base URL from GATEWAY_PORT (default 7830). */ +function getGatewayUrl(): string { + const port = Number(process.env.GATEWAY_PORT) || 7830; + return `http://127.0.0.1:${port}`; +} + +/** Read the runtime HTTP bearer token used to authenticate with the gateway. */ +function getBearerToken(): string { + const token = readHttpToken(); + if (!token) { + throw new Error('No runtime HTTP bearer token available — is the daemon running?'); + } + return token; +} + +/** Read the Telegram bot token from the credential vault. */ +function getBotToken(): string | undefined { + return getSecureKey('credential:telegram:bot_token'); +} + +export const telegramBotMessagingProvider: MessagingProvider = { + id: 'telegram', + displayName: 'Telegram', + credentialService: 'telegram', + capabilities: new Set(['send']), + + /** + * Custom connectivity check. The standard registry check looks for + * credential:telegram:access_token, but the Telegram bot token is + * stored as credential:telegram:bot_token. This method lets the + * registry detect that Telegram credentials exist. + */ + isConnected(): boolean { + return getBotToken() !== undefined; + }, + + async testConnection(_token: string): Promise { + const botToken = getBotToken(); + if (!botToken) { + return { + connected: false, + user: 'unknown', + platform: 'telegram', + metadata: { error: 'No bot token found. Run the telegram-setup skill.' }, + }; + } + + const resp = await telegram.getMe(botToken); + if (!resp.ok || !resp.result) { + return { + connected: false, + user: 'unknown', + platform: 'telegram', + metadata: { error: resp.description ?? 'getMe failed' }, + }; + } + + return { + connected: true, + user: resp.result.username ?? resp.result.first_name, + platform: 'telegram', + metadata: { + botId: resp.result.id, + botUsername: resp.result.username, + botName: resp.result.first_name, + }, + }; + }, + + async sendMessage(_token: string, conversationId: string, text: string, _options?: SendOptions): Promise { + const gatewayUrl = getGatewayUrl(); + const bearerToken = getBearerToken(); + + await telegram.sendMessage(gatewayUrl, bearerToken, conversationId, text); + + return { + id: `tg-${Date.now()}`, + timestamp: Date.now(), + conversationId, + }; + }, + + // Telegram Bot API does not support listing conversations. Bots only + // interact with chats where users have initiated contact or the bot + // has been added to a group. + async listConversations(_token: string, _options?: ListOptions): Promise { + return []; + }, + + // Telegram Bot API does not provide message history retrieval. + async getHistory(_token: string, _conversationId: string, _options?: HistoryOptions): Promise { + return []; + }, + + // Telegram Bot API does not support message search. + async search(_token: string, _query: string, _options?: SearchOptions): Promise { + return { total: 0, messages: [], hasMore: false }; + }, +}; diff --git a/assistant/src/messaging/providers/telegram-bot/client.ts b/assistant/src/messaging/providers/telegram-bot/client.ts new file mode 100644 index 00000000000..cd2befbf783 --- /dev/null +++ b/assistant/src/messaging/providers/telegram-bot/client.ts @@ -0,0 +1,104 @@ +/** + * Low-level Telegram operations. + * + * Outbound message delivery routes through the gateway's /deliver/telegram + * endpoint, which handles bot token management and Telegram API retries. + * Connection verification calls the Telegram Bot API directly with the + * stored bot token. + */ + +import type { TelegramGetMeResponse } from './types.js'; + +const TELEGRAM_API_BASE = 'https://api.telegram.org'; +const DELIVERY_TIMEOUT_MS = 30_000; + +export class TelegramApiError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = 'TelegramApiError'; + } +} + +/** + * Verify a bot token by calling Telegram's getMe API directly. + * Used for testConnection() — the only operation that bypasses the gateway. + */ +export async function getMe(botToken: string): Promise { + const resp = await fetch(`${TELEGRAM_API_BASE}/bot${botToken}/getMe`, { + method: 'POST', + signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS), + }); + + if (!resp.ok) { + throw new TelegramApiError( + resp.status, + `Telegram getMe failed with status ${resp.status}`, + ); + } + + return resp.json() as Promise; +} + +/** + * Send a text message to a Telegram chat via the gateway's deliver endpoint. + */ +export async function sendMessage( + gatewayUrl: string, + bearerToken: string, + chatId: string, + text: string, +): Promise { + await deliverToGateway(gatewayUrl, bearerToken, { chatId, text }); +} + +/** + * Send a message with attachments to a Telegram chat via the gateway. + */ +export async function sendMessageWithAttachments( + gatewayUrl: string, + bearerToken: string, + chatId: string, + text: string | undefined, + attachmentIds: string[], +): Promise { + await deliverToGateway(gatewayUrl, bearerToken, { + chatId, + text, + attachments: attachmentIds.map((id) => ({ id })), + }); +} + +/** Payload accepted by the gateway's /deliver/telegram endpoint. */ +interface DeliverPayload { + chatId: string; + text?: string; + attachments?: Array<{ id: string }>; +} + +async function deliverToGateway( + gatewayUrl: string, + bearerToken: string, + payload: DeliverPayload, +): Promise { + const url = `${gatewayUrl}/deliver/telegram`; + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${bearerToken}`, + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS), + }); + + if (!resp.ok) { + const body = await resp.text().catch(() => ''); + throw new TelegramApiError( + resp.status, + `Gateway /deliver/telegram failed (${resp.status}): ${body}`, + ); + } +} diff --git a/assistant/src/messaging/providers/telegram-bot/types.ts b/assistant/src/messaging/providers/telegram-bot/types.ts new file mode 100644 index 00000000000..d64532860a2 --- /dev/null +++ b/assistant/src/messaging/providers/telegram-bot/types.ts @@ -0,0 +1,15 @@ +/** Telegram Bot API types used by the messaging provider. */ + +export interface TelegramUser { + id: number; + is_bot: boolean; + first_name: string; + last_name?: string; + username?: string; +} + +export interface TelegramGetMeResponse { + ok: boolean; + result?: TelegramUser; + description?: string; +} diff --git a/assistant/src/messaging/registry.ts b/assistant/src/messaging/registry.ts index dde82208686..e0620ef6502 100644 --- a/assistant/src/messaging/registry.ts +++ b/assistant/src/messaging/registry.ts @@ -23,6 +23,7 @@ export function getMessagingProvider(id: string): MessagingProvider { /** Return all registered providers that have stored credentials. */ export function getConnectedProviders(): MessagingProvider[] { return Array.from(providers.values()).filter((p) => { + if (p.isConnected) return p.isConnected(); const token = getSecureKey(`credential:${p.credentialService}:access_token`); return token !== undefined; }); From d4e7430cb3df0fadf45f502d6442ee7fb5682f7e Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 21 Feb 2026 16:30:22 -0500 Subject: [PATCH 05/12] feat: harden /deliver/telegram auth and align docs with Telegram capabilities (#6238) Co-authored-by: Claude --- ARCHITECTURE.md | 37 ++++++++++++++++ README.md | 6 +-- .../config/bundled-skills/messaging/SKILL.md | 17 +++++++- .../vellum-skills/telegram-setup/SKILL.md | 30 +++++++++++++ gateway/README.md | 18 +++++++- gateway/src/__tests__/config.test.ts | 1 + gateway/src/__tests__/load-guards.test.ts | 1 + gateway/src/__tests__/oauth-callback.test.ts | 1 + .../src/__tests__/resolve-assistant.test.ts | 1 + gateway/src/__tests__/runtime-client.test.ts | 1 + .../src/__tests__/runtime-proxy-auth.test.ts | 1 + gateway/src/__tests__/runtime-proxy.test.ts | 1 + .../__tests__/telegram-deliver-auth.test.ts | 42 +++++++++++++++++-- .../telegram-send-attachments.test.ts | 1 + .../telegram-webhook-manager.test.ts | 1 + .../__tests__/twilio-relay-websocket.test.ts | 1 + gateway/src/__tests__/twilio-webhooks.test.ts | 1 + gateway/src/config.ts | 19 +++++++++ gateway/src/http/routes/telegram-deliver.ts | 16 +++++-- 19 files changed, 184 insertions(+), 12 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f6524829391..a1eda818783 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -3432,6 +3432,43 @@ All public-facing URLs are constructed by `assistant/src/inbound/public-ingress- | `getOAuthCallbackUrl()` | `${base}/webhooks/oauth/callback` | | `getTelegramWebhookUrl()` | `${base}/webhooks/telegram` | +### Telegram Messaging Flow + +Telegram messages follow three paths through the system: + +``` +Inbound (user → assistant): + Telegram → Gateway POST /webhooks/telegram → verify secret → normalize → route + → Runtime POST /v1/assistants/:id/channels/inbound + +Outbound reply (assistant → user, triggered by inbound): + Runtime callback → Gateway POST /deliver/telegram (bearer auth) → Telegram sendMessage/sendPhoto/sendDocument + +Outbound proactive (assistant → user, initiated by messaging provider): + Runtime messaging provider → Gateway POST /deliver/telegram (bearer auth) → Telegram sendMessage +``` + +The `/deliver/telegram` endpoint requires bearer auth unconditionally (fail-closed). If no bearer token is configured and the dev-only bypass flag (`GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS`) is not set, the endpoint returns 503 rather than allowing unauthenticated access. + +### Webhook Reconciliation + +On startup, the gateway automatically reconciles the Telegram webhook registration: + +1. Reads `INGRESS_PUBLIC_BASE_URL` and Telegram credentials (bot token, webhook secret) +2. Calls `getWebhookInfo` to check the current registration +3. Compares the URL, secret, and allowed updates against the expected values +4. If any differ, calls `setWebhook` to update the registration + +This also runs when the credential watcher detects changes to Telegram credentials. Manual webhook registration is no longer required. + +### Routing Auto-Configuration + +In single-assistant mode (the default local deployment), routing is automatically configured by the CLI: +- `GATEWAY_UNMAPPED_POLICY=default` is set so all inbound messages are forwarded +- `GATEWAY_DEFAULT_ASSISTANT_ID` is set to the current assistant's ID + +In multi-assistant mode, the operator must configure `GATEWAY_ASSISTANT_ROUTING_JSON` to map specific chat/user IDs to assistant IDs. + --- ## Outgoing AI Phone Calls — Twilio ConversationRelay diff --git a/README.md b/README.md index 23b20f5c7c3..3258215bf52 100644 --- a/README.md +++ b/README.md @@ -203,11 +203,11 @@ If a proxied command receives a 401 or 403 despite having the correct credential Vellum integrates with third-party services via OAuth2. Each integration is exposed as a bundled skill with its own set of tools. -### Messaging (Gmail, Slack) +### Messaging (Gmail, Slack, Telegram) -The unified messaging layer provides platform-agnostic tools (`messaging_send`, `messaging_read`, `messaging_search`, etc.) that delegate to provider adapters. Gmail and Slack each implement the `MessagingProvider` interface. Platform-specific tools (e.g. `gmail_archive`, `slack_add_reaction`) extend beyond the generic interface where needed. +The unified messaging layer provides platform-agnostic tools (`messaging_send`, `messaging_read`, `messaging_search`, etc.) that delegate to provider adapters. Gmail and Slack each implement the `MessagingProvider` interface. Telegram is also supported as a messaging provider, though with limited capabilities compared to Slack and Gmail: bots can send messages to known chat IDs but cannot list conversations, retrieve message history, or search messages (Bot API limitations). Bots can only message users or groups that have previously interacted with the bot. Platform-specific tools (e.g. `gmail_archive`, `slack_add_reaction`) extend beyond the generic interface where needed. -Connect via the Settings UI or `integration_connect` IPC message. OAuth2 tokens are stored in the credential vault — the LLM never sees raw tokens. +Connect Gmail and Slack via the Settings UI or `integration_connect` IPC message. OAuth2 tokens are stored in the credential vault — the LLM never sees raw tokens. Telegram uses a bot token (not OAuth) — see the `telegram-setup` skill for setup instructions. ### Twitter (X) diff --git a/assistant/src/config/bundled-skills/messaging/SKILL.md b/assistant/src/config/bundled-skills/messaging/SKILL.md index b72475ad6f2..251abab6141 100644 --- a/assistant/src/config/bundled-skills/messaging/SKILL.md +++ b/assistant/src/config/bundled-skills/messaging/SKILL.md @@ -47,7 +47,7 @@ The telegram-setup skill handles: verifying the bot token from @BotFather, gener ## Capabilities -### Universal (all platforms) +### Universal (Slack, Gmail) - **Auth Test**: Verify connection and show account info - **List Conversations**: Show channels, inboxes, DMs with unread counts - **Read Messages**: Read message history from a conversation @@ -56,6 +56,21 @@ The telegram-setup skill handles: verifying the bot token from @BotFather, gener - **Reply**: Reply in a thread (medium risk) - **Mark Read**: Mark conversation as read +### Telegram +Telegram is supported as a messaging provider with limited capabilities compared to Slack and Gmail due to Bot API constraints: + +- **Send**: Send a message to a known chat ID (high risk — requires user approval) +- **Auth Test**: Verify bot token and show bot info + +**Not available** (Bot API limitations): +- List conversations — the Bot API does not expose a method to enumerate chats a bot belongs to +- Read message history — bots cannot retrieve past messages from a chat +- Search messages — no search API is available for bots + +**Bot-account limits:** +- The bot can only message users or groups that have previously interacted with it (sent `/start` or been added to a group). Bots cannot initiate conversations with arbitrary phone numbers. +- Future support for MTProto user-account sessions may lift some of these restrictions. + ### Slack-specific - **Add Reaction**: Add an emoji reaction to a message - **Leave Channel**: Leave a Slack channel diff --git a/assistant/src/config/vellum-skills/telegram-setup/SKILL.md b/assistant/src/config/vellum-skills/telegram-setup/SKILL.md index 64155fa2a84..24ca8344449 100644 --- a/assistant/src/config/vellum-skills/telegram-setup/SKILL.md +++ b/assistant/src/config/vellum-skills/telegram-setup/SKILL.md @@ -97,3 +97,33 @@ Summarize what was done: - Routing configuration validated The gateway automatically detects credentials from the vault, reconciles the Telegram webhook registration, and begins accepting Telegram webhooks shortly. In single-assistant mode, routing is automatically configured — no manual environment variable configuration or webhook registration is needed. If the ingress URL or secret changes later, the gateway will automatically re-register the webhook. + +## Bot-Account Limitations + +Telegram bot accounts have inherent limitations imposed by the Bot API: + +- **No arbitrary messaging**: Bots cannot initiate conversations with users who have not first interacted with the bot (sent `/start` or added it to a group). Messaging arbitrary phone numbers is not possible. +- **No conversation listing**: The Bot API does not expose a method to enumerate the chats a bot belongs to. +- **No message history retrieval**: Bots cannot fetch past messages from a chat. +- **No message search**: No search API is available for bots. + +These limitations apply to all Telegram bots regardless of configuration. Future support for MTProto user-account sessions may lift some of these restrictions. + +## Automated vs Manual Steps + +The following steps are now **automated** by the gateway and CLI: + +| Step | Status | Details | +|------|--------|---------| +| Webhook registration | Automated | The gateway reconciles the webhook URL on startup and when credentials change | +| Routing configuration | Automated (single-assistant) | The CLI sets `GATEWAY_UNMAPPED_POLICY=default` and `GATEWAY_DEFAULT_ASSISTANT_ID` automatically | +| Credential detection | Automated | The gateway watches the credential vault for changes | + +The following steps still require **manual** action: + +| Step | Details | +|------|---------| +| Bot token from @BotFather | User must create a bot and provide the token | +| Bot command registration | Registered via the setup skill (Step 4 above) | +| Credential storage | Stored via the setup skill (Step 5 above) | +| Multi-assistant routing | Requires manual `GATEWAY_ASSISTANT_ROUTING_JSON` configuration | diff --git a/gateway/README.md b/gateway/README.md index 66a327fdae4..52e4b056145 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -47,6 +47,7 @@ bun run dev | `GATEWAY_MAX_WEBHOOK_PAYLOAD_BYTES` | No | `1048576` | Max inbound webhook payload size (rejects with 413) | | `GATEWAY_MAX_ATTACHMENT_BYTES` | No | `20971520` | Max single attachment size (oversized are skipped) | | `GATEWAY_MAX_ATTACHMENT_CONCURRENCY` | No | `3` | Max concurrent attachment download/upload operations | +| `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS` | No | `false` | Dev-only: skip bearer auth on `/deliver/telegram` when no token is configured | ## Routing @@ -67,13 +68,28 @@ v1 uses deterministic settings-based routing (no database): ## Setting up the Telegram webhook -After deploying the gateway, register the webhook with Telegram using the `setWebhook` API method. Pass: +Webhook registration is now handled automatically by the gateway. On startup, the gateway reconciles the Telegram webhook by comparing the current registration against `${INGRESS_PUBLIC_BASE_URL}/webhooks/telegram`. If the URL, secret, or allowed updates differ, the gateway re-registers the webhook automatically. This also runs whenever credentials change (e.g., tunnel restart, secret rotation). + +For manual setup (or reference), register the webhook with Telegram using the `setWebhook` API method. Pass: - `url` — your gateway URL, e.g. `https://your-host/webhooks/telegram` - The verify value matching your `TELEGRAM_WEBHOOK_SECRET` env var - `allowed_updates` — `["message", "edited_message"]` See the [Telegram Bot API docs](https://core.telegram.org/bots/api#setwebhook) for the full API reference. +## Telegram Deliver Endpoint Security + +The `/deliver/telegram` endpoint requires bearer auth by default (fail-closed). The security behavior is: + +| Condition | Result | +|-----------|--------| +| Bearer token configured + valid `Authorization` header | Request allowed | +| Bearer token configured + missing/invalid `Authorization` header | 401 Unauthorized | +| No bearer token configured + `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS=true` | Request allowed (dev-only) | +| No bearer token configured + bypass not set | 503 Service Not Configured | + +This ensures that misconfiguration cannot expose an unauthenticated public message-send surface. In production, always configure `RUNTIME_PROXY_BEARER_TOKEN`. The `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS` flag is intended for local development only. + ## Public Ingress Routes The gateway serves as the single public ingress point for all external callbacks. The following routes are handled directly by the gateway before any proxy forwarding: diff --git a/gateway/src/__tests__/config.test.ts b/gateway/src/__tests__/config.test.ts index fa219814ddc..3a7694692af 100644 --- a/gateway/src/__tests__/config.test.ts +++ b/gateway/src/__tests__/config.test.ts @@ -29,6 +29,7 @@ function withEnv(overrides: Record, fn: () => void) "GATEWAY_MAX_WEBHOOK_PAYLOAD_BYTES", "GATEWAY_MAX_ATTACHMENT_BYTES", "GATEWAY_MAX_ATTACHMENT_CONCURRENCY", + "GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS", "VELLUM_HTTP_TOKEN_PATH", ]; diff --git a/gateway/src/__tests__/load-guards.test.ts b/gateway/src/__tests__/load-guards.test.ts index 8d12e7d5df8..e4199b5d1ff 100644 --- a/gateway/src/__tests__/load-guards.test.ts +++ b/gateway/src/__tests__/load-guards.test.ts @@ -20,6 +20,7 @@ function makeConfig(overrides: Partial = {}): GatewayConfig { runtimeTimeoutMs: 30000, runtimeMaxRetries: 2, runtimeInitialBackoffMs: 500, + telegramDeliverAuthBypass: false, telegramInitialBackoffMs: 1000, telegramMaxRetries: 3, telegramTimeoutMs: 15000, diff --git a/gateway/src/__tests__/oauth-callback.test.ts b/gateway/src/__tests__/oauth-callback.test.ts index 67e208035c6..2c9c6e4498e 100644 --- a/gateway/src/__tests__/oauth-callback.test.ts +++ b/gateway/src/__tests__/oauth-callback.test.ts @@ -19,6 +19,7 @@ const makeConfig = (overrides: Partial = {}): GatewayConfig => ({ runtimeTimeoutMs: 30000, runtimeMaxRetries: 2, runtimeInitialBackoffMs: 500, + telegramDeliverAuthBypass: false, telegramInitialBackoffMs: 1000, telegramMaxRetries: 3, telegramTimeoutMs: 15000, diff --git a/gateway/src/__tests__/resolve-assistant.test.ts b/gateway/src/__tests__/resolve-assistant.test.ts index 85f7cca4e38..aa80809c6e2 100644 --- a/gateway/src/__tests__/resolve-assistant.test.ts +++ b/gateway/src/__tests__/resolve-assistant.test.ts @@ -20,6 +20,7 @@ function makeConfig(overrides: Partial = {}): GatewayConfig { runtimeTimeoutMs: 30000, runtimeMaxRetries: 2, runtimeInitialBackoffMs: 500, + telegramDeliverAuthBypass: false, telegramInitialBackoffMs: 1000, telegramMaxRetries: 3, telegramTimeoutMs: 15000, diff --git a/gateway/src/__tests__/runtime-client.test.ts b/gateway/src/__tests__/runtime-client.test.ts index fac7dc74583..281f8f76ea8 100644 --- a/gateway/src/__tests__/runtime-client.test.ts +++ b/gateway/src/__tests__/runtime-client.test.ts @@ -26,6 +26,7 @@ const makeConfig = (overrides: Partial = {}): GatewayConfig => ({ runtimeTimeoutMs: 30000, runtimeMaxRetries: 2, runtimeInitialBackoffMs: 500, + telegramDeliverAuthBypass: false, telegramInitialBackoffMs: 1000, telegramMaxRetries: 3, telegramTimeoutMs: 15000, diff --git a/gateway/src/__tests__/runtime-proxy-auth.test.ts b/gateway/src/__tests__/runtime-proxy-auth.test.ts index 551a8cc1018..2b83cc6f205 100644 --- a/gateway/src/__tests__/runtime-proxy-auth.test.ts +++ b/gateway/src/__tests__/runtime-proxy-auth.test.ts @@ -22,6 +22,7 @@ function makeConfig(overrides: Partial = {}): GatewayConfig { runtimeTimeoutMs: 30000, runtimeMaxRetries: 2, runtimeInitialBackoffMs: 500, + telegramDeliverAuthBypass: false, telegramInitialBackoffMs: 1000, telegramMaxRetries: 3, telegramTimeoutMs: 15000, diff --git a/gateway/src/__tests__/runtime-proxy.test.ts b/gateway/src/__tests__/runtime-proxy.test.ts index 211709ac5a0..5cd3d28be62 100644 --- a/gateway/src/__tests__/runtime-proxy.test.ts +++ b/gateway/src/__tests__/runtime-proxy.test.ts @@ -20,6 +20,7 @@ function makeConfig(overrides: Partial = {}): GatewayConfig { runtimeTimeoutMs: 30000, runtimeMaxRetries: 2, runtimeInitialBackoffMs: 500, + telegramDeliverAuthBypass: false, telegramInitialBackoffMs: 1000, telegramMaxRetries: 3, telegramTimeoutMs: 15000, diff --git a/gateway/src/__tests__/telegram-deliver-auth.test.ts b/gateway/src/__tests__/telegram-deliver-auth.test.ts index c67e310fe50..e8e28e7e682 100644 --- a/gateway/src/__tests__/telegram-deliver-auth.test.ts +++ b/gateway/src/__tests__/telegram-deliver-auth.test.ts @@ -22,6 +22,7 @@ function makeConfig(overrides: Partial = {}): GatewayConfig { runtimeTimeoutMs: 30000, runtimeMaxRetries: 2, runtimeInitialBackoffMs: 500, + telegramDeliverAuthBypass: false, telegramInitialBackoffMs: 1000, telegramMaxRetries: 3, telegramTimeoutMs: 15000, @@ -78,7 +79,7 @@ describe("/deliver/telegram attachment delivery without assistantId", () => { }) as any; const handler = createTelegramDeliverHandler( - makeConfig({ runtimeProxyBearerToken: undefined }), + makeConfig({ runtimeProxyBearerToken: undefined, telegramDeliverAuthBypass: true }), ); const req = new Request("http://localhost:7830/deliver/telegram", { method: "POST", @@ -131,7 +132,7 @@ describe("/deliver/telegram attachment delivery without assistantId", () => { }) as any; const handler = createTelegramDeliverHandler( - makeConfig({ runtimeProxyBearerToken: undefined }), + makeConfig({ runtimeProxyBearerToken: undefined, telegramDeliverAuthBypass: true }), ); const req = new Request("http://localhost:7830/deliver/telegram", { method: "POST", @@ -222,8 +223,7 @@ describe("/deliver/telegram bearer auth enforcement", () => { expect(body.ok).toBe(true); }); - test("allows unauthenticated access when no token is configured", async () => { - mockTelegramApi(); + test("returns 503 when no token is configured and bypass is not set", async () => { const handler = createTelegramDeliverHandler( makeConfig({ runtimeProxyBearerToken: undefined }), ); @@ -234,11 +234,45 @@ describe("/deliver/telegram bearer auth enforcement", () => { }); const res = await handler(req); + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.error).toBe("Service not configured: bearer token required"); + }); + + test("allows unauthenticated access when bypass flag is set and no token configured", async () => { + mockTelegramApi(); + const handler = createTelegramDeliverHandler( + makeConfig({ runtimeProxyBearerToken: undefined, telegramDeliverAuthBypass: true }), + ); + const req = new Request("http://localhost:7830/deliver/telegram", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ chatId: "123", text: "hello" }), + }); + const res = await handler(req); + expect(res.status).toBe(200); const body = await res.json(); expect(body.ok).toBe(true); }); + test("bypass flag is ignored when a bearer token is configured (auth still required)", async () => { + const handler = createTelegramDeliverHandler( + makeConfig({ telegramDeliverAuthBypass: true }), + ); + const req = new Request("http://localhost:7830/deliver/telegram", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ chatId: "123", text: "hello" }), + }); + const res = await handler(req); + + // Token is configured, so missing Authorization header is still rejected + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + test("still rejects non-POST methods before auth check", async () => { const handler = createTelegramDeliverHandler(makeConfig()); const req = new Request("http://localhost:7830/deliver/telegram", { diff --git a/gateway/src/__tests__/telegram-send-attachments.test.ts b/gateway/src/__tests__/telegram-send-attachments.test.ts index 9162ce5eb2e..4fc310e68bd 100644 --- a/gateway/src/__tests__/telegram-send-attachments.test.ts +++ b/gateway/src/__tests__/telegram-send-attachments.test.ts @@ -20,6 +20,7 @@ const makeConfig = (overrides: Partial = {}): GatewayConfig => ({ runtimeTimeoutMs: 30000, runtimeMaxRetries: 2, runtimeInitialBackoffMs: 500, + telegramDeliverAuthBypass: false, telegramInitialBackoffMs: 1000, telegramMaxRetries: 3, telegramTimeoutMs: 15000, diff --git a/gateway/src/__tests__/telegram-webhook-manager.test.ts b/gateway/src/__tests__/telegram-webhook-manager.test.ts index 7a2fea348cc..8cf2839baa3 100644 --- a/gateway/src/__tests__/telegram-webhook-manager.test.ts +++ b/gateway/src/__tests__/telegram-webhook-manager.test.ts @@ -20,6 +20,7 @@ function makeConfig(overrides: Partial = {}): GatewayConfig { runtimeTimeoutMs: 30000, runtimeMaxRetries: 2, runtimeInitialBackoffMs: 500, + telegramDeliverAuthBypass: false, telegramInitialBackoffMs: 1000, telegramMaxRetries: 0, telegramTimeoutMs: 15000, diff --git a/gateway/src/__tests__/twilio-relay-websocket.test.ts b/gateway/src/__tests__/twilio-relay-websocket.test.ts index 95a5dc95f01..eebd74944b3 100644 --- a/gateway/src/__tests__/twilio-relay-websocket.test.ts +++ b/gateway/src/__tests__/twilio-relay-websocket.test.ts @@ -34,6 +34,7 @@ const makeConfig = (overrides: Partial = {}): GatewayConfig => ({ runtimeTimeoutMs: 30000, runtimeMaxRetries: 2, runtimeInitialBackoffMs: 500, + telegramDeliverAuthBypass: false, telegramInitialBackoffMs: 1000, telegramMaxRetries: 3, telegramTimeoutMs: 15000, diff --git a/gateway/src/__tests__/twilio-webhooks.test.ts b/gateway/src/__tests__/twilio-webhooks.test.ts index 9130811b634..f725af60a34 100644 --- a/gateway/src/__tests__/twilio-webhooks.test.ts +++ b/gateway/src/__tests__/twilio-webhooks.test.ts @@ -24,6 +24,7 @@ const makeConfig = (overrides: Partial = {}): GatewayConfig => ({ runtimeTimeoutMs: 30000, runtimeMaxRetries: 2, runtimeInitialBackoffMs: 500, + telegramDeliverAuthBypass: false, telegramInitialBackoffMs: 1000, telegramMaxRetries: 3, telegramTimeoutMs: 15000, diff --git a/gateway/src/config.ts b/gateway/src/config.ts index de65afe08f9..da835d57298 100644 --- a/gateway/src/config.ts +++ b/gateway/src/config.ts @@ -31,6 +31,11 @@ export type GatewayConfig = { shutdownDrainMs: number; telegramApiBaseUrl: string; telegramBotToken: string | undefined; + /** + * When true, the /deliver/telegram endpoint allows unauthenticated access + * even when no bearer token is configured. Intended for local development only. + */ + telegramDeliverAuthBypass: boolean; telegramInitialBackoffMs: number; telegramMaxRetries: number; telegramTimeoutMs: number; @@ -173,6 +178,18 @@ export function loadConfig(): GatewayConfig { throw new Error("GATEWAY_RUNTIME_INITIAL_BACKOFF_MS must be a positive number"); } + const telegramDeliverAuthBypassRaw = process.env.GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS; + if ( + telegramDeliverAuthBypassRaw !== undefined && + telegramDeliverAuthBypassRaw !== "true" && + telegramDeliverAuthBypassRaw !== "false" + ) { + throw new Error( + `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS must be "true" or "false", got "${telegramDeliverAuthBypassRaw}"`, + ); + } + const telegramDeliverAuthBypass = telegramDeliverAuthBypassRaw === "true"; + const telegramTimeoutMs = Number(process.env.GATEWAY_TELEGRAM_TIMEOUT_MS || "15000"); if (!Number.isFinite(telegramTimeoutMs) || telegramTimeoutMs <= 0) { throw new Error("GATEWAY_TELEGRAM_TIMEOUT_MS must be a positive number"); @@ -240,6 +257,7 @@ export function loadConfig(): GatewayConfig { port, runtimeProxyEnabled, runtimeProxyRequireAuth, + telegramDeliverAuthBypass, hasTwilioAuthToken: !!twilioAuthToken, publicUrl, }, @@ -265,6 +283,7 @@ export function loadConfig(): GatewayConfig { shutdownDrainMs, telegramApiBaseUrl, telegramBotToken, + telegramDeliverAuthBypass, telegramInitialBackoffMs, telegramMaxRetries, telegramTimeoutMs, diff --git a/gateway/src/http/routes/telegram-deliver.ts b/gateway/src/http/routes/telegram-deliver.ts index 6ac0d7cb05f..f379759eb60 100644 --- a/gateway/src/http/routes/telegram-deliver.ts +++ b/gateway/src/http/routes/telegram-deliver.ts @@ -12,9 +12,19 @@ export function createTelegramDeliverHandler(config: GatewayConfig) { return Response.json({ error: "Method not allowed" }, { status: 405 }); } - // Require bearer auth when a token is configured, preventing unauthenticated - // public access to the delivery endpoint. - if (config.runtimeProxyBearerToken) { + // Fail-closed auth: when no bearer token is configured and the explicit + // dev-only bypass flag is not set, refuse to serve requests (503) rather + // than silently allowing unauthenticated access. + if (!config.runtimeProxyBearerToken) { + if (config.telegramDeliverAuthBypass) { + // Dev-only bypass — skip auth entirely. + } else { + return Response.json( + { error: "Service not configured: bearer token required" }, + { status: 503 }, + ); + } + } else { const authResult = validateBearerToken( req.headers.get("authorization"), config.runtimeProxyBearerToken, From d73f7fb791fb69c6dee746c26a5eb1a61103b52a Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 21 Feb 2026 16:33:02 -0500 Subject: [PATCH 06/12] fix: correct misleading comment in Telegram attachment download path (#6241) Co-authored-by: Claude --- gateway/src/telegram/send.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gateway/src/telegram/send.ts b/gateway/src/telegram/send.ts index 5d057e0aefa..bc48f3b00a9 100644 --- a/gateway/src/telegram/send.ts +++ b/gateway/src/telegram/send.ts @@ -61,8 +61,8 @@ export async function sendTelegramAttachments( } try { - // Prefer the assistant-less download path; fall back to the legacy - // assistant-scoped path when assistantId is available. + // Use the legacy assistant-scoped download path when assistantId is + // available; fall back to the assistant-less endpoint otherwise. const payload = assistantId ? await downloadAttachment(config, assistantId, meta.id) : await downloadAttachmentById(config, meta.id); From 3a774d2f036329794dc617134d5ad41be99d2e34 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 21 Feb 2026 16:33:38 -0500 Subject: [PATCH 07/12] fix: bound rejection notice cache with periodic eviction (#6242) Co-authored-by: Claude --- gateway/src/http/routes/telegram-webhook.ts | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/gateway/src/http/routes/telegram-webhook.ts b/gateway/src/http/routes/telegram-webhook.ts index b1a4ab08447..e62e601c537 100644 --- a/gateway/src/http/routes/telegram-webhook.ts +++ b/gateway/src/http/routes/telegram-webhook.ts @@ -29,10 +29,43 @@ export function buildTelegramTransportMetadata(): { hints: string[]; uxBrief: st // Rate limiter for routing rejection notices — at most one reply per chat // within the cooldown window to avoid spamming the user. const REJECTION_NOTICE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes +const MAX_REJECTION_CACHE_SIZE = 10_000; +const SWEEP_INTERVAL = 100; // sweep every N calls const rejectionNoticeTimestamps = new Map(); +let rejectionCallCount = 0; + +/** + * Evict expired entries from the rejection notice cache. If the map still + * exceeds MAX_REJECTION_CACHE_SIZE after removing stale entries, drop the + * oldest entries until it fits. + */ +function sweepRejectionCache(now: number): void { + for (const [key, ts] of rejectionNoticeTimestamps) { + if (now - ts >= REJECTION_NOTICE_COOLDOWN_MS) { + rejectionNoticeTimestamps.delete(key); + } + } + + if (rejectionNoticeTimestamps.size > MAX_REJECTION_CACHE_SIZE) { + // Sort by timestamp ascending and drop the oldest entries + const sorted = [...rejectionNoticeTimestamps.entries()].sort((a, b) => a[1] - b[1]); + const toRemove = sorted.length - MAX_REJECTION_CACHE_SIZE; + for (let i = 0; i < toRemove; i++) { + rejectionNoticeTimestamps.delete(sorted[i][0]); + } + } +} function shouldSendRejectionNotice(chatId: string): boolean { const now = Date.now(); + + // Periodically sweep expired entries to bound memory growth + rejectionCallCount++; + if (rejectionCallCount >= SWEEP_INTERVAL) { + rejectionCallCount = 0; + sweepRejectionCache(now); + } + const lastSent = rejectionNoticeTimestamps.get(chatId); if (lastSent !== undefined && now - lastSent < REJECTION_NOTICE_COOLDOWN_MS) { return false; From 754b85216e5ab5adde7aa45fadefd00ab7ea4857 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 21 Feb 2026 16:33:54 -0500 Subject: [PATCH 08/12] fix: support tokenless providers in withProviderToken and fix testConnection error handling (#6244) Co-authored-by: Claude --- .../bundled-skills/messaging/tools/shared.ts | 5 +++ .../providers/telegram-bot/adapter.ts | 37 ++++++++++++------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/assistant/src/config/bundled-skills/messaging/tools/shared.ts b/assistant/src/config/bundled-skills/messaging/tools/shared.ts index fd4b8689cc6..d9289fa96d0 100644 --- a/assistant/src/config/bundled-skills/messaging/tools/shared.ts +++ b/assistant/src/config/bundled-skills/messaging/tools/shared.ts @@ -37,11 +37,16 @@ export function resolveProvider(platformInput?: string): MessagingProvider { /** * Execute a callback with a valid OAuth token for the given provider. + * Providers that manage their own auth (e.g. Telegram with a bot token) + * expose isConnected() and don't need an OAuth access_token lookup. */ export async function withProviderToken( provider: MessagingProvider, fn: (token: string) => Promise, ): Promise { + if (provider.isConnected?.()) { + return fn(''); + } return withValidToken(provider.credentialService, fn); } diff --git a/assistant/src/messaging/providers/telegram-bot/adapter.ts b/assistant/src/messaging/providers/telegram-bot/adapter.ts index cb293a36915..93d7cc2cd7f 100644 --- a/assistant/src/messaging/providers/telegram-bot/adapter.ts +++ b/assistant/src/messaging/providers/telegram-bot/adapter.ts @@ -74,26 +74,35 @@ export const telegramBotMessagingProvider: MessagingProvider = { }; } - const resp = await telegram.getMe(botToken); - if (!resp.ok || !resp.result) { + try { + const resp = await telegram.getMe(botToken); + if (!resp.ok || !resp.result) { + return { + connected: false, + user: 'unknown', + platform: 'telegram', + metadata: { error: resp.description ?? 'getMe failed' }, + }; + } + + return { + connected: true, + user: resp.result.username ?? resp.result.first_name, + platform: 'telegram', + metadata: { + botId: resp.result.id, + botUsername: resp.result.username, + botName: resp.result.first_name, + }, + }; + } catch (e) { return { connected: false, user: 'unknown', platform: 'telegram', - metadata: { error: resp.description ?? 'getMe failed' }, + metadata: { error: e instanceof Error ? e.message : 'getMe failed' }, }; } - - return { - connected: true, - user: resp.result.username ?? resp.result.first_name, - platform: 'telegram', - metadata: { - botId: resp.result.id, - botUsername: resp.result.username, - botName: resp.result.first_name, - }, - }; }, async sendMessage(_token: string, conversationId: string, text: string, _options?: SendOptions): Promise { From e466e7b34e8be9ca51be0d6768317cc59d3eeb25 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 21 Feb 2026 16:33:59 -0500 Subject: [PATCH 09/12] fix: always reconcile webhook and normalize ingress URL (#6245) Co-authored-by: Claude --- .../telegram-webhook-manager.test.ts | 23 +++++++------ gateway/src/index.ts | 2 +- gateway/src/telegram/webhook-manager.ts | 34 ++++++------------- 3 files changed, 24 insertions(+), 35 deletions(-) diff --git a/gateway/src/__tests__/telegram-webhook-manager.test.ts b/gateway/src/__tests__/telegram-webhook-manager.test.ts index 8cf2839baa3..16198a79332 100644 --- a/gateway/src/__tests__/telegram-webhook-manager.test.ts +++ b/gateway/src/__tests__/telegram-webhook-manager.test.ts @@ -82,7 +82,7 @@ describe("reconcileTelegramWebhook", () => { expect((calls[1].body as any).allowed_updates).toEqual(["message", "edited_message"]); }); - test("does not call setWebhook when URL already matches", async () => { + test("always calls setWebhook even when URL already matches (secret may have rotated)", async () => { const calls: string[] = []; globalThis.fetch = mock(async (input: string | URL | Request) => { @@ -105,33 +105,36 @@ describe("reconcileTelegramWebhook", () => { const config = makeConfig(); await reconcileTelegramWebhook(config); - expect(calls).toEqual(["getWebhookInfo"]); + expect(calls).toEqual(["getWebhookInfo", "setWebhook"]); }); - test("calls setWebhook when secret may have changed (forceUpdate)", async () => { - const calls: string[] = []; + test("normalizes trailing slash on ingress base URL", async () => { + const calls: { method: string; body: unknown }[] = []; globalThis.fetch = mock(async (input: string | URL | Request) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; if (url.includes("/getWebhookInfo")) { - calls.push("getWebhookInfo"); + calls.push({ method: "getWebhookInfo", body: null }); return makeTelegramResponse({ - url: "https://example.ngrok.io/webhooks/telegram", + url: "", has_custom_certificate: false, pending_update_count: 0, }); } if (url.includes("/setWebhook")) { - calls.push("setWebhook"); + const req = typeof input === "object" && "json" in input ? input : null; + const body = req ? await (req as Request).json() : null; + calls.push({ method: "setWebhook", body }); return makeTelegramResponse(true); } return new Response("Not found", { status: 404 }); }) as any; - const config = makeConfig(); - await reconcileTelegramWebhook(config, { forceUpdate: true }); + const config = makeConfig({ ingressPublicBaseUrl: "https://example.ngrok.io/" }); + await reconcileTelegramWebhook(config); - expect(calls).toEqual(["getWebhookInfo", "setWebhook"]); + expect(calls).toHaveLength(2); + expect((calls[1].body as any).url).toBe("https://example.ngrok.io/webhooks/telegram"); }); test("skips reconciliation when bot token is not configured", async () => { diff --git a/gateway/src/index.ts b/gateway/src/index.ts index d3283fbaf46..7a22ba7ac19 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -143,7 +143,7 @@ function main() { config.telegramWebhookSecret = credentials.webhookSecret; log.info("Telegram credentials loaded from credential vault"); registerTelegramCommands(); - reconcileTelegramWebhook(config, { forceUpdate: true }).catch((err) => { + reconcileTelegramWebhook(config).catch((err) => { log.error({ err }, "Failed to reconcile Telegram webhook after credential change"); }); } else { diff --git a/gateway/src/telegram/webhook-manager.ts b/gateway/src/telegram/webhook-manager.ts index f97b772aea6..e158ce48643 100644 --- a/gateway/src/telegram/webhook-manager.ts +++ b/gateway/src/telegram/webhook-manager.ts @@ -17,16 +17,13 @@ const ALLOWED_UPDATES = ["message", "edited_message"]; * Reconciles the Telegram webhook registration against the expected state * derived from the gateway's ingress URL and current webhook secret. * - * If the currently registered webhook URL differs from the expected URL, - * or if the secret may have changed (we always re-set when the URL matches - * but we can't verify the secret from getWebhookInfo), the webhook is - * re-registered via setWebhook. - * - * This is safe to call repeatedly; Telegram treats setWebhook as idempotent. + * Always calls setWebhook because Telegram does not expose the current + * secret_token via getWebhookInfo — a secret rotation with an unchanged URL + * would be invisible to us, causing all deliveries to fail with 401. + * setWebhook is idempotent, so calling it unconditionally is safe. */ export async function reconcileTelegramWebhook( config: GatewayConfig, - options?: { forceUpdate?: boolean }, ): Promise { if (!config.telegramBotToken || !config.telegramWebhookSecret) { log.debug("Skipping webhook reconciliation: Telegram credentials not configured"); @@ -38,31 +35,20 @@ export async function reconcileTelegramWebhook( return; } - const expectedUrl = `${config.ingressPublicBaseUrl}/webhooks/telegram`; + // Strip trailing slashes to avoid double-slash in the path + // (e.g. "https://example.com/" + "/webhooks/telegram" => "https://example.com//webhooks/telegram") + const baseUrl = config.ingressPublicBaseUrl.replace(/\/+$/, ""); + const expectedUrl = `${baseUrl}/webhooks/telegram`; const info = await callTelegramApi(config, "getWebhookInfo", {}); - const urlMatches = info.url === expectedUrl; - - // Telegram does not expose the current secret_token via getWebhookInfo, - // so we cannot compare it directly. When credentials are refreshed - // (forceUpdate), we always re-set to ensure the secret is current. - if (urlMatches && !options?.forceUpdate) { - log.info( - { currentUrl: info.url, expectedUrl }, - "Telegram webhook URL matches expected state, no update needed", - ); - return; - } - log.info( { currentUrl: info.url || "(none)", expectedUrl, - forceUpdate: !!options?.forceUpdate, - urlMatches, + urlMatches: info.url === expectedUrl, }, - "Telegram webhook state differs from expected, updating", + "Reconciling Telegram webhook", ); await callTelegramApi(config, "setWebhook", { From aa23f594c37e6e30b73d9d625ba8e0b0d0dab9b9 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 21 Feb 2026 17:28:13 -0500 Subject: [PATCH 10/12] fix: resolve gateway lint error and credential security allowlist for Telegram adapter (#6257) Co-authored-by: Claude --- assistant/src/__tests__/credential-security-invariants.test.ts | 1 + gateway/src/__tests__/twilio-webhooks.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/assistant/src/__tests__/credential-security-invariants.test.ts b/assistant/src/__tests__/credential-security-invariants.test.ts index e943b97e418..e2979f5f48b 100644 --- a/assistant/src/__tests__/credential-security-invariants.test.ts +++ b/assistant/src/__tests__/credential-security-invariants.test.ts @@ -189,6 +189,7 @@ describe('Invariant 2: no generic plaintext secret read API', () => { 'calls/twilio-provider.ts', // call infrastructure credential lookup 'runtime/http-server.ts', // HTTP server credential lookup 'daemon/handlers/twitter-auth.ts', // Twitter OAuth token storage + 'messaging/providers/telegram-bot/adapter.ts', // Telegram bot token lookup for connectivity check ]); const thisDir = dirname(fileURLToPath(import.meta.url)); diff --git a/gateway/src/__tests__/twilio-webhooks.test.ts b/gateway/src/__tests__/twilio-webhooks.test.ts index f725af60a34..a20c42781aa 100644 --- a/gateway/src/__tests__/twilio-webhooks.test.ts +++ b/gateway/src/__tests__/twilio-webhooks.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, mock, afterEach, beforeEach } from "bun:test"; +import { describe, test, expect, mock, afterEach } from "bun:test"; import { createHmac } from "node:crypto"; import type { GatewayConfig } from "../config.js"; import { createTwilioVoiceWebhookHandler } from "../http/routes/twilio-voice-webhook.js"; From 335ea22de65474568615d09f0df6f79aa908aacd Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 21 Feb 2026 17:31:07 -0500 Subject: [PATCH 11/12] fix: require webhook_secret in Telegram isConnected check (#6259) Co-authored-by: Claude --- assistant/src/messaging/providers/telegram-bot/adapter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/assistant/src/messaging/providers/telegram-bot/adapter.ts b/assistant/src/messaging/providers/telegram-bot/adapter.ts index 93d7cc2cd7f..cfae614e72f 100644 --- a/assistant/src/messaging/providers/telegram-bot/adapter.ts +++ b/assistant/src/messaging/providers/telegram-bot/adapter.ts @@ -58,9 +58,13 @@ export const telegramBotMessagingProvider: MessagingProvider = { * credential:telegram:access_token, but the Telegram bot token is * stored as credential:telegram:bot_token. This method lets the * registry detect that Telegram credentials exist. + * + * Both bot_token and webhook_secret are required — the gateway's + * /deliver/telegram endpoint rejects requests without the webhook + * secret, so partial credentials would cause every send to fail. */ isConnected(): boolean { - return getBotToken() !== undefined; + return getBotToken() !== undefined && !!getSecureKey('credential:telegram:webhook_secret'); }, async testConnection(_token: string): Promise { From 43db96bbc63d0accf730434a3b7c9ebc4d253dfe Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 21 Feb 2026 17:31:15 -0500 Subject: [PATCH 12/12] fix: only default routing policy in single-assistant deployments (#6261) Co-authored-by: Claude --- cli/src/lib/local.ts | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/cli/src/lib/local.ts b/cli/src/lib/local.ts index 315671ac3e8..0ab1807ae2c 100644 --- a/cli/src/lib/local.ts +++ b/cli/src/lib/local.ts @@ -4,7 +4,7 @@ import { createRequire } from "module"; import { homedir } from "os"; import { dirname, join } from "path"; -import { loadLatestAssistant } from "./assistant-config.js"; +import { loadAllAssistants, loadLatestAssistant } from "./assistant-config.js"; import { GATEWAY_PORT } from "./constants.js"; const _require = createRequire(import.meta.url); @@ -287,21 +287,32 @@ export async function startGateway(): Promise { console.log("🌐 Starting gateway..."); const gatewayDir = resolveGatewayDir(); - // Auto-configure routing for single-assistant local deployments so that - // inbound Telegram messages are forwarded without manual env var setup. - const defaultAssistantId = - process.env.GATEWAY_DEFAULT_ASSISTANT_ID - || loadLatestAssistant()?.assistantId - || "default"; + // Only auto-configure default routing when the workspace has exactly one + // assistant. In multi-assistant deployments, falling back to "default" + // would silently deliver unmapped Telegram chats to whichever assistant was + // most recently hatched — keep the "reject" policy instead. + const assistants = loadAllAssistants(); + const isSingleAssistant = assistants.length === 1; const gatewayEnv: Record = { ...process.env as Record, GATEWAY_RUNTIME_PROXY_ENABLED: "true", GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "false", RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821", - GATEWAY_UNMAPPED_POLICY: process.env.GATEWAY_UNMAPPED_POLICY || "default", - GATEWAY_DEFAULT_ASSISTANT_ID: defaultAssistantId, }; + + if (process.env.GATEWAY_UNMAPPED_POLICY) { + gatewayEnv.GATEWAY_UNMAPPED_POLICY = process.env.GATEWAY_UNMAPPED_POLICY; + } else if (isSingleAssistant) { + gatewayEnv.GATEWAY_UNMAPPED_POLICY = "default"; + } + + if (process.env.GATEWAY_DEFAULT_ASSISTANT_ID) { + gatewayEnv.GATEWAY_DEFAULT_ASSISTANT_ID = process.env.GATEWAY_DEFAULT_ASSISTANT_ID; + } else if (isSingleAssistant) { + gatewayEnv.GATEWAY_DEFAULT_ASSISTANT_ID = + assistants[0].assistantId || loadLatestAssistant()?.assistantId || "default"; + } const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl(); const ingressPublicBaseUrl = workspaceIngressPublicBaseUrl