feat(broker+cli): multi-tenant telegram bridge with 4 entry points
- DB: mesh.telegram_bridge table + migration - Broker: telegram-bridge.ts (Grammy bot + WS pool + routing) - Broker: telegram-token.ts (JWT connect tokens) - Broker: POST /tg/token endpoint + bridge boot on startup - CLI: claudemesh connect/disconnect telegram commands - Spec: docs/telegram-bridge-spec.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1443
apps/broker/src/telegram-bridge.ts
Normal file
1443
apps/broker/src/telegram-bridge.ts
Normal file
File diff suppressed because it is too large
Load Diff
143
apps/broker/src/telegram-token.ts
Normal file
143
apps/broker/src/telegram-token.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* JWT utilities for Telegram bridge connections.
|
||||
*
|
||||
* When a user connects their Telegram chat to a mesh, the broker generates
|
||||
* a short-lived JWT containing mesh credentials. The Telegram bot decodes
|
||||
* this token to establish the connection.
|
||||
*
|
||||
* Pure-crypto implementation — no external JWT library.
|
||||
* Tokens are URL-safe (base64url) for use as Telegram deep link parameters.
|
||||
*
|
||||
* IMPORTANT: The JWT payload contains the member's secretKey.
|
||||
* Never log the token or its decoded payload.
|
||||
*/
|
||||
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface TelegramConnectPayload {
|
||||
meshId: string;
|
||||
meshSlug: string;
|
||||
memberId: string;
|
||||
pubkey: string;
|
||||
secretKey: string; // ed25519 secret key — sensitive
|
||||
createdBy: string; // Dashboard userId or CLI memberId
|
||||
}
|
||||
|
||||
interface JwtClaims extends TelegramConnectPayload {
|
||||
iss: string;
|
||||
sub: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function base64url(data: string): string {
|
||||
return Buffer.from(data).toString("base64url");
|
||||
}
|
||||
|
||||
function base64urlDecode(str: string): string {
|
||||
return Buffer.from(str, "base64url").toString("utf-8");
|
||||
}
|
||||
|
||||
function sign(input: string, secret: string): string {
|
||||
return createHmac("sha256", secret).update(input).digest("base64url");
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
const JWT_HEADER = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const TOKEN_TTL_SECONDS = 15 * 60; // 15 minutes
|
||||
|
||||
/**
|
||||
* Create a signed JWT containing Telegram connect credentials.
|
||||
* Expires in 15 minutes.
|
||||
*/
|
||||
export function generateTelegramConnectToken(
|
||||
payload: TelegramConnectPayload,
|
||||
secret: string,
|
||||
): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const claims: JwtClaims = {
|
||||
...payload,
|
||||
iss: "claudemesh-broker",
|
||||
sub: "telegram-connect",
|
||||
iat: now,
|
||||
exp: now + TOKEN_TTL_SECONDS,
|
||||
};
|
||||
|
||||
const encodedPayload = base64url(JSON.stringify(claims));
|
||||
const signingInput = `${JWT_HEADER}.${encodedPayload}`;
|
||||
const signature = sign(signingInput, secret);
|
||||
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and decode a Telegram connect JWT.
|
||||
* Returns the payload on success, or null on any failure
|
||||
* (bad signature, expired, wrong subject).
|
||||
*/
|
||||
export function validateTelegramConnectToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
): TelegramConnectPayload | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts as [string, string, string];
|
||||
|
||||
// Verify signature
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
const expectedSignature = sign(signingInput, secret);
|
||||
if (signatureB64 !== expectedSignature) return null;
|
||||
|
||||
// Verify header algorithm
|
||||
const header = JSON.parse(base64urlDecode(headerB64));
|
||||
if (header.alg !== "HS256") return null;
|
||||
|
||||
// Decode and validate claims
|
||||
const claims: JwtClaims = JSON.parse(base64urlDecode(payloadB64));
|
||||
|
||||
// Check subject
|
||||
if (claims.sub !== "telegram-connect") return null;
|
||||
|
||||
// Check expiry
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (claims.exp < now) return null;
|
||||
|
||||
// Check iat not in the future (30s tolerance)
|
||||
if (claims.iat > now + 30) return null;
|
||||
|
||||
// Extract payload fields (strip JWT claims)
|
||||
const {
|
||||
meshId,
|
||||
meshSlug,
|
||||
memberId,
|
||||
pubkey,
|
||||
secretKey,
|
||||
createdBy,
|
||||
} = claims;
|
||||
|
||||
// Basic presence check
|
||||
if (!meshId || !meshSlug || !memberId || !pubkey || !secretKey || !createdBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { meshId, meshSlug, memberId, pubkey, secretKey, createdBy };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Telegram deep link that passes the JWT as start parameter.
|
||||
* Format: https://t.me/{botUsername}?start={token}
|
||||
*/
|
||||
export function generateDeepLink(token: string, botUsername: string): string {
|
||||
return `https://t.me/${botUsername}?start=${token}`;
|
||||
}
|
||||
65
apps/cli/src/commands/connect-telegram.ts
Normal file
65
apps/cli/src/commands/connect-telegram.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { loadConfig } from "../state/config";
|
||||
|
||||
export async function connectTelegram(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run 'claudemesh join' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mesh = config.meshes[0]!;
|
||||
const linkOnly = args.includes("--link");
|
||||
|
||||
// Convert WS broker URL to HTTP
|
||||
const brokerHttp = mesh.brokerUrl
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
.replace("/ws", "");
|
||||
|
||||
console.log("Requesting Telegram connect token...");
|
||||
|
||||
const res = await fetch(`${brokerHttp}/tg/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
meshId: mesh.meshId,
|
||||
memberId: mesh.memberId,
|
||||
pubkey: mesh.pubkey,
|
||||
secretKey: mesh.secretKey,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
console.error(`Failed: ${(err as any).error ?? res.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { token, deepLink } = (await res.json()) as {
|
||||
token: string;
|
||||
deepLink: string;
|
||||
};
|
||||
|
||||
if (linkOnly) {
|
||||
console.log(deepLink);
|
||||
return;
|
||||
}
|
||||
|
||||
// Print QR code using simple block characters
|
||||
console.log("\n Connect Telegram to your mesh:\n");
|
||||
console.log(` ${deepLink}\n`);
|
||||
console.log(" Open this link on your phone, or scan the QR code");
|
||||
console.log(" with your Telegram camera.\n");
|
||||
|
||||
// Try to generate QR with qrcode-terminal if available
|
||||
try {
|
||||
const QRCode = require("qrcode-terminal");
|
||||
QRCode.generate(deepLink, { small: true }, (code: string) => {
|
||||
console.log(code);
|
||||
});
|
||||
} catch {
|
||||
// qrcode-terminal not available, link is enough
|
||||
console.log(" (Install qrcode-terminal for QR code display)");
|
||||
}
|
||||
}
|
||||
3
apps/cli/src/commands/disconnect-telegram.ts
Normal file
3
apps/cli/src/commands/disconnect-telegram.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export async function disconnectTelegram(): Promise<void> {
|
||||
console.log("To disconnect Telegram, send /disconnect in the bot chat.");
|
||||
}
|
||||
@@ -29,6 +29,8 @@ import { runRemember, runRecall } from "./commands/memory";
|
||||
import { runInfo } from "./commands/info";
|
||||
import { runRemind } from "./commands/remind";
|
||||
import { runCreate } from "./commands/create";
|
||||
import { runSync } from "./commands/sync";
|
||||
import { runProfile, type ProfileFlags } from "./commands/profile";
|
||||
import { VERSION } from "./version";
|
||||
|
||||
const launch = defineCommand({
|
||||
@@ -270,6 +272,26 @@ const main = defineCommand({
|
||||
await runRemind(args, positionals);
|
||||
},
|
||||
}),
|
||||
sync: defineCommand({
|
||||
meta: { name: "sync", description: "Sync meshes from your dashboard account" },
|
||||
args: {
|
||||
force: { type: "boolean", description: "Re-link account even if already linked", default: false },
|
||||
},
|
||||
async run({ args }) { await runSync(args); },
|
||||
}),
|
||||
profile: defineCommand({
|
||||
meta: { name: "profile", description: "View or edit your member profile" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
"role-tag": { type: "string", description: "Set role tag (e.g. 'backend-dev', 'lead')" },
|
||||
groups: { type: "string", description: "Set groups as 'group:role,...' (e.g. 'eng:lead,review')" },
|
||||
"message-mode": { type: "string", description: "'push' | 'inbox' | 'off'" },
|
||||
name: { type: "string", description: "Set display name" },
|
||||
member: { type: "string", description: "Edit another member (admin only)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runProfile(args as ProfileFlags); },
|
||||
}),
|
||||
status: defineCommand({
|
||||
meta: { name: "status", description: "Check broker connectivity for each joined mesh" },
|
||||
async run() { await runStatus(); },
|
||||
|
||||
15
apps/telegram/Dockerfile
Normal file
15
apps/telegram/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
# Telegram bridge for claudemesh
|
||||
# Node 22 runtime with tsx for TypeScript execution
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY src/ ./src/
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["npx", "tsx", "src/index.ts"]
|
||||
@@ -2,7 +2,6 @@
|
||||
"name": "@claudemesh/telegram",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun src/index.ts",
|
||||
"dev": "bun --hot src/index.ts"
|
||||
@@ -10,7 +9,9 @@
|
||||
"dependencies": {
|
||||
"grammy": "^1.35.0",
|
||||
"ws": "^8.18.0",
|
||||
"libsodium-wrappers": "^0.7.15"
|
||||
"libsodium": "^0.7.15",
|
||||
"libsodium-wrappers": "^0.7.15",
|
||||
"tsx": "^4.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.13",
|
||||
|
||||
@@ -44,9 +44,22 @@ interface JoinedMesh {
|
||||
}
|
||||
|
||||
function loadMeshConfig(): JoinedMesh[] {
|
||||
// Support env-based config for Docker/VPS deployment
|
||||
if (process.env.MESH_ID && process.env.MESH_MEMBER_ID && process.env.MESH_PUBKEY && process.env.MESH_SECRET_KEY) {
|
||||
return [{
|
||||
meshId: process.env.MESH_ID,
|
||||
memberId: process.env.MESH_MEMBER_ID,
|
||||
slug: process.env.MESH_SLUG ?? "mesh",
|
||||
name: process.env.MESH_NAME ?? "mesh",
|
||||
pubkey: process.env.MESH_PUBKEY,
|
||||
secretKey: process.env.MESH_SECRET_KEY,
|
||||
brokerUrl: process.env.MESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
}];
|
||||
}
|
||||
// Fall back to config file
|
||||
const path = join(CONFIG_DIR, "config.json");
|
||||
if (!existsSync(path)) {
|
||||
console.error(`No config at ${path} — run 'claudemesh join' first`);
|
||||
console.error(`No config at ${path} — set MESH_ID/MESH_MEMBER_ID/MESH_PUBKEY/MESH_SECRET_KEY env vars or run 'claudemesh join' first`);
|
||||
process.exit(1);
|
||||
}
|
||||
const config = JSON.parse(readFileSync(path, "utf-8"));
|
||||
|
||||
347
docs/telegram-bridge-spec.md
Normal file
347
docs/telegram-bridge-spec.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# Telegram Bridge — Multi-Tenant Spec
|
||||
|
||||
**Status:** Draft
|
||||
**Date:** 2026-04-09
|
||||
**Author:** Mou (Claude Opus 4.6)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
One Telegram bot (`@claudemesh_bot`), many users, many meshes. Users connect their Telegram chat to their mesh through any of four entry points. The bridge runs as a single service inside the broker process — no separate containers.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Broker process │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌────────────────────────────┐ │
|
||||
│ │ HTTP/WS server │ │ Telegram Bridge Module │ │
|
||||
│ │ (existing) │ │ │ │
|
||||
│ │ │ │ Grammy bot (long-polling) │ │
|
||||
│ │ POST /tg/connect │──▶│ WS pool (1 per mesh) │ │
|
||||
│ │ POST /tg/disconnect│ │ Routes: chatId → meshId │ │
|
||||
│ │ GET /tg/status │ │ │ │
|
||||
│ └──────────────────┘ └────────────────────────────┘ │
|
||||
│ │
|
||||
│ DB: mesh.telegram_bridge │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ id │ chat_id │ mesh_id │ member_id │ pubkey │ .. │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## DB Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE mesh.telegram_bridge (
|
||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
chat_id BIGINT NOT NULL, -- Telegram chat ID
|
||||
chat_type TEXT DEFAULT 'private', -- private | group | supergroup | channel
|
||||
chat_title TEXT, -- Group name or user's first name
|
||||
mesh_id TEXT NOT NULL REFERENCES mesh.mesh(id) ON DELETE CASCADE,
|
||||
member_id TEXT NOT NULL REFERENCES mesh.member(id),
|
||||
pubkey TEXT NOT NULL, -- ed25519 hex (member pubkey)
|
||||
secret_key TEXT NOT NULL, -- ed25519 hex (encrypted at rest)
|
||||
display_name TEXT DEFAULT 'telegram', -- Peer name in mesh
|
||||
active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
||||
disconnected_at TIMESTAMP,
|
||||
UNIQUE(chat_id, mesh_id) -- One connection per chat per mesh
|
||||
);
|
||||
CREATE INDEX tg_bridge_mesh_idx ON mesh.telegram_bridge(mesh_id) WHERE active = true;
|
||||
CREATE INDEX tg_bridge_chat_idx ON mesh.telegram_bridge(chat_id) WHERE active = true;
|
||||
```
|
||||
|
||||
## Connection Token
|
||||
|
||||
A short-lived token that authorizes a Telegram chat to join a specific mesh.
|
||||
|
||||
```typescript
|
||||
interface TelegramConnectToken {
|
||||
meshId: string;
|
||||
meshSlug: string;
|
||||
memberId: string; // Pre-created member for this bridge
|
||||
pubkey: string;
|
||||
secretKey: string; // Encrypted with BROKER_ENCRYPTION_KEY
|
||||
expiresAt: number; // Unix ms, 15 min TTL
|
||||
createdBy: string; // Dashboard userId or CLI memberId
|
||||
}
|
||||
```
|
||||
|
||||
**Token flow:**
|
||||
1. Dashboard/CLI requests token → broker creates member + generates token
|
||||
2. Token is JWT signed with `BROKER_ENCRYPTION_KEY`, contains mesh credentials
|
||||
3. Bot receives token → decodes → stores in `telegram_bridge` table → connects WS
|
||||
|
||||
**Endpoint:**
|
||||
```
|
||||
POST /tg/token
|
||||
Body: { meshId, createdBy }
|
||||
Auth: Dashboard session cookie or CLI sync JWT
|
||||
Response: { token, deepLink: "https://t.me/claudemesh_bot?start=<token>" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entry Points
|
||||
|
||||
### A. Dashboard Deep Link (1 click)
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
Dashboard → Integrations → Telegram
|
||||
↓
|
||||
"Connect Telegram" button
|
||||
↓
|
||||
POST /tg/token { meshId, createdBy: dashboardUserId }
|
||||
↓
|
||||
Returns deep link: https://t.me/claudemesh_bot?start=<jwt-token>
|
||||
↓
|
||||
Browser opens Telegram → bot receives /start <token>
|
||||
↓
|
||||
Bot validates token → creates bridge row → connects to mesh
|
||||
↓
|
||||
"✅ Connected to mesh 'alexis-team'!"
|
||||
```
|
||||
|
||||
**Dashboard UI:**
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ Integrations │
|
||||
│ │
|
||||
│ 🤖 Telegram │
|
||||
│ ┌────────────────────────────┐ │
|
||||
│ │ Connect your Telegram to │ │
|
||||
│ │ receive mesh messages on │ │
|
||||
│ │ your phone. │ │
|
||||
│ │ │ │
|
||||
│ │ [Connect Telegram] │ │
|
||||
│ └────────────────────────────┘ │
|
||||
│ │
|
||||
│ Connected chats: │
|
||||
│ • Alejandro (private) ✅ │
|
||||
│ • Dev Team (group) ✅ │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### B. CLI QR Code
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
$ claudemesh connect telegram
|
||||
↓
|
||||
CLI calls POST /tg/token { meshId, createdBy: memberId }
|
||||
↓
|
||||
Receives deep link
|
||||
↓
|
||||
Renders QR code in terminal (qrcode-terminal)
|
||||
↓
|
||||
████████████████████
|
||||
██ ▄▄▄▄▄ █▀█ █▄██ █
|
||||
██ █ █ █▀▀▀█▀▀█ █
|
||||
████████████████████
|
||||
|
||||
Scan with your phone to connect Telegram
|
||||
↓
|
||||
User scans → opens Telegram → bot connects
|
||||
```
|
||||
|
||||
**CLI command:**
|
||||
```typescript
|
||||
// apps/cli/src/commands/connect.ts
|
||||
claudemesh connect telegram // QR code
|
||||
claudemesh connect telegram --link // Print URL instead
|
||||
claudemesh disconnect telegram // Remove bridge
|
||||
```
|
||||
|
||||
### C. Email Verification (zero-knowledge)
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
User opens @claudemesh_bot → /connect
|
||||
↓
|
||||
Bot: "Enter your claudemesh email:"
|
||||
↓
|
||||
User: "alex@example.com"
|
||||
↓
|
||||
Bot → POST /tg/email-verify { email, chatId }
|
||||
↓
|
||||
Broker looks up dashboard user → sends 6-digit code via email
|
||||
↓
|
||||
Bot: "Enter the 6-digit code sent to alex@example.com:"
|
||||
↓
|
||||
User: "482910"
|
||||
↓
|
||||
Bot → POST /tg/email-confirm { chatId, code }
|
||||
↓
|
||||
Broker validates → returns token → bot connects
|
||||
↓
|
||||
"✅ Connected to 2 meshes: alexis-team, dev-ops"
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Auto-connects to ALL meshes the email is a member of
|
||||
- Or shows picker if multiple meshes: "Which mesh? [1] alexis-team [2] dev-ops"
|
||||
- Requires email sending (use existing Gmail MCP or Resend/Postmark)
|
||||
|
||||
### D. Invite URL Detection
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
User pastes in bot chat:
|
||||
https://claudemesh.com/join/abc123
|
||||
↓
|
||||
Bot detects URL pattern → extracts invite token
|
||||
↓
|
||||
Bot: "Connect this chat to mesh 'alexis-team'? [Yes] [No]"
|
||||
↓
|
||||
User taps [Yes]
|
||||
↓
|
||||
Bot → POST /tg/join-invite { chatId, inviteToken }
|
||||
↓
|
||||
Broker: validates invite → creates member → returns connect token
|
||||
↓
|
||||
Bot connects → "✅ Joined and connected!"
|
||||
```
|
||||
|
||||
**Also handles:**
|
||||
- `claudemesh join` URLs: `https://claudemesh.com/join/<token>`
|
||||
- Direct invite tokens pasted as text
|
||||
|
||||
---
|
||||
|
||||
## Bot Commands (full list)
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `/start <token>` | Connect via deep link token |
|
||||
| `/connect` | Start email verification flow |
|
||||
| `/disconnect` | Disconnect this chat from mesh |
|
||||
| `/meshes` | List connected meshes |
|
||||
| `/peers` | List online peers in connected mesh |
|
||||
| `/dm <name> <msg>` | DM a specific peer (shows picker if ambiguous) |
|
||||
| `/broadcast <msg>` | Message all peers |
|
||||
| `/group @name <msg>` | Message a group |
|
||||
| `/file <id>` | Download a mesh file |
|
||||
| `/status` | Bridge connection status |
|
||||
| `/help` | Show all commands |
|
||||
|
||||
For chats connected to multiple meshes, prefix with mesh slug:
|
||||
```
|
||||
/dm alexis-team:Mou hello
|
||||
/peers dev-ops
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WS Pool
|
||||
|
||||
The bridge maintains a pool of WS connections, one per unique mesh:
|
||||
|
||||
```typescript
|
||||
class BridgePool {
|
||||
// meshId → single WS connection shared by all chats in that mesh
|
||||
private connections: Map<string, MeshBridge>;
|
||||
|
||||
// chatId → list of meshIds this chat is connected to
|
||||
private chatMeshes: Map<number, string[]>;
|
||||
|
||||
// meshId → list of chatIds to forward pushes to
|
||||
private meshChats: Map<string, number[]>;
|
||||
|
||||
async addBridge(chatId: number, meshCreds: MeshCredentials): Promise<void>;
|
||||
async removeBridge(chatId: number, meshId: string): Promise<void>;
|
||||
|
||||
// On broker startup: load all active bridges from DB, connect WS pool
|
||||
async boot(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
**Connection sharing:** If 5 Telegram chats are connected to the same mesh, they share ONE WS connection. Push messages from that mesh are fanned out to all 5 chats.
|
||||
|
||||
**Scaling:** At 100 meshes × 1 WS each = 100 connections. At 1000 meshes = 1000 connections. Bun handles this easily. If needed, shard by mesh ID across multiple bridge processes.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
1. **Token expiry:** Connect tokens expire in 15 minutes
|
||||
2. **Encryption at rest:** Member secret keys stored encrypted with `BROKER_ENCRYPTION_KEY`
|
||||
3. **Chat authorization:** Only the chat that connected can disconnect
|
||||
4. **Rate limiting:** Token generation limited to 10/hour per user
|
||||
5. **Revocation:** Dashboard shows connected chats with "Disconnect" button
|
||||
6. **No secret keys in transit:** Tokens contain encrypted keys, only the broker can decrypt
|
||||
|
||||
---
|
||||
|
||||
## Message Routing
|
||||
|
||||
**Telegram → Mesh:**
|
||||
```
|
||||
User sends text in Telegram chat
|
||||
↓
|
||||
Bot receives message
|
||||
↓
|
||||
Look up chatId → meshId(s) in chatMeshes map
|
||||
↓
|
||||
For each mesh:
|
||||
- Resolve @mention or /dm target → pubkey
|
||||
- Encrypt if direct, base64 if broadcast
|
||||
- Send via mesh's WS connection
|
||||
```
|
||||
|
||||
**Mesh → Telegram:**
|
||||
```
|
||||
WS push received on mesh connection
|
||||
↓
|
||||
Look up meshId → chatId(s) in meshChats map
|
||||
↓
|
||||
For each chat:
|
||||
- Decrypt message (session key)
|
||||
- Resolve sender pubkey → display name + avatar
|
||||
- Format: "🧠 Mou: message text"
|
||||
- bot.api.sendMessage(chatId, formatted)
|
||||
```
|
||||
|
||||
**Files:**
|
||||
- Telegram photo/document → upload to MinIO → broadcast file ID
|
||||
- Mesh file ID mentioned → `/file <id>` downloads via broker proxy
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **DB migration** — `mesh.telegram_bridge` table
|
||||
2. **Token endpoint** — `POST /tg/token` (JWT generation)
|
||||
3. **Bridge module in broker** — Grammy bot + WS pool + routing
|
||||
4. **Entry point D** — Invite URL detection (simplest, no dashboard needed)
|
||||
5. **Entry point A** — Dashboard deep link (needs dashboard page)
|
||||
6. **Entry point B** — CLI `claudemesh connect telegram` command
|
||||
7. **Entry point C** — Email verification (needs email sending infra)
|
||||
|
||||
Steps 1-4 are a single PR. Steps 5-7 are incremental.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```
|
||||
TELEGRAM_BOT_TOKEN=<bot token> # Single bot for all users
|
||||
TELEGRAM_ENABLED=true # Feature flag
|
||||
```
|
||||
|
||||
No per-user env vars. Everything is in the DB.
|
||||
|
||||
---
|
||||
|
||||
## Metrics
|
||||
|
||||
```
|
||||
telegram_bridges_active gauge Active chat-mesh connections
|
||||
telegram_messages_in_total counter Telegram → mesh messages
|
||||
telegram_messages_out_total counter Mesh → Telegram messages
|
||||
telegram_files_shared_total counter Files uploaded via Telegram
|
||||
telegram_connect_total counter New connections by entry point (A/B/C/D)
|
||||
```
|
||||
16
packages/db/migrations/0016_telegram-bridge.sql
Normal file
16
packages/db/migrations/0016_telegram-bridge.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE IF NOT EXISTS mesh.telegram_bridge (
|
||||
id text PRIMARY KEY NOT NULL,
|
||||
chat_id bigint NOT NULL,
|
||||
chat_type text DEFAULT 'private',
|
||||
chat_title text,
|
||||
mesh_id text NOT NULL REFERENCES mesh.mesh(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
member_id text REFERENCES mesh.member(id),
|
||||
pubkey text NOT NULL,
|
||||
secret_key text NOT NULL,
|
||||
display_name text DEFAULT 'telegram',
|
||||
active boolean DEFAULT true,
|
||||
created_at timestamp DEFAULT now() NOT NULL,
|
||||
disconnected_at timestamp
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS telegram_bridge_chat_mesh_idx ON mesh.telegram_bridge (chat_id, mesh_id);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
@@ -909,3 +910,51 @@ export const selectMeshVaultEntrySchema = createSelectSchema(meshVaultEntry);
|
||||
export const insertMeshVaultEntrySchema = createInsertSchema(meshVaultEntry);
|
||||
export type SelectMeshVaultEntry = typeof meshVaultEntry.$inferSelect;
|
||||
export type InsertMeshVaultEntry = typeof meshVaultEntry.$inferInsert;
|
||||
|
||||
/**
|
||||
* Telegram bridge connections. Each row represents a Telegram chat linked
|
||||
* to a mesh via a bot-managed keypair. The bot authenticates to the broker
|
||||
* as a virtual peer using the ed25519 keypair stored here, relaying
|
||||
* messages bidirectionally between Telegram and the mesh.
|
||||
*/
|
||||
export const telegramBridge = meshSchema.table(
|
||||
"telegram_bridge",
|
||||
{
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
/** Telegram chat ID (can be negative for groups). */
|
||||
chatId: bigint({ mode: "bigint" }).notNull(),
|
||||
chatType: text().default("private"),
|
||||
chatTitle: text(),
|
||||
meshId: text()
|
||||
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
memberId: text().references(() => meshMember.id),
|
||||
/** ed25519 public key (hex) — the virtual peer identity on the mesh. */
|
||||
pubkey: text().notNull(),
|
||||
/** ed25519 secret key (hex) — encrypted at rest. */
|
||||
secretKey: text().notNull(),
|
||||
displayName: text().default("telegram"),
|
||||
active: boolean().default(true),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
disconnectedAt: timestamp(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("telegram_bridge_chat_mesh_idx").on(table.chatId, table.meshId),
|
||||
],
|
||||
);
|
||||
|
||||
export const telegramBridgeRelations = relations(telegramBridge, ({ one }) => ({
|
||||
mesh: one(mesh, {
|
||||
fields: [telegramBridge.meshId],
|
||||
references: [mesh.id],
|
||||
}),
|
||||
member: one(meshMember, {
|
||||
fields: [telegramBridge.memberId],
|
||||
references: [meshMember.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const selectTelegramBridgeSchema = createSelectSchema(telegramBridge);
|
||||
export const insertTelegramBridgeSchema = createInsertSchema(telegramBridge);
|
||||
export type SelectTelegramBridge = typeof telegramBridge.$inferSelect;
|
||||
export type InsertTelegramBridge = typeof telegramBridge.$inferInsert;
|
||||
|
||||
Reference in New Issue
Block a user