feat(03-02): Fix type + three source adapters (telegram/webview/chat-pin) — GEO-01 wrappers
- types.ts: the shared Fix shape + LocationState machine (type-only, D-04/D-05/D-07) - telegram.ts: version-gated LocationManager init/getLocation, hard 8s timeout (Pitfall 3), null-safe telegramLocationSupported (plain browser/Desktop = false, not TypeError), looksStale + haversine + the Android nudge (issue #56 navbar read DISCARDED, then retry) - webview.ts: one-shot geolocation read, maximumAge: 0 (freshness axis), 8s timeout - chat-pin.ts: bounded 60s poll of GET /api/location (Bearer token) + POST /api/location-fallback throw-on-fail so the ASK VIA CHAT handler can revalidate (D-03) - telegram/webapp.ts created early (Rule 3: task 1 imports TelegramWebAppLike from task 3's file; declared the narrow hand-rolled interface now, verbatim per plan)
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* chat-pin.ts — source 3: the chat-keyboard pin relay (GEO-02).
|
||||||
|
* -----------------------------------------------------------
|
||||||
|
* Telegram has no push channel from chat → WebView, so the app POLLS
|
||||||
|
* GET /api/location with its memory-held Bearer token until the user
|
||||||
|
* taps the request_location keyboard in chat and the bot relays the
|
||||||
|
* pin into TTL KV.
|
||||||
|
*
|
||||||
|
* The poll is BOUNDED (60s): AWAITING PIN can never persist forever —
|
||||||
|
* the bounded poll is what returns the UI to the denied card instead
|
||||||
|
* of wedging (RESEARCH §Pattern 3). requestChatFallback (the ASK VIA
|
||||||
|
* CHAT trigger) POSTs to /api/location-fallback — implemented in plan
|
||||||
|
* 03-01 task 3 Step D as handleLocationFallback. The pathname is a
|
||||||
|
* CONTRACT with 03-01, not a guess; if 03-01 ever renames the route,
|
||||||
|
* both plans change together. The route is gated identically to the
|
||||||
|
* poll: a 401 means the session expired and the caller must
|
||||||
|
* revalidate (D-03) via createSession, not retry blindly — so this
|
||||||
|
* function THROWS rather than silently leaving the user on a card
|
||||||
|
* whose button appears to do nothing.
|
||||||
|
*/
|
||||||
|
import type { Fix } from "./types.ts";
|
||||||
|
|
||||||
|
export const CHAT_PIN_POLL_MS = 2000;
|
||||||
|
export const CHAT_PIN_TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
|
export async function fetchChatPin(token: string): Promise<Fix | null> {
|
||||||
|
const res = await fetch("/api/location", {
|
||||||
|
method: "GET",
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null; // 401 (expired session) and 405 both read as "no pin yet"; caller revalidates on the denied path
|
||||||
|
const body: unknown = await res.json();
|
||||||
|
const pin = (body as { pin?: { latitude?: number; longitude?: number; horizontal_accuracy?: number | null } }).pin;
|
||||||
|
if (!pin || typeof pin.latitude !== "number" || typeof pin.longitude !== "number") return null;
|
||||||
|
return {
|
||||||
|
lat: pin.latitude,
|
||||||
|
lng: pin.longitude,
|
||||||
|
accuracyM: pin.horizontal_accuracy ?? null,
|
||||||
|
headingDeg: null,
|
||||||
|
ts: Date.now(),
|
||||||
|
source: "chat-pin",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollChatPin(
|
||||||
|
token: string,
|
||||||
|
options: { timeoutMs?: number; pollMs?: number; sleep?: (ms: number) => Promise<void> } = {},
|
||||||
|
): Promise<Fix | null> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? CHAT_PIN_TIMEOUT_MS;
|
||||||
|
const pollMs = options.pollMs ?? CHAT_PIN_POLL_MS;
|
||||||
|
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const fix = await fetchChatPin(token);
|
||||||
|
if (fix !== null) return fix;
|
||||||
|
await sleep(pollMs);
|
||||||
|
}
|
||||||
|
return null; // bounded — returns to the denied card, never wedges AWAITING PIN
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestChatFallback(token: string): Promise<void> {
|
||||||
|
const res = await fetch("/api/location-fallback", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("location-fallback-request-failed");
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/*
|
||||||
|
* telegram.ts — source 1: the Telegram LocationManager wrapper (GEO-01).
|
||||||
|
* -------------------------------------------------------------------
|
||||||
|
* Version-gated (Bot API 8.0+), init()-before-getLocation(), NULL as the
|
||||||
|
* one denial signal (no error object exists), and a hard wrapper timeout
|
||||||
|
* because the API documents no timeout — without it the boot choreography
|
||||||
|
* wedges in ACQUIRING forever on a wedged client (RESEARCH Pitfall 3).
|
||||||
|
*
|
||||||
|
* Staleness (the Android static-fix, Telegram-Mini-Apps issue #56) is a
|
||||||
|
* SEPARATE axis from availability: two byte-identical successive fixes
|
||||||
|
* mean the native cache never refreshed. `looksStale` detects it and
|
||||||
|
* `nudgeAndRetryTelegram` routes it to recovery — one discarded webview
|
||||||
|
* read (the nudge) refreshes the native cache so the NEXT telegram read
|
||||||
|
* returns fresh coordinates — never to a "successful" stale pin.
|
||||||
|
*/
|
||||||
|
import type { Fix } from "./types.ts";
|
||||||
|
import type { TelegramWebAppLike } from "../telegram/webapp.ts";
|
||||||
|
|
||||||
|
/** NULL-SAFE by contract: `getWebApp()` returns `TelegramWebAppLike | null`, so
|
||||||
|
* this must accept null and answer false — Desktop Telegram and the plain
|
||||||
|
* browser both have no WebApp object at all (issue #77), and a TypeError here
|
||||||
|
* would crash the boot before the chain can fall through to the next link. */
|
||||||
|
export function telegramLocationSupported(wa: TelegramWebAppLike | null): boolean {
|
||||||
|
if (!wa) return false;
|
||||||
|
return typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("8.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestTelegramFix(
|
||||||
|
wa: TelegramWebAppLike,
|
||||||
|
options: { timeoutMs?: number; nowMs?: () => number } = {},
|
||||||
|
): Promise<Fix | null> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? 8000;
|
||||||
|
const nowMs = options.nowMs ?? Date.now;
|
||||||
|
const lm = wa.LocationManager;
|
||||||
|
if (!lm) return null; // field absent on sub-8.0 clients — denial signal, not an error
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (data: unknown | null): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!data) {
|
||||||
|
// getLocation calls back with null when access was NOT granted —
|
||||||
|
// the whole denial class. The chain advances; nothing throws.
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loc = data as { latitude: number; longitude: number; horizontal_accuracy?: number; course?: number };
|
||||||
|
resolve({
|
||||||
|
lat: loc.latitude,
|
||||||
|
lng: loc.longitude,
|
||||||
|
accuracyM: loc.horizontal_accuracy ?? null,
|
||||||
|
headingDeg: loc.course ?? null,
|
||||||
|
ts: nowMs(),
|
||||||
|
source: "telegram",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// The hard timeout (Pitfall 3): init()/getLocation() have no documented
|
||||||
|
// timeout and no error channel — this is the only guarantee the boot
|
||||||
|
// choreography ever leaves ACQUIRING.
|
||||||
|
const timer = setTimeout(() => finish(null), timeoutMs);
|
||||||
|
if (lm.isInited) lm.getLocation(finish);
|
||||||
|
else lm.init(() => lm.getLocation(finish));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android cache-detection (issue #56): a device that has MOVED but returns
|
||||||
|
* byte-identical coordinates to sub-centimetre precision is the signature
|
||||||
|
* of the never-refreshing native cache. Only telegram-vs-telegram pairs
|
||||||
|
* qualify — different sources bounding identically is coincidence, not stale.
|
||||||
|
*/
|
||||||
|
export function looksStale(prev: Fix | null, next: Fix): boolean {
|
||||||
|
if (prev === null || prev.source !== "telegram" || next.source !== "telegram") return false;
|
||||||
|
return haversineMeters(prev, next) < 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function haversineMeters(a: Fix, b: Fix): number {
|
||||||
|
const R = 6371008.8;
|
||||||
|
const toRad = (d: number): number => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(b.lat - a.lat);
|
||||||
|
const dLng = toRad(b.lng - a.lng);
|
||||||
|
const s =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Android stale-fix recovery: read telegram once; if the fix is
|
||||||
|
* byte-identical to the last accepted one, perform the nudge — ONE
|
||||||
|
* navigator.geolocation read whose result is DISCARDED (a WebView read
|
||||||
|
* refreshes the native cache the NEXT telegram read then returns, per
|
||||||
|
* issue #56) — then retry telegram. Degrades to "telegram still wins or
|
||||||
|
* the caller falls through" — never crashes, never returns the stale fix.
|
||||||
|
*/
|
||||||
|
export async function nudgeAndRetryTelegram(
|
||||||
|
wa: TelegramWebAppLike,
|
||||||
|
readWebviewFix: (o?: { timeoutMs?: number }) => Promise<Fix | null>,
|
||||||
|
lastAccepted: Fix | null,
|
||||||
|
options: { timeoutMs?: number; nowMs?: () => number } = {},
|
||||||
|
): Promise<Fix | null> {
|
||||||
|
const first = await requestTelegramFix(wa, options);
|
||||||
|
if (first === null) return null;
|
||||||
|
if (!looksStale(lastAccepted, first)) return first;
|
||||||
|
await readWebviewFix({ timeoutMs: 5000 }); // the nudge — result DISCARDED (issue #56)
|
||||||
|
return requestTelegramFix(wa, options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* types.ts — the phase's shared location contract (D-04, D-05, D-07).
|
||||||
|
* ---------------------------------------------------------------
|
||||||
|
* The single `Fix` shape every acquisition source normalizes to:
|
||||||
|
* `LocationData` (Telegram), `GeolocationPosition` (WebView), and the
|
||||||
|
* bot-relayed chat pin all reduce to these six fields. Freshness lives
|
||||||
|
* IN the fix (ts) — consumers measure age rather than trusting sources
|
||||||
|
* to be fresh (RESEARCH Pitfall 2, the Android static-fix).
|
||||||
|
*
|
||||||
|
* Type-only module: no runtime values that close over Fix instances,
|
||||||
|
* no storage, no imports. D-07: a Fix is a value in memory; nothing
|
||||||
|
* here (or anywhere downstream) may write it to a persistent store.
|
||||||
|
*
|
||||||
|
* The machine states mirror UI-SPEC §Status Strip: validating /
|
||||||
|
* acquiring / live / stale(signal-lost) / denied, plus awaiting-pin —
|
||||||
|
* the D-04 chat-keyboard wait surface (D-06: deliberate card, never
|
||||||
|
* a silent banner).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FixSource = "telegram" | "webview" | "chat-pin";
|
||||||
|
|
||||||
|
export type Fix = {
|
||||||
|
/** degrees — WGS-84 as the sources report them */
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
/** horizontal accuracy in metres; null when the source did not report one */
|
||||||
|
accuracyM: number | null;
|
||||||
|
/** heading in degrees or null — the UI hides the cone on null (no guess) */
|
||||||
|
headingDeg: number | null;
|
||||||
|
/** ms epoch of the ACCEPTANCE moment — this drives FIX Ns AGO telemetry */
|
||||||
|
ts: number;
|
||||||
|
source: FixSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocationState =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "validating" }
|
||||||
|
| { kind: "acquiring"; since: number }
|
||||||
|
| { kind: "awaiting-pin"; since: number }
|
||||||
|
| { kind: "live"; fix: Fix }
|
||||||
|
| { kind: "stale"; fix: Fix }
|
||||||
|
| { kind: "denied"; reason: "no-permission" | "unsupported" | "timeout" };
|
||||||
|
|
||||||
|
/** KL map default (AGENTS.md region focus: Kuala Lumpur metro). [lng, lat]. */
|
||||||
|
export const KL_CENTER: [number, number] = [101.6958, 3.1513];
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* webview.ts — source 2: navigator.geolocation inside the WebView, and the
|
||||||
|
* Android freshness nudge's reading hand (D-04, D-05).
|
||||||
|
* -------------------------------------------------------------------
|
||||||
|
* maximumAge: 0 is load-bearing — D-05 requires the freshest fix, and a
|
||||||
|
* cached position would silently defeat the freshness axis (the whole
|
||||||
|
* point of the nudge is to force a NEW read, not to re-read a cache).
|
||||||
|
* Success AND timeout both resolve; the error callback resolves null so
|
||||||
|
* the chain advances instead of rejecting. Timeout present for the same
|
||||||
|
* reason as the telegram wrapper: wedging ACQUIRING is never acceptable.
|
||||||
|
*/
|
||||||
|
import type { Fix } from "./types.ts";
|
||||||
|
|
||||||
|
export function webviewLocationSupported(): boolean {
|
||||||
|
return typeof navigator !== "undefined" && typeof navigator.geolocation?.getCurrentPosition === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readWebviewFix(options: { timeoutMs?: number; nowMs?: () => number } = {}): Promise<Fix | null> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? 8000;
|
||||||
|
if (!webviewLocationSupported()) return Promise.resolve(null); // Desktop Telegram, plain browsers without permission
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const done = (fix: Fix | null): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(fix);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => done(null), timeoutMs);
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) =>
|
||||||
|
done({
|
||||||
|
lat: pos.coords.latitude,
|
||||||
|
lng: pos.coords.longitude,
|
||||||
|
accuracyM: pos.coords.accuracy ?? null,
|
||||||
|
headingDeg: pos.coords.heading ?? null,
|
||||||
|
ts: (options.nowMs ?? Date.now)(),
|
||||||
|
source: "webview",
|
||||||
|
}),
|
||||||
|
() => done(null), // denial / unavailability = null; the chain's normal signal (no error object escapes)
|
||||||
|
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* webapp.ts — the ONE narrow typed accessor for window.Telegram.WebApp
|
||||||
|
* (D-11, RESEARCH §Standard Stack note).
|
||||||
|
* -------------------------------------------------------------------
|
||||||
|
* `@types/telegram-web-app` is deliberately NOT a dependency (plan 03-01
|
||||||
|
* dropped it): this hand-rolled `TelegramWebAppLike` IS the contract —
|
||||||
|
* every test fake, the D-11 harness mock, and the production clients
|
||||||
|
* satisfy exactly these ~12 members. The official 500-member `WebApp`
|
||||||
|
* type would force each fake to stub ~50 members.
|
||||||
|
*
|
||||||
|
* getWebApp() may honestly return NULL (plain browser, no Telegram) —
|
||||||
|
* that null is the contract the location chain must handle. All
|
||||||
|
* `Telegram.WebApp.*` calls must originate from the app's own document
|
||||||
|
* (Bot API 10.2 origin restriction).
|
||||||
|
*/
|
||||||
|
export interface TelegramWebAppLike {
|
||||||
|
initData: string;
|
||||||
|
colorScheme?: "light" | "dark";
|
||||||
|
themeParams?: { header_bg_color?: string };
|
||||||
|
version?: string;
|
||||||
|
platform?: string;
|
||||||
|
safeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
||||||
|
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
||||||
|
isVersionAtLeast(version: string): boolean;
|
||||||
|
close(): void;
|
||||||
|
onEvent?(event: string, cb: () => void): void;
|
||||||
|
offEvent?(event: string, cb: () => void): void;
|
||||||
|
BackButton?: { show(): void; hide(): void; onClick(cb: () => void): void; offClick(cb: () => void): void };
|
||||||
|
LocationManager?: {
|
||||||
|
isInited: boolean;
|
||||||
|
isLocationAvailable?: boolean;
|
||||||
|
isAccessRequested?: boolean;
|
||||||
|
isAccessGranted?: boolean;
|
||||||
|
init(cb: () => void): void;
|
||||||
|
getLocation(cb: (data: unknown | null) => void): void;
|
||||||
|
openSettings?(): void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebApp(): TelegramWebAppLike | null {
|
||||||
|
const w = window as unknown as { Telegram?: { WebApp?: TelegramWebAppLike } };
|
||||||
|
return w.Telegram?.WebApp ?? null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user