File size: 7,275 Bytes
ee826ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
/**
 * proactive.js β€” Motor Proactivo de Zelin v5.8
 * FIXES:
 * - canales buscados por nombre con find() en vez de get() por ID
 * - cooldown aplicado correctamente
 */

import * as db       from './db.js';
import { callAI, callAIBackground } from './ai.js';
import { readConfig } from './utils.js';

const config = readConfig();

// ── Cron minimalista sin dependencias externas ────────────────────────────────
function scheduleCron(cronStr, fn) {
  const tick = () => {
    const now  = new Date();
    const min  = now.getMinutes();
    const hour = now.getHours();
    const day  = now.getDay();
    const parts = cronStr.trim().split(' ');
    const matchMin  = parts[0]==='*' ? true : parts[0].startsWith('*/')
      ? min % parseInt(parts[0].slice(2)) === 0
      : parseInt(parts[0]) === min;
    const matchHour = parts[1]==='*' ? true : parseInt(parts[1]) === hour;
    const matchDay  = parts[4]==='*' ? true : parseInt(parts[4]) === day;
    if (matchMin && matchHour && matchDay) fn().catch(e=>console.error('[PROACTIVE] cron error:',e.message));
  };
  setInterval(tick, 60_000);
}

// ── Helpers para buscar canales por nombre O por ID ───────────────────────────
// FIX: config.channels guarda nombres, no IDs β€” hay que buscar con find()
function findChannel(client, nameOrId) {
  if (!nameOrId || !client) return null;
  // Intentar por ID primero
  const byId = client.channels.cache.get(nameOrId);
  if (byId) return byId;
  // Buscar por nombre (parcial o exacto)
  const q = nameOrId.toLowerCase().replace('#', '');
  return client.channels.cache.find(c =>
    c.isTextBased?.() && (
      c.name.toLowerCase() === q ||
      c.name.toLowerCase().includes(q)
    )
  ) || null;
}

// ── ProactiveEngine ────────────────────────────────────────────────────────────
export class ProactiveEngine {
  constructor(client) {
    this.client    = client;
    this.lastSpoke = {}; // channelId β†’ timestamp
    this.COOLDOWN  = 30 * 60 * 1000;
  }

  init() {
    const feats = config.features ?? {};
    if (!feats.proactive) return;

    scheduleCron('0 20 * * *', () => this.eveningRecap());
    scheduleCron('0 12 * * 1', () => this.weeklyNews());
    scheduleCron('*/30 * * * *', () => this.checkAlerts());
    setInterval(() => this.checkMinecraftEvents().catch(()=>{}), 60_000);
    this.setupActivityMonitor();

    console.log('[PROACTIVE] Motor proactivo iniciado');
  }

  canSpeak(channelId) {
    const last = this.lastSpoke[channelId] || 0;
    return Date.now() - last > this.COOLDOWN;
  }

  // FIX: buscar canal por nombre con findChannel()
  async speak(channelNameOrId, prompt, context='') {
    const ch = findChannel(this.client, channelNameOrId);
    if (!ch) return;
    if (!this.canSpeak(ch.id)) return;

    try {
      const dec = await callAIBackground([{
        role:'user',
        content:'Eres Zelin. Contexto: "'+context+'". ΒΏDeberΓ­as mandar un mensaje ahora? SOLO JSON: {"speak":true/false,"reason":"..."}',
      }], 'fast', 60, context);
      const parsed = JSON.parse(dec.replace(/```json|```/g,'').trim());
      if (!parsed.speak) return;
    } catch { return; }

    const message = await callAIBackground([
      {role:'system', content:'Eres Zelin, asistente de TomateSMP. SΓ© natural y breve.'},
      {role:'user',   content: prompt},
    ], 'chat', 300, prompt);

    await ch.send(message).catch(()=>{});
    this.lastSpoke[ch.id] = Date.now();
  }

  async eveningRecap() {
    const chRef = config.channels?.general || config.channels?.announcements;
    if (!chRef) return;
    try {
      const stats = await db.getServerStats();
      await this.speak(chRef,
        'Genera un resumen divertido y muy breve del dΓ­a en TomateSMP con estos datos: '+JSON.stringify(stats),
        'Resumen diario del servidor');
    } catch {}
  }

  async weeklyNews() {
    const chRef = config.channels?.general || config.channels?.announcements;
    if (!chRef) return;
    try {
      const stats = await db.getServerStats();
      await this.speak(chRef,
        'Genera un breve mensaje de inicio de semana para TomateSMP. Datos de la semana: '+JSON.stringify(stats),
        'Inicio de semana en el servidor');
    } catch {}
  }

  async checkAlerts() {
    const chRef = config.channels?.staffChat;
    if (!chRef) return;
    try {
      const r = await db.db.execute({sql:"SELECT key,value FROM zelin_memory WHERE category='alerts' AND key LIKE 'alert.pending.%' LIMIT 5",args:[]});
      for (const row of r.rows) {
        let alert;
        try { alert = typeof row.value==='string' ? JSON.parse(row.value) : row.value; } catch { continue; }
        await this.speak(chRef,
          'Informa sobre esta alerta de forma clara y concisa: '+JSON.stringify(alert),
          'Alerta automΓ‘tica del sistema');
        await db.db.execute({sql:"UPDATE zelin_memory SET category='alerts_done' WHERE key=?",args:[row.key]}).catch(()=>{});
      }
    } catch {}
  }

  async checkMinecraftEvents() {
    const chRef = config.channels?.welcome || config.channels?.general;
    if (!chRef) return;
    try {
      const r = await db.db.execute({sql:"SELECT key,value FROM zelin_memory WHERE category='mc_events' AND key LIKE 'mcevent.pending.%' LIMIT 10",args:[]});
      for (const row of r.rows) {
        let event;
        try { event = typeof row.value==='string' ? JSON.parse(row.value) : row.value; } catch { continue; }
        if (event.type==='new_player') {
          await this.speak(chRef,
            'Dale la bienvenida a '+event.playerName+' que acaba de unirse al servidor TomateSMP por primera vez. SΓ© cΓ‘lido y breve.',
            'Nuevo jugador en el servidor');
        }
        await db.db.execute({sql:"UPDATE zelin_memory SET category='mc_events_done' WHERE key=?",args:[row.key]}).catch(()=>{});
      }
    } catch {}
  }

  setupActivityMonitor() {
    this.client.on('messageCreate', async (msg) => {
      if (msg.author.bot) return;
      if (!this.canSpeak(msg.channelId)) return;
      if (Math.random() > 0.10) return;

      try {
        const rel = await callAIBackground([{
          role:'user',
          content:'Mensaje Discord: "'+msg.content.slice(0,200)+'". ΒΏEs sobre TomateSMP o algo donde Zelin aporte algo ΓΊtil? SOLO JSON: {"relevant":true/false}',
        }], 'fast', 40, msg.content);

        const parsed = JSON.parse(rel.replace(/```json|```/g,'').trim());
        if (!parsed.relevant) return;

        const reply = await callAIBackground([
          {role:'system', content:'Eres Zelin. Participa brevemente y de forma natural en la conversaciΓ³n.'},
          {role:'user',   content: msg.content},
        ], 'chat', 200, msg.content);

        await msg.channel.send(reply).catch(()=>{});
        this.lastSpoke[msg.channelId] = Date.now();
      } catch {}
    });
  }
}

export let proactiveEngine = null;

export function initProactive(client) {
  const feats = config.features ?? {};
  if (!feats.proactive) return;
  proactiveEngine = new ProactiveEngine(client);
  proactiveEngine.init();
}