diff --git a/agents/k8s/trip-service-deployment.yaml b/agents/k8s/trip-service-deployment.yaml new file mode 100644 index 0000000..7088f5b --- /dev/null +++ b/agents/k8s/trip-service-deployment.yaml @@ -0,0 +1,100 @@ +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: trip-service-pvc + namespace: ai-agents +spec: + accessModes: [ReadWriteOnce] + storageClassName: longhorn + resources: + requests: + storage: 256Mi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: trip-service + namespace: ai-agents + labels: + app: trip-service +spec: + replicas: 1 + selector: + matchLabels: + app: trip-service + template: + metadata: + labels: + app: trip-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: trip-service + image: node:18-alpine + workingDir: /app/trip-service + command: + - sh + - -c + - npm install --omit=dev && node server.js + ports: + - containerPort: 6002 + env: + - name: DATA_FILE + value: /data/trips.json + - name: PORT + value: "6002" + - name: GATEWAY_URL + value: http://whatsapp-gateway.ai-agents.svc.cluster.local:5001/send-message + - name: OLLAMA_URL + value: http://ollama.ai-agents.svc.cluster.local:11434/api/generate + - 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: 6002 + initialDelaySeconds: 30 + periodSeconds: 15 + volumes: + - name: app-volume + emptyDir: {} + - name: data-volume + persistentVolumeClaim: + claimName: trip-service-pvc +--- +apiVersion: v1 +kind: Service +metadata: + name: trip-service + namespace: ai-agents +spec: + selector: + app: trip-service + ports: + - port: 6002 + targetPort: 6002 diff --git a/trip-service/package.json b/trip-service/package.json new file mode 100644 index 0000000..acf8647 --- /dev/null +++ b/trip-service/package.json @@ -0,0 +1,11 @@ +{ + "name": "trip-service", + "version": "1.0.0", + "description": "Dynamic trip briefing service — weather, NPS alerts, TDOT traffic, AI insights via WhatsApp", + "main": "server.js", + "scripts": { "start": "node server.js" }, + "dependencies": { + "express": "^4.18.2", + "node-cron": "^3.0.3" + } +} diff --git a/trip-service/server.js b/trip-service/server.js new file mode 100644 index 0000000..2a6db59 --- /dev/null +++ b/trip-service/server.js @@ -0,0 +1,205 @@ +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); + if (trip.sendToGroup && GROUP_URL) { + await sendWhatsApp(GROUP_URL, 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.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}`); +});