diff --git a/packages/plugins/anthropic/.gitignore b/packages/plugins/anthropic/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/packages/plugins/anthropic/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/plugins/anthropic/package.json b/packages/plugins/anthropic/package.json new file mode 100644 index 000000000..c0ebbc7d1 --- /dev/null +++ b/packages/plugins/anthropic/package.json @@ -0,0 +1,42 @@ +{ + "name": "@builderbot/plugin-anthropic", + "version": "1.3.15-alpha.21", + "description": "Anthropic Claude AI plugin for BuilderBot", + "author": "BuilderBot", + "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", + "license": "ISC", + "main": "dist/index.cjs", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "build": "rimraf dist && rollup --config", + "test": "jest --forceExit", + "test:coverage": "jest --coverage" + }, + "files": [ + "./dist/" + ], + "directories": { + "src": "src", + "test": "__tests__" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" + }, + "bugs": { + "url": "https://github.com/codigoencasa/bot-whatsapp/issues" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "@builderbot/bot": "^1.3.15-alpha.21" + }, + "devDependencies": { + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-typescript": "^12.3.0", + "@types/node": "^24.10.2", + "rimraf": "^6.1.2", + "tslib": "^2.8.1", + "typescript": "^5.9.3" + } +} diff --git a/packages/plugins/anthropic/rollup.config.js b/packages/plugins/anthropic/rollup.config.js new file mode 100644 index 000000000..cb2bc5182 --- /dev/null +++ b/packages/plugins/anthropic/rollup.config.js @@ -0,0 +1,20 @@ +import typescript from '@rollup/plugin-typescript' +import { nodeResolve } from '@rollup/plugin-node-resolve' + +export default { + input: ['src/index.ts'], + output: [ + { + dir: 'dist', + entryFileNames: '[name].cjs', + format: 'cjs', + exports: 'named', + }, + ], + plugins: [ + nodeResolve({ + resolveOnly: (module) => !/@anthropic-ai|@builderbot\/bot/i.test(module), + }), + typescript(), + ], +} diff --git a/packages/plugins/anthropic/src/anthropic.class.ts b/packages/plugins/anthropic/src/anthropic.class.ts new file mode 100644 index 000000000..c386e8297 --- /dev/null +++ b/packages/plugins/anthropic/src/anthropic.class.ts @@ -0,0 +1,304 @@ +import Anthropic from '@anthropic-ai/sdk' +import { CoreClass } from '@builderbot/bot' +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' + +import { ConversationHistory } from './history' +import type { AnthropicContextOptions, ContentBlock, MessageContextIncoming, TextBlock, ImageBlock } from './types' +import { fileToBase64, getImageMediaType, isImageFile, isAudioFile, isVideoFile, isPdfFile } from './utils' + +const DEFAULT_MODEL = 'claude-sonnet-4-20250514' +const DEFAULT_MAX_HISTORY = 10 +const DEFAULT_MAX_TOKENS = 4096 +const DEFAULT_THINKING_BUDGET = 10000 + +/** + * Lee el token OAuth de Claude setup desde ~/.claude/.credentials.json + * Este archivo es generado por `claude setup-token` (Claude Code CLI). + * Compatible con el patron de OpenClaw para autenticacion via setup-token. + */ +function readClaudeSetupToken(): string | null { + const credentialsPath = join(homedir(), '.claude', '.credentials.json') + try { + if (!existsSync(credentialsPath)) return null + const raw = readFileSync(credentialsPath, 'utf-8') + const credentials = JSON.parse(raw) + + if (credentials.accessToken && typeof credentials.accessToken === 'string') { + // Verificar que no haya expirado + if (credentials.expiresAt) { + const expiresAt = new Date(credentials.expiresAt).getTime() + if (Date.now() >= expiresAt) { + console.warn('[plugin-anthropic] El token de Claude setup ha expirado. Ejecuta `claude setup-token` para renovarlo.') + return null + } + } + return credentials.accessToken + } + } catch { + // Silenciar errores de lectura/parse + } + return null +} + +/** + * Resuelve la API key/token siguiendo el orden de precedencia: + * 1. apiKey proporcionada directamente en opciones + * 2. Variable de entorno ANTHROPIC_API_KEY + * 3. Variable de entorno ANTHROPIC_AUTH_TOKEN + * 4. Token OAuth de Claude setup (~/.claude/.credentials.json) + */ +function resolveApiKey(optionsApiKey?: string): { apiKey?: string; authToken?: string } { + // 1. API key directa + if (optionsApiKey) { + return { apiKey: optionsApiKey } + } + + // 2. Env ANTHROPIC_API_KEY + if (process.env.ANTHROPIC_API_KEY) { + return { apiKey: process.env.ANTHROPIC_API_KEY } + } + + // 3. Env ANTHROPIC_AUTH_TOKEN (Bearer token) + if (process.env.ANTHROPIC_AUTH_TOKEN) { + return { authToken: process.env.ANTHROPIC_AUTH_TOKEN } + } + + // 4. Claude setup-token (~/.claude/.credentials.json) + const setupToken = readClaudeSetupToken() + if (setupToken) { + return { authToken: setupToken } + } + + return {} +} + +export class AnthropicContext extends CoreClass { + private client: Anthropic + private history: ConversationHistory + private options: Required< + Pick + > & + AnthropicContextOptions + + constructor(_database: any, _provider: any, _optionsDX: AnthropicContextOptions = {}) { + super(null, _database, _provider, null) + this.options = { + model: DEFAULT_MODEL, + maxHistoryLength: DEFAULT_MAX_HISTORY, + maxTokens: DEFAULT_MAX_TOKENS, + ..._optionsDX, + } + this.init() + } + + private init(): void { + const { apiKey, authToken } = resolveApiKey(this.options.apiKey) + + if (!apiKey && !authToken) { + throw new Error( + 'Anthropic API key no encontrada. Opciones disponibles:\n' + + ' 1. Proporciona apiKey en las opciones del plugin\n' + + ' 2. Define la variable de entorno ANTHROPIC_API_KEY\n' + + ' 3. Define la variable de entorno ANTHROPIC_AUTH_TOKEN\n' + + ' 4. Ejecuta `claude setup-token` para generar un token OAuth en ~/.claude/.credentials.json' + ) + } + + if (authToken) { + // OAuth token (setup-token) - se envia como Bearer header via authToken + this.client = new Anthropic({ apiKey: authToken }) + } else { + // API key directa - se envia como X-Api-Key header + this.client = new Anthropic({ apiKey }) + } + + this.history = new ConversationHistory(this.options.maxHistoryLength) + } + + /** + * Procesa un mensaje entrante, lo envia a Claude y responde al usuario. + */ + handleMsg = async (messageCtxInComming: MessageContextIncoming): Promise => { + const { from, body } = messageCtxInComming + const userContent = await this.buildUserContent(messageCtxInComming) + + this.history.addEntry(from, 'user', userContent) + + const responseText = await this.chat(from) + + this.history.addEntry(from, 'assistant', responseText) + + await this.handleSummary(from) + + this.sendFlowSimple([{ answer: responseText }], from) + } + + /** + * Construye el contenido del mensaje del usuario, incluyendo media si existe. + */ + private async buildUserContent(ctx: MessageContextIncoming): Promise { + const { body } = ctx + const mediaUrl: string | undefined = ctx.url || ctx.media + + if (!mediaUrl) { + return body || '' + } + + try { + const filePath = await this.provider?.saveFile?.(ctx, { path: process.cwd() }) + if (!filePath) { + return body || '' + } + + if (isImageFile(filePath)) { + return this.buildImageContent(filePath, body) + } + + if (isAudioFile(filePath)) { + const blocks: ContentBlock[] = [ + { type: 'text', text: `[El usuario envio un mensaje de audio]${body ? `\n\nTexto adjunto: ${body}` : ''}` }, + ] + return blocks + } + + if (isVideoFile(filePath)) { + const blocks: ContentBlock[] = [ + { type: 'text', text: `[El usuario envio un video]${body ? `\n\nTexto adjunto: ${body}` : ''}` }, + ] + return blocks + } + + if (isPdfFile(filePath)) { + return this.buildPdfContent(filePath, body) + } + } catch { + // Si falla el procesamiento de media, enviar solo texto + } + + return body || '' + } + + private buildImageContent(filePath: string, body?: string): ContentBlock[] { + const base64 = fileToBase64(filePath) + const mediaType = getImageMediaType(filePath) + const blocks: ContentBlock[] = [ + { + type: 'image', + source: { type: 'base64', media_type: mediaType, data: base64 }, + } as ImageBlock, + ] + if (body) { + blocks.push({ type: 'text', text: body } as TextBlock) + } + return blocks + } + + private buildPdfContent(filePath: string, body?: string): any[] { + const base64 = fileToBase64(filePath) + const blocks: any[] = [ + { + type: 'document', + source: { type: 'base64', media_type: 'application/pdf', data: base64 }, + }, + ] + if (body) { + blocks.push({ type: 'text', text: body }) + } + return blocks + } + + /** + * Envia el historial de conversacion a la API de Anthropic y retorna la respuesta. + */ + private async chat(from: string): Promise { + const messages = this.history.toAnthropicMessages(from) + const { model, maxTokens, systemPrompt, thinking } = this.options + + const params: any = { + model, + max_tokens: maxTokens, + messages, + } + + if (systemPrompt) { + params.system = systemPrompt + } + + if (thinking?.enabled) { + const budgetTokens = thinking.budgetTokens ?? DEFAULT_THINKING_BUDGET + params.thinking = { type: 'enabled', budget_tokens: budgetTokens } + // Cuando thinking esta habilitado, max_tokens debe ser mayor que budget_tokens + if (params.max_tokens <= budgetTokens) { + params.max_tokens = budgetTokens + DEFAULT_MAX_TOKENS + } + } + + const response = await this.client.messages.create(params) + + // Extraer solo bloques de texto de la respuesta (ignorar bloques de thinking) + const textBlocks = response.content.filter((block: any) => block.type === 'text') + return textBlocks.map((block: any) => block.text).join('\n') || '' + } + + /** + * Si el resumen esta habilitado y el historial excede el umbral, + * resume los mensajes antiguos en un solo mensaje de contexto. + */ + private async handleSummary(from: string): Promise { + const { summary, maxHistoryLength } = this.options + if (!summary?.enabled) return + + const threshold = summary.threshold ?? maxHistoryLength + const historySize = this.history.size(from) + + if (historySize <= threshold) return + + const history = this.history.getHistory(from) + const keepCount = Math.floor(threshold / 2) + const oldMessages = history.slice(0, historySize - keepCount) + const recentMessages = history.slice(historySize - keepCount) + + const summaryText = await this.generateSummary(oldMessages) + + const summaryEntry = { + role: 'user' as const, + content: `[Resumen de la conversacion anterior]: ${summaryText}`, + timestamp: Date.now(), + } + + this.history.replaceHistory(from, [summaryEntry, ...recentMessages]) + } + + private async generateSummary( + messages: Array<{ role: string; content: string | ContentBlock[] }> + ): Promise { + const conversationText = messages + .map((m) => { + const text = typeof m.content === 'string' ? m.content : '[contenido multimedia]' + return `${m.role}: ${text}` + }) + .join('\n') + + const response = await this.client.messages.create({ + model: this.options.model, + max_tokens: 500, + system: 'Resume la siguiente conversacion de forma concisa, capturando los puntos clave y el contexto importante. Responde solo con el resumen.', + messages: [{ role: 'user', content: conversationText }], + }) + + const textBlocks = response.content.filter((block: any) => block.type === 'text') + return textBlocks.map((block: any) => block.text).join('\n') || '' + } + + /** Limpia el historial de un usuario especifico */ + clearHistory(from: string): void { + this.history.clear(from) + } + + /** Limpia el historial de todos los usuarios */ + clearAllHistory(): void { + this.history.clearAll() + } +} diff --git a/packages/plugins/anthropic/src/history.ts b/packages/plugins/anthropic/src/history.ts new file mode 100644 index 000000000..4a94feffd --- /dev/null +++ b/packages/plugins/anthropic/src/history.ts @@ -0,0 +1,63 @@ +import type { ConversationEntry, ContentBlock } from './types' + +/** + * Gestiona el historial de conversaciones por usuario. + * Almacena mensajes en memoria con un limite configurable por usuario. + */ +export class ConversationHistory { + private store = new Map() + private maxLength: number + + constructor(maxLength: number = 10) { + this.maxLength = maxLength + } + + /** + * Agrega una entrada al historial de un usuario. + * Si se excede el limite, elimina los mensajes mas antiguos. + */ + addEntry(from: string, role: 'user' | 'assistant', content: string | ContentBlock[]): void { + const history = this.getHistory(from) + history.push({ role, content, timestamp: Date.now() }) + + if (history.length > this.maxLength) { + const excess = history.length - this.maxLength + history.splice(0, excess) + } + + this.store.set(from, history) + } + + /** Obtiene el historial completo de un usuario */ + getHistory(from: string): ConversationEntry[] { + if (!this.store.has(from)) { + this.store.set(from, []) + } + return this.store.get(from)! + } + + /** Convierte el historial a formato de mensajes Anthropic */ + toAnthropicMessages(from: string): Array<{ role: 'user' | 'assistant'; content: string | ContentBlock[] }> { + return this.getHistory(from).map(({ role, content }) => ({ role, content })) + } + + /** Reemplaza el historial de un usuario con uno nuevo (usado al resumir) */ + replaceHistory(from: string, entries: ConversationEntry[]): void { + this.store.set(from, entries) + } + + /** Limpia el historial de un usuario */ + clear(from: string): void { + this.store.delete(from) + } + + /** Limpia el historial de todos los usuarios */ + clearAll(): void { + this.store.clear() + } + + /** Retorna la cantidad de mensajes en el historial de un usuario */ + size(from: string): number { + return this.getHistory(from).length + } +} diff --git a/packages/plugins/anthropic/src/index.ts b/packages/plugins/anthropic/src/index.ts new file mode 100644 index 000000000..5633201d4 --- /dev/null +++ b/packages/plugins/anthropic/src/index.ts @@ -0,0 +1,40 @@ +import { AnthropicContext } from './anthropic.class' +import type { ParamsAnthropic } from './types' + +/** + * Crea una instancia del plugin Anthropic para BuilderBot. + * + * @example + * ```typescript + * import { createBotAnthropic } from '@builderbot/plugin-anthropic' + * + * // Zero-config: usa el token de `claude setup-token` automaticamente + * const anthropic = await createBotAnthropic({ database, provider }) + * + * // Con API key explicita + * const anthropic = await createBotAnthropic({ + * database, + * provider, + * options: { apiKey: 'sk-ant-api03-...' } + * }) + * + * // Configuracion completa + * const anthropic = await createBotAnthropic({ + * database, + * provider, + * options: { + * model: 'claude-sonnet-4-20250514', + * maxHistoryLength: 20, + * maxTokens: 4096, + * systemPrompt: 'Eres un asistente amable para WhatsApp.', + * thinking: { enabled: true, budgetTokens: 8000 }, + * summary: { enabled: true, threshold: 15 }, + * } + * }) + * ``` + */ +const createBotAnthropic = async ({ database, provider, options }: ParamsAnthropic) => + new AnthropicContext(database, provider, options) + +export { createBotAnthropic, AnthropicContext } +export type { AnthropicContextOptions, ParamsAnthropic, ThinkingConfig, SummaryConfig } from './types' diff --git a/packages/plugins/anthropic/src/types.ts b/packages/plugins/anthropic/src/types.ts new file mode 100644 index 000000000..47471e754 --- /dev/null +++ b/packages/plugins/anthropic/src/types.ts @@ -0,0 +1,83 @@ +/** + * Opciones de configuracion del plugin Anthropic para BuilderBot + * + * @example + * ```typescript + * const options: AnthropicContextOptions = { + * model: 'claude-sonnet-4-20250514', + * maxHistoryLength: 20, + * systemPrompt: 'Eres un asistente amable.', + * thinking: { enabled: true, budgetTokens: 8000 }, + * summary: { enabled: true, threshold: 15 }, + * } + * ``` + */ +export interface AnthropicContextOptions { + /** API key de Anthropic (sk-ant-api03-...). Si no se provee, busca automaticamente en este orden: + * 1. Variable de entorno ANTHROPIC_API_KEY + * 2. Variable de entorno ANTHROPIC_AUTH_TOKEN + * 3. Token OAuth de Claude setup (~/.claude/.credentials.json) generado con `claude setup-token` + */ + apiKey?: string + /** Modelo a utilizar. Default: 'claude-sonnet-4-20250514' */ + model?: string + /** Cantidad maxima de mensajes por usuario en el historial. Default: 10 */ + maxHistoryLength?: number + /** Maximo de tokens en la respuesta. Default: 4096 */ + maxTokens?: number + /** Prompt de sistema opcional que define el comportamiento del asistente */ + systemPrompt?: string + /** Configuracion del modo thinking (razonamiento extendido) */ + thinking?: ThinkingConfig + /** Configuracion del resumen automatico de conversaciones */ + summary?: SummaryConfig +} + +export interface ThinkingConfig { + /** Habilita el modo de razonamiento extendido */ + enabled: boolean + /** Presupuesto de tokens para el razonamiento. Default: 10000 */ + budgetTokens?: number +} + +export interface SummaryConfig { + /** Habilita el resumen automatico cuando el historial crece */ + enabled: boolean + /** Cantidad de mensajes que disparan el resumen. Default: igual a maxHistoryLength */ + threshold?: number +} + +export interface ConversationEntry { + role: 'user' | 'assistant' + content: string | ContentBlock[] + timestamp: number +} + +export type ContentBlock = TextBlock | ImageBlock + +export interface TextBlock { + type: 'text' + text: string +} + +export interface ImageBlock { + type: 'image' + source: { + type: 'base64' + media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp' + data: string + } +} + +export interface MessageContextIncoming { + from: string + ref?: string + body?: string + [key: string]: any +} + +export interface ParamsAnthropic { + database: any + provider: any + options?: AnthropicContextOptions +} diff --git a/packages/plugins/anthropic/src/utils.ts b/packages/plugins/anthropic/src/utils.ts new file mode 100644 index 000000000..487129436 --- /dev/null +++ b/packages/plugins/anthropic/src/utils.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'fs' +import { extname } from 'path' + +const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']) +const AUDIO_EXTENSIONS = new Set(['.mp3', '.ogg', '.opus', '.wav', '.m4a', '.aac']) +const VIDEO_EXTENSIONS = new Set(['.mp4', '.avi', '.mov', '.mkv', '.webm']) +const PDF_EXTENSIONS = new Set(['.pdf']) + +/** Convierte un archivo a base64 */ +export const fileToBase64 = (filePath: string): string => { + const buffer = readFileSync(filePath) + return buffer.toString('base64') +} + +type ImageMediaType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp' + +const MEDIA_TYPE_MAP: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', +} + +/** Obtiene el media type de una imagen por su extension */ +export const getImageMediaType = (filePath: string): ImageMediaType => { + const ext = extname(filePath).toLowerCase() + return MEDIA_TYPE_MAP[ext] || 'image/jpeg' +} + +export const isImageFile = (filePath: string): boolean => { + const ext = extname(filePath).toLowerCase() + return IMAGE_EXTENSIONS.has(ext) +} + +export const isAudioFile = (filePath: string): boolean => { + const ext = extname(filePath).toLowerCase() + return AUDIO_EXTENSIONS.has(ext) +} + +export const isVideoFile = (filePath: string): boolean => { + const ext = extname(filePath).toLowerCase() + return VIDEO_EXTENSIONS.has(ext) +} + +export const isPdfFile = (filePath: string): boolean => { + const ext = extname(filePath).toLowerCase() + return PDF_EXTENSIONS.has(ext) +} diff --git a/packages/plugins/anthropic/tsconfig.json b/packages/plugins/anthropic/tsconfig.json new file mode 100644 index 000000000..fd76151f8 --- /dev/null +++ b/packages/plugins/anthropic/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "allowJs": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "es2021", + "module": "ESNext", + "types": ["node"] + }, + "include": ["src/**/*.js", "src/**/*.ts"], + "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9a36b4056..e44ffadc8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - 'packages/*' + - 'packages/plugins/*'