@melaya/runner 1.0.92 → 1.0.94
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/assistantHost.py +23 -14
- package/dist/lumaBrowserBridge.d.ts +20 -6
- package/dist/lumaBrowserBridge.js +373 -9
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -205,13 +205,19 @@ def _build_agent():
|
|
|
205
205
|
"TikTok). Opening the wrong app is a FAILED task, not a reasonable default. "
|
|
206
206
|
"After the user authorizes the app, phone_list_apps reflects it immediately — "
|
|
207
207
|
"re-check and open the REQUESTED app.\n"
|
|
208
|
-
"- You are
|
|
209
|
-
"
|
|
210
|
-
"
|
|
211
|
-
"
|
|
212
|
-
"
|
|
213
|
-
"
|
|
214
|
-
"
|
|
208
|
+
"- You are AUTONOMOUS on the phone: for routine choices, make the reasonable "
|
|
209
|
+
"decision and DO it — never stall the run by ENDING YOUR TURN to ask in chat. "
|
|
210
|
+
"But when you hit a GENUINE fork you cannot reasonably resolve on your own (which "
|
|
211
|
+
"of several accounts to act as, a truly ambiguous target, a risky irreversible "
|
|
212
|
+
"step), or you want a go/no-go before continuing a long task, call "
|
|
213
|
+
"phone_ask_user(question): it expands the Melaya tile ON THE PHONE into a reply "
|
|
214
|
+
"card and BLOCKS for the user's typed answer WITHOUT ending your turn, so the run "
|
|
215
|
+
"continues seamlessly the moment they reply. Use it SPARINGLY, for real decisions "
|
|
216
|
+
"only — not routine ones. The EXACT-APP rule still holds (a missing app is a stop, "
|
|
217
|
+
"not a substitution). If a feed is algorithmic, use the app's own Search / Explore "
|
|
218
|
+
"to find the right content yourself. Keep going until the task is COMPLETE (e.g. "
|
|
219
|
+
"all N comments posted) or you are genuinely blocked; only then report what you "
|
|
220
|
+
"did and what (if anything) is blocked.\n"
|
|
215
221
|
if phone_enabled else ""
|
|
216
222
|
)
|
|
217
223
|
connector_rule = (
|
|
@@ -246,13 +252,16 @@ def _build_agent():
|
|
|
246
252
|
model_name=model,
|
|
247
253
|
provider=provider,
|
|
248
254
|
api_key="", # model.py resolves the provider's own creds (OAuth off disk / localhost)
|
|
249
|
-
# Phone tasks
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
|
|
255
|
+
# Phone tasks that act on N items ("like + comment on 10 posts") take
|
|
256
|
+
# ~10-15 tool calls EACH (screen_tree→read→tap→type→post_comment→verify→
|
|
257
|
+
# swipe to the next), so a flat 40 capped a 10-post run at ~3 done and
|
|
258
|
+
# forced a mid-task stop — which also ends the turn and tears down the
|
|
259
|
+
# phone working-overlay, snapping the phone back to the Melaya app before
|
|
260
|
+
# the task is finished. Give phone runs real headroom (they are HITL-gated,
|
|
261
|
+
# so each write still needs the user's tap — cost stays bounded). Connector
|
|
262
|
+
# WRITE fan-outs (1 list + N sends, each its own reasoning+HITL round) keep
|
|
263
|
+
# the 40 that fits them; read-only Q&A needs very few.
|
|
264
|
+
max_iters=100 if phone_enabled else (40 if connector_services else 8),
|
|
256
265
|
reliability=True,
|
|
257
266
|
bounded_memory=True,
|
|
258
267
|
)
|
|
@@ -32,9 +32,22 @@
|
|
|
32
32
|
* port, mints a per-session token, and exposes both via env vars
|
|
33
33
|
* `MEL_LUMA_BROWSER_URL` + `MEL_LUMA_BROWSER_TOKEN` to the spawned
|
|
34
34
|
* Python pipeline subprocess.
|
|
35
|
-
* 3.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* 3. Two endpoints:
|
|
36
|
+
*
|
|
37
|
+
* POST /luma/register (PREFERRED, Option A — UI-driven)
|
|
38
|
+
* Python's `luma_register_event` posts `{ eventApiId, answers,
|
|
39
|
+
* ticketPreference }`. The bridge opens the REAL event page, installs a
|
|
40
|
+
* response interceptor on Luma's own register XHR, clicks the
|
|
41
|
+
* Register/RSVP button, fills required custom questions + ticket, submits,
|
|
42
|
+
* and returns Luma's REAL request body + endpoint + response. This lets
|
|
43
|
+
* Luma's frontend build the correct wire schema (whatever the current
|
|
44
|
+
* release wants) and handles fresh / invited / ticket / approval flows
|
|
45
|
+
* uniformly — no hand-built body. If the operator already has a spot the
|
|
46
|
+
* page shows a manage state and we return `pageState: already_registered`
|
|
47
|
+
* instead of hunting for a Register button.
|
|
48
|
+
*
|
|
49
|
+
* POST /luma/forward (LEGACY fallback — hand-built body replay)
|
|
50
|
+
* Python posts a full `{ url, method, headers, body }`. The bridge:
|
|
38
51
|
* a. Spawns / reuses a headless Chromium with the saved storage
|
|
39
52
|
* state, freshly reloaded from disk so a recent reconnect's
|
|
40
53
|
* cookies are picked up immediately.
|
|
@@ -52,9 +65,10 @@
|
|
|
52
65
|
* processes on the same machine can't fire requests without it
|
|
53
66
|
* (the env var is only injected into the runner-spawned pipeline
|
|
54
67
|
* subprocess).
|
|
55
|
-
* -
|
|
56
|
-
* api2.luma.com / lu.ma origin
|
|
57
|
-
*
|
|
68
|
+
* - Endpoints are narrow: /luma/forward requires a luma.com / api.luma.com /
|
|
69
|
+
* api2.luma.com / lu.ma origin URL with a GET/POST method; /luma/register
|
|
70
|
+
* requires an evt-… id and only ever opens a luma.com event page. Anything
|
|
71
|
+
* else is rejected with 400.
|
|
58
72
|
* - Body size is capped at 64 KB.
|
|
59
73
|
*
|
|
60
74
|
* Cleanup
|
|
@@ -32,9 +32,22 @@
|
|
|
32
32
|
* port, mints a per-session token, and exposes both via env vars
|
|
33
33
|
* `MEL_LUMA_BROWSER_URL` + `MEL_LUMA_BROWSER_TOKEN` to the spawned
|
|
34
34
|
* Python pipeline subprocess.
|
|
35
|
-
* 3.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* 3. Two endpoints:
|
|
36
|
+
*
|
|
37
|
+
* POST /luma/register (PREFERRED, Option A — UI-driven)
|
|
38
|
+
* Python's `luma_register_event` posts `{ eventApiId, answers,
|
|
39
|
+
* ticketPreference }`. The bridge opens the REAL event page, installs a
|
|
40
|
+
* response interceptor on Luma's own register XHR, clicks the
|
|
41
|
+
* Register/RSVP button, fills required custom questions + ticket, submits,
|
|
42
|
+
* and returns Luma's REAL request body + endpoint + response. This lets
|
|
43
|
+
* Luma's frontend build the correct wire schema (whatever the current
|
|
44
|
+
* release wants) and handles fresh / invited / ticket / approval flows
|
|
45
|
+
* uniformly — no hand-built body. If the operator already has a spot the
|
|
46
|
+
* page shows a manage state and we return `pageState: already_registered`
|
|
47
|
+
* instead of hunting for a Register button.
|
|
48
|
+
*
|
|
49
|
+
* POST /luma/forward (LEGACY fallback — hand-built body replay)
|
|
50
|
+
* Python posts a full `{ url, method, headers, body }`. The bridge:
|
|
38
51
|
* a. Spawns / reuses a headless Chromium with the saved storage
|
|
39
52
|
* state, freshly reloaded from disk so a recent reconnect's
|
|
40
53
|
* cookies are picked up immediately.
|
|
@@ -52,9 +65,10 @@
|
|
|
52
65
|
* processes on the same machine can't fire requests without it
|
|
53
66
|
* (the env var is only injected into the runner-spawned pipeline
|
|
54
67
|
* subprocess).
|
|
55
|
-
* -
|
|
56
|
-
* api2.luma.com / lu.ma origin
|
|
57
|
-
*
|
|
68
|
+
* - Endpoints are narrow: /luma/forward requires a luma.com / api.luma.com /
|
|
69
|
+
* api2.luma.com / lu.ma origin URL with a GET/POST method; /luma/register
|
|
70
|
+
* requires an evt-… id and only ever opens a luma.com event page. Anything
|
|
71
|
+
* else is rejected with 400.
|
|
58
72
|
* - Body size is capped at 64 KB.
|
|
59
73
|
*
|
|
60
74
|
* Cleanup
|
|
@@ -90,6 +104,176 @@ function _stateDir() {
|
|
|
90
104
|
export function lumaStorageStatePath() {
|
|
91
105
|
return path.join(_stateDir(), "storage-state.json");
|
|
92
106
|
}
|
|
107
|
+
/** Detect the "you already have a spot" state. Luma shows a manage / "You're
|
|
108
|
+
* In" / "Request Sent" / "On the Waitlist" panel instead of a Register button
|
|
109
|
+
* for guests it already knows. Returns a short state label or "" if fresh. */
|
|
110
|
+
async function _detectAlreadyRegistered(page) {
|
|
111
|
+
try {
|
|
112
|
+
return await page.evaluate(() => {
|
|
113
|
+
const txt = (document.body.innerText || "").toLowerCase();
|
|
114
|
+
// Order matters: more specific first.
|
|
115
|
+
if (/pending approval|request sent|awaiting approval|pending review/.test(txt))
|
|
116
|
+
return "approval_pending";
|
|
117
|
+
if (/on the waitlist|you.?re on the waitlist|waitlist.?ed/.test(txt))
|
|
118
|
+
return "waitlisted";
|
|
119
|
+
if (/you.?re in|you are in|you.?re going|manage registration|change registration|cancel registration|edit registration/.test(txt))
|
|
120
|
+
return "already_registered";
|
|
121
|
+
return "";
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Click the primary Register / RSVP / Join button. Returns true on click. */
|
|
129
|
+
async function _clickRegisterButton(page) {
|
|
130
|
+
// 1) data-testid / known attribute hooks first (most stable).
|
|
131
|
+
const testidSelectors = [
|
|
132
|
+
'[data-testid="register-button"]',
|
|
133
|
+
'[data-testid="rsvp-button"]',
|
|
134
|
+
'button[data-luma-action="register"]',
|
|
135
|
+
];
|
|
136
|
+
for (const sel of testidSelectors) {
|
|
137
|
+
try {
|
|
138
|
+
const el = page.locator(sel).first();
|
|
139
|
+
if (await el.count() && await el.isVisible()) {
|
|
140
|
+
await el.click({ timeout: 4000 });
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch { /* next */ }
|
|
145
|
+
}
|
|
146
|
+
// 2) button/anchor text — try each verb. getByRole with an accessible-name
|
|
147
|
+
// regex catches "Register", "RSVP", "Join", "One-Click Register", "Get
|
|
148
|
+
// Ticket", "Request to Join" without over-matching "Register a friend".
|
|
149
|
+
const nameRx = /^(register|rsvp|join|one[- ]?click register|get ticket|request to join|reserve( a)? spot)/i;
|
|
150
|
+
for (const role of ["button", "link"]) {
|
|
151
|
+
try {
|
|
152
|
+
const el = page.getByRole(role, { name: nameRx }).first();
|
|
153
|
+
if (await el.count() && await el.isVisible()) {
|
|
154
|
+
await el.click({ timeout: 4000 });
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch { /* next */ }
|
|
159
|
+
}
|
|
160
|
+
// 3) last resort: any visible button whose text starts with a register verb.
|
|
161
|
+
try {
|
|
162
|
+
const btn = page.locator("button, a[role='button']").filter({ hasText: nameRx }).first();
|
|
163
|
+
if (await btn.count() && await btn.isVisible()) {
|
|
164
|
+
await btn.click({ timeout: 4000 });
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch { /* give up */ }
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
/** Select a ticket when the event offers a choice. Best-effort: if a preferred
|
|
172
|
+
* ticket name/id is given, click the row containing it; otherwise pick the
|
|
173
|
+
* first free ("Free"/$0) ticket, else the first ticket. No-op when there's no
|
|
174
|
+
* ticket selector (plain free RSVP). */
|
|
175
|
+
async function _selectTicketIfPresent(page, preference) {
|
|
176
|
+
try {
|
|
177
|
+
// Luma renders ticket options as clickable rows/radios. We look for a
|
|
178
|
+
// container that mentions "ticket" then click a matching option.
|
|
179
|
+
if (preference) {
|
|
180
|
+
const pref = page.getByText(preference, { exact: false }).first();
|
|
181
|
+
if (await pref.count() && await pref.isVisible()) {
|
|
182
|
+
await pref.click({ timeout: 2500 });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Prefer a free ticket.
|
|
187
|
+
const free = page.locator("[class*='ticket'], [data-testid*='ticket']")
|
|
188
|
+
.filter({ hasText: /free|\$0(\.00)?/i }).first();
|
|
189
|
+
if (await free.count() && await free.isVisible()) {
|
|
190
|
+
await free.click({ timeout: 2500 });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch { /* no ticket selector — plain RSVP */ }
|
|
195
|
+
}
|
|
196
|
+
/** Fill required custom questions in the registration modal from the provided
|
|
197
|
+
* answers map, matched by visible label. Handles text inputs, textareas, and
|
|
198
|
+
* select dropdowns. Checkboxes labelled with agreement text are left to the
|
|
199
|
+
* operator's profile-driven answers (passed in `answers` as "yes"). */
|
|
200
|
+
async function _fillCustomQuestions(page, answers) {
|
|
201
|
+
for (const [label, rawVal] of Object.entries(answers)) {
|
|
202
|
+
const val = rawVal == null ? "" : String(rawVal);
|
|
203
|
+
if (!val)
|
|
204
|
+
continue;
|
|
205
|
+
const labelRx = new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
|
|
206
|
+
try {
|
|
207
|
+
// Try an input/textarea associated with a label matching the question.
|
|
208
|
+
const field = page.getByLabel(labelRx).first();
|
|
209
|
+
if (await field.count() && await field.isVisible()) {
|
|
210
|
+
const tag = await field.evaluate((el) => el.tagName.toLowerCase()).catch(() => "");
|
|
211
|
+
if (tag === "select") {
|
|
212
|
+
await field.selectOption({ label: val }).catch(async () => {
|
|
213
|
+
await field.selectOption(val).catch(() => { });
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
else if (val.toLowerCase() === "yes") {
|
|
217
|
+
await field.check({ timeout: 2000 }).catch(() => field.click({ timeout: 2000 }).catch(() => { }));
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
await field.fill(val, { timeout: 2500 }).catch(() => { });
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch { /* fall through to placeholder match */ }
|
|
226
|
+
// Fallback: match by placeholder text.
|
|
227
|
+
try {
|
|
228
|
+
const ph = page.getByPlaceholder(labelRx).first();
|
|
229
|
+
if (await ph.count() && await ph.isVisible()) {
|
|
230
|
+
await ph.fill(val, { timeout: 2500 }).catch(() => { });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch { /* skip this question — submit will surface if it was required */ }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Click the modal's final submit/confirm button. Distinct from the initial
|
|
237
|
+
* Register button: the modal's CTA is usually "Register" / "Confirm" / "Get
|
|
238
|
+
* Ticket" / "Request to Join". Idempotent — safe if there's no modal. */
|
|
239
|
+
async function _submitRegistration(page) {
|
|
240
|
+
const nameRx = /^(register|confirm|rsvp|get ticket|request to join|submit|one[- ]?click register|reserve)/i;
|
|
241
|
+
// Prefer a submit inside a dialog/modal so we don't re-click the page's
|
|
242
|
+
// primary button (which may just re-open the modal).
|
|
243
|
+
const scopes = [
|
|
244
|
+
page.getByRole("dialog"),
|
|
245
|
+
page.locator("[role='dialog'], [class*='modal'], [class*='Modal']"),
|
|
246
|
+
page, // whole page fallback
|
|
247
|
+
];
|
|
248
|
+
for (const scope of scopes) {
|
|
249
|
+
try {
|
|
250
|
+
const anyScope = scope;
|
|
251
|
+
const btn = (typeof anyScope.getByRole === "function"
|
|
252
|
+
? anyScope.getByRole("button", { name: nameRx })
|
|
253
|
+
: page.getByRole("button", { name: nameRx })).first();
|
|
254
|
+
if (await btn.count() && await btn.isVisible()) {
|
|
255
|
+
await btn.click({ timeout: 4000 });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch { /* next scope */ }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Screenshot to base64 PNG, capped, best-effort. Returns "" on failure. */
|
|
263
|
+
async function _safeScreenshot(page) {
|
|
264
|
+
try {
|
|
265
|
+
const buf = await page.screenshot({ type: "png", fullPage: false });
|
|
266
|
+
// Cap at ~1.5MB base64 to stay well under the bridge's 64KB *request*
|
|
267
|
+
// limit is irrelevant here (this is a RESPONSE); but keep it bounded so a
|
|
268
|
+
// huge PNG doesn't bloat the trace. Truncate rather than drop so the
|
|
269
|
+
// operator still gets a partial visual.
|
|
270
|
+
const b64 = buf.toString("base64");
|
|
271
|
+
return b64.length > 1_500_000 ? b64.slice(0, 1_500_000) : b64;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return "";
|
|
275
|
+
}
|
|
276
|
+
}
|
|
93
277
|
// ── Public starter ──────────────────────────────────────────────────────────
|
|
94
278
|
/**
|
|
95
279
|
* Starts the bridge if a saved Luma storage state exists. Returns null
|
|
@@ -237,6 +421,165 @@ export async function startLumaBrowserBridge(opts) {
|
|
|
237
421
|
}
|
|
238
422
|
}
|
|
239
423
|
}
|
|
424
|
+
// ── UI-driven register (Option A) ──────────────────────────────────────────
|
|
425
|
+
//
|
|
426
|
+
// Open the real event page, install a response interceptor on Luma's own
|
|
427
|
+
// register XHR, then click the Register/RSVP button and fill the form. This
|
|
428
|
+
// lets Luma's frontend build the CORRECT wire body (whatever the current
|
|
429
|
+
// release's schema is) and clears Cloudflare (cf_bm refreshes on-page, TLS
|
|
430
|
+
// is Chrome's). We return the intercepted request body + endpoint + upstream
|
|
431
|
+
// response verbatim.
|
|
432
|
+
async function _register(req) {
|
|
433
|
+
const eid = String(req.eventApiId || "").trim();
|
|
434
|
+
if (!eid.startsWith("evt-")) {
|
|
435
|
+
return { ok: false, status: 0, body: "", error: `invalid_event_api_id: ${eid}` };
|
|
436
|
+
}
|
|
437
|
+
const eventUrl = req.eventUrl && /^https?:\/\//.test(req.eventUrl)
|
|
438
|
+
? req.eventUrl
|
|
439
|
+
: `https://luma.com/${eid}`;
|
|
440
|
+
try {
|
|
441
|
+
const u = new URL(eventUrl);
|
|
442
|
+
if (!ALLOWED_HOSTNAMES.has(u.hostname)) {
|
|
443
|
+
return { ok: false, status: 0, body: "", error: `host_not_allowed: ${u.hostname}` };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return { ok: false, status: 0, body: "", error: "invalid_event_url" };
|
|
448
|
+
}
|
|
449
|
+
const b = await _ensureBrowser();
|
|
450
|
+
let ctx = null;
|
|
451
|
+
try {
|
|
452
|
+
const storageState = await _readStorageState();
|
|
453
|
+
ctx = await b.newContext({
|
|
454
|
+
storageState: storageState,
|
|
455
|
+
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
|
456
|
+
"(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
|
|
457
|
+
viewport: { width: 1280, height: 900 },
|
|
458
|
+
});
|
|
459
|
+
const page = await ctx.newPage();
|
|
460
|
+
// Response interceptor: capture the FIRST register/RSVP XHR Luma fires.
|
|
461
|
+
// We match on the URL path so we catch whichever surface the current
|
|
462
|
+
// release uses (/event/register, /event/independent-register-for-event,
|
|
463
|
+
// or a future rename that still contains "register").
|
|
464
|
+
let capturedStatus = 0;
|
|
465
|
+
let capturedBody = "";
|
|
466
|
+
let capturedEndpoint = "";
|
|
467
|
+
let capturedRequestBody = "";
|
|
468
|
+
const REGISTER_PATH_RX = /\/event\/[a-z-]*register[a-z-]*/i;
|
|
469
|
+
page.on("request", (request) => {
|
|
470
|
+
try {
|
|
471
|
+
const url = request.url();
|
|
472
|
+
if (request.method() === "POST" && REGISTER_PATH_RX.test(new URL(url).pathname)) {
|
|
473
|
+
if (!capturedRequestBody) {
|
|
474
|
+
capturedEndpoint = new URL(url).pathname;
|
|
475
|
+
capturedRequestBody = request.postData() || "";
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
catch { /* ignore */ }
|
|
480
|
+
});
|
|
481
|
+
page.on("response", async (response) => {
|
|
482
|
+
try {
|
|
483
|
+
const url = response.url();
|
|
484
|
+
if (response.request().method() === "POST" && REGISTER_PATH_RX.test(new URL(url).pathname)) {
|
|
485
|
+
// First register response wins.
|
|
486
|
+
if (!capturedStatus) {
|
|
487
|
+
capturedStatus = response.status();
|
|
488
|
+
try {
|
|
489
|
+
capturedBody = await response.text();
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
capturedBody = "";
|
|
493
|
+
}
|
|
494
|
+
if (!capturedEndpoint)
|
|
495
|
+
capturedEndpoint = new URL(url).pathname;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch { /* ignore */ }
|
|
500
|
+
});
|
|
501
|
+
try {
|
|
502
|
+
await page.goto(eventUrl, {
|
|
503
|
+
waitUntil: "domcontentloaded",
|
|
504
|
+
timeout: req.navigationTimeoutMs || PAGE_GOTO_TIMEOUT_MS,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
catch (e) {
|
|
508
|
+
opts.log(`[luma-bridge] register goto failed (${e?.message || e}).`);
|
|
509
|
+
}
|
|
510
|
+
await page.waitForTimeout(PAGE_SETTLE_MS);
|
|
511
|
+
// If the operator already has a spot, Luma shows a "You're In" / manage
|
|
512
|
+
// state instead of a Register button. Detect it up-front so we don't
|
|
513
|
+
// hunt for a button that isn't there.
|
|
514
|
+
const alreadyState = await _detectAlreadyRegistered(page);
|
|
515
|
+
if (alreadyState) {
|
|
516
|
+
return {
|
|
517
|
+
ok: true, status: 0, body: "", pageState: alreadyState,
|
|
518
|
+
capturedEndpoint, capturedRequestBody,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
// Click the Register / RSVP / Join button. Resilient to Luma's copy +
|
|
522
|
+
// markup drift: try data-testids, then a set of button texts, then any
|
|
523
|
+
// button whose accessible name contains a register-ish verb.
|
|
524
|
+
const clicked = await _clickRegisterButton(page);
|
|
525
|
+
if (!clicked) {
|
|
526
|
+
const shot = await _safeScreenshot(page);
|
|
527
|
+
return {
|
|
528
|
+
ok: false, status: 0, body: "",
|
|
529
|
+
pageState: "no_register_button",
|
|
530
|
+
screenshotB64: shot,
|
|
531
|
+
error: "could not locate a Register/RSVP button on the event page",
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
// A registration modal may appear with required custom questions and/or a
|
|
535
|
+
// ticket selector. Fill what we can, then submit. Best-effort — Luma's own
|
|
536
|
+
// client-side validation will block submit if something required is empty,
|
|
537
|
+
// and we surface that as a precise error rather than a silent 400.
|
|
538
|
+
await page.waitForTimeout(800);
|
|
539
|
+
await _selectTicketIfPresent(page, req.ticketPreference);
|
|
540
|
+
await _fillCustomQuestions(page, req.answers || {});
|
|
541
|
+
await _submitRegistration(page);
|
|
542
|
+
// Wait for the register XHR to fire + resolve. Poll up to ~15s.
|
|
543
|
+
const deadline = Date.now() + 15_000;
|
|
544
|
+
while (Date.now() < deadline && !capturedStatus) {
|
|
545
|
+
await page.waitForTimeout(300);
|
|
546
|
+
}
|
|
547
|
+
if (capturedStatus) {
|
|
548
|
+
return {
|
|
549
|
+
ok: true,
|
|
550
|
+
status: capturedStatus,
|
|
551
|
+
body: capturedBody,
|
|
552
|
+
capturedEndpoint,
|
|
553
|
+
capturedRequestBody,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
// No register XHR observed. Re-check for an "already in" state the submit
|
|
557
|
+
// may have produced, else return a diagnosable failure with a screenshot.
|
|
558
|
+
const postState = await _detectAlreadyRegistered(page);
|
|
559
|
+
if (postState) {
|
|
560
|
+
return { ok: true, status: 0, body: "", pageState: postState, capturedEndpoint, capturedRequestBody };
|
|
561
|
+
}
|
|
562
|
+
const shot = await _safeScreenshot(page);
|
|
563
|
+
return {
|
|
564
|
+
ok: false, status: 0, body: "",
|
|
565
|
+
pageState: "no_register_xhr",
|
|
566
|
+
capturedEndpoint, capturedRequestBody,
|
|
567
|
+
screenshotB64: shot,
|
|
568
|
+
error: "clicked register but no register XHR fired (form may have unmet required fields)",
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
catch (e) {
|
|
572
|
+
return { ok: false, status: 0, body: "", error: `register_bridge_error: ${e?.message || e}` };
|
|
573
|
+
}
|
|
574
|
+
finally {
|
|
575
|
+
if (ctx) {
|
|
576
|
+
try {
|
|
577
|
+
await ctx.close();
|
|
578
|
+
}
|
|
579
|
+
catch { /* ignore */ }
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
240
583
|
// ── HTTP server ──────────────────────────────────────────────────────────
|
|
241
584
|
const server = createServer(async (req, res) => {
|
|
242
585
|
res.setHeader("X-Frame-Options", "DENY");
|
|
@@ -256,7 +599,8 @@ export async function startLumaBrowserBridge(opts) {
|
|
|
256
599
|
res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
|
|
257
600
|
return;
|
|
258
601
|
}
|
|
259
|
-
|
|
602
|
+
const route = req.url || "";
|
|
603
|
+
if (route !== "/luma/forward" && route !== "/luma/register") {
|
|
260
604
|
res.statusCode = 404;
|
|
261
605
|
res.end(JSON.stringify({ ok: false, error: "not_found" }));
|
|
262
606
|
return;
|
|
@@ -287,12 +631,32 @@ export async function startLumaBrowserBridge(opts) {
|
|
|
287
631
|
res.end(JSON.stringify({ ok: false, error: "invalid_json" }));
|
|
288
632
|
return;
|
|
289
633
|
}
|
|
290
|
-
if (!payload || typeof payload !== "object"
|
|
634
|
+
if (!payload || typeof payload !== "object") {
|
|
635
|
+
res.statusCode = 400;
|
|
636
|
+
res.end(JSON.stringify({ ok: false, error: "invalid_payload" }));
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
// Route to the UI-driven register path (Option A) or the legacy forward.
|
|
640
|
+
if (route === "/luma/register") {
|
|
641
|
+
const rreq = payload;
|
|
642
|
+
if (!rreq.eventApiId) {
|
|
643
|
+
res.statusCode = 400;
|
|
644
|
+
res.end(JSON.stringify({ ok: false, error: "missing_eventApiId" }));
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const rout = await _register(rreq);
|
|
648
|
+
res.statusCode = 200;
|
|
649
|
+
res.setHeader("Content-Type", "application/json");
|
|
650
|
+
res.end(JSON.stringify(rout));
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const freq = payload;
|
|
654
|
+
if (!freq.url) {
|
|
291
655
|
res.statusCode = 400;
|
|
292
656
|
res.end(JSON.stringify({ ok: false, error: "missing_url" }));
|
|
293
657
|
return;
|
|
294
658
|
}
|
|
295
|
-
const out = await _forward(
|
|
659
|
+
const out = await _forward(freq);
|
|
296
660
|
res.statusCode = 200;
|
|
297
661
|
res.setHeader("Content-Type", "application/json");
|
|
298
662
|
res.end(JSON.stringify(out));
|