From 3819f040bed42c49e73bff6678427beeeca5c339 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Sat, 5 Sep 2026 00:42:58 +0300 Subject: [PATCH] feat(integrations): move Telegram credentials into the hub Telegram becomes the hub's second tenant, so an operator setting up a new install has one place to paste every key rather than needing to know which service happens to own a tab. Only the credential moves. Pairing, the owner id, start/stop and the live chat view stay on the Telegram tab: those act on a running channel rather than configuring one, and folding a pairing countdown into a credential list would make both surfaces worse. The hub owns "what is my token", the tab owns "what is the bot doing" -- and the hub's summary line points at the tab so the split is discoverable rather than something to work out. The bot token is validated at entry against the BotFather shape the scrubber already keys off (<6..12 digits>:<>=30 chars>), which turns a truncated paste from a silent "channel won't start" into an immediate, specific complaint. Status is live: `IntegrationStatusContext` gains `channelStates` alongside `mcpServerStates`, so a saved-but-idle token reads differently from a running channel and differently again from one that failed to start. A channel that is merely disabled is reported as configured, not as an error -- that is a normal resting state. Telegram resolves its token at construction, so it declares `appliesLive: false` and the pane says a restart is needed instead of leaving the operator to guess why nothing happened. Verified end-to-end: a malformed token is refused with a readable message and nothing is written; a well-formed one saves, masks, shows "token saved -- pair and enable on the Telegram tab", lands in /.env, and is visible in process.env for the Telegram tab's own has-token check without a restart. --- AGENTS.md | 1 + src/integrations/index.ts | 1 + src/integrations/integration-descriptor.ts | 6 ++ src/integrations/integration-registry.ts | 3 +- src/integrations/telegram-integration.test.ts | 63 ++++++++++++++++ src/integrations/telegram-integration.ts | 73 +++++++++++++++++++ .../integrations/integrations-orchestrator.ts | 7 ++ 7 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 src/integrations/telegram-integration.test.ts create mode 100644 src/integrations/telegram-integration.ts diff --git a/AGENTS.md b/AGENTS.md index 3408838d..eae9ce14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1780,6 +1780,7 @@ Locked invariants (pinned by [src/integrations/integration-secrets.test.ts](src/ 4. **Edit mode swallows the whole keyboard.** `d`, `e` and `r` are bindings on this tab; inside the editor they are key material. A paste that silently triggered "clear field" halfway through would be both baffling and destructive. 5. **A re-sync never yanks the cursor.** Rows are re-read on every refresh; the reducer clamps the selection instead of resetting it, so a background refresh cannot move the operator's place mid-edit. 6. **Changing a Composio key drops the cached tool-router session.** A session belongs to the key that created it; reusing it across a key swap would keep talking to the old account. The orchestrator unmounts, clears the cache, re-resolves, and remounts live. +7. **The hub owns credentials; feature tabs keep operations.** Telegram's bot token lives here, but pairing, the owner id, start/stop and the live chat view stay on the Telegram tab — those act on a running channel rather than configuring one, and a pairing countdown inside a credential list would make both surfaces worse. An integration whose credential is read at construction sets `appliesLive: false` and the pane says a restart is needed instead of leaving the operator to guess. ## Composio (hosted toolkits) diff --git a/src/integrations/index.ts b/src/integrations/index.ts index f2e36ada..6676289c 100644 --- a/src/integrations/index.ts +++ b/src/integrations/index.ts @@ -20,3 +20,4 @@ export { } from "./integration-secrets.js"; export { findIntegration, listIntegrations } from "./integration-registry.js"; export { composioIntegration } from "./composio-integration.js"; +export { telegramIntegration } from "./telegram-integration.js"; diff --git a/src/integrations/integration-descriptor.ts b/src/integrations/integration-descriptor.ts index 752efdd1..742cd3f1 100644 --- a/src/integrations/integration-descriptor.ts +++ b/src/integrations/integration-descriptor.ts @@ -62,6 +62,12 @@ export interface IntegrationStatusContext { * one. Absent when the runtime is not available (e.g. in tests). */ mcpServerStates?: ReadonlyMap; + /** + * Live channel states by channel name (`ChannelState` values), for + * integrations that run one — Telegram today. Absent when the + * runtime is not available (e.g. in tests). + */ + channelStates?: ReadonlyMap; } export interface IntegrationDescriptor { diff --git a/src/integrations/integration-registry.ts b/src/integrations/integration-registry.ts index d2c05890..43e41b1b 100644 --- a/src/integrations/integration-registry.ts +++ b/src/integrations/integration-registry.ts @@ -8,11 +8,12 @@ */ import { composioIntegration } from "./composio-integration.js"; +import { telegramIntegration } from "./telegram-integration.js"; import type { IntegrationDescriptor } from "./integration-descriptor.js"; /** Every known integration, in display order. */ export function listIntegrations(): readonly IntegrationDescriptor[] { - return [composioIntegration]; + return [composioIntegration, telegramIntegration]; } /** Look one up by id. `undefined` when nothing matches. */ diff --git a/src/integrations/telegram-integration.test.ts b/src/integrations/telegram-integration.test.ts new file mode 100644 index 00000000..3ebbcea1 --- /dev/null +++ b/src/integrations/telegram-integration.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { telegramIntegration } from "./telegram-integration.js"; +import { TELEGRAM_BOT_TOKEN_KEY } from "../channels/telegram/index.js"; + +const TOKEN = "botToken"; +const VALID = `123456789:${"A".repeat(35)}`; + +function ctx(present: string[], channel?: string) { + return { + presentFields: new Set(present), + configured: present.includes(TOKEN), + ...(channel === undefined + ? {} + : { channelStates: new Map([["telegram", channel]]) }), + }; +} + +describe("telegramIntegration", () => { + it("owns only the credential, and says where the rest lives", () => { + // Pairing, owner id and start/stop stay on the Telegram tab; folding + // a pairing countdown into a credential list would make both worse. + expect(telegramIntegration.fields).toHaveLength(1); + expect(telegramIntegration.fields[0]?.envVar).toBe(TELEGRAM_BOT_TOKEN_KEY); + expect(telegramIntegration.summary).toMatch(/Telegram tab/); + }); + + it("reads an absent token as not configured", () => { + expect(telegramIntegration.status(ctx([])).level).toBe("not_configured"); + }); + + it("distinguishes a saved token from a running channel", () => { + const saved = telegramIntegration.status(ctx([TOKEN])); + expect(saved.level).toBe("configured"); + expect(saved.detail).toMatch(/pair and enable/); + expect(telegramIntegration.status(ctx([TOKEN], "up"))).toEqual({ + level: "connected", + detail: "channel up", + }); + }); + + it("does not badge a disabled channel as an error", () => { + // A token saved with the channel off is a normal resting state. + expect(telegramIntegration.status(ctx([TOKEN], "disabled")).level).toBe( + "configured", + ); + expect(telegramIntegration.status(ctx([TOKEN], "down")).level).toBe("error"); + }); + + it("rejects a token that is not BotFather-shaped", () => { + const validate = telegramIntegration.fields[0]?.validate; + expect(validate?.(VALID)).toBeUndefined(); + expect(validate?.("not-a-token")).toMatch(/bot token/); + // Right shape, secret too short -- the common truncated-paste case. + expect(validate?.("123456789:short")).toMatch(/bot token/); + }); + + it("says changes need a restart", () => { + // The channel resolves its token at construction, so a new token + // does not take effect until the next boot -- the pane must say so. + expect(telegramIntegration.appliesLive).toBe(false); + }); +}); diff --git a/src/integrations/telegram-integration.ts b/src/integrations/telegram-integration.ts new file mode 100644 index 00000000..71509df3 --- /dev/null +++ b/src/integrations/telegram-integration.ts @@ -0,0 +1,73 @@ +/** + * Telegram as an Integrations-hub tenant. + * + * Only the **credential** moves here. Pairing, the owner id, start/stop + * and the live chat view stay on the Telegram tab: those are operations + * on a running channel, not configuration, and folding a pairing + * countdown into a credential list would make both worse. The hub owns + * "what is my token", the tab owns "what is the bot doing". + */ + +import { TELEGRAM_BOT_TOKEN_KEY } from "../channels/telegram/index.js"; +import type { + IntegrationDescriptor, + IntegrationStatus, + IntegrationStatusContext, +} from "./integration-descriptor.js"; +import { isConfigured } from "./integration-descriptor.js"; + +const TOKEN_FIELD = "botToken"; + +/** + * A Telegram bot token is `<6..12 digits>:<>=30 [A-Za-z0-9_-] chars>` — + * the same shape `scrubErrorMessage` keys off. Checking it at entry + * turns a silent "channel won't start" into an immediate, specific + * complaint about the paste. + */ +const TOKEN_SHAPE = /^\d{6,12}:[A-Za-z0-9_-]{30,}$/; + +export const telegramIntegration: IntegrationDescriptor = { + id: "telegram", + label: "Telegram", + summary: "Drive the agent from Telegram — pair and start from the Telegram tab", + docsUrl: "https://core.telegram.org/bots#botfather", + appliesLive: false, + fields: [ + { + key: TOKEN_FIELD, + label: "Bot token", + envVar: TELEGRAM_BOT_TOKEN_KEY, + secret: true, + required: true, + help: "From @BotFather. Pair the owner account on the Telegram tab.", + validate: (raw) => + TOKEN_SHAPE.test(raw) + ? undefined + : "Doesn't look like a bot token — expected digits, a colon, then a long string, as @BotFather issues it.", + }, + ], + status(ctx: IntegrationStatusContext): IntegrationStatus { + if (!isConfigured(telegramIntegration, ctx.presentFields)) { + return { level: "not_configured", detail: "no bot token" }; + } + switch (ctx.channelStates?.get("telegram")) { + case "up": + return { level: "connected", detail: "channel up" }; + case "down": + return { + level: "error", + detail: "channel failed to start — see the Telegram tab", + }; + case "starting": + return { level: "configured", detail: "channel starting" }; + default: + // Token present but the channel is off or unpaired. That is a + // normal resting state, not an error -- point at the tab that + // can fix it rather than badging it red. + return { + level: "configured", + detail: "token saved — pair and enable on the Telegram tab", + }; + } + }, +}; diff --git a/src/tui/integrations/integrations-orchestrator.ts b/src/tui/integrations/integrations-orchestrator.ts index 09cdccfe..1b6c1a43 100644 --- a/src/tui/integrations/integrations-orchestrator.ts +++ b/src/tui/integrations/integrations-orchestrator.ts @@ -43,6 +43,12 @@ export class IntegrationsOrchestrator { for (const status of this.runtime.mcpManager.listStatuses()) { mcpServerStates.set(status.name, status.state); } + // Channel-backed integrations (Telegram) report liveness the same + // way, so a token that is saved but not running reads differently + // from one that is. + const channelStates = new Map(); + const telegram = this.runtime.telegramChannel; + if (telegram) channelStates.set("telegram", telegram.state()); return listIntegrations().map((descriptor) => { const present = presentFieldKeys(descriptor); const status = descriptor.status({ @@ -51,6 +57,7 @@ export class IntegrationsOrchestrator { .filter((f) => f.required) .every((f) => present.has(f.key)), mcpServerStates, + channelStates, }); const fields: IntegrationFieldRow[] = descriptor.fields.map((field) => ({ key: field.key,