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"));
|
||||
|
||||
Reference in New Issue
Block a user