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,427 @@
|
|
|
1
|
+
import { AUDIENCE, AUTH, arr, authed, CAPABILITIES, CONSUMER, cfgOf, clientHeaders, ensureIdentity, expiresAt, formEncode, http, normPhone, obj, pick, RESTAURANT, WoltError, } from "./shared.js";
|
|
2
|
+
async function authJson(ctx, path, body, extra = {}) {
|
|
3
|
+
return http(AUTH, path, {
|
|
4
|
+
method: "POST",
|
|
5
|
+
web: false,
|
|
6
|
+
body,
|
|
7
|
+
headers: { ...clientHeaders(cfgOf(ctx)), ...extra },
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
async function authForm(ctx, body, extra = {}) {
|
|
11
|
+
return http(AUTH, "/v1/wauth2/access_token", {
|
|
12
|
+
method: "POST",
|
|
13
|
+
web: false,
|
|
14
|
+
body: formEncode(body),
|
|
15
|
+
headers: {
|
|
16
|
+
...clientHeaders(cfgOf(ctx)),
|
|
17
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
18
|
+
...extra,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const captchaHeader = (token) => token ? { "h-captcha-response": token } : {};
|
|
23
|
+
/** The query of a URL, or of a bare `a=b&c=d` fragment. */
|
|
24
|
+
function queryOf(text) {
|
|
25
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(text)) {
|
|
26
|
+
try {
|
|
27
|
+
return new URL(text).searchParams;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
/* not a URL after all */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const query = text.includes("?") ? text.slice(text.indexOf("?") + 1) : text;
|
|
34
|
+
return query.includes("=") ? new URLSearchParams(query) : null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The token out of a magic link.
|
|
38
|
+
*
|
|
39
|
+
* Wolt's emails wrap the real link in a redirect, so the token can sit one or
|
|
40
|
+
* two query layers down. Each layer is unwrapped with URLSearchParams, which
|
|
41
|
+
* decodes exactly once and leaves a malformed escape alone rather than throwing
|
|
42
|
+
* a URIError from inside a login.
|
|
43
|
+
*/
|
|
44
|
+
export function linkToken(link) {
|
|
45
|
+
const text = String(link).trim();
|
|
46
|
+
if (!text)
|
|
47
|
+
throw new Error("wolt: empty login link");
|
|
48
|
+
// A bare token carries none of a URL's punctuation.
|
|
49
|
+
if (!/[:/?&=]/.test(text))
|
|
50
|
+
return text;
|
|
51
|
+
let layers = [text];
|
|
52
|
+
for (let depth = 0; depth < 3 && layers.length; depth++) {
|
|
53
|
+
const nested = [];
|
|
54
|
+
for (const layer of layers) {
|
|
55
|
+
const params = queryOf(layer);
|
|
56
|
+
if (!params)
|
|
57
|
+
continue;
|
|
58
|
+
const token = params.get("token");
|
|
59
|
+
if (token)
|
|
60
|
+
return token;
|
|
61
|
+
for (const [, value] of params)
|
|
62
|
+
if (value.includes("token="))
|
|
63
|
+
nested.push(value);
|
|
64
|
+
}
|
|
65
|
+
layers = nested;
|
|
66
|
+
}
|
|
67
|
+
throw new Error("wolt: no `token` found in that link");
|
|
68
|
+
}
|
|
69
|
+
/** Persist the grant and learn what ordering will need. */
|
|
70
|
+
async function finish(ctx, grant) {
|
|
71
|
+
const access = pick(grant.access_token);
|
|
72
|
+
const refresh = pick(grant.refresh_token);
|
|
73
|
+
if (!access || !refresh)
|
|
74
|
+
throw new Error("wolt: the login grant returned no tokens");
|
|
75
|
+
await ctx.updateConnection({
|
|
76
|
+
refreshToken: refresh,
|
|
77
|
+
accessToken: access,
|
|
78
|
+
accessTokenExpiresAt: expiresAt(grant.expires_in),
|
|
79
|
+
pendingPhone: undefined,
|
|
80
|
+
pendingEmail: undefined,
|
|
81
|
+
pendingConfirmationToken: undefined,
|
|
82
|
+
pendingEmailToken: undefined,
|
|
83
|
+
});
|
|
84
|
+
// A card and an address are what ordering needs; neither failing is allowed
|
|
85
|
+
// to lose a login that worked, so both come back as warnings.
|
|
86
|
+
const warnings = [];
|
|
87
|
+
let card = null;
|
|
88
|
+
try {
|
|
89
|
+
card = await discoverPaymentMethod(ctx);
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
warnings.push(`payment-method discovery failed (${e.message})`);
|
|
93
|
+
}
|
|
94
|
+
if (!card)
|
|
95
|
+
warnings.push("no saved card on the account — add one in the Wolt app before ordering (Google Pay cannot be replayed headless)");
|
|
96
|
+
let addresses = 0;
|
|
97
|
+
try {
|
|
98
|
+
addresses = (await listAddresses(ctx)).length;
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
warnings.push(`address lookup failed (${e.message})`);
|
|
102
|
+
}
|
|
103
|
+
if (addresses === 0)
|
|
104
|
+
warnings.push("no saved delivery address — add one in the Wolt app");
|
|
105
|
+
return {
|
|
106
|
+
connected: true,
|
|
107
|
+
ready_to_order: Boolean(card) && addresses > 0,
|
|
108
|
+
saved_addresses: addresses,
|
|
109
|
+
warnings,
|
|
110
|
+
// Wolt rotates the refresh token on every grant, so an untouched session
|
|
111
|
+
// eventually lapses. A slow schedule is enough: the grant needs no live
|
|
112
|
+
// access token.
|
|
113
|
+
keepalive: { every: "12h", prompt: "call wolt.account.refresh()" },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export async function redeemLink(ctx, link) {
|
|
117
|
+
await ensureIdentity(ctx);
|
|
118
|
+
const cfg = cfgOf(ctx);
|
|
119
|
+
const token = linkToken(link);
|
|
120
|
+
try {
|
|
121
|
+
const grant = await authForm(ctx, {
|
|
122
|
+
grant_type: "email_login",
|
|
123
|
+
token,
|
|
124
|
+
audience: AUDIENCE,
|
|
125
|
+
device_token: cfg.deviceToken,
|
|
126
|
+
capabilities: CAPABILITIES,
|
|
127
|
+
});
|
|
128
|
+
return finish(ctx, grant);
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
if (e instanceof WoltError && e.details().error_code === 126)
|
|
132
|
+
throw new Error("wolt: that login link is expired or already used — request a fresh one and redeem it within a few minutes");
|
|
133
|
+
throw e;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export async function requestEmailCode(ctx, email, captcha) {
|
|
137
|
+
await ensureIdentity(ctx);
|
|
138
|
+
await ctx.updateConnection({ pendingEmail: email, pendingPhone: undefined });
|
|
139
|
+
const r = await authJson(ctx, "/v3/users/email_login", { email, audience: AUDIENCE, email_code_support: true }, captchaHeader(captcha));
|
|
140
|
+
if (r.new_user === true && !captcha) {
|
|
141
|
+
await ctx.updateConnection({ pendingEmail: undefined });
|
|
142
|
+
return {
|
|
143
|
+
sent: false,
|
|
144
|
+
reason: "not_registered",
|
|
145
|
+
email,
|
|
146
|
+
note: "No Wolt account exists for this address. Use the phone number on the account instead: account.requestSmsCode.",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
sent: true,
|
|
151
|
+
email,
|
|
152
|
+
includes_code: r.includes_code ?? null,
|
|
153
|
+
new_user: r.new_user ?? null,
|
|
154
|
+
note: "The email carries a login link and/or a numeric code. Submit the code with account.submitCode, or redeem the link with account.redeemLink.",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** What a phone number can log in with. Sends nothing. */
|
|
158
|
+
export async function loginMethods(ctx, phone) {
|
|
159
|
+
await ensureIdentity(ctx);
|
|
160
|
+
const normalized = normPhone(phone);
|
|
161
|
+
const cfg = cfgOf(ctx);
|
|
162
|
+
const operation = await authJson(ctx, "/v1/captcha/site_key", {
|
|
163
|
+
operation: "start_phone_number_authentication_consumer",
|
|
164
|
+
phone_number: normalized,
|
|
165
|
+
}).catch(() => ({}));
|
|
166
|
+
const methods = await authJson(ctx, "/v1/wauth2/consumer-sms/login-methods", {
|
|
167
|
+
phone_number: normalized,
|
|
168
|
+
device_token: cfg.deviceToken,
|
|
169
|
+
operation_token: pick(operation.operation_token),
|
|
170
|
+
});
|
|
171
|
+
return { phone: normalized, methods };
|
|
172
|
+
}
|
|
173
|
+
export async function requestSmsCode(ctx, input) {
|
|
174
|
+
await ensureIdentity(ctx);
|
|
175
|
+
const normalized = normPhone(input.phone);
|
|
176
|
+
await ctx.updateConnection({ pendingPhone: normalized });
|
|
177
|
+
const cfg = cfgOf(ctx);
|
|
178
|
+
const operation = await authJson(ctx, "/v1/captcha/site_key", {
|
|
179
|
+
operation: "start_phone_number_authentication_consumer",
|
|
180
|
+
phone_number: normalized,
|
|
181
|
+
}).catch(() => ({}));
|
|
182
|
+
const operationToken = pick(operation.operation_token);
|
|
183
|
+
// The merge path: a magic-link token from any mailbox proves the email half,
|
|
184
|
+
// which is what unlocks SMS on a social or phone-only account.
|
|
185
|
+
const warnings = [];
|
|
186
|
+
const emailToken = input.emailToken
|
|
187
|
+
? linkToken(input.emailToken)
|
|
188
|
+
: (cfg.pendingEmailToken ?? null);
|
|
189
|
+
if (emailToken) {
|
|
190
|
+
try {
|
|
191
|
+
await authJson(ctx, "/v1/wauth2/consumer-sms/validate-number", {
|
|
192
|
+
phone_number: normalized,
|
|
193
|
+
email_token: emailToken,
|
|
194
|
+
email: input.email ?? cfg.pendingEmail ?? undefined,
|
|
195
|
+
use_new_response_format: true,
|
|
196
|
+
}, captchaHeader(input.vnCaptcha));
|
|
197
|
+
await ctx.updateConnection({ pendingEmailToken: emailToken });
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
warnings.push(`number validation failed (${e.message}); the SMS was still requested`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await authJson(ctx, "/v2/wauth2/consumer-sms/start-phone-number-authentication", {
|
|
204
|
+
phone_number: normalized,
|
|
205
|
+
message_delivery_method: input.viaWhatsapp ? "whatsapp" : "sms",
|
|
206
|
+
device_token: cfg.deviceToken,
|
|
207
|
+
audience: AUDIENCE,
|
|
208
|
+
capabilities: [CAPABILITIES],
|
|
209
|
+
operation_token: operationToken,
|
|
210
|
+
}, captchaHeader(input.smsCaptcha));
|
|
211
|
+
return {
|
|
212
|
+
sent: true,
|
|
213
|
+
phone: normalized,
|
|
214
|
+
via: input.viaWhatsapp ? "whatsapp" : "sms",
|
|
215
|
+
warnings,
|
|
216
|
+
next: "account.submitCode({ code })",
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Submit the emailed or texted code.
|
|
221
|
+
*
|
|
222
|
+
* Wolt can answer with a second-factor escalation instead of a grant; that
|
|
223
|
+
* arrives as a 4xx whose body carries the confirmation token, which is why the
|
|
224
|
+
* error keeps its body privately.
|
|
225
|
+
*/
|
|
226
|
+
export async function submitCode(ctx, code) {
|
|
227
|
+
const cfg = cfgOf(ctx);
|
|
228
|
+
if (!cfg.pendingEmail && !cfg.pendingPhone)
|
|
229
|
+
throw new Error("wolt: no login in progress — run account.requestEmailCode or account.requestSmsCode first");
|
|
230
|
+
try {
|
|
231
|
+
const grant = cfg.pendingEmail
|
|
232
|
+
? await authForm(ctx, {
|
|
233
|
+
grant_type: "email_login_code",
|
|
234
|
+
email: cfg.pendingEmail,
|
|
235
|
+
code,
|
|
236
|
+
audience: AUDIENCE,
|
|
237
|
+
device_token: cfg.deviceToken,
|
|
238
|
+
capabilities: CAPABILITIES,
|
|
239
|
+
})
|
|
240
|
+
: await authForm(ctx, {
|
|
241
|
+
grant_type: "phone_number_otp",
|
|
242
|
+
phone_number: cfg.pendingPhone,
|
|
243
|
+
otp: code,
|
|
244
|
+
audience: AUDIENCE,
|
|
245
|
+
device_token: cfg.deviceToken,
|
|
246
|
+
capabilities: CAPABILITIES,
|
|
247
|
+
tenant: "wolt",
|
|
248
|
+
});
|
|
249
|
+
return finish(ctx, grant);
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
if (!(e instanceof WoltError))
|
|
253
|
+
throw e;
|
|
254
|
+
const details = e.details();
|
|
255
|
+
const escalation = pick(details.access_confirmation_token);
|
|
256
|
+
if (!escalation)
|
|
257
|
+
throw e;
|
|
258
|
+
return startEmailConfirmation(ctx, escalation, details);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** The second factor: Wolt emails a code that confirms the escalated login. */
|
|
262
|
+
async function startEmailConfirmation(ctx, token, context) {
|
|
263
|
+
await ctx.updateConnection({ pendingConfirmationToken: token });
|
|
264
|
+
const bearer = { authorization: `Bearer ${token}` };
|
|
265
|
+
const status = await http(AUTH, "/v1/access_confirmation/status", {
|
|
266
|
+
method: "POST",
|
|
267
|
+
web: false,
|
|
268
|
+
body: {
|
|
269
|
+
capabilities: [
|
|
270
|
+
"email",
|
|
271
|
+
"phone_number",
|
|
272
|
+
"google",
|
|
273
|
+
"facebook",
|
|
274
|
+
"line",
|
|
275
|
+
"emag",
|
|
276
|
+
"strong_authentication",
|
|
277
|
+
"kyc",
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
headers: { ...clientHeaders(cfgOf(ctx)), ...bearer },
|
|
281
|
+
}).catch(() => ({}));
|
|
282
|
+
await http(AUTH, "/v1/access_confirmation/email", {
|
|
283
|
+
method: "POST",
|
|
284
|
+
web: false,
|
|
285
|
+
body: "",
|
|
286
|
+
headers: { ...clientHeaders(cfgOf(ctx)), ...bearer },
|
|
287
|
+
});
|
|
288
|
+
const methods = arr(status.methods).map(obj);
|
|
289
|
+
return {
|
|
290
|
+
connected: false,
|
|
291
|
+
needs_confirmation: true,
|
|
292
|
+
method: "email",
|
|
293
|
+
account_first_name: pick(obj(context.access_confirmation_context).first_name),
|
|
294
|
+
email_hint: pick(methods.find((m) => pick(m.method) === "email")?.masked_email),
|
|
295
|
+
available_methods: methods
|
|
296
|
+
.map((m) => pick(m.method))
|
|
297
|
+
.filter((m) => m !== null),
|
|
298
|
+
next: "account.submitConfirmation({ code })",
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
export async function submitConfirmation(ctx, code) {
|
|
302
|
+
const cfg = cfgOf(ctx);
|
|
303
|
+
if (!cfg.pendingConfirmationToken)
|
|
304
|
+
throw new Error("wolt: no confirmation in progress — account.submitCode reports when one is needed");
|
|
305
|
+
await http(AUTH, "/v1/access_confirmation/email/submit", {
|
|
306
|
+
method: "POST",
|
|
307
|
+
web: false,
|
|
308
|
+
body: { code },
|
|
309
|
+
headers: {
|
|
310
|
+
...clientHeaders(cfg),
|
|
311
|
+
authorization: `Bearer ${cfg.pendingConfirmationToken}`,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
const grant = await authForm(ctx, {
|
|
315
|
+
grant_type: "access_confirmation_token",
|
|
316
|
+
access_confirmation_token: cfg.pendingConfirmationToken,
|
|
317
|
+
audience: AUDIENCE,
|
|
318
|
+
device_token: cfg.deviceToken,
|
|
319
|
+
});
|
|
320
|
+
return finish(ctx, grant);
|
|
321
|
+
}
|
|
322
|
+
// ---------- what a session knows ----------
|
|
323
|
+
/** The id shape varies by endpoint: a bare string, or a wrapped $uuid/$oid. */
|
|
324
|
+
function methodId(raw) {
|
|
325
|
+
const method = obj(raw);
|
|
326
|
+
const id = method.id;
|
|
327
|
+
if (typeof id === "object" && id !== null)
|
|
328
|
+
return pick(obj(id).$uuid, obj(id).$oid);
|
|
329
|
+
return pick(id, method.card_id);
|
|
330
|
+
}
|
|
331
|
+
export async function discoverPaymentMethod(ctx) {
|
|
332
|
+
const sources = [
|
|
333
|
+
[RESTAURANT, "/v3/user/me/payment_methods"],
|
|
334
|
+
[CONSUMER, "/order-xp/v1/payment-methods"],
|
|
335
|
+
];
|
|
336
|
+
let lastError = null;
|
|
337
|
+
for (const [host, path] of sources) {
|
|
338
|
+
try {
|
|
339
|
+
const r = await authed(ctx, host, path);
|
|
340
|
+
const results = obj(r.results);
|
|
341
|
+
const list = [
|
|
342
|
+
...arr(results.cards),
|
|
343
|
+
...arr(results.payment_methods),
|
|
344
|
+
...arr(r.results),
|
|
345
|
+
...arr(r.payment_methods),
|
|
346
|
+
...arr(r.methods),
|
|
347
|
+
];
|
|
348
|
+
const usable = list.find((m) => obj(m).valid_for_payments === true && methodId(m)) ??
|
|
349
|
+
list.find((m) => methodId(m));
|
|
350
|
+
const id = methodId(usable);
|
|
351
|
+
if (id) {
|
|
352
|
+
await ctx.updateConnection({ paymentMethodId: id });
|
|
353
|
+
return id;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
lastError = e;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Every source failed, as opposed to every source answering "no card".
|
|
361
|
+
if (lastError)
|
|
362
|
+
throw lastError;
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
function coordinatesOf(location) {
|
|
366
|
+
const google = arr(obj(obj(location.google_place_coordinates).coordinates));
|
|
367
|
+
const user = arr(obj(obj(location.user_coordinates).coordinates));
|
|
368
|
+
const source = google.length ? google : user;
|
|
369
|
+
const numbers = source
|
|
370
|
+
.map((c) => (typeof c === "number" ? c : null))
|
|
371
|
+
.filter((c) => c !== null);
|
|
372
|
+
return numbers.length === 2 ? numbers : null;
|
|
373
|
+
}
|
|
374
|
+
export async function listAddresses(ctx) {
|
|
375
|
+
const r = await authed(ctx, RESTAURANT, "/v2/delivery/info");
|
|
376
|
+
return arr(r.results).map((raw) => {
|
|
377
|
+
const a = obj(raw);
|
|
378
|
+
const location = obj(a.location);
|
|
379
|
+
return {
|
|
380
|
+
id: pick(a.id),
|
|
381
|
+
alias: pick(a.alias),
|
|
382
|
+
address: pick(location.address),
|
|
383
|
+
city: pick(location.city),
|
|
384
|
+
street: pick(location.street),
|
|
385
|
+
apartment: pick(location.apartment),
|
|
386
|
+
coordinates: coordinatesOf(location),
|
|
387
|
+
is_verified: typeof a.is_verified === "boolean" ? a.is_verified : null,
|
|
388
|
+
};
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
export function selectAddress(list, wanted) {
|
|
392
|
+
if (!list.length)
|
|
393
|
+
throw new Error("wolt: no saved delivery address on the account — add one in the Wolt app first");
|
|
394
|
+
if (!wanted)
|
|
395
|
+
return list[0];
|
|
396
|
+
const found = list.find((a) => a.id === wanted);
|
|
397
|
+
if (!found)
|
|
398
|
+
throw new Error(`wolt: address ${wanted} is not on this account`);
|
|
399
|
+
return found;
|
|
400
|
+
}
|
|
401
|
+
export async function whoami(ctx) {
|
|
402
|
+
const me = await authed(ctx, RESTAURANT, "/v1/user/me");
|
|
403
|
+
const user = obj(me.user);
|
|
404
|
+
return {
|
|
405
|
+
id: pick(obj(user._id).$oid),
|
|
406
|
+
name: pick(user.name),
|
|
407
|
+
saved_addresses: (await listAddresses(ctx)).length,
|
|
408
|
+
payment_method: cfgOf(ctx).paymentMethodId ?? null,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/** Local view of the connection. No network, so it answers on a broken session. */
|
|
412
|
+
export function status(ctx) {
|
|
413
|
+
const cfg = cfgOf(ctx);
|
|
414
|
+
const missing = [];
|
|
415
|
+
if (!cfg.refreshToken)
|
|
416
|
+
missing.push("refreshToken (owner login)");
|
|
417
|
+
if (!cfg.paymentMethodId)
|
|
418
|
+
missing.push("paymentMethodId (saved card)");
|
|
419
|
+
return {
|
|
420
|
+
connected: !!cfg.refreshToken,
|
|
421
|
+
orderingEnabled: cfg.allowOrdering === true,
|
|
422
|
+
paymentMethod: cfg.paymentMethodId ?? null,
|
|
423
|
+
pendingLogin: cfg.pendingEmail ?? cfg.pendingPhone ?? null,
|
|
424
|
+
pendingConfirmation: !!cfg.pendingConfirmationToken,
|
|
425
|
+
missing,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { arr, CONSUMER, http, numOrNull, obj, pick, RESTAURANT, seg, } from "./shared.js";
|
|
2
|
+
const strings = (values) => values.map((v) => pick(v)).filter((v) => v !== null);
|
|
3
|
+
function venueOf(raw) {
|
|
4
|
+
const v = obj(raw);
|
|
5
|
+
return {
|
|
6
|
+
id: pick(v.id),
|
|
7
|
+
slug: pick(v.slug),
|
|
8
|
+
name: pick(v.name),
|
|
9
|
+
address: pick(v.address),
|
|
10
|
+
city: pick(v.city),
|
|
11
|
+
country: pick(v.country),
|
|
12
|
+
currency: pick(v.currency),
|
|
13
|
+
online: typeof v.online === "boolean" ? v.online : null,
|
|
14
|
+
rating: numOrNull(obj(v.rating).score),
|
|
15
|
+
estimate_minutes: numOrNull(v.estimate),
|
|
16
|
+
delivery_price: numOrNull(v.delivery_price_int),
|
|
17
|
+
tags: strings(arr(v.tags)),
|
|
18
|
+
categories: strings(arr(v.categories).map((c) => obj(c).name)),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function collectVenues(page) {
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const section of arr(page.sections))
|
|
24
|
+
for (const item of arr(obj(section).items)) {
|
|
25
|
+
const venue = obj(item).venue;
|
|
26
|
+
if (venue)
|
|
27
|
+
out.push(venueOf(venue));
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
export async function search(query, lat, lon, limit) {
|
|
32
|
+
const page = await http(RESTAURANT, "/v1/pages/search", {
|
|
33
|
+
method: "POST",
|
|
34
|
+
body: { q: query, target: "venues", lat, lon },
|
|
35
|
+
});
|
|
36
|
+
const venues = collectVenues(page).slice(0, limit);
|
|
37
|
+
return { query, count: venues.length, venues };
|
|
38
|
+
}
|
|
39
|
+
export async function nearby(lat, lon, limit, openOnly) {
|
|
40
|
+
const params = new URLSearchParams({ lat: String(lat), lon: String(lon) });
|
|
41
|
+
const page = await http(RESTAURANT, `/v1/pages/restaurants?${params}`);
|
|
42
|
+
const all = collectVenues(page);
|
|
43
|
+
const venues = openOnly ? all.filter((v) => v.online) : all;
|
|
44
|
+
return {
|
|
45
|
+
city: pick(page.city),
|
|
46
|
+
total: venues.length,
|
|
47
|
+
venues: venues.slice(0, limit),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** A venue's static page plus its live status; the live half is best-effort. */
|
|
51
|
+
export async function venue(slug, lat, lon) {
|
|
52
|
+
const params = new URLSearchParams({ lat: String(lat), lon: String(lon) });
|
|
53
|
+
const name = seg(slug, "slug");
|
|
54
|
+
const [staticPage, dynamic] = await Promise.all([
|
|
55
|
+
http(CONSUMER, `/order-xp/web/v1/pages/venue/slug/${name}/static?${params}`),
|
|
56
|
+
http(CONSUMER, `/order-xp/web/v1/venue/slug/${name}/dynamic/?selected_delivery_method=homedelivery`).catch(() => null),
|
|
57
|
+
]);
|
|
58
|
+
const v = obj(staticPage.venue);
|
|
59
|
+
const live = obj(obj(dynamic ?? {}).venue);
|
|
60
|
+
return {
|
|
61
|
+
...venueOf(v),
|
|
62
|
+
description: pick(v.description),
|
|
63
|
+
timezone: pick(v.timezone),
|
|
64
|
+
phone: pick(v.phone),
|
|
65
|
+
website: pick(v.website),
|
|
66
|
+
active_menu: v.active_menu ?? null,
|
|
67
|
+
delivery_methods: arr(v.delivery_methods),
|
|
68
|
+
delivery_base_price: numOrNull(v.delivery_base_price),
|
|
69
|
+
service_fee_estimate: numOrNull(v.service_fee_estimate),
|
|
70
|
+
order_minimum: numOrNull(staticPage.order_minimum),
|
|
71
|
+
opening_times: v.opening_times_schedule ?? null,
|
|
72
|
+
delivery_times: v.delivery_times_schedule ?? null,
|
|
73
|
+
is_open: typeof live.online === "boolean" ? live.online : null,
|
|
74
|
+
live_estimate_minutes: numOrNull(live.estimate),
|
|
75
|
+
estimate_range: v.estimate_range ?? null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export async function assortment(slug, language = "en") {
|
|
79
|
+
const params = new URLSearchParams({ language });
|
|
80
|
+
return http(CONSUMER, `/consumer-api/consumer-assortment/v1/venues/slug/${seg(slug, "slug")}/assortment?${params}`);
|
|
81
|
+
}
|
|
82
|
+
function optionGroupOf(raw, definitions) {
|
|
83
|
+
const group = obj(raw);
|
|
84
|
+
const definition = obj(definitions.get(pick(group.option_id) ?? ""));
|
|
85
|
+
const range = obj(obj(group.multi_choice_config).total_range);
|
|
86
|
+
const min = numOrNull(range.min);
|
|
87
|
+
return {
|
|
88
|
+
id: pick(group.id),
|
|
89
|
+
option_id: pick(group.option_id),
|
|
90
|
+
name: pick(group.name, definition.name),
|
|
91
|
+
required: (min ?? 0) > 0,
|
|
92
|
+
min,
|
|
93
|
+
max: numOrNull(range.max),
|
|
94
|
+
values: arr(definition.values).map((v) => {
|
|
95
|
+
const value = obj(v);
|
|
96
|
+
return {
|
|
97
|
+
id: pick(value.id),
|
|
98
|
+
name: pick(value.name),
|
|
99
|
+
price: numOrNull(value.price) ?? 0,
|
|
100
|
+
};
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export function menuItemOf(raw, definitions) {
|
|
105
|
+
const item = obj(raw);
|
|
106
|
+
const balance = numOrNull(item.purchasable_balance);
|
|
107
|
+
return {
|
|
108
|
+
id: pick(item.id),
|
|
109
|
+
name: pick(item.name),
|
|
110
|
+
description: pick(item.description),
|
|
111
|
+
price: numOrNull(item.price),
|
|
112
|
+
in_stock: balance === null ? true : balance > 0,
|
|
113
|
+
image: pick(obj(arr(item.images)[0]).url),
|
|
114
|
+
checksum: pick(item.checksum),
|
|
115
|
+
options: arr(item.options).map((o) => optionGroupOf(o, definitions)),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** Option definitions keyed by id; the item payload only carries references. */
|
|
119
|
+
export function optionDefinitions(a) {
|
|
120
|
+
return new Map(arr(a.options).map((o) => [pick(obj(o).id) ?? "", obj(o)]));
|
|
121
|
+
}
|
|
122
|
+
export function itemsById(a) {
|
|
123
|
+
return new Map(arr(a.items).map((i) => [pick(obj(i).id) ?? "", obj(i)]));
|
|
124
|
+
}
|
|
125
|
+
export async function menu(slug, filter, perCategory, language) {
|
|
126
|
+
const a = await assortment(slug, language);
|
|
127
|
+
const items = itemsById(a);
|
|
128
|
+
const definitions = optionDefinitions(a);
|
|
129
|
+
const needle = filter.toLowerCase();
|
|
130
|
+
const matches = (item) => !needle ||
|
|
131
|
+
(item.name ?? "").toLowerCase().includes(needle) ||
|
|
132
|
+
(item.description ?? "").toLowerCase().includes(needle);
|
|
133
|
+
const categories = arr(a.categories)
|
|
134
|
+
.map((c) => {
|
|
135
|
+
const category = obj(c);
|
|
136
|
+
const shaped = arr(category.item_ids)
|
|
137
|
+
.map((id) => items.get(pick(id) ?? ""))
|
|
138
|
+
.filter((i) => i !== undefined)
|
|
139
|
+
.map((i) => menuItemOf(i, definitions))
|
|
140
|
+
.filter(matches);
|
|
141
|
+
return {
|
|
142
|
+
id: pick(category.id),
|
|
143
|
+
name: pick(category.name),
|
|
144
|
+
slug: pick(category.slug),
|
|
145
|
+
description: pick(category.description),
|
|
146
|
+
items: perCategory > 0 ? shaped.slice(0, perCategory) : shaped,
|
|
147
|
+
item_count: shaped.length,
|
|
148
|
+
};
|
|
149
|
+
})
|
|
150
|
+
.filter((c) => c.item_count > 0);
|
|
151
|
+
return {
|
|
152
|
+
venue_slug: slug,
|
|
153
|
+
assortment_id: pick(a.assortment_id),
|
|
154
|
+
language: pick(a.selected_language),
|
|
155
|
+
category_count: categories.length,
|
|
156
|
+
item_count: categories.reduce((n, c) => n + c.item_count, 0),
|
|
157
|
+
categories,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
export async function item(slug, itemId) {
|
|
161
|
+
const a = await assortment(slug);
|
|
162
|
+
const found = itemsById(a).get(itemId);
|
|
163
|
+
if (!found)
|
|
164
|
+
throw new Error(`wolt: item ${itemId} is not on venue ${slug}`);
|
|
165
|
+
return menuItemOf(found, optionDefinitions(a));
|
|
166
|
+
}
|