runline 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plugins/_shared/authedFetch.js +33 -0
- package/dist/plugins/_shared/provider.js +80 -0
- package/dist/plugins/_shared/tokenRefresh.js +5 -3
- package/dist/plugins/gett/src/index.js +358 -0
- package/dist/plugins/gett/src/places.js +140 -0
- package/dist/plugins/gett/src/rides.js +144 -0
- package/dist/plugins/gett/src/session.js +242 -0
- package/dist/plugins/gett/src/shared.js +180 -0
- package/dist/plugins/linear/src/attachments.js +2 -2
- package/dist/plugins/linear/src/cycles.js +6 -6
- package/dist/plugins/linear/src/index.js +2 -2
- package/dist/plugins/linear/src/initiatives.js +6 -6
- package/dist/plugins/linear/src/issues.js +9 -9
- package/dist/plugins/linear/src/labels.js +6 -6
- package/dist/plugins/linear/src/organization.js +2 -2
- package/dist/plugins/linear/src/projects.js +12 -12
- package/dist/plugins/linear/src/shared.js +148 -20
- package/dist/plugins/linear/src/states.js +3 -3
- package/dist/plugins/linear/src/teams.js +6 -5
- package/dist/plugins/linear/src/users.js +3 -3
- package/dist/plugins/linear/src/views.js +10 -8
- package/dist/plugins/linear/src/webhooks.js +5 -5
- package/dist/plugins/openai/src/index.js +28 -9
- package/dist/plugins/typesafe/src/guide.js +172 -0
- package/dist/plugins/typesafe/src/index.js +169 -0
- package/dist/plugins/typesafe/src/questions.js +180 -0
- package/dist/plugins/typesafe/src/shared.js +166 -0
- package/dist/plugins/wolt/src/account.js +427 -0
- package/dist/plugins/wolt/src/catalog.js +166 -0
- package/dist/plugins/wolt/src/index.js +526 -0
- package/dist/plugins/wolt/src/orders.js +349 -0
- package/dist/plugins/wolt/src/shared.js +218 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type { ConnectionHandle, ConnectionPatch, ConnectionProvider, ConnectionR
|
|
|
11
11
|
export type { ActionInvocation, EngineHooks, EngineOptions, ExecuteResult, } from "./core/engine.js";
|
|
12
12
|
export { ExecutionEngine } from "./core/engine.js";
|
|
13
13
|
export type { BuildAuthUrlOptions, ExchangeCodeOptions, OAuthTokens, PKCEPair, RunOAuthOptions, } from "./core/oauth.js";
|
|
14
|
-
export { buildAuthUrl, exchangeAuthCode, generatePKCE, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_URI, runOAuth, } from "./core/oauth.js";
|
|
14
|
+
export { buildAuthUrl, exchangeAuthCode, generatePKCE, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_URI, oauthCallback, runOAuth, } from "./core/oauth.js";
|
|
15
15
|
export { downloadResource } from "./credentials/http.js";
|
|
16
16
|
export { CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, validateCredential, } from "./credentials/registry.js";
|
|
17
17
|
export type { AuthenticatedRequest, CredentialTransportOptions, } from "./credentials/transport.js";
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ export { DEFAULT_CONFIG } from "./config/types.js";
|
|
|
6
6
|
export { FileConnectionProvider } from "./connections/file.js";
|
|
7
7
|
export { MemoryConnectionProvider } from "./connections/memory.js";
|
|
8
8
|
export { ExecutionEngine } from "./core/engine.js";
|
|
9
|
-
export { buildAuthUrl, exchangeAuthCode, generatePKCE, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_URI, runOAuth, } from "./core/oauth.js";
|
|
9
|
+
export { buildAuthUrl, exchangeAuthCode, generatePKCE, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_URI, oauthCallback, runOAuth, } from "./core/oauth.js";
|
|
10
10
|
export { downloadResource } from "./credentials/http.js";
|
|
11
11
|
export { CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, validateCredential, } from "./credentials/registry.js";
|
|
12
12
|
export { CredentialTransport } from "./credentials/transport.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fetch` for a request that carries a credential.
|
|
3
|
+
*
|
|
4
|
+
* Every plugin outside the credential registry hand-rolls its provider calls,
|
|
5
|
+
* and a bare `fetch` follows redirects by default — which hands the
|
|
6
|
+
* `Authorization` header to whatever host the response names. The registry's
|
|
7
|
+
* own transport (`runline/src/credentials/http.ts`) refuses redirects for
|
|
8
|
+
* exactly this reason; this is the same guarantee for the plugins that do not
|
|
9
|
+
* go through it.
|
|
10
|
+
*
|
|
11
|
+
* Drop-in: same signature and same `Response` as `fetch`, so a call site
|
|
12
|
+
* changes by one identifier and nothing downstream moves.
|
|
13
|
+
*
|
|
14
|
+
* const res = await authedFetch(url, { headers: { Authorization: key } });
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* `redirect: "error"` makes fetch reject rather than follow. The
|
|
18
|
+
* `response.redirected` and 3xx checks are belt to that brace: a runtime whose
|
|
19
|
+
* fetch ignores the option, or a hook that replaces it, must not silently
|
|
20
|
+
* reopen the hole.
|
|
21
|
+
*/
|
|
22
|
+
export async function authedFetch(input, init = {}) {
|
|
23
|
+
const response = await fetch(input, { ...init, redirect: "error" });
|
|
24
|
+
const followed = response.redirected;
|
|
25
|
+
const offered = response.status >= 300 && response.status < 400;
|
|
26
|
+
if (followed || offered) {
|
|
27
|
+
void response.body?.cancel().catch(() => { });
|
|
28
|
+
throw new Error(followed
|
|
29
|
+
? "Refusing a request that followed a redirect while carrying credentials."
|
|
30
|
+
: `Refusing a redirect on a request carrying credentials (HTTP ${response.status}).`);
|
|
31
|
+
}
|
|
32
|
+
return response;
|
|
33
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for plugins that consume an undocumented provider JSON API over a
|
|
3
|
+
* bearer token (gett, wolt): total readers for unshaped payloads, path-segment
|
|
4
|
+
* safety, and a bounded body read.
|
|
5
|
+
*
|
|
6
|
+
* The readers are total by design. These payloads are deep and change without
|
|
7
|
+
* notice, so a missing or wrongly-typed field yields the empty value for its
|
|
8
|
+
* shape rather than throwing halfway through a response and losing the rest.
|
|
9
|
+
*/
|
|
10
|
+
export function obj(value) {
|
|
11
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
12
|
+
? value
|
|
13
|
+
: {};
|
|
14
|
+
}
|
|
15
|
+
export function arr(value) {
|
|
16
|
+
return Array.isArray(value) ? value : [];
|
|
17
|
+
}
|
|
18
|
+
/** The first non-empty string among the candidates, else null. Numbers count. */
|
|
19
|
+
export function pick(...values) {
|
|
20
|
+
for (const value of values) {
|
|
21
|
+
if (typeof value === "string" && value !== "")
|
|
22
|
+
return value;
|
|
23
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
24
|
+
return String(value);
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
export function numOrNull(value) {
|
|
29
|
+
if (typeof value === "number")
|
|
30
|
+
return Number.isFinite(value) ? value : null;
|
|
31
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
32
|
+
const parsed = Number(value);
|
|
33
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
export const num = (value, fallback) => numOrNull(value) ?? fallback;
|
|
38
|
+
/**
|
|
39
|
+
* One path segment, escaped. Caller-supplied ids reach a URL carrying a bearer
|
|
40
|
+
* token, so a segment that could climb out of its position is refused outright
|
|
41
|
+
* rather than encoded and hoped about.
|
|
42
|
+
*/
|
|
43
|
+
export function seg(value, what, plugin) {
|
|
44
|
+
const raw = String(value ?? "").trim();
|
|
45
|
+
if (!raw || raw === "." || raw === ".." || /[/\\?#]/.test(raw)) {
|
|
46
|
+
throw new Error(`${plugin}: invalid ${what}`);
|
|
47
|
+
}
|
|
48
|
+
return encodeURIComponent(raw);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Read a response body with a ceiling, so a hostile or broken endpoint cannot
|
|
52
|
+
* make the process hold an unbounded string.
|
|
53
|
+
*/
|
|
54
|
+
export async function readBounded(res, maxBytes, oversizeMessage) {
|
|
55
|
+
const reader = res.body?.getReader();
|
|
56
|
+
if (!reader)
|
|
57
|
+
return "";
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let total = 0;
|
|
60
|
+
for (;;) {
|
|
61
|
+
const { done, value } = await reader.read();
|
|
62
|
+
if (done)
|
|
63
|
+
break;
|
|
64
|
+
if (!value)
|
|
65
|
+
continue;
|
|
66
|
+
total += value.byteLength;
|
|
67
|
+
if (total > maxBytes) {
|
|
68
|
+
await reader.cancel();
|
|
69
|
+
throw new Error(oversizeMessage);
|
|
70
|
+
}
|
|
71
|
+
chunks.push(value);
|
|
72
|
+
}
|
|
73
|
+
const joined = new Uint8Array(total);
|
|
74
|
+
let offset = 0;
|
|
75
|
+
for (const chunk of chunks) {
|
|
76
|
+
joined.set(chunk, offset);
|
|
77
|
+
offset += chunk.byteLength;
|
|
78
|
+
}
|
|
79
|
+
return new TextDecoder().decode(joined);
|
|
80
|
+
}
|
|
@@ -11,12 +11,14 @@ function usableToken(config) {
|
|
|
11
11
|
: undefined;
|
|
12
12
|
}
|
|
13
13
|
/** Refresh ownership includes the provider request and persistence of rotated tokens. */
|
|
14
|
-
export async function coordinatedAccessToken(ctx, refresh
|
|
15
|
-
|
|
14
|
+
export async function coordinatedAccessToken(ctx, refresh,
|
|
15
|
+
/** Renew even when the stored token is still usable, to prove it or to rotate it. */
|
|
16
|
+
force = false) {
|
|
17
|
+
const cached = force ? undefined : usableToken(ctx.connection.config);
|
|
16
18
|
if (cached)
|
|
17
19
|
return cached;
|
|
18
20
|
await ctx.updateConnection(async (current) => {
|
|
19
|
-
if (usableToken(current))
|
|
21
|
+
if (!force && usableToken(current))
|
|
20
22
|
return;
|
|
21
23
|
return refresh(current);
|
|
22
24
|
});
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import * as t from "typebox";
|
|
2
|
+
import { search } from "./places.js";
|
|
3
|
+
import { book, optionsOf, plan, previewOf, quoteFor } from "./rides.js";
|
|
4
|
+
import { createSession, identityOf, requestCode, status, verifyCard, verifyCode, } from "./session.js";
|
|
5
|
+
import { accepted, accessToken, arr, authed, cfgOf, DEF_LAT, DEF_LON, num, numOrNull, obj, pick, seg, } from "./shared.js";
|
|
6
|
+
/**
|
|
7
|
+
* gett — Gett (taxi, Israel) consumer connector.
|
|
8
|
+
*
|
|
9
|
+
* Reaches the owner's PRIVATE Gett account through the mobile app surface
|
|
10
|
+
* (`b2cgateway.gett.com`). The official Business API is gated on a per-company
|
|
11
|
+
* `order` entitlement that is not self-serve, so this rides the JSON REST surface
|
|
12
|
+
* the phone app uses. Unofficial by nature: it can break or be blocked without notice.
|
|
13
|
+
*
|
|
14
|
+
* Auth: phone + SMS OTP + a card-digits second factor (a trusted device skips the
|
|
15
|
+
* third step). A long-lived refresh token then mints short access tokens; both live
|
|
16
|
+
* in the connection and rotate through `ctx.updateConnection`, which coalesces
|
|
17
|
+
* concurrent renewals. No keepalive schedule is needed — the refresh grant does not
|
|
18
|
+
* require a live access token, so a stored session survives unattended.
|
|
19
|
+
*
|
|
20
|
+
* Money: `ride.book` summons a real car and charges the account's saved card, so
|
|
21
|
+
* consent is bound to a fare rather than to an intent. A call without `confirm`
|
|
22
|
+
* returns a priced preview and a `quote` fingerprinting the route, class, and fare;
|
|
23
|
+
* booking needs `allowOrdering` on the connection, `confirm: true`, and that same
|
|
24
|
+
* quote still pricing identically. A fare that moved refuses and reports the new one.
|
|
25
|
+
*/
|
|
26
|
+
const STRICT = { additionalProperties: false };
|
|
27
|
+
const latSchema = t.Number({
|
|
28
|
+
minimum: -90,
|
|
29
|
+
maximum: 90,
|
|
30
|
+
description: "Bias latitude for place search. Defaults to the connection's home location.",
|
|
31
|
+
});
|
|
32
|
+
const lonSchema = t.Number({
|
|
33
|
+
minimum: -180,
|
|
34
|
+
maximum: 180,
|
|
35
|
+
description: "Bias longitude for place search.",
|
|
36
|
+
});
|
|
37
|
+
const placeSchema = (description) => t.String({ minLength: 1, maxLength: 300, description });
|
|
38
|
+
const orderIdSchema = t.String({
|
|
39
|
+
minLength: 1,
|
|
40
|
+
maxLength: 128,
|
|
41
|
+
description: "Order id returned by ride.book.",
|
|
42
|
+
});
|
|
43
|
+
/** Where a request's search bias comes from when the caller gives none. */
|
|
44
|
+
function bias(input, ctx) {
|
|
45
|
+
const cfg = cfgOf(ctx);
|
|
46
|
+
return {
|
|
47
|
+
lat: num(input.lat ?? cfg.defaultLat, DEF_LAT),
|
|
48
|
+
lon: num(input.lon ?? cfg.defaultLon, DEF_LON),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export default function gett(rl) {
|
|
52
|
+
rl.setName("gett");
|
|
53
|
+
rl.setVersion("0.1.0");
|
|
54
|
+
rl.setConnectionSchema(t.Object({
|
|
55
|
+
phone: t.Optional(t.String({
|
|
56
|
+
env: "GETT_PHONE",
|
|
57
|
+
description: "Account phone. International or local Israeli digits both work (972500000000, 0500000000, 050-000-0000); all normalize to 972…",
|
|
58
|
+
})),
|
|
59
|
+
refreshToken: t.Optional(t.String({
|
|
60
|
+
env: "GETT_REFRESH_TOKEN",
|
|
61
|
+
description: "Long-lived refresh JWT from an owner login; mints access tokens. Written by account.verifyCode/verifyCard. Store only in secrets.",
|
|
62
|
+
})),
|
|
63
|
+
creditCardId: t.Optional(t.String({
|
|
64
|
+
env: "GETT_CREDIT_CARD_ID",
|
|
65
|
+
description: "Saved card charged for rides. Discovered automatically during login.",
|
|
66
|
+
})),
|
|
67
|
+
deviceId: t.Optional(t.String({
|
|
68
|
+
env: "GETT_DEVICE_ID",
|
|
69
|
+
description: "x-device-id for the device session. Generated if absent.",
|
|
70
|
+
})),
|
|
71
|
+
clientDeviceUniqueId: t.Optional(t.String({
|
|
72
|
+
env: "GETT_CLIENT_DEVICE_UNIQUE_ID",
|
|
73
|
+
description: "x-client-device-unique-id. Generated if absent.",
|
|
74
|
+
})),
|
|
75
|
+
defaultLat: t.Optional(t.String({
|
|
76
|
+
env: "GETT_DEFAULT_LAT",
|
|
77
|
+
description: "Home latitude used when a request carries no bias (e.g. 32.0779).",
|
|
78
|
+
})),
|
|
79
|
+
defaultLon: t.Optional(t.String({
|
|
80
|
+
env: "GETT_DEFAULT_LON",
|
|
81
|
+
description: "Home longitude used when a request carries no bias (e.g. 34.7743).",
|
|
82
|
+
})),
|
|
83
|
+
allowOrdering: t.Optional(t.Boolean({
|
|
84
|
+
default: false,
|
|
85
|
+
description: "Master switch for ride.book and ride.cancel. Off leaves the connection read-only. Even on, ride.book still needs confirm:true and a matching quote.",
|
|
86
|
+
})),
|
|
87
|
+
}));
|
|
88
|
+
/* ---------------------------------------------------------------- */
|
|
89
|
+
/* account — the owner login and the session it carries */
|
|
90
|
+
/* ---------------------------------------------------------------- */
|
|
91
|
+
rl.registerAction("account.requestCode", {
|
|
92
|
+
access: "write",
|
|
93
|
+
description: "Step 1 of the owner login: ask Gett to text a one-time code to the account's phone. Returns sent:true with the code length, or sent:false when Gett refuses (rate limit or block) — it answers 200 either way, so trust this flag rather than the absence of an error. Follow with account.verifyCode.",
|
|
94
|
+
inputSchema: t.Object({
|
|
95
|
+
phone: t.Optional(t.String({
|
|
96
|
+
minLength: 6,
|
|
97
|
+
maxLength: 32,
|
|
98
|
+
description: "Account phone. Israeli local or international digits both work. Omit to reuse the connection's stored phone.",
|
|
99
|
+
})),
|
|
100
|
+
}, STRICT),
|
|
101
|
+
async execute(input, ctx) {
|
|
102
|
+
const { phone } = (input ?? {});
|
|
103
|
+
return requestCode(ctx, phone);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
rl.registerAction("account.verifyCode", {
|
|
107
|
+
access: "write",
|
|
108
|
+
description: "Step 2 of the owner login: exchange the texted code for tokens. Returns connected:true when the device is trusted, or mfa_required:true when Gett wants the card second factor — then call account.verifyCard.",
|
|
109
|
+
inputSchema: t.Object({
|
|
110
|
+
code: t.String({
|
|
111
|
+
minLength: 3,
|
|
112
|
+
maxLength: 12,
|
|
113
|
+
description: "The one-time code from the SMS.",
|
|
114
|
+
}),
|
|
115
|
+
}, STRICT),
|
|
116
|
+
async execute(input, ctx) {
|
|
117
|
+
const { code } = input;
|
|
118
|
+
return verifyCode(ctx, code);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
rl.registerAction("account.verifyCard", {
|
|
122
|
+
access: "write",
|
|
123
|
+
description: "Step 3 of the owner login, when account.verifyCode reported mfa_required: prove the account with the last digits of its saved card. On success the session is connected and every other action works without a further login.",
|
|
124
|
+
inputSchema: t.Object({
|
|
125
|
+
card: t.String({
|
|
126
|
+
minLength: 2,
|
|
127
|
+
maxLength: 8,
|
|
128
|
+
description: "Last 4 digits of the saved card.",
|
|
129
|
+
}),
|
|
130
|
+
}, STRICT),
|
|
131
|
+
async execute(input, ctx) {
|
|
132
|
+
const { card } = input;
|
|
133
|
+
return verifyCard(ctx, card);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
rl.registerAction("account.status", {
|
|
137
|
+
access: "read",
|
|
138
|
+
description: "What the stored session has and still needs, read locally with no network call — so it answers even when the session is broken. Reports whether ordering is enabled and whether a login is half-finished.",
|
|
139
|
+
inputSchema: t.Object({}, STRICT),
|
|
140
|
+
async execute(_input, ctx) {
|
|
141
|
+
return status(ctx);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
rl.registerAction("account.refresh", {
|
|
145
|
+
access: "write",
|
|
146
|
+
description: "Force a fresh access token from the stored refresh token. Rarely needed: every action renews on demand, and the refresh token is long-lived (~90 days), so no keepalive schedule is required. Useful to prove a stored session is still good.",
|
|
147
|
+
inputSchema: t.Object({}, STRICT),
|
|
148
|
+
async execute(_input, ctx) {
|
|
149
|
+
await accessToken(ctx, true);
|
|
150
|
+
const cfg = cfgOf(ctx);
|
|
151
|
+
return {
|
|
152
|
+
refreshed: true,
|
|
153
|
+
access_expires_at: cfg.accessTokenExpiresAt
|
|
154
|
+
? new Date(cfg.accessTokenExpiresAt).toISOString()
|
|
155
|
+
: null,
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
rl.registerAction("account.get", {
|
|
160
|
+
access: "read",
|
|
161
|
+
description: "The live account behind the session: rider identity plus any orders in flight. Hits Gett, so it also confirms the credentials still work — account.status answers the same shape from local state alone.",
|
|
162
|
+
inputSchema: t.Object({}, STRICT),
|
|
163
|
+
async execute(_input, ctx) {
|
|
164
|
+
const session = await createSession(ctx, DEF_LAT, DEF_LON);
|
|
165
|
+
return {
|
|
166
|
+
...identityOf(ctx, session),
|
|
167
|
+
active_orders: arr(session.active_orders).map((o) => ({
|
|
168
|
+
id: pick(obj(o).id, obj(o).order_id),
|
|
169
|
+
status: pick(obj(o).status),
|
|
170
|
+
})),
|
|
171
|
+
active_requests: arr(session.active_requests).length,
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
/* ---------------------------------------------------------------- */
|
|
176
|
+
/* place / driver — read-only lookups */
|
|
177
|
+
/* ---------------------------------------------------------------- */
|
|
178
|
+
rl.registerAction("place.search", {
|
|
179
|
+
access: "read",
|
|
180
|
+
description: "Find a pickup or drop-off by name or address ('Dizengoff Square', 'Ben Gurion Airport'). Hebrew works. Returns ranked candidates with coordinates; pass a name straight to ride.price or ride.book instead if you do not need to choose.",
|
|
181
|
+
inputSchema: t.Object({
|
|
182
|
+
query: placeSchema("Place name or address to search for."),
|
|
183
|
+
lat: t.Optional(latSchema),
|
|
184
|
+
lon: t.Optional(lonSchema),
|
|
185
|
+
}, STRICT),
|
|
186
|
+
async execute(input, ctx) {
|
|
187
|
+
const p = input;
|
|
188
|
+
const { lat, lon } = bias(p, ctx);
|
|
189
|
+
return search(ctx, String(p.query), lat, lon);
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
rl.registerAction("driver.listNearby", {
|
|
193
|
+
access: "read",
|
|
194
|
+
description: "Live positions of Gett drivers around a point — an availability check before quoting, or a map. Read-only.",
|
|
195
|
+
inputSchema: t.Object({ lat: t.Optional(latSchema), lon: t.Optional(lonSchema) }, STRICT),
|
|
196
|
+
async execute(input, ctx) {
|
|
197
|
+
const p = (input ?? {});
|
|
198
|
+
const { lat, lon } = bias(p, ctx);
|
|
199
|
+
const query = new URLSearchParams({ lat: String(lat), lng: String(lon) });
|
|
200
|
+
const r = await authed(ctx, `/gl/api/v2/drivers/locations?${query}`);
|
|
201
|
+
return arr(r.drivers).map((raw) => {
|
|
202
|
+
const d = obj(raw);
|
|
203
|
+
return {
|
|
204
|
+
id: pick(d.id),
|
|
205
|
+
status: pick(d.status),
|
|
206
|
+
location: arr(d.last_locations)[0] ?? null,
|
|
207
|
+
route_eta_ts: numOrNull(d.route_eta_ts),
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
/* ---------------------------------------------------------------- */
|
|
213
|
+
/* ride — pricing, booking, tracking */
|
|
214
|
+
/* ---------------------------------------------------------------- */
|
|
215
|
+
rl.registerAction("ride.price", {
|
|
216
|
+
access: "read",
|
|
217
|
+
description: "Price a ride between two places by name: every available class (Taxi, Priority, …) with its fare and pickup ETA. Books nothing and charges nothing. To actually book, use ride.book — it prices the ride itself and returns a quote to confirm against.",
|
|
218
|
+
inputSchema: t.Object({
|
|
219
|
+
from: placeSchema("Pickup place or address."),
|
|
220
|
+
to: placeSchema("Destination place or address."),
|
|
221
|
+
lat: t.Optional(latSchema),
|
|
222
|
+
lon: t.Optional(lonSchema),
|
|
223
|
+
}, STRICT),
|
|
224
|
+
async execute(input, ctx) {
|
|
225
|
+
const p = input;
|
|
226
|
+
const { lat, lon } = bias(p, ctx);
|
|
227
|
+
const ride = await plan(ctx, String(p.from), String(p.to), lat, lon);
|
|
228
|
+
return {
|
|
229
|
+
from: { name: ride.from.name, address: ride.from.full_address },
|
|
230
|
+
to: { name: ride.to.name, address: ride.to.full_address },
|
|
231
|
+
options: optionsOf(ride),
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
rl.registerAction("ride.book", {
|
|
236
|
+
access: "write",
|
|
237
|
+
description: "Book a REAL taxi: this charges the saved card and sends an actual car to an actual person. Call it first WITHOUT confirm to get a priced preview and a `quote`. Read the fare and the pickup back to the person, get an explicit yes, then call again with confirm:true and that exact quote. If the fare moved in between, nothing is booked and the new price is returned instead, because consent was given to a price and not to a journey. Needs allowOrdering on the connection.",
|
|
238
|
+
inputSchema: t.Object({
|
|
239
|
+
from: placeSchema("Pickup place or address."),
|
|
240
|
+
to: placeSchema("Destination place or address."),
|
|
241
|
+
class_uuid: t.Optional(t.String({
|
|
242
|
+
minLength: 1,
|
|
243
|
+
maxLength: 128,
|
|
244
|
+
description: "Ride class from ride.price. Defaults to the account's usual class.",
|
|
245
|
+
})),
|
|
246
|
+
note: t.Optional(t.String({ maxLength: 500, description: "Note to the driver." })),
|
|
247
|
+
confirm: t.Optional(t.Boolean({
|
|
248
|
+
description: "True only after a person heard the fare and pickup and said yes. Must be sent together with the quote from the preview call.",
|
|
249
|
+
})),
|
|
250
|
+
quote: t.Optional(t.String({
|
|
251
|
+
minLength: 1,
|
|
252
|
+
maxLength: 64,
|
|
253
|
+
description: "The quote from the preview call. Binds this booking to the exact fare that was approved.",
|
|
254
|
+
})),
|
|
255
|
+
lat: t.Optional(latSchema),
|
|
256
|
+
lon: t.Optional(lonSchema),
|
|
257
|
+
}, STRICT),
|
|
258
|
+
async execute(input, ctx) {
|
|
259
|
+
const p = input;
|
|
260
|
+
const cfg = cfgOf(ctx);
|
|
261
|
+
// A confirmation that ordering will refuse is decided before spending four
|
|
262
|
+
// requests to price a ride nobody can book.
|
|
263
|
+
if (p.confirm === true && !cfg.allowOrdering)
|
|
264
|
+
throw new Error("gett ride.book: ordering is disabled for this connection (set allowOrdering:true). Refusing to book.");
|
|
265
|
+
const { lat, lon } = bias(p, ctx);
|
|
266
|
+
const ride = await plan(ctx, String(p.from), String(p.to), lat, lon, p.class_uuid ? String(p.class_uuid) : undefined);
|
|
267
|
+
const quote = quoteFor(ride);
|
|
268
|
+
const summary = previewOf(ride);
|
|
269
|
+
if (p.confirm !== true) {
|
|
270
|
+
return {
|
|
271
|
+
requiresConfirmation: true,
|
|
272
|
+
action: "ride.book",
|
|
273
|
+
quote,
|
|
274
|
+
summary,
|
|
275
|
+
note: cfg.allowOrdering
|
|
276
|
+
? "Books a REAL taxi and charges the saved card. Read the fare and pickup back to the person, then re-run with confirm:true and this quote."
|
|
277
|
+
: "Ordering is disabled for this connection (allowOrdering is false), so this preview cannot be booked. Enable allowOrdering first.",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
if (p.quote !== quote) {
|
|
281
|
+
return {
|
|
282
|
+
booked: false,
|
|
283
|
+
reason: p.quote ? "price_changed" : "quote_required",
|
|
284
|
+
quote,
|
|
285
|
+
summary,
|
|
286
|
+
note: p.quote
|
|
287
|
+
? "The fare or class changed since the quote that was approved, so nothing was booked. Read the new fare back and confirm again with the new quote."
|
|
288
|
+
: "confirm:true must carry the quote from the preview call, so a person can only approve a fare they were shown. Nothing was booked.",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const result = await book(ctx, ride, p.note ? String(p.note) : undefined);
|
|
292
|
+
// Gett accepted the order or it did not; "booked" is never claimed over a
|
|
293
|
+
// body that said otherwise, because the caller's next move differs entirely.
|
|
294
|
+
return {
|
|
295
|
+
booked: result.ok,
|
|
296
|
+
status: result.ok ? "booked" : "rejected",
|
|
297
|
+
order_id: result.order_id,
|
|
298
|
+
summary,
|
|
299
|
+
...(result.order_id
|
|
300
|
+
? { track_with: `ride.status({ order_id: "${result.order_id}" })` }
|
|
301
|
+
: {}),
|
|
302
|
+
};
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
rl.registerAction("ride.status", {
|
|
306
|
+
access: "read",
|
|
307
|
+
description: "Track a booked ride — 'where is my taxi?'. Status, ETA in seconds, distance, whether it is still cancellable for free, and the driver once assigned.",
|
|
308
|
+
inputSchema: t.Object({ order_id: orderIdSchema }, STRICT),
|
|
309
|
+
async execute(input, ctx) {
|
|
310
|
+
const { order_id } = input;
|
|
311
|
+
const cfg = cfgOf(ctx);
|
|
312
|
+
const r = await authed(ctx, `/gl/server/3_3/phone/${seg(cfg.phone, "phone")}/orders/${seg(order_id, "order_id")}`);
|
|
313
|
+
return {
|
|
314
|
+
order_id: pick(r.id) ?? order_id,
|
|
315
|
+
status: pick(r.status),
|
|
316
|
+
eta_seconds: numOrNull(r.eta),
|
|
317
|
+
distance_m: numOrNull(r.distance),
|
|
318
|
+
cancellable: typeof r.cancellable_by_client === "boolean"
|
|
319
|
+
? r.cancellable_by_client
|
|
320
|
+
: null,
|
|
321
|
+
driver_assigned_at: pick(r.driver_assigned_at),
|
|
322
|
+
payment_type: pick(r.payment_type),
|
|
323
|
+
driver: r.driver ?? r.driver_details ?? null,
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
rl.registerAction("ride.cancel", {
|
|
328
|
+
access: "write",
|
|
329
|
+
description: "Cancel a booked ride. Free inside the cancellation window — check ride.status.cancellable first, because a late cancel can incur a fee. Needs allowOrdering on the connection.",
|
|
330
|
+
inputSchema: t.Object({
|
|
331
|
+
order_id: orderIdSchema,
|
|
332
|
+
reason: t.Optional(t.Integer({
|
|
333
|
+
minimum: 0,
|
|
334
|
+
description: "Gett cancellation reason id, recorded before cancelling.",
|
|
335
|
+
})),
|
|
336
|
+
}, STRICT),
|
|
337
|
+
async execute(input, ctx) {
|
|
338
|
+
const { order_id, reason } = input;
|
|
339
|
+
const cfg = cfgOf(ctx);
|
|
340
|
+
if (!cfg.allowOrdering)
|
|
341
|
+
throw new Error("gett ride.cancel: ordering is disabled for this connection (set allowOrdering:true).");
|
|
342
|
+
const phone = seg(cfg.phone, "phone");
|
|
343
|
+
const order = seg(order_id, "order_id");
|
|
344
|
+
// The reason is optional metadata; a rejection there must not block the
|
|
345
|
+
// cancel, but the caller is told whether it landed rather than left to guess.
|
|
346
|
+
let reasonRecorded = null;
|
|
347
|
+
if (reason != null) {
|
|
348
|
+
reasonRecorded = await authed(ctx, `/gl/server/2_9/phone/${phone}/orders/${order}/order_cancellation_reason`, { method: "POST", body: { cancellation_reason_id: reason } }).then(accepted, () => false);
|
|
349
|
+
}
|
|
350
|
+
const r = await authed(ctx, `/gl/server/3_0/phone/${phone}/orders/${order}/cancel`, { method: "POST", body: {} });
|
|
351
|
+
return {
|
|
352
|
+
cancelled: accepted(r),
|
|
353
|
+
order_id,
|
|
354
|
+
reason_recorded: reasonRecorded,
|
|
355
|
+
};
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { rider } from "./session.js";
|
|
2
|
+
import { arr, authed, DEF_LAT, DEF_LON, num, numOrNull, obj, pick, } from "./shared.js";
|
|
3
|
+
function candidateOf(raw) {
|
|
4
|
+
const entry = obj(raw);
|
|
5
|
+
const loc = obj(entry.location);
|
|
6
|
+
const poi = obj(loc.poi);
|
|
7
|
+
return {
|
|
8
|
+
id: pick(entry.id) ?? "",
|
|
9
|
+
place_id: pick(entry.provider_place_id, poi.id),
|
|
10
|
+
provider: (pick(entry.provider, poi.provider) ?? "GOOGLE").toUpperCase(),
|
|
11
|
+
name: pick(loc.main_text, poi.name, loc.title),
|
|
12
|
+
secondary: pick(loc.secondary_text),
|
|
13
|
+
full_address: pick(loc.complete_address, loc.title, loc.main_text),
|
|
14
|
+
lat: numOrNull(loc.lat),
|
|
15
|
+
lng: numOrNull(loc.lng),
|
|
16
|
+
type: pick(loc.type) ?? "point_of_interest",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export async function search(ctx, query, lat, lon) {
|
|
20
|
+
const body = {
|
|
21
|
+
autocomplete_query: {
|
|
22
|
+
input: query,
|
|
23
|
+
locale: "en",
|
|
24
|
+
coordinates: { lat: num(lat, DEF_LAT), lng: num(lon, DEF_LON) },
|
|
25
|
+
providers: [
|
|
26
|
+
{ name: "google", limit: 6 },
|
|
27
|
+
{ name: "gett", limit: 6 },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const r = await authed(ctx, "/gl/locations-proxy/api/v2/locations/autocomplete", { method: "POST", body });
|
|
32
|
+
return arr(r.locations)
|
|
33
|
+
.map(candidateOf)
|
|
34
|
+
.filter((c) => c.name !== null);
|
|
35
|
+
}
|
|
36
|
+
function placeOf(raw, fallback) {
|
|
37
|
+
const entry = obj(raw);
|
|
38
|
+
const loc = obj(entry.location);
|
|
39
|
+
const parts = obj(loc.components);
|
|
40
|
+
const poi = obj(loc.poi);
|
|
41
|
+
return {
|
|
42
|
+
place_id: pick(entry.provider_place_id, poi.id) ?? fallback.place_id,
|
|
43
|
+
provider: (pick(entry.provider) ?? fallback.provider).toUpperCase(),
|
|
44
|
+
name: pick(loc.main_text, poi.name) ?? fallback.name,
|
|
45
|
+
full_address: pick(loc.complete_address, loc.title) ?? fallback.full_address,
|
|
46
|
+
lat: numOrNull(loc.lat) ?? fallback.lat,
|
|
47
|
+
lng: numOrNull(loc.lng) ?? fallback.lng,
|
|
48
|
+
type: pick(loc.type) ?? fallback.type ?? "establishment",
|
|
49
|
+
city: pick(parts.locality, loc.secondary_text) ?? "",
|
|
50
|
+
state: pick(parts.state) ?? "",
|
|
51
|
+
country: pick(parts.country) ?? "Israel",
|
|
52
|
+
country_code: pick(parts.country_code) ?? "IL",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Promote a candidate to a full place; the candidate stands in if retrieve is empty. */
|
|
56
|
+
async function retrieve(ctx, candidate) {
|
|
57
|
+
const body = {
|
|
58
|
+
locations: [
|
|
59
|
+
{
|
|
60
|
+
id: candidate.id,
|
|
61
|
+
provider_place_Id: candidate.place_id,
|
|
62
|
+
provider: candidate.provider,
|
|
63
|
+
locale: "en",
|
|
64
|
+
location: {
|
|
65
|
+
poi: { provider: candidate.provider, name: candidate.name },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
const r = await authed(ctx, "/gl/locations-proxy/api/v1/locations/retrieve", {
|
|
71
|
+
method: "POST",
|
|
72
|
+
body,
|
|
73
|
+
});
|
|
74
|
+
const [first] = arr(r.locations);
|
|
75
|
+
return placeOf(first, candidate);
|
|
76
|
+
}
|
|
77
|
+
export async function resolve(ctx, query, lat, lon) {
|
|
78
|
+
const candidates = await search(ctx, query, lat, lon);
|
|
79
|
+
if (!candidates.length)
|
|
80
|
+
throw new Error(`gett: no place found for "${query}"`);
|
|
81
|
+
// Google candidates carry the place id the booking payload wants.
|
|
82
|
+
const best = candidates.find((c) => c.provider === "GOOGLE" && c.place_id) ??
|
|
83
|
+
candidates[0];
|
|
84
|
+
return retrieve(ctx, best);
|
|
85
|
+
}
|
|
86
|
+
/** A stop as the pricing and booking payloads expect it. */
|
|
87
|
+
export function stop(place, kind, ctx) {
|
|
88
|
+
const established = !!place.place_id;
|
|
89
|
+
return {
|
|
90
|
+
actions: [
|
|
91
|
+
{ type: kind === "origin" ? "pick_up" : "drop_off", user: rider(ctx) },
|
|
92
|
+
],
|
|
93
|
+
location: {
|
|
94
|
+
address: {
|
|
95
|
+
city: place.city,
|
|
96
|
+
country: place.country,
|
|
97
|
+
full_address: place.full_address ?? place.name ?? "",
|
|
98
|
+
poi: established,
|
|
99
|
+
poi_name: place.name ?? "",
|
|
100
|
+
state: place.state,
|
|
101
|
+
title: place.full_address ?? place.name ?? "",
|
|
102
|
+
type: established ? "establishment" : "point",
|
|
103
|
+
},
|
|
104
|
+
lat: place.lat,
|
|
105
|
+
lng: place.lng,
|
|
106
|
+
poi_place: place.place_id
|
|
107
|
+
? { id: place.place_id, provider: place.provider }
|
|
108
|
+
: undefined,
|
|
109
|
+
source: "autocomplete",
|
|
110
|
+
type: established ? "establishment" : "point",
|
|
111
|
+
},
|
|
112
|
+
type: kind,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** The same place in the flat shape the create-ride endpoint wants. */
|
|
116
|
+
export function flat(place) {
|
|
117
|
+
return {
|
|
118
|
+
poi_id: place.place_id,
|
|
119
|
+
poi_provider: place.provider,
|
|
120
|
+
provider: place.provider,
|
|
121
|
+
country: place.country,
|
|
122
|
+
source: "autocomplete",
|
|
123
|
+
city: place.city,
|
|
124
|
+
complete_address: place.full_address ?? place.name ?? "",
|
|
125
|
+
id: 0,
|
|
126
|
+
lat: place.lat,
|
|
127
|
+
lon: place.lng,
|
|
128
|
+
address_lat: place.lat,
|
|
129
|
+
address_lon: place.lng,
|
|
130
|
+
place_id: place.place_id,
|
|
131
|
+
poi: true,
|
|
132
|
+
poi_name: place.name ?? "",
|
|
133
|
+
poi_type: place.type,
|
|
134
|
+
state: place.state,
|
|
135
|
+
title: place.full_address ?? place.name ?? "",
|
|
136
|
+
valid: true,
|
|
137
|
+
country_code: place.country_code,
|
|
138
|
+
address_type: place.type,
|
|
139
|
+
};
|
|
140
|
+
}
|