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:
79
packages/connector-telegram/README.md
Normal file
79
packages/connector-telegram/README.md
Normal 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
|
||||
20
packages/connector-telegram/package.json
Normal file
20
packages/connector-telegram/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
96
packages/connector-telegram/src/bridge.ts
Normal file
96
packages/connector-telegram/src/bridge.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
32
packages/connector-telegram/src/config.ts
Normal file
32
packages/connector-telegram/src/config.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
66
packages/connector-telegram/src/index.ts
Normal file
66
packages/connector-telegram/src/index.ts
Normal 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);
|
||||
});
|
||||
259
packages/connector-telegram/src/mesh-client.ts
Normal file
259
packages/connector-telegram/src/mesh-client.ts
Normal 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);
|
||||
}
|
||||
148
packages/connector-telegram/src/telegram.ts
Normal file
148
packages/connector-telegram/src/telegram.ts
Normal 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));
|
||||
}
|
||||
17
packages/connector-telegram/tsconfig.json
Normal file
17
packages/connector-telegram/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user