feat: scheduled WhatsApp reminders via Claw — NLP time parsing, batch scheduling, live panel

This commit is contained in:
Antigravity 2026-05-29 14:01:10 -04:00
parent b6851ace08
commit 43697759c8
6 changed files with 609 additions and 3 deletions

View File

@ -0,0 +1,99 @@
---
# PVC for persistent reminder storage
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: reminder-service-pvc
namespace: ai-agents
spec:
accessModes: [ReadWriteOnce]
storageClassName: longhorn
resources:
requests:
storage: 512Mi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: reminder-service
namespace: ai-agents
labels:
app: reminder-service
spec:
replicas: 1
selector:
matchLabels:
app: reminder-service
template:
metadata:
labels:
app: reminder-service
spec:
initContainers:
- name: git-clone
image: alpine/git
command:
- git
- clone
- http://192.168.8.248:3000/deepkoluguri/agentic-os.git
- /app
volumeMounts:
- name: app-volume
mountPath: /app
containers:
- name: reminder-service
image: node:18-alpine
workingDir: /app/reminder-service
command:
- sh
- -c
- npm install --omit=dev && node server.js
ports:
- containerPort: 6001
env:
- name: DATA_FILE
value: /data/reminders.json
- name: PORT
value: "6001"
- name: GATEWAY_URL
value: http://whatsapp-gateway.ai-agents.svc.cluster.local:5001/send-message
- name: PERSONAL_NUM
value: "+14085505485"
- name: GROUP_URL
value: "https://chat.whatsapp.com/0Txfg7iZBbvIuvTbTqqgAX"
volumeMounts:
- name: app-volume
mountPath: /app
- name: data-volume
mountPath: /data
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
readinessProbe:
httpGet:
path: /health
port: 6001
initialDelaySeconds: 30
periodSeconds: 15
volumes:
- name: app-volume
emptyDir: {}
- name: data-volume
persistentVolumeClaim:
claimName: reminder-service-pvc
---
apiVersion: v1
kind: Service
metadata:
name: reminder-service
namespace: ai-agents
spec:
selector:
app: reminder-service
ports:
- port: 6001
targetPort: 6001

View File

