scripter-x 1.0.18 → 1.0.21

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.18",
3
+ "version": "1.0.21",
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
@@ -10,7 +10,7 @@ import * as kuku from './providers/kuku.js';
10
10
  import * as zepto from './providers/zepto.js';
11
11
  import { homedir } from 'node:os';
12
12
  import { join, dirname } from 'node:path';
13
- import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
13
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
14
14
  import { Worker } from './worker.js';
15
15
  import { RunController } from './controller.js';
16
16
  import { STATUS } from './theme.js';
@@ -221,6 +221,59 @@ export async function run(io, api, args = {}) {
221
221
  if (worker.logPath) io.print(` ◉ log saved to: ${worker.logPath}`);
222
222
  }
223
223
 
224
+ // ── recheck: re-validate Minutes status of an EXISTING session JSON, locally ──
225
+ // FK blocks server IPs, so the Minutes check must run here (residential IP). Takes
226
+ // a file (array of sessions, OR our {session:{...}} export shape), re-checks each
227
+ // account's live Minutes orders, and re-splits into correct -minutes-free /
228
+ // -minutes-used files with real stats + the actual order ids.
229
+ export async function recheck(io, _api, args = {}) {
230
+ // one-shot puts the positional path in action/campaign; interactive may pass file/_
231
+ const src = args.file || args._?.[0] || args.action || args.campaign;
232
+ if (!src || !existsSync(src)) throw new Error('usage: recheck <sessions.json>');
233
+ const raw = JSON.parse(readFileSync(src, 'utf8'));
234
+ const list = Array.isArray(raw) ? raw : (raw.accounts || raw.sessions || [raw]);
235
+ // normalize each entry to a flat FK session object
236
+ const sessions = list.map((e) => (e && e.session && typeof e.session === 'object' ? e.session : e)).filter(Boolean);
237
+ if (!sessions.length) throw new Error('no sessions found in file');
238
+
239
+ const { FlipkartLogin } = await import('./flipkart.js');
240
+ const fk = new FlipkartLogin();
241
+
242
+ io.print(` ◉ re-checking ${sessions.length} account(s) locally (your IP) …`);
243
+ const free = [], used = [], errored = [];
244
+ for (let i = 0; i < sessions.length; i++) {
245
+ const s = sessions[i];
246
+ const mob = s.mobileNo || s.mobile || `#${i + 1}`;
247
+ try {
248
+ const m = await fk.checkMinutes(s);
249
+ if (m.eligible) { free.push(s); io.print(` 🟢 ${mob} — minutes-free (₹100 coupon)`); }
250
+ else {
251
+ used.push(s);
252
+ io.print(` 🔴 ${mob} — ${m.count} minutes order(s): ${m.orders.map((o) => o.orderId).join(', ')}`, 'danger');
253
+ }
254
+ } catch (e) { errored.push(s); io.print(` ! ${mob} — check failed: ${e.message}`, 'danger'); }
255
+ }
256
+
257
+ // write corrected files next to the source
258
+ const dir = dirname(src);
259
+ const stem = src.split(/[/\\]/).pop().replace(/\.json$/i, '').replace(/-minutes-(free|used|unchecked)$/i, '');
260
+ const write = (suffix, arr) => {
261
+ if (!arr.length) return null;
262
+ const dest = join(dir, `${stem}-rechecked${suffix}.json`);
263
+ writeFileSync(dest, JSON.stringify(arr, null, 2));
264
+ return dest;
265
+ };
266
+ const fFree = write('-minutes-free', free);
267
+ const fUsed = write('-minutes-used', used);
268
+ const fErr = write('-errors', errored);
269
+
270
+ io.print('');
271
+ io.print(` ✓ stats: 🟢 ${free.length} minutes-free · 🔴 ${used.length} minutes-used · ! ${errored.length} errors`);
272
+ if (fFree) io.print(` 🟢 minutes-free → ${fFree}`, 'accent');
273
+ if (fUsed) io.print(` 🔴 minutes-used → ${fUsed}`, 'accent');
274
+ if (fErr) io.print(` ! errors → ${fErr}`, 'accent');
275
+ }
276
+
224
277
  // ── zepto: local OTP login → extract session to a ZAUTH1 envelope file ──
225
278
  // Dead-simple, no backend: enter a number → we SMS an OTP locally → enter the
226
279
  // code → we verify, build the ZAUTH1 envelope, and write {phone}-{timestamp}.txt.
@@ -549,7 +602,7 @@ export async function configCmd(io, _api, args = {}) {
549
602
  }
550
603
 
551
604
  export function help(io) {
552
- io.print(' commands: run · zepto · campaigns · export · balance · creds · stop · delete · whoami · config · update · logout · exit');
605
+ io.print(' commands: run · zepto · recheck · campaigns · export · balance · creds · stop · delete · whoami · config · update · logout · exit');
553
606
  }
554
607
 
