scripter-x 1.0.2 → 1.0.7

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.
@@ -32,6 +32,9 @@ export function FullScreen({ children }) {
32
32
  }
33
33
 
34
34
  // enterAltScreen / leaveAltScreen — call around render(). Returns a cleanup fn.
35
+ // CRITICAL: restore() must ALSO disable mouse tracking — otherwise on any abrupt exit the
36
+ // shell is left with mouse mode on and spews `35;91;28M…` coordinate codes on every move.
37
+ // We register restore on exit + the kill signals so it runs no matter how we leave.
35
38
  export function enterAltScreen() {
36
39
  process.stdout.write('\x1b[?1049h'); // switch to alternate buffer
37
40
  process.stdout.write('\x1b[2J\x1b[H'); // clear + home
@@ -40,9 +43,14 @@ export function enterAltScreen() {
40
43
  const restore = () => {
41
44
  if (restored) return;
42
45
  restored = true;
43
- process.stdout.write('\x1b[?25h'); // show cursor
44
- process.stdout.write('\x1b[?1049l'); // back to normal buffer (history intact)
46
+ try {
47
+ process.stdout.write('\x1b[?1000l\x1b[?1003l\x1b[?1006l'); // disable ALL mouse modes
48
+ process.stdout.write('\x1b[?25h'); // show cursor
49
+ process.stdout.write('\x1b[?1049l'); // back to normal buffer (history intact)
50
+ } catch { /* */ }
45
51
  };
46
52
  process.on('exit', restore);
53
+ process.on('SIGTERM', () => { restore(); process.exit(0); });
54
+ process.on('SIGHUP', () => { restore(); process.exit(0); });
47
55
  return restore;
48
56
  }
package/src/worker.js CHANGED
@@ -3,6 +3,8 @@
3
3
  // goal = N JSONs, 4× attempt cap; refund rule (charge only if SMS arrived);
4
4
  // OTP timing + wrong-OTP resend; every result pushed to the server (ingest).
5
5
  import { FlipkartLogin, WrongOTP, BlockedError, TransientError } from './flipkart.js';
6
+ import { CampaignLogger } from './logger.js';
7
+ import * as kuku from './providers/kuku.js';
6
8
 
7
9
  const OTP_RESEND_AFTER = 60_000;
8
10
  const OTPCART_DEADLINE = 120_000;
@@ -17,7 +19,7 @@ const ATTEMPT_CAP_MULT = 4;
17
19
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
18
20
 
19
21
  export class Worker {
20
- constructor(api, provider, { campaignId, requested, concurrency, checkMinutes, onEvent, onFailure }) {
22
+ constructor(api, provider, { campaignId, requested, concurrency, checkMinutes, onEvent, onFailure, name, emailMode, kukuAccount }) {
21
23
  this.api = api;
22
24
  this.provider = provider;
23
25
  this.cid = campaignId;
@@ -27,12 +29,19 @@ export class Worker {
27
29
  this.onEvent = onEvent || (() => {});
28
30
  // onFailure({mobile, reason}) → Promise<boolean>: true = keep going, false = stop campaign.
29
31
  this.onFailure = onFailure || null;
32
+ this.emailMode = !!emailMode; // also attach a newaddr.com email to each account
33
+ this.kukuAccount = kukuAccount || null; // ONE shared premium kuku account for all emails
34
+ this._kukuLock = Promise.resolve(); // serialize email generation (one premium account)
30
35
  this.stats = { total: requested, succeeded: 0, failed: 0, cancelled: 0, generated: 0, attempts: 0, charges: 0 };
31
36
  this.slots = {};
32
37
  this.seq = 0;
33
38
  this.stopped = false;
39
+ this.logger = new CampaignLogger({ campaignId, name: name || campaignId, provider: provider.name });
34
40
  }
35
41
 
42
+ // append one line to the campaign log file (clean, timestamped)
43
+ log(line) { try { this.logger.write(line); } catch { /* */ } }
44
+
36
45
  stop() { this.stopped = true; }
37
46
  _nextSeq() { return ++this.seq; }
38
47
 
@@ -61,14 +70,21 @@ export class Worker {
61
70
  fail_reason: res.detail || '', session: res.session,
62
71
  minutes_checked: !!res.minutes_checked, coupon_eligible: res.coupon_eligible ?? undefined,
63
72
  has_minutes_order: res.has_minutes_order ?? undefined,
73
+ linked_email: res.linked_email ?? undefined,
74
+ email_linked: res.email_linked ?? undefined,
75
+ email_reason: res.email_reason ?? undefined,
64
76
  });
65
77
  } catch (e) { this.onEvent('warn', `ingest failed: ${e.message}`); }
66
78
  }
67
79
 
68
80
  async run() {
69
81
  await this.api.markRunning(this.cid);
82
+ this.log(`campaign started — goal ${this.requested} JSONs, concurrency ${this.concurrency}`);
70
83
  const loops = Array.from({ length: this.concurrency }, (_, i) => this._loop(i + 1));
71
84
  await Promise.all(loops);
85
+ // Wait for any in-flight background releases to finish BEFORE we report done, so the
86
+ // log + summary reflect that every rented number was actually cancelled.
87
+ await this._drainReleases();
72
88
  const status = this.stopped ? 'cancelled' : 'completed';
73
89
  const s = this.stats;
74
90
  try {
@@ -77,10 +93,20 @@ export class Worker {
77
93
  numbers_generated: s.generated, attempts: s.attempts, charges: s.charges, status,
78
94
  });
79
95
  } catch (e) { this.onEvent('warn', `finish failed: ${e.message}`); }
96
+ try { this.logger.summary(s); } catch { /* */ }
97
+ this.logPath = this.logger.path;
80
98
  this.onEvent('done', s);
81
99
  return s;
82
100
  }
