import { LocalStorage } from "@vicinae/api"; import { z } from "zod"; import { errorMessage } from "./utils"; /** * IP geolocation, opt-in. * * This is the one place in the extension that tells a third party anything about * the user. The README said no geolocation service ever saw the user's address, * and this is what makes that conditional, so it is off unless asked for, it is * skipped entirely when the user has already said where they are, and the tool * output names the provider so the model can qualify what it repeats. * * The answer is cached for hours rather than looked up per call. A city does not * change between questions, and every lookup hands the provider another copy of * the user's IP address. * * Imports Vicinae for `LocalStorage` only, which is the same footing as * `sessions.ts` and `vision.ts`. Everything else about it is plain `fetch`, so * the parsing and the cache are unit tested without a runtime. */ /** Where the lookup goes. Swapping providers means changing this and the parser below. */ export const GEO_PROVIDER = "https://ipapi.co/json/"; export const GEO_PROVIDER_NAME = "ipapi.co"; /** How long a resolved place is trusted. Long enough to not re-leak the IP on every question. */ export const GEO_TTL_MS = 6 * 60 * 60 * 1000; /** An IP lookup is a nice-to-have, so it must never hold up an answer. */ const GEO_TIMEOUT_MS = 8_000; const STORAGE_KEY = "lam.geo"; export interface GeoPlace { /** Ready to show, e.g. "Cebu City, Central Visayas, Philippines". */ place: string; city?: string; region?: string; country?: string; latitude?: number; longitude?: number; timezone?: string; /** Named in the tool output, so the model can say where this came from. */ source: string; fetchedAt: number; } export type GeoLookup = { status: "ok"; place: GeoPlace } | { status: "failed"; message: string }; export interface GeoOptions { signal?: AbortSignal; /** Injected so the cache is deterministic under test. */ now?: number; /** Ignores a cached answer. */ refresh?: boolean; } const CachedSchema = z.object({ place: z.string(), city: z.string().optional(), region: z.string().optional(), country: z.string().optional(), latitude: z.number().optional(), longitude: z.number().optional(), timezone: z.string().optional(), source: z.string(), fetchedAt: z.number(), }); /** The subset of ipapi.co's response this needs, with every field optional so a shape change degrades instead of throwing. */ const ResponseSchema = z.object({ error: z.unknown().optional(), reason: z.string().optional(), message: z.string().optional(), city: z.string().nullable().optional(), region: z.string().nullable().optional(), country_name: z.string().nullable().optional(), latitude: z.number().nullable().optional(), longitude: z.number().nullable().optional(), timezone: z.string().nullable().optional(), }); /** * Joins the parts of a place, dropping duplicates. * * Providers often return the city twice (once as `city`, once as `region`) for * city-states and single-city regions, and "Singapore, Singapore, Singapore" * reads like a bug. */ export function buildPlace(parts: { city?: string; region?: string; country?: string }): string { const seen = new Set(); const kept: string[] = []; for (const part of [parts.city, parts.region, parts.country]) { const trimmed = part?.trim(); const key = trimmed?.toLowerCase(); if (!trimmed || !key || seen.has(key)) continue; seen.add(key); kept.push(trimmed); } return kept.join(", "); } /** Aborts on either the caller's signal or a timeout, whichever comes first. */ async function fetchWithTimeout( url: string, signal: AbortSignal | undefined, timeoutMs: number, ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const forward = () => controller.abort(); signal?.addEventListener("abort", forward, { once: true }); try { return await fetch(url, { signal: controller.signal, headers: { Accept: "application/json" } }); } finally { clearTimeout(timer); signal?.removeEventListener("abort", forward); } } async function readCache(now: number): Promise { try { const raw = await LocalStorage.getItem(STORAGE_KEY); if (!raw) return undefined; const parsed = CachedSchema.safeParse(JSON.parse(raw)); if (!parsed.success) return undefined; if (now - parsed.data.fetchedAt > GEO_TTL_MS) return undefined; return parsed.data; } catch { return undefined; } } async function writeCache(place: GeoPlace): Promise { await LocalStorage.setItem(STORAGE_KEY, JSON.stringify(place)).catch(() => undefined); } /** * Resolves the user's rough place from their IP address. * * Never throws: a lookup failure leaves the region-level answer intact, and the * message is passed on so the model can say what went wrong rather than quietly * presenting a coarser answer as though it were the same thing. */ export async function ipPlace(options: GeoOptions = {}): Promise { const now = options.now ?? Date.now(); if (!options.refresh) { const cached = await readCache(now); if (cached) return { status: "ok", place: cached }; } let response: Response; try { response = await fetchWithTimeout(GEO_PROVIDER, options.signal, GEO_TIMEOUT_MS); } catch (thrown) { return { status: "failed", message: `could not reach ${GEO_PROVIDER_NAME} (${errorMessage(thrown)})` }; } if (!response.ok) { return { status: "failed", message: `${GEO_PROVIDER_NAME} answered HTTP ${response.status}` }; } let payload: unknown; try { payload = await response.json(); } catch { return { status: "failed", message: `${GEO_PROVIDER_NAME} did not answer with JSON` }; } const parsed = ResponseSchema.safeParse(payload); if (!parsed.success) { return { status: "failed", message: `${GEO_PROVIDER_NAME} answered in an unexpected shape` }; } const data = parsed.data; if (data.error) { return { status: "failed", message: data.message ?? data.reason ?? `${GEO_PROVIDER_NAME} refused the lookup`, }; } const city = data.city ?? undefined; const region = data.region ?? undefined; const country = data.country_name ?? undefined; const place = buildPlace({ city, region, country }); if (!place) { return { status: "failed", message: `${GEO_PROVIDER_NAME} returned no usable place` }; } const resolved: GeoPlace = { place, city, region, country, latitude: data.latitude ?? undefined, longitude: data.longitude ?? undefined, timezone: data.timezone ?? undefined, source: GEO_PROVIDER_NAME, fetchedAt: now, }; await writeCache(resolved); return { status: "ok", place: resolved }; } /** Drops the cached place, so a move or a provider change is not pinned for hours. */ export async function forgetGeoPlace(): Promise { await LocalStorage.removeItem(STORAGE_KEY).catch(() => undefined); }