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
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { flat, resolve, stop } from "./places.js";
|
|
3
|
+
import { createSession } from "./session.js";
|
|
4
|
+
import { accepted, arr, authed, cfgOf, numOrNull, obj, pick, } from "./shared.js";
|
|
5
|
+
function rideClassOf(raw) {
|
|
6
|
+
const entry = obj(raw);
|
|
7
|
+
const klass = obj(entry.class);
|
|
8
|
+
const option = obj(arr(obj(entry.price).pricing_options)[0]);
|
|
9
|
+
return {
|
|
10
|
+
class_uuid: pick(klass.uuid),
|
|
11
|
+
name: pick(klass.name),
|
|
12
|
+
category: pick(klass.category),
|
|
13
|
+
subcategory: pick(klass.subcategory) ?? "default",
|
|
14
|
+
estimation_id: pick(option.estimation_id),
|
|
15
|
+
display_price: pick(option.user_price, option.full_price),
|
|
16
|
+
currency: pick(option.currency_iso),
|
|
17
|
+
eta: numOrNull(klass.display_eta ?? klass.eta),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
async function preorder(ctx, stops) {
|
|
21
|
+
const cfg = cfgOf(ctx);
|
|
22
|
+
const r = await authed(ctx, "/gl/api/v1/preorder/aggregated", {
|
|
23
|
+
method: "POST",
|
|
24
|
+
body: {
|
|
25
|
+
stops,
|
|
26
|
+
phone: cfg.phone,
|
|
27
|
+
country_code: "IL",
|
|
28
|
+
category: "transportation",
|
|
29
|
+
payment_type: "credit_card",
|
|
30
|
+
source: "mobile",
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
const classes = arr(r.classes_with_prices).map(rideClassOf);
|
|
34
|
+
return {
|
|
35
|
+
classes,
|
|
36
|
+
route_id: pick(obj(arr(r.routes)[0]).uuid),
|
|
37
|
+
default_class: pick(r.private_default_class_uuid) ?? classes[0]?.class_uuid ?? null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve both ends, price every class, and pick one. Every pricing read and
|
|
42
|
+
* every booking goes through here, so a preview and its confirmation are priced
|
|
43
|
+
* by identical code.
|
|
44
|
+
*/
|
|
45
|
+
export async function plan(ctx, from, to, lat, lon, classUuid) {
|
|
46
|
+
await createSession(ctx, lat, lon);
|
|
47
|
+
const [origin, destination] = await Promise.all([
|
|
48
|
+
resolve(ctx, from, lat, lon),
|
|
49
|
+
resolve(ctx, to, lat, lon),
|
|
50
|
+
]);
|
|
51
|
+
const stops = [
|
|
52
|
+
stop(origin, "origin", ctx),
|
|
53
|
+
stop(destination, "destination", ctx),
|
|
54
|
+
];
|
|
55
|
+
const pricing = await preorder(ctx, stops);
|
|
56
|
+
const chosen = classUuid
|
|
57
|
+
? pricing.classes.find((c) => c.class_uuid === classUuid)
|
|
58
|
+
: (pricing.classes.find((c) => c.class_uuid === pricing.default_class) ??
|
|
59
|
+
pricing.classes[0]);
|
|
60
|
+
if (!chosen)
|
|
61
|
+
throw new Error(classUuid
|
|
62
|
+
? `gett: ride class ${classUuid} is not available for this route`
|
|
63
|
+
: "gett: no ride class available for this route");
|
|
64
|
+
return {
|
|
65
|
+
from: origin,
|
|
66
|
+
to: destination,
|
|
67
|
+
stops,
|
|
68
|
+
route_id: pricing.route_id,
|
|
69
|
+
classes: pricing.classes,
|
|
70
|
+
chosen,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A fingerprint of everything a person agrees to when they say yes: both
|
|
75
|
+
* endpoints, the ride class, and the fare. Re-derived on the confirming call,
|
|
76
|
+
* so a fare that moved in between cannot be booked against the old consent.
|
|
77
|
+
*/
|
|
78
|
+
export function quoteFor(ride) {
|
|
79
|
+
const material = JSON.stringify([
|
|
80
|
+
ride.from.place_id ?? ride.from.name ?? "",
|
|
81
|
+
ride.to.place_id ?? ride.to.name ?? "",
|
|
82
|
+
ride.chosen.class_uuid ?? "",
|
|
83
|
+
ride.chosen.display_price ?? "",
|
|
84
|
+
ride.chosen.currency ?? "",
|
|
85
|
+
]);
|
|
86
|
+
return `q_${createHash("sha256").update(material).digest("hex").slice(0, 16)}`;
|
|
87
|
+
}
|
|
88
|
+
export function previewOf(ride) {
|
|
89
|
+
return {
|
|
90
|
+
from: { name: ride.from.name, address: ride.from.full_address },
|
|
91
|
+
to: { name: ride.to.name, address: ride.to.full_address },
|
|
92
|
+
ride_class: ride.chosen.name,
|
|
93
|
+
price: ride.chosen.display_price,
|
|
94
|
+
currency: ride.chosen.currency,
|
|
95
|
+
eta_to_pickup: ride.chosen.eta,
|
|
96
|
+
class_uuid: ride.chosen.class_uuid,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export function optionsOf(ride) {
|
|
100
|
+
return ride.classes.map((c) => ({
|
|
101
|
+
ride_class: c.name,
|
|
102
|
+
price: c.display_price,
|
|
103
|
+
currency: c.currency,
|
|
104
|
+
eta: c.eta,
|
|
105
|
+
class_uuid: c.class_uuid,
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
export async function book(ctx, ride, note) {
|
|
109
|
+
const cfg = cfgOf(ctx);
|
|
110
|
+
if (!cfg.creditCardId)
|
|
111
|
+
throw new Error("gett: no saved card (creditCardId) — re-run the owner login to discover it");
|
|
112
|
+
const body = {
|
|
113
|
+
stops: ride.stops,
|
|
114
|
+
division_name: ride.chosen.name || "Taxi",
|
|
115
|
+
route_id: ride.route_id,
|
|
116
|
+
route_provider: "google",
|
|
117
|
+
ofse_order_flow: false,
|
|
118
|
+
origin: flat(ride.from),
|
|
119
|
+
destination: flat(ride.to),
|
|
120
|
+
note_to_driver: note ?? "",
|
|
121
|
+
business: 0,
|
|
122
|
+
token: randomUUID(),
|
|
123
|
+
app_provider: "gettaxi",
|
|
124
|
+
user_current_time: Math.floor(Date.now() / 1000),
|
|
125
|
+
credit_card_id: cfg.creditCardId,
|
|
126
|
+
payment_type: "credit_card",
|
|
127
|
+
ordered_from: "Phone",
|
|
128
|
+
division_uuid: ride.chosen.class_uuid,
|
|
129
|
+
category: ride.chosen.category ?? "transportation",
|
|
130
|
+
subcategory: ride.chosen.subcategory,
|
|
131
|
+
fix_charge_opt_out: true,
|
|
132
|
+
estimation_id: ride.chosen.estimation_id,
|
|
133
|
+
show_class_pricing_info: false,
|
|
134
|
+
timezone_id: "Asia/Jerusalem",
|
|
135
|
+
};
|
|
136
|
+
const r = await authed(ctx, "/il/global-ride-request/api/v1/create", {
|
|
137
|
+
method: "POST",
|
|
138
|
+
body,
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
ok: accepted(r),
|
|
142
|
+
order_id: pick(obj(r.order).id, r.order_id),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { APP_VERSION, accepted, arr, authed, cfgOf, DEF_LAT, DEF_LON, ensureDevice, expiresAt, http, normPhone, num, numOrNull, obj, pick, seg, } from "./shared.js";
|
|
2
|
+
export async function requestCode(ctx, phone) {
|
|
3
|
+
await ensureDevice(ctx);
|
|
4
|
+
let cfg = cfgOf(ctx);
|
|
5
|
+
if (phone) {
|
|
6
|
+
await ctx.updateConnection({ phone: normPhone(phone) });
|
|
7
|
+
cfg = cfgOf(ctx);
|
|
8
|
+
}
|
|
9
|
+
if (!cfg.phone)
|
|
10
|
+
throw new Error("gett: no phone — pass { phone } (e.g. 972500000000) or set GETT_PHONE");
|
|
11
|
+
const body = {
|
|
12
|
+
country_code: "IL",
|
|
13
|
+
country_phone_prefix: 972,
|
|
14
|
+
registration_origin: "organic",
|
|
15
|
+
method: "sms",
|
|
16
|
+
client_device_unique_id: cfg.clientDeviceUniqueId,
|
|
17
|
+
device_tag: "android_ww",
|
|
18
|
+
appsflyer_uid: `${Date.now()}-${Math.floor(Math.random() * 1e18)}`,
|
|
19
|
+
gaid: "",
|
|
20
|
+
};
|
|
21
|
+
const r = await http(ctx, `/gl/api/v2/phone/${seg(cfg.phone, "phone")}/auth/otp/challenge`, { method: "POST", body });
|
|
22
|
+
// Gett answers 200 even when it refuses to send, so a refusal is reported
|
|
23
|
+
// rather than an SMS claimed that will never arrive.
|
|
24
|
+
const status = pick(r.status);
|
|
25
|
+
if (!accepted(r)) {
|
|
26
|
+
const mins = numOrNull(r.blocked_until);
|
|
27
|
+
return {
|
|
28
|
+
sent: false,
|
|
29
|
+
blocked: status === "blocked",
|
|
30
|
+
phone: cfg.phone,
|
|
31
|
+
status,
|
|
32
|
+
retry_after_minutes: mins,
|
|
33
|
+
note: status === "blocked"
|
|
34
|
+
? `Too many attempts — Gett blocked new codes${mins ? ` for ~${mins} min` : ""}. Wait, then call account.requestCode again.`
|
|
35
|
+
: "No SMS was sent. Check the phone digits, then call account.requestCode again.",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
sent: true,
|
|
40
|
+
phone: cfg.phone,
|
|
41
|
+
code_length: num(r.confirmation_code_length, 6),
|
|
42
|
+
next: "account.verifyCode({ code })",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Tokens arrive either at the top level or nested under `tokens`. */
|
|
46
|
+
function tokensIn(r) {
|
|
47
|
+
const nested = obj(r.tokens);
|
|
48
|
+
if (pick(nested.refresh_token))
|
|
49
|
+
return nested;
|
|
50
|
+
if (pick(r.refresh_token))
|
|
51
|
+
return r;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
export async function verifyCode(ctx, code) {
|
|
55
|
+
const cfg = cfgOf(ctx);
|
|
56
|
+
if (!cfg.phone)
|
|
57
|
+
throw new Error("gett: no phone — call account.requestCode first");
|
|
58
|
+
const r = await http(ctx, `/gl/api/v2/phone/${seg(cfg.phone, "phone")}/auth/otp/verify`, { method: "POST", body: { code } });
|
|
59
|
+
const toks = tokensIn(r);
|
|
60
|
+
if (toks) {
|
|
61
|
+
const warnings = await finishTokens(ctx, toks);
|
|
62
|
+
// `connected` is read back from what was persisted, not asserted here.
|
|
63
|
+
return { mfa_required: false, warnings, ...(await status(ctx)) };
|
|
64
|
+
}
|
|
65
|
+
const mfa = r.mfa_required === true || pick(r.status) === "mfa_required";
|
|
66
|
+
// An auth response can carry tokens; report its shape, never its contents.
|
|
67
|
+
if (!mfa)
|
|
68
|
+
throw new Error(`gett: otp/verify returned neither tokens nor an MFA challenge (fields: ${Object.keys(r).sort().join(", ") || "none"})`);
|
|
69
|
+
await ctx.updateConnection({
|
|
70
|
+
pendingTempCode: pick(r.temp_code) ?? undefined,
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
connected: false,
|
|
74
|
+
mfa_required: true,
|
|
75
|
+
challenge: pick(r.mfa_challenge),
|
|
76
|
+
methods: arr(r.mfa_methods).filter((m) => typeof m === "string"),
|
|
77
|
+
masked_email: pick(r.mfa_masked_email),
|
|
78
|
+
next: "account.verifyCard({ card })",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export async function verifyCard(ctx, digits) {
|
|
82
|
+
const cfg = cfgOf(ctx);
|
|
83
|
+
if (!cfg.pendingTempCode)
|
|
84
|
+
throw new Error("gett: no pending MFA — call account.verifyCode first");
|
|
85
|
+
const r = await http(ctx, `/gl/api/v2/phone/${seg(cfg.phone, "phone")}/auth/mfa/verify`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
body: { temp_code: cfg.pendingTempCode, card_digits: digits },
|
|
88
|
+
});
|
|
89
|
+
const toks = tokensIn(r);
|
|
90
|
+
if (!toks) {
|
|
91
|
+
const reported = pick(r.status);
|
|
92
|
+
throw new Error(`gett: mfa/verify did not return tokens${reported ? ` (status: ${reported})` : ""} — check the card digits and retry`);
|
|
93
|
+
}
|
|
94
|
+
await ctx.updateConnection({ pendingTempCode: undefined });
|
|
95
|
+
const warnings = await finishTokens(ctx, toks);
|
|
96
|
+
return { warnings, ...(await status(ctx)) };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Store the IL-scoped tokens, convert to the GL family the app uses for ongoing
|
|
100
|
+
* refresh, then learn identity and the saved card.
|
|
101
|
+
*
|
|
102
|
+
* The conversion and the discovery are both optional: an IL token still serves
|
|
103
|
+
* reads, and a missing card only blocks booking. Neither failure is allowed to
|
|
104
|
+
* lose a login that otherwise succeeded, so both are reported as warnings rather
|
|
105
|
+
* than swallowed or thrown.
|
|
106
|
+
*/
|
|
107
|
+
async function finishTokens(ctx, toks) {
|
|
108
|
+
const warnings = [];
|
|
109
|
+
await ctx.updateConnection({
|
|
110
|
+
refreshToken: pick(toks.refresh_token),
|
|
111
|
+
accessToken: pick(toks.access_token),
|
|
112
|
+
accessTokenExpiresAt: expiresAt(toks.expires_in),
|
|
113
|
+
});
|
|
114
|
+
const cfg = cfgOf(ctx);
|
|
115
|
+
try {
|
|
116
|
+
// `?lc=en` is required here too: the conversion 400s without it even with a
|
|
117
|
+
// valid IL bearer.
|
|
118
|
+
const gl = await http(ctx, `/gl/api/v2/phone/${seg(cfg.phone, "phone")}/auth/token?lc=en`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
token: cfg.accessToken,
|
|
121
|
+
body: { grant_type: "refresh_token", refresh_token: cfg.refreshToken },
|
|
122
|
+
});
|
|
123
|
+
const patch = {};
|
|
124
|
+
const rotated = pick(gl.refresh_token);
|
|
125
|
+
const issued = pick(gl.access_token);
|
|
126
|
+
if (rotated)
|
|
127
|
+
patch.refreshToken = rotated;
|
|
128
|
+
if (issued) {
|
|
129
|
+
patch.accessToken = issued;
|
|
130
|
+
patch.accessTokenExpiresAt = expiresAt(gl.expires_in);
|
|
131
|
+
}
|
|
132
|
+
if (Object.keys(patch).length)
|
|
133
|
+
await ctx.updateConnection(patch);
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
warnings.push(`IL->GL token conversion failed (${e.message}); reads work, but the session may need an earlier re-login.`);
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
await createSession(ctx, DEF_LAT, DEF_LON);
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
warnings.push(`identity and saved-card discovery failed (${e.message}); booking needs creditCardId, so re-run account.get.`);
|
|
143
|
+
}
|
|
144
|
+
return warnings;
|
|
145
|
+
}
|
|
146
|
+
/** The default saved card, out of a create_session response. */
|
|
147
|
+
function discoverCard(session) {
|
|
148
|
+
for (const entry of arr(obj(obj(session.payment_methods).data).list)) {
|
|
149
|
+
for (const pm of arr(obj(entry).payment_methods)) {
|
|
150
|
+
const cards = arr(obj(obj(obj(pm).additional_data).credit_card).cards);
|
|
151
|
+
const preferred = cards.find((c) => obj(c).is_default === true) ?? cards[0];
|
|
152
|
+
const id = pick(obj(preferred).card_id);
|
|
153
|
+
if (id)
|
|
154
|
+
return id;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
/** Local view of the connection. No network, so it works on a broken session. */
|
|
160
|
+
export async function status(ctx) {
|
|
161
|
+
const cfg = cfgOf(ctx);
|
|
162
|
+
const missing = [];
|
|
163
|
+
if (!cfg.phone)
|
|
164
|
+
missing.push("phone");
|
|
165
|
+
if (!cfg.refreshToken)
|
|
166
|
+
missing.push("refreshToken (owner login)");
|
|
167
|
+
if (!cfg.creditCardId)
|
|
168
|
+
missing.push("creditCardId (auto-discovered on login)");
|
|
169
|
+
return {
|
|
170
|
+
connected: !!cfg.refreshToken,
|
|
171
|
+
phone: cfg.phone ?? null,
|
|
172
|
+
name: cfg.name ?? null,
|
|
173
|
+
globalUserId: cfg.globalUserId ?? null,
|
|
174
|
+
creditCard: cfg.creditCardId ?? null,
|
|
175
|
+
pendingMfa: !!cfg.pendingTempCode,
|
|
176
|
+
orderingEnabled: cfg.allowOrdering === true,
|
|
177
|
+
missing,
|
|
178
|
+
// The GL refresh token is long-lived (~90d) and its grant does not need a live
|
|
179
|
+
// access token, so a stored session survives unattended: no keepalive schedule.
|
|
180
|
+
keepalive: null,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The app's session bootstrap. Also the only place identity and the saved card
|
|
185
|
+
* are learned, so pricing and booking call it before they need either.
|
|
186
|
+
*/
|
|
187
|
+
export async function createSession(ctx, lat, lon) {
|
|
188
|
+
const cfg = cfgOf(ctx);
|
|
189
|
+
const body = {
|
|
190
|
+
dt: "Android, runline",
|
|
191
|
+
av: APP_VERSION,
|
|
192
|
+
os: "16",
|
|
193
|
+
device_generated_token: cfg.deviceGeneratedToken,
|
|
194
|
+
client_device_unique_id: cfg.clientDeviceUniqueId,
|
|
195
|
+
gcm: 1,
|
|
196
|
+
np: 0,
|
|
197
|
+
lc: "en",
|
|
198
|
+
cc: "IL",
|
|
199
|
+
ai: "com.gettaxi.android",
|
|
200
|
+
lat: num(lat, DEF_LAT),
|
|
201
|
+
lon: num(lon, DEF_LON),
|
|
202
|
+
app_provider: "gettaxi",
|
|
203
|
+
platform: "android",
|
|
204
|
+
gaid: cfg.gaid,
|
|
205
|
+
};
|
|
206
|
+
const r = await authed(ctx, `/gl/rider-facade/phone/${seg(cfg.phone, "phone")}/create_session`, { method: "POST", body });
|
|
207
|
+
const profile = obj(r.user_profile);
|
|
208
|
+
const patch = {};
|
|
209
|
+
const globalUserId = pick(profile.global_user_id);
|
|
210
|
+
const name = pick(profile.first_name);
|
|
211
|
+
if (globalUserId && !cfg.globalUserId)
|
|
212
|
+
patch.globalUserId = globalUserId;
|
|
213
|
+
if (name && !cfg.name)
|
|
214
|
+
patch.name = name;
|
|
215
|
+
if (!cfg.creditCardId) {
|
|
216
|
+
const card = discoverCard(r);
|
|
217
|
+
if (card)
|
|
218
|
+
patch.creditCardId = card;
|
|
219
|
+
}
|
|
220
|
+
if (Object.keys(patch).length)
|
|
221
|
+
await ctx.updateConnection(patch);
|
|
222
|
+
return r;
|
|
223
|
+
}
|
|
224
|
+
export function identityOf(ctx, session) {
|
|
225
|
+
const cfg = cfgOf(ctx);
|
|
226
|
+
const profile = obj(session.user_profile);
|
|
227
|
+
return {
|
|
228
|
+
global_user_id: pick(profile.global_user_id, cfg.globalUserId),
|
|
229
|
+
name: pick(profile.first_name, cfg.name),
|
|
230
|
+
phone: cfg.phone ?? null,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/** The rider block Gett expects inside every stop. */
|
|
234
|
+
export function rider(ctx) {
|
|
235
|
+
const cfg = cfgOf(ctx);
|
|
236
|
+
return {
|
|
237
|
+
global_id: num(cfg.globalUserId, 0),
|
|
238
|
+
name: cfg.name || "",
|
|
239
|
+
phone: cfg.phone,
|
|
240
|
+
vip: { is_vip: false, level: 0 },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { authedFetch } from "../../_shared/authedFetch.js";
|
|
3
|
+
import { arr, num, numOrNull, obj, pick, readBounded, seg as segment, } from "../../_shared/provider.js";
|
|
4
|
+
import { coordinatedAccessToken } from "../../_shared/tokenRefresh.js";
|
|
5
|
+
export { arr, num, numOrNull, obj, pick };
|
|
6
|
+
/**
|
|
7
|
+
* Transport and credentials for the Gett consumer surface.
|
|
8
|
+
*
|
|
9
|
+
* One host, one bearer, one refresh grant. Everything above this file works in
|
|
10
|
+
* terms of `http`/`authed` and never reaches the network itself.
|
|
11
|
+
*/
|
|
12
|
+
const HOST = "b2cgateway.gett.com";
|
|
13
|
+
const UA = "Gett/android/10.48.187";
|
|
14
|
+
export const APP_VERSION = "10.48.187";
|
|
15
|
+
/** Dizengoff Sq, TLV — a location that always has supply, used when none is given. */
|
|
16
|
+
export const DEF_LAT = 32.0779;
|
|
17
|
+
export const DEF_LON = 34.7743;
|
|
18
|
+
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
19
|
+
const DEFAULT_TOKEN_TTL_MS = 3_600_000;
|
|
20
|
+
export const cfgOf = (ctx) => (ctx.connection.config || {});
|
|
21
|
+
/**
|
|
22
|
+
* Whether a 2xx body reports success.
|
|
23
|
+
*
|
|
24
|
+
* Gett signals refusal inside the body on some endpoints (`rc`) and in a status
|
|
25
|
+
* string on others, and answers 200 either way. An explicit `rc` decides; failing
|
|
26
|
+
* that an explicit status decides; a body carrying neither is an acceptance,
|
|
27
|
+
* because `http` has already rejected every non-2xx. Erring the other way would
|
|
28
|
+
* report a booked ride as unbooked, and the caller would order a second car.
|
|
29
|
+
*/
|
|
30
|
+
export function accepted(body) {
|
|
31
|
+
const rc = numOrNull(body.rc);
|
|
32
|
+
if (rc !== null)
|
|
33
|
+
return rc === 0;
|
|
34
|
+
const status = pick(body.status);
|
|
35
|
+
if (status !== null)
|
|
36
|
+
return status === "success";
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Normalize a phone to Gett's expected international digits (E.164 without '+'),
|
|
41
|
+
* so a number typed the local Israeli way still routes the SMS. Gett pairs the
|
|
42
|
+
* path number with country_phone_prefix:972, so a national "0500000000" (or
|
|
43
|
+
* "050-000-0000") must become "972500000000" — otherwise the challenge returns
|
|
44
|
+
* success but the SMS is silently dropped. Idempotent.
|
|
45
|
+
*/
|
|
46
|
+
export function normPhone(input) {
|
|
47
|
+
let digits = String(input).replace(/[^\d]/g, "");
|
|
48
|
+
if (digits.startsWith("00"))
|
|
49
|
+
digits = digits.slice(2); // intl exit code
|
|
50
|
+
if (digits.startsWith("972"))
|
|
51
|
+
return digits;
|
|
52
|
+
if (digits.startsWith("0"))
|
|
53
|
+
return `972${digits.slice(1)}`;
|
|
54
|
+
return `972${digits}`;
|
|
55
|
+
}
|
|
56
|
+
export const seg = (value, what) => segment(value, what, "gett");
|
|
57
|
+
// ---------- HTTP ----------
|
|
58
|
+
/** Carries the status for control flow without putting a provider body in the message. */
|
|
59
|
+
class GettError extends Error {
|
|
60
|
+
status;
|
|
61
|
+
constructor(status, endpoint) {
|
|
62
|
+
super(`gett: request failed (HTTP ${status}) on ${endpoint}`);
|
|
63
|
+
this.name = "GettError";
|
|
64
|
+
this.status = status;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** The account's phone is in nearly every path; it never belongs in an error. */
|
|
68
|
+
function endpointOf(path) {
|
|
69
|
+
return path.split("?")[0].replace(/\/phone\/[^/]+/, "/phone/{phone}");
|
|
70
|
+
}
|
|
71
|
+
export async function http(ctx, path, opts = {}) {
|
|
72
|
+
const cfg = cfgOf(ctx);
|
|
73
|
+
const { method = "GET", body = null, token = null } = opts;
|
|
74
|
+
const payload = body == null ? null : JSON.stringify(body);
|
|
75
|
+
const headers = {
|
|
76
|
+
accept: "application/json",
|
|
77
|
+
"user-agent": UA,
|
|
78
|
+
"app-platform": "android",
|
|
79
|
+
"app-version": APP_VERSION,
|
|
80
|
+
"x-device-id": cfg.deviceId || "",
|
|
81
|
+
"x-client-device-unique-id": cfg.clientDeviceUniqueId || "",
|
|
82
|
+
"x-country-code": "IL",
|
|
83
|
+
};
|
|
84
|
+
if (token)
|
|
85
|
+
headers.authorization = `Bearer ${token}`;
|
|
86
|
+
if (payload)
|
|
87
|
+
headers["content-type"] = "application/json; charset=UTF-8";
|
|
88
|
+
const endpoint = endpointOf(path);
|
|
89
|
+
const res = await authedFetch(`https://${HOST}${path}`, {
|
|
90
|
+
method,
|
|
91
|
+
headers,
|
|
92
|
+
body: payload,
|
|
93
|
+
});
|
|
94
|
+
const text = await readBounded(res, MAX_RESPONSE_BYTES, `gett: response exceeded the size limit on ${endpoint}`);
|
|
95
|
+
let parsed = null;
|
|
96
|
+
try {
|
|
97
|
+
parsed = text ? JSON.parse(text) : null;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* non-JSON body */
|
|
101
|
+
}
|
|
102
|
+
// The body can hold tokens, the OTP, or the account holder's details; the
|
|
103
|
+
// status and the redacted endpoint are what a caller can act on.
|
|
104
|
+
if (!res.ok)
|
|
105
|
+
throw new GettError(res.status, endpoint);
|
|
106
|
+
return obj(parsed);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Device identity, generated once and then persisted. Gett ties a trusted device
|
|
110
|
+
* to these, so regenerating them would re-trigger the card factor on every login.
|
|
111
|
+
*/
|
|
112
|
+
export async function ensureDevice(ctx) {
|
|
113
|
+
const cfg = cfgOf(ctx);
|
|
114
|
+
const patch = {};
|
|
115
|
+
if (!cfg.deviceId)
|
|
116
|
+
patch.deviceId = randomUUID();
|
|
117
|
+
if (!cfg.clientDeviceUniqueId)
|
|
118
|
+
patch.clientDeviceUniqueId = randomUUID();
|
|
119
|
+
if (!cfg.deviceGeneratedToken)
|
|
120
|
+
patch.deviceGeneratedToken = randomUUID();
|
|
121
|
+
if (!cfg.gaid)
|
|
122
|
+
patch.gaid = randomUUID();
|
|
123
|
+
if (Object.keys(patch).length)
|
|
124
|
+
await ctx.updateConnection(patch);
|
|
125
|
+
}
|
|
126
|
+
// ---------- credentials ----------
|
|
127
|
+
export function expiresAt(expiresIn) {
|
|
128
|
+
const seconds = numOrNull(expiresIn);
|
|
129
|
+
return Date.now() + (seconds ? seconds * 1000 : DEFAULT_TOKEN_TTL_MS);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The refresh grant itself.
|
|
133
|
+
*
|
|
134
|
+
* `?lc=en` is REQUIRED on every /auth/token call: the app sends it and the server
|
|
135
|
+
* 400s with an empty body without it. Access-token TTL is irrelevant to the grant
|
|
136
|
+
* (a bearer expired by days still refreshes), so a genuine 400 here means the
|
|
137
|
+
* refresh token itself is dead and a fresh owner login is required.
|
|
138
|
+
*
|
|
139
|
+
* Called only from inside an update owner, and always with an explicit token, so
|
|
140
|
+
* it can never re-enter the connection store.
|
|
141
|
+
*/
|
|
142
|
+
async function refreshGrant(ctx, cfg) {
|
|
143
|
+
try {
|
|
144
|
+
return await http(ctx, `/gl/api/v2/phone/${seg(cfg.phone, "phone")}/auth/token?lc=en`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
token: cfg.accessToken || cfg.refreshToken,
|
|
147
|
+
body: { grant_type: "refresh_token", refresh_token: cfg.refreshToken },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
if (e instanceof GettError && e.status === 400) {
|
|
152
|
+
throw new Error("gett: session expired (refresh rejected) — re-run the owner login: account.requestCode -> account.verifyCode -> account.verifyCard");
|
|
153
|
+
}
|
|
154
|
+
throw e;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export async function accessToken(ctx, force = false) {
|
|
158
|
+
const cfg = cfgOf(ctx);
|
|
159
|
+
if (!cfg.refreshToken)
|
|
160
|
+
throw new Error("gett: not connected — run account.requestCode({ phone }) -> account.verifyCode({ code }) -> account.verifyCard({ card })");
|
|
161
|
+
if (!cfg.phone)
|
|
162
|
+
throw new Error("gett: no phone configured");
|
|
163
|
+
// Renewal runs under the store's update ownership, so parallel actions
|
|
164
|
+
// coalesce onto one grant instead of clobbering each other's rotated token.
|
|
165
|
+
return coordinatedAccessToken(ctx, async (current) => {
|
|
166
|
+
const resp = await refreshGrant(ctx, current);
|
|
167
|
+
const issued = pick(resp.access_token);
|
|
168
|
+
if (!issued)
|
|
169
|
+
throw new Error("gett: token refresh returned no access_token");
|
|
170
|
+
const rotated = pick(resp.refresh_token);
|
|
171
|
+
return {
|
|
172
|
+
accessToken: issued,
|
|
173
|
+
accessTokenExpiresAt: expiresAt(resp.expires_in),
|
|
174
|
+
...(rotated ? { refreshToken: rotated } : {}),
|
|
175
|
+
};
|
|
176
|
+
}, force);
|
|
177
|
+
}
|
|
178
|
+
export async function authed(ctx, path, opts = {}) {
|
|
179
|
+
return http(ctx, path, { ...opts, token: await accessToken(ctx) });
|
|
180
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { ATTACHMENT_FIELDS, assertAttachmentInScope, assertIssueInScope, gql, key, requireUnscoped, } from "./shared.js";
|
|
2
|
+
import { ATTACHMENT_FIELDS, assertAttachmentInScope, assertIssueInScope, gql, key, requireUnscoped, withScopedNote, } from "./shared.js";
|
|
3
3
|
export function registerAttachmentActions(rl) {
|
|
4
4
|
rl.registerAction("attachment.list", {
|
|
5
5
|
access: "read",
|
|
6
|
-
description: "List issue attachments.
|
|
6
|
+
description: withScopedNote("List issue attachments."),
|
|
7
7
|
inputSchema: t.Object({ limit: t.Optional(t.Number()) }),
|
|
8
8
|
async execute(input, ctx) {
|
|
9
9
|
requireUnscoped(ctx, "attachment.list");
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { bindGetAction, bindListAction, CYCLE_FIELDS, gql, key, requireUnscoped, } from "./shared.js";
|
|
2
|
+
import { bindGetAction, bindListAction, CYCLE_FIELDS, gql, key, requireUnscoped, withScopedNote, } from "./shared.js";
|
|
3
3
|
export function registerCycleActions(rl) {
|
|
4
4
|
const listAction = bindListAction(rl);
|
|
5
5
|
const getAction = bindGetAction(rl);
|
|
6
|
-
listAction("cycle.list", "List cycles.
|
|
6
|
+
listAction("cycle.list", "List cycles. The current cycle is filter { isActive: { eq: true } }; isNext and isPrevious work the same way. Combine with issue.list to report on a cycle's work.", "cycles", "CycleFilter", CYCLE_FIELDS);
|
|
7
7
|
getAction("cycle.get", "Get a cycle by ID.", "cycle", CYCLE_FIELDS);
|
|
8
8
|
rl.registerAction("cycle.create", {
|
|
9
9
|
access: "write",
|
|
10
|
-
description: "Create a cycle for a team.",
|
|
10
|
+
description: withScopedNote("Create a cycle for a team."),
|
|
11
11
|
inputSchema: t.Object({
|
|
12
12
|
teamId: t.String({ description: "The team to associate the cycle with" }),
|
|
13
13
|
startsAt: t.String({
|
|
@@ -33,7 +33,7 @@ export function registerCycleActions(rl) {
|
|
|
33
33
|
});
|
|
34
34
|
rl.registerAction("cycle.update", {
|
|
35
35
|
access: "write",
|
|
36
|
-
description: "Update a cycle.",
|
|
36
|
+
description: withScopedNote("Update a cycle."),
|
|
37
37
|
inputSchema: t.Object({
|
|
38
38
|
id: t.String({ description: "The identifier of the cycle to update" }),
|
|
39
39
|
name: t.Optional(t.String({ description: "The custom name of the cycle" })),
|
|
@@ -60,7 +60,7 @@ export function registerCycleActions(rl) {
|
|
|
60
60
|
// they are not archived with it.
|
|
61
61
|
rl.registerAction("cycle.archive", {
|
|
62
62
|
access: "write",
|
|
63
|
-
description: "Archive one cycle. Issues assigned to it are unlinked from the cycle first, not archived. This is the per-cycle removal; Linear has no single-cycle delete.",
|
|
63
|
+
description: withScopedNote("Archive one cycle. Issues assigned to it are unlinked from the cycle first, not archived. This is the per-cycle removal; Linear has no single-cycle delete."),
|
|
64
64
|
inputSchema: t.Object({
|
|
65
65
|
id: t.String({ description: "The identifier of the cycle to archive" }),
|
|
66
66
|
}),
|
|
@@ -81,7 +81,7 @@ export function registerCycleActions(rl) {
|
|
|
81
81
|
// team, and the description leads with the blast radius.
|
|
82
82
|
rl.registerAction("team.cyclesDeleteAll", {
|
|
83
83
|
access: "write",
|
|
84
|
-
description: "DESTRUCTIVE: delete ALL cycle data for a team and disable the cycles feature. Removes every cycle and its issue associations, not just one. To remove a single cycle use cycle.archive.",
|
|
84
|
+
description: withScopedNote("DESTRUCTIVE: delete ALL cycle data for a team and disable the cycles feature. Removes every cycle and its issue associations, not just one. To remove a single cycle use cycle.archive."),
|
|
85
85
|
inputSchema: t.Object({
|
|
86
86
|
teamId: t.String({
|
|
87
87
|
description: "The identifier of the TEAM whose cycles will all be deleted",
|
|
@@ -14,14 +14,14 @@ import { registerViewActions } from "./views.js";
|
|
|
14
14
|
import { registerWebhookActions } from "./webhooks.js";
|
|
15
15
|
export default function linear(rl) {
|
|
16
16
|
rl.setName("linear");
|
|
17
|
-
rl.setVersion("0.
|
|
17
|
+
rl.setVersion("0.6.0");
|
|
18
18
|
rl.setConnectionSchema(t.Object({
|
|
19
19
|
apiKey: t.String({
|
|
20
20
|
description: "Linear API key (https://linear.app/settings/account/security)",
|
|
21
21
|
env: "LINEAR_API_KEY",
|
|
22
22
|
}),
|
|
23
23
|
scopeLabelIds: t.Optional(t.String({
|
|
24
|
-
description: "Comma-separated Linear issue label
|
|
24
|
+
description: "Comma-separated Linear issue label UUIDs (label names also accepted and resolved). When set, issue/comment/attachment access is restricted to issues with one of these labels; workspace metadata such as teams, states, labels, cycles and users stays readable.",
|
|
25
25
|
env: "LINEAR_SCOPE_LABEL_IDS",
|
|
26
26
|
})),
|
|
27
27
|
}));
|