agentic-os/trip-service/server.js

466 lines
17 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 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;
}
return process.env.GROUP_URL;
}
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;
const GROUP_URL = process.env.GROUP_URL;
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}`);
});