Files
amayo/src/commands/messages/AI/chat.ts

288 lines
11 KiB
TypeScript

import logger from "../../../core/lib/logger";
import { CommandMessage } from "../../../core/types/commands";
import { TextChannel, DMChannel, ThreadChannel, ChannelType, GuildEmoji } from "discord.js";
import { aiService } from "../../../core/services/AIService";
/**
* Dividir texto de forma inteligente preservando markdown
*/
function smartChunkText(text: string, maxLength: number): string[] {
if (text.length <= maxLength) return [text];
const chunks: string[] = [];
let currentChunk = '';
const lines = text.split('\n');
for (const line of lines) {
// Si agregar esta línea excede el límite
if (currentChunk.length + line.length + 1 > maxLength) {
if (currentChunk) {
chunks.push(currentChunk.trim());
currentChunk = '';
}
// Si la línea misma es muy larga, dividirla por palabras
if (line.length > maxLength) {
const words = line.split(' ');
let wordChunk = '';
for (const word of words) {
if (wordChunk.length + word.length + 1 > maxLength) {
if (wordChunk) {
chunks.push(wordChunk.trim());
wordChunk = '';
}
}
wordChunk += (wordChunk ? ' ' : '') + word;
}
if (wordChunk) {
currentChunk = wordChunk;
}
} else {
currentChunk = line;
}
} else {
currentChunk += (currentChunk ? '\n' : '') + line;
}
}
if (currentChunk) {
chunks.push(currentChunk.trim());
}
return chunks;
}
function replaceShortcodesWithEmojisOutsideCode(text: string, emojiMap: Record<string, string>): string {
if (!text) return text;
// Split by triple backticks to avoid code blocks
const parts = text.split(/```/);
for (let i = 0; i < parts.length; i++) {
// Only replace in non-code blocks (even indices)
if (i % 2 === 0) {
// Also avoid inline code wrapped in single backticks by a simple pass
const inlineParts = parts[i].split(/`/);
for (let j = 0; j < inlineParts.length; j++) {
if (j % 2 === 0) {
inlineParts[j] = inlineParts[j].replace(/:([a-zA-Z0-9_]{2,32}):/g, (match, p1: string) => {
const key = p1;
const found = emojiMap[key];
return found ? found : match;
});
}
}
parts[i] = inlineParts.join('`');
}
}
return parts.join('```');
}
async function getGuildCustomEmojis(message: any): Promise<{ names: string[]; map: Record<string, string> }> {
const result = { names: [] as string[], map: {} as Record<string, string> };
try {
const guild = message.guild;
if (!guild) return result;
// Ensure emojis are fetched
const emojis = await guild.emojis.fetch();
const list = Array.from(emojis.values()) as GuildEmoji[];
for (const e of list) {
const name = e.name ?? undefined;
const id = e.id;
if (!name || !id) continue;
const tag = e.animated ? `<a:${name}:${id}>` : `<:${name}:${id}>`;
if (!(name in result.map)) {
result.map[name] = tag;
result.names.push(name);
}
}
// Limit names to 25 for meta context brevity
result.names = result.names.slice(0, 25);
} catch {
// ignore
}
return result;
}
function buildMessageMeta(message: any, emojiNames?: string[]): string {
try {
const parts: string[] = [];
const inGuild = !!message.guild;
// Canal / hilo
if (message.channel) {
if (message.channel.type === ChannelType.GuildText) {
parts.push(`Canal: #${message.channel.name}`);
} else if (message.channel.isThread?.()) {
const parent = message.channel.parent as TextChannel | null;
const threadName = message.channel.name;
const parentName = parent?.name ? ` en #${parent.name}` : '';
parts.push(`Hilo: ${threadName}${parentName}`);
} else if (message.channel.type === ChannelType.DM) {
parts.push('DM');
}
}
// Menciones
const userMentions = message.mentions?.users ? Array.from(message.mentions.users.values()) : [];
const roleMentions = message.mentions?.roles ? Array.from(message.mentions.roles.values()) : [];
const channelMentions = message.mentions?.channels ? Array.from(message.mentions.channels.values()) : [];
if (userMentions.length) {
parts.push(`Menciones usuario: ${userMentions.slice(0, 5).map((u: any) => u.username ?? u.tag ?? u.id).join(', ')}`);
}
if (roleMentions.length) {
parts.push(`Menciones rol: ${roleMentions.slice(0, 5).map((r: any) => r.name ?? r.id).join(', ')}`);
}
if (channelMentions.length) {
parts.push(`Menciones canal: ${channelMentions.slice(0, 3).map((c: any) => c.name ?? c.id).join(', ')}`);
}
// ¿Mención al bot?
const botId = message.client?.user?.id;
if (botId && message.mentions?.users?.has?.(botId)) {
parts.push('El mensaje menciona al bot');
}
// Respuesta/Referencia
if (message.reference?.messageId) {
parts.push('Es una respuesta a otro mensaje');
}
// Adjuntos
const attachments = message.attachments ? Array.from(message.attachments.values()) : [];
if (attachments.length) {
const info = attachments.slice(0, 2).map((a: any) => a.name || a.contentType || 'adjunto').join(', ');
parts.push(`Adjuntos: ${info}`);
}
if (emojiNames && emojiNames.length) {
parts.push(`Emojis personalizados disponibles (usa :nombre:): ${emojiNames.join(', ')}`);
}
const metaRaw = parts.join(' | ');
return metaRaw.length > 800 ? metaRaw.slice(0, 800) : metaRaw;
} catch {
return '';
}
}
export const command: CommandMessage = {
name: 'ai',
type: "message",
aliases: ['chat', 'gemini'],
cooldown: 2, // Reducido porque el servicio maneja su propio rate limiting
description: 'Chatea con la IA (Gemini) de forma estable y escalable.',
category: 'IA',
usage: 'ai <mensaje>',
run: async (message, args) => {
// Validaciones básicas
if (!args || args.length === 0) {
await message.reply({
content: '**Uso:** `ai <tu mensaje>`\n**Ejemplo:** `ai ¿Cómo funciona JavaScript?`\n**Límite:** 4000 caracteres máximo'
});
return;
}
const prompt = args.join(' ');
const userId = message.author.id;
const guildId = message.guild?.id;
// Verificar tipo de canal
const channel = message.channel as TextChannel | DMChannel | ThreadChannel;
if (!channel || !('send' in channel)) {
await message.reply({ content: "❌ **Error:** Este comando no se puede usar en este tipo de canal." });
return;
}
// Indicador de que está escribiendo
const typingInterval = setInterval(() => {
channel.sendTyping().catch(() => {});
}, 5000);
// Emojis personalizados del servidor
const { names: emojiNames, map: emojiMap } = await getGuildCustomEmojis(message);
// Construir metadatos del mensaje para mejor contexto (incluye emojis)
const messageMeta = buildMessageMeta(message, emojiNames);
// Verificar si es una respuesta a un mensaje de la AI
let referencedMessageId: string | undefined;
let isReplyToAI = false;
if (message.reference?.messageId) {
try {
const referencedMessage = await message.channel.messages.fetch(message.reference.messageId);
if (referencedMessage.author.id === message.client.user?.id) {
isReplyToAI = true;
referencedMessageId = message.reference.messageId;
}
} catch (error) {
// Mensaje referenciado no encontrado, ignorar
}
}
try {
let aiResponse: string;
// Verificar si hay imágenes adjuntas
const attachments = Array.from(message.attachments.values());
const hasImages = attachments.length > 0 && aiService.hasImageAttachments(attachments);
// Usar el método unificado con memoria persistente y soporte para imágenes
aiResponse = await aiService.processAIRequestWithMemory(
userId,
prompt,
guildId,
message.channel.id,
message.id,
referencedMessageId,
message.client,
'normal',
{
meta: messageMeta + (hasImages ? ` | Tiene ${attachments.length} imagen(es) adjunta(s)` : ''),
attachments: hasImages ? attachments : undefined
}
);
// Reemplazar :nombre: por el tag real del emoji, evitando bloques de código
if (emojiNames.length > 0) {
aiResponse = replaceShortcodesWithEmojisOutsideCode(aiResponse, emojiMap);
}
// Discord limita el contenido a ~2000 caracteres
const MAX_CONTENT = 2000;
if (aiResponse.length > MAX_CONTENT) {
const chunks = smartChunkText(aiResponse, MAX_CONTENT);
for (let i = 0; i < chunks.length && i < 6; i++) {
if (i === 0) {
await message.reply({ content: chunks[i] });
} else {
await channel.send({ content: chunks[i] });
// Pausa entre mensajes para evitar rate limits
await new Promise(resolve => setTimeout(resolve, 500));
}
}
if (chunks.length > 6) {
await channel.send({ content: "⚠️ Nota: La respuesta fue truncada. Intenta una pregunta más específica." });
}
} else {
await message.reply({ content: aiResponse });
}
// Log para monitoreo (solo en desarrollo)
if (process.env.NODE_ENV === 'development') {
const stats = aiService.getStats();
logger.info(`AI Request completado - Usuario: ${userId}, Queue: ${stats.queueLength}, Conversaciones activas: ${stats.activeConversations}`);
}
} catch (error: any) {
logger.error(`Error en comando AI para usuario ${userId}:`, error);
await message.reply({ content: `❌ Error del Servicio de IA: ${error.message || 'Error desconocido del servicio'}` });
} finally {
// Limpiar indicador de escritura
clearInterval(typingInterval);
}
}
}