466 lines
17 KiB
JavaScript
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}`);
|
|
});
|