From fe9285351bb402f91bacd8cd7d6cb6e594a0c35e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:52:00 +0100 Subject: [PATCH] feat: add Telegram connector package for mesh-to-chat bridging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces @claudemesh/connector-telegram — a standalone bridge process that joins a mesh as peerType: "connector" and relays messages bidirectionally between a Telegram chat and mesh peers via long polling. Co-Authored-By: Claude Sonnet 4.6 --- packages/connector-telegram/README.md | 79 ++++++ packages/connector-telegram/package.json | 20 ++ packages/connector-telegram/src/bridge.ts | 96 +++++++ packages/connector-telegram/src/config.ts | 32 +++ packages/connector-telegram/src/index.ts | 66 +++++ .../connector-telegram/src/mesh-client.ts | 259 ++++++++++++++++++ packages/connector-telegram/src/telegram.ts | 148 ++++++++++ packages/connector-telegram/tsconfig.json | 17 ++ 8 files changed, 717 insertions(+) create mode 100644 packages/connector-telegram/README.md create mode 100644 packages/connector-telegram/package.json create mode 100644 packages/connector-telegram/src/bridge.ts create mode 100644 packages/connector-telegram/src/config.ts create mode 100644 packages/connector-telegram/src/index.ts create mode 100644 packages/connector-telegram/src/mesh-client.ts create mode 100644 packages/connector-telegram/src/telegram.ts create mode 100644 packages/connector-telegram/tsconfig.json diff --git a/packages/connector-telegram/README.md b/packages/connector-telegram/README.md new file mode 100644 index 0000000..31f25cb --- /dev/null +++ b/packages/connector-telegram/README.md @@ -0,0 +1,79 @@ +# @claudemesh/connector-telegram + +Bridges a Telegram chat and a claudemesh mesh, relaying messages bidirectionally. Joins the mesh as `peerType: "connector"`, `channel: "telegram"`. + +## Setup + +### 1. Create a Telegram bot + +1. Open Telegram, search for **@BotFather** +2. Send `/newbot`, follow the prompts +3. Copy the bot token (e.g. `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`) + +### 2. Get the chat ID + +1. Add your bot to a group chat (or start a DM with it) +2. Send a message in the chat +3. Fetch updates to find the chat ID: + ```bash + curl https://api.telegram.org/bot/getUpdates | jq '.result[0].message.chat.id' + ``` + Group IDs are negative numbers (e.g. `-1001234567890`). DM IDs are positive. + +### 3. Get mesh credentials + +You need a claudemesh membership. Use the CLI to join a mesh and note the credentials, or check your mesh config file (`~/.config/claudemesh/config.json`). + +### 4. Configure environment variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | `123456:ABC-DEF...` | +| `TELEGRAM_CHAT_ID` | Target chat ID | `-1001234567890` | +| `BROKER_URL` | Broker WebSocket URL | `wss://ic.claudemesh.com/ws` | +| `MESH_ID` | Mesh UUID | `abc123-...` | +| `MEMBER_ID` | Member UUID | `def456-...` | +| `PUBKEY` | Ed25519 public key (hex) | `a1b2c3...` | +| `SECRET_KEY` | Ed25519 secret key (hex) | `d4e5f6...` | +| `DISPLAY_NAME` | Peer display name (optional) | `Telegram-DevChat` | + +### 5. Run + +```bash +# Build +npm run build + +# Start +TELEGRAM_BOT_TOKEN=... TELEGRAM_CHAT_ID=... BROKER_URL=wss://ic.claudemesh.com/ws \ + MESH_ID=... MEMBER_ID=... PUBKEY=... SECRET_KEY=... DISPLAY_NAME=Telegram-DevChat \ + npm start +``` + +Or with npx (once published): +```bash +TELEGRAM_BOT_TOKEN=... npx @claudemesh/connector-telegram +``` + +## How it works + +- **Telegram -> Mesh**: Text messages from Telegram are formatted as `[SenderName] message` and broadcast to all mesh peers. +- **Mesh -> Telegram**: Messages from mesh peers are formatted as `[PeerName] message` (HTML) and posted to the Telegram chat. +- Non-text messages (photos, stickers, etc.) are skipped with a log note. +- The connector uses long polling (no webhooks needed, no public URL required). +- Auto-reconnects to the mesh broker with exponential backoff. + +## Architecture + +``` +Telegram Chat <--long poll--> TelegramClient + | + Bridge (relay) + | +Mesh Broker <----WebSocket----> MeshClient +``` + +- `src/config.ts` — Configuration types and env loader +- `src/telegram.ts` — Telegram Bot API client (fetch + long polling) +- `src/mesh-client.ts` — Minimal claudemesh WS client (tweetnacl for ed25519 signing) +- `src/bridge.ts` — Bidirectional message relay +- `src/index.ts` — Entry point, wires everything together diff --git a/packages/connector-telegram/package.json b/packages/connector-telegram/package.json new file mode 100644 index 0000000..3ad7c2d --- /dev/null +++ b/packages/connector-telegram/package.json @@ -0,0 +1,20 @@ +{ + "name": "@claudemesh/connector-telegram", + "version": "0.1.0", + "description": "Telegram connector for claudemesh — relay messages between Telegram chats and mesh peers", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "ws": "^8.0.0", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1" + }, + "devDependencies": { + "@types/ws": "^8.0.0", + "typescript": "^5.0.0" + } +} diff --git a/packages/connector-telegram/src/bridge.ts b/packages/connector-telegram/src/bridge.ts new file mode 100644 index 0000000..74e4663 --- /dev/null +++ b/packages/connector-telegram/src/bridge.ts @@ -0,0 +1,96 @@ +/** + * Bidirectional bridge between Telegram and a claudemesh mesh. + * + * Telegram -> Mesh: incoming Telegram messages are formatted as + * "[TelegramUser] message" and broadcast to the mesh. + * + * Mesh -> Telegram: inbound mesh pushes are formatted as + * "[MeshPeerName] message" and posted to the Telegram chat. + */ + +import type { TelegramClient, TelegramMessage } from "./telegram.js"; +import type { MeshClient, InboundPush } from "./mesh-client.js"; + +export class Bridge { + constructor( + private telegram: TelegramClient, + private mesh: MeshClient, + ) {} + + /** Wire up both directions. Call once after both clients are connected. */ + start(): void { + // Telegram -> Mesh + this.telegram.onMessage((msg: TelegramMessage) => { + this.handleTelegramMessage(msg); + }); + + // Mesh -> Telegram + this.mesh.onPush((push: InboundPush) => { + this.handleMeshPush(push); + }); + + console.log("[bridge] relay active"); + } + + private handleTelegramMessage(msg: TelegramMessage): void { + if (!msg.text) { + // Skip non-text messages (photos, stickers, etc.) + const type = msg.from + ? "non-text content" + : "system message"; + console.log(`[bridge] skipping ${type} from Telegram`); + return; + } + + const senderName = formatTelegramSender(msg); + const meshMessage = `[${senderName}] ${msg.text}`; + + console.log(`[bridge] tg->mesh: ${meshMessage.slice(0, 80)}...`); + + // Broadcast to all mesh peers + this.mesh.send("*", meshMessage).catch((err) => { + console.error(`[bridge] failed to relay to mesh:`, err); + }); + } + + private handleMeshPush(push: InboundPush): void { + // Decode the message content + const plaintext = push.plaintext ?? tryDecodeBase64(push.ciphertext); + if (!plaintext) return; + + // Skip messages that originated from this connector (prevent echo) + if (push.senderPubkey === this.mesh.pubkey) return; + + // Find the sender's display name from the push metadata + const senderName = push.senderDisplayName || push.senderPubkey.slice(0, 8); + const telegramMessage = `[${escapeHtml(senderName)}] ${escapeHtml(plaintext)}`; + + console.log(`[bridge] mesh->tg: [${senderName}] ${plaintext.slice(0, 60)}...`); + + this.telegram.sendMessage(telegramMessage).catch((err) => { + console.error(`[bridge] failed to relay to Telegram:`, err); + }); + } +} + +function formatTelegramSender(msg: TelegramMessage): string { + if (!msg.from) return "Unknown"; + const parts = [msg.from.first_name]; + if (msg.from.last_name) parts.push(msg.from.last_name); + return parts.join(" "); +} + +function tryDecodeBase64(b64: string): string | null { + try { + return Buffer.from(b64, "base64").toString("utf-8"); + } catch { + return null; + } +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} diff --git a/packages/connector-telegram/src/config.ts b/packages/connector-telegram/src/config.ts new file mode 100644 index 0000000..37d030a --- /dev/null +++ b/packages/connector-telegram/src/config.ts @@ -0,0 +1,32 @@ +export interface TelegramConnectorConfig { + // Telegram + telegramBotToken: string; // from @BotFather + telegramChatId: string; // group chat or user chat ID + + // Mesh + brokerUrl: string; + meshId: string; + memberId: string; + pubkey: string; + secretKey: string; + displayName: string; // e.g. "Telegram-DevChat" +} + +export function loadConfigFromEnv(): TelegramConnectorConfig { + const required = (key: string): string => { + const val = process.env[key]; + if (!val) throw new Error(`Missing required env var: ${key}`); + return val; + }; + + return { + telegramBotToken: required("TELEGRAM_BOT_TOKEN"), + telegramChatId: required("TELEGRAM_CHAT_ID"), + brokerUrl: required("BROKER_URL"), + meshId: required("MESH_ID"), + memberId: required("MEMBER_ID"), + pubkey: required("PUBKEY"), + secretKey: required("SECRET_KEY"), + displayName: process.env.DISPLAY_NAME || "Telegram", + }; +} diff --git a/packages/connector-telegram/src/index.ts b/packages/connector-telegram/src/index.ts new file mode 100644 index 0000000..09ce215 --- /dev/null +++ b/packages/connector-telegram/src/index.ts @@ -0,0 +1,66 @@ +/** + * @claudemesh/connector-telegram — Entry point + * + * Bridges a Telegram chat and a claudemesh mesh, relaying messages + * bidirectionally. Joins the mesh as peerType: "connector", channel: "telegram". + * + * Configuration via environment variables: + * TELEGRAM_BOT_TOKEN — Bot token from @BotFather + * TELEGRAM_CHAT_ID — Target chat ID (group or user) + * BROKER_URL — claudemesh broker WebSocket URL + * MESH_ID — Mesh UUID + * MEMBER_ID — Member UUID + * PUBKEY — Ed25519 public key (hex) + * SECRET_KEY — Ed25519 secret key (hex) + * DISPLAY_NAME — Peer display name (default: "Telegram") + */ + +import { loadConfigFromEnv } from "./config.js"; +import { TelegramClient } from "./telegram.js"; +import { MeshClient } from "./mesh-client.js"; +import { Bridge } from "./bridge.js"; + +async function main(): Promise { + console.log("[connector-telegram] starting..."); + + // Load configuration + const config = loadConfigFromEnv(); + console.log(`[connector-telegram] display name: ${config.displayName}`); + console.log(`[connector-telegram] chat ID: ${config.telegramChatId}`); + console.log(`[connector-telegram] broker: ${config.brokerUrl}`); + + // Initialize clients + const telegram = new TelegramClient(config.telegramBotToken, config.telegramChatId); + const mesh = new MeshClient(config); + + // Connect to mesh broker + console.log("[connector-telegram] connecting to mesh..."); + await mesh.connect(); + console.log("[connector-telegram] mesh connected"); + + // Start Telegram long polling + telegram.start(); + console.log("[connector-telegram] Telegram polling started"); + + // Wire up bidirectional relay + const bridge = new Bridge(telegram, mesh); + bridge.start(); + + console.log("[connector-telegram] bridge active — relaying messages"); + + // Graceful shutdown + const shutdown = (): void => { + console.log("\n[connector-telegram] shutting down..."); + telegram.stop(); + mesh.close(); + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +main().catch((err) => { + console.error("[connector-telegram] fatal:", err); + process.exit(1); +}); diff --git a/packages/connector-telegram/src/mesh-client.ts b/packages/connector-telegram/src/mesh-client.ts new file mode 100644 index 0000000..9cf7b72 --- /dev/null +++ b/packages/connector-telegram/src/mesh-client.ts @@ -0,0 +1,259 @@ +/** + * Minimal WebSocket client for connecting to a claudemesh broker. + * Uses tweetnacl for ed25519 signing (hello handshake). + * Stripped down from apps/cli/src/ws/client.ts — hello + send/receive only. + */ + +import WebSocket from "ws"; +import nacl from "tweetnacl"; +import { decodeUTF8, encodeBase64 } from "tweetnacl-util"; +import type { TelegramConnectorConfig } from "./config.js"; + +export interface InboundPush { + messageId: string; + meshId: string; + senderPubkey: string; + senderDisplayName?: string; + priority: "now" | "next" | "low"; + nonce: string; + ciphertext: string; + createdAt: string; + receivedAt: string; + plaintext: string | null; + kind: "direct" | "broadcast" | "channel" | "unknown"; + subtype?: "reminder" | "system"; + event?: string; + eventData?: Record; +} + +type PushHandler = (msg: InboundPush) => void; + +const HELLO_ACK_TIMEOUT_MS = 5_000; +const BACKOFF_CAPS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000]; + +export class MeshClient { + private ws: WebSocket | null = null; + private pushHandlers = new Set(); + private closed = false; + private reconnectAttempt = 0; + private helloTimer: NodeJS.Timeout | null = null; + private reconnectTimer: NodeJS.Timeout | null = null; + private connected = false; + private outbound: Array<() => void> = []; + private peerNames = new Map(); // pubkey -> displayName + + readonly pubkey: string; + + constructor(private config: TelegramConnectorConfig) { + this.pubkey = config.pubkey; + } + + onPush(handler: PushHandler): void { + this.pushHandlers.add(handler); + } + + /** Open WS, send hello, resolve when hello_ack received. */ + async connect(): Promise { + if (this.closed) throw new Error("client is closed"); + + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.config.brokerUrl); + this.ws = ws; + + ws.on("open", () => { + console.log("[mesh] ws open, sending hello"); + + const timestamp = Date.now(); + const canonical = `${this.config.meshId}|${this.config.memberId}|${this.config.pubkey}|${timestamp}`; + const secretKey = hexToUint8(this.config.secretKey); + const sigBytes = nacl.sign.detached(decodeUTF8(canonical), secretKey); + const signature = uint8ToHex(sigBytes); + + ws.send( + JSON.stringify({ + type: "hello", + meshId: this.config.meshId, + memberId: this.config.memberId, + pubkey: this.config.pubkey, + displayName: this.config.displayName, + sessionId: `connector-tg-${Date.now()}`, + pid: process.pid, + cwd: process.cwd(), + peerType: "connector", + channel: "telegram", + timestamp, + signature, + }), + ); + + this.helloTimer = setTimeout(() => { + ws.close(); + reject(new Error("hello_ack timeout")); + }, HELLO_ACK_TIMEOUT_MS); + }); + + ws.on("message", (raw: WebSocket.RawData) => { + let msg: Record; + try { + msg = JSON.parse(raw.toString()); + } catch { + return; + } + + if (msg.type === "hello_ack") { + if (this.helloTimer) clearTimeout(this.helloTimer); + this.helloTimer = null; + this.connected = true; + this.reconnectAttempt = 0; + this.flushOutbound(); + console.log("[mesh] connected to broker"); + resolve(); + return; + } + + this.handleServerMessage(msg); + }); + + ws.on("close", () => { + if (this.helloTimer) clearTimeout(this.helloTimer); + this.helloTimer = null; + this.ws = null; + const wasConnected = this.connected; + this.connected = false; + if (!wasConnected) { + reject(new Error("ws closed before hello_ack")); + } + if (!this.closed) this.scheduleReconnect(); + }); + + ws.on("error", (err: Error) => { + console.error(`[mesh] ws error: ${err.message}`); + }); + }); + } + + /** Send a message to the mesh. targetSpec: "*" for broadcast, pubkey for direct. */ + async send( + targetSpec: string, + message: string, + priority: "now" | "next" | "low" = "next", + ): Promise<{ ok: boolean; error?: string }> { + const id = randomId(); + // Connectors send plaintext broadcasts (base64 encoded) — + // direct crypto_box encryption is omitted for simplicity. + const nonce = encodeBase64(nacl.randomBytes(24)); + const ciphertext = Buffer.from(message, "utf-8").toString("base64"); + + return new Promise((resolve) => { + const dispatch = (): void => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + this.ws.send( + JSON.stringify({ + type: "send", + id, + targetSpec, + priority, + nonce, + ciphertext, + }), + ); + }; + + if (this.connected) { + dispatch(); + } else { + this.outbound.push(dispatch); + } + + // Ack timeout + setTimeout(() => { + resolve({ ok: false, error: "ack timeout" }); + }, 10_000); + }); + } + + /** Gracefully close. */ + close(): void { + this.closed = true; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + private handleServerMessage(msg: Record): void { + if (msg.type === "push") { + const push = msg as unknown as InboundPush & { senderDisplayName?: string }; + + // Decode plaintext for broadcasts/channel messages + if (!push.plaintext && push.ciphertext) { + try { + push.plaintext = Buffer.from(push.ciphertext, "base64").toString("utf-8"); + } catch { + // leave null + } + } + + // Cache peer display name if provided + if (push.senderDisplayName && push.senderPubkey) { + this.peerNames.set(push.senderPubkey, push.senderDisplayName); + } + + for (const handler of this.pushHandlers) { + try { + handler(push); + } catch (err) { + console.error("[mesh] push handler error:", err); + } + } + } + + if (msg.type === "peers") { + // Cache peer names from peer list responses + const peers = (msg as Record).peers as Array<{ pubkey: string; displayName: string }> | undefined; + if (peers) { + for (const p of peers) { + this.peerNames.set(p.pubkey, p.displayName); + } + } + } + } + + private flushOutbound(): void { + const fns = this.outbound.splice(0); + for (const fn of fns) fn(); + } + + private scheduleReconnect(): void { + const delay = BACKOFF_CAPS[Math.min(this.reconnectAttempt, BACKOFF_CAPS.length - 1)]!; + this.reconnectAttempt++; + console.log(`[mesh] reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`); + this.reconnectTimer = setTimeout(() => { + this.connect().catch((err) => { + console.error(`[mesh] reconnect failed:`, err); + }); + }, delay); + } +} + +// --- Hex helpers (avoid libsodium dependency) --- + +function hexToUint8(hex: string): Uint8Array { + const len = hex.length / 2; + const arr = new Uint8Array(len); + for (let i = 0; i < len; i++) { + arr[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return arr; +} + +function uint8ToHex(arr: Uint8Array): string { + return Array.from(arr) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function randomId(): string { + return Math.random().toString(36).slice(2) + Date.now().toString(36); +} diff --git a/packages/connector-telegram/src/telegram.ts b/packages/connector-telegram/src/telegram.ts new file mode 100644 index 0000000..37e50ee --- /dev/null +++ b/packages/connector-telegram/src/telegram.ts @@ -0,0 +1,148 @@ +/** + * Minimal Telegram Bot API client using fetch + long polling. + * Zero external dependencies. + */ + +const POLL_TIMEOUT_SECS = 30; + +export interface TelegramMessage { + message_id: number; + from?: { + id: number; + first_name: string; + last_name?: string; + username?: string; + }; + chat: { id: number; type: string; title?: string }; + date: number; + text?: string; +} + +interface Update { + update_id: number; + message?: TelegramMessage; +} + +interface GetUpdatesResponse { + ok: boolean; + result: Update[]; + description?: string; +} + +interface SendMessageResponse { + ok: boolean; + description?: string; +} + +export type MessageHandler = (msg: TelegramMessage) => void; + +export class TelegramClient { + private baseUrl: string; + private offset = 0; + private running = false; + private abortController: AbortController | null = null; + private handlers = new Set(); + + constructor( + private botToken: string, + private chatId: string, + ) { + this.baseUrl = `https://api.telegram.org/bot${botToken}`; + } + + onMessage(handler: MessageHandler): void { + this.handlers.add(handler); + } + + /** Send a text message to the configured chat. */ + async sendMessage(text: string): Promise { + try { + const res = await fetch(`${this.baseUrl}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: this.chatId, + text, + parse_mode: "HTML", + }), + }); + const data = (await res.json()) as SendMessageResponse; + if (!data.ok) { + console.error(`[telegram] sendMessage failed: ${data.description}`); + } + return data.ok; + } catch (err) { + console.error(`[telegram] sendMessage error:`, err); + return false; + } + } + + /** Start long-polling loop. Non-blocking — runs in background. */ + start(): void { + if (this.running) return; + this.running = true; + this.pollLoop(); + } + + /** Stop the polling loop gracefully. */ + stop(): void { + this.running = false; + this.abortController?.abort(); + } + + private async pollLoop(): Promise { + while (this.running) { + try { + this.abortController = new AbortController(); + const url = new URL(`${this.baseUrl}/getUpdates`); + url.searchParams.set("offset", String(this.offset)); + url.searchParams.set("timeout", String(POLL_TIMEOUT_SECS)); + url.searchParams.set("allowed_updates", JSON.stringify(["message"])); + + const res = await fetch(url.toString(), { + signal: this.abortController.signal, + // Allow enough time for the long-poll plus network overhead + }); + + const data = (await res.json()) as GetUpdatesResponse; + + if (!data.ok) { + console.error(`[telegram] getUpdates failed: ${data.description}`); + await sleep(5_000); + continue; + } + + for (const update of data.result) { + this.offset = update.update_id + 1; + if (update.message) { + this.dispatchMessage(update.message); + } + } + } catch (err: unknown) { + if (err instanceof Error && err.name === "AbortError") { + // Expected on stop() + break; + } + console.error(`[telegram] poll error:`, err); + await sleep(5_000); + } + } + } + + private dispatchMessage(msg: TelegramMessage): void { + // Only relay messages from the configured chat + if (String(msg.chat.id) !== this.chatId) return; + + for (const handler of this.handlers) { + try { + handler(msg); + } catch (err) { + console.error(`[telegram] handler error:`, err); + } + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/connector-telegram/tsconfig.json b/packages/connector-telegram/tsconfig.json new file mode 100644 index 0000000..6206741 --- /dev/null +++ b/packages/connector-telegram/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}