2025-10-02 21:52:08 -05:00
import { GoogleGenerativeAI , HarmCategory , HarmBlockThreshold } from "@google/generative-ai" ;
2025-10-04 04:34:55 -05:00
import { GoogleGenAI , PersonGeneration } from "@google/genai" ;
2025-10-02 21:52:08 -05:00
import logger from "../lib/logger" ;
import { Collection } from "discord.js" ;
2025-10-04 01:31:36 -05:00
import { prisma } from "../database/prisma" ;
2025-10-04 02:19:36 -05:00
import { getDatabases , APPWRITE_DATABASE_ID , APPWRITE_COLLECTION_AI_CONVERSATIONS_ID , isAIConversationsConfigured } from "../api/appwrite" ;
2025-10-04 05:03:39 -05:00
import { ensureAIConversationsSchema } from "../api/aiConversationsSchema" ;
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sdk : any = require ( 'node-appwrite' ) ;
2025-10-02 21:52:08 -05:00
// Tipos mejorados para mejor type safety
interface ConversationContext {
messages : Array < {
role : 'user' | 'assistant' ;
content : string ;
timestamp : number ;
tokens : number ;
2025-10-04 02:19:36 -05:00
messageId? : string ; // ID del mensaje de Discord
referencedMessageId? : string ; // ID del mensaje al que responde
2025-10-02 21:52:08 -05:00
} > ;
totalTokens : number ;
imageRequests : number ;
lastActivity : number ;
userId : string ;
guildId? : string ;
2025-10-04 02:19:36 -05:00
channelId? : string ;
conversationId? : string ; // ID único de la conversación
2025-10-02 21:52:08 -05:00
}
interface AIRequest {
userId : string ;
guildId? : string ;
2025-10-04 02:19:36 -05:00
channelId? : string ;
2025-10-02 21:52:08 -05:00
prompt : string ;
priority : 'low' | 'normal' | 'high' ;
timestamp : number ;
resolve : ( value : string ) = > void ;
reject : ( error : Error ) = > void ;
2025-10-04 01:31:36 -05:00
aiRolePrompt? : string ;
meta? : string ;
2025-10-04 02:19:36 -05:00
messageId? : string ;
referencedMessageId? : string ;
}
interface AppwriteConversation {
2025-10-04 05:03:39 -05:00
$id? : string ;
2025-10-04 02:19:36 -05:00
userId : string ;
2025-10-04 05:03:39 -05:00
guildId? : string | null ;
channelId? : string | null ;
2025-10-04 02:19:36 -05:00
conversationId : string ;
2025-10-04 05:03:39 -05:00
messagesJson? : string ; // JSON serializado del historial
lastActivity : string ; // ISO
createdAt : string ; // ISO
2025-10-02 21:52:08 -05:00
}
// Utility function para manejar errores de forma type-safe
function getErrorMessage ( error : unknown ) : string {
if ( error instanceof Error ) {
return error . message ;
}
if ( typeof error === 'string' ) {
return error ;
}
if ( error && typeof error === 'object' && 'message' in error ) {
2025-10-04 02:19:36 -05:00
return String ( ( error as any ) . message ) ;
2025-10-02 21:52:08 -05:00
}
return 'Error desconocido' ;
}
// Type guard para verificar si es un Error
function isError ( error : unknown ) : error is Error {
return error instanceof Error ;
}
// Type guard para verificar errores de API específicos
function isAPIError ( error : unknown ) : error is { message : string ; code? : string } {
return (
error !== null &&
typeof error === 'object' &&
'message' in error &&
typeof ( error as any ) . message === 'string'
) ;
}
2025-10-06 10:28:01 -05:00
const sleep = ( ms : number ) : Promise < void > = > new Promise ( resolve = > setTimeout ( resolve , ms ) ) ;
function isServiceUnavailableError ( error : unknown ) : boolean {
if ( ! error ) {
return false ;
}
const message = getErrorMessage ( error ) . toLowerCase ( ) ;
if (
message . includes ( '503' ) ||
message . includes ( 'service unavailable' ) ||
message . includes ( 'model is overloaded' ) ||
message . includes ( 'model estuvo sobrecargado' ) ||
message . includes ( 'overloaded' ) ||
message . includes ( 'temporarily unavailable' )
) {
return true ;
}
const status = ( error as any ) ? . status ? ? ( error as any ) ? . statusCode ? ? ( error as any ) ? . code ;
if ( typeof status === 'number' && status === 503 ) {
return true ;
}
if ( typeof status === 'string' && status . includes ( '503' ) ) {
return true ;
}
if ( isAPIError ( error ) ) {
const apiMessage = error . message . toLowerCase ( ) ;
return (
apiMessage . includes ( '503' ) ||
apiMessage . includes ( 'service unavailable' ) ||
apiMessage . includes ( 'overloaded' )
) ;
}
return false ;
}
2025-10-02 21:52:08 -05:00
export class AIService {
private genAI : GoogleGenerativeAI ;
2025-10-04 03:10:04 -05:00
private genAIv2 : any ;
2025-10-04 03:53:51 -05:00
// Cache del modelo de imágenes detectado
private imageModelName? : string | null ;
2025-10-02 21:52:08 -05:00
private conversations = new Collection < string , ConversationContext > ( ) ;
private requestQueue : AIRequest [ ] = [ ] ;
private processing = false ;
private userCooldowns = new Collection < string , number > ( ) ;
private rateLimitTracker = new Collection < string , { count : number ; resetTime : number } > ( ) ;
2025-10-04 01:31:36 -05:00
// Cache de configuración por guild
private guildPromptCache = new Collection < string , { prompt : string | null ; fetchedAt : number } > ( ) ;
2025-10-02 21:52:08 -05:00
// Configuración mejorada y escalable
private readonly config = {
2025-10-04 03:10:04 -05:00
maxInputTokens : 1048576 , // 1M tokens Gemini 2.5 Flash (entrada)
maxOutputTokens : 65536 , // 65,536 salida (según aclaración del usuario para preview 09-2025)
2025-10-02 21:52:08 -05:00
tokenResetThreshold : 0.80 , // Más conservador
maxConversationAge : 30 * 60 * 1000 , // 30 minutos
maxMessageHistory : 8 , // Reducido para mejor memoria
cooldownMs : 3000 , // 3 segundos entre requests
maxImageRequests : 3 , // Reducido para evitar spam
requestTimeout : 30000 , // 30 segundos timeout
maxConcurrentRequests : 3 , // Máximo 3 requests simultáneos
rateLimitWindow : 60000 , // 1 minuto
rateLimitMax : 20 , // 20 requests por minuto por usuario
cleanupInterval : 5 * 60 * 1000 , // Limpiar cada 5 minutos
2025-10-04 01:31:36 -05:00
guildConfigTTL : 5 * 60 * 1000 , // 5 minutos de cache para prompts de guild
} as const ;
2025-10-02 21:52:08 -05:00
constructor ( ) {
2025-10-04 04:49:28 -05:00
const apiKey = process . env . GOOGLE_AI_API_KEY || process . env . GEMINI_API_KEY ;
2025-10-02 21:52:08 -05:00
if ( ! apiKey ) {
2025-10-04 04:49:28 -05:00
throw new Error ( 'Falta la clave de Google AI. Define GOOGLE_AI_API_KEY o GEMINI_API_KEY en las variables de entorno.' ) ;
2025-10-02 21:52:08 -05:00
}
2025-10-04 04:24:42 -05:00
2025-10-02 21:52:08 -05:00
this . genAI = new GoogleGenerativeAI ( apiKey ) ;
2025-10-04 04:24:42 -05:00
2025-10-04 03:10:04 -05:00
try {
this . genAIv2 = new GoogleGenAI ( { apiKey } ) ;
2025-10-04 04:24:42 -05:00
logger . info ( 'GoogleGenAI v2 inicializado correctamente para generación de imágenes' ) ;
} catch ( e ) {
logger . warn ( ` GoogleGenAI v2 no pudo inicializarse: ${ getErrorMessage ( e ) } ` ) ;
2025-10-04 03:10:04 -05:00
this . genAIv2 = null ;
}
2025-10-04 04:24:42 -05:00
2025-10-04 04:49:28 -05:00
// Permitir override de modelo por variable de entorno
const envImageModel = process . env . GENAI_IMAGE_MODEL ;
if ( envImageModel && envImageModel . trim ( ) ) {
this . imageModelName = envImageModel . trim ( ) ;
logger . info ( { model : this.imageModelName } , 'Modelo de imágenes fijado por GENAI_IMAGE_MODEL' ) ;
}
2025-10-02 21:52:08 -05:00
this . startQueueProcessor ( ) ;
2025-10-04 04:24:42 -05:00
this . startCleanupService ( ) ;
this . detectImageModel ( ) ;
2025-10-04 03:53:51 -05:00
}
2025-10-04 04:24:42 -05:00
/ * *
* Auto - detectar modelo de imagen disponible
* /
2025-10-04 03:53:51 -05:00
private async detectImageModel ( ) : Promise < string | null > {
if ( ! this . genAIv2 ) {
2025-10-04 04:24:42 -05:00
logger . warn ( 'GoogleGenAI v2 no disponible; sin soporte para imágenes' ) ;
2025-10-04 03:53:51 -05:00
return null ;
}
2025-10-04 05:03:39 -05:00
// Lista de candidatos de modelos de imagen ordenados por preferencia (Imagen 4.0 primero, con retrocompatibilidad)
2025-10-04 03:53:51 -05:00
const candidates = [
2025-10-04 05:03:39 -05:00
'models/imagen-4.0-generate-001' ,
'imagen-4.0-generate-001' ,
'models/imagen-3.0-fast' ,
'imagen-3.0-fast' ,
'models/imagen-3.0' ,
'imagen-3.0' ,
'models/gemini-2.5-flash-image' ,
2025-10-04 04:18:27 -05:00
'gemini-2.5-flash-image' ,
2025-10-04 03:53:51 -05:00
] ;
2025-10-04 04:18:27 -05:00
// Intentar listar modelos primero
2025-10-04 03:53:51 -05:00
try {
const listed : any = await ( this . genAIv2 as any ) . models ? . listModels ? . ( ) ;
2025-10-04 04:18:27 -05:00
if ( listed ? . models && Array . isArray ( listed . models ) ) {
const models : string [ ] = listed . models
. map ( ( m : any ) = > m ? . name || m ? . model || m ? . id || m ? . displayName )
2025-10-04 04:34:55 -05:00
. filter ( Boolean ) ;
2025-10-04 04:18:27 -05:00
logger . debug ( { availableModels : models } , 'Modelos disponibles detectados' ) ;
// Buscar modelos de imagen disponibles
const imageModels = models . filter ( ( id : string ) = >
/imagen|image|generate|vision/i . test ( id ) &&
! /text|chat|embed|code/i . test ( id )
) ;
if ( imageModels . length > 0 ) {
// Priorizar según orden de candidatos
for ( const candidate of candidates ) {
2025-10-04 04:34:55 -05:00
const candidateBase = candidate . replace ( /^models\// , '' ) ;
const found = imageModels . find ( m = >
m === candidate ||
m === candidateBase ||
m . includes ( candidateBase )
) ;
2025-10-04 04:18:27 -05:00
if ( found ) {
this . imageModelName = found ;
logger . info ( { model : found , source : 'listModels' } , 'Modelo de imágenes detectado automáticamente' ) ;
return found ;
}
2025-10-04 03:53:51 -05:00
}
2025-10-04 04:18:27 -05:00
// Si no coincide con candidatos conocidos, usar el primero disponible
this . imageModelName = imageModels [ 0 ] ;
logger . info ( { model : imageModels [ 0 ] , source : 'listModels-fallback' } , 'Modelo de imágenes detectado (fallback)' ) ;
return imageModels [ 0 ] ;
2025-10-04 03:53:51 -05:00
}
}
} catch ( e ) {
2025-10-04 04:18:27 -05:00
logger . debug ( { err : getErrorMessage ( e ) } , 'listModels no disponible' ) ;
2025-10-04 03:53:51 -05:00
}
2025-10-04 04:34:55 -05:00
// Fallback: probar modelos uno por uno
2025-10-04 03:53:51 -05:00
for ( const candidate of candidates ) {
try {
2025-10-04 04:49:28 -05:00
await ( this . genAIv2 as any ) . models . generateImages ( {
2025-10-04 03:53:51 -05:00
model : candidate ,
2025-10-04 04:34:55 -05:00
prompt : 'test' ,
2025-10-04 04:18:27 -05:00
config : {
2025-10-04 04:34:55 -05:00
numberOfImages : 1 ,
outputMimeType : 'image/jpeg' ,
aspectRatio : '1:1' ,
imageSize : '1K' ,
2025-10-04 04:18:27 -05:00
}
2025-10-04 03:53:51 -05:00
} ) ;
2025-10-04 04:18:27 -05:00
2025-10-04 04:34:55 -05:00
// Si no lanza error, el modelo existe
2025-10-04 03:53:51 -05:00
this . imageModelName = candidate ;
2025-10-04 04:18:27 -05:00
logger . info ( { model : candidate , source : 'direct-test' } , 'Modelo de imágenes detectado por prueba directa' ) ;
2025-10-04 03:53:51 -05:00
return candidate ;
2025-10-04 04:18:27 -05:00
} catch ( e : any ) {
const msg = getErrorMessage ( e ) ;
if ( msg . includes ( 'not found' ) || msg . includes ( '404' ) ) {
continue ; // Modelo no disponible, probar siguiente
}
// Otros errores pueden indicar que el modelo existe pero falló por otra razón
logger . debug ( { candidate , err : msg } , 'Modelo podría existir pero falló la prueba' ) ;
2025-10-04 03:53:51 -05:00
}
}
2025-10-04 04:18:27 -05:00
// No se encontró ningún modelo de imagen
2025-10-04 03:53:51 -05:00
this . imageModelName = null ;
2025-10-04 04:18:27 -05:00
logger . warn ( 'No se detectó ningún modelo de imagen disponible' ) ;
2025-10-04 03:53:51 -05:00
return null ;
2025-10-02 21:52:08 -05:00
}
2025-10-04 01:31:36 -05:00
/ * *
2025-10-04 01:47:02 -05:00
* Obtener prompt de rol de IA por guild con caché
2025-10-04 01:31:36 -05:00
* /
public async getGuildAiPrompt ( guildId : string ) : Promise < string | null > {
try {
const cached = this . guildPromptCache . get ( guildId ) ;
const now = Date . now ( ) ;
if ( cached && ( now - cached . fetchedAt ) < this . config . guildConfigTTL ) {
return cached . prompt ;
}
2025-10-04 01:47:02 -05:00
// @ts-ignore
2025-10-04 01:31:36 -05:00
const guild = await prisma . guild . findUnique ( { where : { id : guildId } , select : { aiRolePrompt : true } } ) ;
//@ts-ignore
const prompt = guild ? . aiRolePrompt ? ? null ;
this . guildPromptCache . set ( guildId , { prompt , fetchedAt : now } ) ;
return prompt ;
} catch ( e ) {
logger . warn ( ` No se pudo cargar aiRolePrompt para guild ${ guildId } : ${ getErrorMessage ( e ) } ` ) ;
return null ;
}
}
/ * *
* Invalidar cache de configuración de un guild ( llamar tras guardar cambios )
* /
public invalidateGuildConfig ( guildId : string ) : void {
this . guildPromptCache . delete ( guildId ) ;
}
2025-10-02 21:52:08 -05:00
/ * *
* Procesa una request de IA de forma asíncrona y controlada
* /
async processAIRequest (
userId : string ,
prompt : string ,
guildId? : string ,
2025-10-04 01:31:36 -05:00
priority : 'low' | 'normal' | 'high' = 'normal' ,
options ? : { aiRolePrompt? : string ; meta? : string }
2025-10-02 21:52:08 -05:00
) : Promise < string > {
// Validaciones exhaustivas
if ( ! prompt ? . trim ( ) ) {
throw new Error ( 'El prompt no puede estar vacío' ) ;
}
if ( prompt . length > 4000 ) {
throw new Error ( 'El prompt excede el límite de 4000 caracteres' ) ;
}
// Rate limiting por usuario
if ( ! this . checkRateLimit ( userId ) ) {
throw new Error ( 'Has excedido el límite de requests. Espera un momento.' ) ;
}
// Cooldown entre requests
const lastRequest = this . userCooldowns . get ( userId ) || 0 ;
const timeSinceLastRequest = Date . now ( ) - lastRequest ;
if ( timeSinceLastRequest < this . config . cooldownMs ) {
const waitTime = Math . ceil ( ( this . config . cooldownMs - timeSinceLastRequest ) / 1000 ) ;
throw new Error ( ` Debes esperar ${ waitTime } segundos antes de hacer otra consulta ` ) ;
}
// Agregar a la queue con Promise
return new Promise ( ( resolve , reject ) = > {
const request : AIRequest = {
userId ,
guildId ,
prompt : prompt.trim ( ) ,
priority ,
timestamp : Date.now ( ) ,
resolve ,
2025-10-04 01:31:36 -05:00
reject ,
aiRolePrompt : options?.aiRolePrompt ,
meta : options?.meta ,
2025-10-02 21:52:08 -05:00
} ;
// Insertar según prioridad
if ( priority === 'high' ) {
this . requestQueue . unshift ( request ) ;
} else {
this . requestQueue . push ( request ) ;
}
// Timeout automático
setTimeout ( ( ) = > {
const index = this . requestQueue . findIndex ( r = > r === request ) ;
if ( index !== - 1 ) {
this . requestQueue . splice ( index , 1 ) ;
reject ( new Error ( 'Request timeout: La solicitud tardó demasiado tiempo' ) ) ;
}
} , this . config . requestTimeout ) ;
this . userCooldowns . set ( userId , Date . now ( ) ) ;
} ) ;
}
/ * *
* Procesador de queue mejorado con control de concurrencia
* /
private async startQueueProcessor ( ) : Promise < void > {
setInterval ( async ( ) = > {
if ( this . processing || this . requestQueue . length === 0 ) return ;
this . processing = true ;
try {
// Procesar hasta 3 requests simultáneamente
const batch = this . requestQueue . splice ( 0 , this . config . maxConcurrentRequests ) ;
await Promise . allSettled (
batch . map ( request = > this . processRequest ( request ) )
) ;
} catch ( error ) {
// Usar nuestro helper para manejar el error de forma type-safe
const errorMessage = getErrorMessage ( error ) ;
logger . error ( ` Error en el procesador de queue: ${ errorMessage } ` ) ;
// Si necesitamos más detalles del error, podemos usar type guards
if ( isError ( error ) && error . stack ) {
logger . error ( ` Stack trace: ${ error . stack } ` ) ;
}
} finally {
this . processing = false ;
}
} , 1000 ) ; // Revisar cada segundo
}
/ * *
2025-10-04 02:19:36 -05:00
* Resetear conversación
* /
private resetConversation ( userId : string , guildId? : string ) : void {
const key = ` ${ userId } - ${ guildId || 'dm' } ` ;
this . conversations . delete ( key ) ;
}
/ * *
* Servicio de limpieza automática
* /
private startCleanupService ( ) : void {
setInterval ( ( ) = > {
const now = Date . now ( ) ;
const toDelete : string [ ] = [ ] ;
this . conversations . forEach ( ( context , key ) = > {
if ( now - context . lastActivity > this . config . maxConversationAge ) {
toDelete . push ( key ) ;
}
} ) ;
toDelete . forEach ( key = > this . conversations . delete ( key ) ) ;
if ( toDelete . length > 0 ) {
logger . info ( ` Limpieza automática: ${ toDelete . length } conversaciones expiradas eliminadas ` ) ;
}
} , this . config . cleanupInterval ) ;
}
2025-10-04 04:07:12 -05:00
/ * *
* Parser mejorado de errores de API - Type - safe sin ts - ignore
* /
private parseAPIError ( error : unknown ) : string {
// Extraer mensaje de forma type-safe
const message = getErrorMessage ( error ) . toLowerCase ( ) ;
// Verificar si es un error de API estructurado
if ( isAPIError ( error ) ) {
const apiMessage = error . message . toLowerCase ( ) ;
if ( apiMessage . includes ( 'api key' ) || apiMessage . includes ( 'authentication' ) ) {
return 'Error de autenticación con la API de IA' ;
}
if ( apiMessage . includes ( 'quota' ) || apiMessage . includes ( 'exceeded' ) ) {
return 'Se ha alcanzado el límite de uso de la API. Intenta más tarde' ;
}
2025-10-06 10:28:01 -05:00
if ( apiMessage . includes ( 'service unavailable' ) || apiMessage . includes ( 'overloaded' ) || apiMessage . includes ( '503' ) ) {
return 'El servicio de IA está saturado. Intenta de nuevo en unos segundos' ;
}
2025-10-04 04:07:12 -05:00
if ( apiMessage . includes ( 'safety' ) || apiMessage . includes ( 'blocked' ) ) {
return 'Tu mensaje fue bloqueado por las políticas de seguridad' ;
}
if ( apiMessage . includes ( 'timeout' ) || apiMessage . includes ( 'deadline' ) ) {
return 'La solicitud tardó demasiado tiempo. Intenta de nuevo' ;
}
if ( apiMessage . includes ( 'model not found' ) ) {
return 'El modelo de IA no está disponible en este momento' ;
}
if ( apiMessage . includes ( 'token' ) || apiMessage . includes ( 'length' ) ) {
return 'El mensaje excede los límites permitidos' ;
}
}
// Manejo genérico para otros tipos de errores
if ( message . includes ( 'api key' ) || message . includes ( 'authentication' ) ) {
return 'Error de autenticación con la API de IA' ;
}
if ( message . includes ( 'quota' ) || message . includes ( 'exceeded' ) ) {
return 'Se ha alcanzado el límite de uso de la API. Intenta más tarde' ;
}
2025-10-06 10:28:01 -05:00
if ( message . includes ( 'service unavailable' ) || message . includes ( 'overloaded' ) || message . includes ( '503' ) ) {
return 'El servicio de IA está saturado. Intenta de nuevo en unos segundos' ;
}
2025-10-04 04:07:12 -05:00
if ( message . includes ( 'safety' ) || message . includes ( 'blocked' ) ) {
return 'Tu mensaje fue bloqueado por las políticas de seguridad' ;
}
if ( message . includes ( 'timeout' ) || message . includes ( 'deadline' ) ) {
return 'La solicitud tardó demasiado tiempo. Intenta de nuevo' ;
}
if ( message . includes ( 'model not found' ) ) {
return 'El modelo de IA no está disponible en este momento' ;
}
if ( message . includes ( 'token' ) || message . includes ( 'length' ) ) {
return 'El mensaje excede los límites permitidos' ;
}
return 'Error temporal del servicio de IA. Intenta de nuevo' ;
}
2025-10-06 10:28:01 -05:00
private async generateContentWithRetries ( model : any , content : any , options ? : {
maxAttempts? : number ;
baseDelayMs? : number ;
maxDelayMs? : number ;
} ) : Promise < any > {
const {
maxAttempts = 3 ,
baseDelayMs = 1200 ,
maxDelayMs = 10 _000
} = options ? ? { } ;
let lastError : unknown ;
for ( let attempt = 0 ; attempt < maxAttempts ; attempt ++ ) {
try {
return await model . generateContent ( content ) ;
} catch ( error ) {
lastError = error ;
const isRetryable = isServiceUnavailableError ( error ) ;
const isLastAttempt = attempt === maxAttempts - 1 ;
if ( ! isRetryable || isLastAttempt ) {
throw error ;
}
const backoff = Math . min ( maxDelayMs , Math . floor ( baseDelayMs * Math . pow ( 2 , attempt ) ) ) ;
const jitter = Math . floor ( Math . random ( ) * Math . max ( 200 , Math . floor ( baseDelayMs / 2 ) ) ) ;
const waitMs = backoff + jitter ;
logger . warn (
{ attempt : attempt + 1 , waitMs } ,
` Gemini respondió 503 (overloaded). Reintentando en ${ waitMs } ms (intento ${ attempt + 2 } / ${ maxAttempts } ) `
) ;
await sleep ( waitMs ) ;
}
}
throw lastError ? ? new Error ( 'Error desconocido al generar contenido con Gemini' ) ;
}
2025-10-04 04:07:12 -05:00
/ * *
* Procesa una request de IA con soporte para conversaciones y memoria persistente
* /
async processAIRequestWithMemory (
userId : string ,
prompt : string ,
guildId? : string ,
channelId? : string ,
messageId? : string ,
referencedMessageId? : string ,
client? : any ,
priority : 'low' | 'normal' | 'high' = 'normal' ,
options ? : { aiRolePrompt? : string ; meta? : string ; attachments? : any [ ] }
) : Promise < string > {
// Validaciones exhaustivas
if ( ! prompt ? . trim ( ) ) {
throw new Error ( 'El prompt no puede estar vacío' ) ;
}
if ( prompt . length > 4000 ) {
throw new Error ( 'El prompt excede el límite de 4000 caracteres' ) ;
}
// Rate limiting por usuario
if ( ! this . checkRateLimit ( userId ) ) {
throw new Error ( 'Has excedido el límite de requests. Espera un momento.' ) ;
}
// Cooldown entre requests
const lastRequest = this . userCooldowns . get ( userId ) || 0 ;
const timeSinceLastRequest = Date . now ( ) - lastRequest ;
if ( timeSinceLastRequest < this . config . cooldownMs ) {
const waitTime = Math . ceil ( ( this . config . cooldownMs - timeSinceLastRequest ) / 1000 ) ;
throw new Error ( ` Debes esperar ${ waitTime } segundos antes de hacer otra consulta ` ) ;
}
// Agregar a la queue con Promise
return new Promise ( ( resolve , reject ) = > {
const request : AIRequest & { client? : any ; attachments? : any [ ] } = {
userId ,
guildId ,
channelId ,
prompt : prompt.trim ( ) ,
priority ,
timestamp : Date.now ( ) ,
resolve ,
reject ,
aiRolePrompt : options?.aiRolePrompt ,
meta : options?.meta ,
messageId ,
referencedMessageId ,
client ,
attachments : options?.attachments
} ;
// Insertar según prioridad
if ( priority === 'high' ) {
this . requestQueue . unshift ( request ) ;
} else {
this . requestQueue . push ( request ) ;
}
// Timeout automático
setTimeout ( ( ) = > {
const index = this . requestQueue . findIndex ( r = > r === request ) ;
if ( index !== - 1 ) {
this . requestQueue . splice ( index , 1 ) ;
reject ( new Error ( 'Request timeout: La solicitud tardó demasiado tiempo' ) ) ;
}
} , this . config . requestTimeout ) ;
this . userCooldowns . set ( userId , Date . now ( ) ) ;
} ) ;
}
/ * *
* Lista modelos de imagen visibles por la clave ( si el SDK lo permite )
* /
public async listImageModels ( ) : Promise < string [ ] > {
if ( ! this . genAIv2 || ! ( this . genAIv2 as any ) . models ? . listModels ) return [ ] ;
try {
const listed : any = await ( this . genAIv2 as any ) . models . listModels ( ) ;
const models : string [ ] = Array . isArray ( listed ? . models )
? listed . models . map ( ( m : any ) = > m ? . name || m ? . model || m ? . id ) . filter ( Boolean )
: [ ] ;
// Filtrar a modelos de imagen de forma heurística
return models . filter ( ( id ) = > /imagen|image/i . test ( id ) ) ;
} catch {
return [ ] ;
}
}
// Override manual del modelo de imágenes (útil para runtime)
public setImageModel ( model : string | null | undefined ) : void {
this . imageModelName = model ? ? null ;
if ( this . imageModelName ) {
logger . info ( { model : this.imageModelName } , 'Modelo de imágenes fijado manualmente' ) ;
} else {
logger . info ( 'Modelo de imágenes reseteado; se volverá a detectar automáticamente' ) ;
}
}
/ * *
2025-10-04 04:24:42 -05:00
* Detectar si hay imágenes adjuntas en el mensaje para análisis
2025-10-04 04:07:12 -05:00
* /
2025-10-04 04:24:42 -05:00
public hasImageAttachments ( attachments? : any [ ] ) : boolean {
if ( ! attachments || attachments . length === 0 ) return false ;
const imageExtensions = [ '.jpg' , '.jpeg' , '.png' , '.gif' , '.webp' , '.bmp' ] ;
const imageMimeTypes = [ 'image/jpeg' , 'image/png' , 'image/gif' , 'image/webp' , 'image/bmp' ] ;
return attachments . some ( attachment = > {
const hasImageExtension = imageExtensions . some ( ext = >
attachment . name ? . toLowerCase ( ) . endsWith ( ext )
) ;
const hasImageMimeType = imageMimeTypes . includes ( attachment . contentType ? . toLowerCase ( ) ) ;
return hasImageExtension || hasImageMimeType ;
} ) ;
2025-10-04 03:10:04 -05:00
}
/ * *
2025-10-04 04:24:42 -05:00
* Procesar imágenes adjuntas para análisis con Gemini Vision
2025-10-04 03:10:04 -05:00
* /
2025-10-04 04:24:42 -05:00
private async processImageAttachments ( attachments : any [ ] ) : Promise < any [ ] > {
2025-10-05 05:19:16 -05:00
const imageAttachments : Array < { inlineData : { data : string ; mimeType : string } } > = [ ] ;
2025-10-04 03:10:04 -05:00
2025-10-04 04:24:42 -05:00
for ( const attachment of attachments ) {
if ( this . hasImageAttachments ( [ attachment ] ) ) {
try {
// Descargar la imagen
const response = await fetch ( attachment . url ) ;
if ( ! response . ok ) {
logger . warn ( ` Error descargando imagen: ${ response . statusText } ` ) ;
continue ;
}
2025-10-04 03:10:04 -05:00
2025-10-04 04:24:42 -05:00
const arrayBuffer = await response . arrayBuffer ( ) ;
const base64Data = Buffer . from ( arrayBuffer ) . toString ( 'base64' ) ;
2025-10-04 03:10:04 -05:00
2025-10-04 04:24:42 -05:00
// Determinar el tipo MIME
let mimeType = attachment . contentType || 'image/png' ;
if ( ! mimeType . startsWith ( 'image/' ) ) {
// Inferir del nombre del archivo
const ext = attachment . name ? . toLowerCase ( ) . split ( '.' ) . pop ( ) ;
switch ( ext ) {
case 'jpg' :
case 'jpeg' :
mimeType = 'image/jpeg' ;
break ;
case 'png' :
mimeType = 'image/png' ;
break ;
case 'gif' :
mimeType = 'image/gif' ;
break ;
case 'webp' :
mimeType = 'image/webp' ;
break ;
default :
mimeType = 'image/png' ;
}
}
2025-10-04 03:10:04 -05:00
2025-10-04 04:24:42 -05:00
imageAttachments . push ( {
inlineData : {
data : base64Data ,
mimeType : mimeType
}
} ) ;
2025-10-04 03:10:04 -05:00
2025-10-04 04:24:42 -05:00
logger . info ( ` Imagen procesada: ${ attachment . name } ( ${ mimeType } ) ` ) ;
2025-10-04 03:10:04 -05:00
2025-10-04 04:24:42 -05:00
} catch ( error ) {
logger . error ( ` Error procesando imagen ${ attachment . name } : ${ getErrorMessage ( error ) } ` ) ;
2025-10-04 03:10:04 -05:00
}
2025-10-04 04:24:42 -05:00
}
}
2025-10-04 03:10:04 -05:00
2025-10-04 04:24:42 -05:00
return imageAttachments ;
2025-10-04 03:10:04 -05:00
}
2025-10-04 02:19:36 -05:00
/ * *
2025-10-04 04:24:42 -05:00
* Procesa una request individual con manejo completo de errores y memoria persistente
2025-10-04 02:19:36 -05:00
* /
2025-10-04 04:24:42 -05:00
private async processRequest ( request : AIRequest ) : Promise < void > {
try {
const { userId , prompt , guildId , channelId , messageId , referencedMessageId } = request ;
const context = await this . getOrCreateContextWithMemory ( userId , guildId , channelId ) ;
2025-10-04 02:19:36 -05:00
2025-10-04 04:24:42 -05:00
// Obtener imágenes adjuntas si existen
const messageAttachments = ( request as any ) . attachments || [ ] ;
const hasImages = this . hasImageAttachments ( messageAttachments ) ;
const isImageRequest = this . detectImageRequest ( prompt ) ;
2025-10-04 02:19:36 -05:00
2025-10-04 04:24:42 -05:00
if ( isImageRequest && context . imageRequests >= this . config . maxImageRequests ) {
const error = new Error ( ` Has alcanzado el límite de ${ this . config . maxImageRequests } solicitudes de imagen. La conversación se ha reiniciado. ` ) ;
request . reject ( error ) ;
return ;
2025-10-04 02:19:36 -05:00
}
2025-10-04 04:24:42 -05:00
// Verificar límites de tokens
const estimatedTokens = this . estimateTokens ( prompt ) ;
if ( context . totalTokens + estimatedTokens > this . config . maxInputTokens * this . config . tokenResetThreshold ) {
this . resetConversation ( userId , guildId ) ;
logger . info ( ` Conversación reseteada para usuario ${ userId } por límite de tokens ` ) ;
2025-10-04 02:19:36 -05:00
}
2025-10-04 04:24:42 -05:00
// Obtener prompt del sistema (desde opciones o DB)
let effectiveAiRolePrompt = request . aiRolePrompt ;
if ( effectiveAiRolePrompt === undefined && guildId ) {
effectiveAiRolePrompt = ( await this . getGuildAiPrompt ( guildId ) ) ? ? undefined ;
2025-10-04 01:31:36 -05:00
}
2025-10-04 02:19:36 -05:00
// Obtener jerarquía de roles si está en un servidor
let roleHierarchy = '' ;
if ( guildId ) {
const client = ( request as any ) . client ;
if ( client ) {
roleHierarchy = await this . getGuildRoleHierarchy ( guildId , client ) ;
}
}
// Construir metadatos mejorados
const enhancedMeta = ( request . meta || '' ) + roleHierarchy ;
2025-10-02 21:52:08 -05:00
// Construir prompt del sistema optimizado
2025-10-04 02:30:28 -05:00
let systemPrompt = this . buildSystemPrompt (
2025-10-04 01:31:36 -05:00
prompt ,
context ,
isImageRequest ,
effectiveAiRolePrompt ,
2025-10-04 02:19:36 -05:00
enhancedMeta
2025-10-04 01:31:36 -05:00
) ;
2025-10-04 02:30:28 -05:00
// Procesar imágenes si las hay
let imageAttachments : any [ ] = [ ] ;
if ( hasImages ) {
imageAttachments = await this . processImageAttachments ( messageAttachments ) ;
if ( imageAttachments . length > 0 ) {
systemPrompt = ` ${ systemPrompt } \ n \ n## Imágenes adjuntas: \ nPor favor, analiza las imágenes proporcionadas y responde de acuerdo al contexto. ` ;
}
}
2025-10-04 03:10:04 -05:00
// Usar gemini-2.5-flash-preview-09-2025 que puede leer imágenes y responder con texto
2025-10-02 21:52:08 -05:00
const model = this . genAI . getGenerativeModel ( {
2025-10-06 10:28:01 -05:00
model : "gemini-2.5-flash" ,
2025-10-02 21:52:08 -05:00
generationConfig : {
maxOutputTokens : Math.min ( this . config . maxOutputTokens , Math . max ( 1024 , estimatedTokens * 0.5 ) ) ,
temperature : 0.7 ,
topP : 0.85 ,
topK : 40 ,
} ,
safetySettings : [
{
category : HarmCategory.HARM_CATEGORY_HARASSMENT ,
threshold : HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE
} ,
{
category : HarmCategory.HARM_CATEGORY_HATE_SPEECH ,
threshold : HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE
}
]
} ) ;
2025-10-04 02:30:28 -05:00
// Construir el contenido para la API
let content : any ;
if ( hasImages && imageAttachments . length > 0 ) {
// Para multimodal (texto + imágenes)
content = [
{ text : systemPrompt } ,
. . . imageAttachments
] ;
logger . info ( ` Procesando ${ imageAttachments . length } imagen(es) con Gemini Vision ` ) ;
} else {
// Solo texto
content = systemPrompt ;
}
2025-10-06 10:28:01 -05:00
const result = await this . generateContentWithRetries ( model , content ) ;
2025-10-02 21:52:08 -05:00
const response = await result . response ;
const aiResponse = response . text ( ) ? . trim ( ) ;
if ( ! aiResponse ) {
const error = new Error ( 'La IA no generó una respuesta válida' ) ;
request . reject ( error ) ;
return ;
}
2025-10-04 02:19:36 -05:00
// Actualizar contexto con memoria persistente
await this . updateContextWithMemory (
context ,
prompt ,
aiResponse ,
estimatedTokens ,
2025-10-04 02:30:28 -05:00
isImageRequest || hasImages ,
2025-10-04 02:19:36 -05:00
messageId ,
referencedMessageId
) ;
2025-10-02 21:52:08 -05:00
request . resolve ( aiResponse ) ;
2025-10-04 02:19:36 -05:00
2025-10-02 21:52:08 -05:00
} catch ( error ) {
// Manejo type-safe de errores sin ts-ignore
const errorMessage = this . parseAPIError ( error ) ;
const logMessage = getErrorMessage ( error ) ;
logger . error ( ` Error procesando AI request para ${ request . userId } : ${ logMessage } ` ) ;
// Log adicional si es un Error con stack trace
if ( isError ( error ) && error . stack ) {
logger . error ( ` Stack trace completo: ${ error . stack } ` ) ;
}
request . reject ( new Error ( errorMessage ) ) ;
}
}
/ * *
* Construcción optimizada del prompt del sistema
* /
2025-10-04 01:31:36 -05:00
private buildSystemPrompt (
userPrompt : string ,
context : ConversationContext ,
isImageRequest : boolean ,
aiRolePrompt? : string ,
meta? : string
) : string {
2025-10-02 21:52:08 -05:00
const recentMessages = context . messages
2025-10-04 01:47:02 -05:00
. slice ( - 4 )
2025-10-02 21:52:08 -05:00
. map ( msg = > ` ${ msg . role === 'user' ? 'Usuario' : 'Asistente' } : ${ msg . content } ` )
. join ( '\n' ) ;
2025-10-04 01:31:36 -05:00
const roleBlock = aiRolePrompt && aiRolePrompt . trim ( ) ? ` \ n## Rol del sistema (servidor): \ n ${ aiRolePrompt . trim ( ) . slice ( 0 , 1200 ) } \ n ` : '' ;
const metaBlock = meta && meta . trim ( ) ? ` \ n## Contexto del mensaje: \ n ${ meta . trim ( ) . slice ( 0 , 800 ) } \ n ` : '' ;
return ` Eres una hermana mayor kawaii y cariñosa que habla por Discord. Responde de manera natural, útil y concisa. ${ roleBlock } ${ metaBlock }
2025-10-02 21:52:08 -05:00
# # Reglas Discord :
- USA * * markdown de Discord * * : * * negrita * * , * cursiva * , \ ` código \` , \` \` \` bloques \` \` \`
- NUNCA uses LaTeX ( $ $ )
- Máximo 2 - 3 emojis por respuesta
2025-10-04 01:47:02 -05:00
- Prefiere emojis Unicode estándar ( 🙂 , 🎯 , etc . ) cuando no haya más contexto
- Si se te proporciona una lista de "Emojis personalizados disponibles" , puedes usarlos escribiendo :nombre : exactamente como aparece ; NO inventes nombres
2025-10-02 21:52:08 -05:00
- Respuestas concisas y claras
$ { isImageRequest ? `
# # Limitación :
- No puedes generar imágenes
- Ofrece ayuda alternativa ( descripciones , recursos , etc . )
` : ''}
# # Contexto reciente :
$ { recentMessages || 'Sin historial previo' }
# # Consulta actual :
$ { userPrompt }
Responde de forma directa y ú til : ` ;
}
/ * *
* Sistema de rate limiting mejorado
* /
private checkRateLimit ( userId : string ) : boolean {
const now = Date . now ( ) ;
const userLimit = this . rateLimitTracker . get ( userId ) ;
2025-10-04 02:19:36 -05:00
2025-10-02 21:52:08 -05:00
if ( ! userLimit || now > userLimit . resetTime ) {
this . rateLimitTracker . set ( userId , {
count : 1 ,
resetTime : now + this . config . rateLimitWindow
} ) ;
return true ;
}
2025-10-04 02:19:36 -05:00
2025-10-02 21:52:08 -05:00
if ( userLimit . count >= this . config . rateLimitMax ) {
return false ;
}
2025-10-04 02:19:36 -05:00
2025-10-02 21:52:08 -05:00
userLimit . count ++ ;
return true ;
}
2025-10-04 03:10:04 -05:00
/ * *
* Estimación de tokens más precisa
* /
private estimateTokens ( text : string ) : number {
// Aproximación mejorada basada en la tokenización real
const words = text . split ( /\s+/ ) . length ;
const chars = text . length ;
// Fórmula híbrida más precisa
return Math . ceil ( ( words * 1.3 ) + ( chars * 0.25 ) ) ;
}
2025-10-02 21:52:08 -05:00
/ * *
* Detección mejorada de requests de imagen
* /
private detectImageRequest ( prompt : string ) : boolean {
const imageKeywords = [
'imagen' , 'image' , 'dibujo' , 'draw' , 'dibujar' ,
'generar imagen' , 'create image' , 'picture' , 'foto' ,
'ilustración' , 'arte' , 'pintura' , 'sketch'
] ;
2025-10-04 02:19:36 -05:00
2025-10-02 21:52:08 -05:00
const lowerPrompt = prompt . toLowerCase ( ) ;
return imageKeywords . some ( keyword = > lowerPrompt . includes ( keyword ) ) ;
}
/ * *
2025-10-04 04:24:42 -05:00
* Obtener o crear contexto de conversación con carga desde Appwrite
2025-10-04 02:30:28 -05:00
* /
2025-10-04 04:24:42 -05:00
private async getOrCreateContextWithMemory ( userId : string , guildId? : string , channelId? : string ) : Promise < ConversationContext > {
const key = ` ${ userId } - ${ guildId || 'dm' } ` ;
let context = this . conversations . get ( key ) ;
2025-10-04 02:30:28 -05:00
2025-10-04 04:24:42 -05:00
if ( ! context ) {
// Intentar cargar desde Appwrite
const loadedContext = await this . loadConversationFromAppwrite ( userId , guildId , channelId ) ;
2025-10-04 02:30:28 -05:00
2025-10-04 04:24:42 -05:00
if ( loadedContext ) {
context = loadedContext ;
2025-10-04 02:30:28 -05:00
} else {
2025-10-04 04:24:42 -05:00
// Crear nuevo contexto si no existe en Appwrite
context = {
messages : [ ] ,
totalTokens : 0 ,
imageRequests : 0 ,
lastActivity : Date.now ( ) ,
userId ,
guildId ,
channelId
} ;
2025-10-04 02:30:28 -05:00
}
2025-10-02 21:52:08 -05:00
this . conversations . set ( key , context ) ;
}
2025-10-04 02:19:36 -05:00
2025-10-02 21:52:08 -05:00
context . lastActivity = Date . now ( ) ;
return context ;
}
/ * *
2025-10-04 02:19:36 -05:00
* Actualizar contexto de forma eficiente con guardado en Appwrite
2025-10-02 21:52:08 -05:00
* /
2025-10-04 02:19:36 -05:00
private async updateContextWithMemory (
context : ConversationContext ,
userPrompt : string ,
aiResponse : string ,
inputTokens : number ,
isImageRequest : boolean ,
messageId? : string ,
referencedMessageId? : string
) : Promise < void > {
const outputTokens = this . estimateTokens ( aiResponse ) ;
const now = Date . now ( ) ;
2025-10-02 21:52:08 -05:00
2025-10-04 02:19:36 -05:00
// Agregar mensajes con IDs de Discord
context . messages . push (
{
role : 'user' ,
content : userPrompt ,
timestamp : now ,
tokens : inputTokens ,
messageId ,
referencedMessageId
} ,
{
role : 'assistant' ,
content : aiResponse ,
timestamp : now ,
tokens : outputTokens
2025-10-02 21:52:08 -05:00
}
2025-10-04 02:19:36 -05:00
) ;
// Mantener solo los mensajes más recientes
if ( context . messages . length > this . config . maxMessageHistory ) {
const removed = context . messages . splice ( 0 , context . messages . length - this . config . maxMessageHistory ) ;
const removedTokens = removed . reduce ( ( sum , msg ) = > sum + msg . tokens , 0 ) ;
context . totalTokens -= removedTokens ;
}
context . totalTokens += inputTokens + outputTokens ;
context . lastActivity = now ;
if ( isImageRequest ) {
context . imageRequests ++ ;
}
// Guardar en Appwrite de forma asíncrona
this . saveConversationToAppwrite ( context ) . catch ( error = > {
logger . warn ( ` Error guardando conversación: ${ getErrorMessage ( error ) } ` ) ;
} ) ;
2025-10-02 21:52:08 -05:00
}
2025-10-04 02:19:36 -05:00
/ * *
* Limpiar cache pero mantener memoria persistente
* /
public clearCache ( ) : void {
this . conversations . clear ( ) ;
this . userCooldowns . clear ( ) ;
this . rateLimitTracker . clear ( ) ;
this . guildPromptCache . clear ( ) ;
logger . info ( 'Cache de AI limpiado, memoria persistente mantenida' ) ;
}
/ * *
* Reset completo pero mantener memoria persistente
* /
public fullReset ( ) : void {
this . clearCache ( ) ;
this . requestQueue . length = 0 ;
logger . info ( 'AI completamente reseteada, memoria persistente mantenida' ) ;
2025-10-02 21:52:08 -05:00
}
/ * *
* Obtener estadísticas del servicio
* /
getStats ( ) : {
activeConversations : number ;
queueLength : number ;
totalRequests : number ;
averageResponseTime : number ;
} {
return {
activeConversations : this.conversations.size ,
queueLength : this.requestQueue.length ,
totalRequests : this.userCooldowns.size ,
averageResponseTime : 0
} ;
}
2025-10-04 02:19:36 -05:00
/ * *
* Guardar conversación en Appwrite para memoria persistente
* /
private async saveConversationToAppwrite ( context : ConversationContext ) : Promise < void > {
if ( ! isAIConversationsConfigured ( ) ) {
return ; // Si no está configurado, no guardamos
}
try {
2025-10-04 05:03:39 -05:00
await ensureAIConversationsSchema ( ) ;
2025-10-04 02:19:36 -05:00
const databases = getDatabases ( ) ;
if ( ! databases ) return ;
2025-10-04 05:03:39 -05:00
// Asegurar conversationId válido y corto para Appwrite
2025-10-04 02:24:18 -05:00
let conversationId = context . conversationId ;
if ( ! conversationId ) {
2025-10-04 05:03:39 -05:00
const userIdShort = context . userId . slice ( - 8 ) ;
2025-10-04 02:24:18 -05:00
const guildIdShort = context . guildId ? context . guildId . slice ( - 8 ) : 'dm' ;
2025-10-04 05:03:39 -05:00
const timestamp = Date . now ( ) . toString ( 36 ) ;
conversationId = ` ai_ ${ userIdShort } _ ${ guildIdShort } _ ${ timestamp } ` . slice ( 0 , 36 ) ;
2025-10-04 02:24:18 -05:00
context . conversationId = conversationId ;
}
2025-10-04 02:19:36 -05:00
2025-10-04 05:03:39 -05:00
// Serializar mensajes a JSON
const messagesPayload = context . messages . map ( m = > ( {
role : m.role ,
content : m.content ,
timestamp : m.timestamp ,
messageId : m.messageId ,
referencedMessageId : m.referencedMessageId ,
} ) ) ;
const messagesJson = JSON . stringify ( messagesPayload ) ;
const data : AppwriteConversation = {
2025-10-04 02:19:36 -05:00
userId : context.userId ,
2025-10-04 05:03:39 -05:00
guildId : context.guildId ? ? null ,
channelId : context.channelId ? ? null ,
2025-10-04 02:19:36 -05:00
conversationId ,
2025-10-04 05:03:39 -05:00
messagesJson ,
lastActivity : new Date ( context . lastActivity ) . toISOString ( ) ,
createdAt : new Date ( ) . toISOString ( ) ,
2025-10-04 02:19:36 -05:00
} ;
2025-10-04 05:03:39 -05:00
// Upsert por ID estable
2025-10-04 02:24:18 -05:00
try {
await databases . updateDocument (
APPWRITE_DATABASE_ID ,
APPWRITE_COLLECTION_AI_CONVERSATIONS_ID ,
conversationId ,
2025-10-04 05:03:39 -05:00
data
2025-10-04 02:24:18 -05:00
) ;
} catch ( updateError ) {
await databases . createDocument (
APPWRITE_DATABASE_ID ,
APPWRITE_COLLECTION_AI_CONVERSATIONS_ID ,
conversationId ,
2025-10-04 05:03:39 -05:00
data
2025-10-04 02:24:18 -05:00
) ;
}
2025-10-04 02:19:36 -05:00
logger . debug ( ` Conversación guardada en Appwrite: ${ conversationId } ` ) ;
} catch ( error ) {
logger . warn ( ` Error guardando conversación en Appwrite: ${ getErrorMessage ( error ) } ` ) ;
}
}
/ * *
* Cargar conversación desde Appwrite
* /
private async loadConversationFromAppwrite ( userId : string , guildId? : string , channelId? : string ) : Promise < ConversationContext | null > {
if ( ! isAIConversationsConfigured ( ) ) {
return null ;
}
try {
2025-10-04 05:03:39 -05:00
await ensureAIConversationsSchema ( ) ;
2025-10-04 02:19:36 -05:00
const databases = getDatabases ( ) ;
if ( ! databases ) return null ;
2025-10-04 05:03:39 -05:00
const queries : any [ ] = [ sdk . Query . equal ( 'userId' , userId ) ] ;
if ( guildId ) queries . push ( sdk . Query . equal ( 'guildId' , guildId ) ) ;
if ( channelId ) queries . push ( sdk . Query . equal ( 'channelId' , channelId ) ) ;
queries . push ( sdk . Query . orderDesc ( 'lastActivity' ) ) ;
queries . push ( sdk . Query . limit ( 1 ) ) ;
2025-10-04 02:24:18 -05:00
2025-10-04 02:19:36 -05:00
const response = await databases . listDocuments (
APPWRITE_DATABASE_ID ,
APPWRITE_COLLECTION_AI_CONVERSATIONS_ID ,
2025-10-04 02:24:18 -05:00
queries
2025-10-04 05:03:39 -05:00
) as unknown as { documents : AppwriteConversation [ ] } ;
2025-10-04 02:19:36 -05:00
2025-10-04 05:03:39 -05:00
const docs = ( response ? . documents || [ ] ) as AppwriteConversation [ ] ;
if ( ! docs . length ) return null ;
2025-10-04 02:19:36 -05:00
2025-10-04 05:03:39 -05:00
const latest = docs [ 0 ] ;
const messagesArray : any [ ] = ( ( ) = > {
try { return latest . messagesJson ? JSON . parse ( latest . messagesJson ) : [ ] ; } catch { return [ ] ; }
} ) ( ) ;
2025-10-04 02:19:36 -05:00
const context : ConversationContext = {
2025-10-04 05:03:39 -05:00
messages : messagesArray.map ( ( msg : any ) = > ( {
role : msg.role === 'assistant' ? 'assistant' : 'user' ,
content : String ( msg . content || '' ) ,
timestamp : Number ( msg . timestamp || Date . now ( ) ) ,
tokens : this.estimateTokens ( String ( msg . content || '' ) ) ,
messageId : msg.messageId ,
referencedMessageId : msg.referencedMessageId ,
2025-10-04 02:19:36 -05:00
} ) ) ,
2025-10-04 05:03:39 -05:00
totalTokens : messagesArray.reduce ( ( sum : number , m : any ) = > sum + this . estimateTokens ( String ( m . content || '' ) ) , 0 ) ,
imageRequests : 0 ,
lastActivity : Date.parse ( latest . lastActivity || new Date ( ) . toISOString ( ) ) || Date . now ( ) ,
userId : latest.userId ,
guildId : latest.guildId || undefined ,
channelId : latest.channelId || undefined ,
conversationId : latest.conversationId ,
2025-10-04 02:19:36 -05:00
} ;
2025-10-04 05:03:39 -05:00
logger . debug ( ` Conversación cargada desde Appwrite: ${ latest . conversationId } ` ) ;
2025-10-04 02:19:36 -05:00
return context ;
} catch ( error ) {
logger . warn ( ` Error cargando conversación desde Appwrite: ${ getErrorMessage ( error ) } ` ) ;
return null ;
}
}
/ * *
* Obtener jerarquía de roles de un servidor
* /
private async getGuildRoleHierarchy ( guildId : string , client : any ) : Promise < string > {
try {
const guild = await client . guilds . fetch ( guildId ) ;
if ( ! guild ) return '' ;
const roles = await guild . roles . fetch ( ) ;
const sortedRoles = roles
. filter ( ( role : any ) = > role . id !== guild . id ) // Excluir @everyone
. sort ( ( a : any , b : any ) = > b . position - a . position )
. map ( ( role : any ) = > {
2025-10-05 05:19:16 -05:00
const permissions : string [ ] = [ ] ;
2025-10-04 02:19:36 -05:00
if ( role . permissions . has ( 'Administrator' ) ) permissions . push ( 'Admin' ) ;
if ( role . permissions . has ( 'ManageGuild' ) ) permissions . push ( 'Manage Server' ) ;
if ( role . permissions . has ( 'ManageChannels' ) ) permissions . push ( 'Manage Channels' ) ;
if ( role . permissions . has ( 'ManageMessages' ) ) permissions . push ( 'Manage Messages' ) ;
if ( role . permissions . has ( 'ModerateMembers' ) ) permissions . push ( 'Moderate Members' ) ;
const permStr = permissions . length > 0 ? ` ( ${ permissions . join ( ', ' ) } ) ` : '' ;
return ` - ${ role . name } ${ permStr } ` ;
} )
. slice ( 0 , 15 ) // Limitar a 15 roles principales
. join ( '\n' ) ;
return sortedRoles ? ` \ n## Jerarquía de roles del servidor: \ n ${ sortedRoles } \ n ` : '' ;
} catch ( error ) {
logger . warn ( ` Error obteniendo jerarquía de roles: ${ getErrorMessage ( error ) } ` ) ;
return '' ;
}
}
2025-10-04 03:10:04 -05:00
/ * *
2025-10-04 04:34:55 -05:00
* Generar imagen usando la nueva API de @google / genai ( basada en Google AI Studio )
2025-10-04 03:10:04 -05:00
* Retorna un objeto con los bytes de la imagen y el tipo MIME .
* /
2025-10-04 04:34:55 -05:00
public async generateImage ( prompt : string , options ? : {
size ? : 'square' | 'portrait' | 'landscape' ;
mimeType? : string ;
numberOfImages? : number ;
personGeneration? : boolean ;
} ) : Promise < { data : Buffer ; mimeType : string ; fileName : string ; } > {
2025-10-04 03:10:04 -05:00
if ( ! prompt ? . trim ( ) ) {
throw new Error ( 'El prompt de imagen no puede estar vacío' ) ;
}
if ( ! this . genAIv2 ) {
throw new Error ( 'El SDK moderno (@google/genai) no está inicializado' ) ;
}
2025-10-04 03:53:51 -05:00
// Obtener/descubrir el modelo
const model = this . imageModelName ? ? ( await this . detectImageModel ( ) ) ;
if ( ! model ) {
2025-10-04 04:34:55 -05:00
throw new Error ( 'El generador de imágenes no está disponible para tu cuenta o región. Habilita Imagen 4.0 (imagen-4.0-generate-001) en Google AI Studio.' ) ;
2025-10-04 03:53:51 -05:00
}
2025-10-04 04:34:55 -05:00
const mimeType = options ? . mimeType ? ? 'image/jpeg' ;
2025-10-04 03:43:36 -05:00
const size = options ? . size ? ? 'square' ;
2025-10-04 04:34:55 -05:00
const numberOfImages = options ? . numberOfImages ? ? 1 ;
const personGeneration = options ? . personGeneration ? ? true ;
// Mapear tamaño a aspectRatio según la nueva API
const aspectRatio = size === 'portrait' ? '9:16' : size === 'landscape' ? '16:9' : '1:1' ;
2025-10-04 03:10:04 -05:00
try {
2025-10-04 04:34:55 -05:00
logger . info ( { model , prompt : prompt.slice ( 0 , 100 ) } , 'Generando imagen con nueva API' ) ;
const response : any = await ( this . genAIv2 as any ) . models . generateImages ( {
model : model ,
prompt : prompt ,
2025-10-04 03:10:04 -05:00
config : {
2025-10-04 04:34:55 -05:00
numberOfImages : numberOfImages ,
outputMimeType : mimeType ,
personGeneration : personGeneration ? PersonGeneration.ALLOW_ALL : PersonGeneration.DONT_ALLOW ,
aspectRatio : aspectRatio ,
imageSize : '1K' , // Usar 1K como tamaño estándar
2025-10-04 03:10:04 -05:00
}
} ) ;
2025-10-04 04:34:55 -05:00
if ( ! response ? . generatedImages || ! Array . isArray ( response . generatedImages ) || response . generatedImages . length === 0 ) {
logger . error ( { response , model } , 'No se generaron imágenes en la respuesta' ) ;
throw new Error ( 'No se generaron imágenes' ) ;
2025-10-04 03:10:04 -05:00
}
2025-10-04 04:34:55 -05:00
// Tomar la primera imagen generada
const generatedImage = response . generatedImages [ 0 ] ;
if ( ! generatedImage ? . image ? . imageBytes ) {
logger . error ( { generatedImage , model } , 'La imagen generada no contiene datos de bytes' ) ;
throw new Error ( 'La imagen generada no contiene datos válidos' ) ;
2025-10-04 03:10:04 -05:00
}
2025-10-04 04:34:55 -05:00
const base64Data = generatedImage . image . imageBytes ;
const buffer = Buffer . from ( base64Data , 'base64' ) ;
// Generar nombre de archivo basado en el tipo MIME
let fileName = ` gen_ ${ Date . now ( ) } ` ;
if ( mimeType . includes ( 'png' ) ) fileName += '.png' ;
else if ( mimeType . includes ( 'jpeg' ) || mimeType . includes ( 'jpg' ) ) fileName += '.jpg' ;
else if ( mimeType . includes ( 'webp' ) ) fileName += '.webp' ;
else fileName += '.img' ;
logger . info ( {
fileName ,
mimeType ,
bufferSize : buffer.length ,
model
} , 'Imagen generada exitosamente' ) ;
return {
data : buffer ,
mimeType : mimeType ,
fileName : fileName
} ;
2025-10-04 03:10:04 -05:00
} catch ( e ) {
2025-10-04 04:34:55 -05:00
logger . error ( { err : e as any , model , prompt : prompt.slice ( 0 , 100 ) } , 'Error en generateImage' ) ;
2025-10-04 03:43:36 -05:00
const parsed = this . parseAPIError ( e ) ;
const original = getErrorMessage ( e ) ;
2025-10-04 04:34:55 -05:00
// Proporcionar mensajes de error más específicos
if ( original . includes ( 'not found' ) || original . includes ( '404' ) ) {
throw new Error ( 'El modelo de generación de imágenes no está disponible. Verifica que Imagen 4.0 esté habilitado en tu cuenta de Google AI Studio.' ) ;
}
if ( original . includes ( 'quota' ) || original . includes ( 'limit' ) ) {
throw new Error ( 'Has alcanzado el límite de generación de imágenes. Intenta más tarde.' ) ;
}
// Si el parser no aporta información útil, usar el mensaje original
2025-10-04 03:43:36 -05:00
const message = parsed === 'Error temporal del servicio de IA. Intenta de nuevo' ? original : parsed ;
throw new Error ( message || parsed ) ;
2025-10-04 03:10:04 -05:00
}
}
2025-10-02 21:52:08 -05:00
}
// Instancia singleton
export const aiService = new AIService ( ) ;