Files
claudemesh/apps/broker/src/telegram-ai.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

425 lines
15 KiB
TypeScript

/**
* Claude-powered natural language processing for Telegram mesh interactions.
*
* Uses Claude Haiku 4.5 with tool calling to interpret user intent
* and map to mesh operations. Destructive/social actions require
* confirmation via Telegram inline buttons.
*/
import Anthropic from "@anthropic-ai/sdk";
import { env } from "./env";
import { log } from "./logger";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface AiTool {
name: string;
description: string;
input_schema: Record<string, unknown>;
}
export interface AiToolCall {
name: string;
input: Record<string, unknown>;
}
export interface AiResult {
type: "text" | "tool_call" | "error";
text?: string;
toolCall?: AiToolCall;
requiresConfirmation?: boolean;
}
// ---------------------------------------------------------------------------
// Tools definition
// ---------------------------------------------------------------------------
const TOOLS: AiTool[] = [
{
name: "send_message",
description: "Send a message to a peer in the mesh. Use when the user wants to tell, ask, or communicate something to a specific person or group.",
input_schema: {
type: "object",
properties: {
to: { type: "string", description: "Peer name, @group, or * for broadcast" },
message: { type: "string", description: "The message content" },
priority: { type: "string", enum: ["now", "next", "low"], description: "Delivery priority (default: next)" },
},
required: ["to", "message"],
},
},
{
name: "list_peers",
description: "List all connected peers in the mesh. Use when user asks who's online, who's available, or what everyone is doing.",
input_schema: {
type: "object",
properties: {},
},
},
{
name: "list_meshes",
description: "List all meshes this Telegram chat is connected to. Use when user asks about their meshes, which meshes are available, or wants to see their workspace list.",
input_schema: {
type: "object",
properties: {},
},
},
{
name: "remember",
description: "Store a memory/note in the mesh's shared knowledge. Use when user wants to save information for later.",
input_schema: {
type: "object",
properties: {
content: { type: "string", description: "The content to remember" },
tags: { type: "array", items: { type: "string" }, description: "Tags for categorization" },
},
required: ["content"],
},
},
{
name: "recall",
description: "Search the mesh's shared memory. Use when user asks about something that was previously stored.",
input_schema: {
type: "object",
properties: {
query: { type: "string", description: "Search query" },
},
required: ["query"],
},
},
{
name: "get_state",
description: "Read a shared state value. Use when user asks about a specific key/variable.",
input_schema: {
type: "object",
properties: {
key: { type: "string", description: "State key to read" },
},
required: ["key"],
},
},
{
name: "set_state",
description: "Write a shared state value. Use when user wants to set/update a key.",
input_schema: {
type: "object",
properties: {
key: { type: "string", description: "State key" },
value: { type: "string", description: "Value to set" },
},
required: ["key", "value"],
},
},
{
name: "create_mesh",
description: "Create a new mesh. Use when user wants to create a new workspace/mesh.",
input_schema: {
type: "object",
properties: {
name: { type: "string", description: "Mesh name" },
},
required: ["name"],
},
},
{
name: "share_mesh",
description: "Generate an invite link or send an invite email. Use when user wants to invite someone to the mesh.",
input_schema: {
type: "object",
properties: {
email: { type: "string", description: "Email to invite (optional — if omitted, generates a link)" },
},
},
},
{
name: "list_services",
description: "List all deployed MCP services and skills in the mesh. Use when user asks about available tools, services, MCPs, skills, or capabilities.",
input_schema: {
type: "object",
properties: {},
},
},
{
name: "list_commands",
description: "Show available Telegram bot commands. Use when user asks what commands are available, what they can do, or asks for help.",
input_schema: {
type: "object",
properties: {},
},
},
];
// Actions that need user confirmation before executing
const CONFIRM_ACTIONS = new Set([
"send_message",
"create_mesh",
"share_mesh",
"set_state",
"remember",
]);
const SYSTEM_PROMPT = `You are the claudemesh Telegram assistant. You help users interact with their claudemesh peer network using natural language.
You have access to tools for mesh operations. When the user's intent maps to a tool, use it. When it's a general question or conversation, respond directly.
IMPORTANT: Always respond in the same language the user writes in. If they write in Spanish, respond in Spanish. If English, respond in English.
Key concepts:
- A MESH is a workspace/group (like "flexicar", "alexis-mou"). This Telegram chat can be connected to multiple meshes.
- A PEER is a person/agent connected to a mesh (like "Nedas", "Mou").
- When user says "send to <mesh-name>", they mean BROADCAST to all peers in that mesh. Use send_message with to="*" — the system will route to the correct mesh.
- When user says "send to <person-name>", they mean a direct message to that peer.
Rules:
- Be concise — Telegram messages should be short
- When sending messages to peers, preserve the user's tone and intent
- If the target looks like a mesh name (matches one from context), broadcast to it
- Never fabricate peer names — use list_peers to find real names
- Default to the first connected mesh if not specified`;
// ---------------------------------------------------------------------------
// AI Engine
// ---------------------------------------------------------------------------
let client: Anthropic | null = null;
function getClient(): Anthropic {
if (!client) {
const apiKey = env.ANTHROPIC_API_KEY;
if (!apiKey) throw new Error("ANTHROPIC_API_KEY not configured");
client = new Anthropic({ apiKey });
}
return client;
}
// ---------------------------------------------------------------------------
// Conversation history (per chat, rolling window)
// ---------------------------------------------------------------------------
const MAX_HISTORY = 10;
const HISTORY_TTL_MS = 30 * 60 * 1000; // 30 min
interface HistoryEntry {
role: "user" | "assistant";
content: string;
ts: number;
}
const chatHistory = new Map<number, HistoryEntry[]>();
// Clean stale histories every 10 min
setInterval(() => {
const now = Date.now();
for (const [chatId, entries] of chatHistory) {
const fresh = entries.filter(e => now - e.ts < HISTORY_TTL_MS);
if (fresh.length === 0) chatHistory.delete(chatId);
else chatHistory.set(chatId, fresh);
}
}, 10 * 60 * 1000);
function getHistory(chatId: number): HistoryEntry[] {
return chatHistory.get(chatId) ?? [];
}
function pushHistory(chatId: number, role: "user" | "assistant", content: string): void {
const entries = chatHistory.get(chatId) ?? [];
entries.push({ role, content, ts: Date.now() });
if (entries.length > MAX_HISTORY * 2) entries.splice(0, entries.length - MAX_HISTORY * 2);
chatHistory.set(chatId, entries);
}
/**
* Record a tool result in conversation history so the AI knows what happened.
*/
export function recordToolResult(chatId: number, toolName: string, resultSummary: string): void {
pushHistory(chatId, "assistant", `[Tool ${toolName} result]: ${resultSummary}`);
}
/**
* Process a natural language message through Claude and return the intent.
*/
export async function processMessage(
chatId: number,
userMessage: string,
context: { meshSlug?: string; meshSlugs?: string[]; userName?: string; recentPeers?: string[] },
): Promise<AiResult> {
try {
const anthropic = getClient();
// Record user message in history
pushHistory(chatId, "user", userMessage);
const contextInfo = [
context.meshSlugs?.length ? `Connected meshes: ${context.meshSlugs.join(", ")}` : context.meshSlug ? `Current mesh: ${context.meshSlug}` : null,
context.userName ? `User's name: ${context.userName}` : null,
context.recentPeers?.length ? `Known peers: ${context.recentPeers.join(", ")}` : null,
].filter(Boolean).join(". ");
// Build message history for multi-turn context
const history = getHistory(chatId);
const messages: Array<{ role: "user" | "assistant"; content: string }> = [];
for (const entry of history) {
// Alternate roles — Claude API requires user/assistant alternation
if (messages.length === 0 || messages[messages.length - 1]!.role !== entry.role) {
messages.push({ role: entry.role, content: entry.content });
} else {
// Same role consecutive — merge into the last message
messages[messages.length - 1]!.content += "\n" + entry.content;
}
}
// Ensure messages start with user and alternate
if (messages.length > 0 && messages[0]!.role !== "user") {
messages.shift();
}
const response = await anthropic.messages.create({
model: "claude-haiku-4-5-20251001",
max_tokens: 500,
system: SYSTEM_PROMPT + (contextInfo ? `\n\nContext: ${contextInfo}` : ""),
tools: TOOLS as Anthropic.Messages.Tool[],
messages,
});
// Check for tool use
for (const block of response.content) {
if (block.type === "tool_use") {
pushHistory(chatId, "assistant", `[Using tool: ${block.name}(${JSON.stringify(block.input).slice(0, 100)})]`);
return {
type: "tool_call",
toolCall: { name: block.name, input: block.input as Record<string, unknown> },
requiresConfirmation: CONFIRM_ACTIONS.has(block.name),
};
}
if (block.type === "text") {
pushHistory(chatId, "assistant", block.text);
return { type: "text", text: block.text };
}
}
return { type: "text", text: "I'm not sure how to help with that." };
} catch (err) {
log.error("telegram-ai", { error: err instanceof Error ? err.message : String(err) });
return { type: "error", text: "AI processing failed. Try a /command instead." };
}
}
/**
* Format a tool call as a human-readable confirmation message for Telegram.
*/
export function formatConfirmation(toolCall: AiToolCall): string {
const { name, input } = toolCall;
switch (name) {
case "send_message":
return `📤 <b>Send message to ${escHtml(String(input.to))}:</b>\n\n"${escHtml(String(input.message))}"\n\nPriority: ${input.priority ?? "next"}`;
case "create_mesh":
return `🔧 <b>Create mesh:</b>\n\nName: ${escHtml(String(input.name))}`;
case "share_mesh":
return input.email
? `📧 <b>Send invite to:</b>\n\n${escHtml(String(input.email))}`
: `🔗 <b>Generate invite link</b>`;
case "set_state":
return `📝 <b>Set state:</b>\n\n<code>${escHtml(String(input.key))}</code> = <code>${escHtml(String(input.value))}</code>`;
case "remember":
return `💾 <b>Remember:</b>\n\n"${escHtml(String(input.content))}"${input.tags ? `\nTags: ${(input.tags as string[]).join(", ")}` : ""}`;
default:
return `⚙️ <b>${escHtml(name)}:</b>\n\n<pre>${escHtml(JSON.stringify(input, null, 2))}</pre>`;
}
}
/**
* Format a tool result as a Telegram reply.
*/
export function formatResult(toolName: string, result: unknown): string {
switch (toolName) {
case "send_message":
return "✅ Message sent.";
case "list_peers": {
const peers = result as Array<{ displayName: string; status: string; summary?: string }>;
if (!peers || peers.length === 0) return "No peers online.";
return "👥 <b>Online peers:</b>\n\n" + peers.map(p => {
const icon = p.status === "idle" ? "🟢" : p.status === "working" ? "🟡" : p.status === "dnd" ? "🔴" : "⚪";
return `${icon} <b>${escHtml(p.displayName)}</b>${p.summary ? `${escHtml(p.summary)}` : ""}`;
}).join("\n");
}
case "list_meshes": {
const meshes = result as Array<{ slug: string; peers: number }>;
if (!meshes || meshes.length === 0) return "No meshes connected. Use /connect to add one.";
return "🔗 <b>Connected meshes:</b>\n\n" + meshes.map(m =>
`• <b>${escHtml(m.slug)}</b> — ${m.peers} peer${m.peers !== 1 ? "s" : ""} online`
).join("\n");
}
case "recall": {
const memories = result as Array<{ content: string; tags: string[] }>;
if (!memories || memories.length === 0) return "No memories found.";
return "🧠 <b>Memories:</b>\n\n" + memories.map(m =>
`${escHtml(m.content)}${m.tags.length ? ` <i>[${m.tags.join(", ")}]</i>` : ""}`
).join("\n");
}
case "get_state": {
const state = result as { key: string; value: unknown } | null;
if (!state) return "Key not found.";
return `📊 <code>${escHtml(state.key)}</code> = <code>${escHtml(String(state.value))}</code>`;
}
case "remember":
return "💾 Remembered.";
case "set_state":
return "📝 State updated.";
case "create_mesh":
return "✅ Mesh created.";
case "share_mesh":
return typeof result === "string" ? `🔗 Invite: ${result}` : "✅ Invite sent.";
case "list_services": {
const services = result as Array<{ name: string; type: string; tools: number; status: string }>;
if (!services || services.length === 0) return "No services deployed in this mesh.";
return "⚙️ <b>Mesh services:</b>\n\n" + services.map(s =>
`• <b>${escHtml(s.name)}</b> (${s.type}) — ${s.tools} tool${s.tools !== 1 ? "s" : ""} [${s.status}]`
).join("\n");
}
case "list_commands":
return `📋 <b>Available commands:</b>
/connect — connect to a mesh
/disconnect — disconnect from mesh
/peers — list online peers
/meshes — list connected meshes
/dm @Name message — send direct message
/broadcast message — send to all peers
/status — connection status
/help — show help
Or just type naturally:
• "who's online?"
• "tell Nedas the API is ready"
• "list my meshes"
• "what services are available?"`;
default:
return `✅ Done: ${JSON.stringify(result)}`;
}
}
function escHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
export { CONFIRM_ACTIONS };