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,349 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { selectAddress } from "./account.js";
|
|
3
|
+
import { assortment, itemsById, optionDefinitions, } from "./catalog.js";
|
|
4
|
+
import { arr, authed, CONSUMER, cfgOf, http, numOrNull, obj, pick, RESTAURANT, seg, WoltError, } from "./shared.js";
|
|
5
|
+
export async function resolveCart(slug, cart) {
|
|
6
|
+
const a = await assortment(slug);
|
|
7
|
+
const items = itemsById(a);
|
|
8
|
+
const definitions = optionDefinitions(a);
|
|
9
|
+
const categoryOfItem = new Map();
|
|
10
|
+
for (const c of arr(a.categories)) {
|
|
11
|
+
const category = obj(c);
|
|
12
|
+
const categoryId = pick(category.id);
|
|
13
|
+
if (!categoryId)
|
|
14
|
+
continue;
|
|
15
|
+
for (const id of arr(category.item_ids)) {
|
|
16
|
+
const itemId = pick(id);
|
|
17
|
+
if (itemId && !categoryOfItem.has(itemId))
|
|
18
|
+
categoryOfItem.set(itemId, categoryId);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const menu_items = [];
|
|
22
|
+
const purchase_items = [];
|
|
23
|
+
const lines = [];
|
|
24
|
+
for (const line of cart) {
|
|
25
|
+
const item = items.get(line.id);
|
|
26
|
+
if (!item)
|
|
27
|
+
throw new Error(`wolt: item ${line.id} is not on venue ${slug}`);
|
|
28
|
+
const count = line.count ?? 1;
|
|
29
|
+
const categoryId = categoryOfItem.get(line.id) ?? null;
|
|
30
|
+
const basePrice = numOrNull(item.price) ?? 0;
|
|
31
|
+
let optionsTotal = 0;
|
|
32
|
+
const checkoutOptions = [];
|
|
33
|
+
const purchaseOptions = [];
|
|
34
|
+
for (const group of line.options ?? []) {
|
|
35
|
+
const onItem = arr(item.options)
|
|
36
|
+
.map(obj)
|
|
37
|
+
.find((o) => pick(o.id) === group.id);
|
|
38
|
+
if (!onItem)
|
|
39
|
+
throw new Error(`wolt: option group ${group.id} is not on item ${line.id}`);
|
|
40
|
+
const definition = obj(definitions.get(pick(onItem.option_id) ?? ""));
|
|
41
|
+
const values = group.values.map((v) => {
|
|
42
|
+
const declared = arr(definition.values)
|
|
43
|
+
.map(obj)
|
|
44
|
+
.find((d) => pick(d.id) === v.id);
|
|
45
|
+
if (!declared)
|
|
46
|
+
throw new Error(`wolt: option ${v.id} is not a choice in group ${group.id}`);
|
|
47
|
+
const price = numOrNull(declared.price) ?? 0;
|
|
48
|
+
const valueCount = v.count ?? 1;
|
|
49
|
+
optionsTotal += price * valueCount;
|
|
50
|
+
return { id: v.id, price, count: valueCount };
|
|
51
|
+
});
|
|
52
|
+
checkoutOptions.push({ id: group.id, values });
|
|
53
|
+
purchaseOptions.push({
|
|
54
|
+
id: { $oid: group.id },
|
|
55
|
+
type: pick(definition.type) ?? "Multichoice",
|
|
56
|
+
values: values.map((v) => ({
|
|
57
|
+
id: { $oid: v.id },
|
|
58
|
+
price: v.price,
|
|
59
|
+
count: v.count,
|
|
60
|
+
})),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const amount = (basePrice + optionsTotal) * count;
|
|
64
|
+
menu_items.push({
|
|
65
|
+
id: line.id,
|
|
66
|
+
count,
|
|
67
|
+
options: checkoutOptions,
|
|
68
|
+
base_price: basePrice,
|
|
69
|
+
end_amount: amount,
|
|
70
|
+
category_id: categoryId,
|
|
71
|
+
category_ids: categoryId ? [categoryId] : [],
|
|
72
|
+
exclude_from_discounts: false,
|
|
73
|
+
exclude_from_discounts_min_basket: false,
|
|
74
|
+
exclude_from_credits: false,
|
|
75
|
+
alcohol_permille: numOrNull(item.alcohol_permille) ?? 0,
|
|
76
|
+
restrictions: [],
|
|
77
|
+
});
|
|
78
|
+
purchase_items.push({
|
|
79
|
+
id: { $oid: line.id },
|
|
80
|
+
baseprice: basePrice,
|
|
81
|
+
options: purchaseOptions,
|
|
82
|
+
count,
|
|
83
|
+
end_amount: amount,
|
|
84
|
+
alcohol_percentage: 0,
|
|
85
|
+
checksum: pick(item.checksum),
|
|
86
|
+
substitution_settings: { is_allowed: true },
|
|
87
|
+
from_recommendation: false,
|
|
88
|
+
});
|
|
89
|
+
lines.push({ id: line.id, name: pick(item.name), count, amount });
|
|
90
|
+
}
|
|
91
|
+
return { menu_items, purchase_items, lines };
|
|
92
|
+
}
|
|
93
|
+
export async function estimate(venue, cart, method, tip) {
|
|
94
|
+
const menuItems = cart.map((line) => ({
|
|
95
|
+
id: line.id,
|
|
96
|
+
count: line.count ?? 1,
|
|
97
|
+
options: line.options ?? [],
|
|
98
|
+
}));
|
|
99
|
+
const r = await http(CONSUMER, "/order-xp/web/v1/pages/venue/pricing-estimates", {
|
|
100
|
+
method: "POST",
|
|
101
|
+
body: {
|
|
102
|
+
purchase_plan: {
|
|
103
|
+
venue: {
|
|
104
|
+
id: venue.id,
|
|
105
|
+
country: venue.country,
|
|
106
|
+
currency: venue.currency,
|
|
107
|
+
},
|
|
108
|
+
delivery_method: method,
|
|
109
|
+
menu_items: menuItems,
|
|
110
|
+
courier_tip: tip,
|
|
111
|
+
use_promo_discount_ids: [],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
return {
|
|
116
|
+
venue: { id: venue.id, slug: venue.slug, name: venue.name },
|
|
117
|
+
currency: venue.currency,
|
|
118
|
+
items: menuItems,
|
|
119
|
+
estimate: r,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async function checkout(ctx, venue, address, menuItems, tip) {
|
|
123
|
+
const cfg = cfgOf(ctx);
|
|
124
|
+
const [longitude, latitude] = address.coordinates ?? [null, null];
|
|
125
|
+
return authed(ctx, CONSUMER, "/order-xp/mobile/v2/pages/checkout", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
body: {
|
|
128
|
+
purchase_plan: {
|
|
129
|
+
delivery: {
|
|
130
|
+
delivery_coordinates: { latitude, longitude },
|
|
131
|
+
delivery_info_id: address.id,
|
|
132
|
+
},
|
|
133
|
+
venue: {
|
|
134
|
+
id: venue.id,
|
|
135
|
+
country: venue.country,
|
|
136
|
+
currency: venue.currency,
|
|
137
|
+
},
|
|
138
|
+
delivery_method: "homedelivery",
|
|
139
|
+
menu_items: menuItems,
|
|
140
|
+
use_cash: false,
|
|
141
|
+
selected_offer_ids: [],
|
|
142
|
+
pre_considered_discount_ids: [],
|
|
143
|
+
courier_tip: tip,
|
|
144
|
+
use_loyalty_points_amount: 0,
|
|
145
|
+
use_credits_and_tokens: true,
|
|
146
|
+
did_client_disable_purchase: false,
|
|
147
|
+
payment_methods: cfg.paymentMethodId
|
|
148
|
+
? [{ id: cfg.paymentMethodId, type: "card" }]
|
|
149
|
+
: [],
|
|
150
|
+
delivery_config: { method: "homedelivery", schedule: "standard" },
|
|
151
|
+
is_partial_address: false,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function deliveryEstimate(checkoutBody, venue) {
|
|
157
|
+
const configs = arr(checkoutBody.delivery_configs).map(obj);
|
|
158
|
+
const chosen = configs.find((c) => pick(c.method) === "homedelivery" &&
|
|
159
|
+
pick(c.schedule) === "standard" &&
|
|
160
|
+
numOrNull(obj(c.estimate).max) !== null) ?? configs.find((c) => numOrNull(obj(c.estimate).max) !== null);
|
|
161
|
+
const range = obj(chosen?.estimate);
|
|
162
|
+
const min = numOrNull(range.min);
|
|
163
|
+
const max = numOrNull(range.max);
|
|
164
|
+
if (min !== null && max !== null)
|
|
165
|
+
return `${min}-${max}`;
|
|
166
|
+
const declared = obj(venue.estimate_range);
|
|
167
|
+
const dmin = numOrNull(declared.min);
|
|
168
|
+
const dmax = numOrNull(declared.max);
|
|
169
|
+
if (dmin !== null && dmax !== null)
|
|
170
|
+
return `${dmin}-${dmax}`;
|
|
171
|
+
const single = venue.live_estimate_minutes ?? venue.estimate_minutes;
|
|
172
|
+
return single !== null ? `${single}-${single + 10}` : "30-45";
|
|
173
|
+
}
|
|
174
|
+
export async function planOrder(ctx, venue, addresses, wantedAddress, slug, cart, tip) {
|
|
175
|
+
const address = selectAddress(addresses, wantedAddress);
|
|
176
|
+
const resolved = await resolveCart(slug, cart);
|
|
177
|
+
const body = await checkout(ctx, venue, address, resolved.menu_items, tip);
|
|
178
|
+
if (body.purchasing_disabled === true) {
|
|
179
|
+
const reason = pick(obj(body.call_to_action).text) ??
|
|
180
|
+
"Wolt has disabled purchasing here";
|
|
181
|
+
throw new Error(`wolt: this order cannot be placed — ${reason}`);
|
|
182
|
+
}
|
|
183
|
+
const validation = obj(body.purchase_validation);
|
|
184
|
+
const payable = numOrNull(validation.end_amount) ?? numOrNull(body.payable_amount);
|
|
185
|
+
// Without a total there is nothing to show a person and nothing to bind a
|
|
186
|
+
// confirmation to, and the purchase would post a null amount.
|
|
187
|
+
if (payable === null)
|
|
188
|
+
throw new Error("wolt: the checkout returned no payable amount, so this order cannot be priced");
|
|
189
|
+
return {
|
|
190
|
+
venue,
|
|
191
|
+
address,
|
|
192
|
+
cart: resolved,
|
|
193
|
+
checkout: body,
|
|
194
|
+
payable_amount: payable,
|
|
195
|
+
delivery_price: numOrNull(validation.delivery_price),
|
|
196
|
+
estimate_minutes: deliveryEstimate(body, venue),
|
|
197
|
+
tip,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* A fingerprint of everything a person agrees to when they say yes: the venue,
|
|
202
|
+
* every line and its amount, where it is going, and the total. Re-derived on the
|
|
203
|
+
* confirming call, so a total that moved in between cannot be charged against
|
|
204
|
+
* the old consent.
|
|
205
|
+
*/
|
|
206
|
+
export function quoteFor(plan) {
|
|
207
|
+
const material = JSON.stringify([
|
|
208
|
+
plan.venue.id ?? plan.venue.slug ?? "",
|
|
209
|
+
plan.address.id ?? "",
|
|
210
|
+
plan.cart.lines.map((l) => [l.id, l.count, l.amount]),
|
|
211
|
+
plan.payable_amount,
|
|
212
|
+
plan.venue.currency ?? "",
|
|
213
|
+
plan.tip,
|
|
214
|
+
]);
|
|
215
|
+
return `q_${createHash("sha256").update(material).digest("hex").slice(0, 16)}`;
|
|
216
|
+
}
|
|
217
|
+
export function previewOf(plan) {
|
|
218
|
+
return {
|
|
219
|
+
venue: {
|
|
220
|
+
id: plan.venue.id,
|
|
221
|
+
slug: plan.venue.slug,
|
|
222
|
+
name: plan.venue.name,
|
|
223
|
+
},
|
|
224
|
+
deliver_to: {
|
|
225
|
+
id: plan.address.id,
|
|
226
|
+
address: plan.address.address,
|
|
227
|
+
city: plan.address.city,
|
|
228
|
+
},
|
|
229
|
+
items: plan.cart.lines,
|
|
230
|
+
delivery_price: plan.delivery_price,
|
|
231
|
+
tip: plan.tip,
|
|
232
|
+
payable_amount: plan.payable_amount,
|
|
233
|
+
currency: plan.venue.currency,
|
|
234
|
+
estimate_minutes: plan.estimate_minutes,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
export async function purchase(ctx, plan) {
|
|
238
|
+
const cfg = cfgOf(ctx);
|
|
239
|
+
if (!cfg.paymentMethodId)
|
|
240
|
+
throw new Error("wolt: no saved card (paymentMethodId) — a saved card is required, Google Pay cannot be replayed headless");
|
|
241
|
+
const validation = obj(plan.checkout.purchase_validation);
|
|
242
|
+
const [longitude, latitude] = plan.address.coordinates ?? [null, null];
|
|
243
|
+
const r = await authed(ctx, RESTAURANT, "/v2/purchases", {
|
|
244
|
+
method: "POST",
|
|
245
|
+
body: {
|
|
246
|
+
venue_id: plan.venue.id,
|
|
247
|
+
checkout_id: plan.checkout.id,
|
|
248
|
+
payment_method_id: cfg.paymentMethodId,
|
|
249
|
+
payment_method_type: "card",
|
|
250
|
+
end_amount: plan.payable_amount,
|
|
251
|
+
end_amount_rounding: numOrNull(validation.end_amount_rounding) ?? 0,
|
|
252
|
+
items: plan.cart.purchase_items,
|
|
253
|
+
client_pre_estimate: plan.estimate_minutes,
|
|
254
|
+
signature_datetime: { $date: Date.now() },
|
|
255
|
+
client_nonce: randomUUID(),
|
|
256
|
+
delivery_method: "homedelivery",
|
|
257
|
+
currency: plan.venue.currency,
|
|
258
|
+
preorder: false,
|
|
259
|
+
no_credits_or_tokens: false,
|
|
260
|
+
credits_amount: numOrNull(validation.credits_amount) ?? 0,
|
|
261
|
+
delivery_price: plan.delivery_price ?? 0,
|
|
262
|
+
delivery_info: {
|
|
263
|
+
id: { $oid: plan.address.id },
|
|
264
|
+
delivery_comments: "\n",
|
|
265
|
+
location: {
|
|
266
|
+
address: plan.address.address,
|
|
267
|
+
city: plan.address.city,
|
|
268
|
+
coordinates: { coordinates: [longitude, latitude], type: "Point" },
|
|
269
|
+
},
|
|
270
|
+
use_last_100m_address_picker: true,
|
|
271
|
+
},
|
|
272
|
+
tip_amount: plan.tip,
|
|
273
|
+
language: "en",
|
|
274
|
+
additional_checkout_options: { no_contact_delivery: false },
|
|
275
|
+
ravelin_device_id: cfg.ravelinDeviceId,
|
|
276
|
+
browser_info: {
|
|
277
|
+
color_depth: 32,
|
|
278
|
+
screen_width: 1080,
|
|
279
|
+
screen_height: 2400,
|
|
280
|
+
time_zone_offset: -180,
|
|
281
|
+
java_enabled: false,
|
|
282
|
+
language: "en",
|
|
283
|
+
ravelin_device_id: cfg.ravelinDeviceId,
|
|
284
|
+
},
|
|
285
|
+
to: { id: { $oid: plan.venue.id }, type: "venue" },
|
|
286
|
+
signature: "N/A",
|
|
287
|
+
type: "purchase",
|
|
288
|
+
device_channel: "app",
|
|
289
|
+
pricing_model_version: 2023,
|
|
290
|
+
use_self_service_cancellation: false,
|
|
291
|
+
discounts: arr(validation.discounts),
|
|
292
|
+
surcharges: arr(validation.surcharges),
|
|
293
|
+
offers: arr(validation.offers),
|
|
294
|
+
menu_items_source: "consumer-assortment",
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
const first = obj(Array.isArray(r.results) ? r.results[0] : r.results);
|
|
298
|
+
const orderId = pick(obj(first.id).$oid, first.id, obj(r.id).$oid, r.id, r.purchase_id);
|
|
299
|
+
return { order_id: orderId };
|
|
300
|
+
}
|
|
301
|
+
export async function orderStatus(ctx, orderId) {
|
|
302
|
+
const id = seg(orderId, "order_id");
|
|
303
|
+
const [tracking, page] = await Promise.all([
|
|
304
|
+
authed(ctx, RESTAURANT, `/v2/order_details/purchase_tracking/${id}`),
|
|
305
|
+
authed(ctx, CONSUMER, `/order-xp/v1/pages/order-tracking/${id}`).catch(() => null),
|
|
306
|
+
]);
|
|
307
|
+
const details = obj(tracking.order_details);
|
|
308
|
+
const context = obj(obj(page ?? {}).purchase_context);
|
|
309
|
+
const eta = numOrNull(obj(details.delivery_eta).$date);
|
|
310
|
+
return {
|
|
311
|
+
order_id: orderId,
|
|
312
|
+
status: pick(context.purchase_status),
|
|
313
|
+
venue: pick(context.venue_name),
|
|
314
|
+
eta: eta !== null ? new Date(eta).toISOString() : null,
|
|
315
|
+
delivery_distance: numOrNull(details.delivery_distance),
|
|
316
|
+
handshake_code: pick(details.delivery_handshake_code),
|
|
317
|
+
couriers: arr(tracking.drivers).map((d) => ({
|
|
318
|
+
name: pick(obj(d).name),
|
|
319
|
+
coordinates: obj(d).coordinates ?? obj(d).location ?? null,
|
|
320
|
+
})),
|
|
321
|
+
currency: pick(details.currency),
|
|
322
|
+
poll_after_seconds: numOrNull(tracking.expires_in_seconds),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
export async function cancelOrder(ctx, orderId, reason) {
|
|
326
|
+
const id = seg(orderId, "order_id");
|
|
327
|
+
try {
|
|
328
|
+
await authed(ctx, RESTAURANT, `/v2/purchases/${id}/cancel`, {
|
|
329
|
+
method: "PUT",
|
|
330
|
+
body: { cancellation_reason: reason },
|
|
331
|
+
});
|
|
332
|
+
return { cancelled: true, order_id: orderId, reason };
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
// A refusal is the normal outcome once the venue has accepted, so it comes
|
|
336
|
+
// back as an answer rather than an exception. error_code 4051 is that case.
|
|
337
|
+
if (!(e instanceof WoltError))
|
|
338
|
+
throw e;
|
|
339
|
+
const code = numOrNull(e.details().error_code);
|
|
340
|
+
return {
|
|
341
|
+
cancelled: false,
|
|
342
|
+
order_id: orderId,
|
|
343
|
+
error_code: code,
|
|
344
|
+
note: code === 4051
|
|
345
|
+
? "The venue already accepted this order, so self-service cancellation is closed. Wolt support can still help."
|
|
346
|
+
: "Wolt refused the cancellation.",
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
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 Wolt consumer surface.
|
|
8
|
+
*
|
|
9
|
+
* Three hosts, two client personas. Catalogue reads are anonymous and go out as
|
|
10
|
+
* the web app; everything authenticated goes out as the mobile app, because the
|
|
11
|
+
* API only accepts the authed calls from a client that looks like the phone.
|
|
12
|
+
*/
|
|
13
|
+
export const RESTAURANT = "restaurant-api.wolt.com";
|
|
14
|
+
export const CONSUMER = "consumer-api.wolt.com";
|
|
15
|
+
export const AUTH = "authentication.wolt.com";
|
|
16
|
+
export const DEF_LAT = 32.0853;
|
|
17
|
+
export const DEF_LON = 34.7818; // TLV
|
|
18
|
+
export const AUDIENCE = "restaurant-api";
|
|
19
|
+
export const CAPABILITIES = "access_confirmation";
|
|
20
|
+
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
21
|
+
const DEFAULT_TOKEN_TTL_MS = 3_600_000;
|
|
22
|
+
const WEB_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
|
|
23
|
+
/** Mobile client headers; the authed endpoints reject anything that is not the app. */
|
|
24
|
+
const MOBILE = {
|
|
25
|
+
"app-language": "en",
|
|
26
|
+
"app-locale": "en-US",
|
|
27
|
+
"client-version": "26.30.4",
|
|
28
|
+
clientversionnumber: "142026304",
|
|
29
|
+
platform: "Android",
|
|
30
|
+
"user-agent": "Wolt/26.30.4; Build/142026304; Android/16; Google sdk_gphone",
|
|
31
|
+
};
|
|
32
|
+
const WEB = {
|
|
33
|
+
"user-agent": WEB_UA,
|
|
34
|
+
"accept-language": "en",
|
|
35
|
+
"app-language": "en",
|
|
36
|
+
platform: "Web",
|
|
37
|
+
"client-version": "1.16.125",
|
|
38
|
+
origin: "https://wolt.com",
|
|
39
|
+
referer: "https://wolt.com/",
|
|
40
|
+
};
|
|
41
|
+
export const cfgOf = (ctx) => (ctx.connection.config || {});
|
|
42
|
+
export const seg = (value, what) => segment(value, what, "wolt");
|
|
43
|
+
/**
|
|
44
|
+
* Normalize a phone to E.164 for Wolt, so a number given the local Israeli way
|
|
45
|
+
* still routes. Handles intl "+972…"/"00972…", national "0500000000", and bare
|
|
46
|
+
* local "500000000" alike. Idempotent.
|
|
47
|
+
*/
|
|
48
|
+
export function normPhone(input) {
|
|
49
|
+
let digits = String(input).replace(/[^\d]/g, "");
|
|
50
|
+
if (digits.startsWith("00"))
|
|
51
|
+
digits = digits.slice(2); // intl exit code
|
|
52
|
+
if (digits.startsWith("972"))
|
|
53
|
+
return `+${digits}`;
|
|
54
|
+
if (digits.startsWith("0"))
|
|
55
|
+
return `+972${digits.slice(1)}`;
|
|
56
|
+
return `+972${digits}`;
|
|
57
|
+
}
|
|
58
|
+
export const formEncode = (body) => Object.entries(body)
|
|
59
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v ?? ""))}`)
|
|
60
|
+
.join("&");
|
|
61
|
+
/**
|
|
62
|
+
* A failed request.
|
|
63
|
+
*
|
|
64
|
+
* The body stays private. Wolt puts meaningful data in its 4xx bodies — the
|
|
65
|
+
* login escalation token, `error_code` — so the login flow needs to read it,
|
|
66
|
+
* but those same bodies carry access and refresh tokens. `details()` is the
|
|
67
|
+
* only way in, and the message never contains any of it.
|
|
68
|
+
*/
|
|
69
|
+
export class WoltError extends Error {
|
|
70
|
+
status;
|
|
71
|
+
endpoint;
|
|
72
|
+
#body;
|
|
73
|
+
constructor(status, endpoint, body) {
|
|
74
|
+
const retired = status === 410 ? " (endpoint retired by Wolt)" : "";
|
|
75
|
+
super(`wolt: request failed (HTTP ${status})${retired} on ${endpoint}`);
|
|
76
|
+
this.name = "WoltError";
|
|
77
|
+
this.status = status;
|
|
78
|
+
this.endpoint = endpoint;
|
|
79
|
+
this.#body = body;
|
|
80
|
+
}
|
|
81
|
+
/** The parsed body, for the branches that must read it. Never logged. */
|
|
82
|
+
details() {
|
|
83
|
+
try {
|
|
84
|
+
return obj(JSON.parse(this.#body));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function endpointOf(host, path) {
|
|
92
|
+
return `${host}${path.split("?")[0]}`;
|
|
93
|
+
}
|
|
94
|
+
export async function http(host, path, opts = {}) {
|
|
95
|
+
const endpoint = endpointOf(host, path);
|
|
96
|
+
const text = await httpText(host, path, opts);
|
|
97
|
+
// Wolt answers 200 with nothing at all on endpoints it has retired.
|
|
98
|
+
if (!text.trim())
|
|
99
|
+
throw new Error(`wolt: ${endpoint} returned an empty body (endpoint retired)`);
|
|
100
|
+
try {
|
|
101
|
+
return obj(JSON.parse(text));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new Error(`wolt: non-JSON response from ${endpoint}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function httpText(host, path, opts = {}) {
|
|
108
|
+
const { method = "GET", body = null, headers = {}, web = true } = opts;
|
|
109
|
+
const payload = body == null
|
|
110
|
+
? null
|
|
111
|
+
: typeof body === "string"
|
|
112
|
+
? body
|
|
113
|
+
: JSON.stringify(body);
|
|
114
|
+
const merged = {
|
|
115
|
+
...(web ? WEB : {}),
|
|
116
|
+
accept: "application/json",
|
|
117
|
+
...headers,
|
|
118
|
+
};
|
|
119
|
+
if (payload && !merged["content-type"])
|
|
120
|
+
merged["content-type"] = "application/json";
|
|
121
|
+
const endpoint = endpointOf(host, path);
|
|
122
|
+
const res = await authedFetch(`https://${host}${path}`, {
|
|
123
|
+
method,
|
|
124
|
+
headers: merged,
|
|
125
|
+
body: payload,
|
|
126
|
+
});
|
|
127
|
+
const text = await readBounded(res, MAX_RESPONSE_BYTES, `wolt: response exceeded the size limit on ${endpoint}`);
|
|
128
|
+
if (res.status >= 400)
|
|
129
|
+
throw new WoltError(res.status, endpoint, text);
|
|
130
|
+
return text;
|
|
131
|
+
}
|
|
132
|
+
// ---------- identity ----------
|
|
133
|
+
const ravelinDeviceId = () => `rvnand-6-${(randomUUID() + randomUUID()).replace(/-/g, "").toUpperCase().slice(0, 64)}`;
|
|
134
|
+
const hex32 = () => (randomUUID() + randomUUID()).replace(/-/g, "").slice(0, 32);
|
|
135
|
+
/**
|
|
136
|
+
* Device identity, generated once and then persisted. Wolt binds the session and
|
|
137
|
+
* its fraud fingerprint to these, so regenerating them invalidates the login.
|
|
138
|
+
*/
|
|
139
|
+
export async function ensureIdentity(ctx) {
|
|
140
|
+
const cfg = cfgOf(ctx);
|
|
141
|
+
const patch = {};
|
|
142
|
+
if (!cfg.visitorId)
|
|
143
|
+
patch.visitorId = randomUUID();
|
|
144
|
+
if (!cfg.ravelinDeviceId)
|
|
145
|
+
patch.ravelinDeviceId = ravelinDeviceId();
|
|
146
|
+
if (!cfg.deviceToken)
|
|
147
|
+
patch.deviceToken = hex32();
|
|
148
|
+
if (!cfg.woltSessionId)
|
|
149
|
+
patch.woltSessionId = randomUUID();
|
|
150
|
+
if (Object.keys(patch).length)
|
|
151
|
+
await ctx.updateConnection(patch);
|
|
152
|
+
return cfgOf(ctx);
|
|
153
|
+
}
|
|
154
|
+
/** Headers every authenticated or login call carries. */
|
|
155
|
+
export function clientHeaders(cfg) {
|
|
156
|
+
return {
|
|
157
|
+
...MOBILE,
|
|
158
|
+
"w-wolt-session-id": cfg.woltSessionId ?? randomUUID(),
|
|
159
|
+
"x-wolt-visitor-id": cfg.visitorId ?? randomUUID(),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// ---------- credentials ----------
|
|
163
|
+
export function expiresAt(expiresIn) {
|
|
164
|
+
const seconds = numOrNull(expiresIn);
|
|
165
|
+
return Date.now() + (seconds ? seconds * 1000 : DEFAULT_TOKEN_TTL_MS);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The refresh grant. Called only from inside an update owner and always with
|
|
169
|
+
* explicit values, so it can never re-enter the connection store.
|
|
170
|
+
*/
|
|
171
|
+
async function refreshGrant(cfg) {
|
|
172
|
+
return http(AUTH, "/v1/wauth2/access_token", {
|
|
173
|
+
method: "POST",
|
|
174
|
+
web: false,
|
|
175
|
+
body: formEncode({
|
|
176
|
+
grant_type: "refresh_token",
|
|
177
|
+
refresh_token: cfg.refreshToken,
|
|
178
|
+
device_token: cfg.deviceToken ?? "",
|
|
179
|
+
}),
|
|
180
|
+
headers: {
|
|
181
|
+
...clientHeaders(cfg),
|
|
182
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
export async function accessToken(ctx, force = false) {
|
|
187
|
+
const cfg = await ensureIdentity(ctx);
|
|
188
|
+
if (!cfg.refreshToken)
|
|
189
|
+
throw new Error("wolt: not connected — run the owner login (account.requestEmailCode / account.requestSmsCode / account.redeemLink)");
|
|
190
|
+
// Renewal runs under the store's update ownership, so parallel actions
|
|
191
|
+
// coalesce onto one grant. Wolt rotates the refresh token on every grant, so
|
|
192
|
+
// a race would persist one the provider has already replaced.
|
|
193
|
+
return coordinatedAccessToken(ctx, async (current) => {
|
|
194
|
+
const resp = await refreshGrant(current);
|
|
195
|
+
const issued = pick(resp.access_token);
|
|
196
|
+
if (!issued)
|
|
197
|
+
throw new Error("wolt: token refresh returned no access_token — the refresh token has expired, re-run the owner login");
|
|
198
|
+
const rotated = pick(resp.refresh_token);
|
|
199
|
+
return {
|
|
200
|
+
accessToken: issued,
|
|
201
|
+
accessTokenExpiresAt: expiresAt(resp.expires_in),
|
|
202
|
+
...(rotated ? { refreshToken: rotated } : {}),
|
|
203
|
+
};
|
|
204
|
+
}, force);
|
|
205
|
+
}
|
|
206
|
+
export async function authed(ctx, host, path, opts = {}) {
|
|
207
|
+
const token = await accessToken(ctx);
|
|
208
|
+
const cfg = cfgOf(ctx);
|
|
209
|
+
return http(host, path, {
|
|
210
|
+
...opts,
|
|
211
|
+
web: false,
|
|
212
|
+
headers: {
|
|
213
|
+
...clientHeaders(cfg),
|
|
214
|
+
authorization: `Bearer ${token}`,
|
|
215
|
+
...(opts.headers ?? {}),
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|