scripter-x 1.0.31 → 1.0.33

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.31",
3
+ "version": "1.0.33",
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
@@ -552,13 +552,12 @@ function saveBigbasketSession(args, number, sessionKeys) {
552
552
  }
553
553
 
554
554
  export async function bigbasketCmd(io, api, args = {}) {
555
- // Only manual exists today; auto is wired after the server contract is known.
556
- const mode = args.auto ? 'auto' : 'manual';
557
- if (mode === 'auto') {
558
- io.print(' ! BigBasket auto mode is not wired yet — coming once the server is set.', 'danger');
559
- io.print(' ◉ use manual for now: scripterx bigbasket --manual', 'accent');
560
- return;
561
- }
555
+ const mode = args.manual ? 'manual' : (args.auto ? 'auto' : (args.number ? 'manual' : (await io.select('Choose BigBasket login flow', [
556
+ { label: 'Auto', value: 'auto', description: 'Auto-rent numbers via TempOTP (hands-off)' },
557
+ { label: 'Manual', value: 'manual', description: 'Enter phone + type OTP yourself' },
558
+ ])).value || 'auto'));
559
+
560
+ if (mode === 'auto') return bigbasketAuto(io, args);
562
561
 
563
562
  io.print(' ◉ BigBasket — local OTP → session JSON (Manual)');
564
563
  io.print(' ◉ press esc to cancel', 'accent');
@@ -617,6 +616,94 @@ export async function bigbasketCmd(io, api, args = {}) {
617
616
  io.print(` ✓ done — ${done} session(s) saved.`);
618
617
  }
619
618
 
619
+ // ── BigBasket auto: rent numbers via TempOTP, poll SMS, verify headlessly ─────
620
+ // Mirrors the Zepto auto flow. Local-first: every success is appended to a combined
621
+ // file the instant it lands (so a mid-run stop never loses an account).
622
+ async function bigbasketAuto(io, args = {}) {
623
+ io.print(' ◉ BigBasket — auto extraction via TempOTP');
624
+ const cfg = config.load();
625
+
626
+ // TempOTP key (reuse the saved-key / add-new pattern from buildProvider).
627
+ let key = cfg.tempotp_api_key;
628
+ if (key) {
629
+ const choice = await io.select(`saved TempOTP key: ${maskSecret(key)}`, [
630
+ { label: 'use saved', value: 'use', description: maskSecret(key) },
631
+ { label: 'add new', value: 'new', description: 'enter a different key' }]);
632
+ if ((choice.value || choice) === 'new') key = null;
633
+ }
634
+ let freshlyEntered = false;
635
+ if (!key) { key = await io.ask('TempOTP API key'); freshlyEntered = true; }
636
+ const bal = await tp.balance(key);
637
+ if (bal == null) { io.print(' ✗ TempOTP key invalid or unreachable', 'danger'); return; }
638
+ io.print(` ✓ TempOTP balance: ₹${bal}`);
639
+ if (freshlyEntered && await io.confirm('save this key locally for next time?', true)) config.setMany({ tempotp_api_key: key });
640
+
641
+ // BigBasket service picker (only the BB service ids).
642
+ const svc = args.service || (await io.select('BigBasket service', tp.BIGBASKET_SERVICES.map((id) => ({
643
+ label: `${id} · ${tp.SERVICE_NAMES[id]}`, value: id, description: `₹${tp.SERVICES[id]}`,
644
+ })))).value;
645
+ const provider = new tp.TempOTPProvider(key, svc);
646
+
647
+ const count = args.count || Number(await io.ask('how many sessions?', { dflt: '1' }));
648
+ const concurrency = args.concurrency || Number(await io.ask('concurrency (keep low — 1 recommended)', { dflt: '1' }));
649
+ const name = args.name || `BigBasket-${Math.floor(Date.now() / 1000)}`;
650
+ if (!(await io.confirm(`start ${count} BigBasket extraction(s) at concurrency ${concurrency} on service ${svc}?`, true))) return;
651
+
652
+ const { BigBasketWorker } = await import('./worker.js');
653
+ const { RunController } = await import('./controller.js');
654
+ const controller = new RunController({ name, provider: `tempotp-bigbasket`, requested: count });
655
+
656
+ // INCREMENTAL SAVE: append every extracted session to one JSONL the instant it lands.
657
+ const baseDir = bigbasketBaseDir(args);
658
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
659
+ const liveFile = join(baseDir, `${name}-${stamp}.jsonl`);
660
+ const onResult = (res) => {
661
+ if (res.status !== 'success' || !res.session) return;
662
+ mkdirSync(baseDir, { recursive: true });
663
+ appendFileSync(liveFile, JSON.stringify(res.session) + '\n');
664
+ };
665
+
666
+ const worker = new BigBasketWorker(provider, { requested: count, concurrency, onEvent: controller.handleEvent, onResult });
667
+ controller.stop = () => worker.stop();
668
+
669
+ const results = [];
670
+ const origRecord = worker._record.bind(worker);
671
+ worker._record = (res) => { results.push(res); origRecord(res); };
672
+
673
+ const interactive = !!io.startRun;
674
+ if (interactive) io.startRun(controller);
675
+ let onSigint = null;
676
+ if (!interactive) {
677
+ let lastSig = 0;
678
+ onSigint = () => {
679
+ const now = Date.now();
680
+ if (now - lastSig < 2000) { worker.stop(); process.exit(0); }
681
+ lastSig = now; worker.stop();
682
+ io.print(' ⚠ stopping — press Ctrl+C again to exit');
683
+ };
684
+ process.on('SIGINT', onSigint);
685
+ }
686
+
687
+ await worker.run();
688
+ if (onSigint) process.off('SIGINT', onSigint);
689
+ if (io.endRun) io.endRun();
690
+
691
+ io.print(` ✓ done — ${worker.stats.succeeded} succeeded · ${worker.stats.failed} failed · ${worker.stats.cancelled} cancelled · ₹${worker.stats.charges} spent`);
692
+
693
+ // Also write a combined pretty JSON array of all successes for convenience.
694
+ const succeeded = results.filter((r) => r.status === 'success' && r.session).map((r) => r.session);
695
+ if (succeeded.length) {
696
+ mkdirSync(baseDir, { recursive: true });
697
+ const combined = join(baseDir, `${name}-${stamp}.json`);
698
+ writeFileSync(combined, JSON.stringify(succeeded, null, 2) + '\n');
699
+ io.print(` ✓ saved ${succeeded.length} session(s):`);
700
+ io.print(` ${combined}`, 'accent');
701
+ io.print(` (live JSONL: ${liveFile})`, 'accent');
702
+ } else {
703
+ io.print(' ◉ no sessions extracted — nothing to save.');
704
+ }
705
+ }
706
+
620
707
  const TIER_LABEL = { rs100: '🟢 ₹100', rs75: '🟢 ₹75', rs50: '🟢 ₹50', normal: '⚪ normal', unchecked: '❔ unchecked' };
621
708
 
622
709
  // The directory the per-tier files go in for an auto run.
package/src/index.js CHANGED
@@ -55,8 +55,10 @@ const HELP = `${paint.bold('scripterx')} — local Flipkart session extractor
55
55
  --number <10-digit> --otp <code>
56
56
  (envelope keyed to ZeptoAuthManager cert; override with --cert or $ZAUTH_CERT_SHA)
57
57
  scripterx bigbasket [flags] local BigBasket OTP login → session JSON (alias: bb)
58
- --manual --number <10-digit> --otp <code> --out <file|dir>
59
- writes {phone, bbAuthToken, mId, bbVisitorId} per number (auto mode soon)
58
+ --auto | --manual --count N --concurrency N --out <file|dir>
59
+ --service <1819|1874|1919|2259> (auto: TempOTP BigBasket service id)
60
+ --number <10-digit> --otp <code> (manual one-shot)
61
+ → writes {phone, bbAuthToken, mId, bbVisitorId} per session
60
62
  scripterx recheck <file> re-validate Minutes status of a session JSON, locally (your IP)
61
63
  → re-splits into corrected -minutes-free / -minutes-used files + stats
62
64
  scripterx campaigns | export [name] | balance | creds | stop | delete | whoami | config
@@ -121,6 +123,7 @@ async function main() {
121
123
  mode: flags.email ? 'json_email' : flags.mode, // --email or --mode json_email
122
124
  target: flags.target, // run: 'flipkart' | 'zepto'
123
125
  number: flags.number, otp: flags.otp, cert: flags.cert, // zepto: local OTP login → ZAUTH1 envelope
126
+ service: flags.service, // bigbasket auto: TempOTP service id (1819/1874/1919/2259)
124
127
  auto: flags.auto, manual: flags.manual,
125
128
  checkCoupon: flags['check-coupon'] === true ? true : (flags['check-coupon'] === false ? false : null),
126
129
  campaign: flags._[0], out: flags.out, action: flags._[0], value: flags._[1],
@@ -113,6 +113,8 @@ export function newSession(number) {
113
113
  mId: null, // base64 member id (server-provided)
114
114
  visitorId: null, // base64 visitor id (server-provided)
115
115
  user: null,
116
+ isSignup: false, // true if verify created a brand-new account
117
+ activated: false, // true once the name/profile step succeeded (or wasn't needed)
116
118
  };
117
119
  }
118
120
 
@@ -146,18 +148,44 @@ export async function sendOtp(session) {
146
148
  const refId = r.json && (r.json.refId || r.json.ref_id);
147
149
  const ok = r.status === 200 && !!refId;
148
150
  if (ok) session.refId = refId;
149
- return { ok, status: r.status, msg: ok ? (r.json.message || 'OTP sent') : (r.json?.message || r.text.slice(0, 160)) };
151
+ // Surface BigBasket's error code so callers can react:
152
+ // HU4001 = account does not exist / inactive (often a recycled/dead number) → rotate
153
+ // HU4000 = bad request shape
154
+ const err = r.json?.errors?.[0];
155
+ const code = err?.code_str || null;
156
+ return {
157
+ ok, status: r.status, code,
158
+ msg: ok ? (r.json.message || 'OTP sent') : (err?.msg || err?.display_msg || r.json?.message || r.text.slice(0, 160)),
159
+ };
150
160
  }
151
161
 
162
+ // A default name for brand-new accounts. BigBasket requires a non-empty first_name to
163
+ // activate a fresh signup; override via $BB_SIGNUP_NAME.
164
+ const SIGNUP_NAME = (process.env.BB_SIGNUP_NAME || 'Rohan').trim();
165
+
152
166
  // Step 2 — verify the OTP. On success, fills session.token / mId / visitorId / user.
167
+ // Handles BOTH returning members AND brand-new signups: a new account comes back with
168
+ // is_signup:true and an empty name, then needs a profile/set-name call to activate
169
+ // (otherwise the account is half-created and the session is unusable). See the BigBasket
170
+ // signup flow: send(unified_login) → verify → [if is_signup] profile/set-name.
171
+ async function _doVerify(session, otp, referrer) {
172
+ const body = { mobile_no: session.mobile, mobile_no_otp: String(otp).trim(), refId: session.refId, source: 'BIGBASKET' };
173
+ if (referrer) body.referrer = referrer; // signup variant adds referrer:"login_signup"
174
+ await throttle.wait();
175
+ return req(session, 'POST', '/member-tdl/v3/member/unified-login/', body,
176
+ { 'X-Login-Origin': referrer || 'unified_login', 'X-tcp-client-id': 'BIGBASKET-ANDROID-APP' });
177
+ }
178
+
153
179
  export async function verifyOtp(session, otp) {
154
180
  if (!session.refId) return { ok: false, status: 0, error: 'no refId — call sendOtp first' };
155
- await throttle.wait();
156
- const r = await req(session, 'POST', '/member-tdl/v3/member/unified-login/',
157
- { mobile_no: session.mobile, mobile_no_otp: String(otp).trim(), refId: session.refId, source: 'BIGBASKET' },
158
- { 'X-Login-Origin': 'unified_login', 'X-tcp-client-id': 'BIGBASKET-ANDROID-APP' });
181
+ // First try the returning-member verify. If the number has no account yet (HU4001),
182
+ // retry with the signup referrer so a brand-new account is created.
183
+ let r = await _doVerify(session, otp, null);
184
+ if (r.status !== 200 && r.json?.errors?.[0]?.code_str === 'HU4001') {
185
+ r = await _doVerify(session, otp, 'login_signup');
186
+ }
159
187
 
160
- // Native unified-login returns { bb_token, m_id, visitor_id }.
188
+ // Native unified-login returns { bb_token, m_id, visitor_id, is_signup, first_name, ... }.
161
189
  const J = r.json || {};
162
190
  const token = J.bb_token || J.BBAUTHTOKEN || session.jar.BBAUTHTOKEN || J.token;
163
191
  if (r.status === 200 && token) {
@@ -165,11 +193,39 @@ export async function verifyOtp(session, otp) {
165
193
  session.mId = J.m_id || session.jar._bb_mid || null;
166
194
  session.visitorId = J.visitor_id || session.jar._bb_vid || null;
167
195
  session.user = J.member_details || J.user || null;
168
- return { ok: true, user: session.user };
196
+ session.isSignup = !!(J.is_signup || J.isSignup);
197
+ // A brand-new account (or one with no name) must be activated with a name, else the
198
+ // session is half-baked. Best-effort: failure here still returns the token we got.
199
+ const noName = !((J.first_name || J.firstName || '').trim());
200
+ if (session.isSignup || noName) {
201
+ session.activated = await setProfile(session, SIGNUP_NAME);
202
+ } else {
203
+ session.activated = true;
204
+ }
205
+ return { ok: true, user: session.user, isSignup: session.isSignup, activated: session.activated };
206
+ }
207
+ // Error codes seen at verify:
208
+ // HU4011 = wrong/expired OTP HU4001 = account does not exist/inactive HU4000 = bad request
209
+ const e = J.errors?.[0];
210
+ const code = e?.code_str || null;
211
+ const error = e?.msg || e?.display_msg || J.message || r.text.slice(0, 160);
212
+ return { ok: false, status: r.status, code, error };
213
+ }
214
+
215
+ // Step 3 (new users only) — set the name to activate the freshly-created account.
216
+ // POST /member-tdl/v3/member/profile/ with the BBAUTHTOKEN we just got. Returns true on
217
+ // success. Never throws — a name-set failure shouldn't discard the token.
218
+ export async function setProfile(session, firstName, lastName = '') {
219
+ try {
220
+ if (session.token) session.jar.BBAUTHTOKEN = session.token; // authenticate the call
221
+ await throttle.wait();
222
+ const r = await req(session, 'POST', '/member-tdl/v3/member/profile/',
223
+ { first_name: firstName, last_name: lastName, name: lastName ? `${firstName} ${lastName}` : firstName },
224
+ { 'X-Login-Origin': 'login_signup', 'X-tcp-client-id': 'BIGBASKET-ANDROID-APP' });
225
+ return r.status === 200;
226
+ } catch {
227
+ return false;
169
228
  }
170
- // HU4000 / wrong-otp surface here
171
- const err = J.errors?.[0]?.msg || J.message || r.text.slice(0, 160);
172
- return { ok: false, status: r.status, error: err };
173
229
  }
174
230
 
175
231
  // Build the persisted-session object — the requested {bbAuthToken, mId, bbVisitorId}
@@ -3,9 +3,19 @@ import { extractOtp } from '../flipkart.js';
3
3
 
4
4
  const BASE = 'https://api.tempotp.online';
5
5
  // serviceId -> cost (₹). Keep ids as strings (they go straight into the GET params).
6
- export const SERVICES = { '1040': 10.0, '940': 16.0, '2451': 12.5, '2452': 12.5, '2453': 12.0, '2484': 18.0 };
6
+ export const SERVICES = {
7
+ '1040': 10.0, '940': 16.0, '2451': 12.5, '2452': 12.5, '2453': 12.0, '2484': 18.0,
8
+ // BigBasket (SERVER 16 [best-sv-multi])
9
+ '1819': 12.0, '1874': 12.0, '1919': 14.0, '2259': 14.0,
10
+ };
7
11
  // optional friendly names shown in the service picker
8
- export const SERVICE_NAMES = { '1040': 'Flipkart', '940': 'Flipkart', '2451': 'Shopsy/Flipkart 1 (SERVER 16)', '2452': 'Shopsy/Flipkart 2 (SERVER 16)', '2453': 'Shopsy/Flipkart 3 (SERVER 16)', '2484': 'ALL 6 DIGIT (SERVER 17 [ALL])' };
12
+ export const SERVICE_NAMES = {
13
+ '1040': 'Flipkart', '940': 'Flipkart', '2451': 'Shopsy/Flipkart 1 (SERVER 16)', '2452': 'Shopsy/Flipkart 2 (SERVER 16)', '2453': 'Shopsy/Flipkart 3 (SERVER 16)', '2484': 'ALL 6 DIGIT (SERVER 17 [ALL])',
14
+ '1819': 'BigBasket 1.0 (SERVER 16)', '1874': 'BigBasket 2.0 (SERVER 16)', '1919': 'BigBasket 3.0 (SERVER 16)', '2259': 'BigBasket 4.0 (SERVER 16)',
15
+ };
16
+ // BigBasket service ids (for the bigbasket auto picker)
17
+ export const BIGBASKET_SERVICES = ['1819', '1874', '1919', '2259'];
18
+ export const DEFAULT_BIGBASKET_SERVICE = '1819';
9
19
  export const DEFAULT_SERVICE = '940';
10
20
  const COUNTRY = '22';
11
21
 
@@ -51,8 +61,22 @@ export class TempOTPProvider {
51
61
 
52
62
  startOtp(number) { return new TempStream(this.apiKey, number.txn); }
53
63
 
64
+ // Cancel + refund the rented number. Returns { ok, tooEarly, msg } so the worker's
65
+ // releaseNumber loop knows whether the cancel landed. TempOTP enforces a ~2-min
66
+ // no-cancel policy: a too-early attempt is reported back so the caller waits + retries
67
+ // (rather than treating it as a hard failure or a success).
54
68
  async cancel(number) {
55
- try { await getJson('/cancelNumber', { apikey: this.apiKey, id: number.txn }); } catch { /* */ }
69
+ let d;
70
+ try { d = await getJson('/cancelNumber', { apikey: this.apiKey, id: number.txn }); }
71
+ catch (e) { return { ok: false, tooEarly: false, msg: e.message }; }
72
+ // success: API returns status 200 (sometimes with a "cancelled"/"success" message)
73
+ const msg = (d && (d.message || d.msg)) || '';
74
+ if (d && (d.status === 200 || /cancel|success|refund/i.test(msg))) {
75
+ return { ok: true, tooEarly: false, msg: msg || 'cancelled' };
76
+ }
77
+ // the 2-min policy / "wait before cancel" → retryable, not a hard fail
78
+ const tooEarly = /2\s*min|two\s*min|120\s*sec|wait|too early|cannot cancel|not allowed yet|before/i.test(msg);
79
+ return { ok: false, tooEarly, msg: msg || `cancel rejected (status ${d?.status})` };
56
80
  }
57
81
  }
58
82
 
package/src/worker.js CHANGED
@@ -6,10 +6,15 @@ import { FlipkartLogin, WrongOTP, BlockedError, TransientError } from './flipkar
6
6
  import { CampaignLogger } from './logger.js';
7
7
  import * as kuku from './providers/kuku.js';
8
8
  import * as zepto from './providers/zepto.js';
9
+ import * as bigbasket from './providers/bigbasket.js';
9
10
 
10
11
  const OTP_RESEND_AFTER = 60_000;
11
12
  const OTPCART_DEADLINE = 120_000;
12
- const TEMPOTP_DEADLINE = 90_000; // TempOTP: give up after 90s, cancel + buy a new number
13
+ // TempOTP now enforces a 2-min no-cancel policy on rented numbers (you can't cancel/refund
14
+ // a number before 120s). So wait the FULL 2 min for the OTP — there's no point bailing at
15
+ // 90s when the number can't be released until 120s anyway. The cancel-lock below (also
16
+ // 120s) then fires right at the unlock point, identical to OTPCart's behaviour.
17
+ const TEMPOTP_DEADLINE = 120_000;
13
18
  const WRONG_OTP_WAIT = 60_000;
14
19
  const NUMBER_CANCEL_LOCK = 120_000;
15
20
  const RENT_RETRY_DELAY = 3_000;
@@ -759,3 +764,202 @@ export class ZeptoWorker {
759
764
  }
760
765
  }
761
766
  }
767
+
768
+ // ── BigBasket auto worker ─────────────────────────────────────────────────────
769
+ // Identical orchestration to ZeptoWorker (rent → SMS poll → verify → save), but the
770
+ // merchant is BigBasket: bigbasket.sendOtp() does its own 3-call bootstrap+send, we
771
+ // poll the OTP provider's SMS stream, then bigbasket.verifyOtp() → session JSON.
772
+ // Designed for TempOTP (poll, no resend) but works with any provider exposing
773
+ // rentOnce/startOtp(poll)/cancel. Local-first: emits res.session for incremental save;
774
+ // server push is layered on top by the caller (onResult) when a backend is configured.
775
+ export class BigBasketWorker {
776
+ constructor(provider, { requested, concurrency, deadlineMs, onEvent, onResult }) {
777
+ this.provider = provider;
778
+ this.requested = requested;
779
+ this.concurrency = Math.max(1, Math.min(concurrency, requested));
780
+ this.deadlineMs = deadlineMs || (provider?.name === 'tempotp' ? TEMPOTP_DEADLINE : OTPCART_DEADLINE);
781
+ this.onEvent = onEvent || (() => {});
782
+ this.onResult = onResult || null;
783
+ if (provider && 'onNotice' in provider) provider.onNotice = (msg) => this.onEvent('warn', `⚠ ${msg}`);
784
+ this.stats = { total: requested, succeeded: 0, failed: 0, cancelled: 0, generated: 0, attempts: 0, charges: 0 };
785
+ this.slots = {};
786
+ this.seq = 0;
787
+ this.stopped = false;
788
+ this._releases = [];
789
+ }
790
+
791
+ stop() { this.stopped = true; }
792
+ _nextSeq() { return ++this.seq; }
793
+
794
+ _claim() {
795
+ if (this.stopped) return 0;
796
+ if (this.stats.succeeded >= this.requested) return 0;
797
+ const active = this.stats.attempts - (this.stats.succeeded + this.stats.failed + this.stats.cancelled);
798
+ if (this.stats.succeeded + active >= this.requested) return 0;
799
+ if (this.stats.attempts >= this.requested * ATTEMPT_CAP_MULT) return 0;
800
+ return ++this.stats.attempts;
801
+ }
802
+
803
+ _emit(slot, kv) {
804
+ this.slots[slot] = { slot, ...(this.slots[slot] || {}), ...kv };
805
+ this.onEvent('slot', this.slots[slot]);
806
+ }
807
+
808
+ _record(res) {
809
+ if (res.mobile) this.stats.generated++;
810
+ if (res.status === 'success') { this.stats.succeeded++; this.stats.charges += res.cost || 0; }
811
+ else if (res.status === 'cancelled') this.stats.cancelled++;
812
+ else { this.stats.failed++; this.stats.charges += res.cost || 0; }
813
+ this.onEvent('progress', this.stats);
814
+ this.onEvent('row', res);
815
+ if (res.status === 'success' && res.session && this.onResult) {
816
+ try { this.onResult(res); } catch { /* best-effort; never break the loop */ }
817
+ }
818
+ }
819
+
820
+ async run() {
821
+ const loops = Array.from({ length: this.concurrency }, (_, i) => this._loop(i + 1));
822
+ await Promise.all(loops);
823
+ if (this._releases.length) {
824
+ this.onEvent('warn', `finishing up — releasing ${this._releases.length} number(s)…`);
825
+ await Promise.allSettled(this._releases);
826
+ }
827
+ this.onEvent('done', this.stats);
828
+ return this.stats;
829
+ }
830
+
831
+ _release(number, rentedAt, slot) {
832
+ const p = releaseNumber(this.provider, number, rentedAt, {
833
+ log: () => {},
834
+ warn: (m) => this.onEvent('warn', m),
835
+ onWait: slot ? (sec) => this._emit(slot, { phase: 'cancelling', detail: 'releasing (cancel-lock)', wait: sec }) : undefined,
836
+ });
837
+ this._releases.push(p);
838
+ return p;
839
+ }
840
+
841
+ async _rent(slot) {
842
+ let attempt = 0;
843
+ while (!this.stopped) {
844
+ attempt++;
845
+ this._emit(slot, { phase: 'renting', detail: `requesting a number (try ${attempt})` });
846
+ const { number } = await this.provider.rentOnce();
847
+ if (number) return number;
848
+ if (attempt % RENT_COOLDOWN_EVERY === 0) {
849
+ for (let rem = RENT_COOLDOWN_SECS; rem > 0; rem--) {
850
+ if (this.stopped) return null;
851
+ this._emit(slot, { phase: 'rent_wait', detail: 'no numbers — waiting', wait: rem });
852
+ await sleep(1000);
853
+ }
854
+ } else {
855
+ await sleep(RENT_RETRY_DELAY);
856
+ }
857
+ }
858
+ return null;
859
+ }
860
+
861
+ async _loop(slot) {
862
+ for (;;) {
863
+ if (this._claim() === 0) return;
864
+ const res = await this._process(slot);
865
+ await this._record(res);
866
+ }
867
+ }
868
+
869
+ async _process(slot) {
870
+ const idNo = this._nextSeq();
871
+ const res = { id_no: idNo, status: 'failed', cost: 0, detail: '' };
872
+
873
+ if (this.stats.succeeded >= this.requested) {
874
+ this._emit(slot, { phase: 'done', detail: 'goal reached' });
875
+ res.status = 'cancelled'; res.detail = 'goal reached';
876
+ return res;
877
+ }
878
+
879
+ this._emit(slot, { mobile: '', phase: 'renting', detail: 'requesting a number', wait: 0 });
880
+ const number = await this._rent(slot);
881
+ if (!number) { res.status = 'cancelled'; res.detail = 'stopped while renting'; return res; }
882
+ const rentedAt = Date.now();
883
+ if (this.stats.succeeded >= this.requested) {
884
+ this._release(number, rentedAt, slot);
885
+ this._emit(slot, { phase: 'done', detail: 'goal reached' });
886
+ res.status = 'cancelled'; res.detail = 'goal reached';
887
+ return res;
888
+ }
889
+ res.mobile = number.mobile;
890
+ this._emit(slot, { mobile: number.mobile, phase: 'rented', detail: 'got a number' });
891
+
892
+ let stream = null;
893
+ let released = false;
894
+ const smsArrived = { v: false };
895
+
896
+ const releaseOnce = (charged = false, doneDetail = 'done', consumed = false) => {
897
+ if (released) return;
898
+ released = true;
899
+ if (consumed || (charged && !this.stopped)) { this._emit(slot, { phase: 'done', detail: doneDetail }); return; }
900
+ this._emit(slot, { phase: 'cancelling', detail: 'releasing number' });
901
+ this._release(number, rentedAt, slot);
902
+ };
903
+
904
+ const fail = (detail, category) => {
905
+ const charged = smsArrived.v;
906
+ res.status = charged ? 'failed' : 'cancelled';
907
+ res.cost = charged ? number.cost : 0;
908
+ res.detail = detail;
909
+ res.category = category || (res.status === 'failed' ? 'failed' : 'cancelled');
910
+ releaseOnce(charged, 'failed');
911
+ return res;
912
+ };
913
+
914
+ const session = bigbasket.newSession(number.mobile);
915
+
916
+ try {
917
+ this._emit(slot, { phase: 'ws', detail: 'opening OTP channel' });
918
+ stream = this.provider.startOtp(number);
919
+ if (typeof stream.ready === 'function') await stream.ready();
920
+
921
+ // bigbasket.sendOtp does register/device → header → otp (all on our IP).
922
+ this._emit(slot, { phase: 'send_otp', detail: 'sending OTP' });
923
+ const sent = await bigbasket.sendOtp(session);
924
+ if (!sent.ok) {
925
+ // HU4001 = the rented number has no BigBasket account / is inactive (recycled).
926
+ // No SMS will ever come → don't wait; mark as a dead number and rotate to a new one.
927
+ const dead = sent.code === 'HU4001';
928
+ return fail(`send: ${sent.msg}`, dead ? 'no_account' : 'send_blocked');
929
+ }
930
+
931
+ // Poll the rented number's SMS stream for the BigBasket OTP.
932
+ const end = Date.now() + this.deadlineMs;
933
+ let otp = null;
934
+ while (Date.now() < end && !this.stopped) {
935
+ const { otp: o, arrived } = await stream.poll();
936
+ if (arrived) smsArrived.v = true;
937
+ if (o) { otp = o; break; }
938
+ this._emit(slot, { phase: 'await_otp', detail: 'waiting for OTP', wait: Math.floor((end - Date.now()) / 1000) });
939
+ await sleep(1000);
940
+ }
941
+ if (!otp) return fail(`no OTP within ${Math.round(this.deadlineMs / 1000)}s — abandoned`, 'timeout');
942
+
943
+ this._emit(slot, { phase: 'verify', detail: 'verifying OTP' });
944
+ const v = await bigbasket.verifyOtp(session, otp);
945
+ if (!v.ok) {
946
+ const wrong = /valid otp|incorrect|invalid|expired/i.test(v.error || '');
947
+ return fail(`verify: ${v.error}`, wrong ? 'wrong_otp' : 'failed');
948
+ }
949
+
950
+ res.status = 'success';
951
+ res.cost = number.cost;
952
+ res.session = bigbasket.toSessionKeys(session); // { phone, bbAuthToken, mId, bbVisitorId }
953
+ res.category = 'extracted';
954
+
955
+ this._emit(slot, { phase: 'saving', detail: 'saving session' });
956
+ releaseOnce(true, 'extracted', true); // success: OTP consumed → no cancel
957
+ return res;
958
+ } catch (e) {
959
+ return fail(`unexpected: ${e.message}`);
960
+ } finally {
961
+ releaseOnce(false);
962
+ if (stream) try { stream.close(); } catch { /* */ }
963
+ }
964
+ }
965
+ }