feat: scheduled WhatsApp reminders via Claw — NLP time parsing, batch scheduling, live panel
This commit is contained in:
parent
b6851ace08
commit
43697759c8
|
|
@ -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
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)" }}>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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).`);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue