344 lines
13 KiB
JavaScript
344 lines
13 KiB
JavaScript
const express = require('express');
|
|
const cron = require('node-cron');
|
|
const fs = require('fs');
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const path = require('path');
|
|
|
|
const app = express();
|
|
app.use(express.json({ limit: '1mb' }));
|
|
|
|
const DATA_FILE = process.env.DATA_FILE || '/data/trips.json';
|
|
const GATEWAY_URL = process.env.GATEWAY_URL || 'http://whatsapp-gateway.ai-agents.svc.cluster.local:5001/send-message';
|
|
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://ollama.ai-agents.svc.cluster.local:11434/api/generate';
|
|
const PERSONAL_NUM = process.env.PERSONAL_NUM || '+14085505485';
|
|
const GROUP_URL = process.env.GROUP_URL || 'https://chat.whatsapp.com/0Txfg7iZBbvIuvTbTqqgAX';
|
|
const PORT = parseInt(process.env.PORT) || 6002;
|
|
|
|
// ── Persistence ──────────────────────────────────────────────────────────────
|
|
function loadTrips() {
|
|
try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); }
|
|
catch { return []; }
|
|
}
|
|
|
|
function saveTrips(trips) {
|
|
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
|
|
fs.writeFileSync(DATA_FILE, JSON.stringify(trips, null, 2));
|
|
}
|
|
|
|
// ── HTTP Helpers ─────────────────────────────────────────────────────────────
|
|
function fetchJson(url) {
|
|
return new Promise((resolve, reject) => {
|
|
const lib = url.startsWith('https') ? https : http;
|
|
lib.get(url, { headers: { 'User-Agent': 'AgenticOS-TripService/1.0' } }, (res) => {
|
|
let data = '';
|
|
res.on('data', chunk => data += chunk);
|
|
res.on('end', () => {
|
|
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
|
});
|
|
}).on('error', reject);
|
|
});
|
|
}
|
|
|
|
function fetchText(url) {
|
|
return new Promise((resolve, reject) => {
|
|
const lib = url.startsWith('https') ? https : http;
|
|
lib.get(url, { headers: { 'User-Agent': 'AgenticOS-TripService/1.0' } }, (res) => {
|
|
let data = '';
|
|
res.on('data', chunk => data += chunk);
|
|
res.on('end', () => resolve(data));
|
|
}).on('error', reject);
|
|
});
|
|
}
|
|
|
|
// ── Integrations ─────────────────────────────────────────────────────────────
|
|
async function getWeather(location) {
|
|
try {
|
|
const data = await fetchJson(`https://wttr.in/${encodeURIComponent(location)}?format=j1`);
|
|
const curr = data.current_condition[0];
|
|
return `${curr.temp_F}°F, ${curr.weatherDesc[0].value}`;
|
|
} catch (e) {
|
|
return 'Weather unavailable';
|
|
}
|
|
}
|
|
|
|
async function getNpsAlerts(parkCode) {
|
|
if (!parkCode) return null;
|
|
try {
|
|
const data = await fetchJson(`https://developer.nps.gov/api/v1/alerts?parkCode=${parkCode}&api_key=DEMO_KEY`);
|
|
if (data.data && data.data.length > 0) {
|
|
return data.data.map(a => `⚠️ ${a.title}`).slice(0, 3).join('\n');
|
|
}
|
|
return 'No active NPS alerts.';
|
|
} catch (e) {
|
|
return 'NPS alerts unavailable';
|
|
}
|
|
}
|
|
|
|
async function getTdotTraffic() {
|
|
try {
|
|
// Basic TDOT Incidents fetch (Smoky Mountains general area filter could be applied, returning raw count for brevity)
|
|
const data = await fetchJson('https://smartway.tn.gov/Traffic/Api/Incidents');
|
|
if (data && data.length >= 0) {
|
|
return `Current active incidents in TN: ${data.length}`;
|
|
}
|
|
return 'No traffic data.';
|
|
} catch (e) {
|
|
return 'Traffic data unavailable';
|
|
}
|
|
}
|
|
|
|
async function getAiInsight(activityDetails, location, modelName = 'qwen2.5:0.5b') {
|
|
return new Promise((resolve) => {
|
|
const prompt = `Provide one short, clever tip or insight for someone visiting ${location} today, doing: ${activityDetails}. Keep it under 2 sentences.`;
|
|
const body = JSON.stringify({ model: modelName, prompt, stream: false });
|
|
const url = new URL(OLLAMA_URL);
|
|
const req = http.request({
|
|
hostname: url.hostname, port: url.port, path: url.pathname, method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
|
}, (res) => {
|
|
let d = '';
|
|
res.on('data', c => d += c);
|
|
res.on('end', () => {
|
|
try { resolve(JSON.parse(d).response.trim()); } catch { resolve('Have a great trip!'); }
|
|
});
|
|
});
|
|
req.on('error', () => resolve('Have a great trip!'));
|
|
req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
async function generateAiSchedule(activities, meals, date, trip) {
|
|
return new Promise((resolve) => {
|
|
const prompt = `You are a strict JSON API scheduling agent. Extract a daily schedule from these plans:
|
|
Activities: ${activities}
|
|
Meals: ${meals}
|
|
Date: ${date}
|
|
|
|
Return ONLY a JSON array of objects. Each object must have "time" (in 24-hour HH:mm format) and "message" (a short, fun 1-sentence reminder). Estimate reasonable times if none are given (e.g. breakfast 8:00, lunch 12:30, dinner 18:30). DO NOT output any markdown or conversational text.`;
|
|
|
|
const modelName = trip.llmModel || 'qwen2.5:0.5b';
|
|
// Support future providers via trip.llmProvider
|
|
// Defaulting to Ollama for now
|
|
const body = JSON.stringify({ model: modelName, prompt, stream: false });
|
|
const url = new URL(OLLAMA_URL);
|
|
|
|
const req = http.request({
|
|
hostname: url.hostname, port: url.port, path: url.pathname, method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
|
}, (res) => {
|
|
let d = '';
|
|
res.on('data', c => d += c);
|
|
res.on('end', () => {
|
|
try {
|
|
const raw = JSON.parse(d).response;
|
|
// Extract JSON array using regex in case of conversational wrapper
|
|
const match = raw.match(/\[[\s\S]*\]/);
|
|
if (match) {
|
|
resolve(JSON.parse(match[0]));
|
|
} else {
|
|
resolve([]);
|
|
}
|
|
} catch (e) {
|
|
console.error('[TripService] AI Schedule parsing failed:', e.message);
|
|
resolve([]);
|
|
}
|
|
});
|
|
});
|
|
req.on('error', () => resolve([]));
|
|
req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function scheduleRemindersBatch(events, trip, dateStr) {
|
|
if (!events || events.length === 0) return;
|
|
|
|
// Base date object for today in Eastern Time (approximated locally)
|
|
const baseDate = new Date(dateStr + "T00:00:00-04:00");
|
|
|
|
const items = events.map(ev => {
|
|
const [hh, mm] = ev.time.split(':');
|
|
const eventTime = new Date(baseDate);
|
|
eventTime.setHours(parseInt(hh, 10));
|
|
eventTime.setMinutes(parseInt(mm, 10));
|
|
|
|
// Send reminder 15 minutes before the event time
|
|
const sendAt = new Date(eventTime.getTime() - 15 * 60000);
|
|
|
|
// Don't schedule in the past
|
|
if (sendAt <= new Date()) return null;
|
|
|
|
return {
|
|
message: `⏰ *${trip.name} Schedule Alert*\n\n${ev.message}\n\n_Starts at ${ev.time}_`,
|
|
sendAt: sendAt.toISOString(),
|
|
label: `${trip.name} - ${ev.message.slice(0, 30)}...`,
|
|
to: trip.sendToGroup && trip.groupUrls && trip.groupUrls.length > 0 ? trip.groupUrls[0] : (GROUP_URL || PERSONAL_NUM)
|
|
};
|
|
}).filter(Boolean);
|
|
|
|
if (items.length === 0) return;
|
|
|
|
const body = JSON.stringify({ items });
|
|
const req = http.request({
|
|
hostname: 'reminder-service.ai-agents.svc.cluster.local',
|
|
port: 6001,
|
|
path: '/reminders/batch',
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
|
}, (res) => {
|
|
res.on('data', () => {});
|
|
res.on('end', () => console.log(`[TripService] Batch queued ${items.length} reminders via reminder-service`));
|
|
});
|
|
req.on('error', (e) => console.error('[TripService] Failed to queue reminders:', e.message));
|
|
req.write(body);
|
|
req.end();
|
|
}
|
|
|
|
function sendWhatsApp(to, message) {
|
|
return new Promise((resolve) => {
|
|
const body = JSON.stringify({ to, message });
|
|
const url = new URL(GATEWAY_URL);
|
|
const req = http.request({
|
|
hostname: url.hostname, port: url.port, path: url.pathname, method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
|
}, (res) => {
|
|
res.on('data', () => {});
|
|
res.on('end', () => resolve(true));
|
|
});
|
|
req.on('error', () => resolve(false));
|
|
req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// ── Briefing Generation ──────────────────────────────────────────────────────
|
|
async function generateAndSendBriefing(trip, dayPlan) {
|
|
const weather = await getWeather(dayPlan.location || 'Pigeon Forge, TN');
|
|
const nps = await getNpsAlerts(trip.npsParkCode);
|
|
const traffic = await getTdotTraffic();
|
|
const insight = await getAiInsight(dayPlan.activities, dayPlan.location || 'Smoky Mountains', trip.llmModel);
|
|
|
|
const message = `*🏕️ ${trip.name} - ${dayPlan.date} Briefing*\n\n` +
|
|
`*🌤️ Weather (${dayPlan.location || 'Pigeon Forge'}):* ${weather}\n\n` +
|
|
`*📋 Today's Plan:*\n${dayPlan.activities}\n\n` +
|
|
`*🍴 Meals:*\n${dayPlan.meals}\n\n` +
|
|
(nps ? `*🌲 Park Alerts:*\n${nps}\n\n` : '') +
|
|
`*🚗 Traffic Info:*\n${traffic}\n\n` +
|
|
`*🤖 AI Insight:*\n${insight}`;
|
|
|
|
console.log(`[TripService] Sending briefing for ${trip.name} - ${dayPlan.date}`);
|
|
await sendWhatsApp(PERSONAL_NUM, message);
|
|
|
|
// Support specific groups for this trip, fallback to default if configured
|
|
let groupsToNotify = [];
|
|
if (trip.groupUrls && Array.isArray(trip.groupUrls) && trip.groupUrls.length > 0) {
|
|
groupsToNotify = trip.groupUrls;
|
|
} else if (trip.sendToGroup && GROUP_URL) {
|
|
groupsToNotify = [GROUP_URL];
|
|
}
|
|
|
|
for (const group of groupsToNotify) {
|
|
await sendWhatsApp(group, message);
|
|
}
|
|
|
|
// Generate and schedule detailed AI itinerary
|
|
console.log(`[TripService] Generating detailed AI schedule for ${trip.name} - ${dayPlan.date}`);
|
|
const scheduleEvents = await generateAiSchedule(dayPlan.activities, dayPlan.meals, dayPlan.date, trip);
|
|
if (scheduleEvents.length > 0) {
|
|
scheduleRemindersBatch(scheduleEvents, trip, dayPlan.date);
|
|
} else {
|
|
console.log(`[TripService] AI yielded no parseable schedule events.`);
|
|
}
|
|
}
|
|
|
|
// ── Routes ───────────────────────────────────────────────────────────────────
|
|
app.post('/trips', (req, res) => {
|
|
const trip = req.body;
|
|
if (!trip.name || !trip.days) return res.status(400).json({ error: 'Missing name or days' });
|
|
|
|
trip.id = `trip_${Date.now()}`;
|
|
const trips = loadTrips();
|
|
trips.push(trip);
|
|
saveTrips(trips);
|
|
|
|
res.json({ success: true, id: trip.id });
|
|
});
|
|
|
|
app.get('/trips', (req, res) => {
|
|
res.json(loadTrips());
|
|
});
|
|
|
|
app.put('/trips/:id', (req, res) => {
|
|
const trips = loadTrips();
|
|
const index = trips.findIndex(t => t.id === req.params.id);
|
|
if (index === -1) return res.status(404).json({ error: 'Trip not found' });
|
|
|
|
const updatedTrip = { ...req.body, id: req.params.id }; // preserve ID
|
|
trips[index] = updatedTrip;
|
|
saveTrips(trips);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.delete('/trips/:id', (req, res) => {
|
|
let trips = loadTrips();
|
|
trips = trips.filter(t => t.id !== req.params.id);
|
|
saveTrips(trips);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.post('/trips/:id/test', async (req, res) => {
|
|
const trips = loadTrips();
|
|
const trip = trips.find(t => t.id === req.params.id);
|
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
|
|
|
// Just use the first day's plan for the test
|
|
const dayPlan = trip.days[0];
|
|
try {
|
|
await generateAndSendBriefing(trip, dayPlan);
|
|
res.json({ success: true, message: 'Test briefing sent!' });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.get('/health', (req, res) => res.json({ status: 'ok' }));
|
|
|
|
// ── Scheduler (Runs every minute, checks if time to send) ────────────────────
|
|
cron.schedule('* * * * *', async () => {
|
|
const now = new Date();
|
|
const trips = loadTrips();
|
|
|
|
// Format current date as YYYY-MM-DD
|
|
const tzOffset = -4 * 60; // Assuming EDT for now, ideally use a robust timezone approach
|
|
const localTime = new Date(now.getTime() + tzOffset * 60000);
|
|
const todayStr = localTime.toISOString().split('T')[0];
|
|
const currentHour = localTime.getUTCHours().toString().padStart(2, '0');
|
|
const currentMin = localTime.getUTCMinutes().toString().padStart(2, '0');
|
|
const currentTime = `${currentHour}:${currentMin}`;
|
|
|
|
for (const trip of trips) {
|
|
const briefingTime = trip.briefingTime || '07:00';
|
|
if (currentTime !== briefingTime) continue; // Only trigger at exact minute
|
|
|
|
const dayPlan = trip.days.find(d => d.date === todayStr);
|
|
if (!dayPlan) continue;
|
|
|
|
// To prevent duplicate sends if cron runs multiple times in the same minute
|
|
if (dayPlan.sent) continue;
|
|
|
|
try {
|
|
await generateAndSendBriefing(trip, dayPlan);
|
|
dayPlan.sent = true;
|
|
saveTrips(trips);
|
|
} catch (err) {
|
|
console.error(`[TripService] Failed to send briefing: ${err.message}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`[TripService] Listening on :${PORT}`);
|
|
});
|