425 lines
15 KiB
TypeScript
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
export { CONFIRM_ACTIONS };
|