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