feat: opt-in IP geolocation for get_location
Adds the one thing the extension could not answer before: roughly where the user actually is, rather than which zone their clock is set to. - `lib/geo.ts` resolves a place from ipapi.co. Chosen because it is HTTPS and needs no API key; ip-api.com, the other obvious free option, is HTTP-only on its free tier, which is not a place to send an IP address. - Off by default, and skipped entirely when the user has set Location: the better answer is already there, so spending their privacy on a worse one would be indefensible. `ipPlace` is only wired into ToolContext when the preference is on, so an off setting cannot reach the network at all. - Cached for 6 hours. A city does not change between questions, and every lookup hands the provider another copy of the address. Set Up Lam Agent clears it on re-test, alongside the vision cache. - Failures are returned, not swallowed: a rate limit or an offline machine says so in the tool output while the region answer stays available. The provider signals some failures with error: true and HTTP 200, so the body is checked as well as the status. - The tool output always names the provider and calls the result city-level rather than exact. It also flags an IP timezone that disagrees with the device clock, which usually means a VPN. - The README claimed no geolocation service ever saw the user's address. That is now conditional, so the section was rewritten rather than left to quietly become untrue. Deliberately not done: GPS. CoreLocation has no CLI on macOS and GeoClue's usual provider resolves nearby Wi-Fi through a Mozilla service anyway. Verified: 341 tests (25 new), tsc, eslint, prettier, and vici build. I did not make a live API call, because doing so would send this machine's IP to the provider as the exact flow being opted into.
This commit is contained in:
+220
@@ -0,0 +1,220 @@
|
||||
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<string>();
|
||||
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<Response> {
|
||||
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<GeoPlace | undefined> {
|
||||
try {
|
||||
const raw = await LocalStorage.getItem<string>(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<void> {
|
||||
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<GeoLookup> {
|
||||
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<void> {
|
||||
await LocalStorage.removeItem(STORAGE_KEY).catch(() => undefined);
|
||||
}
|
||||
Reference in New Issue
Block a user