Turning IP Geolocation on changed nothing: every lookup failed, and the
tool output still said "no Location is set in preferences", which is a
different problem and sent the user looking in the wrong place.
ipapi.co no longer serves an unauthenticated caller. Verified directly:
no User-Agent -> HTTP 429 {"reason":"RateLimited",
"message":"Please sign up for a paid plan"}
browser UA -> HTTP 403, a Cloudflare "Just a moment..." challenge
So the provider is now ipwho.is, which answers 200 without a key. It
signals refusal with success: false on an HTTP 200, so the body is checked
as well as the status, and it nests the timezone under an object, which the
parser now accepts alongside a bare string. A named User-Agent is sent:
node's default is bare, and "who is asking" is the one thing a geolocation
provider has a fair interest in.
The failure branch is also fixed. A failed lookup and an unset preference
are different problems, and the wording no longer conflates them. The note
tells the model to pass the failure on, because the model is the only
channel that reaches the user and it had been paraphrasing the failure away.
Accuracy is documented honestly rather than as "city-level". An IP locates
the ISP's egress, not the person: measured from one machine, an IPv4 query
resolved to Cebu City and an IPv6 query moments later to Talisay in
Calabarzon, about 500km apart. That is now in the README, the preference
description and the tool output, and it is the argument for setting
Location instead.
Verified live with the exact request and field access the extension uses:
HTTP 200, "Talisay, Calabarzon (Region IV-A), Philippines", Asia/Manila.
344 tests, tsc, eslint, prettier, and vici build.
241 lines
7.8 KiB
TypeScript
241 lines
7.8 KiB
TypeScript
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.
|
|
*
|
|
* How accurate is it? Less than the name suggests, and the honest answer is that
|
|
* an IP address locates the point where the traffic left the ISP, not the person.
|
|
* Measured from one machine on Globe Telecom, an IPv4 query resolved to Cebu City
|
|
* and an IPv6 query, moments later, to Talisay in Calabarzon: about 500km apart.
|
|
* The cache is therefore not just a privacy measure, and the tool output calls
|
|
* the result a rough guess rather than a city.
|
|
*
|
|
* 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://ipwho.is/";
|
|
export const GEO_PROVIDER_NAME = "ipwho.is";
|
|
|
|
/** 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 the provider's response this needs, with every field optional so a shape change degrades instead of throwing. */
|
|
const ResponseSchema = z.object({
|
|
success: z.boolean().optional(),
|
|
message: z.string().optional(),
|
|
city: z.string().nullable().optional(),
|
|
region: z.string().nullable().optional(),
|
|
country: z.string().nullable().optional(),
|
|
latitude: z.number().nullable().optional(),
|
|
longitude: z.number().nullable().optional(),
|
|
// ipwho.is nests the timezone; some providers hand back a plain string.
|
|
timezone: z
|
|
.union([z.string(), z.object({ id: z.string().optional() })])
|
|
.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",
|
|
// Named rather than anonymous. Node's default UA is bare, and "who is
|
|
// asking" is the one thing a geolocation provider has a fair interest in.
|
|
"User-Agent": "lam-agent-vicinae",
|
|
},
|
|
});
|
|
} 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.success === false) {
|
|
return {
|
|
status: "failed",
|
|
message: data.message ?? `${GEO_PROVIDER_NAME} refused the lookup`,
|
|
};
|
|
}
|
|
|
|
const city = data.city ?? undefined;
|
|
const region = data.region ?? undefined;
|
|
const country = data.country ?? undefined;
|
|
const place = buildPlace({ city, region, country });
|
|
|
|
if (!place) {
|
|
return { status: "failed", message: `${GEO_PROVIDER_NAME} returned no usable place` };
|
|
}
|
|
|
|
const timezone = typeof data.timezone === "string" ? data.timezone : data.timezone?.id;
|
|
|
|
const resolved: GeoPlace = {
|
|
place,
|
|
city,
|
|
region,
|
|
country,
|
|
latitude: data.latitude ?? undefined,
|
|
longitude: data.longitude ?? undefined,
|
|
timezone: 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);
|
|
}
|