diff --git a/README.md b/README.md
index b51c7a8..468f2e2 100644
--- a/README.md
+++ b/README.md
@@ -106,6 +106,7 @@ runline exec 'return await github.user.listRepos({ username: "torvalds" })'
|
**freshservice** | 77 | agent, agentGroup, announcement, asset, assetType, change, department, location, problem, product, release, requester, requesterGroup, software, ticket, agentRole | `FRESHSERVICE_DOMAIN`, `FRESHSERVICE_API_KEY` |
|
**freshworksCrm** | 35 | account, appointment, contact, deal, note, salesActivity, task, search | `FRESHWORKS_CRM_DOMAIN`, `FRESHWORKS_CRM_API_KEY` |
|
**getresponse** | 5 | contact | `GETRESPONSE_API_KEY` |
+|
**gett** | 9 | connect, refresh, whoami, find_place, price, nearby_drivers, ride_status, book_ride, cancel_ride | |
|
**ghost** | 5 | post | `GHOST_URL`, `GHOST_ADMIN_API_KEY` |
|
**github** | 37 | file, issue, release, commit, branch, repository, review, user, organization, workflow | `GITHUB_TOKEN` |
|
**gitlab** | 17 | issue, release, repository, user, file | `GITLAB_TOKEN` |
diff --git a/packages/runline-plugins/ACCESS_CHECKLIST.md b/packages/runline-plugins/ACCESS_CHECKLIST.md
index 810ccaf..97db89a 100644
--- a/packages/runline-plugins/ACCESS_CHECKLIST.md
+++ b/packages/runline-plugins/ACCESS_CHECKLIST.md
@@ -60,6 +60,7 @@ Status values: `pending`, `in-progress`, `done`.
| `freshservice` | done | Annotated 77 runtime actions via CRUD helper and direct registrations: 32 read (resource get/list plus agentRole get/list) and 45 write (resource create/update/delete). |
| `freshworksCrm` | done | Annotated 35 runtime actions via CRUD helper and direct registrations: 14 read (resource get/list where enabled plus `search.query`, `search.lookup`) and 21 write (resource create/update/delete). |
| `getresponse` | done | Annotated 5 actions: 2 read (`contact.get`, `contact.list`) and 3 write (`contact.create`, `contact.update`, `contact.delete`). |
+| `gett` | done | Annotated 9 actions: 5 read (`whoami`, `find_place`, `price`, `nearby_drivers`, `ride_status`) and 4 write (`connect` owner login/token rotation, `refresh` keepalive token rotation, `book_ride`, `cancel_ride`). `book_ride`/`cancel_ride` spend money and are gated behind `allowOrdering` + `confirm:true`. |
| `ghost` | done | Annotated 5 actions: 2 read (`post.get`, `post.list`) and 3 write (`post.create`, `post.update`, `post.delete`). |
| `github` | done | Annotated 37 actions: 22 read (file/issue/release/commit/branch/repository/review/user/org/workflow get/list/usage actions) and 15 write (file create/update/delete, issue create/update/comment/lock, release create/update/delete, review create/update, user invite, workflow dispatch/enable/disable). |
| `gitlab` | done | Annotated 17 actions: 8 read (`issue.get`, release get/list, repository get/listIssues, `user.listProjects`, file get/list) and 9 write (issue create/update/note/lock, release create/update/delete, file createOrUpdate/delete). |
diff --git a/packages/runline-plugins/gett/src/index.ts b/packages/runline-plugins/gett/src/index.ts
new file mode 100644
index 0000000..6466e60
--- /dev/null
+++ b/packages/runline-plugins/gett/src/index.ts
@@ -0,0 +1,745 @@
+import type { ActionContext, RunlinePluginAPI } from "runline";
+import { randomUUID } from "node:crypto";
+
+/**
+ * gett — Gett (taxi, Israel) consumer connector.
+ *
+ * Reaches the owner's PRIVATE Gett account via the mobile app surface
+ * (`b2cgateway.gett.com`). The official Business API is gated on a per-company
+ * `order` entitlement, so this rides the same JSON REST surface the phone app uses.
+ *
+ * Auth: phone + SMS OTP + a card-digits MFA second factor (a trusted device skips
+ * the MFA step). A long-lived refresh token then mints short access tokens; both are
+ * persisted via `ctx.updateConnection` (rotating). Transport is `Authorization: Bearer`
+ * + `x-device-id`; there is no per-request signing. Payment is the account's saved card
+ * charged server-side; tracking is REST polling (no websocket).
+ *
+ * SAFETY: `book_ride`/`cancel_ride` spend money and summon/dismiss a real car. Booking is
+ * refused unless the connection sets `allowOrdering: true` AND the caller passes
+ * `confirm: true`; without `confirm` it returns a priced PREVIEW + `requiresConfirmation`
+ * for a human confirmation gate. Reads (`find_place`, `price`, `ride_status`,
+ * `nearby_drivers`, `whoami`) are free.
+ *
+ * REFRESH: the session is long-lived and unattended — NO keepalive schedule is needed. The GL refresh
+ * token lasts ~90 days and the refresh grant does NOT require a live access token (Gett accepts a bearer
+ * expired by days). Every `/auth/token` call MUST carry the `?lc=en` query param — without it the server
+ * returns a bare 400 (empty body); that missing param, not the ~15-min access-token TTL, was the real
+ * lockout (proven end-to-end 2026-08-11). `refresh` just mints a fresh access token on demand.
+ */
+
+const HOST = "b2cgateway.gett.com";
+const UA = "Gett/android/10.48.187";
+const APP_VERSION = "10.48.187";
+const DEF_LAT = 32.0779;
+const DEF_LON = 34.7743; // Dizengoff Sq, TLV — always has supply
+
+type Cfg = {
+ phone?: string;
+ refreshToken?: string;
+ accessToken?: string;
+ accessTokenExpiresAt?: number;
+ deviceId?: string;
+ clientDeviceUniqueId?: string;
+ deviceGeneratedToken?: string;
+ gaid?: string;
+ globalUserId?: string | number;
+ name?: string;
+ creditCardId?: string;
+ pendingTempCode?: 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));
+
+// Normalize a phone to Gett's expected international digits (E.164 without '+'),
+// so a number spoken/typed the local Israeli way still routes the SMS. Gett pairs
+// the path number with country_phone_prefix:972, so a national "0526471797" (or
+// "052-647-1797") must become "972526471797" — otherwise the challenge returns
+// success but the SMS is silently dropped. Idempotent; leaves a valid intl number.
+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 (e.g. 526471797)
+}
+
+// ---------- HTTP ----------
+async function http(
+ ctx: ActionContext,
+ path: string,
+ opts: { method?: string; body?: unknown; token?: string | null } = {},
+): Promise {
+ const cfg = cfgOf(ctx);
+ const { method = "GET", body = null, token = null } = opts;
+ const payload = body == null ? null : JSON.stringify(body);
+ const headers: Record = {
+ accept: "application/json",
+ "user-agent": UA,
+ "app-platform": "android",
+ "app-version": APP_VERSION,
+ "x-device-id": cfg.deviceId || "",
+ "x-client-device-unique-id": cfg.clientDeviceUniqueId || "",
+ "x-country-code": "IL",
+ };
+ if (token) headers.authorization = `Bearer ${token}`;
+ if (payload) headers["content-type"] = "application/json; charset=UTF-8";
+ const res = await fetch(`https://${HOST}${path}`, { method, headers, body: payload });
+ const text = await res.text();
+ let parsed: any = null;
+ try {
+ parsed = text ? JSON.parse(text) : null;
+ } catch {
+ /* non-JSON body */
+ }
+ if (!res.ok) {
+ const detail = parsed?.error_description || parsed?.message || parsed?.error || text.slice(0, 200);
+ throw new Error(`gett ${method} ${path.split("?")[0]} -> ${res.status}: ${detail}`);
+ }
+ return parsed ?? {};
+}
+
+// ---------- device identity (generated once, then persisted) ----------
+async function ensureDevice(ctx: ActionContext): Promise {
+ const cfg = cfgOf(ctx);
+ const patch: Record = {};
+ if (!cfg.deviceId) patch.deviceId = randomUUID();
+ if (!cfg.clientDeviceUniqueId) patch.clientDeviceUniqueId = randomUUID();
+ if (!cfg.deviceGeneratedToken) patch.deviceGeneratedToken = randomUUID();
+ if (!cfg.gaid) patch.gaid = randomUUID();
+ if (Object.keys(patch).length) await ctx.updateConnection(patch);
+ return cfgOf(ctx);
+}
+
+// ---------- auth ----------
+async function accessToken(ctx: ActionContext, force = false): Promise {
+ const cfg = cfgOf(ctx);
+ if (!cfg.refreshToken)
+ throw new Error(
+ "gett: not connected — run connect({ phone }) -> connect({ code }) -> connect({ card }) (owner login)",
+ );
+ if (!cfg.phone) throw new Error("gett: no phone configured");
+ if (!force && cfg.accessToken && cfg.accessTokenExpiresAt && Date.now() < cfg.accessTokenExpiresAt - 60_000)
+ return cfg.accessToken;
+ // /auth/token renews the access token from the refresh token. The `?lc=en` query param is REQUIRED:
+ // the app sends it on every /auth/token and the server 400s (empty body) without it. Access-token TTL
+ // is irrelevant to the grant (a bearer expired by days still refreshes); a genuine 400 here means the
+ // refresh token itself is dead and a fresh owner login is required.
+ let resp: any;
+ try {
+ resp = await http(ctx, `/gl/api/v2/phone/${cfg.phone}/auth/token?lc=en`, {
+ method: "POST",
+ token: cfg.accessToken || cfg.refreshToken,
+ body: { grant_type: "refresh_token", refresh_token: cfg.refreshToken },
+ });
+ } catch (e) {
+ if (/-> 400/.test(String((e as Error).message)))
+ throw new Error(
+ "gett: session expired (refresh rejected) — re-run the owner login: connect({ phone }) -> connect({ code }) -> connect({ card })",
+ );
+ throw e;
+ }
+ if (!resp.access_token) throw new Error("gett: token refresh returned no access_token");
+ 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; // rotate
+ await ctx.updateConnection(patch);
+ return resp.access_token;
+}
+
+async function authed(ctx: ActionContext, path: string, opts: { method?: string; body?: unknown } = {}) {
+ return http(ctx, path, { ...opts, token: await accessToken(ctx) });
+}
+
+// ---------- connect: headless owner login (phone + SMS OTP + card-digits MFA) ----------
+async function connectChallenge(ctx: ActionContext, phone?: string) {
+ await ensureDevice(ctx);
+ let cfg = cfgOf(ctx);
+ if (phone) {
+ await ctx.updateConnection({ phone: normPhone(String(phone)) });
+ cfg = cfgOf(ctx);
+ }
+ if (!cfg.phone) throw new Error("gett connect: { phone } required (e.g. 972500000000)");
+ const body = {
+ country_code: "IL",
+ country_phone_prefix: 972,
+ registration_origin: "organic",
+ method: "sms",
+ client_device_unique_id: cfg.clientDeviceUniqueId,
+ device_tag: "android_ww",
+ appsflyer_uid: `${Date.now()}-${Math.floor(Math.random() * 1e18)}`,
+ gaid: "",
+ };
+ const r = await http(ctx, `/gl/api/v2/phone/${cfg.phone}/auth/otp/challenge`, { method: "POST", body });
+ // Gett answers 200 even when it refuses to send (rate-limit/block); the verdict is in
+ // the body's rc/status, not the HTTP code. Success is rc:0 / status:"success". Surface a
+ // block instead of falsely reporting the SMS went out, so the caller waits rather than
+ // asking for a code that never arrives.
+ if ((r.rc != null && r.rc !== 0) || (r.status && r.status !== "success")) {
+ const mins = r.blocked_until != null ? Number(r.blocked_until) : null;
+ return {
+ step: r.status === "blocked" ? "blocked" : "error",
+ phone: cfg.phone,
+ status: r.status ?? null,
+ rc: r.rc ?? null,
+ retry_after_minutes: mins,
+ error: r.error || "Gett refused the OTP challenge and did NOT send an SMS.",
+ note:
+ r.status === "blocked"
+ ? `Too many attempts — Gett blocked new codes${mins != null ? ` for ~${mins} min` : ""}. Wait, then run connect({ phone }) again.`
+ : "No SMS was sent. Check the phone digits, then retry connect({ phone }).",
+ };
+ }
+ return { step: "otp_sent", phone: cfg.phone, code_length: r.confirmation_code_length ?? 6, next: "connect({ code })" };
+}
+
+async function connectCode(ctx: ActionContext, code: string) {
+ const cfg = cfgOf(ctx);
+ if (!cfg.phone) throw new Error("gett connect: no phone — run connect({ phone }) first");
+ const r = await http(ctx, `/gl/api/v2/phone/${cfg.phone}/auth/otp/verify`, {
+ method: "POST",
+ body: { code: String(code) },
+ });
+ const toks = r.tokens || (r.access_token ? r : null);
+ if (toks && toks.refresh_token) {
+ await finishTokens(ctx, toks);
+ return { step: "connected", note: "no MFA needed", ...(await connectStatus(ctx)) };
+ }
+ if (!r.mfa_required && r.status !== "mfa_required")
+ throw new Error(`gett connect: unexpected otp/verify response: ${JSON.stringify(r).slice(0, 200)}`);
+ await ctx.updateConnection({ pendingTempCode: r.temp_code });
+ return {
+ step: "mfa_required",
+ challenge: r.mfa_challenge,
+ methods: r.mfa_methods,
+ masked_email: r.mfa_masked_email,
+ next: r.mfa_methods?.includes("credit_card") ? "connect({ card })" : "connect({ card })",
+ };
+}
+
+async function connectCard(ctx: ActionContext, digits: string) {
+ const cfg = cfgOf(ctx);
+ if (!cfg.pendingTempCode) throw new Error("gett connect: no pending MFA — run connect({ code }) first");
+ const r = await http(ctx, `/gl/api/v2/phone/${cfg.phone}/auth/mfa/verify`, {
+ method: "POST",
+ body: { temp_code: cfg.pendingTempCode, card_digits: String(digits) },
+ });
+ const toks = r.tokens || (r.access_token ? r : null);
+ if (!toks || !toks.refresh_token)
+ throw new Error(`gett connect: mfa/verify failed: ${r.error || r.status || JSON.stringify(r).slice(0, 200)}`);
+ await ctx.updateConnection({ pendingTempCode: undefined });
+ await finishTokens(ctx, toks);
+ return { step: "connected", ...(await connectStatus(ctx)) };
+}
+
+// Store the (IL-scoped) tokens, convert to the GL family the app uses for ongoing refresh, then
+// learn identity + the saved card from create_session.
+async function finishTokens(ctx: ActionContext, toks: any) {
+ await ctx.updateConnection({
+ refreshToken: toks.refresh_token,
+ accessToken: toks.access_token,
+ accessTokenExpiresAt: Date.now() + (toks.expires_in ? toks.expires_in * 1000 : 3_600_000),
+ });
+ const cfg = cfgOf(ctx);
+ // IL -> GL conversion (first /auth/token, Bearer = the fresh access token). `?lc=en` is REQUIRED here
+ // too — without the query param the conversion 400s even with a valid IL bearer.
+ try {
+ const gl = await http(ctx, `/gl/api/v2/phone/${cfg.phone}/auth/token?lc=en`, {
+ method: "POST",
+ token: cfg.accessToken,
+ body: { grant_type: "refresh_token", refresh_token: cfg.refreshToken },
+ });
+ const patch: Record = {};
+ if (gl.refresh_token) patch.refreshToken = gl.refresh_token;
+ if (gl.access_token) {
+ patch.accessToken = gl.access_token;
+ patch.accessTokenExpiresAt = Date.now() + (gl.expires_in ? gl.expires_in * 1000 : 3_600_000);
+ }
+ if (Object.keys(patch).length) await ctx.updateConnection(patch);
+ } catch {
+ /* IL token still works for reads if conversion hiccups */
+ }
+ try {
+ await createSession(ctx, DEF_LAT, DEF_LON);
+ } catch {
+ /* identity/card discovery is best-effort */
+ }
+}
+
+// pull the default saved card out of a create_session response
+function discoverCard(sessionResp: any): { id: string; last4?: string; type?: string } | null {
+ try {
+ const list = sessionResp?.payment_methods?.data?.list || [];
+ for (const entry of list)
+ for (const pm of entry.payment_methods || []) {
+ const cards = pm?.additional_data?.credit_card?.cards || [];
+ const card = cards.find((c: any) => c.is_default) || cards[0];
+ if (card?.card_id) return { id: card.card_id, last4: card.card_number, type: card.card_type };
+ }
+ } catch {
+ /* ignore */
+ }
+ return null;
+}
+
+async function connectStatus(ctx: ActionContext) {
+ const cfg = cfgOf(ctx);
+ const need: string[] = [];
+ if (!cfg.phone) need.push("phone");
+ if (!cfg.refreshToken) need.push("refreshToken (login)");
+ if (!cfg.creditCardId) need.push("creditCardId (auto-discovered on connect)");
+ return {
+ connected: !!cfg.refreshToken,
+ phone: cfg.phone || null,
+ name: cfg.name || null,
+ globalUserId: cfg.globalUserId || null,
+ creditCard: cfg.creditCardId || null,
+ pendingMfa: !!cfg.pendingTempCode,
+ missing: need,
+ // No keepalive schedule is needed: the GL refresh token is long-lived (~90d) and the refresh grant
+ // doesn't need a live access token, so a stored session survives unattended. `refresh` is on-demand.
+ keepalive: null,
+ };
+}
+
+// ---------- session bootstrap (identity + card discovery) ----------
+async function createSession(ctx: ActionContext, lat: number, lon: number): Promise {
+ const cfg = cfgOf(ctx);
+ const body = {
+ dt: "Android, runline",
+ av: APP_VERSION,
+ os: "16",
+ device_generated_token: cfg.deviceGeneratedToken,
+ client_device_unique_id: cfg.clientDeviceUniqueId,
+ gcm: 1,
+ np: 0,
+ lc: "en",
+ cc: "IL",
+ ai: "com.gettaxi.android",
+ lat: num(lat, DEF_LAT),
+ lon: num(lon, DEF_LON),
+ app_provider: "gettaxi",
+ platform: "android",
+ gaid: cfg.gaid,
+ };
+ const r = await authed(ctx, `/gl/rider-facade/phone/${cfg.phone}/create_session`, { method: "POST", body });
+ const up = r.user_profile || {};
+ const patch: Record = {};
+ if (up.global_user_id && !cfg.globalUserId) patch.globalUserId = up.global_user_id;
+ if (up.first_name && !cfg.name) patch.name = up.first_name;
+ if (!cfg.creditCardId) {
+ const c = discoverCard(r);
+ if (c?.id) patch.creditCardId = c.id;
+ }
+ if (Object.keys(patch).length) await ctx.updateConnection(patch);
+ return r;
+}
+
+function user(ctx: ActionContext) {
+ const cfg = cfgOf(ctx);
+ return {
+ global_id: Number(cfg.globalUserId) || 0,
+ name: cfg.name || "",
+ phone: cfg.phone,
+ vip: { is_vip: false, level: 0 },
+ };
+}
+
+// ---------- place search: autocomplete -> retrieve ----------
+function shapeCandidate(l: any) {
+ const loc = l.location || {};
+ const poi = loc.poi || {};
+ return {
+ id: l.id ?? "",
+ place_id: l.provider_place_id || poi.id || null,
+ provider: (l.provider || poi.provider || "GOOGLE").toUpperCase(),
+ name: loc.main_text || poi.name || loc.title || null,
+ secondary: loc.secondary_text || null,
+ full_address: loc.complete_address || loc.title || loc.main_text || null,
+ lat: loc.lat ?? null,
+ lng: loc.lng ?? null,
+ type: loc.type || "point_of_interest",
+ };
+}
+
+async function findPlace(ctx: ActionContext, query: string, lat: number, lon: number) {
+ if (!query) throw new Error("gett find_place: a query is required");
+ const body = {
+ autocomplete_query: {
+ input: String(query),
+ locale: "en",
+ coordinates: { lat: num(lat, DEF_LAT), lng: num(lon, DEF_LON) },
+ providers: [
+ { name: "google", limit: 6 },
+ { name: "gett", limit: 6 },
+ ],
+ },
+ };
+ const r = await authed(ctx, "/gl/locations-proxy/api/v2/locations/autocomplete", { method: "POST", body });
+ return (r.locations || []).map(shapeCandidate).filter((c: any) => c.name);
+}
+
+async function retrievePlace(ctx: ActionContext, cand: any) {
+ const body = {
+ locations: [
+ {
+ id: String(cand.id || ""),
+ provider_place_Id: cand.place_id,
+ provider: cand.provider,
+ locale: "en",
+ location: { poi: { provider: cand.provider, name: cand.name } },
+ },
+ ],
+ };
+ const r = await authed(ctx, "/gl/locations-proxy/api/v1/locations/retrieve", { method: "POST", body });
+ const e = (r.locations || [])[0];
+ if (!e) return cand;
+ const loc = e.location || {};
+ const c = loc.components || {};
+ const poi = loc.poi || {};
+ return {
+ place_id: e.provider_place_id || poi.id || cand.place_id,
+ provider: (e.provider || cand.provider).toUpperCase(),
+ name: loc.main_text || poi.name || cand.name,
+ full_address: loc.complete_address || loc.title || cand.full_address,
+ lat: loc.lat ?? cand.lat,
+ lng: loc.lng ?? cand.lng,
+ type: loc.type || cand.type || "establishment",
+ city: c.locality || loc.secondary_text || "",
+ state: c.state || "",
+ country: c.country || "Israel",
+ country_code: c.country_code || "IL",
+ };
+}
+
+async function resolvePlace(ctx: ActionContext, query: string, lat: number, lon: number) {
+ const list = await findPlace(ctx, query, lat, lon);
+ if (!list.length) throw new Error(`gett: no place found for "${query}"`);
+ const cand = list.find((c: any) => c.provider === "GOOGLE" && c.place_id) || list[0];
+ return retrievePlace(ctx, cand);
+}
+
+function stopLocation(place: any, kind: "origin" | "destination", ctx: ActionContext) {
+ const est = !!place.place_id;
+ return {
+ actions: [{ type: kind === "origin" ? "pick_up" : "drop_off", user: user(ctx) }],
+ location: {
+ address: {
+ city: place.city || "",
+ country: place.country || "Israel",
+ full_address: place.full_address || place.name || "",
+ poi: !!est,
+ poi_name: place.name || "",
+ state: place.state || "",
+ title: place.full_address || place.name || "",
+ type: est ? "establishment" : "point",
+ },
+ lat: place.lat,
+ lng: place.lng,
+ poi_place: place.place_id ? { id: place.place_id, provider: place.provider } : undefined,
+ source: "autocomplete",
+ type: est ? "establishment" : "point",
+ },
+ type: kind,
+ };
+}
+
+function flatPlace(place: any) {
+ return {
+ poi_id: place.place_id,
+ poi_provider: place.provider,
+ provider: place.provider,
+ country: place.country || "Israel",
+ source: "autocomplete",
+ city: place.city || "",
+ complete_address: place.full_address || place.name || "",
+ id: 0,
+ lat: place.lat,
+ lon: place.lng,
+ address_lat: place.lat,
+ address_lon: place.lng,
+ place_id: place.place_id,
+ poi: true,
+ poi_name: place.name || "",
+ poi_type: place.type || "establishment",
+ state: place.state || "",
+ title: place.full_address || place.name || "",
+ valid: true,
+ country_code: place.country_code || "IL",
+ address_type: place.type || "establishment",
+ };
+}
+
+// ---------- pricing ----------
+async function preorder(ctx: ActionContext, originStop: any, destStop: any) {
+ const cfg = cfgOf(ctx);
+ const body = {
+ stops: [originStop, destStop],
+ phone: cfg.phone,
+ country_code: "IL",
+ category: "transportation",
+ payment_type: "credit_card",
+ source: "mobile",
+ };
+ const r = await authed(ctx, "/gl/api/v1/preorder/aggregated", { method: "POST", body });
+ const classes = (r.classes_with_prices || []).map((c: any) => {
+ const po = (c.price?.pricing_options || [])[0] || {};
+ return {
+ class_uuid: c.class?.uuid ?? null,
+ name: c.class?.name ?? null,
+ category: c.class?.category ?? null,
+ subcategory: c.class?.subcategory ?? "default",
+ estimation_id: po.estimation_id ?? null,
+ display_price: po.user_price || po.full_price || null,
+ currency: po.currency_iso || null,
+ eta: c.class?.display_eta ?? c.class?.eta ?? null,
+ };
+ });
+ const route_id = (r.routes || [])[0]?.uuid ?? null;
+ const default_class = r.private_default_class_uuid ?? classes[0]?.class_uuid ?? null;
+ return { classes, route_id, default_class };
+}
+
+async function planRide(ctx: ActionContext, fromQuery: string, toQuery: string, lat: number, lon: number, classUuid?: string) {
+ await createSession(ctx, lat, lon);
+ const [from, to] = await Promise.all([resolvePlace(ctx, fromQuery, lat, lon), resolvePlace(ctx, toQuery, lat, lon)]);
+ const originStop = stopLocation(from, "origin", ctx);
+ const destStop = stopLocation(to, "destination", ctx);
+ const pricing = await preorder(ctx, originStop, destStop);
+ const chosen = classUuid
+ ? pricing.classes.find((c: any) => c.class_uuid === classUuid)
+ : pricing.classes.find((c: any) => c.class_uuid === pricing.default_class) || pricing.classes[0];
+ if (!chosen) throw new Error("gett: no ride class available for this route");
+ return { from, to, originStop, destStop, pricing, chosen };
+}
+
+async function bookRide(ctx: ActionContext, plan: any, note?: string) {
+ const cfg = cfgOf(ctx);
+ if (!cfg.creditCardId) throw new Error("gett: no saved card (creditCardId) — reconnect to auto-discover it");
+ const { from, to, originStop, destStop, pricing, chosen } = plan;
+ const body = {
+ stops: [originStop, destStop],
+ division_name: chosen.name || "Taxi",
+ route_id: pricing.route_id,
+ route_provider: "google",
+ ofse_order_flow: false,
+ origin: flatPlace(from),
+ destination: flatPlace(to),
+ note_to_driver: note ? String(note) : "",
+ business: 0,
+ token: randomUUID(),
+ app_provider: "gettaxi",
+ user_current_time: Math.floor(Date.now() / 1000),
+ credit_card_id: cfg.creditCardId,
+ payment_type: "credit_card",
+ ordered_from: "Phone",
+ division_uuid: chosen.class_uuid,
+ category: chosen.category || "transportation",
+ subcategory: chosen.subcategory || "default",
+ fix_charge_opt_out: true,
+ estimation_id: chosen.estimation_id,
+ show_class_pricing_info: false,
+ timezone_id: "Asia/Jerusalem",
+ };
+ return authed(ctx, "/il/global-ride-request/api/v1/create", { method: "POST", body });
+}
+
+// ---------- plugin ----------
+export default function gett(rl: RunlinePluginAPI) {
+ rl.setName("gett");
+ rl.setVersion("0.1.0");
+
+ rl.setConnectionSchema({
+ phone: { type: "string", required: false, description: "Account phone; international or local Israeli digits both work (e.g. 972526471797, 0526471797, or 052-647-1797 — all normalized to 972…).", env: "GETT_PHONE" },
+ refreshToken: { type: "string", required: false, description: "Refresh JWT from an owner login; mints access tokens. Set by connect().", env: "GETT_REFRESH_TOKEN" },
+ creditCardId: { type: "string", required: false, description: "Saved card id charged for rides. Auto-discovered on connect.", env: "GETT_CREDIT_CARD_ID" },
+ deviceId: { type: "string", required: false, description: "x-device-id for the device session (generated if absent).", env: "GETT_DEVICE_ID" },
+ clientDeviceUniqueId: { type: "string", required: false, description: "x-client-device-unique-id (generated if absent).", env: "GETT_CLIENT_DEVICE_UNIQUE_ID" },
+ defaultLat: { type: "string", required: false, description: "Home latitude for place search when a request carries none (e.g. 32.0779).", env: "GETT_DEFAULT_LAT" },
+ defaultLon: { type: "string", required: false, description: "Home longitude for place search (e.g. 34.7743).", env: "GETT_DEFAULT_LON" },
+ allowOrdering: { type: "boolean", required: false, default: false, description: "Master switch for booking/cancelling rides. Off = read-only. Even on, book_ride still needs confirm:true." },
+ });
+
+ rl.registerAction("connect", {
+ access: "write",
+ description:
+ "Owner login for the Gett account (phone + SMS OTP + card-digits MFA), headless after a one-time relay. Call with { phone } to send the SMS; then { code } with the SMS code; then { card } with the last 4 digits of the saved card. A trusted device skips the card step. { status: true } reports what the session still needs. After this the refresh token drives every read/booking with no further login.",
+ inputSchema: {
+ phone: { type: "string", required: false, description: "Account phone; international or local Israeli digits both work (972526471797 / 0526471797 / 052-647-1797). Sends the SMS OTP." },
+ code: { type: "string", required: false, description: "The SMS one-time code the owner received." },
+ card: { type: "string", required: false, description: "Last 4 digits of the saved card (the MFA second factor)." },
+ status: { type: "boolean", required: false, description: "Just report connection status / what's missing." },
+ },
+ async execute(input, ctx) {
+ const p = (input ?? {}) as Record;
+ if (p.status === true) return connectStatus(ctx);
+ if (p.card != null && p.card !== "") return connectCard(ctx, String(p.card));
+ if (p.code != null && p.code !== "") return connectCode(ctx, String(p.code));
+ if (p.phone != null && p.phone !== "") return connectChallenge(ctx, String(p.phone));
+ return connectStatus(ctx);
+ },
+ });
+
+ rl.registerAction("refresh", {
+ access: "write",
+ description:
+ "Mint a fresh access token on demand from the stored refresh token. NOT time-critical and NO keepalive schedule is needed — the refresh token is long-lived (~90 days) and survives unattended, so a session stays valid indefinitely after connect. 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 Gett account: user, phone, and any active orders/requests. Confirms the session is authenticated.",
+ inputSchema: {},
+ async execute(_input, ctx) {
+ const r = await createSession(ctx, DEF_LAT, DEF_LON);
+ const up = r.user_profile || {};
+ return {
+ global_user_id: up.global_user_id ?? cfgOf(ctx).globalUserId ?? null,
+ name: up.first_name ?? cfgOf(ctx).name ?? null,
+ phone: cfgOf(ctx).phone,
+ active_orders: (r.active_orders || []).map((o: any) => ({ id: o.id ?? o.order_id, status: o.status })),
+ active_requests: (r.active_requests || []).length,
+ };
+ },
+ });
+
+ rl.registerAction("find_place", {
+ access: "read",
+ description: "Search Gett for a pickup or drop-off place by name ('Dizengoff Square', 'Ben Gurion Airport'). Returns candidates with coordinates and address. Hebrew works. Read-only.",
+ inputSchema: {
+ query: { type: "string", required: true, description: "Place name or address to search for." },
+ lat: { type: "number", required: false, description: "Bias latitude (defaults to the connection's home location)." },
+ lon: { type: "number", required: false, description: "Bias longitude." },
+ },
+ async execute(input, ctx) {
+ const p = input as Record;
+ const cfg = cfgOf(ctx);
+ return findPlace(ctx, String(p.query), num(p.lat ?? cfg.defaultLat, DEF_LAT), num(p.lon ?? cfg.defaultLon, DEF_LON));
+ },
+ });
+
+ rl.registerAction("price", {
+ access: "read",
+ description: "Price a ride between two places by name: every available ride class (Taxi, Priority, …) with its fare and pickup ETA. Read-only — books nothing.",
+ inputSchema: {
+ from: { type: "string", required: true, description: "Pickup place/address." },
+ to: { type: "string", required: true, description: "Destination place/address." },
+ lat: { type: "number", required: false, description: "Bias latitude for place search." },
+ lon: { type: "number", required: false, description: "Bias longitude." },
+ },
+ async execute(input, ctx) {
+ const p = input as Record;
+ const cfg = cfgOf(ctx);
+ const plan = await planRide(ctx, String(p.from), String(p.to), num(p.lat ?? cfg.defaultLat, DEF_LAT), num(p.lon ?? cfg.defaultLon, DEF_LON));
+ return {
+ from: { name: plan.from.name, address: plan.from.full_address },
+ to: { name: plan.to.name, address: plan.to.full_address },
+ options: plan.pricing.classes.map((c: any) => ({ ride_class: c.name, price: c.display_price, currency: c.currency, eta: c.eta, class_uuid: c.class_uuid })),
+ };
+ },
+ });
+
+ rl.registerAction("nearby_drivers", {
+ access: "read",
+ description: "Live positions of Gett drivers near a location (for a map or an availability check). Read-only.",
+ inputSchema: {
+ 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;
+ const cfg = cfgOf(ctx);
+ const r = await authed(ctx, `/gl/api/v2/drivers/locations?lat=${num(p.lat ?? cfg.defaultLat, DEF_LAT)}&lng=${num(p.lon ?? cfg.defaultLon, DEF_LON)}`).catch(() => ({}));
+ return (r.drivers || []).map((d: any) => ({ id: d.id, status: d.status ?? null, location: (d.last_locations || [])[0] ?? null, route_eta_ts: d.route_eta_ts ?? null }));
+ },
+ });
+
+ rl.registerAction("ride_status", {
+ access: "read",
+ description: "Track a booked ride ('where's my taxi?'): status, ETA in seconds, distance, whether it's still cancellable, and driver info once assigned. Needs the order id from book_ride.",
+ inputSchema: { order_id: { type: "string", required: true, description: "Order id from book_ride." } },
+ async execute(input, ctx) {
+ const p = input as Record;
+ const cfg = cfgOf(ctx);
+ const r = await authed(ctx, `/gl/server/3_3/phone/${cfg.phone}/orders/${p.order_id}`);
+ return {
+ order_id: r.id ?? p.order_id,
+ status: r.status ?? null,
+ eta_seconds: r.eta ?? null,
+ distance_m: r.distance ?? null,
+ cancellable: r.cancellable_by_client ?? null,
+ driver_assigned_at: r.driver_assigned_at ?? null,
+ payment_type: r.payment_type ?? null,
+ driver: r.driver ?? r.driver_details ?? null,
+ };
+ },
+ });
+
+ rl.registerAction("book_ride", {
+ access: "write",
+ description:
+ "Book a REAL Gett taxi — this spends money and summons an actual car to a real person. Call it FIRST without confirm to get a priced preview (pickup, destination, ride class, fare, ETA) and a requiresConfirmation flag; read the fare + pickup back, get an explicit yes, then call again with confirm:true. Requires the connection's allowOrdering. Payment is the account's saved card.",
+ inputSchema: {
+ from: { type: "string", required: true, description: "Pickup place/address." },
+ to: { type: "string", required: true, description: "Destination place/address." },
+ class_uuid: { type: "string", required: false, description: "Specific ride class (from price). Defaults to the account's default class." },
+ note: { type: "string", required: false, description: "Note to the driver." },
+ confirm: { type: "boolean", required: false, description: "Set true ONLY after the person heard the fare + pickup and said yes. Actually books (and pays for) the ride." },
+ lat: { type: "number", required: false, description: "Bias latitude for place search." },
+ lon: { type: "number", required: false, description: "Bias longitude." },
+ },
+ async execute(input, ctx) {
+ const p = input as Record;
+ const cfg = cfgOf(ctx);
+ const plan = await planRide(ctx, String(p.from), String(p.to), num(p.lat ?? cfg.defaultLat, DEF_LAT), num(p.lon ?? cfg.defaultLon, DEF_LON), p.class_uuid ? String(p.class_uuid) : undefined);
+ const preview = {
+ from: { name: plan.from.name, address: plan.from.full_address },
+ to: { name: plan.to.name, address: plan.to.full_address },
+ ride_class: plan.chosen.name,
+ price: plan.chosen.display_price,
+ currency: plan.chosen.currency,
+ eta_to_pickup: plan.chosen.eta,
+ class_uuid: plan.chosen.class_uuid,
+ };
+ if (p.confirm !== true)
+ return { requiresConfirmation: true, action: "book_ride", summary: preview, note: "This books a REAL taxi and charges the saved card. Re-run with confirm:true to book." };
+ if (!cfg.allowOrdering) throw new Error("gett book_ride: ordering is disabled for this connection (set allowOrdering:true). Refusing to book.");
+ const r = await bookRide(ctx, plan, p.note ? String(p.note) : undefined);
+ const orderId = r.order?.id ?? r.order_id ?? null;
+ return { ok: r.rc === 0 || r.status === "success", status: "booked", order_id: orderId, summary: preview, track_with: `ride_status(${orderId})` };
+ },
+ });
+
+ rl.registerAction("cancel_ride", {
+ access: "write",
+ description: "Cancel a booked Gett ride. Free inside the cancellation window (check ride_status.cancellable); a late cancel may incur a fee. Requires the connection's allowOrdering.",
+ inputSchema: {
+ order_id: { type: "string", required: true, description: "Order id from book_ride." },
+ reason: { type: "number", required: false, description: "Cancellation reason id (optional)." },
+ },
+ async execute(input, ctx) {
+ const p = input as Record;
+ const cfg = cfgOf(ctx);
+ if (!cfg.allowOrdering) throw new Error("gett cancel_ride: disabled for this connection (set allowOrdering:true).");
+ if (p.reason != null) {
+ await authed(ctx, `/gl/server/2_9/phone/${cfg.phone}/orders/${p.order_id}/order_cancellation_reason`, {
+ method: "POST",
+ body: { cancellation_reason_id: Number(p.reason) },
+ }).catch(() => {});
+ }
+ const r = await authed(ctx, `/gl/server/3_0/phone/${cfg.phone}/orders/${p.order_id}/cancel`, { method: "POST", body: {} });
+ return { cancelled: r.rc === 0, order_id: p.order_id, result: r };
+ },
+ });
+}
diff --git a/packages/runline-plugins/icons/gett.svg b/packages/runline-plugins/icons/gett.svg
new file mode 100644
index 0000000..0f5c038
--- /dev/null
+++ b/packages/runline-plugins/icons/gett.svg
@@ -0,0 +1 @@
+