Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/plugins/anthropic/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist/
42 changes: 42 additions & 0 deletions packages/plugins/anthropic/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
20 changes: 20 additions & 0 deletions packages/plugins/anthropic/rollup.config.js
Original file line number Diff line number Diff line change
@@ -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(),
],
}
304 changes: 304 additions & 0 deletions packages/plugins/anthropic/src/anthropic.class.ts
Original file line number Diff line number Diff line change
@@ -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, 'model' | 'maxHistoryLength' | 'maxTokens'>
> &
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<any> => {
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<string | ContentBlock[]> {
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<string> {
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<void> {
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<string> {
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()
}
}
Loading