Skip to content
Open
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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,11 @@ Connect messaging apps during the wizard or later:
```bash
gsv channel whatsapp login # Scan QR code
gsv channel discord start # Start Discord bot
gsv channel telegram start # Start Telegram bot
```

> [!NOTE]
> Both WhatsApp and Discord channels require an always-on Durable Object to run. While the Workers free tier fits 1 always-on DO, having multiple channels or multiple accounts in a single channel will require a paid plan (or you'll experience downtime).
> All channels use Durable Object state. WhatsApp and Discord keep persistent gateway connections (effectively always-on), while Telegram is webhook-driven and wakes on inbound/outbound traffic. On the free Workers tier, running multiple always-on channel accounts may require a paid plan.


## Architecture
Expand Down Expand Up @@ -127,7 +128,7 @@ gsv channel discord start # Start Discord bot
- **Gateway** - Central brain running on Cloudflare. Routes messages, manages tools, stores config.
- **Sessions** - Each conversation is a Durable Object with persistent history and its own agent loop.
- **Nodes** - Your devices running the CLI, providing tools (Bash, Read, Write, Edit, Glob, Grep).
- **Channels** - Bridges to WhatsApp, Discord, etc. Each runs as a separate Worker.
- **Channels** - Bridges to WhatsApp, Discord, Telegram, etc. Each runs as a separate Worker.

## Tool Namespacing

Expand Down Expand Up @@ -189,6 +190,8 @@ gsv channel whatsapp login # Connect WhatsApp
gsv channel whatsapp logout # Disconnect
gsv channel discord start # Start Discord bot
gsv channel discord stop # Stop bot
gsv channel telegram start # Start Telegram bot
gsv channel telegram stop # Stop bot

# Access control
gsv pair list # List pending pair requests
Expand Down
48 changes: 48 additions & 0 deletions channels/telegram/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# GSV Telegram Channel

Telegram bot integration for GSV Gateway using the Telegram Bot API webhook flow.

## Outbound Media

- Supports outbound attachments for `image`, `video`, `audio`, and `document`.
- Supports media groups (albums) with 2-10 attachments.
- Telegram rule is enforced: groups containing `audio` must be all-audio, and groups containing `document` must be all-document.
- Attachment source can be `url` or base64 `data`.
- If `text` is present, it is sent as caption (for groups, caption is applied to the first item).

## Required Secrets

Set these on the channel worker:

- `TELEGRAM_BOT_TOKEN` -- bot token from BotFather
- `TELEGRAM_WEBHOOK_BASE_URL` -- public base URL for this worker (for example `https://gsv-channel-telegram.<subdomain>.workers.dev`)

## Usage

Start the account:

```bash
gsv channel telegram start
```

Check status:

```bash
gsv channel telegram status
```

Stop and delete webhook:

```bash
gsv channel telegram stop
```

## Webhook Endpoint

Telegram updates are received on:

```text
POST /webhook/:accountId
```

The worker verifies `X-Telegram-Bot-Api-Secret-Token` before forwarding messages to the Gateway via Service Binding RPC.
16 changes: 16 additions & 0 deletions channels/telegram/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "@gsv/channel-telegram",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250109.0",
"typescript": "^5.7.3",
"wrangler": "^3.101.0"
}
}
269 changes: 269 additions & 0 deletions channels/telegram/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
import { WorkerEntrypoint } from "cloudflare:workers";
import type {
ChannelAccountStatus,
ChannelCapabilities,
ChannelOutboundMessage,
ChannelPeer,
ChannelWorkerInterface,
SendResult,
StartResult,
StopResult,
} from "./types";

export { TelegramAccount } from "./telegram-account";
export type * from "./types";

interface Env {
TELEGRAM_ACCOUNT: DurableObjectNamespace;
TELEGRAM_BOT_TOKEN?: string;
TELEGRAM_WEBHOOK_BASE_URL?: string;
TELEGRAM_WEBHOOK_SECRET?: string;
}

type WebhookResult = { ok: boolean; status?: number; error?: string };

type TelegramAccountStub = {
start(
botToken: string,
accountId: string,
webhookBaseUrl: string,
webhookSecret?: string,
): Promise<void>;
stop(): Promise<void>;
getStatus(): Promise<ChannelAccountStatus>;
sendMessage(
message: ChannelOutboundMessage,
): Promise<{ ok: boolean; messageId?: string; error?: string }>;
setTyping(peer: ChannelPeer, typing: boolean): Promise<void>;
handleWebhook(update: unknown, secretToken: string | null): Promise<WebhookResult>;
};

function accountFromPath(pathname: string): string | null {
const match = pathname.match(/^\/webhook\/([^/]+)$/);
if (!match) {
return null;
}

try {
return decodeURIComponent(match[1]);
} catch {
return null;
}
}

function toJsonError(message: string, status = 500): Response {
return Response.json({ ok: false, error: message }, { status });
}

