agentic-os/reminder-service/server.js

183 lines
6.6 KiB
JavaScript

const express = require('express');
const cron = require('node-cron');
const fs = require('fs');
const http = require('http');
const path = require('path');
const app = express();
app.use(express.json());
const DATA_FILE = process.env.DATA_FILE || '/data/reminders.json';
const GATEWAY = process.env.GATEWAY_URL || 'http://whatsapp-gateway.ai-agents.svc.cluster.local:5001/send-message';
const PERSONAL = process.env.PERSONAL_NUM;
const GROUP = process.env.GROUP_URL;
const PORT = parseInt(process.env.PORT) || 6001;
// ── Persistence ──────────────────────────────────────────────────────────────
function load() {
try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); }
catch { return []; }
}
function save(reminders) {
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
fs.writeFileSync(DATA_FILE, JSON.stringify(reminders, null, 2));
}
// ── WhatsApp delivery ─────────────────────────────────────────────────────────
function sendWhatsApp(to, message) {
return new Promise((resolve) => {
const body = JSON.stringify({ to, message });
const url = new URL(GATEWAY);
const opts = {
hostname: url.hostname,
port: parseInt(url.port) || 80,
path: url.pathname,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
};
const req = http.request(opts, r => {
let d = '';
r.on('data', c => d += c);
r.on('end', () => {
try { resolve(JSON.parse(d)); }
catch { resolve({ success: false, error: 'parse error' }); }
});
});
req.on('error', e => resolve({ success: false, error: e.message }));
req.write(body);
req.end();
});
}
// ── Routes ────────────────────────────────────────────────────────────────────
// POST /reminders — schedule a new reminder
app.post('/reminders', (req, res) => {
const { message, sendAt, to, label } = req.body;
if (!message || !sendAt) {
return res.status(400).json({ error: 'message and sendAt are required' });
}
const sendAtDate = new Date(sendAt);
if (isNaN(sendAtDate.getTime())) {
return res.status(400).json({ error: 'sendAt is not a valid ISO date string' });
}
if (sendAtDate <= new Date()) {
return res.status(400).json({ error: 'sendAt must be in the future' });
}
const reminders = load();
const id = `rem_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
reminders.push({
id,
message,
sendAt: sendAtDate.toISOString(),
to: to || PERSONAL,
label: label || message.slice(0, 60),
status: 'pending',
createdAt: new Date().toISOString(),
});
save(reminders);
console.log(`[Reminder] Scheduled ${id}: "${label || message.slice(0, 40)}" → ${sendAtDate.toISOString()}`);
res.json({ success: true, id, sendAt: sendAtDate.toISOString() });
});
// POST /reminders/batch — schedule multiple reminders at once
app.post('/reminders/batch', (req, res) => {
const { items } = req.body; // [{ message, sendAt, to, label }]
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: 'items array is required' });
}
const reminders = load();
const created = [];
const errors = [];
for (const item of items) {
const { message, sendAt, to, label } = item;
if (!message || !sendAt) { errors.push({ item, error: 'missing fields' }); continue; }
const sendAtDate = new Date(sendAt);
if (isNaN(sendAtDate.getTime()) || sendAtDate <= new Date()) {
errors.push({ item, error: 'invalid or past sendAt' }); continue;
}
const id = `rem_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
reminders.push({
id,
message,
sendAt: sendAtDate.toISOString(),
to: to || PERSONAL,
label: label || message.slice(0, 60),
status: 'pending',
createdAt: new Date().toISOString(),
});
created.push({ id, sendAt: sendAtDate.toISOString(), label: label || message.slice(0, 40) });
}
save(reminders);
console.log(`[Reminder] Batch: ${created.length} scheduled, ${errors.length} failed`);
res.json({ success: true, created, errors });
});
// GET /reminders — list reminders
app.get('/reminders', (req, res) => {
const all = load();
const status = req.query.status; // optional filter: pending|sent|cancelled|failed
const result = status ? all.filter(r => r.status === status) : all;
res.json(result.slice(-100)); // last 100
});
// DELETE /reminders/:id — cancel a reminder
app.delete('/reminders/:id', (req, res) => {
const reminders = load();
const idx = reminders.findIndex(r => r.id === req.params.id);
if (idx === -1) return res.status(404).json({ error: 'not found' });
if (reminders[idx].status !== 'pending') {
return res.status(409).json({ error: `Cannot cancel — status is "${reminders[idx].status}"` });
}
reminders[idx].status = 'cancelled';
save(reminders);
console.log(`[Reminder] Cancelled ${req.params.id}`);
res.json({ success: true });
});
// GET /health
app.get('/health', (req, res) => res.json({ status: 'ok', time: new Date().toISOString() }));
// ── Scheduler (every minute) ──────────────────────────────────────────────────
cron.schedule('* * * * *', async () => {
const now = new Date();
const reminders = load();
let changed = false;
for (const r of reminders) {
if (r.status !== 'pending') continue;
if (new Date(r.sendAt) > now) continue;
console.log(`[Reminder] Firing ${r.id}: "${r.label}" → ${r.to}`);
const result = await sendWhatsApp(r.to, r.message);
r.status = result.success ? 'sent' : 'failed';
r.sentAt = now.toISOString();
if (!result.success) r.error = result.error;
changed = true;
console.log(`[Reminder] ${result.success ? '✅' : '❌'} ${r.id} ${result.success ? result.messageId : r.error}`);
}
if (changed) save(reminders);
});
app.listen(PORT, () => {
console.log(`[ReminderService] Listening on :${PORT}`);
console.log(`[ReminderService] Data: ${DATA_FILE} | Gateway: ${GATEWAY}`);
const pending = load().filter(r => r.status === 'pending').length;
console.log(`[ReminderService] Loaded ${pending} pending reminder(s).`);
});