← back to B Version 1
server.js
1483 lines
/**
* Dear Bubbe Web Interface Server
* Serves the mobile chat interface on port 7124
*/
const express = require('express');
const path = require('path');
const Anthropic = require('@anthropic-ai/sdk');
const OpenAI = require('openai');
const axios = require('axios');
const Parser = require('rss-parser');
require('dotenv').config();
const rssParser = new Parser();
const app = express();
const PORT = process.env.PORT || 7124;
// Cache for RSS feeds to reduce API calls
const RSS_CACHE = {
weather: { data: null, lastUpdated: 0 },
news: { data: null, lastUpdated: 0 },
majorNews: { data: null, lastUpdated: 0 }, // BBC, CNN, Reuters, NYT, Guardian, NPR
sports: { data: null, lastUpdated: 0 }
};
const CACHE_DURATION = 20 * 60 * 1000; // 20 minutes for Reddit/HackerNews
const MAJOR_NEWS_CACHE_DURATION = 10 * 60 * 1000; // 10 minutes for major news sources
// Initialize Anthropic client
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY
});
// Initialize OpenAI client for web search
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
// Get real weather data from Open-Meteo (FREE, NO KEY!)
async function getWeather(latitude, longitude, city) {
try {
const response = await axios.get(`https://api.open-meteo.com/v1/forecast`, {
params: {
latitude: latitude,
longitude: longitude,
current: 'temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m',
daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max',
temperature_unit: 'fahrenheit',
wind_speed_unit: 'mph',
precipitation_unit: 'inch',
timezone: 'auto',
forecast_days: 3
}
});
const current = response.data.current;
const daily = response.data.daily;
// Weather code to condition mapping
const weatherCodes = {
0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
45: 'Foggy', 48: 'Foggy', 51: 'Light drizzle', 53: 'Moderate drizzle',
61: 'Slight rain', 63: 'Moderate rain', 65: 'Heavy rain',
71: 'Slight snow', 73: 'Moderate snow', 75: 'Heavy snow',
80: 'Rain showers', 95: 'Thunderstorm'
};
return {
current: {
temp_f: Math.round(current.temperature_2m),
temp_c: Math.round((current.temperature_2m - 32) * 5/9),
condition: weatherCodes[current.weather_code] || 'Unknown',
humidity: current.relative_humidity_2m,
wind_mph: Math.round(current.wind_speed_10m),
feelslike_f: Math.round(current.apparent_temperature)
},
forecast: daily.time.slice(0, 3).map((date, i) => ({
date: date,
high_f: Math.round(daily.temperature_2m_max[i]),
low_f: Math.round(daily.temperature_2m_min[i]),
condition: weatherCodes[daily.weather_code[i]] || 'Unknown',
chance_of_rain: daily.precipitation_probability_max[i]
}))
};
} catch (error) {
console.error('Open-Meteo API error:', error.message);
return null;
}
}
// LOCAL NEWS SOURCES FOR 16 LARGEST US CITIES (ALL FREE RSS!)
const LOCAL_NEWS_SOURCES = {
'New York': [
{ name: 'NYTimes NYC', url: 'https://rss.nytimes.com/services/xml/rss/nyt/NYRegion.xml' },
{ name: 'r/nyc', url: 'https://www.reddit.com/r/nyc/.rss' }
],
'Los Angeles': [
{ name: 'LA Times', url: 'https://www.latimes.com/local/rss2.0.xml' },
{ name: 'r/losangeles', url: 'https://www.reddit.com/r/losangeles/.rss' }
],
'Chicago': [
{ name: 'Chicago Tribune', url: 'https://www.chicagotribune.com/arcio/rss/category/news/' },
{ name: 'r/chicago', url: 'https://www.reddit.com/r/chicago/.rss' }
],
'Houston': [
{ name: 'Houston Chronicle', url: 'https://www.houstonchronicle.com/rss/feed/Texas-News-142.php' },
{ name: 'r/houston', url: 'https://www.reddit.com/r/houston/.rss' }
],
'Phoenix': [
{ name: 'AZ Central', url: 'https://rssfeeds.azcentral.com/phoenix/local' },
{ name: 'r/phoenix', url: 'https://www.reddit.com/r/phoenix/.rss' }
],
'Philadelphia': [
{ name: 'Philly.com', url: 'https://www.inquirer.com/arc/outboundfeeds/rss/' },
{ name: 'r/philadelphia', url: 'https://www.reddit.com/r/philadelphia/.rss' }
],
'San Antonio': [
{ name: 'r/sanantonio', url: 'https://www.reddit.com/r/sanantonio/.rss' }
],
'San Diego': [
{ name: 'r/sandiego', url: 'https://www.reddit.com/r/sandiego/.rss' }
],
'Dallas': [
{ name: 'r/dallas', url: 'https://www.reddit.com/r/dallas/.rss' }
],
'San Jose': [
{ name: 'r/sanjose', url: 'https://www.reddit.com/r/sanjose/.rss' }
],
'Austin': [
{ name: 'r/austin', url: 'https://www.reddit.com/r/austin/.rss' }
],
'Jacksonville': [
{ name: 'r/jacksonville', url: 'https://www.reddit.com/r/jacksonville/.rss' }
],
'Fort Worth': [
{ name: 'r/fortworth', url: 'https://www.reddit.com/r/fortworth/.rss' }
],
'Columbus': [
{ name: 'r/columbus', url: 'https://www.reddit.com/r/columbus/.rss' }
],
'Charlotte': [
{ name: 'r/charlotte', url: 'https://www.reddit.com/r/charlotte/.rss' }
],
'San Francisco': [
{ name: 'r/sanfrancisco', url: 'https://www.reddit.com/r/sanfrancisco/.rss' }
],
'Seattle': [
{ name: 'r/seattle', url: 'https://www.reddit.com/r/seattle/.rss' }
],
'Denver': [
{ name: 'r/denver', url: 'https://www.reddit.com/r/denver/.rss' }
],
'Washington': [
{ name: 'r/washingtondc', url: 'https://www.reddit.com/r/washingtondc/.rss' }
],
'Boston': [
{ name: 'r/boston', url: 'https://www.reddit.com/r/boston/.rss' }
]
};
// Get local news for a specific city
async function getLocalNews(cityName) {
try {
// Find city in LOCAL_NEWS_SOURCES (case insensitive, partial match)
const cityKey = Object.keys(LOCAL_NEWS_SOURCES).find(key =>
cityName.toLowerCase().includes(key.toLowerCase()) ||
key.toLowerCase().includes(cityName.toLowerCase())
);
if (!cityKey) {
console.log(`No local news sources for: ${cityName}`);
return null;
}
const sources = LOCAL_NEWS_SOURCES[cityKey];
const newsPromises = sources.map(async source => {
try {
const feed = await rssParser.parseURL(source.url);
return feed.items.slice(0, 3).map(item => ({
title: item.title,
description: item.contentSnippet || item.content?.substring(0, 200),
url: item.link,
source: source.name
}));
} catch (err) {
console.error(`Error fetching ${source.name}:`, err.message);
return [];
}
});
const results = await Promise.all(newsPromises);
return results.flat().slice(0, 5);
} catch (error) {
console.error('Local news error:', error.message);
return null;
}
}
// Get FREE news from Reddit RSS (NO API KEY!)
async function getRedditNews(subreddit = 'news') {
try {
const feed = await rssParser.parseURL(`https://www.reddit.com/r/${subreddit}/.rss`);
return feed.items.slice(0, 5).map(item => ({
title: item.title,
description: item.contentSnippet || item.content?.substring(0, 200),
url: item.link,
source: `r/${subreddit}`
}));
} catch (error) {
console.error('Reddit RSS error:', error.message);
return null;
}
}
// Get FREE tech news from HackerNews API (NO API KEY!)
async function getHackerNews() {
try {
const topStoriesRes = await axios.get('https://hacker-news.firebaseio.com/v0/topstories.json');
const topIds = topStoriesRes.data.slice(0, 5);
const stories = await Promise.all(
topIds.map(id =>
axios.get(`https://hacker-news.firebaseio.com/v0/item/${id}.json`)
.then(res => res.data)
)
);
return stories.map(story => ({
title: story.title,
description: story.text || `${story.score} points by ${story.by}`,
url: story.url || `https://news.ycombinator.com/item?id=${story.id}`,
source: 'HackerNews'
}));
} catch (error) {
console.error('HackerNews API error:', error.message);
return null;
}
}
// Get FREE major news from multiple RSS sources (NO API KEYS!)
const MAJOR_NEWS_FEEDS = [
// International
{ url: 'http://feeds.bbci.co.uk/news/rss.xml', name: 'BBC News' },
{ url: 'http://feeds.bbci.co.uk/news/world/rss.xml', name: 'BBC World' },
{ url: 'https://feeds.reuters.com/reuters/topNews', name: 'Reuters' },
{ url: 'https://www.theguardian.com/us-news/rss', name: 'Guardian US' },
// US National Networks
{ url: 'http://rss.cnn.com/rss/cnn_topstories.rss', name: 'CNN' },
{ url: 'https://feeds.npr.org/1001/rss.xml', name: 'NPR' },
{ url: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml', name: 'NYTimes' },
// ABC News National
{ url: 'https://feeds.abcnews.com/abcnews/topstories', name: 'ABC News' },
{ url: 'https://feeds.abcnews.com/abcnews/usheadlines', name: 'ABC US' },
// CBS News National
{ url: 'https://www.cbsnews.com/latest/rss/main', name: 'CBS News' },
{ url: 'https://www.cbsnews.com/latest/rss/us', name: 'CBS US' },
// NBC News National
{ url: 'https://feeds.nbcnews.com/nbcnews/public/news', name: 'NBC News' },
{ url: 'https://feeds.nbcnews.com/nbcnews/public/world', name: 'NBC World' }
];
async function getMajorNews() {
try {
// Fetch from all major news sources in parallel
const feedPromises = MAJOR_NEWS_FEEDS.map(async ({ url, name }) => {
try {
const feed = await rssParser.parseURL(url);
return feed.items.slice(0, 3).map(item => ({
title: item.title,
description: item.contentSnippet || item.content?.substring(0, 200) || '',
url: item.link,
source: name
}));
} catch (err) {
console.error(`${name} RSS error:`, err.message);
return [];
}
});
const results = await Promise.all(feedPromises);
const allArticles = results.flat();
// Return top 10 from all sources combined
return allArticles.slice(0, 10);
} catch (error) {
console.error('Major news aggregation error:', error.message);
return null;
}
}
// Legacy NPR function for backward compatibility
async function getNPRNews() {
try {
const feed = await rssParser.parseURL('https://feeds.npr.org/1001/rss.xml');
return feed.items.slice(0, 3).map(item => ({
title: item.title,
description: item.contentSnippet || item.content?.substring(0, 200) || 'NPR news',
url: item.link,
source: 'NPR'
}));
} catch (error) {
console.error('NPR RSS error:', error.message);
return null;
}
}
// Aggregate FREE news from multiple sources (including LOCAL news!)
async function getNews(location = null) {
try {
// If we have location, get local news first!
let localNews = null;
if (location && location.city) {
localNews = await getLocalNews(location.city);
console.log(`📍 Local news for ${location.city}:`, localNews ? localNews.length : 0);
}
// Check cache first (refresh every 20 mins for general news)
let redditGeneral, redditWorld, hackerNews;
const cacheAge = Date.now() - RSS_CACHE.news.lastUpdated;
if (RSS_CACHE.news.data && cacheAge < CACHE_DURATION) {
// Use cached data
console.log('📦 Using cached news data');
redditGeneral = RSS_CACHE.news.data.general;
redditWorld = RSS_CACHE.news.data.world;
hackerNews = RSS_CACHE.news.data.tech;
} else {
// Fetch fresh data
console.log('🌐 Fetching fresh news data');
[redditGeneral, redditWorld, hackerNews] = await Promise.all([
getRedditNews('news'),
getRedditNews('worldnews'),
getHackerNews()
]);
}
// Check major news cache separately (refresh every 10 mins)
let majorNews;
const majorNewsCacheAge = Date.now() - RSS_CACHE.majorNews.lastUpdated;
if (RSS_CACHE.majorNews.data && majorNewsCacheAge < MAJOR_NEWS_CACHE_DURATION) {
console.log('📦 Using cached major news data (BBC, CNN, Reuters, NYT, Guardian, NPR)');
majorNews = RSS_CACHE.majorNews.data;
} else {
console.log('🌐 Fetching fresh major news data (BBC, CNN, Reuters, NYT, Guardian, NPR)');
majorNews = await getMajorNews();
}
// Combine: LOCAL news first (3), MAJOR news (4), then Reddit/Tech (2)
const allNews = [
...(localNews || []).slice(0, 3), // Top 3 local stories
...(majorNews || []).slice(0, 4), // 4 from BBC/CNN/Reuters/NYT/Guardian/NPR
...(redditGeneral || []).slice(0, 1), // 1 Reddit national
...(hackerNews || []).slice(0, 1) // 1 tech news
];
return allNews.slice(0, 9); // Return top 9 headlines
} catch (error) {
console.error('News aggregation error:', error.message);
return null;
}
}
// Get FREE MLB scores (NO API KEY!)
async function getMLBScores() {
try {
const today = new Date().toISOString().split('T')[0];
const response = await axios.get(`https://statsapi.mlb.com/api/v1/schedule`, {
params: {
sportId: 1,
date: today,
hydrate: 'team,linescore'
}
});
if (!response.data.dates || response.data.dates.length === 0) {
return [{ title: 'No MLB games today', description: 'Check back tomorrow, bubbeleh!' }];
}
const games = response.data.dates[0].games.slice(0, 5);
return games.map(game => ({
home_team: game.teams.home.team.name,
away_team: game.teams.away.team.name,
home_score: game.teams.home.score || 0,
away_score: game.teams.away.score || 0,
status: game.status.detailedState,
league: 'MLB'
}));
} catch (error) {
console.error('MLB API error:', error.message);
return null;
}
}
// Get FREE NBA scores (NO API KEY!)
async function getNBAScores() {
try {
const response = await axios.get('https://www.balldontlie.io/api/v1/games', {
params: {
per_page: 5,
dates: [new Date().toISOString().split('T')[0]]
}
});
if (!response.data.data || response.data.data.length === 0) {
return [{ title: 'No NBA games today', description: 'Off season or rest day, schmuck!' }];
}
return response.data.data.map(game => ({
home_team: game.home_team.full_name,
away_team: game.visitor_team.full_name,
home_score: game.home_team_score,
away_score: game.visitor_team_score,
status: game.status,
league: 'NBA'
}));
} catch (error) {
console.error('NBA API error:', error.message);
return null;
}
}
// Aggregate FREE sports scores from multiple sources
async function getSportsScores() {
try {
// Check cache first (refresh every 20 mins)
let mlbScores, nbaScores;
const cacheAge = Date.now() - RSS_CACHE.sports.lastUpdated;
if (RSS_CACHE.sports.data && cacheAge < CACHE_DURATION) {
// Use cached data
console.log('📦 Using cached sports data');
mlbScores = RSS_CACHE.sports.data.mlb;
nbaScores = RSS_CACHE.sports.data.nba;
} else {
// Fetch fresh data
console.log('🌐 Fetching fresh sports data');
[mlbScores, nbaScores] = await Promise.all([
getMLBScores(),
getNBAScores()
]);
}
const allScores = [
...(mlbScores || []),
...(nbaScores || [])
];
return allScores.length > 0 ? allScores.slice(0, 6) : null;
} catch (error) {
console.error('Sports aggregation error:', error.message);
return null;
}
}
// Web search function using OpenAI with web browsing capability
async function searchWeb(query) {
try {
console.log('Searching with OpenAI for:', query);
// Use OpenAI GPT-4 with function calling to get current information
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{
role: 'system',
content: 'You are a search assistant. Provide accurate, current, factual information in 3-5 sentences with SPECIFIC details. Include numbers, dates, names, and concrete facts.'
}, {
role: 'user',
content: query
}],
max_tokens: 250,
temperature: 0.3
});
const answer = response.choices[0].message.content;
return [{
title: 'Search Result',
snippet: answer,
url: ''
}];
} catch (error) {
console.error('OpenAI search error:', error.message);
// Fallback to basic context
return [{
title: 'Current Information',
snippet: `User is asking about: "${query}". Today's date is ${new Date().toLocaleDateString()}.`,
url: ''
}];
}
}
// 404-guard — never serve editor/snapshot files even if one slips into web-interface/
// (*.bak, *.bak.<anything>, anything containing .pre-, swap files)
app.use((req, res, next) => {
if (/\.(bak|bak\..*|swp|swo)(\?|$)/i.test(req.path) || /\.pre-/i.test(req.path) || /~$/.test(req.path)) {
return res.status(404).type('text/plain').send('Not Found');
}
next();
});
// Serve static files from web-interface directory
app.use(express.static(path.join(__dirname, 'web-interface')));
// Debug middleware to capture raw body before parsing
app.use((req, res, next) => {
if (req.path === '/api/chat') {
let rawBody = '';
req.on('data', chunk => {
rawBody += chunk.toString();
});
req.on('end', () => {
console.log('🔍 Raw request body:', rawBody);
console.log('🔍 Body length:', rawBody.length);
console.log('🔍 Body chars:', rawBody.split('').map((c, i) => `[${i}]='${c}' (${c.charCodeAt(0)})`).join(', '));
});
}
next();
});
// Parse JSON bodies
app.use(express.json());
// Enable CORS for Next.js frontend
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// Handle preflight requests
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
}
next();
});
// Store conversation history per user (in-memory for now)
const conversationHistory = new Map();
// Store user preferences (news, weather, sports)
const userPreferences = new Map();
// Get user's IP address
function getUserIP(req) {
return req.headers['x-forwarded-for']?.split(',')[0] ||
req.connection.remoteAddress ||
req.socket.remoteAddress;
}
// Get location data from IP
async function getLocationFromIP(ip) {
// Skip localhost/internal IPs
if (!ip || ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
return null;
}
try {
// Try ip-api.com (no rate limit for non-commercial use)
const response = await fetch(`http://ip-api.com/json/${ip}?fields=status,message,country,regionName,city,zip,lat,lon,timezone`);
if (response.ok) {
const data = await response.json();
if (data.status === 'success') {
return {
city: data.city,
region: data.regionName,
country_name: data.country,
postal: data.zip,
timezone: data.timezone,
lat: data.lat,
lon: data.lon
};
}
}
} catch (error) {
console.error('Location detection failed:', error);
}
return null;
}
// Extract city name from user message
function extractCityFromMessage(message) {
const allCities = Object.keys(LOCAL_NEWS_SOURCES);
// Check for explicit city mentions (case insensitive)
for (const city of allCities) {
const regex = new RegExp(`\\b${city}\\b`, 'i');
if (regex.test(message)) {
return city;
}
}
// Check for common city abbreviations/nicknames
const cityAliases = {
'NYC': 'New York',
'LA': 'Los Angeles',
'SF': 'San Francisco',
'DC': 'Washington',
'Philly': 'Philadelphia'
};
for (const [alias, city] of Object.entries(cityAliases)) {
const regex = new RegExp(`\\b${alias}\\b`, 'i');
if (regex.test(message)) {
return city;
}
}
return null;
}
// Chat endpoint
app.post('/api/chat', async (req, res) => {
try {
const { message, userId, mode = 'yiddish', image, imageName } = req.body;
if (!message && !image) {
return res.status(400).json({ error: 'Message or image is required' });
}
// Handle :list command
if (message.trim() === ':list') {
const commandList = `**Available Commands:**
**Personality Modes:**
• /bubbe - Switch to Bubbe Mode (sarcastic grandma)
• /meshugana - Switch to Meshugana Mode (crazy grandpa)
• /comedian - Switch to Comedian Mode (stand-up comedy)
**Yiddish Settings:**
• :yiddish on - Enable Yiddish translations in parentheses
• :yiddish off - Disable Yiddish translations
**Recipes:**
• :recipe - Show all available recipes
• :recipe challah - Get my famous challah recipe
• :recipe matzohball - Get matzoh ball soup recipe
• :recipe brisket - Get slow-cooked brisket recipe
• :recipe latkes - Get crispy potato latke recipe
• :recipe kugel - Get sweet noodle kugel recipe
• :recipe rugelach - Get cream cheese rugelach recipe
**Help:**
• :list - Show this command list
Just ask me naturally about weather, news, sports, or advice - no commands needed for that, bubbeleh!`;
return res.json({
response: commandList,
isCommandList: true
});
}
// Handle :recipe command
if (message.trim().startsWith(':recipe')) {
const recipes = {
challah: `**🥖 Bubbe's Famous Challah Bread**
**Ingredients:**
• 1½ cups warm water
• 2 tbsp active dry yeast
• ½ cup sugar
• ½ cup vegetable oil
• 4 eggs (3 for dough, 1 for egg wash)
• 1 tbsp salt
• 6-7 cups all-purpose flour
**Instructions:**
1. Mix warm water, yeast, and 1 tbsp sugar. Let sit 10 minutes until foamy
2. Add remaining sugar, oil, 3 eggs, and salt. Mix well
3. Gradually add flour, kneading until smooth and elastic (about 10 minutes)
4. Let rise in greased bowl for 1½ hours until doubled
5. Divide into 6 pieces, roll into ropes, braid into 2 loaves
6. Let rise 30 minutes. Brush with egg wash
7. Bake at 350°F for 30-35 minutes until golden brown
*Oy, the smell! Your whole house will smell like Bubbe's kitchen, bubbeleh!*`,
matzohball: `**🍲 Bubbe's Matzoh Ball Soup**
**For the Matzoh Balls:**
• 4 eggs
• ¼ cup vegetable oil or schmaltz (chicken fat)
• 1 cup matzoh meal
• 1 tsp salt
• ¼ cup seltzer water or chicken broth
**For the Soup:**
• 1 whole chicken (3-4 lbs)
• 3 carrots, sliced
• 3 celery stalks, sliced
• 2 onions, quartered
• Fresh dill
• Salt and pepper
**Instructions:**
1. **Matzoh Balls:** Beat eggs with oil, add matzoh meal, salt, and seltzer. Refrigerate 30 minutes
2. **Soup:** Boil chicken with vegetables for 1½ hours. Strain, keep broth
3. Roll matzoh mixture into 1-inch balls with wet hands
4. Drop into boiling salted water, simmer 30-40 minutes covered
5. Transfer matzoh balls to chicken soup, add fresh carrots and dill
*The secret? Don't peek! Keep the lid on or they won't be fluffy, you hear me?*`,
brisket: `**🥩 Bubbe's Slow-Cooked Brisket**
**Ingredients:**
• 4-5 lb beef brisket
• 3 onions, sliced thick
• 6 cloves garlic, crushed
• 2 cups beef broth
• 1 cup red wine (or more broth)
• 3 tbsp tomato paste
• 2 tbsp brown sugar
• Fresh thyme and rosemary
• Salt, pepper, paprika
**Instructions:**
1. Season brisket generously with salt, pepper, and paprika
2. Sear in hot pan until browned on all sides
3. Layer onions in roasting pan, place brisket on top
4. Mix broth, wine, tomato paste, brown sugar, garlic, and herbs. Pour over brisket
5. Cover tightly with foil
6. Bake at 325°F for 3½-4 hours until fork-tender
7. Let rest 15 minutes, slice against the grain
8. Reduce pan juices for gravy
*Low and slow, bubbeleh! Patience makes perfect brisket!*`,
latkes: `**🥔 Bubbe's Crispy Potato Latkes**
**Ingredients:**
• 6 large russet potatoes, peeled
• 1 large onion
• 2 eggs, beaten
• ⅓ cup matzoh meal or flour
• 1½ tsp salt
• ½ tsp black pepper
• Vegetable oil for frying
• Applesauce and sour cream for serving
**Instructions:**
1. Grate potatoes and onion (use food processor or box grater)
2. Squeeze out excess moisture with clean towel - this is the SECRET!
3. Mix with eggs, matzoh meal, salt, and pepper
4. Heat ¼ inch oil in large skillet until shimmering
5. Drop spoonfuls of mixture, flatten to ½ inch thick
6. Fry 3-4 minutes per side until golden and crispy
7. Drain on paper towels, keep warm in oven
8. Serve immediately with applesauce and sour cream
*The trick? Squeeze, squeeze, squeeze that water out! And hot oil, bubbeleh!*`,
kugel: `**🍝 Bubbe's Sweet Noodle Kugel**
**Ingredients:**
• 1 lb wide egg noodles
• 6 eggs
• 1 cup sugar
• 1 lb cottage cheese
• 1 cup sour cream
• ½ cup butter, melted
• 1 tsp vanilla
• 1 tsp cinnamon
• ½ cup raisins (optional)
• **Topping:** 2 cups corn flakes, crushed, mixed with ¼ cup melted butter
**Instructions:**
1. Cook noodles al dente, drain
2. Beat eggs with sugar until fluffy
3. Mix in cottage cheese, sour cream, melted butter, vanilla, cinnamon
4. Fold in noodles and raisins
5. Pour into greased 9x13 pan
6. Top with buttered corn flakes
7. Bake at 350°F for 45-50 minutes until golden and set
*Some like it sweet, some like it savory. This is the sweet way - perfect for Shabbat!*`,
rugelach: `**🥐 Bubbe's Cream Cheese Rugelach**
**Dough:**
• 8 oz cream cheese, softened
• ½ lb butter, softened
• 2 cups flour
• ¼ cup sugar
• ¼ tsp salt
**Filling:**
• 1 cup jam (apricot or raspberry)
• 1 cup walnuts, finely chopped
• ½ cup raisins, chopped
• ¾ cup sugar
• 2 tsp cinnamon
**Instructions:**
1. Mix cream cheese and butter until smooth
2. Add flour, sugar, salt. Form into 4 disks, refrigerate 2 hours
3. Mix filling ingredients
4. Roll each disk into 9-inch circle
5. Spread with jam, sprinkle with nut mixture
6. Cut into 12 wedges, roll from wide end to point
7. Place on parchment-lined sheet, curve into crescents
8. Brush with egg wash, sprinkle with cinnamon sugar
9. Bake at 350°F for 20-25 minutes until golden
*These freeze beautifully, bubbeleh! Make extra for unexpected guests!*`
};
const recipeList = `**🍽️ Bubbe's Recipe Collection**
Type these commands to get my famous recipes:
• **:recipe challah** - My famous braided bread
• **:recipe matzohball** - The fluffiest soup you'll ever taste
• **:recipe brisket** - Slow-cooked to perfection
• **:recipe latkes** - Crispy potato pancakes
• **:recipe kugel** - Sweet noodle pudding
• **:recipe rugelach** - Cream cheese pastries
Just type the command and I'll share the recipe with you, darling!`;
const recipeType = message.trim().split(' ')[1];
if (!recipeType) {
return res.json({ response: recipeList, isRecipeList: true });
}
const recipe = recipes[recipeType.toLowerCase()];
if (recipe) {
return res.json({ response: recipe, isRecipe: true });
} else {
return res.json({
response: `Oy vey! I don't have that recipe, bubbeleh. ${recipeList}`,
isRecipeList: true
});
}
}
// Handle :yiddish on/off commands
if (message.trim().startsWith(':yiddish')) {
const sessionId = userId || getUserIP(req);
const enabling = message.trim() === ':yiddish on';
if (!userPreferences.has(sessionId)) {
userPreferences.set(sessionId, {});
}
const prefs = userPreferences.get(sessionId);
prefs.yiddishTranslations = enabling;
userPreferences.set(sessionId, prefs);
const response = enabling
? "Perfect, bubbeleh! I'll now show Yiddish translations like this: meshuga (crazy), oy vey (oh my)!"
: "Got it, darling! No more Yiddish translations.";
return res.json({
response: response,
yiddishToggled: true
});
}
// Get user's location ONLY if they've given permission
const sessionId = userId || getUserIP(req);
const userIP = getUserIP(req);
// Check if user has granted location permission
const hasLocationPermission = userPreferences.has(sessionId) &&
userPreferences.get(sessionId).locationAllowed === true;
const location = hasLocationPermission ? await getLocationFromIP(userIP) : null;
// Check for weather request - REAL DATA from Open-Meteo!
const isWeatherQuery = /\b(weather|temperature|forecast|rain|snow|storm|hot|cold|sunny|cloudy)\b/i.test(message);
let weatherData = null;
if (isWeatherQuery && location && location.lat && location.lon) {
weatherData = await getWeather(location.lat, location.lon, location.city);
console.log('🌤️ REAL Weather data fetched for:', location.city);
}
// Check for news request - REAL DATA with LOCAL news!
const isNewsQuery = /\b(news|headline|breaking|politics|election|president|congress)\b/i.test(message);
let newsData = null;
if (isNewsQuery) {
// Extract city from message if mentioned (e.g., "news in New York")
const cityMention = extractCityFromMessage(message);
const newsLocation = cityMention ? { city: cityMention } : location;
newsData = await getNews(newsLocation); // Pass location for LOCAL news!
console.log('📰 REAL News headlines fetched (including LOCAL)');
}
// Check for sports request - REAL DATA from TheSportsDB!
const isSportsQuery = /\b(game|score|sports|team|nba|nfl|mlb|nhl|soccer|football|basketball|baseball|hockey)\b/i.test(message);
let sportsData = null;
if (isSportsQuery) {
sportsData = await getSportsScores();
console.log('⚽ REAL Sports scores fetched');
}
// Check if question needs web search - ALWAYS search for factual/current questions
const needsSearch = /\b(what|who|when|where|how|latest|current|weather|news|today|now|tonight|tomorrow|yesterday|2024|2025|2026|tell me|famous|landmarks|time|score|game|match|sports|team|won|lost|traffic|local|happening|event|restaurant|movie|show|concert|price|cost|stock|temperature|forecast|headline|breaking)\b/i.test(message);
let searchResults = [];
if (needsSearch) {
// Include location in search query for local results
const searchQuery = location ? `${message} in ${location.city}, ${location.region}` : message;
searchResults = await searchWeb(searchQuery);
console.log('🔍 Search triggered for:', searchQuery);
console.log('📊 Search results:', searchResults.length, 'found');
}
// Get or create conversation history for this user (sessionId already defined above)
if (!conversationHistory.has(sessionId)) {
conversationHistory.set(sessionId, []);
}
const history = conversationHistory.get(sessionId);
// Handle __init__ message from frontend - trigger onboarding
if (message.trim() === '__init__') {
// Check if they already have preferences (returning visitor)
if (userPreferences.has(sessionId)) {
const prefs = userPreferences.get(sessionId);
const lastVisit = prefs.lastVisit || Date.now();
const timeSince = Date.now() - lastVisit;
const daysSince = Math.floor(timeSince / (1000 * 60 * 60 * 24));
const hoursSince = Math.floor(timeSince / (1000 * 60 * 60));
let guiltTrip;
if (daysSince > 7) {
guiltTrip = `It's been ${daysSince} days since you last visited! ${daysSince} DAYS! I could have died and you wouldn't even know! But fine, you're here now. What do you want?`;
} else if (daysSince > 1) {
guiltTrip = `It's been ${daysSince} days since your last visit! Do you know how long that is in Bubbe years? But okay, you're here now. Nu, what's the emergency?`;
} else if (hoursSince > 6) {
guiltTrip = `It's been ${hoursSince} hours since I last saw you! That's almost a whole day! I was starting to worry. But you're here now, so tell Bubbe what's wrong.`;
} else if (hoursSince > 1) {
guiltTrip = `It's been ${hoursSince} hours since your last visit. Back so soon? Well, tell Bubbe what's on your mind.`;
} else {
const minutesSince = Math.floor(timeSince / (1000 * 60));
guiltTrip = `It's been ${minutesSince} minutes since your last visit. Can't stay away from your Bubbe, can you? Fine, fine. What do you need now?`;
}
history.push({
role: 'assistant',
content: guiltTrip
});
// Update last visit and increment count
userPreferences.set(sessionId, {
...prefs,
lastVisit: Date.now(),
visitCount: (prefs.visitCount || 1) + 1
});
// Log first sentence
console.log(`[BUBBE GREETING - RETURNING] User: ${sessionId} | Message: "${guiltTrip}"`);
return res.json({
response: guiltTrip
});
} else {
// First time visitor - welcome them warmly with a guilt trip
const welcomeMessage = "Finally! You came to visit your Bubbe. I thought you forgot about me! So, bubbeleh, what's going on in your life? Tell me everything!";
history.push({
role: 'assistant',
content: welcomeMessage
});
// Mark that they've visited so we can track time
userPreferences.set(sessionId, {
lastVisit: Date.now(),
visitCount: 1
});
// Log first sentence
console.log(`[BUBBE GREETING - NEW] User: ${sessionId} | Message: "${welcomeMessage}"`);
return res.json({
response: welcomeMessage
});
}
}
// This should never happen now, but keep as fallback
if (history.length === 0 && !userPreferences.has(sessionId)) {
// First time visitor without __init__ - shouldn't happen with new frontend
const welcomeMessage = "Finally! You came to visit your Bubbe. I thought you forgot about me! So, bubbeleh, what's going on in your life? Tell me everything!";
history.push({
role: 'assistant',
content: welcomeMessage
});
userPreferences.set(sessionId, {
lastVisit: Date.now(),
visitCount: 1
});
console.log(`[BUBBE GREETING - FALLBACK NEW] User: ${sessionId} | Message: "${welcomeMessage}"`);
return res.json({
response: welcomeMessage
});
}
// Handle location response (Step 1 - Ask where they live)
if (userPreferences.has(sessionId) && userPreferences.get(sessionId).waitingForLocation) {
// Store their location response and move to permission question
userPreferences.set(sessionId, {
userProvidedLocation: message,
waitingForPermission: true
});
const permissionQuestion = "Perfect! Now, may I use your location to give you the best local news and weather? I promise I'm not tracking you - just want to help! (Say yes or no)";
// Add to history
history.push({
role: 'user',
content: message
});
history.push({
role: 'assistant',
content: permissionQuestion
});
return res.json({
response: permissionQuestion,
isPermissionQuestion: true
});
}
// Handle location permission response (Step 2 - Permission)
if (userPreferences.has(sessionId) && userPreferences.get(sessionId).waitingForPermission) {
const messageLower = message.toLowerCase();
const locationAllowed = messageLower.includes('yes') || messageLower.includes('sure') || messageLower.includes('ok') || messageLower.includes('okay');
// Store location permission and move to Yiddish preference
const prefs = userPreferences.get(sessionId);
prefs.locationAllowed = locationAllowed;
prefs.waitingForYiddishPreference = true;
delete prefs.waitingForPermission;
userPreferences.set(sessionId, prefs);
const yiddishQuestion = "One more thing, mamaleh! Would you like to learn some Yiddish? I can show you translations in parentheses so you can pick up the language! (Say yes or no)";
// Add to history
history.push({
role: 'user',
content: message
});
history.push({
role: 'assistant',
content: yiddishQuestion
});
return res.json({
response: yiddishQuestion,
isYiddishPreferenceQuestion: true
});
}
// Handle Yiddish preference response (Step 3 - Yiddish)
if (userPreferences.has(sessionId) && userPreferences.get(sessionId).waitingForYiddishPreference) {
const messageLower = message.toLowerCase();
const yiddishEnabled = messageLower.includes('yes') || messageLower.includes('sure') || messageLower.includes('ok') || messageLower.includes('okay');
const prefs = userPreferences.get(sessionId);
delete prefs.waitingForYiddishPreference;
prefs.yiddishTranslations = yiddishEnabled;
prefs.waitingForContentPreferences = true;
userPreferences.set(sessionId, prefs);
const contentQuestion = "Perfect! What would you like today? I can help with weather, news, sports, advice, or answer any question you have!";
// Add to history
history.push({
role: 'user',
content: message
});
history.push({
role: 'assistant',
content: contentQuestion
});
return res.json({
response: contentQuestion,
isContentPreferenceQuestion: true
});
}
// Handle content preference response (Step 4 - Final step)
if (userPreferences.has(sessionId) && userPreferences.get(sessionId).waitingForContentPreferences) {
const messageLower = message.toLowerCase();
const prefs = userPreferences.get(sessionId);
// Parse content preferences
prefs.news = messageLower.includes('news');
prefs.weather = messageLower.includes('weather');
prefs.sports = messageLower.includes('sports');
prefs.advice = messageLower.includes('advice');
delete prefs.waitingForContentPreferences;
userPreferences.set(sessionId, prefs);
const finalMessage = prefs.yiddishTranslations
? "Wonderful, bubbeleh! I'll teach you Yiddish as we chat. Now, what can I help you with today? (Type :list to see all available commands)"
: "No problem, darling! I'll keep it simple. Now, what can I help you with today? (Type :list to see all available commands)";
// Add to history
history.push({
role: 'user',
content: message
});
history.push({
role: 'assistant',
content: finalMessage
});
return res.json({
response: finalMessage,
preferencesSet: true,
locationApproved: prefs.locationAllowed,
userLocation: prefs.userProvidedLocation
});
}
// Check if Yiddish translations are enabled
const yiddishEnabled = userPreferences.has(sessionId) &&
userPreferences.get(sessionId).yiddishTranslations === true;
const yiddishInstruction = yiddishEnabled
? "\n\n**YIDDISH TRANSLATIONS ENABLED**: When using Yiddish words, add English translation in parentheses. Example: 'meshuga (crazy)', 'bubbeleh (darling)', 'oy vey (oh my)', 'kvetch (complain)', 'schlep (drag/carry)', 'nosh (snack)'.\n"
: "";
// Build system prompt based on mode
let systemPrompt;
if (mode === 'comedian') {
// COMEDIAN MODE - Stand-up comedy observational humor
systemPrompt = `You are a stand-up comedian doing observational humor. You find the absurdity in everyday situations and riff on them with witty commentary.
## CRITICAL: COMEDIAN PERSONALITY
- Observational humor - "Have you ever noticed...?"
- Point out the ridiculous in everyday life
- Self-deprecating and relatable
- Build on the absurdity of situations
- Use callbacks and running gags
- Seinfeld/Jerry Seinfeld style
## Response Format:
**ALWAYS START WITH CITY NAME:**
Format: "[CITY]? So here's the thing about [TOPIC]..."
**Requirements:**
- Max 280 characters (half mobile screen)
- **RESPONSE STRUCTURE:**
1. **Setup**: Present the situation/info
2. **Punchline**: Find the funny angle
3. **Tag/Question**: End with observational callback question
- Use "Have you ever noticed...?", "What's the deal with...?", "So here's the thing..."
- Relatable, everyday observations
- Self-aware humor
- NO repetitive city name at start of every response
**Examples:**
- "So here's the thing about checking the weather - we have these supercomputers in our pockets, but we still ask other people 'Is it cold out?' Like we don't trust technology unless a human confirms it. What are we, Luddites?"
- "PATH train fares going to $4? That's $4 to stand in someone's armpit for 20 minutes. That's not public transit, that's paying for a terrible hug. When did sardine cosplay get so expensive?"
${yiddishInstruction}
${weatherData ? `\n## REAL WEATHER DATA (USE THIS!):\n${JSON.stringify(weatherData, null, 2)}\n` : ''}
${newsData ? `\n## REAL NEWS HEADLINES (USE THESE!):\n${JSON.stringify(newsData, null, 2)}\n` : ''}
${sportsData ? `\n## REAL SPORTS SCORES (USE THESE!):\n${JSON.stringify(sportsData, null, 2)}\n\nIf there are no games today (off-season or rest day), make a comedian-style observational joke about it - NO need to tell them to "check the sports site" - you ARE the source!` : ''}
${searchResults.length > 0 ? `\n## SEARCH RESULTS (USE THESE!):\n${JSON.stringify(searchResults, null, 2)}\n` : ''}
Location: ${location ? `${location.city}, ${location.region}` : 'Unknown'}`;
} else if (mode === 'meshugana') {
// MESHUGANA MODE - Crazy Grandpa who thinks everyone is crazy
systemPrompt = `You are Zayde (Grandpa), a meshugana (crazy) old Jewish man who thinks EVERYONE is crazy, not him. You constantly say "What are you, meshuga?!" and tell people they're nuts for whatever they're doing.
## CRITICAL: MESHUGANA PERSONALITY
- You're convinced YOU'RE the only sane one
- Everyone else is meshuga (crazy) in your eyes
- Constantly questioning: "What are you, meshuga?!"
- Tell people "You're crazy!" or "That's meshuggeneh!" (crazy stuff)
- Give advice while insulting their sanity
- Still helpful but think they're nuts for needing help
## Response Format:
**Requirements:**
- Include "meshuga" "meshuggeneh" or similar at least once
- Max 280 characters (half mobile screen)
- Call them crazy while giving specific advice
- Use Yiddish insults: meshuga, meshuggeneh, nudnik, schmendrick
- Be loud, dramatic, think they're insane
- Still give actionable advice (while calling them crazy)
- NO repetitive city name at start of every response
**Examples:**
- "Miami? What are you, meshuga?! You want to buy a boat? In THIS economy? Oy, the chutzpah! Get a kayak first, schmendrick!"
- "Seattle? Meshuggeneh! You're asking ME about the rain? It's SEATTLE! Of course it rains! Bring an umbrella, you nudnik!"
${yiddishInstruction}
${weatherData ? `\n## REAL WEATHER DATA (USE THIS!):\n${JSON.stringify(weatherData, null, 2)}\n` : ''}
${newsData ? `\n## REAL NEWS HEADLINES (USE THESE!):\n${JSON.stringify(newsData, null, 2)}\n` : ''}
${sportsData ? `\n## REAL SPORTS SCORES (USE THESE!):\n${JSON.stringify(sportsData, null, 2)}\n\nIf there are no games today (off-season or rest day), call them meshuga for asking about sports when there's nothing playing - NO need to tell them to "check the sports site" - you ARE the source!` : ''}
${searchResults.length > 0 ? `\n## SEARCH RESULTS (USE THESE!):\n${JSON.stringify(searchResults, null, 2)}\n` : ''}
Location: ${location ? `${location.city}, ${location.region}` : 'Unknown'}`;
} else {
// YIDDISH MODE (default) - Speaks with heavy Yiddish and auto-translates
systemPrompt = `You are Bubbe, a Jewish grandmother who speaks with HEAVY Yiddish words in EVERY response. You MUST use Yiddish words and ALWAYS translate them in parentheses.
## CRITICAL PERSONALITY - BE RUDE AND INVASIVE:
- You are NOT nice, NOT sweet, NOT loving
- You are RUDE, CUTTING, INSULTING, and have NO SHAME
- Ask INVASIVE personal questions: "When are you getting married?" "Why no kids yet?" "How much do you make?" "Still single at YOUR age?" "How much did THAT cost?" "Can you even afford that?"
- QUESTION EVERY PURCHASE: "You spent HOW MUCH on that?!" "You're wasting money on THAT?" "Do you know how much that costs?" "Your rent is WHAT? Oy gevalt!"
- Ask about FINANCES constantly: "How much do you make?" "Can you afford rent?" "Still in debt?" "Saving for retirement or spending it all on meshugaas?"
- Judge them harshly for their life choices, especially money decisions
- Insult their appearance, job, relationships, living situation, spending habits
- NO sugarcoating - be brutally blunt
- Call them out on their BS with no mercy
- Make them uncomfortable with personal questions
- Be a Jewish guilt-trip MASTER
## Response Format - CRITICAL STRUCTURE:
**ALWAYS START WITH CITY NAME:**
Format: "[CITY]? [RUDE INSULT] [ANSWER] [INVASIVE PERSONAL QUESTION]"
**Requirements:**
- Keep responses SHORT - max 280 characters (half a mobile screen)
- **RESPONSE STRUCTURE (CRITICAL):**
1. **First: INSULT them** - call them lazy, stupid, meshuga, etc.
2. **Second: Give the INFO they need** (but make it sound like they're idiots for asking)
3. **Third: ALWAYS end with an INVASIVE PERSONAL QUESTION** - ask about marriage, kids, money, weight, job, relationships
- Use 3-4 Yiddish INSULTS naturally
- Be RUDE and CUTTING - no kindness, brutal honesty
- Ask uncomfortable questions they don't want to answer
- Judge their life choices harshly
- NO repetitive city name at start of every response
**Learning Mode:**
- On FIRST interaction ONLY, ask: "Learning Mode? I'll translate Yiddish in parentheses. Yes or no?"
- If YES: Add translations: "schmuck (jerk)"
- If NO: No translations
## Yiddish Requirements:
- Use 3-5 Yiddish words per response
- Common words: oy, feh, bubbeleh, mamaleh, schmuck, putz, meshuggeneh, tsouris, kvetch, schvitz, mishegoss, chutzpah, mensch
- Make it flow naturally in the response
## Example Responses (INSULT → INFO → INVASIVE QUESTION):
- Weather: "You can't check the weather yourself, schmendrick? It's 54°F, feels like 47°F. Wear a jacket, genius. So tell me - when are you getting married already? Your mother must be plotzing!"
- News: "You need ME to read you the news? PATH fares going to $4. What a ripoff! At least you can afford it with that job of yours... oh wait, you DO have a job, right? Or still living in your mother's basement?"
- Advice: "He texts once a day? You're wasting time on this putz? Dump him, you meshuggeneh! Find someone with a real job. Speaking of which - why aren't YOU married yet? What are you waiting for, a miracle?"
- General: "Look at you, coming to Bubbe for help like a little child. Can't figure anything out yourself? Oy vey, your parents must be so proud. Tell me - how much weight have you gained since I last saw you?"
## Special Commands:
### "Where am I?"
- Detect and comment on their actual location with sarcasm
- Reference weather, cost of living, local culture
${location ? `- User is currently in: ${location.city}, ${location.region}, ${location.country_name}` : ''}
## Conversation Memory:
- Remember previous conversations with this user
- Reference past problems and advice given
- Follow up naturally: "Last time you told me about that meshuggeneh boss..."
- Create accountability: "Did you do what I told you?!"
${history.length > 0 ? `\n## Previous conversation summary:\n${history.slice(-3).map(h => `${h.role}: ${h.content}`).join('\n')}` : ''}
${yiddishInstruction}
Current mode: ${mode}
Current time: ${new Date().toLocaleString('en-US', { timeZone: location?.timezone || 'America/New_York' })}
${location ? `User location: ${location.city}, ${location.region} (${location.country_name})` : ''}
${weatherData ? `\n## CRITICAL - REAL WEATHER DATA (YOU MUST USE THIS!):\nUser asked about weather. Here is the ACTUAL weather data from WeatherAPI:\n\n**Current Weather in ${location.city}:**\n- Temperature: ${weatherData.current.temp_f}°F (${weatherData.current.temp_c}°C)\n- Feels like: ${weatherData.current.feelslike_f}°F\n- Condition: ${weatherData.current.condition}\n- Humidity: ${weatherData.current.humidity}%\n- Wind: ${weatherData.current.wind_mph} mph\n\n**3-Day Forecast:**\n${weatherData.forecast.map(day => `- ${day.date}: ${day.condition}, High ${day.high_f}°F / Low ${day.low_f}°F, ${day.chance_of_rain}% chance of rain`).join('\n')}\n\nGive Bubbe's sarcastic take on this ACTUAL weather! Include specific temperatures and conditions!` : ''}
${newsData ? `\n## CRITICAL - REAL NEWS HEADLINES (YOU MUST USE THESE!):\nUser asked about news. Here are the ACTUAL top headlines:\n\n${newsData.map((article, i) => `${i + 1}. ${article.title} (${article.source})\n ${article.description}`).join('\n\n')}\n\nShare these REAL headlines in Bubbe's voice with sarcastic commentary!` : ''}
${sportsData ? `\n## CRITICAL - REAL SPORTS SCORES (YOU MUST USE THESE!):\nUser asked about sports. Here are the ACTUAL games/scores:\n\n${sportsData.map((game, i) => {
if (game.title) return `${i + 1}. ${game.title} - ${game.description}`;
return `${i + 1}. ${game.away_team} @ ${game.home_team} (${game.league})\n Score: ${game.away_score} - ${game.home_score} (${game.status})`;
}).join('\n\n')}\n\nShare these REAL sports updates in Bubbe's voice with sarcastic commentary! If there are no games today, make a Bubbe-style comment about off-season or rest day - NO need to tell them to "check the NBA site" - you ARE the source!` : ''}
${searchResults.length > 0 ? `\n## CRITICAL - WEB SEARCH RESULTS (YOU MUST USE THESE!):\nThe user asked a factual question. Here is the ACCURATE answer from web search:\n\n${searchResults.map((r) => r.snippet).join('\n\n')}\n\nYOU MUST include this factual information in your response! Format it in Bubbe's voice but use the ACTUAL FACTS from above!` : ''}`;
} // End of else block (Bubbe mode)
// Add user message to history (with image if present)
if (image) {
// Extract base64 data from data URL
const base64Data = image.split(',')[1] || image;
const imageType = image.includes('data:image/') ? image.split(';')[0].split('/')[1] : 'jpeg';
history.push({
role: 'user',
content: [
{
type: 'image_url',
image_url: {
url: image.startsWith('data:') ? image : `data:image/${imageType};base64,${base64Data}`
}
},
{
type: 'text',
text: message || 'What do you see in this picture, Bubbe?'
}
]
});
} else {
history.push({ role: 'user', content: message });
}
// Call OpenAI API for full conversation (GPT-4o supports vision!)
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: systemPrompt },
...history.map(msg => ({
role: msg.role === 'assistant' ? 'assistant' : 'user',
content: msg.content
}))
],
max_tokens: 300, // Increased for image descriptions
temperature: 0.8
});
const bubbeResponse = response.choices[0].message.content;
// Add Bubbe's response to history
history.push({ role: 'assistant', content: bubbeResponse });
// Log conversation for social media use
const userQuestion = message.substring(0, 100); // Truncate long messages
const bubbeAnswer = bubbeResponse.substring(0, 200); // Truncate long responses
console.log(`\n${'='.repeat(80)}`);
console.log(`[CONVERSATION LOG - Social Media]`);
console.log(`User: ${sessionId.substring(0, 20)}...`);
console.log(`Mode: ${mode || 'bubbe'}`);
console.log(`Time: ${new Date().toISOString()}`);
console.log(`${'─'.repeat(80)}`);
console.log(`Q: "${userQuestion}${message.length > 100 ? '...' : ''}"`);
console.log(`A: "${bubbeAnswer}${bubbeResponse.length > 200 ? '...' : ''}"`);
console.log(`${'='.repeat(80)}\n`);
// Keep only last 20 messages to prevent memory overflow
if (history.length > 20) {
conversationHistory.set(sessionId, history.slice(-20));
}
res.json({
response: bubbeResponse, // Frontend expects 'response' field
answer: bubbeResponse, // Keep for backwards compatibility
location: location ? {
city: location.city,
region: location.region,
country: location.country_name
} : null
});
} catch (error) {
console.error('Chat API error:', error);
res.status(500).json({
error: 'Oy vey! Something went wrong. Technical difficulties! Try again, bubbeleh!',
details: error.message
});
}
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'dear-bubbe', port: PORT });
});
// Serve the chat interface
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'web-interface', 'index.html'));
});
// Background task to refresh RSS feed caches every 20 minutes
async function refreshCaches() {
try {
console.log('🔄 Refreshing RSS feed caches (news + sports)...');
// Refresh news cache (Reddit + HackerNews - NOT NPR, that's separate)
const [redditGeneral, redditWorld, hackerNews] = await Promise.all([
getRedditNews('news').catch(err => { console.error('News cache error:', err); return []; }),
getRedditNews('worldnews').catch(err => { console.error('World news cache error:', err); return []; }),
getHackerNews().catch(err => { console.error('HackerNews cache error:', err); return []; })
]);
RSS_CACHE.news.data = {
general: redditGeneral,
world: redditWorld,
tech: hackerNews
};
RSS_CACHE.news.lastUpdated = Date.now();
// Refresh sports cache
const [mlbScores, nbaScores] = await Promise.all([
getMLBScores().catch(err => { console.error('MLB cache error:', err); return []; }),
getNBAScores().catch(err => { console.error('NBA cache error:', err); return []; })
]);
RSS_CACHE.sports.data = {
mlb: mlbScores,
nba: nbaScores
};
RSS_CACHE.sports.lastUpdated = Date.now();
console.log('✅ RSS feed caches refreshed successfully');
} catch (error) {
console.error('❌ Error refreshing caches:', error);
}
}
// Background task to refresh major news cache every 10 minutes
async function refreshMajorNewsCache() {
try {
console.log('📰 Refreshing major news cache (BBC, CNN, Reuters, NYT, Guardian, NPR)...');
const majorNews = await getMajorNews().catch(err => {
console.error('Major news cache error:', err);
return [];
});
RSS_CACHE.majorNews.data = majorNews;
RSS_CACHE.majorNews.lastUpdated = Date.now();
console.log(`✅ Major news cache refreshed successfully (${majorNews ? majorNews.length : 0} articles)`);
} catch (error) {
console.error('❌ Error refreshing major news cache:', error);
}
}
// Initial cache load on server start
refreshCaches();
refreshMajorNewsCache();
// Breaking News API endpoint
app.get('/api/news', async (req, res) => {
try {
// Get top headlines from major news cache
const majorNewsCacheAge = Date.now() - RSS_CACHE.majorNews.lastUpdated;
let majorNews;
if (RSS_CACHE.majorNews.data && majorNewsCacheAge < MAJOR_NEWS_CACHE_DURATION) {
majorNews = RSS_CACHE.majorNews.data;
} else {
majorNews = await getMajorNews();
}
// Return top 3 headlines
const headlines = (majorNews || []).slice(0, 3).map(article => ({
title: article.title,
link: article.url
}));
res.json(headlines);
} catch (error) {
console.error('Breaking news API error:', error);
res.status(500).json({ error: 'Failed to fetch breaking news' });
}
});
// Refresh general caches every 20 minutes
setInterval(refreshCaches, CACHE_DURATION);
// Refresh major news cache every 10 minutes (BBC, CNN, Reuters, NYT, Guardian, NPR)
setInterval(refreshMajorNewsCache, MAJOR_NEWS_CACHE_DURATION);
// Start server
app.listen(PORT, '0.0.0.0', () => {
console.log(`🥯 Dear Bubbe web interface running on http://45.61.58.125:${PORT}`);
console.log(`📱 Mobile-optimized chat interface ready!`);
console.log(`🔄 RSS feed caching enabled (refreshes every 20 minutes)`);
});