555
608
  export async function update(io) {
@@ -562,6 +615,6 @@ export async function update(io) {
562
615
 
563
616
  // dispatch table used by the interactive shell
564
617
  export const REGISTRY = {
565
- run, zepto: zeptoCmd, campaigns, export: exportCmd, balance, creds, stop, delete: del,
618
+ run, zepto: zeptoCmd, recheck, campaigns, export: exportCmd, balance, creds, stop, delete: del,
566
619
  whoami, login, logout, config: configCmd, update, help,
567
620
  };
package/src/flipkart.js CHANGED
@@ -29,6 +29,40 @@ const nativeUa = (vid) =>
29
29
  `Mozilla/5.0 (Linux; Android 13; sdk_gphone64_arm64 Build/TE1A.240213.009) ` +
30
30
  `FKUA/Retail/3130902/Android/Mobile (Google/sdk_gphone64_arm64/${(vid || '').toLowerCase()})`;
31
31
 
32
+ // Parse a MY_ORDER_PAGE response and return ONLY genuine Flipkart-Minutes orders.
33
+ // An order is a slot whose widget is an ORDER CARD (SUPER_WIDGET +
34
+ // ORDER_CARD_SUPER_WIDGET_TRANSFORMER); its id is widget.params.trackingParams.orderId
35
+ // (exactly one per card). It's a MINUTES order iff the card's redirect carries
36
+ // queryParam.hyperlocal === "true" — Grocery/Kilos/memberships are NOT hyperlocal,
37
+ // so they're excluded even in an unfiltered response. (Verified against captured
38
+ // ground-truth: 0-Minutes accounts → 0, 1/2-Minutes accounts → exact match.)
39
+ // Returns [{ orderId, title, status }].
40
+ export function parseMinutesOrders(text) {
41
+ let env;
42
+ try { env = JSON.parse(text); } catch { return []; }
43
+ const resp = (env && env.RESPONSE) || env || {};
44
+ const slots = Array.isArray(resp.slots) ? resp.slots : [];
45
+ const seen = new Set();
46
+ const out = [];
47
+ for (const s of slots) {
48
+ const w = (s && s.widget) || {};
49
+ if (w.type !== 'SUPER_WIDGET' || w.transformerProvider !== 'ORDER_CARD_SUPER_WIDGET_TRANSFORMER') continue;
50
+ const tp = (w.params && w.params.trackingParams) || {};
51
+ const orderId = tp.orderId;
52
+ if (!orderId || seen.has(orderId)) continue;
53
+ let isMinutes = false, title = '';
54
+ try {
55
+ const rc = w.data.subWidgets[0].data.buttons.renderableComponents[0];
56
+ isMinutes = rc.action.nonWidgetizeRedirection.queryParam.hyperlocal === 'true';
57
+ title = (rc.value && rc.value.subText) || '';
58
+ } catch { /* malformed card → not a counted Minutes order */ }
59
+ if (!isMinutes) continue;
60
+ seen.add(orderId);
61
+ out.push({ orderId, title, status: tp.orderStatus || '' });
62
+ }
63
+ return out;
64
+ }
65
+
32
66
  // Parse a fetch Response's set-cookie headers into a {name:value} map.
33
67
  function parseCookies(res, jar) {
34
68
  // Node fetch exposes combined set-cookie via getSetCookie() (undici).
@@ -138,7 +172,9 @@ export class FlipkartLogin {
138
172
  };
139
173
  }
140
174
 
141
- async checkMinutes(session) {
175
+ // One MY_ORDER_PAGE / HYPERLOCAL fetch on a given DC. `withRT` attaches the refresh
176
+ // token so a 206 "AT expired" can be recovered in-place. Returns { status, text }.
177
+ async _fetchOrderPage(session, dc, withRT) {
142
178
  await throttle.wait();
143
179
  const body = {
144
180
  requestContext: { type: 'MY_ORDER_PAGE', pageView: '', queryTime: '', cxTenant: 'cs',
@@ -148,19 +184,50 @@ export class FlipkartLogin {
148
184
  paginatedFetch: false, pageNumber: 1, fetchAllPages: false, networkSpeed: 0,
149
185
  trackingContext: null, fetchSeoData: false },
150
186
  locationContext: { pincode: '' } };
151
- const res = await fetch(`${NATIVE_HOST}/4/page/fetch`, {
152
- method: 'POST', body: JSON.stringify(body),
153
- headers: {
154
- 'User-Agent': 'okhttp/4.9.2', 'X-User-Agent': nativeUa(this.vid),
155
- 'Content-Type': 'application/json; charset=UTF-8',
156
- 'x-request-metaInfo': '{"actionType":"LOGIN","pageUri":"questContext"}',
157
- at: session.at || '', sn: session.sn || '',
158
- secureToken: session.secureToken || '', secureCookie: session.secureCookie || '',
159
- },
160
- });
161
- const text = await res.text();
162
- const count = (text.match(/OD\d{15,}/g) || []).length;
163
- return count === 0; // no orders ⇒ coupon available
187
+ const host = `https://${dc}.rome.api.flipkart.net`;
188
+ const at = session.at || '';
189
+ const headers = {
190
+ 'User-Agent': 'okhttp/4.9.2', 'X-User-Agent': nativeUa(session.visitId || this.vid),
191
+ 'Content-Type': 'application/json; charset=UTF-8',
192
+ 'x-request-metaInfo': '{"actionType":"LOGIN","pageUri":"questContext"}',
193
+ at, sn: session.sn || '',
194
+ secureToken: session.secureToken || '', secureCookie: session.secureCookie || '',
195
+ Cookie: `ud=${session.ud || ''}`,
196
+ };
197
+ if (withRT && session.rt) headers.rt = session.rt;
198
+ const res = await fetch(`${host}/4/page/fetch`, { method: 'POST', body: JSON.stringify(body), headers });
199
+ return { status: res.status, text: await res.text() };
200
+ }
201
+
202
+ // Robust FK Minutes check. Handles 206 (AT-expired → resend with rt) and 406
203
+ // (DC change → retry on home DC), then parses ORDER CARDS (not a blind regex)
204
+ // and gates each on its per-card `hyperlocal` flag — so non-Minutes orders
205
+ // (Grocery/Kilos/memberships) and page chrome never count.
206
+ // Returns { eligible, count, orders:[{orderId,title,status}] }.
207
+ async checkMinutes(session) {
208
+ let dc = 2;
209
+ const triedDC = { 2: true };
210
+ let r = await this._fetchOrderPage(session, dc, false);
211
+
212
+ // 206 "AT expired" → resend with rt on the same DC
213
+ if (r.status === 206 || /AT expired/i.test(r.text)) {
214
+ r = await this._fetchOrderPage(session, dc, true);
215
+ // adopt the refreshed token for any follow-up DC retry
216
+ try { const e = JSON.parse(r.text); if (e?.SESSION?.at) session = { ...session, at: e.SESSION.at }; } catch { /* */ }
217
+ }
218
+ // 406 DC change → {RESPONSE:{id:"<dc>"}} → retry on the home DC
219
+ if (r.status === 406) {
220
+ let id = null;
221
+ try { const e = JSON.parse(r.text); id = parseInt(e?.RESPONSE?.id || e?.id, 10); } catch { /* */ }
222
+ if (id >= 1 && !triedDC[id]) {
223
+ dc = id; triedDC[id] = true;
224
+ r = await this._fetchOrderPage(session, dc, false);
225
+ if (r.status === 206 || /AT expired/i.test(r.text)) r = await this._fetchOrderPage(session, dc, true);
226
+ }
227
+ }
228
+
229
+ const orders = parseMinutesOrders(r.text);
230
+ return { eligible: orders.length === 0, count: orders.length, orders };
164
231
  }
165
232
 
166
233
  // ─── Email attach (FK-5/6/7) — on the EMAIL host (2.rome.api.flipkart.com) ──────
package/src/index.js CHANGED
@@ -54,6 +54,8 @@ const HELP = `${paint.bold('scripterx')} — local Flipkart session extractor
54
54
  --auto | --manual --count N --concurrency N --out <file|dir> --cert <64hex>
55
55
  --number <10-digit> --otp <code>
56
56
  (envelope keyed to ZeptoAuthManager cert; override with --cert or $ZAUTH_CERT_SHA)
57
+ scripterx recheck <file> re-validate Minutes status of a session JSON, locally (your IP)
58
+ → re-splits into corrected -minutes-free / -minutes-used files + stats
57
59
  scripterx campaigns | export [name] | balance | creds | stop | delete | whoami | config
58
60
  scripterx --version`;
59
61
 
@@ -104,7 +106,7 @@ async function main() {
104
106
  }
105
107
 
106
108
  // ── one-shot mode ──
107
- const map = { run: 'run', zepto: 'zepto', campaigns: 'campaigns', export: 'export', balance: 'balance',
109
+ const map = { run: 'run', zepto: 'zepto', recheck: 'recheck', campaigns: 'campaigns', export: 'export', balance: 'balance',
108
110
  creds: 'creds', stop: 'stop', delete: 'delete', whoami: 'whoami', login: 'login',
109
111
  logout: 'logout', config: 'config', update: 'update', help: 'help' };
110
112
  const fnName = map[cmd];
@@ -128,11 +128,30 @@ export class OTPCartProvider {
128
128
 
129
129
  startOtp(number) { return new OTPCartStream(this, number.txn, number.extra); }
130
130
 
131
+ // Cancel (release) a rented number. Returns a structured outcome instead of
132
+ // swallowing everything, so the worker can tell apart:
133
+ // { ok: true } → number released
134
+ // { ok: false, tooEarly: true, ... } → still inside OTPCart's ~2min cancel lock; retry later
135
+ // { ok: false, ... } → other error; retry
136
+ // OTPCart refuses to cancel a number rented < ~2 min ago; the worker must wait
137
+ // out that lock and keep retrying, otherwise the number stays rented (charged).
131
138
  async cancel(number) {
139
+ let status, d;
132
140
  try {
133
- await this._post('/otp/cancelOtp',
134
- { serialNumber: number.txn, mobileId: number.extra, serviceId: this.serviceId }, 12000);
135
- } catch { /* */ }
141
+ ({ status, data: d } = await this._post('/otp/cancelOtp',
142
+ { serialNumber: number.txn, mobileId: number.extra, serviceId: this.serviceId }, 12000));
143
+ } catch (e) {
144
+ return { ok: false, tooEarly: false, msg: e.message };
145
+ }
146
+ const msg = String(d?.message || '');
147
+ const low = msg.toLowerCase();
148
+ // success: doc says "Otp canceled successfully!!!"
149
+ if (status >= 200 && status < 300 && /cancel|success|refund/.test(low)) {
150
+ return { ok: true, msg };
151
+ }
152
+ // the cancel-lock window: messages like "you can cancel after 2 minutes" / "wait".
153
+ const tooEarly = /(\d+\s*min|after|wait|too soon|not allowed yet|before)/.test(low);
154
+ return { ok: false, tooEarly, msg: msg || `cancel failed (status ${status})` };
136
155
  }
137
156
  }
138
157
 
@@ -141,6 +160,8 @@ export class OTPCartProvider {
141
160
  // lands well within this; a *dead-token* socket NEVER pushes, so this is what
142
161
  // rescues an otherwise-silent run.
143
162
  const WS_SILENT_RELOGIN_MS = 25000;
163
+ const WS_PING_MS = 20000; // keep-alive so an idle socket isn't dropped before the SMS lands
164
+ const WS_DEBUG = process.env.OTPCART_WS_DEBUG === '1';
144
165
 
145
166
  class OTPCartStream {
146
167
  // Takes the PROVIDER (not a frozen jwt) so every (re)connect reads the current
@@ -156,16 +177,22 @@ class OTPCartStream {
156
177
  this._reconnectTimer = null;
157
178
  this._connectedAt = 0;
158
179
  this._silentTimer = null; // fires if no OTP arrives → relogin + reconnect
180
+ this._pingTimer = null; // keep-alive
159
181
  this._reloggingIn = false;
160
182
  this._silentReloginsLeft = 1; // recover a taken-over session once; don't churn a slow SMS
183
+ this._gotServerAck = false; // server sent the {serialNumber,mobileId} connect frame
161
184
  this._connect();
162
185
  }
163
186
 
164
187
  _currentToken() { return this.provider.token; }
188
+ _dbg(...a) { if (WS_DEBUG) { try { console.error('[otpcart-ws]', ...a); } catch { /* */ } } }
165
189
 
166
190
  _clearSilentTimer() {
167
191
  if (this._silentTimer) { clearTimeout(this._silentTimer); this._silentTimer = null; }
168
192
  }
193
+ _clearPingTimer() {
194
+ if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
195
+ }
169
196
 
170
197
  // Schedule the "this socket has gone quiet — maybe the token is dead" check.
171
198
  // Only while we still have a relogin budget (a genuinely slow SMS shouldn't keep
@@ -180,6 +207,7 @@ class OTPCartStream {
180
207
  if (this.closed || this.otp || this._reloggingIn || this._silentReloginsLeft <= 0) return;
181
208
  this._silentReloginsLeft--;
182
209
  this._reloggingIn = true;
210
+ this._dbg('silent watchdog fired — relogin + reconnect');
183
211
  try {
184
212
  // Re-login (de-duped at the provider) to get a token that the WS will accept,
185
213
  // then reconnect this socket with it. If the token was actually fine, this is
@@ -193,30 +221,77 @@ class OTPCartStream {
193
221
  this._connect(); // reconnect with the (possibly refreshed) current token
194
222
  }
195
223
 
224
+ // The browser sends an Origin (and a UA) on the WS handshake. The `ws` library
225
+ // sends NEITHER by default — and OTPCart's server appears to only attach the OTP
226
+ // listener for browser-origin sockets, so a header-less socket connects but never
227
+ // receives a push. Mirroring the browser handshake is the actual fix for the
228
+ // "site gets the OTP, the script doesn't" symptom.
229
+ _wsOptions() {
230
+ return {
231
+ headers: {
232
+ Origin: 'https://www.otpcart.xyz',
233
+ 'User-Agent': UA,
234
+ },
235
+ // also keeps proxies from dropping us; ws sends its own pongs automatically
236
+ handshakeTimeout: 15000,
237
+ };
238
+ }
239
+
240
+ _subscribe() {
241
+ // Echo the ack frame the browser sends back to bind this socket to our number.
242
+ // Harmless if the server doesn't require it; necessary if it does.
243
+ try {
244
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
245
+ this.ws.send(JSON.stringify({ serialNumber: this.serial, mobileId: this.mobileId, resend: false }));
246
+ this._dbg('sent subscribe', this.serial, this.mobileId);
247
+ }
248
+ } catch { /* */ }
249
+ }
250
+
196
251
  _connect() {
197
252
  const token = this._currentToken();
198
- this.ws = new WebSocket(`${WS_URL}?token=${token}`);
253
+ this._dbg('connecting', WS_URL, 'token', String(token).slice(0, 10) + '…');
254
+ this.ws = new WebSocket(`${WS_URL}?token=${token}`, this._wsOptions());
255
+ this._gotServerAck = false;
199
256
  this._openP = new Promise((resolve) => {
200
257
  this.ws.once('open', resolve);
201
258
  this.ws.once('error', resolve); // resolve anyway so ready() never hangs
202
259
  });
203
260
  this.ws.on('open', () => {
204
261
  this._connectedAt = Date.now();
262
+ this._dbg('open');
263
+ this._subscribe(); // bind this socket to our number (browser parity)
205
264
  this._armSilentTimer(); // start the dead-token watchdog
265
+ this._clearPingTimer();
266
+ this._pingTimer = setInterval(() => { try { this.ws.ping(); } catch { /* */ } }, WS_PING_MS);
206
267
  });
207
268
  this.ws.on('message', (raw) => {
208
- let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
209
- // OTP frame: {message, otpMessage}. Ack frame {serialNumber,mobileId,resend} is ignored.
269
+ const text = raw.toString();
270
+ this._dbg('recv', text.slice(0, 160));
271
+ let msg; try { msg = JSON.parse(text); } catch { return; }
272
+ // OTP frame: {message, otpMessage}.
210
273
  if (msg.otpMessage) {
211
274
  const m = String(msg.otpMessage).match(OTP_RE);
212
- if (m) { this.otp = m[1]; this.arrived = true; this._clearSilentTimer(); }
275
+ if (m) { this.otp = m[1]; this.arrived = true; this._clearSilentTimer(); this._dbg('OTP', m[1]); }
276
+ return;
277
+ }
278
+ // Ack frame: {serialNumber, mobileId, resend}. Confirms the socket is bound.
279
+ // Adopt the server's serial/mobileId in case they differ from ours.
280
+ if (msg.serialNumber || msg.mobileId) {
281
+ this._gotServerAck = true;
282
+ if (msg.serialNumber) this.serial = msg.serialNumber;
283
+ if (msg.mobileId) this.mobileId = msg.mobileId;
284
+ this._dbg('server ack', this.serial, this.mobileId);
213
285
  }
214
286
  });
215
- this.ws.on('error', () => { /* swallow — poll() returns nothing; we auto-reconnect */ });
287
+ this.ws.on('pong', () => this._dbg('pong'));
288
+ this.ws.on('error', (e) => { this._dbg('error', e?.message); /* swallow — poll() returns nothing; we auto-reconnect */ });
216
289
  // Auto-reconnect if the socket drops before the OTP arrives (so a dropped WS doesn't
217
290
  // silently lose the push — this is the "purchased but never got OTP" symptom).
218
- this.ws.on('close', () => {
291
+ this.ws.on('close', (code) => {
292
+ this._dbg('close', code);
219
293
  this._clearSilentTimer();
294
+ this._clearPingTimer();
220
295
  if (this.closed || this.otp || this._reloggingIn) return;
221
296
  this._reconnectTimer = setTimeout(() => { if (!this.closed && !this.otp) this._connect(); }, 1000);
222
297
  });
@@ -240,6 +315,7 @@ class OTPCartStream {
240
315
  try {
241
316
  if (this.ws.readyState === WebSocket.OPEN) {
242
317
  this.ws.send(JSON.stringify({ serialNumber: this.serial, mobileId: this.mobileId, resend: true }));
318
+ this._dbg('sent resend');
243
319
  return true;
244
320
  }
245
321
  } catch { /* */ }
@@ -249,6 +325,7 @@ class OTPCartStream {
249
325
  close() {
250
326
  this.closed = true;
251
327
  this._clearSilentTimer();
328
+ this._clearPingTimer();
252
329
  if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
253
330
  try { this.ws.close(); } catch { /* */ }
254
331
  }
@@ -3,9 +3,9 @@ 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 };
6
+ export const SERVICES = { '1040': 10.0, '940': 16.0, '2451': 12.5, '2484': 18.0 };
7
7
  // optional friendly names shown in the service picker
8
- export const SERVICE_NAMES = { '1040': 'Flipkart', '940': 'Flipkart', '2451': 'Shopsy/Flipkart 1 (SERVER 16)' };
8
+ export const SERVICE_NAMES = { '1040': 'Flipkart', '940': 'Flipkart', '2451': 'Shopsy/Flipkart 1 (SERVER 16)', '2484': 'ALL 6 DIGIT (SERVER 17 [ALL])' };
9
9
  export const DEFAULT_SERVICE = '940';
10
10
  const COUNTRY = '22';
11
11
 
package/src/worker.js CHANGED
@@ -75,6 +75,11 @@ export class Worker {
75
75
  fail_reason: res.detail || '', session: res.session,
76
76
  minutes_checked: !!res.minutes_checked, coupon_eligible: res.coupon_eligible ?? undefined,
77
77
  has_minutes_order: res.has_minutes_order ?? undefined,
78
+ // pack the local Minutes order count + ids into minutes_note (free-text the
79
+ // server already stores) so stats/export survive the round-trip.
80
+ minutes_note: res.minutes_checked
81
+ ? (res.minutes_order_count ? `${res.minutes_order_count} minutes order(s): ${(res.minutes_orders || []).map((o) => o.orderId).join(',')}` : 'no minutes orders')
82
+ : undefined,
78
83
  linked_email: res.linked_email ?? undefined,
79
84
  email_linked: res.email_linked ?? undefined,
80
85
  email_reason: res.email_reason ?? undefined,
@@ -136,7 +141,7 @@ export class Worker {
136
141
  if (!number) { res.status = 'cancelled'; res.detail = 'stopped while renting'; return res; }
137
142
  if (this.stats.succeeded >= this.requested) {
138
143
  this.log(`goal reached by another slot; releasing ${number.mobile} immediately`);
139
- this._release(number, Date.now());
144
+ this._release(number, Date.now(), slot);
140
145
  this._emit(slot, { phase: 'done', detail: 'goal reached' });
141
146
  res.status = 'cancelled';
142
147
  res.detail = 'goal reached';
@@ -155,15 +160,19 @@ export class Worker {
155
160
  // releaseOnce — the single chokepoint that schedules the provider cancel. Called from
156
161
  // every terminal path (fail, success, error). Without this a blocked/errored number
157
162
  // could stay rented → we'd be charged.
158
- const releaseOnce = (charged = false, doneDetail = 'done') => {
163
+ const releaseOnce = (charged = false, doneDetail = 'done', consumed = false) => {
159
164
  if (released) return;
160
165
  released = true;
161
- if (charged) {
166
+ // `consumed` = the OTP was actually used (success) → cancelling is pointless.
167
+ // A charged-but-not-consumed number (SMS arrived, login failed) normally isn't
168
+ // worth cancelling either (no refund) — EXCEPT on an explicit stop, where we
169
+ // ALWAYS attempt the cancel so no rented number is left active when the user bails.
170
+ if (consumed || (charged && !this.stopped)) {
162
171
  this._emit(slot, { phase: 'done', detail: doneDetail });
163
172
  return;
164
173
  }
165
174
  this._emit(slot, { phase: 'cancelling', detail: 'releasing number' });
166
- this._release(number, rentedAt);
175
+ this._release(number, rentedAt, slot);
167
176
  };
168
177
 
169
178
  const fail = (detail) => {
@@ -216,8 +225,15 @@ export class Worker {
216
225
  if (this.checkMinutes) {
217
226
  this._emit(slot, { phase: 'minutes', detail: 'checking Minutes' });
218
227
  try {
219
- const avail = await fk.checkMinutes(session);
220
- res.minutes_checked = true; res.coupon_eligible = avail; res.has_minutes_order = !avail;
228
+ const m = await fk.checkMinutes(session);
229
+ // checkMinutes now returns { eligible, count, orders }; tolerate an old boolean too.
230
+ const eligible = typeof m === 'boolean' ? m : m.eligible;
231
+ res.minutes_checked = true;
232
+ res.coupon_eligible = eligible;
233
+ res.has_minutes_order = !eligible;
234
+ res.minutes_order_count = typeof m === 'boolean' ? (eligible ? 0 : 1) : m.count;
235
+ res.minutes_orders = typeof m === 'boolean' ? [] : (m.orders || []);
236
+ this._emit(slot, { phase: 'minutes', detail: eligible ? 'minutes-free (₹100 coupon)' : `has ${res.minutes_order_count} minutes order(s)` });
221
237
  } catch { /* non-fatal */ }
222
238
  }
223
239
 
@@ -249,7 +265,7 @@ export class Worker {
249
265
  res.session = session;
250
266
  const doneDetail = res.email_linked ? `extracted + ${res.linked_email}` : 'extracted';
251
267
  this._emit(slot, { phase: 'done', detail: doneDetail });
252
- releaseOnce(true, doneDetail); // success still releases the number (OTP already consumed)
268
+ releaseOnce(true, doneDetail, true); // success: OTP consumed no cancel
253
269
  return res;
254
270
  } catch (e) {
255
271
  // ANY unexpected error → still release the number so we're never charged for a leak
@@ -432,35 +448,71 @@ export class Worker {
432
448
  }
433
449
 
434
450
  // _release — GUARANTEE the rented number is cancelled so we're never charged for a
435
- // number that didn't succeed. TempOTP can't be cancelled before its 2-min lock, so we
436
- // wait that out first. The cancel is RETRIED up to 5× until it confirms; the outcome is
437
- // logged. The returned promise is tracked so the campaign waits for it before finishing.
438
- _release(number, rentedAt) {
451
+ // number that didn't succeed. Both TempOTP and OTPCart enforce a ~2-min cancel lock,
452
+ // so we wait that out, then RETRY until the provider confirms the cancel. The returned
453
+ // promise is tracked so the campaign waits for it before finishing (incl. on ESC/stop).
454
+ _release(number, rentedAt, slot) {
439
455
  if (!this._releases) this._releases = [];
440
- const p = (async () => {
441
- if (this.provider.name === 'tempotp') {
442
- const wait = NUMBER_CANCEL_LOCK - (Date.now() - rentedAt);
443
- if (wait > 0) await sleep(wait);
444
- }
445
- for (let attempt = 1; attempt <= 5; attempt++) {
446
- try {
447
- await this.provider.cancel(number);
448
- this.log(`cancelled ${number.mobile} (txn ${number.txn}) — refunded`);
449
- return;
450
- } catch (e) {
451
- this.log(`cancel ${number.mobile} attempt ${attempt}/5 failed: ${e.message}`);
452
- await sleep(3000);
453
- }
454
- }
455
- // Could NOT cancel after 5 tries — flag it loudly so the user knows a number may be charged.
456
- this.log(`⚠ COULD NOT CANCEL ${number.mobile} (txn ${number.txn}) after 5 tries — may be charged! cancel manually on the provider.`);
457
- this.onEvent('warn', `⚠ couldn't cancel ${number.mobile.slice(-10)} — cancel it on the provider to avoid a charge`);
458
- })();
456
+ const p = releaseNumber(this.provider, number, rentedAt, {
457
+ log: (m) => this.log(m),
458
+ warn: (m) => this.onEvent('warn', m),
459
+ onWait: slot ? (sec) => this._emit(slot, { phase: 'cancelling', detail: 'releasing (cancel-lock)', wait: sec }) : undefined,
460
+ });
459
461
  this._releases.push(p);
460
462
  return p;
461
463
  }
462
464
  }
463
465
 
466
+ // Shared release routine for both workers. Waits out the provider's cancel lock,
467
+ // then retries until the cancel is CONFIRMED (the provider returns ok), honouring a
468
+ // `tooEarly` response by waiting and trying again rather than giving up. Never throws.
469
+ async function releaseNumber(provider, number, rentedAt, { log = () => {}, warn = () => {}, onWait = () => {} } = {}) {
470
+ // OTPCart AND TempOTP both refuse to cancel a number rented < ~2 min ago.
471
+ const sinceRent = Date.now() - (rentedAt || Date.now());
472
+ const initialWait = NUMBER_CANCEL_LOCK - sinceRent;
473
+ if (initialWait > 0) {
474
+ log(`waiting ${Math.ceil(initialWait / 1000)}s for ${number.mobile} cancel-lock to expire…`);
475
+ // Tick down a visible countdown so ESC/stop doesn't look frozen during the wait.
476
+ const until = Date.now() + initialWait;
477
+ while (Date.now() < until) {
478
+ onWait(Math.ceil((until - Date.now()) / 1000));
479
+ await sleep(1000);
480
+ }
481
+ }
482
+ // Retry generously — we MUST land the cancel or the number stays charged. ~3 min of
483
+ // retries past the lock covers transient errors and a slightly-longer server lock.
484
+ const deadline = Date.now() + 180_000;
485
+ let attempt = 0;
486
+ while (Date.now() < deadline) {
487
+ attempt++;
488
+ let r;
489
+ try {
490
+ r = await provider.cancel(number);
491
+ } catch (e) {
492
+ r = { ok: false, tooEarly: false, msg: e.message };
493
+ }
494
+ if (r && r.ok) {
495
+ log(`cancelled ${number.mobile} (txn ${number.txn}) — refunded`);
496
+ return true;
497
+ }
498
+ const why = r?.msg || 'unknown error';
499
+ if (r?.tooEarly) {
500
+ log(`cancel ${number.mobile}: still locked (${why}) — retrying in 15s`);
501
+ await sleep(15_000);
502
+ } else {
503
+ log(`cancel ${number.mobile} attempt ${attempt} failed: ${why} — retrying in 5s`);
504
+ await sleep(5_000);
505
+ }
506
+ }
507
+ // Could NOT cancel — flag it loudly so the user knows a number may be charged.
508
+ log(`⚠ COULD NOT CANCEL ${number.mobile} (txn ${number.txn}) — may be charged! cancel manually on the provider.`);
509
+ warn(`⚠ couldn't cancel ${number.mobile.slice(-10)} — cancel it on the provider to avoid a charge`);
510
+ return false;
511
+ }
512
+
513
+ // Exposed for tests only.
514
+ export const __releaseNumberForTest = releaseNumber;
515
+
464
516
  export class ZeptoWorker {
465
517
  constructor(provider, { requested, concurrency, certSha, onEvent }) {
466
518
  this.provider = provider;
@@ -506,25 +558,25 @@ export class ZeptoWorker {
506
558
  async run() {
507
559
  const loops = Array.from({ length: this.concurrency }, (_, i) => this._loop(i + 1));
508
560
  await Promise.all(loops);
561
+ // Don't report "done" until EVERY rented number is actually cancelled — including
562
+ // numbers still inside their 2-min cancel lock. This is what makes ESC/stop wait
563
+ // for the last taken number to be released properly.
509
564
  if (this._releases.length) {
565
+ this.onEvent('warn', `finishing up — releasing ${this._releases.length} number(s)…`);
510
566
  await Promise.allSettled(this._releases);
511
567
  }
512
568
  this.onEvent('done', this.stats);
513
569
  return this.stats;
514
570
  }
515
571
 
516
- _release(number) {
517
- const p = (async () => {
518
- for (let attempt = 1; attempt <= 5; attempt++) {
519
- try {
520
- await this.provider.cancel(number);
521
- return;
522
- } catch {
523
- await sleep(3000);
524
- }
525
- }
526
- })();
572
+ _release(number, rentedAt, slot) {
573
+ const p = releaseNumber(this.provider, number, rentedAt, {
574
+ log: () => {},
575
+ warn: (m) => this.onEvent('warn', m),
576
+ onWait: slot ? (sec) => this._emit(slot, { phase: 'cancelling', detail: 'releasing (cancel-lock)', wait: sec }) : undefined,
577
+ });
527
578
  this._releases.push(p);
579
+ return p;
528
580
  }
529
581
 
530
582
  async _rent(slot) {
@@ -569,29 +621,32 @@ export class ZeptoWorker {
569
621
  this._emit(slot, { mobile: '', phase: 'renting', detail: 'requesting a number', wait: 0 });
570
622
  const number = await this._rent(slot);
571
623
  if (!number) { res.status = 'cancelled'; res.detail = 'stopped while renting'; return res; }
624
+ const rentedAt = Date.now();
572
625
  if (this.stats.succeeded >= this.requested) {
573
- this._release(number);
626
+ this._release(number, rentedAt, slot);
574
627
  this._emit(slot, { phase: 'done', detail: 'goal reached' });
575
628
  res.status = 'cancelled';
576
629
  res.detail = 'goal reached';
577
630
  return res;
578
631
  }
579
- const rentedAt = Date.now();
580
632
  res.mobile = number.mobile;
581
633
  this._emit(slot, { mobile: number.mobile, phase: 'rented', detail: 'got a number' });
582
634
 
583
635
  let stream = null;
584
636
  let released = false;
585
637
 
586
- const releaseOnce = (charged = false, doneDetail = 'done') => {
638
+ const releaseOnce = (charged = false, doneDetail = 'done', consumed = false) => {
587
639
  if (released) return;
588
640
  released = true;
589
- if (charged) {
641
+ // `consumed` = OTP used (success) → no point cancelling. On an explicit stop we
642
+ // ALWAYS attempt the cancel for a non-consumed number (even if charged) so none
643
+ // is left active when the user bails.
644
+ if (consumed || (charged && !this.stopped)) {
590
645
  this._emit(slot, { phase: 'done', detail: doneDetail });
591
646
  return;
592
647
  }
593
648
  this._emit(slot, { phase: 'cancelling', detail: 'releasing number' });
594
- this._release(number);
649
+ this._release(number, rentedAt, slot);
595
650
  };
596
651
 
597
652
  const fail = (detail) => {
@@ -633,12 +688,14 @@ export class ZeptoWorker {
633
688
  const envelope = zepto.encodeEnvelope(zepto.toSessionKeys(session), this.certSha);
634
689
  this._emit(slot, { phase: 'saving', detail: 'encoding envelope' });
635
690
  res.status = 'success'; res.cost = number.cost; res.session = zepto.toSessionKeys(session); res.envelope = envelope;
636
- releaseOnce(true, 'extracted');
691
+ releaseOnce(true, 'extracted', true); // success: OTP consumed → no cancel
637
692
  return res;
638
693
  } catch (e) {
639
694
  return fail(`unexpected: ${e.message}`);
640
695
  } finally {
641
- releaseOnce(true);
696
+ // Catch-all: if neither success nor fail released it (shouldn't happen), make sure
697
+ // the number is cancelled rather than left rented.
698
+ releaseOnce(false);
642
699
  if (stream) try { stream.close(); } catch { /* */ }
643
700
  }
644
701
  }