agentic-os/trip-service/server.js

248 lines
9.4 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();
});
}
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);
}
}
// ── 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}`);
});