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,