feat: add Telegram connector package for mesh-to-chat bridging

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 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-07 23:52:00 +01:00
parent 08e289a5e3
commit fe9285351b
8 changed files with 717 additions and 0 deletions

View File

@@ -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<YOUR_TOKEN>/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 `<b>[PeerName]</b> 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

View File

@@ -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"
}
}

View File

@@ -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 = `<b>[${escapeHtml(senderName)}]</b> ${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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

View File

@@ -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",
};
}

View File

@@ -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<void> {
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);
});

View File

@@ -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<string, unknown>;
}
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<PushHandler>();
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<string, string>(); // 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<void> {
if (this.closed) throw new Error("client is closed");
return new Promise<void>((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<string, unknown>;
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<string, unknown>): 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<string, unknown>).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);
}

View File

@@ -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<MessageHandler>();
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<boolean> {
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<void> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -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"]
}