diff --git a/README.md b/README.md index b51c7a8..4256419 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,7 @@ runline exec 'return await github.user.listRepos({ username: "torvalds" })' | **vero** | 8 | user, event | `VERO_AUTH_TOKEN` | | **vonage** | 1 | sms | `VONAGE_API_KEY`, `VONAGE_API_SECRET` | | **wekan** | 24 | board, list, card, cardComment, checklist, checklistItem | `WEKAN_URL`, `WEKAN_TOKEN` | +| **wolt** | 13 | search, nearby, venue, menu, item, estimate, connect, refresh, whoami, addresses, order_create, order_status, order_cancel | | | **woocommerce** | 15 | product, order, customer | `WOOCOMMERCE_URL`, `WOOCOMMERCE_CONSUMER_KEY`, `WOOCOMMERCE_CONSUMER_SECRET` | | **wordpress** | 15 | post, page, user | `WORDPRESS_URL`, `WORDPRESS_USERNAME`, `WORDPRESS_PASSWORD` | | **xai** | 1 | image | `XAI_API_KEY` | diff --git a/packages/runline-plugins/ACCESS_CHECKLIST.md b/packages/runline-plugins/ACCESS_CHECKLIST.md index 810ccaf..e011aba 100644 --- a/packages/runline-plugins/ACCESS_CHECKLIST.md +++ b/packages/runline-plugins/ACCESS_CHECKLIST.md @@ -211,6 +211,7 @@ Status values: `pending`, `in-progress`, `done`. | `vero` | done | Annotated 8 runtime actions: 8 write (user create/alias/unsubscribe/resubscribe/delete/addTags/removeTags and event track). All actions mutate user state or track events in Vero. | | `vonage` | done | Annotated 1 action: 1 write (`sms.send`) because it sends SMS messages through Vonage and may trigger a delivery callback webhook. | | `wekan` | done | Annotated 24 actions: 11 read (board/list/card/cardComment/checklist/checklistItem get/list actions) and 13 write (create/update/delete actions). | +| `wolt` | done | Annotated 13 actions: 9 read (`search`, `nearby`, `venue`, `menu`, `item`, `estimate`, `whoami`, `addresses`, `order_status`) and 4 write (`connect` owner login/token rotation, `refresh` keepalive token rotation, `order_create`, `order_cancel`). `order_create`/`order_cancel` spend/stop real money and are gated behind `allowOrdering` + `confirm:true`. | | `woocommerce` | done | Annotated 15 helper-expanded CRUD actions across 3 resources: 6 read (get/list) and 9 write (create/update/delete). | | `wordpress` | done | Annotated 15 runtime actions: 6 read (post/page/user get/list) and 9 write (post/page/user create/update/delete). | | `xai` | done | Annotated 1 action: 1 write (`image.create`) because it triggers external image generation and writes generated image files to disk. | diff --git a/packages/runline-plugins/icons/wolt.svg b/packages/runline-plugins/icons/wolt.svg new file mode 100644 index 0000000..9b697e4 --- /dev/null +++ b/packages/runline-plugins/icons/wolt.svg @@ -0,0 +1 @@ +Wolt diff --git a/packages/runline-plugins/wolt/src/index.ts b/packages/runline-plugins/wolt/src/index.ts new file mode 100644 index 0000000..ae62339 --- /dev/null +++ b/packages/runline-plugins/wolt/src/index.ts @@ -0,0 +1,1074 @@ +import type { ActionContext, RunlinePluginAPI } from "runline"; +import { randomUUID } from "node:crypto"; + +/** + * wolt — Wolt (food/grocery delivery, Israel + global) consumer connector. + * + * Reads (search / venue / menu / estimate) are ANONYMOUS against Wolt's public JSON surface. + * Ordering is authenticated and GATED: `order_create` returns a priced preview + requiresConfirmation, + * and only places a real, paid order when the connection sets `allowOrdering: true` AND the caller + * passes `confirm: true`. Payment is the account's saved card (Google Pay is not replayable headless). + * + * Onboarding (`connect`) is headless and covers every Wolt account type: email code, email magic link, + * and the phone-merge path that reaches Google/social/phone-only accounts. Every login email/SMS send + * is fronted by a passive hCaptcha token (`captcha`), minted in a real browser on a wolt.com origin. + * + * Session (refresh token, rotating) + device ids + saved-card id are persisted via `ctx.updateConnection`. + * Prices are integer MINOR UNITS (agorot in ISR): 1800 === ₪18.00. Single-tenant: one account per connection. + * + * KEEPALIVE: on a successful `connect` the result carries a `keepalive` hint. The onboarding agent should + * create a recurring schedule whose prompt is just `call wolt.refresh()` (every ~12h) so the rotating + * refresh token never lapses. Wolt's refresh needs no live access token, so a slow cadence is enough. + */ + +const R = "restaurant-api.wolt.com"; +const C = "consumer-api.wolt.com"; +const AUTH = "authentication.wolt.com"; +const DEF_LAT = 32.0853; +const DEF_LON = 34.7818; // TLV +const AUDIENCE = "restaurant-api"; +const CAPABILITIES = "access_confirmation"; + +const UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; + +// mobile client headers — must match the captured app so the API accepts the authed calls. +const MOBILE: Record = { + "app-language": "en", + "app-locale": "en-US", + "client-version": "26.30.4", + clientversionnumber: "142026304", + platform: "Android", + "user-agent": "Wolt/26.30.4; Build/142026304; Android/16; Google sdk_gphone", +}; + +type Cfg = { + refreshToken?: string; + accessToken?: string; + accessTokenExpiresAt?: number; + deviceToken?: string; + visitorId?: string; + woltSessionId?: string; + ravelinDeviceId?: string; + paymentMethodId?: string; + pendingPhone?: string; + pendingEmail?: string; + pendingEmailToken?: string; + pendingOperationToken?: string; + pendingConfirmationToken?: string; + defaultLat?: string | number; + defaultLon?: string | number; + allowOrdering?: boolean; +}; + +const cfgOf = (ctx: ActionContext): Cfg => (ctx.connection.config || {}) as Cfg; +const num = (v: unknown, d: number): number => (v == null || v === "" ? d : Number(v)); + +class WoltError extends Error { + status?: number; + body?: string; +} + +// web=true → anonymous read headers (impersonate the wolt.com web app). web=false → bare/mobile. +async function http( + host: string, + path: string, + opts: { method?: string; body?: unknown; headers?: Record; raw?: boolean; web?: boolean } = {}, +): Promise { + const { method = "GET", body = null, headers = {}, raw = false, web = true } = opts; + const payload = body == null ? null : typeof body === "string" ? body : JSON.stringify(body); + const base: Record = web + ? { + "user-agent": UA, + accept: raw ? "text/html,application/xhtml+xml" : "application/json", + "accept-language": "en", + "app-language": "en", + platform: "Web", + "client-version": "1.16.125", + origin: "https://wolt.com", + referer: "https://wolt.com/", + } + : { accept: "application/json" }; + const h: Record = { ...base, ...headers }; + if (payload && !h["content-type"]) h["content-type"] = "application/json"; + const res = await fetch(`https://${host}${path}`, { method, headers: h, body: payload }); + const text = await res.text(); + if (res.status >= 400) { + let msg = text.slice(0, 300); + try { + msg = JSON.parse(text).msg || msg; + } catch { + /* keep raw */ + } + const hint = res.status === 410 ? " (endpoint retired by Wolt)" : ""; + const err = new WoltError(`wolt ${method} ${host}${path.split("?")[0]} -> ${res.status}${hint}: ${msg}`); + // Wolt returns MEANINGFUL data in 4xx bodies (e.g. the login escalation token) — keep it. + err.status = res.status; + err.body = text; + throw err; + } + if (raw) return text; + if (!text.trim()) throw new WoltError(`wolt ${method} ${host}${path.split("?")[0]} -> 200 EMPTY BODY (retired endpoint)`); + try { + return JSON.parse(text); + } catch { + throw new WoltError("wolt: non-JSON response"); + } +} + +const formEncode = (o: Record) => + Object.entries(o) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent((v ?? "") as string)}`) + .join("&"); + +// ---------- assortment (the menu — anonymous) ---------- +async function assortment(slug: string, lang = "en") { + return http(C, `/consumer-api/consumer-assortment/v1/venues/slug/${encodeURIComponent(slug)}/assortment?language=${lang}`); +} + +// ---------- reads (anonymous) ---------- +function collectVenues(data: any) { + const out: any[] = []; + for (const s of data.sections || []) + for (const it of s.items || []) { + const v = it.venue; + if (!v) continue; + out.push({ + id: v.id, + slug: v.slug, + name: v.name, + address: v.address, + city: v.city || null, + online: v.online, + currency: v.currency, + country: v.country, + rating: v.rating?.score ?? null, + rating_volume: v.rating?.volume ?? null, + price_range: v.price_range ?? null, + estimate_minutes: v.estimate ?? null, + estimate_range: v.estimate_range ?? null, + delivery_price: v.delivery_price_int ?? null, + tags: v.tags || [], + categories: (v.categories || []).map((c: any) => c.name), + short_description: v.short_description || null, + location: v.location?.coordinates ?? null, + }); + } + return out; +} + +async function cmdSearch(q: string, lat: number, lon: number, limit: number) { + const data = await http(R, "/v1/pages/search", { method: "POST", body: { q, target: "venues", lat, lon } }); + const venues = collectVenues(data).slice(0, limit); + return { query: q, count: venues.length, venues }; +} + +async function cmdNearby(lat: number, lon: number, limit: number, openOnly: boolean) { + const data = await http(R, `/v1/pages/restaurants?lat=${lat}&lon=${lon}`); + let venues = collectVenues(data); + if (openOnly) venues = venues.filter((v) => v.online); + return { city: data.city, total: venues.length, venues: venues.slice(0, limit) }; +} + +async function cmdVenue(slug: string, lat: number, lon: number) { + const [stat, dyn] = await Promise.all([ + http(C, `/order-xp/web/v1/pages/venue/slug/${encodeURIComponent(slug)}/static?lat=${lat}&lon=${lon}`), + http(C, `/order-xp/web/v1/venue/slug/${encodeURIComponent(slug)}/dynamic/?selected_delivery_method=homedelivery`).catch(() => null), + ]); + const v = stat.venue || {}; + return { + id: v.id, + slug: v.slug, + name: v.name, + description: v.description, + address: v.address, + city: v.city, + country: v.country, + post_code: v.post_code, + phone: v.phone, + website: v.website, + timezone: v.timezone, + currency: v.currency, + active_menu: v.active_menu, + rating: v.rating?.score ?? null, + delivery_methods: v.delivery_methods, + delivery_base_price: v.delivery_base_price, + service_fee_estimate: v.service_fee_estimate ?? null, + order_minimum: stat.order_minimum ?? null, + opening_times: v.opening_times_schedule ?? null, + delivery_times: v.delivery_times_schedule ?? null, + allowed_payment_methods: stat.venue_raw?.allowed_payment_methods ?? null, + is_open: dyn?.venue?.online ?? null, + estimate_minutes: dyn?.venue?.estimate ?? null, + estimate_range: v.estimate_range ?? null, + order_status: dyn?.order_status ?? null, + dynamic_order_minimum: dyn?.order_minimum ?? null, + }; +} + +function shapeItem(i: any, optionsById: Map = new Map()) { + return { + id: i.id, + name: i.name, + description: i.description || null, + price: i.price, + net_price: i.net_price ?? null, + original_price: i.original_price ?? null, + vat_percentage: i.vat_percentage ?? null, + in_stock: i.purchasable_balance == null ? true : i.purchasable_balance > 0, + disabled_reason: i.disabled_info?.reason ?? null, + allowed_delivery_methods: i.allowed_delivery_methods || null, + alcohol_permille: i.alcohol_permille ?? 0, + max_quantity_per_purchase: i.max_quantity_per_purchase ?? null, + min_quantity_per_purchase: i.min_quantity_per_purchase ?? null, + dietary_preferences: i.dietary_preferences || [], + image: i.images?.[0]?.url ?? null, + checksum: i.checksum ?? null, + options: (i.options || []).map((op: any) => { + const def = optionsById.get(op.option_id) || {}; + const range = op.multi_choice_config?.total_range || {}; + return { + id: op.id, + option_id: op.option_id, + name: op.name ?? def.name ?? null, + type: def.type ?? null, + required: (range.min ?? 0) > 0, + min: range.min ?? null, + max: range.max ?? null, + max_single_selections: op.multi_choice_config?.max_single_selections ?? null, + free_selections: op.multi_choice_config?.free_selections ?? null, + values: (def.values || []).map((v: any) => ({ id: v.id, name: v.name, price: v.price ?? 0, dietary_preferences: v.dietary_preferences || [] })), + }; + }), + }; +} + +async function cmdMenu(slug: string, lat: number, lon: number, filterQ: string, limit: number, lang: string) { + const [a, venue] = await Promise.all([assortment(slug, lang), cmdVenue(slug, lat, lon).catch(() => ({}) as any)]); + const byId = new Map((a.items || []).map((i: any) => [i.id, i])); + const optionsById = new Map((a.options || []).map((op: any) => [op.id, op])); + const filter = (filterQ || "").toLowerCase(); + const cats = (a.categories || []) + .map((c: any) => ({ + id: c.id, + name: c.name, + slug: c.slug, + description: c.description || null, + items: (c.item_ids || []) + .map((id: string) => byId.get(id)) + .filter(Boolean) + .map((i: any) => shapeItem(i, optionsById)) + .filter((i: any) => !filter || (i.name || "").toLowerCase().includes(filter) || (i.description || "").toLowerCase().includes(filter)), + })) + .filter((c: any) => c.items.length); + return { + venue_slug: slug, + venue_name: (venue as any).name ?? null, + currency: (venue as any).currency ?? null, + assortment_id: a.assortment_id ?? null, + language: a.selected_language ?? null, + category_count: cats.length, + item_count: cats.reduce((n: number, c: any) => n + c.items.length, 0), + categories: limit ? cats.map((c: any) => ({ ...c, items: c.items.slice(0, limit) })) : cats, + }; +} + +async function cmdItem(slug: string, itemId: string) { + const a = await assortment(slug); + const it = (a.items || []).find((i: any) => i.id === itemId); + if (!it) throw new Error(`wolt: item ${itemId} not found at ${slug}`); + return shapeItem(it, new Map((a.options || []).map((op: any) => [op.id, op]))); +} + +async function cmdEstimate(slug: string, lat: number, lon: number, itemsJson: string | undefined, method: string, tip: number, cfg: Cfg) { + const v = await cmdVenue(slug, lat, lon); + let menuItems: any[] = []; + if (itemsJson) menuItems = JSON.parse(itemsJson).map((m: any) => ({ id: m.id, count: m.count ?? 1, options: m.options ?? [] })); + const est = await http(C, "/order-xp/web/v1/pages/venue/pricing-estimates", { + method: "POST", + body: { + purchase_plan: { + venue: { id: v.id, country: v.country, currency: v.currency }, + delivery_method: method || "homedelivery", + menu_items: menuItems, + courier_tip: tip, + use_promo_discount_ids: [], + }, + }, + }); + return { venue: { id: v.id, slug: v.slug, name: v.name, currency: v.currency }, items: menuItems, estimate: est }; +} + +// ---------- auth / session ---------- +function ravelinDeviceId(): string { + return "rvnand-6-" + (randomUUID() + randomUUID()).replace(/-/g, "").toUpperCase().slice(0, 64); +} +const hex32 = () => (randomUUID() + randomUUID()).replace(/-/g, "").slice(0, 32); + +// generated-once identity, persisted +async function ensureIdentity(ctx: ActionContext): Promise { + const cfg = cfgOf(ctx); + const patch: Record = {}; + if (!cfg.visitorId) patch.visitorId = randomUUID(); + if (!cfg.ravelinDeviceId) patch.ravelinDeviceId = ravelinDeviceId(); + if (Object.keys(patch).length) await ctx.updateConnection(patch); + return cfgOf(ctx); +} + +async function accessToken(ctx: ActionContext, force = false): Promise { + const cfg = await ensureIdentity(ctx); + if (!cfg.refreshToken) throw new Error("wolt: no refresh token — connect an account first (connect action)."); + if (!force && cfg.accessToken && cfg.accessTokenExpiresAt && Date.now() < cfg.accessTokenExpiresAt - 60_000) return cfg.accessToken; + const resp = await http(AUTH, "/v1/wauth2/access_token", { + method: "POST", + web: false, + body: formEncode({ grant_type: "refresh_token", refresh_token: cfg.refreshToken, device_token: cfg.deviceToken || "" }), + headers: { ...MOBILE, "content-type": "application/x-www-form-urlencoded", "w-wolt-session-id": randomUUID(), "x-wolt-visitor-id": cfg.visitorId! }, + }); + if (!resp.access_token) throw new Error("wolt: token refresh returned no access_token (refresh token expired? re-connect)"); + const patch: Record = { + accessToken: resp.access_token, + accessTokenExpiresAt: Date.now() + (resp.expires_in ? resp.expires_in * 1000 : 3_600_000), + }; + if (resp.refresh_token) patch.refreshToken = resp.refresh_token; + await ctx.updateConnection(patch); + return resp.access_token; +} + +async function authed(ctx: ActionContext, host: string, path: string, opts: { method?: string; body?: unknown; headers?: Record } = {}) { + const tok = await accessToken(ctx); + const cfg = cfgOf(ctx); + return http(host, path, { + ...opts, + web: false, + headers: { ...MOBILE, authorization: `Bearer ${tok}`, "w-wolt-session-id": randomUUID(), "x-wolt-visitor-id": cfg.visitorId!, ...(opts.headers || {}) }, + }); +} + +// ---------- connect (headless onboarding) ---------- +function woltSessionId(cfg: Cfg): string { + // one stable id links email_login -> validate-number -> start-phone-auth -> the OTP grant. + return cfg.woltSessionId || randomUUID(); +} +// Normalize a phone to E.164 for Wolt, so a number given the local Israeli way +// still routes. Handles intl "+972…"/"00972…", national "0526471797", and bare +// local "526471797" alike. Idempotent. +function normPhone(p: string): string { + let d = String(p).replace(/[^\d]/g, ""); + if (d.startsWith("00")) d = d.slice(2); // 00 = intl exit code + if (d.startsWith("972")) return "+" + d; // already international + if (d.startsWith("0")) return "+972" + d.slice(1); // national 0XXXXXXXXX + return "+972" + d; // bare local, no leading 0 +} +function extractLinkToken(link: string): string { + let str = String(link).trim(); + if (!/[?&]token=/.test(str) && !/[:/]/.test(str)) return str; + for (let i = 0; i < 3; i++) { + const m = str.match(/[?&]token=([^&\s]+)/); + if (m) return decodeURIComponent(m[1]); + if (/%25|%3D|%26/.test(str)) { + str = decodeURIComponent(str); + continue; + } + break; + } + throw new Error("wolt connect: could not find a `token` in that link"); +} + +async function authJson(ctx: ActionContext, path: string, body: unknown, extra: Record = {}) { + const cfg = cfgOf(ctx); + return http(AUTH, path, { method: "POST", web: false, body, headers: { ...MOBILE, "w-wolt-session-id": woltSessionId(cfg), "x-wolt-visitor-id": cfg.visitorId!, ...extra } }); +} +async function authForm(ctx: ActionContext, body: Record, extra: Record = {}) { + const cfg = cfgOf(ctx); + return http(AUTH, "/v1/wauth2/access_token", { + method: "POST", + web: false, + body: formEncode(body), + headers: { ...MOBILE, "content-type": "application/x-www-form-urlencoded", "w-wolt-session-id": woltSessionId(cfg), "x-wolt-visitor-id": cfg.visitorId!, ...extra }, + }); +} + +async function finishConnect(ctx: ActionContext, resp: any) { + if (!resp.access_token || !resp.refresh_token) throw new Error("wolt connect: no tokens returned"); + await ctx.updateConnection({ + refreshToken: resp.refresh_token, + accessToken: resp.access_token, + accessTokenExpiresAt: Date.now() + (resp.expires_in ? resp.expires_in * 1000 : 3_600_000), + pendingPhone: undefined, + pendingEmail: undefined, + pendingOperationToken: undefined, + pendingConfirmationToken: undefined, + pendingEmailToken: undefined, + }); + let card: string | null = null; + try { + card = await discoverPaymentMethod(ctx); + } catch { + /* best-effort */ + } + const who = await cmdWhoami(ctx).catch(() => ({}) as any); + return { + connected: true, + account: (who as any).name ?? null, + addresses: (who as any).address_count ?? null, + payment_method: card ? "saved card found" : "none found — add a card in the Wolt app to place orders", + ready_to_order: !!card, + // Onboarding agent: create a keepalive schedule so the rotating refresh token never lapses. + // Wolt's refresh is self-sufficient (no live access token needed), so a slow cadence is plenty. + keepalive: { every: "12h", prompt: "call wolt.refresh()" }, + }; +} + +async function redeemMagicLink(ctx: ActionContext, token: string) { + const cfg = cfgOf(ctx); + const patch: Record = {}; + if (!cfg.deviceToken) patch.deviceToken = hex32(); + if (!cfg.visitorId) patch.visitorId = randomUUID(); + if (Object.keys(patch).length) await ctx.updateConnection(patch); + const c2 = cfgOf(ctx); + const resp = await authForm(ctx, { grant_type: "email_login", token, audience: AUDIENCE, device_token: c2.deviceToken, capabilities: CAPABILITIES }).catch((e: WoltError) => { + let code: unknown = ""; + try { + code = JSON.parse(e.body || "{}").error_code; + } catch { + /* ignore */ + } + if (code === 126) + throw new Error("wolt connect: this login link is expired or already used — request a fresh one (connect { email }) and redeem within a few minutes."); + throw e; + }); + return finishConnect(ctx, resp); +} + +async function cmdConnect(ctx: ActionContext, o: Record) { + await ensureIdentity(ctx); + + // magic-link redemption (email-only accounts) — send-free + if (o.link || o.token) return redeemMagicLink(ctx, extractLinkToken(String(o.link || o.token))); + + // email confirmation code (2nd factor, if the OTP grant escalated) + if (o.confirmCode) { + const cfg = cfgOf(ctx); + if (!cfg.pendingConfirmationToken) throw new Error("wolt connect: no pending confirmation — start with { phone }"); + const bearer = { authorization: `Bearer ${cfg.pendingConfirmationToken}` }; + await http(AUTH, "/v1/access_confirmation/email/submit", { + method: "POST", + web: false, + body: { code: String(o.confirmCode) }, + headers: { ...MOBILE, "w-wolt-session-id": randomUUID(), "x-wolt-visitor-id": cfg.visitorId!, ...bearer }, + }); + const resp = await authForm(ctx, { + grant_type: "access_confirmation_token", + access_confirmation_token: cfg.pendingConfirmationToken, + audience: AUDIENCE, + device_token: cfg.deviceToken, + }); + return finishConnect(ctx, resp); + } + + // submit the login code (email or SMS) + if (o.code) { + const cfg = cfgOf(ctx); + if (!cfg.pendingEmail && !cfg.pendingPhone) throw new Error("wolt connect: no pending login — run { email } or { phone } first"); + let resp: any; + try { + resp = cfg.pendingEmail + ? await authForm(ctx, { grant_type: "email_login_code", email: cfg.pendingEmail, code: String(o.code), audience: AUDIENCE, device_token: cfg.deviceToken, capabilities: CAPABILITIES }) + : await authForm(ctx, { grant_type: "phone_number_otp", phone_number: cfg.pendingPhone, otp: String(o.code), audience: AUDIENCE, device_token: cfg.deviceToken, capabilities: CAPABILITIES, tenant: "wolt" }); + } catch (e) { + const err = e as WoltError; + let esc: any = null; + try { + esc = JSON.parse(err.body || ""); + } catch { + /* ignore */ + } + if (esc?.access_confirmation_token) { + await ctx.updateConnection({ pendingConfirmationToken: esc.access_confirmation_token }); + const cfg2 = cfgOf(ctx); + const bearer = { authorization: `Bearer ${cfg2.pendingConfirmationToken}` }; + const st = await http(AUTH, "/v1/access_confirmation/status", { + method: "POST", + web: false, + body: { capabilities: ["email", "phone_number", "google", "facebook", "line", "emag", "strong_authentication", "kyc"] }, + headers: { ...MOBILE, "w-wolt-session-id": randomUUID(), "x-wolt-visitor-id": cfg2.visitorId!, ...bearer }, + }).catch(() => null); + await http(AUTH, "/v1/access_confirmation/email", { method: "POST", web: false, body: "", headers: { ...MOBILE, "w-wolt-session-id": randomUUID(), "x-wolt-visitor-id": cfg2.visitorId!, ...bearer } }); + return { + step: "needs_confirmation", + method: "email", + account_first_name: esc.access_confirmation_context?.first_name ?? null, + email_hint: (st?.methods || []).find((m: any) => m.method === "email")?.masked_email ?? null, + available_methods: (st?.methods || []).map((m: any) => m.method), + note: "phone verified, but this account needs a second factor. Wolt emailed a confirmation code — run connect({ confirmCode })", + }; + } + throw new Error(`wolt connect: OTP exchange failed: ${String(err.message || err)}`); + } + return finishConnect(ctx, resp); + } + + // request an emailed login code/link (email path) + if (o.email && !o.phone) { + const patch: Record = { pendingEmail: String(o.email), pendingPhone: undefined }; + const cfg0 = cfgOf(ctx); + if (!cfg0.deviceToken) patch.deviceToken = hex32(); + if (!cfg0.visitorId) patch.visitorId = randomUUID(); + await ctx.updateConnection(patch); + const capHdr = o.captcha ? { "h-captcha-response": String(o.captcha) } : {}; + const r = await authJson(ctx, "/v3/users/email_login", { email: String(o.email), audience: AUDIENCE, email_code_support: true }, capHdr); + if (r && r.new_user === true && !o.captcha) { + await ctx.updateConnection({ pendingEmail: undefined }); + return { + step: "not_registered", + email: o.email, + use_instead: "connect({ phone })", + error: "No Wolt account exists for this address — use the phone number on your account instead: connect({ phone }).", + }; + } + return { + step: "code_sent", + email: String(o.email), + includes_code: r?.includes_code ?? null, + new_user: r?.new_user ?? null, + next: o.captcha ? "check the inbox for the LINK, then run connect({ phone, emailToken })" : "check your email, then run connect({ code })", + note: "The email contains a login link and/or a numeric code.", + }; + } + + // phone path — request the SMS (phone-merge aware) + if (!o.phone) throw new Error("wolt connect: { email } or { phone } required (or { code } once you have it)"); + const patch: Record = { pendingPhone: normPhone(String(o.phone)) }; + const cfg0 = cfgOf(ctx); + if (!cfg0.deviceToken) patch.deviceToken = hex32(); + if (!cfg0.visitorId) patch.visitorId = randomUUID(); + if (!cfg0.woltSessionId) patch.woltSessionId = randomUUID(); + await ctx.updateConnection(patch); + const cfg = cfgOf(ctx); + const cap = await authJson(ctx, "/v1/captcha/site_key", { operation: "start_phone_number_authentication_consumer", phone_number: cfg.pendingPhone }).catch(() => ({}) as any); + await ctx.updateConnection({ pendingOperationToken: cap.operation_token || null }); + const cfg1 = cfgOf(ctx); + const methods = await authJson(ctx, "/v1/wauth2/consumer-sms/login-methods", { + phone_number: cfg1.pendingPhone, + device_token: cfg1.deviceToken, + operation_token: cfg1.pendingOperationToken, + }).catch((e: WoltError) => ({ _error: String(e.message || e) })); + if (o.methodsOnly) return { step: "login_methods", phone: cfg1.pendingPhone, methods }; + + const cap2 = (t: unknown) => (t ? { "h-captcha-response": String(t) } : {}); + const emailToken = o.emailToken ? extractLinkToken(String(o.emailToken)) : cfg1.pendingEmailToken || null; + let validate: any = null; + if (emailToken) { + validate = await authJson( + ctx, + "/v1/wauth2/consumer-sms/validate-number", + { phone_number: cfg1.pendingPhone, email_token: emailToken, email: o.email || cfg1.pendingEmail || undefined, use_new_response_format: true }, + cap2(o.vnCaptcha), + ).catch((e: WoltError) => ({ _error: String(e.message || e) })); + await ctx.updateConnection({ pendingEmailToken: emailToken }); + } + await authJson( + ctx, + "/v2/wauth2/consumer-sms/start-phone-number-authentication", + { + phone_number: cfg1.pendingPhone, + message_delivery_method: o.viaWhatsapp ? "whatsapp" : "sms", + device_token: cfg1.deviceToken, + audience: AUDIENCE, + capabilities: ["access_confirmation"], + operation_token: cfg1.pendingOperationToken, + }, + cap2(o.smsCaptcha), + ); + return { + step: "sms_sent", + phone: cfg1.pendingPhone, + login_methods: methods, + ...(validate ? { validate_number: validate } : {}), + next: "enter the SMS code: connect({ code })", + }; +} + +// ---------- payment method / addresses ---------- +async function discoverPaymentMethod(ctx: ActionContext): Promise { + const idOf = (m: any) => (m?.id && typeof m.id === "object" ? m.id.$uuid || m.id.$oid : m?.id) || m?.card_id || null; + for (const [host, path] of [ + [R, "/v3/user/me/payment_methods"], + [C, "/order-xp/v1/payment-methods"], + ] as const) { + try { + const r = await authed(ctx, host, path); + const list = r.results?.cards || r.results?.payment_methods || (Array.isArray(r.results) ? r.results : null) || r.payment_methods || r.methods || (Array.isArray(r) ? r : []); + const card = list.find((m: any) => m.valid_for_payments && idOf(m)) || list.find((m: any) => idOf(m)); + if (card) { + const id = idOf(card); + await ctx.updateConnection({ paymentMethodId: id }); + return id; + } + } catch { + /* try next */ + } + } + return null; +} + +async function getAddresses(ctx: ActionContext) { + const d = await authed(ctx, R, "/v2/delivery/info"); + return (d.results || []).map((a: any) => ({ + id: a.id, + alias: a.alias ?? null, + label_type: a.label_type ?? null, + is_verified: a.is_verified ?? null, + address: a.location?.address ?? null, + city: a.location?.city ?? null, + street: a.location?.street ?? null, + apartment: a.location?.apartment ?? null, + coordinates: a.location?.google_place_coordinates?.coordinates ?? a.location?.user_coordinates?.coordinates ?? null, + comments: a.location?.address_form_data?.additional_instructions ?? null, + })); +} +function pickAddress(list: any[], wanted?: string) { + if (!list.length) throw new Error("wolt: no saved delivery address on the account — add one in the app first"); + if (wanted) { + const m = list.find((a) => a.id === wanted); + if (!m) throw new Error(`wolt: address ${wanted} not found`); + return m; + } + return list[0]; +} + +async function cmdWhoami(ctx: ActionContext) { + const me = await authed(ctx, R, "/v1/user/me"); + const u = me.user || {}; + return { id: u._id?.$oid ?? null, name: u.name ?? null, has_account: !!u._id, address_count: (await getAddresses(ctx)).length }; +} + +// ---------- ordering ---------- +async function resolveItems(slug: string, selections: any[]) { + const a = await assortment(slug); + const itemsById = new Map((a.items || []).map((i: any) => [i.id, i])); + const optionsById = new Map((a.options || []).map((op: any) => [op.id, op])); + const catOfItem = new Map(); + for (const c of a.categories || []) for (const id of c.item_ids || []) if (!catOfItem.has(id)) catOfItem.set(id, c.id); + + const menu_items: any[] = []; + const purchase_items: any[] = []; + for (const sel of selections) { + const it: any = itemsById.get(sel.id); + if (!it) throw new Error(`wolt: item ${sel.id} not on venue ${slug}`); + const count = sel.count ?? 1; + const catId = catOfItem.get(sel.id) || null; + let optSum = 0; + const coOpts: any[] = []; + const puOpts: any[] = []; + for (const grp of sel.options || []) { + const itemGrp = (it.options || []).find((op: any) => op.id === grp.id); + if (!itemGrp) throw new Error(`wolt: option group ${grp.id} not on item ${sel.id}`); + const def: any = optionsById.get(itemGrp.option_id) || {}; + const values = (grp.values || []).map((v: any) => { + const dv = (def.values || []).find((x: any) => x.id === v.id) || {}; + const price = dv.price ?? 0; + const c = v.count ?? 1; + optSum += price * c; + return { id: v.id, price, count: c }; + }); + coOpts.push({ id: grp.id, values: values.map(({ id, price, count }: any) => ({ id, price, count })) }); + puOpts.push({ id: { $oid: grp.id }, type: def.type || "Multichoice", values: values.map((v: any) => ({ id: { $oid: v.id }, price: v.price, count: v.count })) }); + } + const basePrice = it.price ?? 0; + const endAmount = (basePrice + optSum) * count; + menu_items.push({ + id: sel.id, + count, + options: coOpts, + base_price: basePrice, + end_amount: endAmount, + category_id: catId, + category_ids: catId ? [catId] : [], + exclude_from_discounts: false, + exclude_from_discounts_min_basket: false, + exclude_from_credits: false, + alcohol_permille: it.alcohol_permille ?? 0, + restrictions: [], + }); + purchase_items.push({ + id: { $oid: sel.id }, + baseprice: basePrice, + options: puOpts, + count, + end_amount: endAmount, + alcohol_percentage: 0, + checksum: it.checksum, + substitution_settings: { is_allowed: true }, + from_recommendation: false, + }); + } + return { menu_items, purchase_items }; +} + +async function doCheckout(ctx: ActionContext, venue: any, addr: any, menu_items: any[], tip: number) { + const cfg = cfgOf(ctx); + const coords = addr.coordinates || []; + const [lng, lat] = coords.length === 2 ? coords : [null, null]; + const plan = { + purchase_plan: { + delivery: { delivery_coordinates: { latitude: lat, longitude: lng }, delivery_info_id: addr.id }, + venue: { id: venue.id, country: venue.country, currency: venue.currency }, + delivery_method: "homedelivery", + menu_items, + use_cash: false, + selected_offer_ids: [], + pre_considered_discount_ids: [], + courier_tip: tip, + use_loyalty_points_amount: 0, + use_credits_and_tokens: true, + did_client_disable_purchase: false, + payment_methods: cfg.paymentMethodId ? [{ id: cfg.paymentMethodId, type: "card" }] : [], + delivery_config: { method: "homedelivery", schedule: "standard" }, + is_partial_address: false, + }, + }; + return authed(ctx, C, "/order-xp/mobile/v2/pages/checkout", { method: "POST", body: plan }); +} + +async function placePurchase(ctx: ActionContext, venue: any, addr: any, purchase_items: any[], checkout: any, tip: number, estimate: string) { + const cfg = cfgOf(ctx); + if (!cfg.paymentMethodId) throw new Error("wolt: no saved card (paymentMethodId) — Google Pay is not replayable, a saved card is required"); + const pv = checkout.purchase_validation || {}; + const coords = addr.coordinates || []; + const [lng, lat] = coords.length === 2 ? coords : [null, null]; + const body = { + venue_id: venue.id, + checkout_id: checkout.id, + payment_method_id: cfg.paymentMethodId, + payment_method_type: "card", + end_amount: pv.end_amount ?? checkout.payable_amount, + end_amount_rounding: pv.end_amount_rounding ?? 0, + items: purchase_items, + client_pre_estimate: estimate, + signature_datetime: { $date: Date.now() }, + client_nonce: randomUUID(), + delivery_method: "homedelivery", + currency: venue.currency, + preorder: false, + no_credits_or_tokens: false, + credits_amount: pv.credits_amount ?? 0, + delivery_price: pv.delivery_price ?? 0, + delivery_info: { + id: { $oid: addr.id }, + delivery_comments: "\n", + location: { address: addr.address, city: addr.city, coordinates: { coordinates: [lng, lat], type: "Point" } }, + use_last_100m_address_picker: true, + }, + tip_amount: tip, + language: "en", + additional_checkout_options: { no_contact_delivery: false }, + ravelin_device_id: cfg.ravelinDeviceId, + browser_info: { color_depth: 32, screen_width: 1080, screen_height: 2400, time_zone_offset: -180, java_enabled: false, language: "en", ravelin_device_id: cfg.ravelinDeviceId }, + to: { id: { $oid: venue.id }, type: "venue" }, + signature: "N/A", + type: "purchase", + device_channel: "app", + pricing_model_version: 2023, + use_self_service_cancellation: false, + discounts: pv.discounts ?? [], + surcharges: pv.surcharges ?? [], + offers: pv.offers ?? [], + menu_items_source: "consumer-assortment", + }; + return authed(ctx, R, "/v2/purchases", { method: "POST", body }); +} + +async function cmdOrderCreate(ctx: ActionContext, slug: string, o: Record) { + if (!o.items) throw new Error('wolt order_create: items required, e.g. [{"id":"","count":1,"options":[{"id":"","values":[{"id":"","count":1}]}]}]'); + const cfg = cfgOf(ctx); + const selections = typeof o.items === "string" ? JSON.parse(o.items) : o.items; + const lat = num(o.lat ?? cfg.defaultLat, DEF_LAT); + const lon = num(o.lon ?? cfg.defaultLon, DEF_LON); + const tip = num(o.tip, 0); + const venue = await cmdVenue(slug, lat, lon); + const addresses = await getAddresses(ctx); + const addr = pickAddress(addresses, o.address ? String(o.address) : undefined); + const { menu_items, purchase_items } = await resolveItems(slug, selections); + const checkout = await doCheckout(ctx, venue, addr, menu_items, tip); + if (checkout.purchasing_disabled) + return { ok: false, purchasing_disabled: true, reason: checkout.call_to_action?.text || checkout.purchase_validation || "purchasing disabled by Wolt", checkout_id: checkout.id }; + const dc = + (checkout.delivery_configs || []).find((c: any) => c.method === "homedelivery" && c.schedule === "standard" && c.estimate?.max) || + (checkout.delivery_configs || []).find((c: any) => c.estimate?.max); + const estimate = dc?.estimate?.max + ? `${dc.estimate.min}-${dc.estimate.max}` + : (venue as any).estimate_range + ? `${(venue as any).estimate_range.min}-${(venue as any).estimate_range.max}` + : venue.estimate_minutes + ? `${venue.estimate_minutes}-${venue.estimate_minutes + 10}` + : "30-45"; + const preview = { + venue: { id: venue.id, slug: venue.slug, name: venue.name, currency: venue.currency }, + deliver_to: { id: addr.id, address: addr.address, city: addr.city }, + items: menu_items.map((m) => ({ id: m.id, count: m.count, amount: m.end_amount })), + payable_amount: checkout.payable_amount, + delivery_price: checkout.purchase_validation?.delivery_price ?? null, + currency: venue.currency, + estimate_minutes: estimate, + checkout_id: checkout.id, + }; + if (o.confirm !== true) + return { requiresConfirmation: true, action: "wolt order_create", summary: preview, note: "This spends real money and orders real food. Re-run with confirm:true to place." }; + if (!cfg.allowOrdering) throw new Error("wolt order_create: ordering is disabled for this connection (set allowOrdering:true). Refusing to place."); + const res = await placePurchase(ctx, venue, addr, purchase_items, checkout, tip, estimate); + const r0 = Array.isArray(res.results) ? res.results[0] : res.results; + const orderId = r0?.id?.$oid || r0?.id || res.id?.$oid || res.id || res.purchase_id || null; + return { ok: true, status: "placed", order_id: orderId, payable_amount: checkout.payable_amount, currency: venue.currency, deliver_to: preview.deliver_to, track_with: `order_status(${orderId})` }; +} + +async function cmdOrderStatus(ctx: ActionContext, purchaseId: string) { + const [track, page] = await Promise.all([ + authed(ctx, R, `/v2/order_details/purchase_tracking/${purchaseId}`).catch(() => null), + authed(ctx, C, `/order-xp/v1/pages/order-tracking/${purchaseId}`).catch(() => null), + ]); + const od = track?.order_details || {}; + return { + order_id: purchaseId, + status: page?.purchase_context?.purchase_status ?? null, + venue: page?.purchase_context?.venue_name ?? null, + eta: od.delivery_eta?.$date ? new Date(od.delivery_eta.$date).toISOString() : null, + delivery_distance: od.delivery_distance ?? null, + handshake_code: od.delivery_handshake_code ?? null, + couriers: (track?.drivers || []).map((d: any) => ({ name: d.name ?? null, coordinates: d.coordinates ?? d.location ?? null })), + currency: od.currency ?? null, + poll_after_seconds: track?.expires_in_seconds ?? null, + }; +} + +async function cmdOrderCancel(ctx: ActionContext, purchaseId: string, reason: string) { + try { + const res = await authed(ctx, R, `/v2/purchases/${purchaseId}/cancel`, { method: "PUT", body: { cancellation_reason: reason } }); + return { ok: true, cancelled: true, order_id: purchaseId, reason, response: res ?? null }; + } catch (e) { + const err = e as WoltError; + let msg = String(err.message || err); + let code: unknown = null; + try { + const j = JSON.parse(err.body || "{}"); + msg = j.msg || msg; + code = j.error_code ?? null; + } catch { + /* ignore */ + } + return { + ok: false, + cancelled: false, + order_id: purchaseId, + error_code: code, + reason_declined: msg, + note: code === 4051 ? "The venue already received the order — self-service cancel is no longer possible; contact Wolt support." : undefined, + }; + } +} + +// ---------- plugin ---------- +export default function wolt(rl: RunlinePluginAPI) { + rl.setName("wolt"); + rl.setVersion("0.1.0"); + + rl.setConnectionSchema({ + refreshToken: { type: "string", required: false, description: "Refresh JWT from an owner login (rotates; persisted). Set by connect().", env: "WOLT_REFRESH_TOKEN" }, + deviceToken: { type: "string", required: false, description: "Device token bound to the session (generated on connect).", env: "WOLT_DEVICE_TOKEN" }, + visitorId: { type: "string", required: false, description: "x-wolt-visitor-id (generated if absent).", env: "WOLT_VISITOR_ID" }, + ravelinDeviceId: { type: "string", required: false, description: "Ravelin fraud fingerprint (rvnand-6-<64hex>); generated + persisted if absent.", env: "WOLT_RAVELIN_DEVICE_ID" }, + paymentMethodId: { type: "string", required: false, description: "Saved card id charged for orders. Auto-discovered on connect.", env: "WOLT_PAYMENT_METHOD_ID" }, + defaultLat: { type: "string", required: false, description: "Home latitude for reads that carry none (e.g. 32.0853).", env: "WOLT_DEFAULT_LAT" }, + defaultLon: { type: "string", required: false, description: "Home longitude (e.g. 34.7818).", env: "WOLT_DEFAULT_LON" }, + allowOrdering: { type: "boolean", required: false, default: false, description: "Master switch for placing/cancelling orders. Off = read-only. Even on, order_create still needs confirm:true." }, + }); + + const latOf = (ctx: ActionContext, p: any) => num(p?.lat ?? cfgOf(ctx).defaultLat, DEF_LAT); + const lonOf = (ctx: ActionContext, p: any) => num(p?.lon ?? cfgOf(ctx).defaultLon, DEF_LON); + + rl.registerAction("search", { + access: "read", + description: "Search Wolt for venues (restaurants, groceries, shops) by name/cuisine near a location. Returns venues with slug, rating, delivery price and ETA. Anonymous, read-only. Hebrew works.", + inputSchema: { + query: { type: "string", required: true, description: "Search text, e.g. 'sushi' or 'pharmacy'." }, + lat: { type: "number", required: false, description: "Latitude (defaults to the connection's home)." }, + lon: { type: "number", required: false, description: "Longitude." }, + limit: { type: "number", required: false, description: "Max venues (default 20)." }, + }, + async execute(input, ctx) { + const p = input as Record; + return cmdSearch(String(p.query), latOf(ctx, p), lonOf(ctx, p), num(p.limit, 20)); + }, + }); + + rl.registerAction("nearby", { + access: "read", + description: "List venues near a location (Wolt city frontpage). Optionally only open venues. Anonymous, read-only.", + inputSchema: { + lat: { type: "number", required: false, description: "Latitude (defaults to home)." }, + lon: { type: "number", required: false, description: "Longitude." }, + limit: { type: "number", required: false, description: "Max venues (default 30)." }, + open_only: { type: "boolean", required: false, description: "Only currently-open venues." }, + }, + async execute(input, ctx) { + const p = (input ?? {}) as Record; + return cmdNearby(latOf(ctx, p), lonOf(ctx, p), num(p.limit, 30), p.open_only === true); + }, + }); + + rl.registerAction("venue", { + access: "read", + description: "Full details for one venue by slug: address, currency, open/closed, ETA, delivery fee, order minimum, opening/delivery schedules. Anonymous, read-only.", + inputSchema: { + slug: { type: "string", required: true, description: "Venue slug (from search/nearby)." }, + lat: { type: "number", required: false, description: "Latitude (defaults to home)." }, + lon: { type: "number", required: false, description: "Longitude." }, + }, + async execute(input, ctx) { + const p = input as Record; + return cmdVenue(String(p.slug), latOf(ctx, p), lonOf(ctx, p)); + }, + }); + + rl.registerAction("menu", { + access: "read", + description: "Read a venue's full menu by slug: categories and items with prices (minor units), stock, and normalised option groups (required flags, choices, prices). Optionally filter items by text. Anonymous, read-only.", + inputSchema: { + slug: { type: "string", required: true, description: "Venue slug." }, + q: { type: "string", required: false, description: "Filter items by name/description." }, + limit: { type: "number", required: false, description: "Max items per category (0 = all)." }, + lat: { type: "number", required: false, description: "Latitude for the venue lookup." }, + lon: { type: "number", required: false, description: "Longitude." }, + lang: { type: "string", required: false, description: "Assortment language (default 'en')." }, + }, + async execute(input, ctx) { + const p = input as Record; + return cmdMenu(String(p.slug), latOf(ctx, p), lonOf(ctx, p), p.q ? String(p.q) : "", num(p.limit, 0), p.lang ? String(p.lang) : "en"); + }, + }); + + rl.registerAction("item", { + access: "read", + description: "One menu item's full detail (price, options, checksum) by venue slug + item id. Anonymous, read-only.", + inputSchema: { + slug: { type: "string", required: true, description: "Venue slug." }, + item_id: { type: "string", required: true, description: "Item id (from menu)." }, + }, + async execute(input) { + const p = input as Record; + return cmdItem(String(p.slug), String(p.item_id)); + }, + }); + + rl.registerAction("estimate", { + access: "read", + description: "Price estimate for a venue (and optionally a cart of items): delivery fee + totals. Works with an empty cart for a delivery-fee estimate. Anonymous, read-only.", + inputSchema: { + slug: { type: "string", required: true, description: "Venue slug." }, + items: { type: "string", required: false, description: 'JSON array of {id,count,options?} to price a cart.' }, + method: { type: "string", required: false, description: "Delivery method (default 'homedelivery')." }, + tip: { type: "number", required: false, description: "Courier tip in minor units." }, + lat: { type: "number", required: false, description: "Latitude." }, + lon: { type: "number", required: false, description: "Longitude." }, + }, + async execute(input, ctx) { + const p = input as Record; + return cmdEstimate(String(p.slug), latOf(ctx, p), lonOf(ctx, p), p.items ? String(p.items) : undefined, p.method ? String(p.method) : "homedelivery", num(p.tip, 0), cfgOf(ctx)); + }, + }); + + rl.registerAction("connect", { + access: "write", + description: + "Owner login for the Wolt account, headless. Paths by account type: email code (connect({ email, captcha }) -> connect({ code })); email magic LINK (connect({ link })); phone / Google-social (connect({ phone, emailToken, vnCaptcha, smsCaptcha }) -> connect({ code }) -> connect({ confirmCode })). Probe with connect({ phone, methodsOnly:true }). Every email/SMS SEND needs a fresh passive hCaptcha token (captcha/vnCaptcha/smsCaptcha) minted in a real browser on a wolt.com origin; redeeming a link is the only send-free step. After success the refresh token drives everything.", + inputSchema: { + email: { type: "string", required: false, description: "Account (or throwaway) email to request a login code/link." }, + phone: { type: "string", required: false, description: "Account phone (international or local IL digits)." }, + code: { type: "string", required: false, description: "The emailed/SMS one-time code." }, + confirmCode: { type: "string", required: false, description: "Second-factor email confirmation code (phone-merge escalation)." }, + link: { type: "string", required: false, description: "Full magic-link URL (or raw token) to redeem — no captcha needed." }, + token: { type: "string", required: false, description: "Bare magic-link token (alternative to link)." }, + emailToken: { type: "string", required: false, description: "Magic-link URL/token from a throwaway email_login, to unlock the phone-merge validate-number." }, + captcha: { type: "string", required: false, description: "Fresh h-captcha-response for the email_login send." }, + vnCaptcha: { type: "string", required: false, description: "Fresh h-captcha-response for validate-number (phone-merge)." }, + smsCaptcha: { type: "string", required: false, description: "Fresh h-captcha-response for start-phone-authentication." }, + viaWhatsapp: { type: "boolean", required: false, description: "Deliver the code by WhatsApp instead of SMS." }, + methodsOnly: { type: "boolean", required: false, description: "Only ask Wolt what this account can log in with; sends nothing." }, + }, + async execute(input, ctx) { + return cmdConnect(ctx, (input ?? {}) as Record); + }, + }); + + rl.registerAction("refresh", { + access: "write", + description: + "Keepalive: force a token refresh so the rotating session never lapses. After connect(), the onboarding agent should create a recurring schedule that runs the one-line prompt 'call wolt.refresh()' every ~12h (Wolt's refresh needs no live access token, so a slow cadence is plenty). Returns the new access-token expiry.", + inputSchema: {}, + async execute(_input, ctx) { + await accessToken(ctx, true); + const cfg = cfgOf(ctx); + return { refreshed: true, access_expires_at: cfg.accessTokenExpiresAt ? new Date(cfg.accessTokenExpiresAt).toISOString() : null }; + }, + }); + + rl.registerAction("whoami", { + access: "read", + description: "The connected Wolt account: id, name, and saved-address count. Confirms the session is authenticated.", + inputSchema: {}, + async execute(_input, ctx) { + return cmdWhoami(ctx); + }, + }); + + rl.registerAction("addresses", { + access: "read", + description: "The account's saved delivery addresses (id, alias, address, coordinates). You need one to place an order.", + inputSchema: {}, + async execute(_input, ctx) { + return getAddresses(ctx); + }, + }); + + rl.registerAction("order_create", { + access: "write", + description: + "Place a REAL, paid Wolt order — this spends money and orders real food. Call FIRST without confirm to get a priced preview (items, payable amount, delivery fee, ETA, delivery address) and a requiresConfirmation flag; read it back, get an explicit yes, then call again with confirm:true. Requires the connection's allowOrdering. Payment is the account's saved card.", + inputSchema: { + slug: { type: "string", required: true, description: "Venue slug." }, + items: { type: "string", required: true, description: 'JSON array of selections: [{"id":"","count":1,"options":[{"id":"","values":[{"id":"","count":1}]}]}]' }, + address: { type: "string", required: false, description: "Delivery address id (from addresses). Defaults to the first saved address." }, + tip: { type: "number", required: false, description: "Courier tip in minor units." }, + confirm: { type: "boolean", required: false, description: "Set true ONLY after the person heard the total + address and said yes. Actually places (and pays for) the order." }, + lat: { type: "number", required: false, description: "Latitude for the venue lookup." }, + lon: { type: "number", required: false, description: "Longitude." }, + }, + async execute(input, ctx) { + const p = input as Record; + return cmdOrderCreate(ctx, String(p.slug), p); + }, + }); + + rl.registerAction("order_status", { + access: "read", + description: "Track an order ('where's my food'): status, ETA, courier hand-off code and location, delivery distance. Needs the order id from order_create.", + inputSchema: { order_id: { type: "string", required: true, description: "Order id from order_create." } }, + async execute(input, ctx) { + return cmdOrderStatus(ctx, String((input as Record).order_id)); + }, + }); + + rl.registerAction("order_cancel", { + access: "write", + description: "Cancel an order — self-service, only in the short window BEFORE the venue accepts it (afterwards Wolt returns error_code 4051 and you must contact support). Stops a charge; still requires the order id.", + inputSchema: { + order_id: { type: "string", required: true, description: "Order id from order_create." }, + reason: { type: "string", required: false, description: "Cancellation reason (default 'ordered_by_mistake')." }, + }, + async execute(input, ctx) { + const p = input as Record; + return cmdOrderCancel(ctx, String(p.order_id), p.reason ? String(p.reason) : "ordered_by_mistake"); + }, + }); +}