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) { 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: 'qwen2.5:0.5b', 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'); 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.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}`); });