import axios from 'axios'; import crypto from 'crypto'; import { TrackingProvider } from '../../types/gen/types.gen'; import { isBoltSimulated, getSimulatedBoltOrder } from './boltSimulator'; /** * Adaptéry rozvozových služeb pro sledování objednávek. * * Každá služba umí ze sdílecího odkazu vytáhnout svůj kód a dotázat se svého * (nedokumentovaného) veřejného API. Výsledek normalizují do tvaru TrackedOrder, * se kterým už dál pracuje jen scheduler v orderTracking.ts. */ /** Znormalizovaný stav sledované objednávky — společný jmenovatel všech služeb. */ export interface TrackedOrder { /** Raw stav objednávky ze služby (Bolt order_state, Wolt status). */ orderState: string; /** Raw stav kurýra, pokud ho služba poskytuje. */ courierState?: string; /** Očekávaný čas doručení ve formátu HH:MM, pokud ho lze určit. */ deliveryAt?: string; } export interface DeliveryTracker { provider: TrackingProvider; /** Lidský název služby (do UI hlášek a logů). */ label: string; /** Vytáhne kód sledování ze vstupu, nebo null, pokud vstup službě nepatří. */ extractCode(input: string): string | null; /** Dotáže se API služby. Vrátí null, pokud objednávka už neexistuje. */ poll(code: string): Promise; } const BOLT_POLLING_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getOrderPolling'; const BOLT_SHARE_HOST = 'bolt.eu'; const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i; const WOLT_TRACKING_URL = 'https://consumer-api.wolt.com/order-tracking-api/v1/details/tracking-code/track/'; const WOLT_SHARE_HOST = 'wolt.com'; /** Wolt tracking code je base64url (v praxi 22 znaků); délku bereme s rezervou. */ const WOLT_CODE_REGEX = /^[A-Za-z0-9_-]{20,32}$/; /** Identifikátor zařízení pro Bolt API — generuje se jednou na proces. */ const DEVICE_ID = crypto.randomUUID(); /** Vrátí poslední neprázdný segment cesty URL (u odkazu bez URL tvaru vrátí null). */ function lastPathSegment(input: string): string | null { let url: URL; try { url = new URL(input); } catch { return null; } const segments = url.pathname.split('/').filter(Boolean); return segments[segments.length - 1] ?? null; } /** Patří hostitel odkazu dané službě (včetně subdomén)? */ function isHost(input: string, host: string): boolean { try { const hostname = new URL(input).hostname.toLowerCase(); return hostname === host || hostname.endsWith(`.${host}`); } catch { return false; } } /** Spočítá očekávaný čas doručení (teď + sekundy) ve formátu HH:MM. */ export function computeDeliveryHHMM(seconds: number, now: Date = new Date()): string { const eta = new Date(now.getTime() + seconds * 1000); return `${String(eta.getHours()).padStart(2, '0')}:${String(eta.getMinutes()).padStart(2, '0')}`; } /** * Zformátuje absolutní ISO čas na HH:MM. Wolt v odpovědi posílá i časovou zónu * objednávky, takže se čas zobrazí správně i kdyby server běžel v jiné zóně. */ export function formatEtaHHMM(iso: string, timeZone?: string): string | undefined { const eta = new Date(iso); if (Number.isNaN(eta.getTime())) return undefined; const options: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }; try { return new Intl.DateTimeFormat('cs-CZ', { ...options, timeZone }).format(eta); } catch { // Neznámá zóna z API — spadneme na lokální čas serveru (TZ=Europe/Prague) return new Intl.DateTimeFormat('cs-CZ', options).format(eta); } } interface BoltOrder { order_id?: number; order_state: string; expected_time_to_client_in_seconds?: number; courier?: { state?: string } | null; } /** Dotáže se veřejného Bolt API na stav sdílené objednávky. Vrátí null, pokud objednávka už neexistuje. */ async function pollBoltOrder(token: string): Promise { // DEV simulace: simulované tokeny obsluhuje boltSimulator místo reálného Bolt API. // V produkci je registr vždy prázdný, takže se sem nikdy nedostane. if (isBoltSimulated(token)) { return getSimulatedBoltOrder(token); } const res = await axios.post(BOLT_POLLING_URL, { token }, { params: { version: 'FW.1.113', language: 'cs-CZ', country: 'cz', device_name: 'web', device_os_version: 'web', deviceType: 'web', session_id: DEVICE_ID, distinct_id: `$device:${DEVICE_ID}`, deviceId: DEVICE_ID, }, headers: { 'Content-Type': 'application/json' }, timeout: 10_000, }); if (res.data?.code !== 0) { throw new Error(`Bolt API vrátilo kód ${res.data?.code}: ${res.data?.message}`); } return res.data?.data?.orders?.[0] ?? null; } const boltTracker: DeliveryTracker = { provider: TrackingProvider.BOLT, label: 'Bolt Food', /** * Přijme sdílecí URL Bolt Food (https://food.bolt.eu/sharedActiveOrder/) * nebo samotný token (64 hex znaků). */ extractCode(input) { if (BOLT_TOKEN_REGEX.test(input)) return input; if (!isHost(input, BOLT_SHARE_HOST)) return null; const last = lastPathSegment(input); return last && BOLT_TOKEN_REGEX.test(last) ? last : null; }, async poll(code) { const order = await pollBoltOrder(code); if (!order) return null; const seconds = order.expected_time_to_client_in_seconds; return { orderState: order.order_state || '', courierState: order.courier?.state || undefined, deliveryAt: typeof seconds === 'number' ? computeDeliveryHHMM(seconds) : undefined, }; }, }; /** Odpověď Wolt tracking API (jen pole, která používáme). */ interface WoltTrackingDetails { status?: string; delivery_eta?: string | null; pickup_eta?: string | null; couriers?: WoltCourier[]; timezone?: string; } interface WoltCourier { /** Kurýr veze nějakou objednávku (nemusí to být ta naše — viz is_delivering_other_order). */ is_delivering?: boolean; /** Kurýr právě doručuje cizí objednávku, tu naši teprve vyzvedne nebo veze až po ní. */ is_delivering_other_order?: boolean; } /** * Wolt nemá stav „vyzvednuto" — status zůstává celou dobu doručování na hodnotě * ready (ta popisuje jen podnik: jídlo je hotové). Že kurýr jídlo skutečně veze, * poznáme podle pickup_eta: dokud vyzvednutí teprve nastane, je v budoucnosti; * po vyzvednutí se ustálí na skutečném čase a zůstane v minulosti (na rozdíl od * delivery_eta, který se dál přepočítává). * * Odvozené stavy kurýra: * - 'assigned' kurýr přiřazen, jídlo ještě nevyzvedl * - 'delivering' veze naši objednávku k nám * - 'delivering_other' veze naši objednávku, ale nejdřív doručuje cizí (Wolt * spojuje objednávky — kurýr se pak na mapě vzdaluje) */ function woltCourierState(details: WoltTrackingDetails, now: number = Date.now()): string | undefined { const courier = details.couriers?.[0]; if (!courier) return undefined; const pickupEta = details.pickup_eta ? new Date(details.pickup_eta).getTime() : NaN; const pickedUp = !Number.isNaN(pickupEta) && pickupEta <= now; if (!courier.is_delivering || !pickedUp) return 'assigned'; return courier.is_delivering_other_order ? 'delivering_other' : 'delivering'; } const woltTracker: DeliveryTracker = { provider: TrackingProvider.WOLT, label: 'Wolt', /** * Přijme odkaz na sledování Wolt (https://track.wolt.com/, případně * s jazykovým segmentem /en/) nebo samotný tracking code. */ extractCode(input) { if (WOLT_CODE_REGEX.test(input)) return input; if (!isHost(input, WOLT_SHARE_HOST)) return null; const last = lastPathSegment(input); return last && WOLT_CODE_REGEX.test(last) ? last : null; }, async poll(code) { let details: WoltTrackingDetails; try { const res = await axios.get(`${WOLT_TRACKING_URL}${encodeURIComponent(code)}`, { headers: { Accept: 'application/json', 'Accept-Language': 'cs', // Wolt API odpovídá jen na dotazy z jeho tracking stránky Referer: 'https://track.wolt.com/', Origin: 'https://track.wolt.com', }, timeout: 10_000, }); details = res.data ?? {}; } catch (e) { // Neznámý/expirovaný kód → objednávka už neexistuje (stejně jako u Boltu) if (axios.isAxiosError(e) && e.response?.status === 404) return null; throw e; } if (!details.status) { throw new Error('Wolt API vrátilo odpověď bez stavu objednávky'); } return { orderState: details.status, courierState: woltCourierState(details), deliveryAt: details.delivery_eta ? formatEtaHHMM(details.delivery_eta, details.timezone) : undefined, }; }, }; export const TRACKERS: Record = { [TrackingProvider.BOLT]: boltTracker, [TrackingProvider.WOLT]: woltTracker, }; /** * Rozpozná službu podle sdílecího odkazu (nebo samotného kódu) a vytáhne z něj * kód sledování. Vrátí null, pokud vstup nepatří žádné podporované službě. */ export function extractTracking(input: string): { provider: TrackingProvider; code: string } | null { const trimmed = input.trim(); if (!trimmed) return null; for (const tracker of Object.values(TRACKERS)) { const code = tracker.extractCode(trimmed); if (code) return { provider: tracker.provider, code }; } return null; }