export class TelegramChannel
extends WorkerEntrypoint<Env>
implements ChannelWorkerInterface
{
readonly channelId = "telegram";

readonly capabilities: ChannelCapabilities = {
chatTypes: ["dm", "group", "channel"],
media: true,
reactions: false,
threads: false,
typing: true,
editing: false,
deletion: false,
};

async start(
accountId: string,
config: Record<string, unknown>,
): Promise<StartResult> {
const botToken =
(typeof config.botToken === "string" ? config.botToken : undefined) ||
this.env.TELEGRAM_BOT_TOKEN;
const webhookBaseUrl =
(typeof config.webhookBaseUrl === "string"
? config.webhookBaseUrl
: undefined) || this.env.TELEGRAM_WEBHOOK_BASE_URL;
const webhookSecret =
(typeof config.webhookSecret === "string" ? config.webhookSecret : undefined) ||
this.env.TELEGRAM_WEBHOOK_SECRET;

if (!botToken) {
return {
ok: false,
error: "No Telegram bot token provided (set TELEGRAM_BOT_TOKEN or pass config.botToken)",
};
}

if (!webhookBaseUrl) {
return {
ok: false,
error:
"No webhook base URL provided (set TELEGRAM_WEBHOOK_BASE_URL or pass config.webhookBaseUrl)",
};
}

try {
const account = this.getAccountDO(accountId);
await account.start(botToken, accountId, webhookBaseUrl, webhookSecret);
return { ok: true };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}

async stop(accountId: string): Promise<StopResult> {
try {
const account = this.getAccountDO(accountId);
await account.stop();
return { ok: true };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}

async status(accountId?: string): Promise<ChannelAccountStatus[]> {
if (!accountId) {
// Account listing is not tracked yet.
return [];
}

try {
const account = this.getAccountDO(accountId);
return [await account.getStatus()];
} catch (error) {
return [
{
accountId,
connected: false,
authenticated: false,
mode: "webhook",
error: error instanceof Error ? error.message : String(error),
},
];
}
}

async send(accountId: string, message: ChannelOutboundMessage): Promise<SendResult> {
try {
const account = this.getAccountDO(accountId);
const result = await account.sendMessage(message);
if (!result.ok) {
return { ok: false, error: result.error || "Failed to send Telegram message" };
}
return { ok: true, messageId: result.messageId };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}

async setTyping(accountId: string, peer: ChannelPeer, typing: boolean): Promise<void> {
try {
const account = this.getAccountDO(accountId);
await account.setTyping(peer, typing);
} catch (error) {
console.warn(`[TelegramChannel] setTyping failed for ${accountId}:`, error);
}
}

private getAccountDO(accountId: string): TelegramAccountStub {
const id = this.env.TELEGRAM_ACCOUNT.idFromName(accountId);
return this.env.TELEGRAM_ACCOUNT.get(id) as unknown as TelegramAccountStub;
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === "/" || url.pathname === "/health") {
return Response.json({
service: "gsv-channel-telegram",
status: "ok",
hasBotToken: !!env.TELEGRAM_BOT_TOKEN,
hasWebhookBaseUrl: !!env.TELEGRAM_WEBHOOK_BASE_URL,
});
}

if (request.method === "POST") {
const accountId = accountFromPath(url.pathname);
if (accountId) {
const id = env.TELEGRAM_ACCOUNT.idFromName(accountId);
const account = env.TELEGRAM_ACCOUNT.get(id) as unknown as TelegramAccountStub;

let updatePayload: unknown;
try {
updatePayload = await request.json();
} catch {
return toJsonError("Invalid JSON payload", 400);
}

const secretToken = request.headers.get("X-Telegram-Bot-Api-Secret-Token");
const result = await account.handleWebhook(updatePayload, secretToken);
if (!result.ok) {
return toJsonError(
result.error || "Failed to handle Telegram webhook",
result.status || 500,
);
}

return Response.json({ ok: true });
}

if (url.pathname === "/start") {
const accountId = url.searchParams.get("accountId") || "default";
const botToken = env.TELEGRAM_BOT_TOKEN;
const webhookBaseUrl = env.TELEGRAM_WEBHOOK_BASE_URL;
if (!botToken || !webhookBaseUrl) {
return toJsonError(
"TELEGRAM_BOT_TOKEN and TELEGRAM_WEBHOOK_BASE_URL are required",
400,
);
}
const id = env.TELEGRAM_ACCOUNT.idFromName(accountId);
const account = env.TELEGRAM_ACCOUNT.get(id) as unknown as TelegramAccountStub;
try {
await account.start(
botToken,
accountId,
webhookBaseUrl,
env.TELEGRAM_WEBHOOK_SECRET,
);
return Response.json({ ok: true });
} catch (error) {
return toJsonError(
error instanceof Error ? error.message : String(error),
500,
);
}
}

if (url.pathname === "/stop") {
const accountId = url.searchParams.get("accountId") || "default";
const id = env.TELEGRAM_ACCOUNT.idFromName(accountId);
const account = env.TELEGRAM_ACCOUNT.get(id) as unknown as TelegramAccountStub;
try {
await account.stop();
return Response.json({ ok: true });
} catch (error) {
return toJsonError(
error instanceof Error ? error.message : String(error),
500,
);
}
}
}

if (request.method === "GET" && url.pathname === "/status") {
const accountId = url.searchParams.get("accountId") || "default";
const id = env.TELEGRAM_ACCOUNT.idFromName(accountId);
const account = env.TELEGRAM_ACCOUNT.get(id) as unknown as TelegramAccountStub;
try {
const status = await account.getStatus();
return Response.json(status);
} catch (error) {
return toJsonError(error instanceof Error ? error.message : String(error), 500);
}
}

return new Response("Not Found", { status: 404 });
},
};
Loading