diff --git a/server/.env.template b/server/.env.template index 11becb5..7fea79d 100644 --- a/server/.env.template +++ b/server/.env.template @@ -3,7 +3,21 @@ OPENAI_KEY=XXX GROQ_KEY=XXX GROK_KEY=XXX GOOGLE_KEY=XXX -OPEN_WEATHER_KEY=XXX -BING_KEY=XXX FINNHUB_KEY=XXX -WOLFRAM_KEY=XXX \ No newline at end of file +WOLFRAM_KEY=XXX + +# --- Web search provider --------------------------------------------------- +# Bing Web Search is retired (new Azure keys can no longer be created). +# Choose one: "searxng" (self-hosted, keyless), "brave", or legacy "bing". +# If SEARCH_PROVIDER is unset, the first configured provider is used +# (SearXNG > Brave > Bing). +SEARCH_PROVIDER=searxng +SEARXNG_BASE_URL=https://searxng.example.com +BRAVE_KEY=XXX +BING_KEY=XXX + +# --- Weather provider ------------------------------------------------------ +# OpenWeather One Call 3.0 needs a paid key; Open-Meteo is keyless. +# Choose "open-meteo" (default, keyless) or "openweather". +WEATHER_PROVIDER=open-meteo +OPEN_WEATHER_KEY=XXX diff --git a/server/src/services/search.ts b/server/src/services/search.ts index 60e04b0..b521991 100644 --- a/server/src/services/search.ts +++ b/server/src/services/search.ts @@ -1,41 +1,132 @@ import axios from "axios"; -const getKey = () => process.env.BING_KEY as string; +/** + * Provider-agnostic web search. + * + * Bing Web Search is being retired and new Azure keys can no longer be created, + * so the provider is now selectable. Set SEARCH_PROVIDER to one of: + * - "brave" : Brave Search API (needs BRAVE_KEY) + * - "searxng" : self-hosted SearXNG instance (needs SEARXNG_BASE_URL, no key) + * - "bing" : legacy Bing Web Search (needs BING_KEY) + * If SEARCH_PROVIDER is unset, the first provider with usable config is chosen, + * preferring self-hosted SearXNG, then Brave, then Bing. + */ -interface WebPagesItem { - name: string; +export interface SearchResult { + title: string; snippet: string; } -interface WebPages { - webSearchUrl: string; - totalEstimatedMatches: number; - value: WebPagesItem[]; -} +type SearchProvider = "brave" | "searxng" | "bing"; -interface SearchResponse { - webPages: WebPages; -} +const env = (name: string) => { + const v = process.env[name]; + return v && v !== "XXX" ? v : undefined; +}; -export const getSearchResults = async (query: string) => { - const searchClient = axios.create({ - baseURL: "https://api.bing.microsoft.com/v7.0", +const REQUIRED_ENV: Record = { + brave: "BRAVE_KEY", + searxng: "SEARXNG_BASE_URL", + bing: "BING_KEY", +}; + +const resolveProvider = (): SearchProvider => { + const explicit = process.env.SEARCH_PROVIDER?.toLowerCase(); + if (explicit) { + if (explicit !== "brave" && explicit !== "searxng" && explicit !== "bing") { + throw new Error( + `Unknown SEARCH_PROVIDER "${explicit}" — use brave, searxng, or bing.` + ); + } + if (!env(REQUIRED_ENV[explicit])) { + throw new Error( + `SEARCH_PROVIDER=${explicit} requires ${REQUIRED_ENV[explicit]} to be set.` + ); + } + return explicit; + } + if (env("SEARXNG_BASE_URL")) return "searxng"; + if (env("BRAVE_KEY")) return "brave"; + if (env("BING_KEY")) return "bing"; + throw new Error( + "No web search provider configured. Set SEARCH_PROVIDER and the matching " + + "credentials (BRAVE_KEY, SEARXNG_BASE_URL, or BING_KEY)." + ); +}; + +const MAX_RESULTS = 8; +const REQUEST_TIMEOUT_MS = 15000; + +const searchBrave = async (query: string): Promise => { + const client = axios.create({ + baseURL: "https://api.search.brave.com/res/v1", + timeout: REQUEST_TIMEOUT_MS, headers: { - "Ocp-Apim-Subscription-Key": getKey(), + Accept: "application/json", + "X-Subscription-Token": env("BRAVE_KEY") as string, }, }); - const res = await searchClient.get("/search", { - params: { - q: query, - mkt: "en-us", - }, + const res = await client.get("/web/search", { + params: { q: query, count: MAX_RESULTS }, }); - const { webPages } = res.data as SearchResponse; + const results = (res.data?.web?.results ?? []) as { + title: string; + description: string; + }[]; + + return results.map((r) => ({ title: r.title, snippet: r.description })); +}; + +const searchSearxng = async (query: string): Promise => { + const baseURL = (env("SEARXNG_BASE_URL") as string).replace(/\/+$/, ""); + const client = axios.create({ baseURL, timeout: REQUEST_TIMEOUT_MS }); + + const res = await client.get("/search", { + params: { q: query, format: "json" }, + }); + + const results = (res.data?.results ?? []) as { + title: string; + content: string; + }[]; + + return results + .slice(0, MAX_RESULTS) + .map((r) => ({ title: r.title, snippet: r.content })); +}; + +const searchBing = async (query: string): Promise => { + const client = axios.create({ + baseURL: "https://api.bing.microsoft.com/v7.0", + timeout: REQUEST_TIMEOUT_MS, + headers: { "Ocp-Apim-Subscription-Key": env("BING_KEY") as string }, + }); + + const res = await client.get("/search", { + params: { q: query, mkt: "en-us" }, + }); + + const results = (res.data?.webPages?.value ?? []) as { + name: string; + snippet: string; + }[]; + + return results.map((r) => ({ title: r.name, snippet: r.snippet })); +}; + +export const getSearchResults = async ( + query: string +): Promise => { + const provider = resolveProvider(); - return webPages.value.map((page) => ({ - title: page.name, - snippet: page.snippet, - })); + switch (provider) { + case "brave": + return searchBrave(query); + case "searxng": + return searchSearxng(query); + case "bing": + return searchBing(query); + } }; diff --git a/server/src/services/weather.ts b/server/src/services/weather.ts index 4befe27..d3c3240 100644 --- a/server/src/services/weather.ts +++ b/server/src/services/weather.ts @@ -19,60 +19,81 @@ export type ConditionName = | "Clear" | "Clouds"; -interface WeatherConditions { +export interface WeatherReport { + low: number; + high: number; + probOfPercip: number; + sunrise: Date; + sunset: Date; + currentTemp: number; + currentHumidity: number; + currentUvi: number; + currentWindSpeed: number; + currentConditions: ConditionName; +} + +/** + * Provider-agnostic weather. + * + * OpenWeather's One Call API 3.0 requires a paid subscription, so weather now + * defaults to keyless Open-Meteo. Set WEATHER_PROVIDER to: + * - "open-meteo" : keyless, no account required (default) + * - "openweather" : legacy One Call 3.0 (needs OPEN_WEATHER_KEY) + * If unset, OpenWeather is used only when OPEN_WEATHER_KEY is present. + */ + +type WeatherProvider = "open-meteo" | "openweather"; + +const env = (name: string) => { + const v = process.env[name]; + return v && v !== "XXX" ? v : undefined; +}; + +const resolveProvider = (): WeatherProvider => { + const explicit = process.env.WEATHER_PROVIDER?.toLowerCase(); + if (explicit === "open-meteo" || explicit === "openweather") return explicit; + return env("OPEN_WEATHER_KEY") ? "openweather" : "open-meteo"; +}; + +// --- OpenWeather (legacy) --- + +interface OwmConditions { id: number; main: ConditionName; } - -interface CurrentWeather { +interface OwmCurrent { temp: number; humidity: number; uvi: number; wind_speed: number; - weather: WeatherConditions[]; -} - -interface DailyTemp { - min: number; - max: number; + weather: OwmConditions[]; } - -interface DailyWeather { +interface OwmDaily { sunrise: number; sunset: number; - temp: DailyTemp; + temp: { min: number; max: number }; pop: number; } - -interface WeatherResponse { - current: CurrentWeather; - daily: DailyWeather[]; +interface OwmResponse { + current: OwmCurrent; + daily: OwmDaily[]; } -const getKey = () => process.env.OPEN_WEATHER_KEY as string; - -export const getWeather = async ( +const getOpenWeather = async ( lat: number, lng: number, - units?: WeatherUnits -) => { - const weatherClient = axios.create({ + units: WeatherUnits +): Promise => { + const client = axios.create({ baseURL: "https://api.openweathermap.org/data/3.0", - params: { - appid: getKey(), - }, + params: { appid: env("OPEN_WEATHER_KEY") as string }, }); - const res = await weatherClient.get("/onecall", { - params: { - lat, - lon: lng, - units: units || "imperial", - exclude: "minutely,hourly,alerts", - }, + const res = await client.get("/onecall", { + params: { lat, lon: lng, units, exclude: "minutely,hourly,alerts" }, }); - const { current, daily } = res.data as WeatherResponse; + const { current, daily } = res.data as OwmResponse; return { low: daily[0].temp.min, @@ -84,6 +105,89 @@ export const getWeather = async ( currentHumidity: current.humidity, currentUvi: current.uvi, currentWindSpeed: current.wind_speed, - currentConditions: current.weather[0]?.main + currentConditions: current.weather[0]?.main ?? "Clear", + }; +}; + +// --- Open-Meteo (keyless, default) --- + +interface OpenMeteoResponse { + current: { + temperature_2m: number; + relative_humidity_2m: number; + wind_speed_10m: number; + uv_index: number; + weather_code: number; + }; + daily: { + temperature_2m_min: number[]; + temperature_2m_max: number[]; + precipitation_probability_max: number[]; + sunrise: string[]; + sunset: string[]; }; +} + +// Map WMO weather codes to the OpenWeather-style condition names the app already knows. +const wmoToCondition = (code: number): ConditionName => { + if (code === 0) return "Clear"; + if (code <= 3) return "Clouds"; + if (code === 45 || code === 48) return "Fog"; + if (code >= 51 && code <= 57) return "Drizzle"; + if (code >= 61 && code <= 67) return "Rain"; + if (code >= 71 && code <= 77) return "Snow"; + if (code >= 80 && code <= 82) return "Rain"; + if (code === 85 || code === 86) return "Snow"; + if (code >= 95) return "Thunderstorm"; + return "Clouds"; +}; + +const getOpenMeteo = async ( + lat: number, + lng: number, + units: WeatherUnits +): Promise => { + const client = axios.create({ baseURL: "https://api.open-meteo.com/v1" }); + + const res = await client.get("/forecast", { + params: { + latitude: lat, + longitude: lng, + current: + "temperature_2m,relative_humidity_2m,wind_speed_10m,uv_index,weather_code", + daily: + "temperature_2m_min,temperature_2m_max,precipitation_probability_max,sunrise,sunset", + temperature_unit: units === "metric" ? "celsius" : "fahrenheit", + wind_speed_unit: units === "metric" ? "kmh" : "mph", + timezone: "UTC", + forecast_days: 1, + }, + }); + + const { current, daily } = res.data as OpenMeteoResponse; + + return { + low: daily.temperature_2m_min[0], + high: daily.temperature_2m_max[0], + // Open-Meteo reports probability as 0-100; the app expects a 0-1 fraction. + probOfPercip: (daily.precipitation_probability_max[0] ?? 0) / 100, + sunrise: new Date(daily.sunrise[0]), + sunset: new Date(daily.sunset[0]), + currentTemp: current.temperature_2m, + currentHumidity: current.relative_humidity_2m, + currentUvi: current.uv_index, + currentWindSpeed: current.wind_speed_10m, + currentConditions: wmoToCondition(current.weather_code), + }; +}; + +export const getWeather = async ( + lat: number, + lng: number, + units?: WeatherUnits +): Promise => { + const resolvedUnits = units || "imperial"; + return resolveProvider() === "openweather" + ? getOpenWeather(lat, lng, resolvedUnits) + : getOpenMeteo(lat, lng, resolvedUnits); };