Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions src/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
6 changes: 6 additions & 0 deletions src/integrations/integration-descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ export interface IntegrationStatusContext {
* one. Absent when the runtime is not available (e.g. in tests).
*/
mcpServerStates?: ReadonlyMap<string, string>;
/**
* 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<string, string>;
}

export interface IntegrationDescriptor {
Expand Down
3 changes: 2 additions & 1 deletion src/integrations/integration-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
63 changes: 63 additions & 0 deletions src/integrations/telegram-integration.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
73 changes: 73 additions & 0 deletions src/integrations/telegram-integration.ts
Original file line number Diff line number Diff line change
@@ -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",
};
}
},
};
7 changes: 7 additions & 0 deletions src/tui/integrations/integrations-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
const telegram = this.runtime.telegramChannel;
if (telegram) channelStates.set("telegram", telegram.state());
return listIntegrations().map((descriptor) => {
const present = presentFieldKeys(descriptor);
const status = descriptor.status({
Expand All @@ -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,
Expand Down