@ -0,0 +1,48 @@
import { NextResponse } from 'next/server';
const REMINDER_SVC = process.env.REMINDER_SERVICE_URL ||
'http://reminder-service.ai-agents.svc.cluster.local:6001';
// GET /api/reminders — list reminders (optional ?status=pending)
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || '';
const url = `${REMINDER_SVC}/reminders${status ? `?status=${status}` : ''}`;
const res = await fetch(url, { next: { revalidate: 0 } });
const data = await res.json();
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// POST /api/reminders/batch — schedule multiple at once
export async function POST(request) {
try {
const body = await request.json();
const res = await fetch(`${REMINDER_SVC}/reminders/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// DELETE /api/reminders?id=rem_xxx — cancel a reminder
export async function DELETE(request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 });
const res = await fetch(`${REMINDER_SVC}/reminders/${id}`, { method: 'DELETE' });
const data = await res.json();
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
const REMINDER_SVC = process.env.REMINDER_SERVICE_URL ||
'http://reminder-service.ai-agents.svc.cluster.local:6001';
export async function POST(request) {
try {
const body = await request.json();
const res = await fetch(`${REMINDER_SVC}/reminders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@ -36,7 +36,7 @@ export default function Page() {
const [messages, setMessages] = useState([ const [messages, setMessages] = useState([
{ {
role: "assistant", role: "assistant",
content: "Hello! I am Claw, your local agent. Try asking me:\n• `weather` — live weather for Mars, PA\n• `whatsapp me: your message` — send anything to your phone\n• `check k8s pods` — Kubernetes diagnostics\n• `diagnose proxmox container 102` — disk & resource check", content: "Hello! I am Claw, your local agent. Try:\n• `weather` — live weather for Mars, PA\n• `whatsapp me: message` — send now to your phone\n• `remind me at 3pm: call doctor` — schedule for later\n• `remind me tomorrow at 6am: gym` — future reminders\n• Or give me a list with times and I'll schedule them all!",
timestamp: "Just now" timestamp: "Just now"
} }
]); ]);
@ -46,6 +46,7 @@ export default function Page() {
const [weatherCard, setWeatherCard] = useState(null); const [weatherCard, setWeatherCard] = useState(null);
const [weatherSending, setWeatherSending] = useState(false); const [weatherSending, setWeatherSending] = useState(false);
const [weatherSent, setWeatherSent] = useState(false); const [weatherSent, setWeatherSent] = useState(false);
const [scheduledReminders, setScheduledReminders] = useState([]);
// OpsAgent State // OpsAgent State
const [pendingApprovals, setPendingApprovals] = useState([ const [pendingApprovals, setPendingApprovals] = useState([
@ -151,6 +152,88 @@ export default function Page() {
responseText = `❌ Couldn't send to WhatsApp: ${err.message}. Make sure the gateway is running.`; responseText = `❌ Couldn't send to WhatsApp: ${err.message}. Make sure the gateway is running.`;
} }
// Scheduled Reminder
// Patterns: "remind me at X: msg", "schedule (whatsapp) at X: msg",
// "send to whatsapp at X: msg", multi-bullet lists
} else if (
/(?:remind\s+me|schedule\s+(?:a\s+)?(?:whatsapp\s+)?(?:reminder|message)?)\s+(?:at|on|for|in|tomorrow|monday|tuesday|wednesday|thursday|friday|saturday|sunday)/i.test(userText) ||
/send\s+to\s+whatsapp\s+(?:at|in|tomorrow|on\s+)/i.test(userText) ||
// multi-bullet list: "schedule these:\n- msg at 3pm\n- msg2 at 6am"
(/(?:schedule|remind\s+me)/i.test(userText) && userText.includes('\n'))
) {
// Check for multi-item list (lines starting with - or )
const lines = userText.split('\n').map(l => l.trim()).filter(Boolean);
const bulletLines = lines.filter(l => /^[-*]\s+/.test(l) || (lines.length > 2 && l !== lines[0]));
const isBatch = bulletLines.length > 1;
if (isBatch) {
// Batch scheduling
const items = [];
const skipped = [];
for (const line of bulletLines) {
const clean = line.replace(/^[-•*]\s*/, '').trim();
// Try to split into message + time: "call doctor at 2pm" or "2pm: call doctor"
// Strategy: extract time, rest is the message
const sendAt = parseScheduleDateTime(clean);
if (!sendAt) { skipped.push(clean); continue; }
// Remove the time expression from the label
const label = clean.replace(/(at|by|on|for)?\s*\d{1,2}(?::\d{2})?\s*(am|pm)/gi, '').replace(/tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday/gi, '').replace(/in\s+\d+\s+(hour|hr|minute|min)s?/gi, '').trim().replace(/\s+/g, ' ');
const formatted = `⏰ *Claw Reminder*\n\n${label}\n\n_Scheduled for ${sendAt.toLocaleString('en-US', { timeZone: 'America/New_York', weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })} ET_`;
items.push({ message: formatted, sendAt: sendAt.toISOString(), label });
}
if (items.length === 0) {
responseText = `❌ I couldn't parse any valid times from your list. Make sure each item includes a time like "at 3pm", "tomorrow at 9am", or "in 2 hours".`;
} else {
setClawTerminalLogs(prev => [...prev, `[Claw] Scheduling ${items.length} reminders...`]);
try {
const res = await fetch('/api/reminders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items }) });
const data = await res.json();
const lines2 = data.created?.map(c => `✅ "${c.label}" → ${formatReminderTime(c.sendAt)} ET`) || [];
if (skipped.length) lines2.push(...skipped.map(s => `⚠️ Skipped (no time): "${s}"`));
responseText = `📅 Scheduled ${data.created?.length || 0} reminder(s):\n\n${lines2.join('\n')}`;
// Refresh reminder panel
fetch('/api/reminders?status=pending').then(r => r.json()).then(d => { if (Array.isArray(d)) setScheduledReminders(d); });
setClawTerminalLogs(prev => [...prev, `[Claw] ✅ Batch scheduled: ${items.length} reminders`]);
} catch (err) {
responseText = `❌ Failed to schedule: ${err.message}`;
}
}
} else {
// Single reminder
// Parse message after colon: "remind me at 3pm: call doctor" msg = "call doctor"
const colonSplit = userText.match(/[:\u2014]\s*(.+)$/s);
const msgText = colonSplit ? colonSplit[1].trim() : userText;
const sendAt = parseScheduleDateTime(userText);
if (!sendAt) {
responseText = `❌ I couldn't find a valid time in your message. Try: "remind me at 3pm: call doctor" or "remind me tomorrow at 9am: gym".`;
} else {
const etLabel = sendAt.toLocaleString('en-US', { timeZone: 'America/New_York', weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true });
const waMsg = `⏰ *Claw Reminder*\n\n${msgText}\n\n_Scheduled for ${etLabel} ET_`;
setClawTerminalLogs(prev => [...prev, `[Claw] Scheduling: "${msgText}" → ${sendAt.toISOString()}`]);
try {
const res = await fetch('/api/schedule-reminder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: waMsg, sendAt: sendAt.toISOString(), label: msgText }),
});
const data = await res.json();
if (data.success) {
responseText = `✅ Reminder scheduled!\n\n📌 **"${msgText}"**\n🕐 Delivers at **${etLabel} ET** to your WhatsApp (+14085505485)`;
fetch('/api/reminders?status=pending').then(r => r.json()).then(d => { if (Array.isArray(d)) setScheduledReminders(d); });
setClawTerminalLogs(prev => [...prev, `[Claw] ✅ Scheduled ID: ${data.id}`]);
} else {
throw new Error(data.error || 'Scheduling failed');
}
} catch (err) {
responseText = `❌ Couldn't schedule: ${err.message}`;
setClawTerminalLogs(prev => [...prev, `[Claw] ❌ ${err.message}`]);
}
}
}
} else if (cleanInput.includes("weather")) { } else if (cleanInput.includes("weather")) {
// Extract optional location override // Extract optional location override
@ -277,7 +360,108 @@ export default function Page() {
} }
}; };
// Approval Handlers // Poll scheduled reminders every 30s when on claw tab
useEffect(() => {
if (activeTab !== 'claw') return;
const fetchReminders = async () => {
try {
const res = await fetch('/api/reminders?status=pending');
if (res.ok) {
const data = await res.json();
if (Array.isArray(data)) setScheduledReminders(data);
}
} catch { /* reminder service may not be up yet */ }
};
fetchReminders();
const interval = setInterval(fetchReminders, 30000);
return () => clearInterval(interval);
}, [activeTab]);
// Natural-language date/time parser
const parseScheduleDateTime = (text) => {
const t = text.toLowerCase();
const now = new Date();
// "in X hours/minutes"
const inMatch = t.match(/\bin\s+(\d+)\s+(hour|hr|minute|min)s?\b/);
if (inMatch) {
const n = parseInt(inMatch[1]);
const ms = inMatch[2].startsWith('h') ? n * 3600000 : n * 60000;
return new Date(now.getTime() + ms);
}
// Extract time component: "3pm", "6:30am", "14:00"
let hours = null, minutes = 0;
const ampm = t.match(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)/);
const h24 = t.match(/(\d{2}):(\d{2})(?!\s*(am|pm))/);
if (ampm) {
hours = parseInt(ampm[1]);
minutes = ampm[2] ? parseInt(ampm[2]) : 0;
if (ampm[3] === 'pm' && hours !== 12) hours += 12;
if (ampm[3] === 'am' && hours === 12) hours = 0;
} else if (h24) {
hours = parseInt(h24[1]);
minutes = parseInt(h24[2]);
}
// Build date
let date = new Date(now);
let explicitDate = false;
if (t.includes('tomorrow')) {
date.setDate(date.getDate() + 1);
explicitDate = true;
} else {
const days = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'];
const dayIdx = days.findIndex(d => t.includes(d));
if (dayIdx !== -1) {
const diff = ((dayIdx - now.getDay()) + 7) % 7 || 7; // next occurrence
date.setDate(date.getDate() + diff);
explicitDate = true;
} else {
// Named month: "May 30", "June 1"
const months = ['january','february','march','april','may','june','july','august','september','october','november','december'];
const shortM = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
let mIdx = months.findIndex(m => t.includes(m));
if (mIdx === -1) mIdx = shortM.findIndex(m => t.match(new RegExp('\\b' + m + '\\b')));
if (mIdx !== -1) {
const dayNum = t.match(/(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\s+(\d{1,2})/i);
if (dayNum) {
date.setMonth(mIdx, parseInt(dayNum[1]));
if (date < now) date.setFullYear(date.getFullYear() + 1);
explicitDate = true;
}
}
}
}
if (hours === null) return null; // no time found
date.setHours(hours, minutes, 0, 0);
// If the computed time is in the past and no explicit date was given, push to tomorrow
if (date <= now && !explicitDate) date.setDate(date.getDate() + 1);
return date;
};
// Format a Date nicely for display
const formatReminderTime = (isoStr) => {
const d = new Date(isoStr);
return d.toLocaleString('en-US', {
timeZone: 'America/New_York',
weekday: 'short', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit', hour12: true
});
};
// Cancel a reminder
const handleCancelReminder = async (id) => {
try {
const res = await fetch(`/api/reminders?id=${id}`, { method: 'DELETE' });
if (res.ok) setScheduledReminders(prev => prev.filter(r => r.id !== id));
} catch { /* silent */ }
};
const handleApprove = async (id, action, target) => { const handleApprove = async (id, action, target) => {
setPendingApprovals(prev => prev.map(item => item.id === id ? { ...item, status: "approved" } : item)); setPendingApprovals(prev => prev.map(item => item.id === id ? { ...item, status: "approved" } : item));
setOpsLogs(prev => [...prev, `2026-05-29 01:14:10 [OpsAgent] RECEIVED APPROVAL SIGNAL for request ${id}`]); setOpsLogs(prev => [...prev, `2026-05-29 01:14:10 [OpsAgent] RECEIVED APPROVAL SIGNAL for request ${id}`]);
@ -749,6 +933,7 @@ export default function Page() {
{/* Claw Tool & Console Log Panel */} {/* Claw Tool & Console Log Panel */}
<div className="glass" style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden", borderLeft: "1px solid var(--border-color)" }}> <div className="glass" style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden", borderLeft: "1px solid var(--border-color)" }}>
{/* Terminal Console */}
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border-color)", background: "rgba(255,255,255,0.01)" }}> <div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border-color)", background: "rgba(255,255,255,0.01)" }}>
<h3 style={{ fontSize: "14px", display: "flex", alignItems: "center", gap: "8px" }}> <h3 style={{ fontSize: "14px", display: "flex", alignItems: "center", gap: "8px" }}>
<Terminal size={16} style={{ color: "var(--accent-cyan)" }} /> <Terminal size={16} style={{ color: "var(--accent-cyan)" }} />
@ -756,7 +941,7 @@ export default function Page() {
</h3> </h3>
</div> </div>
<div style={{ flex: 1, padding: "16px", background: "#05070a", fontFamily: "var(--font-mono)", fontSize: "11px", color: "#a5f3fc", overflowY: "auto", display: "flex", flexDirection: "column", gap: "8px" }}> <div style={{ flex: 1, minHeight: 0, padding: "16px", background: "#05070a", fontFamily: "var(--font-mono)", fontSize: "11px", color: "#a5f3fc", overflowY: "auto", display: "flex", flexDirection: "column", gap: "8px" }}>
{clawTerminalLogs.length === 0 ? ( {clawTerminalLogs.length === 0 ? (
<div style={{ color: "var(--text-muted)", fontStyle: "italic" }}>Console logs will stream here as Claw calls CLI and API tools...</div> <div style={{ color: "var(--text-muted)", fontStyle: "italic" }}>Console logs will stream here as Claw calls CLI and API tools...</div>
) : ( ) : (
@ -768,10 +953,70 @@ export default function Page() {
)} )}
<div ref={terminalEndRef} /> <div ref={terminalEndRef} />
</div> </div>
{/* Scheduled Reminders Panel */}
<div style={{ borderTop: "1px solid var(--border-color)", display: "flex", flexDirection: "column", maxHeight: "300px" }}>
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border-color)", display: "flex", alignItems: "center", justifyContent: "space-between", background: "rgba(139,92,246,0.05)" }}>
<h3 style={{ fontSize: "13px", display: "flex", alignItems: "center", gap: "8px", color: "var(--text-primary)" }}>
<span></span>
Scheduled Reminders
{scheduledReminders.length > 0 && (
<span style={{ fontSize: "10px", padding: "1px 6px", borderRadius: "10px", background: "rgba(139,92,246,0.3)", color: "#c4b5fd", fontWeight: "700" }}>
{scheduledReminders.length}
</span>
)}
</h3>
<span style={{ fontSize: "10px", color: "var(--text-muted)" }}>auto-refreshes</span>
</div>
<div style={{ overflowY: "auto", flex: 1 }}>
{scheduledReminders.length === 0 ? (
<div style={{ padding: "20px 16px", textAlign: "center", color: "var(--text-muted)", fontSize: "12px" }}>
No reminders scheduled.<br />
<span style={{ color: "var(--accent-violet)", fontSize: "11px" }}>Try: "remind me at 3pm: call doctor"</span>
</div>
) : (
scheduledReminders.map((r) => (
<div key={r.id} style={{
display: "flex", alignItems: "flex-start", justifyContent: "space-between",
padding: "10px 14px",
borderBottom: "1px solid rgba(255,255,255,0.04)",
gap: "8px"
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "12px", fontWeight: "600", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{r.label}
</div>
<div style={{ fontSize: "10px", color: "var(--accent-violet)", marginTop: "2px" }}>
🕐 {formatReminderTime(r.sendAt)} ET
</div>
</div>
<button
onClick={() => handleCancelReminder(r.id)}
title="Cancel reminder"
style={{
flexShrink: 0,
padding: "3px 8px",
border: "1px solid rgba(239,68,68,0.3)",
borderRadius: "4px",
background: "rgba(239,68,68,0.1)",
color: "#f87171",
fontSize: "10px",
cursor: "pointer"
}}
>
</button>
</div>
))
)}
</div>
</div>
</div> </div>
</div> </div>
)} )}
{/* TAB 3: OPSAGENT HUB */} {/* TAB 3: OPSAGENT HUB */}
{activeTab === "ops" && ( {activeTab === "ops" && (
<div className="fade-in" style={{ display: "grid", gridTemplateColumns: "1fr 400px", gap: "24px", height: "calc(100vh - 140px)" }}> <div className="fade-in" style={{ display: "grid", gridTemplateColumns: "1fr 400px", gap: "24px", height: "calc(100vh - 140px)" }}>

View File

@ -0,0 +1,13 @@
{
"name": "reminder-service",
"version": "1.0.0",
"description": "Scheduled WhatsApp reminder delivery service for Agentic OS",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"node-cron": "^3.0.3"
}
}

182
reminder-service/server.js Normal file
View File

@ -0,0 +1,182 @@
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 || '+14085505485';
const GROUP = process.env.GROUP_URL || 'https://chat.whatsapp.com/0Txfg7iZBbvIuvTbTqqgAX';
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).`);
});