scripter-x 1.0.15 → 1.0.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scripter-x",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "ScripterX — local Flipkart session extractor (runs on your residential IP, syncs to marthunt)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.js CHANGED
@@ -224,80 +224,204 @@ export async function run(io, api, args = {}) {
224
224
  // code → we verify, build the ZAUTH1 envelope, and write {phone}-{timestamp}.txt.
225
225
  // LOOPS: after each one it asks for the next number — keep going until the user
226
226
  // presses Esc (or double Ctrl+C). Runs entirely on your IP.
227
- export async function zeptoCmd(io, _api, args = {}) {
228
- io.print(' Zepto local OTP ZAUTH1 envelope');
229
- io.print(' ◉ press esc to cancel', 'accent');
230
-
231
- let done = 0;
232
- // double-Ctrl+C to exit (only meaningful in one-shot mode; the ink App owns its own Ctrl+C)
233
- let lastSig = 0, stopping = false;
234
- const onSigint = () => {
235
- const now = Date.now();
236
- if (now - lastSig < 2000) { stopping = true; process.exit(0); }
237
- lastSig = now;
238
- io.print(' ⚠ press Ctrl+C again to exit');
239
- };
240
- const interactive = !!io.startRun; // ink App present → don't grab SIGINT
241
- if (!interactive) process.on('SIGINT', onSigint);
242
-
243
- try {
244
- for (;;) {
245
- // number entry — escapable: Esc stops the whole loop
246
- const raw = args.number || await io.ask('zepto phone number', { escapable: true });
247
- if (raw === CANCEL) break;
248
- const number = String(raw).replace(/\D/g, '').slice(-10);
249
- if (number.length !== 10) { io.print(' ! enter a 10-digit number', 'danger'); if (args.number) break; continue; }
250
-
251
- try {
252
- const session = zepto.newSession(number);
253
- io.print(` ◉ sending OTP to ${number} …`);
254
- const s = await zepto.sendOtp(session);
255
- if (!s.ok) { io.print(` could not send OTP: ${s.msg || s.status}`, 'danger'); if (args.number) break; continue; }
256
- io.print(' ✓ OTP sent');
257
-
258
- // OTP entry — escapable too (Esc abandons this number and loops back)
259
- const otpRaw = args.otp || await io.ask('enter the OTP', { escapable: true });
260
- if (otpRaw === CANCEL) break;
261
- const v = await zepto.verifyOtp(session, String(otpRaw).trim());
262
- if (!v.ok) { io.print(` OTP verify failed: ${v.error || v.status}`, 'danger'); if (args.number) break; continue; }
263
- io.print(` ✓ logged in${v.user?.fullName ? ` as ${v.user.fullName}` : ''}`);
264
-
265
- // Build the ZAUTH1 envelope the format the ZeptoAuthManager tool imports.
266
- const envelope = zepto.encodeEnvelope(zepto.toSessionKeys(session), args.cert);
267
-
268
- // {phone}-{timestamp}.txt in the chosen dir (default ~/Downloads/scripterx/zepto)
269
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
270
- const fname = `${number}-${stamp}.txt`;
271
- let dest;
272
- if (args.out) {
273
- const expanded = args.out.startsWith('~') ? join(homedir(), args.out.slice(1)) : args.out;
274
- dest = /\.(txt|json)$/i.test(expanded) ? expanded : join(expanded, fname);
275
- } else {
276
- const downloads = join(homedir(), 'Downloads');
277
- dest = join(existsSync(downloads) ? downloads : homedir(), 'scripterx', 'zepto', fname);
227
+ export async function zeptoCmd(io, api, args = {}) {
228
+ const mode = args.manual ? 'manual' : (args.auto ? 'auto' : (await io.select('Choose Zepto login flow', [
229
+ { label: 'Auto', value: 'auto', description: 'Headless extraction via OTPCart (Recommended)' },
230
+ { label: 'Manual', value: 'manual', description: 'Enter phone + type OTP manually' }
231
+ ])).value || 'auto');
232
+
233
+ if (mode === 'manual') {
234
+ io.print(' Zepto local OTP → ZAUTH1 envelope (Manual)');
235
+ io.print(' ◉ press esc to cancel', 'accent');
236
+
237
+ let done = 0;
238
+ // double-Ctrl+C to exit (only meaningful in one-shot mode; the ink App owns its own Ctrl+C)
239
+ let lastSig = 0, stopping = false;
240
+ const onSigint = () => {
241
+ const now = Date.now();
242
+ if (now - lastSig < 2000) { stopping = true; process.exit(0); }
243
+ lastSig = now;
244
+ io.print(' ⚠ press Ctrl+C again to exit');
245
+ };
246
+ const interactive = !!io.startRun; // ink App present → don't grab SIGINT
247
+ if (!interactive) process.on('SIGINT', onSigint);
248
+
249
+ try {
250
+ for (;;) {
251
+ // number entry — escapable: Esc stops the whole loop
252
+ const raw = args.number || await io.ask('zepto phone number', { escapable: true });
253
+ if (raw === CANCEL) break;
254
+ const number = String(raw).replace(/\D/g, '').slice(-10);
255
+ if (number.length !== 10) { io.print(' ! enter a 10-digit number', 'danger'); if (args.number) break; continue; }
256
+
257
+ try {
258
+ const session = zepto.newSession(number);
259
+ io.print(` ◉ sending OTP to ${number} …`);
260
+ const s = await zepto.sendOtp(session);
261
+ if (!s.ok) { io.print(` ✗ could not send OTP: ${s.msg || s.status}`, 'danger'); if (args.number) break; continue; }
262
+ io.print(' OTP sent');
263
+
264
+ // OTP entry — escapable too (Esc abandons this number and loops back)
265
+ const otpRaw = args.otp || await io.ask('enter the OTP', { escapable: true });
266
+ if (otpRaw === CANCEL) break;
267
+ const v = await zepto.verifyOtp(session, String(otpRaw).trim());
268
+ if (!v.ok) { io.print(` OTP verify failed: ${v.error || v.status}`, 'danger'); if (args.number) break; continue; }
269
+ io.print(` ✓ logged in${v.user?.fullName ? ` as ${v.user.fullName}` : ''}`);
270
+
271
+ // Build the ZAUTH1 envelope — the format the ZeptoAuthManager tool imports.
272
+ const envelope = zepto.encodeEnvelope(zepto.toSessionKeys(session), args.cert);
273
+
274
+ // {phone}-{timestamp}.txt in the chosen dir (default ~/Downloads/scripterx/zepto)
275
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
276
+ const fname = `${number}-${stamp}.txt`;
277
+ let dest;
278
+ if (args.out) {
279
+ const expanded = args.out.startsWith('~') ? join(homedir(), args.out.slice(1)) : args.out;
280
+ dest = /\.(txt|json)$/i.test(expanded) ? expanded : join(expanded, fname);
281
+ } else {
282
+ const downloads = join(homedir(), 'Downloads');
283
+ dest = join(existsSync(downloads) ? downloads : homedir(), 'scripterx', 'zepto', fname);
284
+ }
285
+ mkdirSync(dirname(dest), { recursive: true });
286
+ writeFileSync(dest, envelope + '\n');
287
+ done++;
288
+ io.print(' ✓ envelope saved to:');
289
+ io.print(` ${dest}`, 'accent');
290
+ io.print(' ◉ paste into ZeptoAuthManager → Import:');
291
+ io.print(` ${envelope}`, 'accent');
292
+ } catch (e) {
293
+ // never let one bad number kill the loop — surface the real reason and keep going
294
+ io.print(` ✗ ${number}: ${e.message}`, 'danger');
295
+ if (args.number) break;
296
+ continue;
278
297
  }
279
- mkdirSync(dirname(dest), { recursive: true });
280
- writeFileSync(dest, envelope + '\n');
281
- done++;
282
- io.print(' ✓ envelope saved to:');
283
- io.print(` ${dest}`, 'accent');
284
- io.print(' ◉ paste into ZeptoAuthManager → Import:');
285
- io.print(` ${envelope}`, 'accent');
286
- } catch (e) {
287
- // never let one bad number kill the loop — surface the real reason and keep going
288
- io.print(` ✗ ${number}: ${e.message}`, 'danger');
298
+
299
+ // one-shot (a number was passed on the CLI): do exactly one and stop
289
300
  if (args.number) break;
290
- continue;
301
+ io.print(' ◉ next number (esc to finish)', 'accent');
291
302
  }
303
+ } finally {
304
+ if (!interactive && !stopping) process.off('SIGINT', onSigint);
305
+ }
306
+ io.print(` ✓ done — ${done} envelope(s) saved.`);
307
+ return;
308
+ }
309
+
310
+ // ── Auto Mode ─────────────────────────────────────────────────────────────
311
+ io.print(' ◉ Zepto — local OTP → ZAUTH1 envelope (Auto)');
312
+ const cfg = config.load();
313
+ let email = cfg.otpcart_email, password = cfg.otpcart_password;
314
+ if (email && password) {
315
+ const choice = await io.select(`saved OTPCart: ${email}`, [
316
+ { label: 'use saved', value: 'use', description: email },
317
+ { label: 'add new', value: 'new', description: 'enter different creds' }]);
318
+ if ((choice.value || choice) === 'new') { email = null; password = null; }
319
+ }
320
+ let freshlyEntered = false;
321
+ if (!email || !password) {
322
+ email = await io.ask('OTPCart email');
323
+ password = await io.ask('OTPCart password', { mask: true });
324
+ freshlyEntered = true;
325
+ }
326
+ const jwt = await oc.login(email, password);
327
+ io.print(' ✓ OTPCart connected');
328
+ if (freshlyEntered && await io.confirm('save these creds locally for next time?', true)) {
329
+ config.setMany({ otpcart_email: email, otpcart_password: password });
330
+ }
331
+
332
+ const count = args.count || Number(await io.ask('how many sessions?', { dflt: '1' }));
333
+ const concurrency = args.concurrency || Number(await io.ask('concurrency (keep low — 1 recommended)', { dflt: '1' }));
334
+ const cert = args.cert || zepto.DEFAULT_CERT_SHA;
335
+ const name = args.name || `Zepto-${Math.floor(Date.now() / 1000)}`;
336
+
337
+ if (!(await io.confirm(`start ${count} Zepto auto extractions at concurrency ${concurrency}?`, true))) return;
292
338
 
293
- // one-shot (a number was passed on the CLI): do exactly one and stop
294
- if (args.number) break;
295
- io.print(' ◉ next number (esc to finish)', 'accent');
339
+ const ZEPTO_SERVICE_ID = '68b2cf55980e8cf480b28c96';
340
+ const provider = new oc.OTPCartProvider(jwt, false, ZEPTO_SERVICE_ID);
341
+
342
+ const { ZeptoWorker } = await import('./worker.js');
343
+ const { RunController } = await import('./controller.js');
344
+ const controller = new RunController({ name, provider: 'otpcart-zepto', requested: count });
345
+ const worker = new ZeptoWorker(provider, {
346
+ requested: count,
347
+ concurrency,
348
+ certSha: cert,
349
+ onEvent: controller.handleEvent,
350
+ });
351
+ controller.stop = () => worker.stop();
352
+
353
+ const results = [];
354
+ const origRecord = worker._record.bind(worker);
355
+ worker._record = (res) => {
356
+ results.push(res);
357
+ origRecord(res);
358
+ };
359
+
360
+ const interactive = !!io.startRun;
361
+ if (interactive) io.startRun(controller);
362
+ let onSigint = null;
363
+ if (!interactive) {
364
+ let lastSig = 0;
365
+ onSigint = () => {
366
+ const now = Date.now();
367
+ if (now - lastSig < 2000) { worker.stop(); process.exit(0); }
368
+ lastSig = now;
369
+ worker.stop();
370
+ io.print(' ⚠ stopping campaign — press Ctrl+C again to exit');
371
+ };
372
+ process.on('SIGINT', onSigint);
373
+ }
374
+
375
+ await worker.run();
376
+ if (onSigint) process.off('SIGINT', onSigint);
377
+ if (io.endRun) io.endRun();
378
+
379
+ io.print(` ✓ done — ${worker.stats.succeeded} succeeded · ${worker.stats.failed} failed · ${worker.stats.cancelled} cancelled · ₹${worker.stats.charges} spent`);
380
+
381
+ if (worker.stats.succeeded > 0) {
382
+ const saved = await saveZeptoSessions(results, name, args.out);
383
+ if (saved) {
384
+ io.print(` ✓ saved ${saved.count} session(s) to:`);
385
+ io.print(` ${saved.path}`, 'accent');
386
+ io.print(' ◉ ZAUTH1 envelopes:');
387
+ for (const r of results) {
388
+ if (r.status === 'success') {
389
+ io.print(` +91${r.mobile} → ${r.envelope}`, 'accent');
390
+ }
391
+ }
296
392
  }
297
- } finally {
298
- if (!interactive && !stopping) process.off('SIGINT', onSigint);
393
+ } else {
394
+ io.print(' ◉ no sessions extracted — nothing to save.');
299
395
  }
300
- io.print(` ✓ done — ${done} envelope(s) saved.`);
396
+ }
397
+
398
+ async function saveZeptoSessions(results, name, out) {
399
+ const { statSync } = await import('node:fs');
400
+ const ok = results.filter((r) => r.status === 'success');
401
+ if (!ok.length) return null;
402
+
403
+ const safeName = name.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'Zepto';
404
+ const fname = `Zepto-${safeName}-${Math.floor(Date.now() / 1000)}.json`;
405
+ let dest;
406
+ if (out) {
407
+ const expanded = out.startsWith('~') ? join(homedir(), out.slice(1)) : out;
408
+ const isDir = (existsSync(expanded) && statSync(expanded).isDirectory()) || /[/\\]$/.test(out);
409
+ dest = isDir ? join(expanded, fname) : expanded;
410
+ } else {
411
+ const downloads = join(homedir(), 'Downloads');
412
+ const base = existsSync(downloads) ? downloads : homedir();
413
+ dest = join(base, 'scripterx', fname);
414
+ }
415
+
416
+ const payload = ok.map((r) => ({
417
+ mobileNo: r.mobile,
418
+ zauth1_envelope: r.envelope,
419
+ session: r.session,
420
+ }));
421
+
422
+ mkdirSync(dirname(dest), { recursive: true });
423
+ writeFileSync(dest, JSON.stringify(payload, null, 2));
424
+ return { path: dest, count: payload.length };
301
425
  }
302
426
 
303
427
  export async function campaigns(io, api) {
package/src/flipkart.js CHANGED
@@ -159,7 +159,7 @@ export class FlipkartLogin {
159
159
  },
160
160
  });
161
161
  const text = await res.text();
162
- const count = (text.match(/"orderId"\s*:\s*"(OD\w+)"/g) || []).length;
162
+ const count = (text.match(/OD\d{15,}/g) || []).length;
163
163
  return count === 0; // no orders ⇒ coupon available
164
164
  }
165
165
 
package/src/index.js CHANGED
@@ -50,8 +50,9 @@ const HELP = `${paint.bold('scripterx')} — local Flipkart session extractor
50
50
  scripterx interactive shell (recommended)
51
51
  scripterx run [flags] run an extraction directly
52
52
  --provider otpcart|tempotp --count N --concurrency N --name <n> --check-minutes
53
- scripterx zepto [flags] local Zepto OTP login → {phone}-{timestamp}.txt (ZAUTH1 envelope)
54
- --number <10-digit> --otp <code> --out <file|dir> --cert <64hex>
53
+ scripterx zepto [flags] local Zepto OTP login → ZAUTH1 envelope (JSON/txt)
54
+ --auto | --manual --count N --concurrency N --out <file|dir> --cert <64hex>
55
+ --number <10-digit> --otp <code>
55
56
  (envelope keyed to ZeptoAuthManager cert; override with --cert or $ZAUTH_CERT_SHA)
56
57
  scripterx campaigns | export [name] | balance | creds | stop | delete | whoami | config
57
58
  scripterx --version`;
@@ -115,6 +116,7 @@ async function main() {
115
116
  mode: flags.email ? 'json_email' : flags.mode, // --email or --mode json_email
116
117
  target: flags.target, // run: 'flipkart' | 'zepto'
117
118
  number: flags.number, otp: flags.otp, cert: flags.cert, // zepto: local OTP login → ZAUTH1 envelope
119
+ auto: flags.auto, manual: flags.manual,
118
120
  campaign: flags._[0], out: flags.out, action: flags._[0], value: flags._[1],
119
121
  };
120
122
  try { await REGISTRY[fnName](cliIo, null, args); }
@@ -22,7 +22,11 @@ export async function balance() { return null; }
22
22
 
23
23
  export class OTPCartProvider {
24
24
  name = 'otpcart';
25
- constructor(jwt, deepCheck = false) { this.jwt = jwt; this.deepCheck = deepCheck; }
25
+ constructor(jwt, deepCheck = false, serviceId = SERVICE_ID) {
26
+ this.jwt = jwt;
27
+ this.deepCheck = deepCheck;
28
+ this.serviceId = serviceId;
29
+ }
26
30
 
27
31
  _hdrs() {
28
32
  return { 'Content-Type': 'application/json', Authorization: `Bearer ${this.jwt}`,
@@ -34,7 +38,7 @@ export class OTPCartProvider {
34
38
  try {
35
39
  const res = await fetch(`${BASE}/mobile/generate`, {
36
40
  method: 'POST', headers: this._hdrs(),
37
- body: JSON.stringify({ serviceId: SERVICE_ID, isDeepCheck: this.deepCheck }),
41
+ body: JSON.stringify({ serviceId: this.serviceId, isDeepCheck: this.deepCheck }),
38
42
  signal: AbortSignal.timeout(15000),
39
43
  });
40
44
  d = await res.json();
@@ -52,7 +56,7 @@ export class OTPCartProvider {
52
56
  try {
53
57
  await fetch(`${BASE}/otp/cancelOtp`, {
54
58
  method: 'POST', headers: this._hdrs(),
55
- body: JSON.stringify({ serialNumber: number.txn, mobileId: number.extra, serviceId: SERVICE_ID }),
59
+ body: JSON.stringify({ serialNumber: number.txn, mobileId: number.extra, serviceId: this.serviceId }),
56
60
  signal: AbortSignal.timeout(12000),
57
61
  });
58
62
  } catch { /* */ }
package/src/util.js CHANGED
@@ -84,11 +84,13 @@ export async function saveSessions(api, cid, { campaignName, out } = {}) {
84
84
  const minutesUsed = []; // coupon_eligible=false → already placed a Minutes order
85
85
  const unchecked = []; // coupon_eligible absent → check wasn't done for this account
86
86
 
87
+ let hasChecked = false;
87
88
  for (const a of accounts) {
88
89
  const sess = a.session;
89
90
  if (!sess) continue;
90
91
  const eligible = a.coupon_eligible;
91
- if (checkMinutes && eligible !== undefined && eligible !== null) {
92
+ if (eligible !== undefined && eligible !== null) {
93
+ hasChecked = true;
92
94
  (eligible ? minutesFree : minutesUsed).push(sess);
93
95
  } else {
94
96
  unchecked.push(sess);
@@ -96,7 +98,7 @@ export async function saveSessions(api, cid, { campaignName, out } = {}) {
96
98
  }
97
99
 
98
100
  const results = [];
99
- if (checkMinutes && (minutesFree.length || minutesUsed.length)) {
101
+ if (hasChecked || (checkMinutes && (minutesFree.length || minutesUsed.length))) {
100
102
  const destFree = write('-minutes-free', minutesFree);
101
103
  if (destFree) results.push({ label: '🟢 minutes-free (₹100 coupon)', path: destFree, count: minutesFree.length });
102
104
 
package/src/worker.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { FlipkartLogin, WrongOTP, BlockedError, TransientError } from './flipkart.js';
6
6
  import { CampaignLogger } from './logger.js';
7
7
  import * as kuku from './providers/kuku.js';
8
+ import * as zepto from './providers/zepto.js';
8
9
 
9
10
  const OTP_RESEND_AFTER = 60_000;
10
11
  const OTPCART_DEADLINE = 120_000;
@@ -241,6 +242,9 @@ export class Worker {
241
242
 
242
243
  this._emit(slot, { phase: 'saving', detail: 'saving session' });
243
244
  this.log(`SUCCESS ${number.mobile} — session extracted (₹${number.cost})${res.email_linked ? ` + email ${res.linked_email}` : ''}`);
245
+ res.status = 'success';
246
+ res.cost = number.cost;
247
+ res.session = session;
244
248
  const doneDetail = res.email_linked ? `extracted + ${res.linked_email}` : 'extracted';
245
249
  this._emit(slot, { phase: 'done', detail: doneDetail });
246
250
  releaseOnce(true, doneDetail); // success still releases the number (OTP already consumed)
@@ -454,3 +458,184 @@ export class Worker {
454
458
  return p;
455
459
  }
456
460
  }
461
+
462
+ export class ZeptoWorker {
463
+ constructor(provider, { requested, concurrency, certSha, onEvent }) {
464
+ this.provider = provider;
465
+ this.requested = requested;
466
+ this.concurrency = Math.max(1, Math.min(concurrency, requested));
467
+ this.certSha = certSha;
468
+ this.onEvent = onEvent || (() => {});
469
+ this.stats = { total: requested, succeeded: 0, failed: 0, cancelled: 0, generated: 0, attempts: 0, charges: 0 };
470
+ this.slots = {};
471
+ this.seq = 0;
472
+ this.stopped = false;
473
+ this._releases = [];
474
+ }
475
+
476
+ stop() { this.stopped = true; }
477
+ _nextSeq() { return ++this.seq; }
478
+
479
+ _claim() {
480
+ if (this.stopped) return 0;
481
+ if (this.stats.succeeded >= this.requested) return 0;
482
+ const active = this.stats.attempts - (this.stats.succeeded + this.stats.failed + this.stats.cancelled);
483
+ if (this.stats.succeeded + active >= this.requested) return 0;
484
+ if (this.stats.attempts >= this.requested * ATTEMPT_CAP_MULT) return 0;
485
+ return ++this.stats.attempts;
486
+ }
487
+
488
+ _emit(slot, kv) {
489
+ this.slots[slot] = { slot, ...(this.slots[slot] || {}), ...kv };
490
+ this.onEvent('slot', this.slots[slot]);
491
+ }
492
+
493
+ _record(res) {
494
+ if (res.mobile) this.stats.generated++;
495
+ if (res.status === 'success') { this.stats.succeeded++; this.stats.charges += res.cost || 0; }
496
+ else if (res.status === 'cancelled') this.stats.cancelled++;
497
+ else { this.stats.failed++; this.stats.charges += res.cost || 0; }
498
+ this.onEvent('progress', this.stats);
499
+ this.onEvent('row', res);
500
+ }
501
+
502
+ async run() {
503
+ const loops = Array.from({ length: this.concurrency }, (_, i) => this._loop(i + 1));
504
+ await Promise.all(loops);
505
+ if (this._releases.length) {
506
+ await Promise.allSettled(this._releases);
507
+ }
508
+ this.onEvent('done', this.stats);
509
+ return this.stats;
510
+ }
511
+
512
+ _release(number) {
513
+ const p = (async () => {
514
+ for (let attempt = 1; attempt <= 5; attempt++) {
515
+ try {
516
+ await this.provider.cancel(number);
517
+ return;
518
+ } catch {
519
+ await sleep(3000);
520
+ }
521
+ }
522
+ })();
523
+ this._releases.push(p);
524
+ }
525
+
526
+ async _rent(slot) {
527
+ let attempt = 0;
528
+ while (!this.stopped) {
529
+ attempt++;
530
+ this._emit(slot, { phase: 'renting', detail: `requesting a number (try ${attempt})` });
531
+ const { number } = await this.provider.rentOnce();
532
+ if (number) return number;
533
+ if (attempt % RENT_COOLDOWN_EVERY === 0) {
534
+ for (let rem = RENT_COOLDOWN_SECS; rem > 0; rem--) {
535
+ if (this.stopped) return null;
536
+ this._emit(slot, { phase: 'rent_wait', detail: 'no numbers — waiting', wait: rem });
537
+ await sleep(1000);
538
+ }
539
+ } else {
540
+ await sleep(RENT_RETRY_DELAY);
541
+ }
542
+ }
543
+ return null;
544
+ }
545
+
546
+ async _loop(slot) {
547
+ for (;;) {
548
+ if (this._claim() === 0) return;
549
+ const res = await this._process(slot);
550
+ await this._record(res);
551
+ }
552
+ }
553
+
554
+ async _process(slot) {
555
+ const idNo = this._nextSeq();
556
+ const res = { id_no: idNo, status: 'failed', cost: 0, detail: '' };
557
+
558
+ if (this.stats.succeeded >= this.requested) {
559
+ this._emit(slot, { phase: 'done', detail: 'goal reached' });
560
+ res.status = 'cancelled';
561
+ res.detail = 'goal reached';
562
+ return res;
563
+ }
564
+
565
+ this._emit(slot, { mobile: '', phase: 'renting', detail: 'requesting a number', wait: 0 });
566
+ const number = await this._rent(slot);
567
+ if (!number) { res.status = 'cancelled'; res.detail = 'stopped while renting'; return res; }
568
+ if (this.stats.succeeded >= this.requested) {
569
+ this._release(number);
570
+ this._emit(slot, { phase: 'done', detail: 'goal reached' });
571
+ res.status = 'cancelled';
572
+ res.detail = 'goal reached';
573
+ return res;
574
+ }
575
+ const rentedAt = Date.now();
576
+ res.mobile = number.mobile;
577
+ this._emit(slot, { mobile: number.mobile, phase: 'rented', detail: 'got a number' });
578
+
579
+ let stream = null;
580
+ let released = false;
581
+
582
+ const releaseOnce = (charged = false, doneDetail = 'done') => {
583
+ if (released) return;
584
+ released = true;
585
+ if (charged) {
586
+ this._emit(slot, { phase: 'done', detail: doneDetail });
587
+ return;
588
+ }
589
+ this._emit(slot, { phase: 'cancelling', detail: 'releasing number' });
590
+ this._release(number);
591
+ };
592
+
593
+ const fail = (detail) => {
594
+ const charged = smsArrived.v;
595
+ res.status = charged ? 'failed' : 'cancelled';
596
+ res.cost = charged ? number.cost : 0;
597
+ res.detail = detail;
598
+ releaseOnce(charged, 'failed');
599
+ return res;
600
+ };
601
+
602
+ const smsArrived = { v: false };
603
+ const session = zepto.newSession(number.mobile);
604
+
605
+ try {
606
+ this._emit(slot, { phase: 'ws', detail: 'opening OTP channel' });
607
+ stream = this.provider.startOtp(number);
608
+ if (typeof stream.ready === 'function') await stream.ready();
609
+
610
+ this._emit(slot, { phase: 'send_otp', detail: 'sending OTP' });
611
+ const sent = await zepto.sendOtp(session);
612
+ if (!sent.ok) return fail(`send: ${sent.msg}`);
613
+
614
+ const end = Date.now() + 120000;
615
+ let otp = null;
616
+ while (Date.now() < end && !this.stopped) {
617
+ const { otp: o, arrived } = await stream.poll();
618
+ if (arrived) smsArrived.v = true;
619
+ if (o) { otp = o; break; }
620
+ this._emit(slot, { phase: 'await_otp', detail: 'waiting for OTP', wait: Math.floor((end - Date.now()) / 1000) });
621
+ await sleep(1000);
622
+ }
623
+ if (!otp) return fail('no OTP within 120s — abandoned');
624
+
625
+ this._emit(slot, { phase: 'verify', detail: 'verifying OTP' });
626
+ const v = await zepto.verifyOtp(session, otp);
627
+ if (!v.ok) return fail(`verify: ${v.error}`);
628
+
629
+ const envelope = zepto.encodeEnvelope(zepto.toSessionKeys(session), this.certSha);
630
+ this._emit(slot, { phase: 'saving', detail: 'encoding envelope' });
631
+ res.status = 'success'; res.cost = number.cost; res.session = zepto.toSessionKeys(session); res.envelope = envelope;
632
+ releaseOnce(true, 'extracted');
633
+ return res;
634
+ } catch (e) {
635
+ return fail(`unexpected: ${e.message}`);
636
+ } finally {
637
+ releaseOnce(true);
638
+ if (stream) try { stream.close(); } catch { /* */ }
639
+ }
640
+ }
641
+ }