Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions server/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
141 changes: 116 additions & 25 deletions server/src/services/search.ts
Original file line number Diff line number Diff line change
@@ -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<SearchProvider, string> = {
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<SearchResult[]> => {
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<SearchResult[]> => {
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<SearchResult[]> => {
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<SearchResult[]> => {
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);
}
};
172 changes: 138 additions & 34 deletions server/src/services/weather.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WeatherReport> => {
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,
Expand All @@ -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<WeatherReport> => {
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<WeatherReport> => {
const resolvedUnits = units || "imperial";
return resolveProvider() === "openweather"
? getOpenWeather(lat, lng, resolvedUnits)
: getOpenMeteo(lat, lng, resolvedUnits);
};