83
101
 
102
+ // track + await background number-cancel goroutines so the campaign doesn't "finish"
103
+ // while a number is still being released (which would risk a charge leak going unlogged).
104
+ async _drainReleases() {
105
+ if (!this._releases || !this._releases.length) return;
106
+ this.log(`waiting for ${this._releases.length} pending number cancellation(s)…`);
107
+ await Promise.allSettled(this._releases);
108
+ }
109
+
84
110
  async _loop(slot) {
85
111
  for (;;) {
86
112
  if (this._claim() === 0) return;
@@ -99,45 +125,65 @@ export class Worker {
99
125
  const rentedAt = Date.now();
100
126
  res.mobile = number.mobile;
101
127
  this._emit(slot, { mobile: number.mobile, phase: 'rented', detail: 'got a number' });
128
+ this.log(`rented ${number.mobile} (txn ${number.txn}, cost ₹${number.cost})`);
102
129
 
103
130
  const smsArrived = { v: false };
104
131
  const fk = new FlipkartLogin();
105
132
  let stream = null;
133
+ let released = false; // GUARANTEE: a rented number is released exactly once, no leak
134
+
135
+ // releaseOnce — the single chokepoint that schedules the provider cancel. Called from
136
+ // every terminal path (fail, success, error). Without this a blocked/errored number
137
+ // could stay rented → we'd be charged.
138
+ const releaseOnce = () => {
139
+ if (released) return;
140
+ released = true;
141
+ this._emit(slot, { phase: 'cancelling', detail: 'releasing number' });
142
+ this._release(number, rentedAt);
143
+ };
106
144
 
107
145
  const fail = (detail) => {
108
146
  const charged = smsArrived.v;
109
147
  res.status = charged ? 'failed' : 'cancelled';
110
148
  res.cost = charged ? number.cost : 0;
111
149
  res.detail = detail;
112
- this._emit(slot, { phase: 'cancelling', detail: 'releasing number' });
113
- this._release(number, rentedAt);
150
+ this.log(`FAILED ${number.mobile}: ${detail} (charged: ${charged})`);
151
+ releaseOnce();
114
152
  return res;
115
153
  };
116
154
 
117
155
  try {
118
156
  this._emit(slot, { phase: 'ws', detail: 'opening OTP channel' });
119
157
  stream = this.provider.startOtp(number);
158
+ // WAIT for the OTP channel to be OPEN before triggering the SMS — otherwise the OTP
159
+ // push can arrive before the WS is listening and be lost (the "purchased but no OTP"
160
+ // bug). OTPCart streams expose ready(); TempOTP (HTTP poll) has no ready() → skip.
161
+ if (typeof stream.ready === 'function') await stream.ready();
120
162
 
163
+ // 1. SEND — retry 3× on transient (529/throttle/network) with a 2s gap.
121
164
  this._emit(slot, { phase: 'send_otp', detail: 'sending OTP' });
122
- try { await fk.sendOtp(number.mobile); }
123
- catch (e) { return fail(`send: ${e.message}`); }
165
+ const sent = await this._fkRetry(slot, 'send OTP', () => fk.sendOtp(number.mobile), number);
166
+ if (!sent.ok) {
167
+ if (sent.stop) { this.stopped = true; return fail(`send blocked: ${sent.error}`); }
168
+ return fail(`send: ${sent.error}`);
169
+ }
124
170
 
171
+ // 2. AWAIT OTP
125
172
  const deadline = this.provider.name === 'tempotp' ? TEMPOTP_DEADLINE : OTPCART_DEADLINE;
126
173
  const doResend = this.provider.name === 'otpcart';
127
174
  const otp = await this._awaitOtp(slot, stream, fk, number, deadline, doResend, smsArrived);
128
175
  if (!otp) return fail(`no OTP within ${deadline / 1000}s — abandoned`);
129
176
 
177
+ // 3. VERIFY (already retries transients internally via _verify; re-verify on blockage)
130
178
  this._emit(slot, { phase: 'verify', detail: 'verifying OTP' });
131
179
  const { session, error: verifyErr } = await this._verify(slot, fk, stream, number, otp, smsArrived);
132
180
  if (!session) {
133
- // surface the REAL Flipkart reason, and (since the OTP SMS was delivered) charge it.
134
181
  const failed = fail(verifyErr || 'verify failed');
135
- // ask the user whether to keep going after a failure (one prompt at a time)
136
182
  if (this.onFailure && !this.stopped && !this._asking) {
137
183
  this._asking = true;
138
184
  try {
139
185
  const cont = await this.onFailure({ mobile: number.mobile, reason: verifyErr || 'verify failed' });
140
- if (!cont) this.stopped = true; // stop the whole campaign
186
+ if (!cont) this.stopped = true;
141
187
  } finally { this._asking = false; }
142
188
  }
143
189
  return failed;
@@ -151,16 +197,119 @@ export class Worker {
151
197
  } catch { /* non-fatal */ }
152
198
  }
153
199
 
200
+ // 5. EMAIL LINK (optional) — keep the SAME number's OTP stream alive: FK-6 triggers a
201
+ // 2nd phone OTP that arrives on `stream`, plus an email OTP from kuku. Partial-success:
202
+ // if linking fails, the JSON is STILL a success (email_linked=false + reason).
203
+ if (this.emailMode) {
204
+ try {
205
+ const linked = await this._linkEmail(slot, fk, stream, number, session, smsArrived);
206
+ if (linked.email) {
207
+ session.email = linked.email;
208
+ session.emailVerified = !!linked.ok;
209
+ res.linked_email = linked.email;
210
+ res.email_linked = !!linked.ok;
211
+ if (!linked.ok) res.email_reason = linked.reason;
212
+ } else {
213
+ res.email_linked = false; res.email_reason = linked.reason;
214
+ }
215
+ } catch (e) {
216
+ res.email_linked = false; res.email_reason = e.message;
217
+ this.log(`email link ${number.mobile} failed (JSON still saved): ${e.message}`);
218
+ }
219
+ }
220
+
154
221
  this._emit(slot, { phase: 'saving', detail: 'saving session' });
155
222
  res.status = 'success'; res.cost = number.cost; res.session = session;
156
- this._emit(slot, { phase: 'done', detail: 'extracted' });
157
- this._release(number, rentedAt);
223
+ this.log(`SUCCESS ${number.mobile} session extracted (₹${number.cost})${res.email_linked ? ` + email ${res.linked_email}` : ''}`);
224
+ this._emit(slot, { phase: 'done', detail: res.email_linked ? `extracted + ${res.linked_email}` : 'extracted' });
225
+ releaseOnce(); // success still releases the number (OTP already consumed)
158
226
  return res;
227
+ } catch (e) {
228
+ // ANY unexpected error → still release the number so we're never charged for a leak
229
+ this.log(`ERROR ${number.mobile}: ${e.message}`);
230
+ return fail(`unexpected: ${e.message}`);
159
231
  } finally {
232
+ releaseOnce(); // belt-and-suspenders: never leave a rented number un-released
160
233
  if (stream) try { stream.close(); } catch { /* */ }
161
234
  }
162
235
  }
163
236
 
237
+ // _linkEmail — attach a newaddr.com email to the just-logged-in account.
238
+ // Returns { email, ok, reason }. The number's OTP `stream` is kept OPEN by the caller so
239
+ // the FK-6 phone OTP arrives on it; the email OTP comes from kuku (subject line).
240
+ async _linkEmail(slot, fk, stream, number, session, smsArrived) {
241
+ // FK-5: skip if an email is already attached
242
+ try {
243
+ const prof = await fk.profileInfo(session);
244
+ if (prof.emailAlready) return { email: prof.email || '', ok: true, reason: 'already linked' };
245
+ } catch { /* non-fatal — proceed to attach */ }
246
+
247
+ // 1. mint a newaddr.com email from the shared premium account (serialized — one account)
248
+ this._emit(slot, { phase: 'minutes', detail: 'generating email' });
249
+ let email;
250
+ const gen = this._kukuLock.then(() => kuku.generateEmail(this.kukuAccount, 'newaddr.com'));
251
+ this._kukuLock = gen.catch(() => {});
252
+ try { email = await gen; } catch (e) { return { email: '', ok: false, reason: `email gen: ${e.message}` }; }
253
+ this.log(`email ${number.mobile} → ${email}`);
254
+
255
+ // 2. FK-6: send OTP to BOTH the email and the phone (retry transients)
256
+ this._emit(slot, { phase: 'send_otp', detail: 'sending email+phone OTP' });
257
+ let reqs;
258
+ const sent = await this._fkRetry(slot, 'email OTP', async () => { reqs = await fk.sendEmailOtp(session, email); }, number);
259
+ if (!sent.ok) return { email, ok: false, reason: `email OTP send: ${sent.error}` };
260
+
261
+ // 3. catch BOTH OTPs in parallel: email from kuku, phone from the open stream
262
+ this._emit(slot, { phase: 'await_otp', detail: 'awaiting email + phone OTP' });
263
+ const emailOtpP = kuku.waitForOtp(this.kukuAccount, { toEmail: email, timeoutMs: 120000, pollMs: 4000 });
264
+ const phoneOtpP = this._awaitOtp(slot, stream, fk, number, 120000, false, smsArrived);
265
+ const [emailOtp, phoneOtp] = await Promise.all([emailOtpP, phoneOtpP]);
266
+ if (!emailOtp) return { email, ok: false, reason: 'no email OTP received' };
267
+ if (!phoneOtp) return { email, ok: false, reason: 'no phone OTP for email-link' };
268
+
269
+ // 4. FK-7: submit both OTPs
270
+ this._emit(slot, { phase: 'verify', detail: 'linking email' });
271
+ try {
272
+ await fk.verifyEmailOtp(session, {
273
+ email, emailOtp, emailReqId: reqs.emailReqId,
274
+ phoneUserId: reqs.phoneUserId, phoneOtp, phoneReqId: reqs.phoneReqId });
275
+ this.log(`EMAIL LINKED ${number.mobile} ↔ ${email}`);
276
+ return { email, ok: true };
277
+ } catch (e) {
278
+ return { email, ok: false, reason: `link verify: ${e.message}` };
279
+ }
280
+ }
281
+
282
+ // _fkRetry — run a Flipkart call up to 3× with a 2s gap on transient failures (529 /
283
+ // throttle / network). On final failure, asks the user continue-or-stop (one at a time).
284
+ // Returns { ok, error?, stop? }.
285
+ async _fkRetry(slot, label, fn, number) {
286
+ const MAX = 3, GAP = 2000;
287
+ let lastErr = '';
288
+ for (let attempt = 1; attempt <= MAX; attempt++) {
289
+ if (this.stopped) return { ok: false, error: 'stopped' };
290
+ try { await fn(); return { ok: true }; }
291
+ catch (e) {
292
+ lastErr = e.message || String(e);
293
+ const transient = e instanceof TransientError || e instanceof BlockedError;
294
+ this.log(`${label} attempt ${attempt}/${MAX} failed: ${lastErr}${transient ? ' (transient)' : ''}`);
295
+ if (!transient) return { ok: false, error: lastErr };
296
+ if (attempt < MAX) {
297
+ this._emit(slot, { phase: 'send_otp', detail: `Flipkart busy — retry ${attempt}/${MAX} in 2s` });
298
+ await sleep(GAP);
299
+ }
300
+ }
301
+ }
302
+ // all 3 attempts failed → ask the user whether to keep going
303
+ if (this.onFailure && !this.stopped && !this._asking) {
304
+ this._asking = true;
305
+ try {
306
+ const cont = await this.onFailure({ mobile: number?.mobile || '', reason: `${label} blocked after ${MAX} tries: ${lastErr}` });
307
+ if (!cont) return { ok: false, error: lastErr, stop: true };
308
+ } finally { this._asking = false; }
309
+ }
310
+ return { ok: false, error: lastErr };
311
+ }
312
+
164
313
  async _rent(slot) {
165
314
  let attempt = 0;
166
315
  while (!this.stopped) {
@@ -193,7 +342,8 @@ export class Worker {
193
342
  const rem = Math.floor((end - Date.now()) / 1000);
194
343
  if (doResend && !resent && Date.now() >= resendAt) {
195
344
  try { await fk.sendOtp(number.mobile); } catch { /* */ }
196
- stream.resend(); resent = true;
345
+ try { await stream.resend(); } catch { /* */ }
346
+ resent = true;
197
347
  this._emit(slot, { phase: 'otp_resend', detail: 'OTP resent', wait: rem });
198
348
  } else {
199
349
  this._emit(slot, { phase: 'await_otp', detail: 'waiting for OTP', wait: rem });
@@ -208,15 +358,20 @@ export class Worker {
208
358
  // not mislabeled as "OTP rejected".
209
359
  async _verify(slot, fk, stream, number, otp, smsArrived) {
210
360
  let lastErr = 'verify failed';
361
+ // RE-VERIFY on blockage: a 529/transient is NOT the OTP's fault, so retry the same
362
+ // (still-valid) OTP 3× with a 2s gap before giving up — never cancel a good number
363
+ // just because Flipkart hiccupped.
211
364
  for (let attempt = 1; attempt <= 3; attempt++) {
212
365
  try {
213
366
  const session = await fk.verifyOtp(number.mobile, otp);
367
+ if (attempt > 1) this.log(`verify ${number.mobile} succeeded on retry ${attempt}`);
214
368
  return { session, error: null };
215
369
  } catch (e) {
216
- if (e instanceof TransientError) {
370
+ if (e instanceof TransientError || e instanceof BlockedError) {
217
371
  lastErr = e.message;
218
- this._emit(slot, { phase: 'verify', detail: `Flipkart busy — retry ${attempt}/3` });
219
- await sleep(3000);
372
+ this.log(`verify ${number.mobile} transient attempt ${attempt}/3: ${lastErr} — re-verifying`);
373
+ this._emit(slot, { phase: 'verify', detail: `Flipkart busy — re-verify ${attempt}/3` });
374
+ if (attempt < 3) await sleep(2000);
220
375
  continue; // retry the SAME otp (it's still valid; the server just hiccupped)
221
376
  }
222
377
  if (e instanceof WrongOTP) {
@@ -233,7 +388,7 @@ export class Worker {
233
388
  async _resendAndVerify(slot, fk, stream, number, smsArrived, why) {
234
389
  this._emit(slot, { phase: 'otp_resend', detail: `${why} — resending`, wait: WRONG_OTP_WAIT / 1000 });
235
390
  try { await fk.sendOtp(number.mobile); } catch (e) { return { session: null, error: `resend failed: ${e.message}` }; }
236
- stream.resend();
391
+ try { await stream.resend(); } catch { /* */ }
237
392
  const end = Date.now() + WRONG_OTP_WAIT;
238
393
  let otp2 = null;
239
394
  while (Date.now() < end && !this.stopped) {
@@ -249,13 +404,32 @@ export class Worker {
249
404
  catch (e) { return { session: null, error: e.message }; }
250
405
  }
251
406
 
407
+ // _release — GUARANTEE the rented number is cancelled so we're never charged for a
408
+ // number that didn't succeed. TempOTP can't be cancelled before its 2-min lock, so we
409
+ // wait that out first. The cancel is RETRIED up to 5× until it confirms; the outcome is
410
+ // logged. The returned promise is tracked so the campaign waits for it before finishing.
252
411
  _release(number, rentedAt) {
253
- (async () => {
412
+ if (!this._releases) this._releases = [];
413
+ const p = (async () => {
254
414
  if (this.provider.name === 'tempotp') {
255
415
  const wait = NUMBER_CANCEL_LOCK - (Date.now() - rentedAt);
256
416
  if (wait > 0) await sleep(wait);
257
417
  }
258
- try { await this.provider.cancel(number); } catch { /* */ }
418
+ for (let attempt = 1; attempt <= 5; attempt++) {
419
+ try {
420
+ await this.provider.cancel(number);
421
+ this.log(`cancelled ${number.mobile} (txn ${number.txn}) — refunded`);
422
+ return;
423
+ } catch (e) {
424
+ this.log(`cancel ${number.mobile} attempt ${attempt}/5 failed: ${e.message}`);
425
+ await sleep(3000);
426
+ }
427
+ }
428
+ // Could NOT cancel after 5 tries — flag it loudly so the user knows a number may be charged.
429
+ this.log(`⚠ COULD NOT CANCEL ${number.mobile} (txn ${number.txn}) after 5 tries — may be charged! cancel manually on the provider.`);
430
+ this.onEvent('warn', `⚠ couldn't cancel ${number.mobile.slice(-10)} — cancel it on the provider to avoid a charge`);
259
431
  })();
432
+ this._releases.push(p);
433
+ return p;
260
434
  }
261
435
  }