Files
arah2/web/src/location/chat-pin.ts
T
araharah-executor 9552e7e4ab 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)
2026-10-22 23:37:58 +08:00

68 lines
2.8 KiB
TypeScript

/*
* 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");
}