/* * 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 { 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 } = {}, ): Promise { 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 { const res = await fetch("/api/location-fallback", { method: "POST", headers: { authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error("location-fallback-request-failed"); }