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 cheerio = require('cheerio'); const Redis = require('ioredis'); const redisHost = process.env.REDIS_HOST || 'redis.platform-data.svc.cluster.local'; const redis = new Redis({ host: redisHost, port: 6379, maxRetriesPerRequest: 1 }); async function getGroupUrl(type = 'trip-group-url') { try { const raw = await redis.get('whatsapp_config'); if (raw) { const data = JSON.parse(raw); if (data[type]) return data[type]; } } catch (e) { console.error('[Redis] Failed to fetch config:', e.message); } return process.env.GROUP_URL || 'https://chat.whatsapp.com/HN2z93yhXPyEJPuoib3dws'; } 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/HN2z93yhXPyEJPuoib3dws'; 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 scrapeNpsConditions(parkCode) { if (!parkCode) return null; const url = `https://www.nps.gov/${parkCode}/planyourvisit/conditions.htm`; try { const html = await fetchText(url); const $ = cheerio.load(html); const alerts = []; $('.Alert-title').each((i, el) => { alerts.push(`⚠️ ${$(el).text().trim()}`); }); if (alerts.length > 0) { return alerts.slice(0, 5).join('\n'); } return 'No active NPS conditions listed.'; } catch (e) { console.error('[TripService] NPS scrape error:', e.message); return 'NPS conditions unavailable'; } } async function getWeatherAlerts(stateCode) { try { const data = await fetchJson(`https://api.weather.gov/alerts/active?area=${stateCode}`); if (data.features && data.features.length > 0) { return data.features.map(f => `🌩️ ${f.properties.event || f.properties.headline}`).slice(0, 3).join('\n'); } return 'No active weather alerts.'; } catch (e) { console.error('[TripService] Weather alerts error:', e.message); return 'Weather 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, trip) { 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.`; if (trip && trip.llmProvider === 'anthropic') { const modelName = trip.llmModel || 'claude-3-5-sonnet-20240620'; const body = JSON.stringify({ model: modelName, max_tokens: 150, messages: [{ role: 'user', content: prompt }] }); const req = https.request({ hostname: 'api.anthropic.com', path: '/v1/messages', method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01', 'Content-Length': Buffer.byteLength(body) } }, (res) => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { const data = JSON.parse(d); resolve(data.content[0].text.trim()); } catch { resolve('Have a great trip!'); } }); }); req.on('error', () => resolve('Have a great trip!')); req.write(body); req.end(); } else { // Default to Ollama const modelName = trip ? (trip.llmModel || 'qwen2.5:0.5b') : 'qwen2.5:0.5b'; 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 handleResponse = (d) => { try { const raw = d; 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([]); } }; if (trip && trip.llmProvider === 'anthropic') { const modelName = trip.llmModel || 'claude-3-5-sonnet-20240620'; const body = JSON.stringify({ model: modelName, max_tokens: 300, messages: [{ role: 'user', content: prompt }] }); const req = https.request({ hostname: 'api.anthropic.com', path: '/v1/messages', method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01', 'Content-Length': Buffer.byteLength(body) } }, (res) => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { const data = JSON.parse(d); handleResponse(data.content[0].text); } catch { resolve([]); } }); }); req.on('error', () => resolve([])); req.write(body); req.end(); } else { // Default to Ollama const modelName = trip.llmModel || 'qwen2.5:0.5b'; 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 { handleResponse(JSON.parse(d).response); } catch { 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] : null // handled in batch }; }).filter(Boolean); if (items.length === 0) return; // Resolve group URL for items that need it getGroupUrl('trip-group-url').then(groupUrl => { items.forEach(item => { if (!item.to) item.to = groupUrl || PERSONAL_NUM; }); 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 weatherAlerts = await getWeatherAlerts('TN'); const nps = await scrapeNpsConditions(trip.npsParkCode); const traffic = await getTdotTraffic(); const insight = await getAiInsight(dayPlan.activities, dayPlan.location || 'Smoky Mountains', trip); const message = `*🏕️ ${trip.name} - ${dayPlan.date} Briefing*\n\n` + `*🌤️ Weather (${dayPlan.location || 'Pigeon Forge'}):* ${weather}\n\n` + `*🌩️ Weather Alerts (TN):*\n${weatherAlerts}\n\n` + `*📋 Today's Plan:*\n${dayPlan.activities}\n\n` + `*🍴 Meals:*\n${dayPlan.meals}\n\n` + (nps ? `*🌲 Park Conditions:*\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); const tripGroupUrl = await getGroupUrl('trip-group-url'); // 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 && tripGroupUrl) { groupsToNotify = [tripGroupUrl]; } 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}`); });