neoagent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +28 -0
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/bin/neoagent.js +8 -0
- package/com.neoagent.plist +45 -0
- package/docs/configuration.md +45 -0
- package/docs/skills.md +45 -0
- package/lib/manager.js +459 -0
- package/package.json +61 -0
- package/server/db/database.js +239 -0
- package/server/index.js +442 -0
- package/server/middleware/auth.js +35 -0
- package/server/public/app.html +559 -0
- package/server/public/css/app.css +608 -0
- package/server/public/css/styles.css +472 -0
- package/server/public/favicon.svg +17 -0
- package/server/public/js/app.js +3283 -0
- package/server/public/login.html +313 -0
- package/server/routes/agents.js +125 -0
- package/server/routes/auth.js +105 -0
- package/server/routes/browser.js +116 -0
- package/server/routes/mcp.js +164 -0
- package/server/routes/memory.js +193 -0
- package/server/routes/messaging.js +153 -0
- package/server/routes/protocols.js +87 -0
- package/server/routes/scheduler.js +63 -0
- package/server/routes/settings.js +98 -0
- package/server/routes/skills.js +107 -0
- package/server/routes/store.js +1192 -0
- package/server/services/ai/compaction.js +82 -0
- package/server/services/ai/engine.js +1690 -0
- package/server/services/ai/models.js +46 -0
- package/server/services/ai/multiStep.js +112 -0
- package/server/services/ai/providers/anthropic.js +181 -0
- package/server/services/ai/providers/base.js +40 -0
- package/server/services/ai/providers/google.js +187 -0
- package/server/services/ai/providers/grok.js +121 -0
- package/server/services/ai/providers/ollama.js +162 -0
- package/server/services/ai/providers/openai.js +167 -0
- package/server/services/ai/toolRunner.js +218 -0
- package/server/services/browser/controller.js +320 -0
- package/server/services/cli/executor.js +204 -0
- package/server/services/mcp/client.js +260 -0
- package/server/services/memory/embeddings.js +126 -0
- package/server/services/memory/manager.js +431 -0
- package/server/services/messaging/base.js +23 -0
- package/server/services/messaging/discord.js +238 -0
- package/server/services/messaging/manager.js +328 -0
- package/server/services/messaging/telegram.js +243 -0
- package/server/services/messaging/telnyx.js +693 -0
- package/server/services/messaging/whatsapp.js +304 -0
- package/server/services/scheduler/cron.js +312 -0
- package/server/services/websocket.js +191 -0
- package/server/utils/security.js +71 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
const { BasePlatform } = require('./base');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
const AUTH_DIR = path.join(__dirname, '..', '..', '..', 'data', 'whatsapp-auth');
|
|
6
|
+
|
|
7
|
+
class WhatsAppPlatform extends BasePlatform {
|
|
8
|
+
constructor(config = {}) {
|
|
9
|
+
super('whatsapp', config);
|
|
10
|
+
this.supportsGroups = true;
|
|
11
|
+
this.supportsMedia = true;
|
|
12
|
+
this.sock = null;
|
|
13
|
+
this.qrCode = null;
|
|
14
|
+
this.reconnectAttempts = 0;
|
|
15
|
+
this.maxReconnect = 5;
|
|
16
|
+
this.authDir = config.authDir || AUTH_DIR;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async connect() {
|
|
20
|
+
if (!fs.existsSync(this.authDir)) fs.mkdirSync(this.authDir, { recursive: true });
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
default: makeWASocket,
|
|
24
|
+
useMultiFileAuthState,
|
|
25
|
+
DisconnectReason,
|
|
26
|
+
makeCacheableSignalKeyStore,
|
|
27
|
+
fetchLatestBaileysVersion,
|
|
28
|
+
Browsers
|
|
29
|
+
} = require('@whiskeysockets/baileys');
|
|
30
|
+
const pino = require('pino');
|
|
31
|
+
|
|
32
|
+
let logger;
|
|
33
|
+
try {
|
|
34
|
+
logger = pino({ level: 'silent' });
|
|
35
|
+
} catch {
|
|
36
|
+
logger = { level: 'silent', info: () => {}, error: () => {}, warn: () => {}, debug: () => {}, trace: () => {}, child: () => logger };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { version, isLatest } = await fetchLatestBaileysVersion();
|
|
40
|
+
console.log(`[WhatsApp] Using WA version ${version.join('.')}, isLatest: ${isLatest}`);
|
|
41
|
+
|
|
42
|
+
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
|
|
43
|
+
|
|
44
|
+
this._logger = logger;
|
|
45
|
+
|
|
46
|
+
this.sock = makeWASocket({
|
|
47
|
+
version,
|
|
48
|
+
auth: {
|
|
49
|
+
creds: state.creds,
|
|
50
|
+
keys: makeCacheableSignalKeyStore(state.keys, logger)
|
|
51
|
+
},
|
|
52
|
+
logger,
|
|
53
|
+
browser: Browsers.appropriate('Chrome'),
|
|
54
|
+
connectTimeoutMs: 60000,
|
|
55
|
+
defaultQueryTimeoutMs: 60000,
|
|
56
|
+
markOnlineOnConnect: false,
|
|
57
|
+
generateHighQualityLinkPreview: false,
|
|
58
|
+
syncFullHistory: false,
|
|
59
|
+
fireInitQueries: false
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this.sock.ev.on('creds.update', saveCreds);
|
|
63
|
+
|
|
64
|
+
this.sock.ev.on('connection.update', (update) => {
|
|
65
|
+
const { connection, lastDisconnect, qr } = update;
|
|
66
|
+
|
|
67
|
+
if (qr) {
|
|
68
|
+
this.qrCode = qr;
|
|
69
|
+
this.status = 'awaiting_qr';
|
|
70
|
+
this.emit('qr', qr);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (connection === 'close') {
|
|
74
|
+
const statusCode = lastDisconnect?.error?.output?.statusCode;
|
|
75
|
+
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
|
|
76
|
+
|
|
77
|
+
this.status = 'disconnected';
|
|
78
|
+
this.emit('disconnected', { statusCode, shouldReconnect });
|
|
79
|
+
|
|
80
|
+
if (shouldReconnect && this.reconnectAttempts < this.maxReconnect) {
|
|
81
|
+
this.reconnectAttempts++;
|
|
82
|
+
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
|
|
83
|
+
setTimeout(() => this.connect(), delay);
|
|
84
|
+
} else if (statusCode === DisconnectReason.loggedOut) {
|
|
85
|
+
fs.rmSync(this.authDir, { recursive: true, force: true });
|
|
86
|
+
this.emit('logged_out');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (connection === 'open') {
|
|
91
|
+
this.status = 'connected';
|
|
92
|
+
this.qrCode = null;
|
|
93
|
+
this.reconnectAttempts = 0;
|
|
94
|
+
this.emit('connected');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
|
99
|
+
if (type !== 'notify') return;
|
|
100
|
+
|
|
101
|
+
for (const msg of messages) {
|
|
102
|
+
if (msg.key.fromMe) continue;
|
|
103
|
+
|
|
104
|
+
const chatId = msg.key.remoteJid;
|
|
105
|
+
const isGroup = chatId?.endsWith('@g.us');
|
|
106
|
+
const sender = isGroup ? msg.key.participant : chatId;
|
|
107
|
+
const pushName = msg.pushName || '';
|
|
108
|
+
|
|
109
|
+
let content = '';
|
|
110
|
+
let mediaType = null;
|
|
111
|
+
|
|
112
|
+
if (msg.message?.conversation) {
|
|
113
|
+
content = msg.message.conversation;
|
|
114
|
+
} else if (msg.message?.extendedTextMessage?.text) {
|
|
115
|
+
content = msg.message.extendedTextMessage.text;
|
|
116
|
+
} else if (msg.message?.imageMessage) {
|
|
117
|
+
content = msg.message.imageMessage.caption || '[Image]';
|
|
118
|
+
mediaType = 'image';
|
|
119
|
+
} else if (msg.message?.videoMessage) {
|
|
120
|
+
content = msg.message.videoMessage.caption || '[Video]';
|
|
121
|
+
mediaType = 'video';
|
|
122
|
+
} else if (msg.message?.audioMessage) {
|
|
123
|
+
content = '[Voice Note]';
|
|
124
|
+
mediaType = 'audio';
|
|
125
|
+
} else if (msg.message?.documentMessage) {
|
|
126
|
+
content = msg.message.documentMessage.fileName || '[Document]';
|
|
127
|
+
mediaType = 'document';
|
|
128
|
+
} else if (msg.message?.stickerMessage) {
|
|
129
|
+
content = '[Sticker]';
|
|
130
|
+
mediaType = 'sticker';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!content && !mediaType) continue;
|
|
134
|
+
|
|
135
|
+
let localMediaPath = null;
|
|
136
|
+
if (mediaType && mediaType !== 'sticker') {
|
|
137
|
+
try {
|
|
138
|
+
const { downloadMediaMessage } = require('@whiskeysockets/baileys');
|
|
139
|
+
const MEDIA_DIR = path.join(__dirname, '..', '..', '..', 'data', 'media');
|
|
140
|
+
if (!fs.existsSync(MEDIA_DIR)) fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
|
141
|
+
const buffer = await downloadMediaMessage(msg, 'buffer', {}, {
|
|
142
|
+
logger: this._logger,
|
|
143
|
+
reuploadRequest: this.sock.updateMediaMessage
|
|
144
|
+
});
|
|
145
|
+
const extMap = { image: 'jpg', video: 'mp4', document: 'bin', audio: 'ogg' };
|
|
146
|
+
const ext = extMap[mediaType] || 'bin';
|
|
147
|
+
const safeId = (msg.key.id || 'file').replace(/[^a-zA-Z0-9]/g, '');
|
|
148
|
+
const fname = `${Date.now()}_${safeId}.${ext}`;
|
|
149
|
+
localMediaPath = path.join(MEDIA_DIR, fname);
|
|
150
|
+
fs.writeFileSync(localMediaPath, buffer);
|
|
151
|
+
|
|
152
|
+
// Transcribe WhatsApp voice notes using OpenAI Whisper
|
|
153
|
+
if (mediaType === 'audio' && process.env.OPENAI_API_KEY) {
|
|
154
|
+
try {
|
|
155
|
+
const OpenAI = require('openai');
|
|
156
|
+
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
157
|
+
const transcription = await openai.audio.transcriptions.create({
|
|
158
|
+
file: fs.createReadStream(localMediaPath),
|
|
159
|
+
model: 'whisper-1',
|
|
160
|
+
response_format: 'text'
|
|
161
|
+
});
|
|
162
|
+
content = (typeof transcription === 'string' ? transcription : transcription?.text || '').trim() || '[Voice Note - empty audio]';
|
|
163
|
+
console.log(`[WhatsApp] Voice note transcribed: "${content.slice(0, 80)}"`);
|
|
164
|
+
} catch (transcribeErr) {
|
|
165
|
+
console.error('[WhatsApp] Audio transcription failed:', transcribeErr.message);
|
|
166
|
+
content = '[Voice Note - transcription failed]';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch (dlErr) {
|
|
170
|
+
console.error('[WhatsApp] Media download failed:', dlErr.message);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
await this.sock.readMessages([msg.key]);
|
|
176
|
+
} catch { /* non-fatal */ }
|
|
177
|
+
|
|
178
|
+
this.emit('message', {
|
|
179
|
+
platform: 'whatsapp',
|
|
180
|
+
chatId,
|
|
181
|
+
sender,
|
|
182
|
+
senderName: pushName,
|
|
183
|
+
content,
|
|
184
|
+
mediaType,
|
|
185
|
+
localMediaPath,
|
|
186
|
+
isGroup,
|
|
187
|
+
messageId: msg.key.id,
|
|
188
|
+
timestamp: msg.messageTimestamp ? new Date(msg.messageTimestamp * 1000).toISOString() : new Date().toISOString(),
|
|
189
|
+
rawMessage: msg
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return { status: this.status };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async disconnect() {
|
|
198
|
+
if (this.sock) {
|
|
199
|
+
this.sock.end();
|
|
200
|
+
this.sock = null;
|
|
201
|
+
}
|
|
202
|
+
this.status = 'disconnected';
|
|
203
|
+
this.emit('disconnected', { manual: true });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async sendMessage(to, content, options = {}) {
|
|
207
|
+
if (!this.sock || this.status !== 'connected') {
|
|
208
|
+
throw new Error('WhatsApp not connected');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let jid = to;
|
|
212
|
+
if (!jid.includes('@')) {
|
|
213
|
+
jid = jid.replace(/[^0-9]/g, '') + '@s.whatsapp.net';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (options.mediaPath) {
|
|
217
|
+
const ext = path.extname(options.mediaPath).toLowerCase();
|
|
218
|
+
if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
|
|
219
|
+
return await this.sock.sendMessage(jid, {
|
|
220
|
+
image: fs.readFileSync(options.mediaPath),
|
|
221
|
+
caption: content || undefined
|
|
222
|
+
});
|
|
223
|
+
} else if (['.mp4', '.avi', '.mov'].includes(ext)) {
|
|
224
|
+
return await this.sock.sendMessage(jid, {
|
|
225
|
+
video: fs.readFileSync(options.mediaPath),
|
|
226
|
+
caption: content || undefined
|
|
227
|
+
});
|
|
228
|
+
} else if (['.mp3', '.ogg', '.m4a'].includes(ext)) {
|
|
229
|
+
return await this.sock.sendMessage(jid, {
|
|
230
|
+
audio: fs.readFileSync(options.mediaPath),
|
|
231
|
+
mimetype: 'audio/mp4'
|
|
232
|
+
});
|
|
233
|
+
} else {
|
|
234
|
+
return await this.sock.sendMessage(jid, {
|
|
235
|
+
document: fs.readFileSync(options.mediaPath),
|
|
236
|
+
fileName: path.basename(options.mediaPath),
|
|
237
|
+
caption: content || undefined
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return await this.sock.sendMessage(jid, { text: content });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async markRead(chatId, messageId) {
|
|
246
|
+
if (!this.sock) return;
|
|
247
|
+
let jid = chatId;
|
|
248
|
+
if (!jid.includes('@')) jid = jid + '@s.whatsapp.net';
|
|
249
|
+
// readMessages expects full message keys; we do a best-effort read
|
|
250
|
+
await this.sock.sendReadReceipt(jid, null, [messageId]).catch(() => {});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async sendTyping(chatId, isTyping) {
|
|
254
|
+
if (!this.sock || this.status !== 'connected') return;
|
|
255
|
+
let jid = chatId;
|
|
256
|
+
if (!jid.includes('@')) jid = jid + '@s.whatsapp.net';
|
|
257
|
+
await this.sock.sendPresenceUpdate(isTyping ? 'composing' : 'paused', jid).catch(() => {});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async getContacts() {
|
|
261
|
+
if (!this.sock) return [];
|
|
262
|
+
try {
|
|
263
|
+
const contacts = await this.sock.store?.contacts || {};
|
|
264
|
+
return Object.entries(contacts).map(([id, contact]) => ({
|
|
265
|
+
id,
|
|
266
|
+
name: contact.name || contact.notify || id.split('@')[0],
|
|
267
|
+
isGroup: id.endsWith('@g.us')
|
|
268
|
+
}));
|
|
269
|
+
} catch {
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async getChats() {
|
|
275
|
+
if (!this.sock) return [];
|
|
276
|
+
try {
|
|
277
|
+
const chats = await this.sock.groupFetchAllParticipating();
|
|
278
|
+
return Object.entries(chats).map(([id, chat]) => ({
|
|
279
|
+
id,
|
|
280
|
+
name: chat.subject || id,
|
|
281
|
+
isGroup: true,
|
|
282
|
+
participants: chat.participants?.length || 0
|
|
283
|
+
}));
|
|
284
|
+
} catch {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
getAuthInfo() {
|
|
290
|
+
return { qrCode: this.qrCode, status: this.status };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async logout() {
|
|
294
|
+
if (this.sock) {
|
|
295
|
+
await this.sock.logout();
|
|
296
|
+
this.sock = null;
|
|
297
|
+
}
|
|
298
|
+
fs.rmSync(this.authDir, { recursive: true, force: true });
|
|
299
|
+
this.status = 'disconnected';
|
|
300
|
+
this.qrCode = null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
module.exports = { WhatsAppPlatform };
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
const cron = require('node-cron');
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const db = require('../../db/database');
|
|
4
|
+
|
|
5
|
+
class Scheduler {
|
|
6
|
+
constructor(io, agentEngine) {
|
|
7
|
+
this.io = io;
|
|
8
|
+
this.agentEngine = agentEngine;
|
|
9
|
+
this.jobs = new Map();
|
|
10
|
+
this.heartbeatJob = null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
start() {
|
|
14
|
+
this._loadFromDB();
|
|
15
|
+
this._startHeartbeat();
|
|
16
|
+
this._startOneTimePoller();
|
|
17
|
+
console.log('[Scheduler] Started');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
stop() {
|
|
21
|
+
for (const [id, job] of this.jobs) {
|
|
22
|
+
job.task.stop();
|
|
23
|
+
}
|
|
24
|
+
this.jobs.clear();
|
|
25
|
+
if (this.heartbeatJob) {
|
|
26
|
+
this.heartbeatJob.stop();
|
|
27
|
+
this.heartbeatJob = null;
|
|
28
|
+
}
|
|
29
|
+
if (this.oneTimePoller) {
|
|
30
|
+
this.oneTimePoller.stop();
|
|
31
|
+
this.oneTimePoller = null;
|
|
32
|
+
}
|
|
33
|
+
console.log('[Scheduler] Stopped');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_startHeartbeat() {
|
|
37
|
+
// Heartbeat runs every 5 minutes
|
|
38
|
+
this.heartbeatJob = cron.schedule('*/5 * * * *', async () => {
|
|
39
|
+
try {
|
|
40
|
+
await this._runHeartbeat();
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error('[Heartbeat] Error:', err.message);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
console.log('[Scheduler] Heartbeat active (every 5 min)');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_startOneTimePoller() {
|
|
49
|
+
this.oneTimePoller = cron.schedule('* * * * *', async () => {
|
|
50
|
+
const due = db.prepare(
|
|
51
|
+
`SELECT * FROM scheduled_tasks WHERE one_time = 1 AND enabled = 1 AND run_at IS NOT NULL AND run_at <= datetime('now')`
|
|
52
|
+
).all();
|
|
53
|
+
|
|
54
|
+
for (const task of due) {
|
|
55
|
+
const config = JSON.parse(task.task_config || '{}');
|
|
56
|
+
// Remove from memory before executing so a slow run can't double-fire
|
|
57
|
+
this.jobs.delete(task.id);
|
|
58
|
+
try {
|
|
59
|
+
await this._executeTask(task.id, task.user_id, config);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error(`[Scheduler] One-time task ${task.id} error:`, err.message);
|
|
62
|
+
}
|
|
63
|
+
// Auto-delete after execution
|
|
64
|
+
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(task.id);
|
|
65
|
+
this.io.to(`user:${task.user_id}`).emit('scheduler:task_deleted', { taskId: task.id });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
console.log('[Scheduler] One-time poller active (every 1 min)');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async _runHeartbeat() {
|
|
72
|
+
const users = db.prepare('SELECT id FROM users').all();
|
|
73
|
+
|
|
74
|
+
for (const user of users) {
|
|
75
|
+
const settings = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?')
|
|
76
|
+
.get(user.id, 'heartbeat_enabled');
|
|
77
|
+
|
|
78
|
+
if (!settings || settings.value !== 'true') continue;
|
|
79
|
+
|
|
80
|
+
const prompt = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?')
|
|
81
|
+
.get(user.id, 'heartbeat_prompt');
|
|
82
|
+
|
|
83
|
+
const defaultPrompt = 'You are running a silent background heartbeat check. Scan memory, pending tasks, and reminders. DEFAULT ACTION IS SILENCE — do NOT contact the user unless something is genuinely important (urgent deadline, critical failure, time-sensitive action required, or something the user would be upset to miss). If nothing important is found, do nothing and end the run quietly. Never send routine updates, summaries, or "all clear" messages.';
|
|
84
|
+
|
|
85
|
+
const lastPlatform = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?').get(user.id, 'last_platform')?.value;
|
|
86
|
+
const lastChatId = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?').get(user.id, 'last_chat_id')?.value;
|
|
87
|
+
const platformHint = lastPlatform && lastChatId
|
|
88
|
+
? `\n\nOnly if you found something that genuinely requires the user's attention, send_message to platform="${lastPlatform}" to="${lastChatId}". Otherwise stay silent.`
|
|
89
|
+
: '';
|
|
90
|
+
|
|
91
|
+
this.io.to(`user:${user.id}`).emit('heartbeat:running', { timestamp: new Date().toISOString() });
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
if (this.agentEngine) {
|
|
95
|
+
const convId = this._getMessagingConversation(user.id);
|
|
96
|
+
|
|
97
|
+
await this.agentEngine.run(user.id, (prompt?.value || defaultPrompt) + platformHint, {
|
|
98
|
+
triggerSource: 'heartbeat',
|
|
99
|
+
...(convId ? { conversationId: convId } : {}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error(`[Heartbeat] Error for user ${user.id}:`, err.message);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
createTask(userId, { name, cronExpression, prompt, enabled = true, callTo = null, callGreeting = null, runAt = null, oneTime = false }) {
|
|
109
|
+
if (oneTime) {
|
|
110
|
+
if (!runAt) throw new Error('runAt is required for one-time tasks');
|
|
111
|
+
const runAtDate = new Date(runAt);
|
|
112
|
+
if (isNaN(runAtDate.getTime())) throw new Error(`Invalid runAt value: ${runAt}`);
|
|
113
|
+
|
|
114
|
+
const config = { prompt };
|
|
115
|
+
if (callTo) { config.callTo = callTo; config.callGreeting = callGreeting || ''; }
|
|
116
|
+
|
|
117
|
+
const result = db.prepare(
|
|
118
|
+
'INSERT INTO scheduled_tasks (user_id, name, cron_expression, run_at, one_time, task_type, task_config, enabled) VALUES (?, ?, NULL, ?, 1, ?, ?, ?)'
|
|
119
|
+
).run(userId, name, runAtDate.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ''), 'agent_prompt', JSON.stringify(config), enabled ? 1 : 0);
|
|
120
|
+
|
|
121
|
+
return { id: result.lastInsertRowid, name, runAt: runAtDate.toISOString(), oneTime: true, enabled };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!cronExpression || !cron.validate(cronExpression)) {
|
|
125
|
+
throw new Error(`Invalid cron expression: ${cronExpression}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const config = { prompt };
|
|
129
|
+
if (callTo) { config.callTo = callTo; config.callGreeting = callGreeting || ''; }
|
|
130
|
+
|
|
131
|
+
const result = db.prepare(
|
|
132
|
+
'INSERT INTO scheduled_tasks (user_id, name, cron_expression, task_type, task_config, enabled) VALUES (?, ?, ?, ?, ?, ?)'
|
|
133
|
+
).run(userId, name, cronExpression, 'agent_prompt', JSON.stringify(config), enabled ? 1 : 0);
|
|
134
|
+
|
|
135
|
+
const taskId = result.lastInsertRowid;
|
|
136
|
+
|
|
137
|
+
if (enabled) {
|
|
138
|
+
this._scheduleTask(taskId, userId, cronExpression, config);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { id: taskId, name, cronExpression, enabled, callTo: config.callTo || null };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
updateTask(taskId, userId, updates) {
|
|
145
|
+
const task = db.prepare('SELECT * FROM scheduled_tasks WHERE id = ? AND user_id = ?').get(taskId, userId);
|
|
146
|
+
if (!task) throw new Error('Task not found');
|
|
147
|
+
|
|
148
|
+
const name = updates.name || task.name;
|
|
149
|
+
const cronExpr = updates.cronExpression || task.cron_expression;
|
|
150
|
+
const enabled = updates.enabled !== undefined ? updates.enabled : task.enabled;
|
|
151
|
+
|
|
152
|
+
// Merge config — start from existing, apply any changes
|
|
153
|
+
let config = JSON.parse(task.task_config || '{}');
|
|
154
|
+
if (updates.prompt !== undefined) config.prompt = updates.prompt;
|
|
155
|
+
if (updates.callTo !== undefined) config.callTo = updates.callTo || null;
|
|
156
|
+
if (updates.callGreeting !== undefined) config.callGreeting = updates.callGreeting || null;
|
|
157
|
+
// Clean up nulls
|
|
158
|
+
if (!config.callTo) { delete config.callTo; delete config.callGreeting; }
|
|
159
|
+
|
|
160
|
+
if (updates.cronExpression && !cron.validate(updates.cronExpression)) {
|
|
161
|
+
throw new Error(`Invalid cron expression: ${updates.cronExpression}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
db.prepare('UPDATE scheduled_tasks SET name = ?, cron_expression = ?, task_config = ?, enabled = ? WHERE id = ?')
|
|
165
|
+
.run(name, cronExpr, JSON.stringify(config), enabled ? 1 : 0, taskId);
|
|
166
|
+
|
|
167
|
+
// Reschedule
|
|
168
|
+
const existing = this.jobs.get(taskId);
|
|
169
|
+
if (existing) {
|
|
170
|
+
existing.task.stop();
|
|
171
|
+
this.jobs.delete(taskId);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (enabled) {
|
|
175
|
+
this._scheduleTask(taskId, userId, cronExpr, config);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { id: taskId, name, cronExpression: cronExpr, enabled, callTo: config.callTo || null };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
deleteTask(taskId, userId) {
|
|
182
|
+
const task = db.prepare('SELECT * FROM scheduled_tasks WHERE id = ? AND user_id = ?').get(taskId, userId);
|
|
183
|
+
if (!task) throw new Error('Task not found');
|
|
184
|
+
|
|
185
|
+
const existing = this.jobs.get(taskId);
|
|
186
|
+
if (existing) {
|
|
187
|
+
existing.task.stop();
|
|
188
|
+
this.jobs.delete(taskId);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(taskId);
|
|
192
|
+
return { deleted: true };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
listTasks(userId) {
|
|
196
|
+
const tasks = db.prepare('SELECT * FROM scheduled_tasks WHERE user_id = ? ORDER BY created_at DESC').all(userId);
|
|
197
|
+
return tasks.map(t => ({
|
|
198
|
+
id: t.id,
|
|
199
|
+
name: t.name,
|
|
200
|
+
cronExpression: t.cron_expression,
|
|
201
|
+
runAt: t.run_at || null,
|
|
202
|
+
oneTime: !!t.one_time,
|
|
203
|
+
enabled: !!t.enabled,
|
|
204
|
+
lastRun: t.last_run,
|
|
205
|
+
nextRun: t.one_time ? t.run_at : this._getNextRun(t.cron_expression),
|
|
206
|
+
config: JSON.parse(t.task_config || '{}')
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
runTaskNow(taskId, userId) {
|
|
211
|
+
const task = db.prepare('SELECT * FROM scheduled_tasks WHERE id = ? AND user_id = ?').get(taskId, userId);
|
|
212
|
+
if (!task) throw new Error('Task not found');
|
|
213
|
+
|
|
214
|
+
const config = JSON.parse(task.task_config || '{}');
|
|
215
|
+
this._executeTask(taskId, userId, config);
|
|
216
|
+
return { running: true };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
_scheduleTask(taskId, userId, cronExpression, config) {
|
|
220
|
+
const task = cron.schedule(cronExpression, async () => {
|
|
221
|
+
await this._executeTask(taskId, userId, config);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
this.jobs.set(taskId, { task, userId, config });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async _executeTask(taskId, userId, config) {
|
|
228
|
+
db.prepare('UPDATE scheduled_tasks SET last_run = datetime(\'now\') WHERE id = ?').run(taskId);
|
|
229
|
+
|
|
230
|
+
this.io.to(`user:${userId}`).emit('scheduler:task_running', { taskId, timestamp: new Date().toISOString() });
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
if (this.agentEngine && config.prompt) {
|
|
234
|
+
let notifyHint = '';
|
|
235
|
+
|
|
236
|
+
if (config.callTo) {
|
|
237
|
+
notifyHint = `\n\nThis task is configured to notify the user by phone. Use the make_call tool to call "${config.callTo}" with an appropriate greeting based on your findings. The configured greeting hint is: "${config.callGreeting || 'Hello, this is your scheduled reminder.'}"`;
|
|
238
|
+
} else {
|
|
239
|
+
const lastPlatform = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?').get(userId, 'last_platform')?.value;
|
|
240
|
+
const lastChatId = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?').get(userId, 'last_chat_id')?.value;
|
|
241
|
+
notifyHint = lastPlatform && lastChatId
|
|
242
|
+
? `\n\nIf your task result is worth notifying the user about, send it proactively via send_message to platform="${lastPlatform}" to="${lastChatId}".`
|
|
243
|
+
: '';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const convId = this._getMessagingConversation(userId);
|
|
247
|
+
|
|
248
|
+
const result = await this.agentEngine.run(userId, config.prompt + notifyHint, {
|
|
249
|
+
triggerSource: 'scheduler',
|
|
250
|
+
...(convId ? { conversationId: convId } : {}),
|
|
251
|
+
taskId,
|
|
252
|
+
});
|
|
253
|
+
this.io.to(`user:${userId}`).emit('scheduler:task_complete', { taskId, result });
|
|
254
|
+
}
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.error(`[Scheduler] Task ${taskId} error:`, err.message);
|
|
257
|
+
this.io.to(`user:${userId}`).emit('scheduler:task_error', { taskId, error: err.message });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_loadFromDB() {
|
|
262
|
+
const tasks = db.prepare('SELECT * FROM scheduled_tasks WHERE enabled = 1').all();
|
|
263
|
+
let loaded = 0;
|
|
264
|
+
for (const task of tasks) {
|
|
265
|
+
try {
|
|
266
|
+
const config = JSON.parse(task.task_config || '{}');
|
|
267
|
+
if (task.one_time) {
|
|
268
|
+
// One-time tasks are handled by the poller; nothing to register here
|
|
269
|
+
// But if it's already past due when we restart, the poller will catch it in <1 min
|
|
270
|
+
} else if (task.cron_expression) {
|
|
271
|
+
this._scheduleTask(task.id, task.user_id, task.cron_expression, config);
|
|
272
|
+
loaded++;
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.error(`[Scheduler] Failed to load task ${task.id}:`, err.message);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
console.log(`[Scheduler] Loaded ${loaded} recurring tasks from DB`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
_getNextRun(cronExpression) {
|
|
282
|
+
try {
|
|
283
|
+
const interval = cron.schedule(cronExpression, () => { });
|
|
284
|
+
interval.stop();
|
|
285
|
+
// node-cron doesn't expose nextRun; we just return null
|
|
286
|
+
return null;
|
|
287
|
+
} catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
_getMessagingConversation(userId) {
|
|
292
|
+
const lastPlatform = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?').get(userId, 'last_platform')?.value;
|
|
293
|
+
const lastChatId = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?').get(userId, 'last_chat_id')?.value;
|
|
294
|
+
if (!lastPlatform || !lastChatId) return null;
|
|
295
|
+
|
|
296
|
+
let convRow = db.prepare(
|
|
297
|
+
'SELECT id FROM conversations WHERE user_id = ? AND platform = ? AND platform_chat_id = ?'
|
|
298
|
+
).get(userId, lastPlatform, lastChatId);
|
|
299
|
+
|
|
300
|
+
if (!convRow) {
|
|
301
|
+
const convId = crypto.randomUUID();
|
|
302
|
+
db.prepare(
|
|
303
|
+
'INSERT INTO conversations (id, user_id, platform, platform_chat_id, title) VALUES (?, ?, ?, ?, ?)'
|
|
304
|
+
).run(convId, userId, lastPlatform, lastChatId, `${lastPlatform} — ${lastChatId}`);
|
|
305
|
+
convRow = { id: convId };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return convRow.id;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = { Scheduler };
|