Files
claudemesh/apps/broker/src/telegram-bridge.ts
Alejandro Gutiérrez 3595fc2c4d
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
feat(broker): add list_services and list_commands tools to telegram AI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:20:00 +01:00

1945 lines
61 KiB
TypeScript

/**
* Telegram Bridge — Multi-Tenant Module
*
* Manages a single @claudemesh_bot instance with long-polling and a pool of
* WebSocket connections (one per unique mesh). Multiple Telegram chats can
* share the same mesh connection; push messages fan out to all chats.
*
* This file is self-contained. The broker's index.ts imports bootTelegramBridge
* and connectChat, passing DB accessor callbacks so we never import db.ts.
*/
import { Bot, InputFile } from "grammy";
import WebSocket from "ws";
import sodium from "libsodium-wrappers";
import { validateTelegramConnectToken } from "./telegram-token";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface BridgeRow {
chatId: number;
meshId: string;
meshSlug?: string;
memberId: string;
pubkey: string;
secretKey: string;
displayName: string;
chatType: string;
chatTitle: string | null;
}
interface MeshCredentials {
meshId: string;
memberId: string;
pubkey: string;
secretKey: string;
displayName: string;
brokerUrl: string;
}
interface PeerInfo {
displayName: string;
pubkey: string;
status: string;
summary?: string;
cwd?: string;
groups?: string[];
avatar?: string;
}
// ---------------------------------------------------------------------------
// Crypto helpers (mirrors apps/telegram/src/index.ts)
// ---------------------------------------------------------------------------
let sodiumReady = false;
async function ensureSodium() {
if (!sodiumReady) {
await sodium.ready;
sodiumReady = true;
}
return sodium;
}
async function generateSessionKeypair() {
const s = await ensureSodium();
const kp = s.crypto_sign_keypair();
return {
publicKey: s.to_hex(kp.publicKey),
secretKey: s.to_hex(kp.privateKey),
};
}
async function signHello(
meshId: string,
memberId: string,
pubkey: string,
secretKeyHex: string,
) {
const s = await ensureSodium();
const timestamp = Date.now();
const canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`;
const sig = s.crypto_sign_detached(
s.from_string(canonical),
s.from_hex(secretKeyHex),
);
return { timestamp, signature: s.to_hex(sig) };
}
async function decryptDirect(
nonce: string,
ciphertext: string,
senderPubkeyHex: string,
recipientSecretKeyHex: string,
): Promise<string | null> {
const s = await ensureSodium();
try {
const senderPub = s.crypto_sign_ed25519_pk_to_curve25519(
s.from_hex(senderPubkeyHex),
);
const recipientSec = s.crypto_sign_ed25519_sk_to_curve25519(
s.from_hex(recipientSecretKeyHex),
);
const nonceBytes = s.from_base64(nonce, s.base64_variants.ORIGINAL);
const ciphertextBytes = s.from_base64(
ciphertext,
s.base64_variants.ORIGINAL,
);
const plain = s.crypto_box_open_easy(
ciphertextBytes,
nonceBytes,
senderPub,
recipientSec,
);
return s.to_string(plain);
} catch {
return null;
}
}
async function encryptDirect(
message: string,
recipientPubkeyHex: string,
senderSecretKeyHex: string,
): Promise<{ nonce: string; ciphertext: string }> {
const s = await ensureSodium();
const recipientPub = s.crypto_sign_ed25519_pk_to_curve25519(
s.from_hex(recipientPubkeyHex),
);
const senderSec = s.crypto_sign_ed25519_sk_to_curve25519(
s.from_hex(senderSecretKeyHex),
);
const nonceBytes = s.randombytes_buf(s.crypto_box_NONCEBYTES);
const ciphertextBytes = s.crypto_box_easy(
s.from_string(message),
nonceBytes,
recipientPub,
senderSec,
);
return {
nonce: s.to_base64(nonceBytes, s.base64_variants.ORIGINAL),
ciphertext: s.to_base64(ciphertextBytes, s.base64_variants.ORIGINAL),
};
}
// ---------------------------------------------------------------------------
// MeshConnection — one WS per unique mesh, shared across chats
// ---------------------------------------------------------------------------
class MeshConnection {
private ws: WebSocket | null = null;
private creds: MeshCredentials;
private sessionPubkey: string | null = null;
private sessionSecretKey: string | null = null;
private connected = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempt = 0;
private resolvers = new Map<
string,
{ resolve: (v: any) => void; timer: ReturnType<typeof setTimeout> }
>();
/** pubkey/sessionPubkey → { name, avatar } */
private peerInfo = new Map<string, { name: string; avatar?: string }>();
private onPush: (meshId: string, from: string, text: string, priority: string) => void;
private peerRefreshInterval: ReturnType<typeof setInterval> | null = null;
constructor(
creds: MeshCredentials,
onPush: (meshId: string, from: string, text: string, priority: string) => void,
) {
this.creds = creds;
this.onPush = onPush;
}
async connect(): Promise<void> {
const sessionKP = await generateSessionKeypair();
this.sessionPubkey = sessionKP.publicKey;
this.sessionSecretKey = sessionKP.secretKey;
await this._connect();
// Refresh peer name cache every 30 s
this.peerRefreshInterval = setInterval(
() => this.listPeers().catch(() => {}),
30_000,
);
}
private _connect(): Promise<void> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(this.creds.brokerUrl);
this.ws = ws;
ws.on("open", async () => {
try {
const { timestamp, signature } = await signHello(
this.creds.meshId,
this.creds.memberId,
this.creds.pubkey,
this.creds.secretKey,
);
ws.send(
JSON.stringify({
type: "hello",
meshId: this.creds.meshId,
memberId: this.creds.memberId,
pubkey: this.creds.pubkey,
sessionPubkey: this.sessionPubkey,
displayName: this.creds.displayName,
sessionId: `tg-bridge-${this.creds.meshId.slice(0, 8)}-${Date.now()}`,
pid: process.pid,
cwd: process.cwd(),
hostname: require("os").hostname(),
peerType: "bridge",
channel: "telegram",
timestamp,
signature,
}),
);
} catch (e) {
reject(e);
}
});
const helloTimeout = setTimeout(() => {
ws.close();
reject(new Error("hello_ack timeout"));
}, 10_000);
ws.on("message", async (raw) => {
try {
const msg = JSON.parse(raw.toString());
if (msg.type === "hello_ack") {
clearTimeout(helloTimeout);
this.connected = true;
this.reconnectAttempt = 0;
console.log(
`[tg-bridge] WS connected to mesh ${this.creds.meshId.slice(0, 8)} as ${this.creds.displayName}`,
);
resolve();
return;
}
// Push messages from peers → forward to Telegram
if (msg.type === "push") {
let text: string | null = null;
const senderPubkey =
msg.senderPubkey ?? msg.senderSessionPubkey;
if (msg.subtype === "system") {
const event = msg.event ?? "";
const data = msg.eventData ?? {};
if (event === "peer_joined")
text = `[joined] ${data.displayName ?? "peer"}`;
else if (event === "peer_left")
text = `[left] ${data.displayName ?? "peer"}`;
else if (event === "peer_returned")
text = `[returned] ${data.name ?? "peer"}`;
else text = msg.plaintext ?? `[${event}]`;
} else if (senderPubkey && msg.nonce && msg.ciphertext) {
// Try session key, then member key
text =
(await decryptDirect(
msg.nonce,
msg.ciphertext,
senderPubkey,
this.sessionSecretKey!,
)) ??
(await decryptDirect(
msg.nonce,
msg.ciphertext,
senderPubkey,
this.creds.secretKey,
));
if (!text) text = "[could not decrypt]";
} else if (msg.plaintext) {
text = msg.plaintext;
} else if (msg.ciphertext && !msg.nonce) {
try {
text = Buffer.from(msg.ciphertext, "base64").toString("utf-8");
} catch {
text = "[decode error]";
}
}
if (text) {
const info = senderPubkey
? this.peerInfo.get(senderPubkey)
: null;
const fromName =
info?.name ?? senderPubkey?.slice(0, 12) ?? "system";
const avatar = info?.avatar ?? "🤖";
this.onPush(
this.creds.meshId,
`${avatar} ${fromName}`,
text,
msg.priority ?? "next",
);
}
}
// Resolve pending request/response pairs
const reqId = msg._reqId;
if (reqId && this.resolvers.has(reqId)) {
const r = this.resolvers.get(reqId)!;
clearTimeout(r.timer);
this.resolvers.delete(reqId);
r.resolve(msg);
}
} catch {
/* ignore parse errors */
}
});
ws.on("close", () => {
this.connected = false;
this.ws = null;
if (this.reconnectTimer) return;
const MAX_RECONNECT_ATTEMPTS = 20;
if (this.reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
console.error(
`[tg-bridge] mesh ${this.creds.meshId.slice(0, 8)} giving up after ${MAX_RECONNECT_ATTEMPTS} attempts`,
);
meshConnections.delete(this.creds.meshId);
return;
}
const delays = [1000, 2000, 4000, 8000, 16000, 30000];
const delay =
delays[Math.min(this.reconnectAttempt, delays.length - 1)]!;
this.reconnectAttempt++;
console.log(
`[tg-bridge] mesh ${this.creds.meshId.slice(0, 8)} reconnecting in ${delay}ms (attempt ${this.reconnectAttempt}/${MAX_RECONNECT_ATTEMPTS})`,
);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this._connect().catch((e) =>
console.error("[tg-bridge] reconnect failed:", e),
);
}, delay);
});
ws.on("error", (err) => {
console.error(
`[tg-bridge] WS error mesh ${this.creds.meshId.slice(0, 8)}:`,
err.message,
);
});
});
}
// -- Request / Response helpers --
private makeReqId(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
private request(
msg: Record<string, unknown>,
timeout = 10_000,
): Promise<any> {
return new Promise((resolve) => {
const reqId = this.makeReqId();
const timer = setTimeout(() => {
this.resolvers.delete(reqId);
resolve(null);
}, timeout);
this.resolvers.set(reqId, { resolve, timer });
this.ws?.send(JSON.stringify({ ...msg, _reqId: reqId }));
});
}
// -- Public API --
async sendMessage(
to: string,
message: string,
priority = "next",
): Promise<boolean> {
if (!this.ws || !this.connected) return false;
const isDirect = /^[0-9a-f]{64}$/.test(to);
let nonce = "";
let ciphertext = "";
if (isDirect) {
const enc = await encryptDirect(
message,
to,
this.sessionSecretKey!,
);
nonce = enc.nonce;
ciphertext = enc.ciphertext;
} else {
ciphertext = Buffer.from(message, "utf-8").toString("base64");
}
this.ws.send(
JSON.stringify({
type: "send",
id: this.makeReqId(),
targetSpec: to,
priority,
nonce,
ciphertext,
}),
);
return true;
}
async listPeers(): Promise<PeerInfo[]> {
const resp = await this.request({ type: "list_peers" });
if (!resp?.peers) return [];
return resp.peers.map((p: any) => {
const name = p.displayName ?? p.pubkey?.slice(0, 12) ?? "?";
const avatar = p.profile?.avatar;
const info = { name, avatar };
if (p.pubkey) this.peerInfo.set(p.pubkey, info);
if (p.sessionPubkey) this.peerInfo.set(p.sessionPubkey, info);
return {
displayName: name,
pubkey: p.pubkey ?? "",
status: p.status ?? "unknown",
summary: p.summary,
cwd: p.cwd,
groups: p.groups?.map((g: any) => g.name) ?? [],
avatar,
};
});
}
async findPeersByName(name: string): Promise<PeerInfo[]> {
const peers = await this.listPeers();
return peers.filter(
(p) => p.displayName.toLowerCase() === name.toLowerCase(),
);
}
async setSummary(summary: string): Promise<void> {
this.ws?.send(JSON.stringify({ type: "set_summary", summary }));
}
async uploadFile(
data: Buffer,
fileName: string,
tags?: string[],
): Promise<string | null> {
const brokerHttp = this.creds.brokerUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
.replace("/ws", "");
try {
const res = await fetch(`${brokerHttp}/upload`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"X-Mesh-Id": this.creds.meshId,
"X-Member-Id": this.creds.memberId,
"X-File-Name": fileName,
"X-Tags": JSON.stringify(tags ?? ["telegram"]),
"X-Persistent": "true",
},
body: data,
signal: AbortSignal.timeout(30_000),
});
const body = (await res.json()) as {
ok?: boolean;
fileId?: string;
error?: string;
};
if (!res.ok || !body.fileId) return null;
return body.fileId;
} catch (e) {
console.error("[tg-bridge] upload failed:", e);
return null;
}
}
async getFileUrl(
fileId: string,
): Promise<{ url: string; name: string } | null> {
const resp = await this.request({ type: "get_file", fileId });
if (!resp?.url) return null;
return { url: resp.url, name: resp.name ?? "file" };
}
isConnected(): boolean {
return this.connected;
}
getMeshId(): string {
return this.creds.meshId;
}
close(): void {
if (this.peerRefreshInterval) clearInterval(this.peerRefreshInterval);
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.ws?.close();
}
}
// ---------------------------------------------------------------------------
// Routing maps
// ---------------------------------------------------------------------------
/** chatId → meshIds this chat is connected to */
const chatMeshes = new Map<number, string[]>();
/** meshId → chatIds that should receive push messages */
const meshChats = new Map<string, Set<number>>();
/** meshId → shared WS connection */
const meshConnections = new Map<string, MeshConnection>();
/** meshId → slug (human-readable name) */
const meshSlugs = new Map<string, string>();
// Pending DM picker state: chatId → { message, matches, meshId }
const pendingDMs = new Map<
number,
{ message: string; matches: PeerInfo[]; meshId: string }
>();
// Pending file upload picker state: chatId → { fileId, fileName, meshId, caption }
const pendingFiles = new Map<
number,
{ fileId: string; fileName: string; meshId: string; caption: string }
>();
// Pending email verification state: chatId → { email, code, expiresAt, attempts }
const pendingVerifications = new Map<
number,
{ email: string; code: string; expiresAt: number; attempts: number }
>();
// Conversation state: chatId → which input the bot is waiting for
const conversationState = new Map<number, "awaiting_email" | "awaiting_code">();
/** Pending AI actions awaiting user confirmation */
const pendingAiActions = new Map<string, {
chatId: number;
meshIds: string[];
toolCall: { name: string; input: Record<string, unknown> };
expiresAt: number;
}>();
/** Chat → mesh slugs mapping for AI context */
const chatMeshSlugs = new Map<number, string[]>();
// Clean expired AI actions every 5 min
setInterval(() => {
const now = Date.now();
for (const [k, v] of pendingAiActions) {
if (now > v.expiresAt) pendingAiActions.delete(k);
}
}, 5 * 60 * 1000);
/** Invite URL regex: https://claudemesh.com/join/<token> */
const INVITE_URL_RE =
/https?:\/\/(?:www\.)?claudemesh\.com\/join\/([A-Za-z0-9_\-\.]+)/;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Add a chat ↔ mesh link to the in-memory maps. */
function linkChatMesh(chatId: number, meshId: string): void {
const meshes = chatMeshes.get(chatId) ?? [];
if (!meshes.includes(meshId)) {
meshes.push(meshId);
chatMeshes.set(chatId, meshes);
}
const chats = meshChats.get(meshId) ?? new Set();
chats.add(chatId);
meshChats.set(meshId, chats);
}
/** Remove a chat ↔ mesh link from in-memory maps. */
function unlinkChatMesh(chatId: number, meshId: string): void {
const meshes = chatMeshes.get(chatId);
if (meshes) {
const idx = meshes.indexOf(meshId);
if (idx !== -1) meshes.splice(idx, 1);
if (meshes.length === 0) chatMeshes.delete(chatId);
}
const chats = meshChats.get(meshId);
if (chats) {
chats.delete(chatId);
if (chats.size === 0) meshChats.delete(meshId);
}
}
/**
* Resolve which MeshConnection a chat command should target.
* If the chat is connected to exactly one mesh, return it.
* If connected to multiple and a prefix is given (e.g. "dev-team"),
* match by meshId prefix. Otherwise return null (caller should prompt).
*/
function resolveMesh(
chatId: number,
meshPrefix?: string,
): MeshConnection | null {
const meshIds = chatMeshes.get(chatId);
if (!meshIds || meshIds.length === 0) return null;
if (meshIds.length === 1) {
return meshConnections.get(meshIds[0]!) ?? null;
}
if (meshPrefix) {
const lower = meshPrefix.toLowerCase();
const match = meshIds.find((id) => id.toLowerCase().startsWith(lower));
if (match) return meshConnections.get(match) ?? null;
// Also try partial match anywhere in the id
const partial = meshIds.find((id) => id.toLowerCase().includes(lower));
if (partial) return meshConnections.get(partial) ?? null;
}
return null;
}
/**
* Parse an optional mesh prefix from command text.
* Format: "meshSlug:rest" or "meshSlug rest" (for /peers etc.)
* Returns [meshPrefix | undefined, remainingText].
*/
function parseMeshPrefix(
chatId: number,
text: string,
): [string | undefined, string] {
const meshIds = chatMeshes.get(chatId);
if (!meshIds || meshIds.length <= 1) return [undefined, text];
// Try "slug:rest" format
const colonIdx = text.indexOf(":");
if (colonIdx > 0 && colonIdx < 40) {
return [text.slice(0, colonIdx), text.slice(colonIdx + 1).trimStart()];
}
// Try "slug rest" — only if first word matches a known meshId
const spaceIdx = text.indexOf(" ");
const firstWord = spaceIdx === -1 ? text : text.slice(0, spaceIdx);
const lower = firstWord.toLowerCase();
const isSlug = meshIds.some(
(id) =>
id.toLowerCase().startsWith(lower) || id.toLowerCase().includes(lower),
);
if (isSlug) {
return [
firstWord,
spaceIdx === -1 ? "" : text.slice(spaceIdx + 1).trimStart(),
];
}
return [undefined, text];
}
// ---------------------------------------------------------------------------
// Push handler — fan out mesh push to Telegram chats
// ---------------------------------------------------------------------------
function createPushHandler(bot: Bot) {
return (
meshId: string,
from: string,
text: string,
_priority: string,
) => {
const chatIds = meshChats.get(meshId);
if (!chatIds || chatIds.size === 0) return;
const meshLabel = meshSlugs.get(meshId) ?? meshId.slice(0, 12);
const formatted = `💬 [${meshLabel}] ${from}\n${text}`;
for (const chatId of chatIds) {
bot.api
.sendMessage(chatId, formatted)
.catch((e) => {
console.error(`[tg-bridge] send to chat ${chatId} failed:`, e.message);
});
}
};
}
/** Escape Markdown v1 special chars for Telegram. */
function escapeMarkdown(s: string): string {
return s.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
}
/** Strip markdown formatting from AI text responses for plain Telegram display. */
function stripMarkdown(s: string): string {
return s
.replace(/\*\*(.*?)\*\*/g, "$1") // **bold** → bold
.replace(/\*(.*?)\*/g, "$1") // *italic* → italic
.replace(/__(.*?)__/g, "$1") // __underline__ → underline
.replace(/~~(.*?)~~/g, "$1") // ~~strike~~ → strike
.replace(/`(.*?)`/g, "$1") // `code` → code
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1"); // [text](url) → text
}
// ---------------------------------------------------------------------------
// Bot command handlers
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Email verification helpers
// ---------------------------------------------------------------------------
function generateCode(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
async function startEmailVerification(
ctx: any,
chatId: number,
email: string,
lookupMeshesByEmail: (email: string) => Promise<UserMeshInfo[]>,
sendVerificationEmail: (email: string, code: string) => Promise<boolean>,
): Promise<void> {
// Check if email exists in our system
const meshes = await lookupMeshesByEmail(email);
if (meshes.length === 0) {
conversationState.delete(chatId);
pendingVerifications.delete(chatId);
await ctx.reply(
"❌ No claudemesh account found for that email.\n\n" +
"Sign up at claudemesh.com first, or use a connect link:\n" +
"`claudemesh connect telegram`",
{ parse_mode: "Markdown" },
);
return;
}
const code = generateCode();
const sent = await sendVerificationEmail(email, code);
if (!sent) {
conversationState.delete(chatId);
await ctx.reply("❌ Failed to send verification email. Try again later.");
return;
}
pendingVerifications.set(chatId, {
email,
code,
expiresAt: Date.now() + 10 * 60_000, // 10 min
attempts: 0,
});
conversationState.set(chatId, "awaiting_code");
const masked = email.replace(/(.{2})(.*)(@.*)/, "$1***$3");
await ctx.reply(`📬 Verification code sent to ${masked}\n\nEnter the 6-digit code:`);
}
/** Result from looking up a user's meshes by email */
export interface UserMeshInfo {
userId: string;
meshId: string;
meshSlug: string;
memberId: string;
pubkey: string;
secretKey: string;
}
function setupBotCommands(
bot: Bot,
botToken: string,
brokerUrl: string,
saveBridge: (
row: Omit<BridgeRow, "chatId"> & { chatId: number },
) => Promise<void>,
deactivateBridge: (chatId: number, meshId: string) => Promise<void>,
pushHandler: (
meshId: string,
from: string,
text: string,
priority: string,
) => void,
lookupMeshesByEmail: (email: string) => Promise<UserMeshInfo[]>,
sendVerificationEmail: (email: string, code: string) => Promise<boolean>,
): void {
// --- /start <token> ---
bot.command("start", async (ctx) => {
const token = ctx.match?.trim();
if (!token) {
await ctx.reply(
"🔗 *Claudemesh Telegram Bridge*\n\n" +
"Use a connect link from the dashboard or CLI to get started.\n" +
"Or type /connect to link via email.\n\n" +
"Commands: /help",
{ parse_mode: "Markdown" },
);
return;
}
// Validate JWT signature, expiry, and claims
const encKey = process.env.BROKER_ENCRYPTION_KEY;
if (!encKey) {
await ctx.reply("❌ Broker not configured for token validation.");
return;
}
const payload = validateTelegramConnectToken(token, encKey);
if (!payload) {
await ctx.reply("❌ Invalid, expired, or tampered token. Request a new link.");
return;
}
const { meshId, memberId, pubkey, secretKey, meshSlug } = payload;
const chatId = ctx.chat.id;
const chatType = ctx.chat.type;
const chatTitle =
ctx.chat.type === "private"
? (ctx.from?.first_name ?? "Private")
: ("title" in ctx.chat ? ctx.chat.title : null) ?? "Group";
const displayName = `tg:${chatTitle}`;
// Check if already connected
const existing = chatMeshes.get(chatId);
if (existing?.includes(meshId)) {
await ctx.reply(`Already connected to mesh \`${meshSlug ?? meshId.slice(0, 8)}\`.`, {
parse_mode: "Markdown",
});
return;
}
try {
// Persist bridge row
await saveBridge({
chatId,
meshId,
memberId,
pubkey,
secretKey,
displayName,
chatType,
chatTitle,
});
// Connect or reuse WS
await ensureMeshConnection(
{ meshId, memberId, pubkey, secretKey, displayName, brokerUrl },
pushHandler,
);
linkChatMesh(chatId, meshId);
if (meshSlug) meshSlugs.set(meshId, meshSlug);
await ctx.reply(
`✅ Connected to mesh *${escapeMarkdown(meshSlug ?? meshId.slice(0, 8))}*\\!`,
{ parse_mode: "MarkdownV2" },
);
} catch (e) {
console.error("[tg-bridge] /start connect failed:", e);
await ctx.reply("❌ Connection failed. Try again or request a new token.");
}
});
// --- /connect (email verification flow) ---
bot.command("connect", async (ctx) => {
const chatId = ctx.chat.id;
// If they passed an email inline: /connect user@example.com
const emailArg = ctx.match?.trim();
if (emailArg && emailArg.includes("@")) {
conversationState.set(chatId, "awaiting_code");
await startEmailVerification(ctx, chatId, emailArg, lookupMeshesByEmail, sendVerificationEmail);
return;
}
conversationState.set(chatId, "awaiting_email");
await ctx.reply(
"📧 *Connect via email*\n\nEnter the email address you used to sign up on claudemesh.com:",
{ parse_mode: "Markdown" },
);
});
// --- /disconnect ---
bot.command("disconnect", async (ctx) => {
const chatId = ctx.chat.id;
const meshIds = chatMeshes.get(chatId);
if (!meshIds || meshIds.length === 0) {
await ctx.reply("Not connected to any mesh.");
return;
}
const [meshPrefix, _] = parseMeshPrefix(chatId, ctx.match ?? "");
let targetMeshId: string | undefined;
if (meshIds.length === 1) {
targetMeshId = meshIds[0]!;
} else if (meshPrefix) {
const lower = meshPrefix.toLowerCase();
targetMeshId = meshIds.find(
(id) =>
id.toLowerCase().startsWith(lower) ||
id.toLowerCase().includes(lower),
);
}
if (!targetMeshId && meshIds.length > 1) {
const list = meshIds.map((id) => `\`${id.slice(0, 12)}\``).join("\n");
await ctx.reply(
`Connected to multiple meshes. Specify which:\n${list}\n\n/disconnect <mesh-slug>`,
{ parse_mode: "Markdown" },
);
return;
}
if (!targetMeshId) {
await ctx.reply("Mesh not found.");
return;
}
try {
await deactivateBridge(chatId, targetMeshId);
unlinkChatMesh(chatId, targetMeshId);
// If no more chats reference this mesh, close the WS
const remaining = meshChats.get(targetMeshId);
if (!remaining || remaining.size === 0) {
const conn = meshConnections.get(targetMeshId);
if (conn) {
conn.close();
meshConnections.delete(targetMeshId);
}
}
await ctx.reply(`✅ Disconnected from mesh \`${targetMeshId.slice(0, 12)}\`.`, {
parse_mode: "Markdown",
});
} catch (e) {
console.error("[tg-bridge] /disconnect failed:", e);
await ctx.reply("❌ Disconnect failed.");
}
});
// --- /meshes ---
bot.command("meshes", async (ctx) => {
const meshIds = chatMeshes.get(ctx.chat.id);
if (!meshIds || meshIds.length === 0) {
await ctx.reply("Not connected to any mesh. Use a connect link to join.");
return;
}
const lines = meshIds.map((id) => {
const conn = meshConnections.get(id);
const status = conn?.isConnected() ? "🟢" : "🔴";
return `${status} \`${meshSlugs.get(id) ?? id.slice(0, 12)}\``;
});
await ctx.reply(`*Connected meshes:*\n${lines.join("\n")}`, {
parse_mode: "Markdown",
});
});
// --- /peers [mesh-slug] ---
bot.command("peers", async (ctx) => {
const chatId = ctx.chat.id;
const [meshPrefix] = parseMeshPrefix(chatId, ctx.match ?? "");
const conn = resolveMesh(chatId, meshPrefix);
if (!conn) {
const meshIds = chatMeshes.get(chatId);
if (!meshIds || meshIds.length === 0) {
await ctx.reply("Not connected to any mesh.");
} else {
await ctx.reply(
"Connected to multiple meshes. Specify which: /peers <mesh-slug>",
);
}
return;
}
const peers = await conn.listPeers();
if (peers.length === 0) {
await ctx.reply("No peers online.");
return;
}
const lines = peers.map((p) => {
const icon =
p.status === "idle" ? "🟢" : p.status === "working" ? "🟡" : "🔴";
const summary = p.summary ? ` — _${escapeMarkdown(p.summary)}_` : "";
return `${icon} *${escapeMarkdown(p.displayName)}*${summary}`;
});
await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" });
});
// --- /dm [mesh:]<name> <message> ---
bot.command("dm", async (ctx) => {
const chatId = ctx.chat.id;
const rawText = ctx.match ?? "";
if (!rawText.trim()) {
await ctx.reply("Usage: /dm <peer-name> <message>");
return;
}
const [meshPrefix, text] = parseMeshPrefix(chatId, rawText);
const conn = resolveMesh(chatId, meshPrefix);
if (!conn) {
const meshIds = chatMeshes.get(chatId);
if (!meshIds || meshIds.length === 0) {
await ctx.reply("Not connected to any mesh.");
} else {
await ctx.reply(
"Connected to multiple meshes. Prefix with mesh slug: /dm mesh-slug:Mou hello",
);
}
return;
}
const spaceIdx = text.indexOf(" ");
if (spaceIdx === -1) {
await ctx.reply("Usage: /dm <peer-name> <message>");
return;
}
const target = text.slice(0, spaceIdx);
const message = text.slice(spaceIdx + 1);
const matches = await conn.findPeersByName(target);
if (matches.length === 0) {
await ctx.reply(`❌ No peer named "${target}" found.`);
return;
}
if (matches.length === 1) {
const ok = await conn.sendMessage(
matches[0]!.pubkey,
`[via Telegram] ${message}`,
"now",
);
await ctx.reply(
ok
? `✅ → ${matches[0]!.avatar ?? "🤖"} ${matches[0]!.displayName}`
: "❌ Not connected",
);
return;
}
// Multiple matches — show inline keyboard picker
pendingDMs.set(chatId, {
message,
matches,
meshId: conn.getMeshId(),
});
const buttons = matches.map((p, i) => {
const dir = p.cwd?.split("/").pop() ?? "?";
const avatar = p.avatar ?? "🤖";
return [
{ text: `${avatar} ${p.displayName} (${dir})`, callback_data: `dm:${i}` },
];
});
buttons.push([{ text: "📨 Send to ALL", callback_data: "dm:all" }]);
await ctx.reply(`Multiple "${target}" peers online. Pick one or all:`, {
reply_markup: { inline_keyboard: buttons },
});
});
// --- /broadcast [mesh:] <message> ---
bot.command("broadcast", async (ctx) => {
const chatId = ctx.chat.id;
const [meshPrefix, message] = parseMeshPrefix(chatId, ctx.match ?? "");
if (!message.trim()) {
await ctx.reply("Usage: /broadcast <message>");
return;
}
const conn = resolveMesh(chatId, meshPrefix);
if (!conn) {
await ctx.reply("Not connected or specify mesh: /broadcast mesh-slug:hello");
return;
}
const ok = await conn.sendMessage("*", `[via Telegram] ${message}`, "now");
await ctx.reply(ok ? "✅ Broadcast sent" : "❌ Not connected");
});
// --- /group [mesh:]@name <message> ---
bot.command("group", async (ctx) => {
const chatId = ctx.chat.id;
const [meshPrefix, text] = parseMeshPrefix(chatId, ctx.match ?? "");
if (!text.trim()) {
await ctx.reply("Usage: /group @group-name <message>");
return;
}
const spaceIdx = text.indexOf(" ");
if (spaceIdx === -1) {
await ctx.reply("Usage: /group @group-name <message>");
return;
}
const target = text.slice(0, spaceIdx);
const message = text.slice(spaceIdx + 1);
const conn = resolveMesh(chatId, meshPrefix);
if (!conn) {
await ctx.reply("Not connected or specify mesh.");
return;
}
const ok = await conn.sendMessage(target, `[via Telegram] ${message}`, "now");
await ctx.reply(ok ? `✅ Sent to ${target}` : "❌ Not connected");
});
// --- /file <id> ---
bot.command("file", async (ctx) => {
const chatId = ctx.chat.id;
const fileId = ctx.match?.trim();
if (!fileId) {
await ctx.reply("Usage: /file <file-id>");
return;
}
// Try all connected meshes for this chat
const meshIds = chatMeshes.get(chatId) ?? [];
for (const meshId of meshIds) {
const conn = meshConnections.get(meshId);
if (!conn?.isConnected()) continue;
const file = await conn.getFileUrl(fileId);
if (!file) continue;
try {
const resp = await fetch(file.url, {
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) continue;
const buf = Buffer.from(await resp.arrayBuffer());
await ctx.replyWithDocument(new InputFile(buf, file.name));
return;
} catch {
continue;
}
}
await ctx.reply(`❌ File \`${fileId}\` not found in any connected mesh.`, {
parse_mode: "Markdown",
});
});
// --- /status ---
bot.command("status", async (ctx) => {
const chatId = ctx.chat.id;
const meshIds = chatMeshes.get(chatId);
if (!meshIds || meshIds.length === 0) {
await ctx.reply("Not connected to any mesh.");
return;
}
const lines = meshIds.map((id) => {
const conn = meshConnections.get(id);
const icon = conn?.isConnected() ? "🟢" : "🔴";
return `${icon} \`${meshSlugs.get(id) ?? id.slice(0, 12)}\``;
});
await ctx.reply(
`*Claudemesh Telegram Bridge*\n${lines.join("\n")}`,
{ parse_mode: "Markdown" },
);
});
// --- /help ---
bot.command("help", async (ctx) => {
await ctx.reply(
"🔗 *Claudemesh Telegram Bridge*\n\n" +
"*Commands:*\n" +
"• /start <token> — Connect via deep link\n" +
"• /connect — Link via email (coming soon)\n" +
"• /disconnect — Disconnect from mesh\n" +
"• /meshes — List connected meshes\n" +
"• /peers — List online peers\n" +
"• /dm <name> <msg> — DM a peer\n" +
"• /broadcast <msg> — Message all peers\n" +
"• /group @name <msg> — Message a group\n" +
"• /file <id> — Download a mesh file\n" +
"• /status — Connection status\n\n" +
"_Multi-mesh: prefix commands with mesh slug_\n" +
"`/peers dev-team` or `/dm dev-team:Mou hello`",
{ parse_mode: "Markdown" },
);
});
// --- Callback query handler (DM picker + file picker) ---
bot.on("callback_query:data", async (ctx) => {
const data = ctx.callbackQuery.data;
const chatId = ctx.chat?.id;
if (!chatId) { await ctx.answerCallbackQuery(); return; }
// --- AI action confirmation ---
if (data.startsWith("ai_")) {
const [action, actionId] = data.split(":");
if (!actionId) { await ctx.answerCallbackQuery(); return; }
const pending = pendingAiActions.get(actionId);
if (!pending || pending.chatId !== chatId) {
await ctx.answerCallbackQuery({ text: "Expired. Send your message again." });
return;
}
if (action === "ai_cancel") {
pendingAiActions.delete(actionId);
await ctx.answerCallbackQuery({ text: "Cancelled" });
await ctx.editMessageText("❌ Cancelled.");
return;
}
if (action === "ai_edit") {
pendingAiActions.delete(actionId);
await ctx.answerCallbackQuery({ text: "Type your edited message" });
await ctx.editMessageText("✏️ Type your message again with corrections.");
return;
}
if (action === "ai_confirm") {
pendingAiActions.delete(actionId);
await ctx.answerCallbackQuery({ text: "Executing..." });
try {
const { formatResult, recordToolResult } = await import("./telegram-ai");
const result = await executeAiToolCall(pending.toolCall, pending.meshIds);
const resultText = formatResult(pending.toolCall.name, result);
recordToolResult(chatId, pending.toolCall.name, resultText.replace(/<[^>]+>/g, "").slice(0, 200));
await ctx.editMessageText(resultText, { parse_mode: "HTML" });
} catch (err) {
await ctx.editMessageText(`❌ Failed: ${err instanceof Error ? err.message : String(err)}`);
}
return;
}
}
// --- File recipient picker ---
if (data.startsWith("file:")) {
const pending = pendingFiles.get(chatId);
if (!pending) {
await ctx.answerCallbackQuery({ text: "Session expired. Send the file again." });
return;
}
const conn = meshConnections.get(pending.meshId);
if (!conn?.isConnected()) {
pendingFiles.delete(chatId);
await ctx.answerCallbackQuery({ text: "Not connected." });
return;
}
const target = data.slice(5); // after "file:"
const emoji = pending.fileName.endsWith(".jpg") ? "📷" : "📎";
const captionSuffix = pending.caption ? ` — "${pending.caption}"` : "";
const fileMsg = `[via Telegram] ${emoji} ${pending.fileName}${captionSuffix} (file: ${pending.fileId})`;
if (target === "none") {
// Keep in mesh only — no message sent
pendingFiles.delete(chatId);
await ctx.answerCallbackQuery({ text: "Kept private" });
await ctx.editMessageText(`🔒 File stored in mesh. ID: \`${pending.fileId}\``, { parse_mode: "Markdown" });
return;
}
if (target === "*") {
// Broadcast to everyone
await conn.sendMessage("*", fileMsg, "next");
pendingFiles.delete(chatId);
await ctx.answerCallbackQuery({ text: "Sent to everyone" });
await ctx.editMessageText(`📢 ${emoji} Shared with everyone: \`${pending.fileName}\``, { parse_mode: "Markdown" });
return;
}
// Send to specific peer (target is pubkey prefix)
const peers = await conn.listPeers();
const peer = peers.find(p => p.pubkey.startsWith(target));
if (!peer) {
await ctx.answerCallbackQuery({ text: "Peer not found" });
return;
}
await conn.sendMessage(peer.pubkey, fileMsg, "now");
pendingFiles.delete(chatId);
await ctx.answerCallbackQuery({ text: `Sent to ${peer.displayName}` });
await ctx.editMessageText(
`${emoji} Sent to ${peer.avatar ?? "🤖"} *${escapeMarkdown(peer.displayName)}*: \`${escapeMarkdown(pending.fileName)}\``,
{ parse_mode: "Markdown" },
);
return;
}
// --- DM peer picker ---
if (!data.startsWith("dm:")) {
await ctx.answerCallbackQuery();
return;
}
const pending = pendingDMs.get(chatId);
if (!pending) {
await ctx.answerCallbackQuery({ text: "Session expired. Send /dm again." });
return;
}
const conn = meshConnections.get(pending.meshId);
if (!conn?.isConnected()) {
pendingDMs.delete(chatId);
await ctx.answerCallbackQuery({ text: "Not connected." });
return;
}
if (data === "dm:all") {
let sent = 0;
for (const p of pending.matches) {
const ok = await conn.sendMessage(
p.pubkey,
`[via Telegram] ${pending.message}`,
"now",
);
if (ok) sent++;
}
pendingDMs.delete(chatId);
await ctx.answerCallbackQuery({ text: `Sent to ${sent} peers` });
await ctx.editMessageText(
`✅ Sent to all ${sent} ${pending.matches[0]?.displayName ?? "?"} peers`,
);
return;
}
const idx = parseInt(data.slice(3));
const peer = pending.matches[idx];
if (!peer) {
await ctx.answerCallbackQuery({ text: "Invalid selection" });
return;
}
const ok = await conn.sendMessage(
peer.pubkey,
`[via Telegram] ${pending.message}`,
"now",
);
pendingDMs.delete(chatId);
const dir = peer.cwd?.split("/").pop() ?? "?";
await ctx.answerCallbackQuery({ text: ok ? "Sent!" : "Failed" });
await ctx.editMessageText(
ok
? `✅ → ${peer.avatar ?? "🤖"} ${peer.displayName} (${dir})`
: "❌ Not connected",
);
});
// --- Photo/Document upload → upload to mesh, then show recipient picker ---
async function handleFileUpload(
ctx: any,
tgFileId: string,
fileName: string,
isPhoto: boolean,
): Promise<void> {
const chatId = ctx.chat.id;
const meshIds = chatMeshes.get(chatId);
if (!meshIds || meshIds.length === 0) return;
const caption = ctx.message?.caption ?? "";
const emoji = isPhoto ? "📷" : "📎";
try {
const file = await ctx.api.getFile(tgFileId);
const url = `https://api.telegram.org/file/bot${botToken}/${file.file_path}`;
const resp = await fetch(url, { signal: AbortSignal.timeout(30_000) });
const buf = Buffer.from(await resp.arrayBuffer());
// Upload to first connected mesh
const meshId = meshIds[0]!;
const conn = meshConnections.get(meshId);
if (!conn?.isConnected()) {
await ctx.reply("❌ Not connected to mesh.");
return;
}
const meshFileId = await conn.uploadFile(buf, fileName, [
"telegram",
isPhoto ? "photo" : "document",
]);
if (!meshFileId) {
await ctx.reply("❌ Upload failed.");
return;
}
// Store pending file and show recipient picker
pendingFiles.set(chatId, { fileId: meshFileId, fileName, meshId, caption });
const peers = await conn.listPeers();
// Filter out the bridge itself
const targets = peers.filter(p => !p.displayName.startsWith("tg:"));
if (targets.length === 0) {
// No peers online — broadcast anyway
await conn.sendMessage("*", `[via Telegram] ${emoji} ${fileName}${caption ? ` — "${caption}"` : ""} (file: ${meshFileId})`, "next");
pendingFiles.delete(chatId);
await ctx.reply(`${emoji} Uploaded and broadcast (no peers online).`);
return;
}
// Build inline keyboard: top peers + Everyone + Keep private
const buttons: { text: string; callback_data: string }[][] = [];
const shown = targets.slice(0, 6); // Cap at 6 to avoid huge keyboard
for (const p of shown) {
buttons.push([{
text: `${p.avatar ?? "🤖"} ${p.displayName}`,
callback_data: `file:${p.pubkey.slice(0, 16)}`,
}]);
}
buttons.push([{ text: "📢 Everyone", callback_data: "file:*" }]);
buttons.push([{ text: "🔒 Keep in mesh only", callback_data: "file:none" }]);
await ctx.reply(`${emoji} *Uploaded:* \`${escapeMarkdown(fileName)}\`\nSend to:`, {
parse_mode: "Markdown",
reply_markup: { inline_keyboard: buttons },
});
} catch (e) {
await ctx.reply(`${e instanceof Error ? e.message : String(e)}`);
}
}
bot.on("message:photo", async (ctx) => {
const photo = ctx.message.photo.at(-1);
if (!photo) return;
await handleFileUpload(ctx, photo.file_id, `telegram-photo-${Date.now()}.jpg`, true);
});
bot.on("message:document", async (ctx) => {
const doc = ctx.message.document;
if (!doc) return;
await handleFileUpload(ctx, doc.file_id, doc.file_name ?? `telegram-file-${Date.now()}`, false);
});
// --- Default text handler: conversation state, invite URLs, @mentions, broadcast ---
bot.on("message:text", async (ctx) => {
const chatId = ctx.chat.id;
const text = ctx.message.text;
if (text.startsWith("/")) return; // Skip unknown commands
// --- Email verification conversation state ---
const state = conversationState.get(chatId);
if (state === "awaiting_email") {
const email = text.trim().toLowerCase();
if (!email.includes("@") || !email.includes(".")) {
await ctx.reply("That doesn't look like an email. Try again:");
return;
}
await startEmailVerification(ctx, chatId, email, lookupMeshesByEmail, sendVerificationEmail);
return;
}
if (state === "awaiting_code") {
const pending = pendingVerifications.get(chatId);
if (!pending) {
conversationState.delete(chatId);
await ctx.reply("Session expired. Type /connect to start again.");
return;
}
// Check expiry
if (Date.now() > pending.expiresAt) {
pendingVerifications.delete(chatId);
conversationState.delete(chatId);
await ctx.reply("⏰ Code expired. Type /connect to get a new one.");
return;
}
const inputCode = text.trim().replace(/\s/g, "");
// Check attempts
pending.attempts++;
if (pending.attempts > 5) {
pendingVerifications.delete(chatId);
conversationState.delete(chatId);
await ctx.reply("❌ Too many attempts. Type /connect to start again.");
return;
}
if (inputCode !== pending.code) {
await ctx.reply(`❌ Wrong code. ${5 - pending.attempts} attempts left.`);
return;
}
// Code correct — connect to all meshes for this email
pendingVerifications.delete(chatId);
conversationState.delete(chatId);
const meshes = await lookupMeshesByEmail(pending.email);
if (meshes.length === 0) {
await ctx.reply("❌ No meshes found. The account may have been removed.");
return;
}
const chatType = ctx.chat.type;
const chatTitle =
ctx.chat.type === "private"
? (ctx.from?.first_name ?? "Private")
: ("title" in ctx.chat ? ctx.chat.title : null) ?? "Group";
const displayName = `tg:${chatTitle}`;
let connected = 0;
for (const m of meshes) {
try {
// Skip if already connected
const existing = chatMeshes.get(chatId);
if (existing?.includes(m.meshId)) { connected++; continue; }
await saveBridge({
chatId,
meshId: m.meshId,
memberId: m.memberId,
pubkey: m.pubkey,
secretKey: m.secretKey,
displayName,
chatType,
chatTitle,
});
await ensureMeshConnection(
{ meshId: m.meshId, memberId: m.memberId, pubkey: m.pubkey, secretKey: m.secretKey, displayName, brokerUrl },
pushHandler,
);
linkChatMesh(chatId, m.meshId);
connected++;
} catch (e) {
console.error(`[tg-bridge] /connect failed for mesh ${m.meshId.slice(0, 8)}:`, e);
}
}
if (connected === 0) {
await ctx.reply("❌ Connection failed for all meshes.");
} else if (meshes.length === 1) {
await ctx.reply(
`✅ Connected to mesh *${escapeMarkdown(meshes[0]!.meshSlug)}*\\!`,
{ parse_mode: "MarkdownV2" },
);
} else {
const names = meshes.map(m => m.meshSlug).join(", ");
await ctx.reply(`✅ Connected to ${connected} mesh(es): ${names}`);
}
return;
}
// --- Invite URL detection ---
const inviteMatch = text.match(INVITE_URL_RE);
if (inviteMatch) {
const inviteToken = inviteMatch[1]!;
await ctx.reply(
`🔗 Detected invite link.\n\nTo connect, use the deep link from the dashboard or CLI.\nInvite token: \`${inviteToken}\``,
{ parse_mode: "Markdown" },
);
return;
}
const meshIds = chatMeshes.get(chatId);
if (!meshIds || meshIds.length === 0) {
// Not connected — ignore non-command messages
return;
}
// --- @Mention pattern: "@PeerName message" ---
const mentionMatch = text.match(/^@(\S+)\s+([\s\S]+)$/);
if (mentionMatch) {
const target = mentionMatch[1]!;
const message = mentionMatch[2]!;
// For multi-mesh, try all connections
for (const meshId of meshIds) {
const conn = meshConnections.get(meshId);
if (!conn?.isConnected()) continue;
const matches = await conn.findPeersByName(target);
if (matches.length === 0) continue;
if (matches.length === 1) {
const ok = await conn.sendMessage(
matches[0]!.pubkey,
`[via Telegram] ${message}`,
"now",
);
await ctx.reply(
ok
? `✅ → ${matches[0]!.avatar ?? "🤖"} ${matches[0]!.displayName}`
: "❌ Not connected",
);
return;
}
// Multiple matches — picker
pendingDMs.set(chatId, { message, matches, meshId });
const buttons = matches.map((p, i) => {
const dir = p.cwd?.split("/").pop() ?? "?";
return [
{
text: `${p.avatar ?? "🤖"} ${p.displayName} (${dir})`,
callback_data: `dm:${i}`,
},
];
});
buttons.push([
{ text: "📨 Send to ALL", callback_data: "dm:all" },
]);
await ctx.reply(
`Multiple "${target}" peers. Pick one or all:`,
{ reply_markup: { inline_keyboard: buttons } },
);
return;
}
await ctx.reply(`❌ No peer named "${target}" in any connected mesh.`);
return;
}
// --- No mention → process through Claude AI ---
try {
const { processMessage, formatConfirmation, formatResult, CONFIRM_ACTIONS } = await import("./telegram-ai");
// Gather context for the AI
const firstMeshId = meshIds[0]!;
const firstConn = meshConnections.get(firstMeshId);
const allMeshSlugs = meshIds.map(id => meshSlugs.get(id) ?? id.slice(0, 12));
let recentPeers: string[] = [];
if (firstConn?.isConnected()) {
try {
const peers = await firstConn.listPeers();
recentPeers = peers.map(p => p.displayName);
} catch {}
}
const result = await processMessage(chatId, text, {
meshSlug: allMeshSlugs[0],
meshSlugs: allMeshSlugs,
userName: ctx.from?.first_name,
recentPeers,
});
if (result.type === "error") {
await ctx.reply(stripMarkdown(result.text ?? "Something went wrong."));
return;
}
if (result.type === "text") {
await ctx.reply(stripMarkdown(result.text ?? ""));
return;
}
if (result.type === "tool_call" && result.toolCall) {
if (result.requiresConfirmation) {
// Store pending action and show confirmation buttons
const actionId = `ai_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
pendingAiActions.set(actionId, {
chatId,
meshIds,
toolCall: result.toolCall,
expiresAt: Date.now() + 5 * 60 * 1000,
});
const confirmText = formatConfirmation(result.toolCall);
await ctx.reply(confirmText, {
parse_mode: "HTML",
reply_markup: {
inline_keyboard: [
[
{ text: "✅ Confirm", callback_data: `ai_confirm:${actionId}` },
{ text: "✏️ Edit", callback_data: `ai_edit:${actionId}` },
{ text: "❌ Cancel", callback_data: `ai_cancel:${actionId}` },
],
],
},
});
} else {
// Read-only action — execute immediately
const execResult = await executeAiToolCall(result.toolCall, meshIds);
const resultText = formatResult(result.toolCall.name, execResult);
// Record in conversation history
const { recordToolResult } = await import("./telegram-ai");
recordToolResult(chatId, result.toolCall.name, resultText.replace(/<[^>]+>/g, "").slice(0, 200));
await ctx.reply(resultText, {
parse_mode: "HTML",
});
}
}
} catch (err) {
log.error("telegram-ai-handler", { error: err instanceof Error ? err.message : String(err) });
// Fallback: broadcast the text directly
let sent = 0;
for (const meshId of meshIds) {
const conn = meshConnections.get(meshId);
if (!conn?.isConnected()) continue;
const ok = await conn.sendMessage("*", `[via Telegram] ${text}`, "next");
if (ok) sent++;
}
if (sent === 0) await ctx.reply("❌ Not connected.");
}
});
}
// ---------------------------------------------------------------------------
// AI tool call executor
// ---------------------------------------------------------------------------
async function executeAiToolCall(
toolCall: { name: string; input: Record<string, unknown> },
meshIds: string[],
): Promise<unknown> {
const firstMeshId = meshIds[0];
if (!firstMeshId) throw new Error("No mesh connected");
const conn = meshConnections.get(firstMeshId);
if (!conn?.isConnected()) throw new Error("Not connected to mesh");
switch (toolCall.name) {
case "send_message": {
const to = String(toolCall.input.to ?? "*");
const message = String(toolCall.input.message ?? "");
const priority = String(toolCall.input.priority ?? "next");
// Resolve peer name → pubkey
let targetSpec = to;
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/.test(to)) {
const peers = await conn.listPeers();
const match = peers.find(p => p.displayName.toLowerCase() === to.toLowerCase());
if (!match) {
const partials = peers.filter(p => p.displayName.toLowerCase().includes(to.toLowerCase()));
if (partials.length === 1) targetSpec = partials[0]!.pubkey;
else throw new Error(`Peer "${to}" not found`);
} else {
targetSpec = match.pubkey;
}
}
const ok = await conn.sendMessage(targetSpec, `[via Telegram] ${message}`, priority as "now" | "next" | "low");
if (!ok) throw new Error("Send failed");
return { ok: true };
}
case "list_peers":
return conn.listPeers();
case "list_services": {
// Query deployed services from the broker DB
try {
const { listDbMeshServices } = await import("./broker");
const allServices: Array<{ name: string; type: string; tools: number; status: string }> = [];
for (const meshId of meshIds) {
const services = await listDbMeshServices(meshId);
for (const s of services) {
allServices.push({
name: s.name,
type: s.type ?? "mcp",
tools: s.tool_count ?? 0,
status: s.status ?? "running",
});
}
}
return allServices;
} catch {
return [];
}
}
case "list_meshes": {
const results: Array<{ slug: string; peers: number }> = [];
for (const meshId of meshIds) {
const conn = meshConnections.get(meshId);
const slug = meshSlugs.get(meshId) ?? meshId.slice(0, 12);
let peerCount = 0;
if (conn?.isConnected()) {
try {
const peers = await conn.listPeers();
peerCount = peers.length;
} catch {}
}
results.push({ slug, peers: peerCount });
}
return results;
}
case "list_commands":
return null; // The formatter handles this — no execution needed
case "remember":
case "recall":
case "get_state":
case "set_state":
throw new Error(`${toolCall.name} not yet available via Telegram. Use the CLI.`);
default:
throw new Error(`Unknown tool: ${toolCall.name}`);
}
}
// ---------------------------------------------------------------------------
// Ensure a mesh WS connection exists (create or reuse)
// ---------------------------------------------------------------------------
async function ensureMeshConnection(
creds: MeshCredentials,
pushHandler: (
meshId: string,
from: string,
text: string,
priority: string,
) => void,
): Promise<MeshConnection> {
const existing = meshConnections.get(creds.meshId);
if (existing?.isConnected()) return existing;
// Close stale connection if any
if (existing) {
existing.close();
meshConnections.delete(creds.meshId);
}
const conn = new MeshConnection(creds, pushHandler);
meshConnections.set(creds.meshId, conn);
await conn.connect();
await conn.setSummary(
"Telegram bridge — relays messages between Telegram chats and mesh peers",
);
return conn;
}
// ---------------------------------------------------------------------------
// Boot — called by broker on startup
// ---------------------------------------------------------------------------
export async function bootTelegramBridge(
loadActiveBridges: () => Promise<BridgeRow[]>,
saveBridge: (
row: Omit<BridgeRow, "chatId"> & { chatId: number },
) => Promise<void>,
deactivateBridge: (chatId: number, meshId: string) => Promise<void>,
botToken: string,
brokerUrl: string,
lookupMeshesByEmail?: (email: string) => Promise<UserMeshInfo[]>,
sendVerificationEmail?: (email: string, code: string) => Promise<boolean>,
): Promise<void> {
await ensureSodium();
const bot = new Bot(botToken);
const pushHandler = createPushHandler(bot);
// Load all active bridges from DB
const rows = await loadActiveBridges();
console.log(`[tg-bridge] loaded ${rows.length} active bridge(s) from DB`);
// Group by meshId to connect WS pool
const byMesh = new Map<string, BridgeRow[]>();
for (const row of rows) {
const arr = byMesh.get(row.meshId) ?? [];
arr.push(row);
byMesh.set(row.meshId, arr);
}
// Connect one WS per unique mesh
for (const [meshId, meshRows] of byMesh) {
const first = meshRows[0]!;
try {
await ensureMeshConnection(
{
meshId,
memberId: first.memberId,
pubkey: first.pubkey,
secretKey: first.secretKey,
displayName: first.displayName,
brokerUrl,
},
pushHandler,
);
console.log(
`[tg-bridge] connected WS for mesh ${meshId.slice(0, 8)} (${meshRows.length} chat(s))`,
);
} catch (e) {
console.error(
`[tg-bridge] failed to connect mesh ${meshId.slice(0, 8)}:`,
e,
);
}
// Populate routing maps and slug cache for all chats in this mesh
for (const row of meshRows) {
linkChatMesh(row.chatId, meshId);
if (row.meshSlug) meshSlugs.set(meshId, row.meshSlug);
}
}
// Grammy global error handler — prevents unhandled rejections from crashing broker
bot.catch((err) => {
console.error("[tg-bridge] Grammy error:", err.message ?? err);
});
// Expire stale pendingDMs entries every 5 minutes (prevent memory leak)
setInterval(() => {
// pendingDMs/pendingFiles have no timestamp, so we cap size — clear all if > 1000
if (pendingDMs.size > 1000) {
console.warn(`[tg-bridge] clearing ${pendingDMs.size} stale pendingDMs`);
pendingDMs.clear();
}
if (pendingFiles.size > 1000) {
console.warn(`[tg-bridge] clearing ${pendingFiles.size} stale pendingFiles`);
pendingFiles.clear();
}
}, 5 * 60_000).unref();
// Default stubs if email callbacks not provided
const emailLookup = lookupMeshesByEmail ?? (async () => []);
const emailSend = sendVerificationEmail ?? (async () => false);
// Wire up bot commands
setupBotCommands(
bot,
botToken,
brokerUrl,
saveBridge,
deactivateBridge,
pushHandler,
emailLookup,
emailSend,
);
// Start Grammy long-polling (fire-and-forget, must not crash broker)
console.log("[tg-bridge] starting bot...");
bot.start({
onStart: () =>
console.log(
`[tg-bridge] bot running — ${meshConnections.size} mesh(es), ${chatMeshes.size} chat(s)`,
),
}).catch((err: unknown) => {
console.error("[tg-bridge] bot.start() error:", err instanceof Error ? err.message : String(err));
});
}
// ---------------------------------------------------------------------------
// Connect a new chat at runtime (called from broker HTTP endpoints)
// ---------------------------------------------------------------------------
export async function connectChat(
chatId: number,
chatType: string,
chatTitle: string | null,
meshCreds: MeshCredentials,
pushHandler?: (
meshId: string,
from: string,
text: string,
priority: string,
) => void,
): Promise<void> {
// Default push handler is a no-op if bot isn't running yet
// (the real one is wired during bootTelegramBridge)
const handler = pushHandler ?? (() => {});
await ensureMeshConnection(meshCreds, handler);
linkChatMesh(chatId, meshCreds.meshId);
console.log(
`[tg-bridge] chat ${chatId} (${chatType}) connected to mesh ${meshCreds.meshId.slice(0, 8)}`,
);
}