scripter-x 1.0.17 → 1.0.20

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.17",
3
+ "version": "1.0.20",
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';
@@ -122,14 +122,16 @@ async function buildProvider(io, name) {
122
122
  io.print(' ✓ OTPCart connected');
123
123
  if (freshlyEntered && await io.confirm('save these creds locally for next time?', true)) config.setMany({ otpcart_email: email, otpcart_password: password });
124
124
  const deep = await io.confirm('deep number check?', false);
125
- return new oc.OTPCartProvider(jwt, deep);
125
+ // Pass creds so the provider can transparently re-login if OTPCart's single active
126
+ // session is taken over by another login mid-run (otherwise the OTP WS goes silent).
127
+ return new oc.OTPCartProvider(jwt, deep, undefined, { email, password });
126
128
  }
127
129
 
128
130
  export async function run(io, api, args = {}) {
129
131
  // TARGET: Flipkart (the backend campaign flow) or Zepto (local OTP → ZAUTH1 envelope).
130
132
  const target = args.target || (args.number ? 'zepto' : (await io.select('what to extract?', [
131
133
  { label: 'Flipkart', value: 'flipkart', description: 'session JSON via OTP provider (campaign)' },
132
- { label: 'Zepto', value: 'zepto', description: 'local OTP login ZAUTH1 envelope for AuthManager' }])).value);
134
+ { label: 'Zepto', value: 'zepto', description: 'headless extraction (auto) OR local OTP login (manual)' }])).value);
133
135
  if (target === 'zepto') return zeptoCmd(io, api, args);
134
136
 
135
137
  api = api || await getApi(io);
@@ -219,6 +221,59 @@ export async function run(io, api, args = {}) {
219
221
  if (worker.logPath) io.print(` ◉ log saved to: ${worker.logPath}`);
220
222
  }
221
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
+
222
277
  // ── zepto: local OTP login → extract session to a ZAUTH1 envelope file ──
223
278
  // Dead-simple, no backend: enter a number → we SMS an OTP locally → enter the
224
279
  // code → we verify, build the ZAUTH1 envelope, and write {phone}-{timestamp}.txt.
@@ -337,7 +392,9 @@ export async function zeptoCmd(io, api, args = {}) {
337
392
  if (!(await io.confirm(`start ${count} Zepto auto extractions at concurrency ${concurrency}?`, true))) return;
338
393
 
339
394
  const ZEPTO_SERVICE_ID = '68b2cf55980e8cf480b28c96';
340
- const provider = new oc.OTPCartProvider(jwt, false, ZEPTO_SERVICE_ID);
395
+ // Pass creds so the provider can transparently re-login if OTPCart invalidates the
396
+ // session (another login with the same account takes it over → WS stops pushing OTPs).
397
+ const provider = new oc.OTPCartProvider(jwt, false, ZEPTO_SERVICE_ID, { email, password });
341
398
 
342
399
  const { ZeptoWorker } = await import('./worker.js');
343
400
  const { RunController } = await import('./controller.js');
@@ -545,7 +602,7 @@ export async function configCmd(io, _api, args = {}) {
545
602
  }
546
603
 
547
604
  export function help(io) {
548
- 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');
549
606
  }
550
607
 
551
608
  export async function update(io) {
@@ -558,6 +615,6 @@ export async function update(io) {
558
615
 
559
616
  // dispatch table used by the interactive shell
560
617
  export const REGISTRY = {
561
- run, zepto: zeptoCmd, campaigns, export: exportCmd, balance, creds, stop, delete: del,
618
+ run, zepto: zeptoCmd, recheck, campaigns, export: exportCmd, balance, creds, stop, delete: del,
562
619
  whoami, login, logout, config: configCmd, update, help,
563
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];
@@ -1,13 +1,33 @@
1
1
  // OTPCart provider (api.otpcart.xyz) — email/password → JWT, WebSocket OTP push.
2
+ //
3
+ // ⚠️ OTPCart allows only ONE active session per account: a fresh login (anyone,
4
+ // anywhere, with the same email/password) INVALIDATES the previous JWT. When that
5
+ // happens mid-run the symptom is a *silently dead* run — rent/cancel start returning
6
+ // 401 and, worst of all, the OTP WebSocket connects fine but never pushes any
7
+ // `otpMessage`, so OTPs simply never arrive.
8
+ //
9
+ // To survive that, the provider holds the credentials and can transparently
10
+ // re-login (de-duped, one at a time) to mint a fresh JWT, then everything —
11
+ // HTTP calls and NEW WebSocket connections — reads the *current* token. Callers
12
+ // that hit an auth failure trigger relogin() and retry.
2
13
  import WebSocket from 'ws';
3
14
 
4
- const BASE = 'https://api.otpcart.xyz';
5
- const WS_URL = 'wss://api.otpcart.xyz/check-otp';
15
+ // Overridable for tests (defaults to the real hosts).
16
+ const BASE = process.env.OTPCART_BASE || 'https://api.otpcart.xyz';
17
+ const WS_URL = process.env.OTPCART_WS || 'wss://api.otpcart.xyz/check-otp';
6
18
  const SERVICE_ID = '68b19097980e8cf480b1df04';
7
19
  const OTP_RE = /\b(\d{6})\b/;
8
20
  const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
9
21
  '(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36';
10
22
 
23
+ // Heuristic: does an OTPCart response (status + body message) look like the token
24
+ // was rejected / the session was taken over by another login?
25
+ function isAuthFailure(status, message) {
26
+ if (status === 401 || status === 403) return true;
27
+ const m = String(message || '').toLowerCase();
28
+ return /unauthor|jwt|token|please login|log\s?in again|session expired|invalid signature/.test(m);
29
+ }
30
+
11
31
  export async function login(email, password) {
12
32
  const res = await fetch(`${BASE}/users/login`, {
13
33
  method: 'POST', headers: { 'Content-Type': 'application/json', Origin: 'https://www.otpcart.xyz', 'User-Agent': UA },
@@ -22,10 +42,19 @@ export async function balance() { return null; }
22
42
 
23
43
  export class OTPCartProvider {
24
44
  name = 'otpcart';
25
- constructor(jwt, deepCheck = false, serviceId = SERVICE_ID) {
45
+
46
+ // Backward compatible: (jwt, deepCheck, serviceId). Pass creds via the 4th arg
47
+ // { email, password } to enable transparent re-login when the session is taken
48
+ // over by another login.
49
+ constructor(jwt, deepCheck = false, serviceId = SERVICE_ID, creds = null) {
26
50
  this.jwt = jwt;
27
51
  this.deepCheck = deepCheck;
28
52
  this.serviceId = serviceId;
53
+ this.email = creds?.email || null;
54
+ this.password = creds?.password || null;
55
+ this._reloginInflight = null; // shared promise so concurrent slots relogin once
56
+ this._tokenEpoch = 0; // bumps on every successful relogin
57
+ this.onNotice = null; // optional (msg) => void for UI breadcrumbs
29
58
  }
30
59
 
31
60
  _hdrs() {
@@ -33,15 +62,62 @@ export class OTPCartProvider {
33
62
  Origin: 'https://www.otpcart.xyz', 'User-Agent': UA };
34
63
  }
35
64
 
65
+ get token() { return this.jwt; }
66
+ get tokenEpoch() { return this._tokenEpoch; }
67
+ get canRelogin() { return !!(this.email && this.password); }
68
+
69
+ // Re-login to mint a fresh JWT after the session was invalidated. De-duped:
70
+ // concurrent callers share ONE in-flight login so they don't invalidate each
71
+ // other in a relogin storm. `seenEpoch` lets a caller say "only relogin if the
72
+ // token I was using is still the current one" (avoids redundant relogins when
73
+ // another slot already refreshed it).
74
+ async relogin(seenEpoch = null) {
75
+ if (!this.canRelogin) throw new Error('OTPCart session invalidated and no saved credentials to re-login');
76
+ if (seenEpoch != null && seenEpoch !== this._tokenEpoch) {
77
+ // Someone already refreshed since this caller's token — use the new one.
78
+ return this.jwt;
79
+ }
80
+ if (this._reloginInflight) return this._reloginInflight;
81
+ this._reloginInflight = (async () => {
82
+ try {
83
+ if (this.onNotice) this.onNotice('OTPCart session was taken over — re-logging in…');
84
+ const tok = await login(this.email, this.password);
85
+ this.jwt = tok;
86
+ this._tokenEpoch++;
87
+ return tok;
88
+ } finally {
89
+ this._reloginInflight = null;
90
+ }
91
+ })();
92
+ return this._reloginInflight;
93
+ }
94
+
95
+ // POST a JSON body to OTPCart, transparently re-logging-in + retrying ONCE on an
96
+ // auth failure. Returns { status, data }.
97
+ async _post(path, body, timeoutMs = 15000) {
98
+ const epoch = this._tokenEpoch;
99
+ const res = await fetch(`${BASE}${path}`, {
100
+ method: 'POST', headers: this._hdrs(), body: JSON.stringify(body),
101
+ signal: AbortSignal.timeout(timeoutMs),
102
+ });
103
+ const data = await res.json().catch(() => ({}));
104
+ if (isAuthFailure(res.status, data?.message) && this.canRelogin) {
105
+ await this.relogin(epoch);
106
+ const res2 = await fetch(`${BASE}${path}`, {
107
+ method: 'POST', headers: this._hdrs(), body: JSON.stringify(body),
108
+ signal: AbortSignal.timeout(timeoutMs),
109
+ });
110
+ const data2 = await res2.json().catch(() => ({}));
111
+ return { status: res2.status, data: data2 };
112
+ }
113
+ return { status: res.status, data };
114
+ }
115
+
36
116
  async rentOnce() {
37
- let d;
117
+ let status, d;
38
118
  try {
39
- const res = await fetch(`${BASE}/mobile/generate`, {
40
- method: 'POST', headers: this._hdrs(),
41
- body: JSON.stringify({ serviceId: this.serviceId, isDeepCheck: this.deepCheck }),
42
- signal: AbortSignal.timeout(15000),
43
- });
44
- d = await res.json();
119
+ ({ status, data: d } = await this._post('/mobile/generate',
120
+ { serviceId: this.serviceId, isDeepCheck: this.deepCheck }));
45
121
  } catch (e) { return { number: null, msg: e.message, err: e }; }
46
122
  if (d.isNumberGenerated && d.mobile?.mobileno) {
47
123
  const m = d.mobile;
@@ -50,55 +126,103 @@ export class OTPCartProvider {
50
126
  return { number: null, msg: d.message || 'no number available', err: 'err' };
51
127
  }
52
128
 
53
- startOtp(number) { return new OTPCartStream(this.jwt, number.txn, number.extra); }
129
+ startOtp(number) { return new OTPCartStream(this, number.txn, number.extra); }
54
130
 
55
131
  async cancel(number) {
56
132
  try {
57
- await fetch(`${BASE}/otp/cancelOtp`, {
58
- method: 'POST', headers: this._hdrs(),
59
- body: JSON.stringify({ serialNumber: number.txn, mobileId: number.extra, serviceId: this.serviceId }),
60
- signal: AbortSignal.timeout(12000),
61
- });
133
+ await this._post('/otp/cancelOtp',
134
+ { serialNumber: number.txn, mobileId: number.extra, serviceId: this.serviceId }, 12000);
62
135
  } catch { /* */ }
63
136
  }
64
137
  }
65
138
 
139
+ // Grace period after the WS connects with no OTP push before we suspect the token
140
+ // is dead and reconnect with a freshly re-logged-in token. The real SMS usually
141
+ // lands well within this; a *dead-token* socket NEVER pushes, so this is what
142
+ // rescues an otherwise-silent run.
143
+ const WS_SILENT_RELOGIN_MS = 25000;
144
+
66
145
  class OTPCartStream {
67
- constructor(jwt, serial, mobileId) {
146
+ // Takes the PROVIDER (not a frozen jwt) so every (re)connect reads the current
147
+ // token and can ask the provider to re-login when the session was taken over.
148
+ constructor(provider, serial, mobileId) {
149
+ this.provider = provider;
68
150
  this.serial = serial;
69
151
  this.mobileId = mobileId;
70
152
  this.otp = null;
71
153
  this.arrived = false;
72
154
  this.closed = false;
73
- this.jwt = jwt;
74
- this._openP = null; // resolves when the WS is OPEN
155
+ this._openP = null; // resolves when the WS is OPEN
156
+ this._reconnectTimer = null;
157
+ this._connectedAt = 0;
158
+ this._silentTimer = null; // fires if no OTP arrives → relogin + reconnect
159
+ this._reloggingIn = false;
160
+ this._silentReloginsLeft = 1; // recover a taken-over session once; don't churn a slow SMS
75
161
  this._connect();
76
162
  }
77
163
 
164
+ _currentToken() { return this.provider.token; }
165
+
166
+ _clearSilentTimer() {
167
+ if (this._silentTimer) { clearTimeout(this._silentTimer); this._silentTimer = null; }
168
+ }
169
+
170
+ // Schedule the "this socket has gone quiet — maybe the token is dead" check.
171
+ // Only while we still have a relogin budget (a genuinely slow SMS shouldn't keep
172
+ // rotating the shared token, which would force every other slot to reconnect).
173
+ _armSilentTimer() {
174
+ this._clearSilentTimer();
175
+ if (this.closed || this.otp || !this.provider.canRelogin || this._silentReloginsLeft <= 0) return;
176
+ this._silentTimer = setTimeout(() => this._handleSilent(), WS_SILENT_RELOGIN_MS);
177
+ }
178
+
179
+ async _handleSilent() {
180
+ if (this.closed || this.otp || this._reloggingIn || this._silentReloginsLeft <= 0) return;
181
+ this._silentReloginsLeft--;
182
+ this._reloggingIn = true;
183
+ try {
184
+ // Re-login (de-duped at the provider) to get a token that the WS will accept,
185
+ // then reconnect this socket with it. If the token was actually fine, this is
186
+ // a cheap no-op reconnect that costs nothing but a fresh socket.
187
+ const epoch = this.provider.tokenEpoch;
188
+ await this.provider.relogin(epoch);
189
+ } catch { /* keep the old socket; nothing better to do */ }
190
+ finally { this._reloggingIn = false; }
191
+ if (this.closed || this.otp) return;
192
+ try { if (this.ws) this.ws.terminate(); } catch { /* */ }
193
+ this._connect(); // reconnect with the (possibly refreshed) current token
194
+ }
195
+
78
196
  _connect() {
79
- this.ws = new WebSocket(`${WS_URL}?token=${this.jwt}`);
197
+ const token = this._currentToken();
198
+ this.ws = new WebSocket(`${WS_URL}?token=${token}`);
80
199
  this._openP = new Promise((resolve) => {
81
200
  this.ws.once('open', resolve);
82
201
  this.ws.once('error', resolve); // resolve anyway so ready() never hangs
83
202
  });
203
+ this.ws.on('open', () => {
204
+ this._connectedAt = Date.now();
205
+ this._armSilentTimer(); // start the dead-token watchdog
206
+ });
84
207
  this.ws.on('message', (raw) => {
85
208
  let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
86
209
  // OTP frame: {message, otpMessage}. Ack frame {serialNumber,mobileId,resend} is ignored.
87
210
  if (msg.otpMessage) {
88
211
  const m = String(msg.otpMessage).match(OTP_RE);
89
- if (m) { this.otp = m[1]; this.arrived = true; }
212
+ if (m) { this.otp = m[1]; this.arrived = true; this._clearSilentTimer(); }
90
213
  }
91
214
  });
92
215
  this.ws.on('error', () => { /* swallow — poll() returns nothing; we auto-reconnect */ });
93
216
  // Auto-reconnect if the socket drops before the OTP arrives (so a dropped WS doesn't
94
217
  // silently lose the push — this is the "purchased but never got OTP" symptom).
95
218
  this.ws.on('close', () => {
96
- if (this.closed || this.otp) return;
97
- setTimeout(() => { if (!this.closed && !this.otp) this._connect(); }, 1000);
219
+ this._clearSilentTimer();
220
+ if (this.closed || this.otp || this._reloggingIn) return;
221
+ this._reconnectTimer = setTimeout(() => { if (!this.closed && !this.otp) this._connect(); }, 1000);
98
222
  });
99
223
  }
100
224
 
101
- // Wait until the WS is OPEN (so we never send the FK OTP before we can receive the push).
225
+ // Wait until the WS is OPEN (so we never send the OTP before we can receive the push).
102
226
  async ready(timeoutMs = 8000) {
103
227
  await Promise.race([this._openP, new Promise((r) => setTimeout(r, timeoutMs))]);
104
228
  return this.ws.readyState === WebSocket.OPEN;
@@ -122,5 +246,10 @@ class OTPCartStream {
122
246
  return false;
123
247
  }
124
248
 
125
- close() { this.closed = true; try { this.ws.close(); } catch { /* */ } }
249
+ close() {
250
+ this.closed = true;
251
+ this._clearSilentTimer();
252
+ if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
253
+ try { this.ws.close(); } catch { /* */ }
254
+ }
126
255
  }
package/src/worker.js CHANGED
@@ -28,6 +28,8 @@ export class Worker {
28
28
  this.concurrency = Math.max(1, Math.min(concurrency, requested));
29
29
  this.checkMinutes = checkMinutes;
30
30
  this.onEvent = onEvent || (() => {});
31
+ // Surface OTPCart re-logins (session taken over by another login) in the UI.
32
+ if (provider && 'onNotice' in provider) provider.onNotice = (msg) => this.onEvent('warn', `⚠ ${msg}`);
31
33
  // onFailure({mobile, reason}) → Promise<boolean>: true = keep going, false = stop campaign.
32
34
  this.onFailure = onFailure || null;
33
35
  this.emailMode = !!emailMode; // also attach a newaddr.com email to each account
@@ -73,6 +75,11 @@ export class Worker {
73
75
  fail_reason: res.detail || '', session: res.session,
74
76
  minutes_checked: !!res.minutes_checked, coupon_eligible: res.coupon_eligible ?? undefined,
75
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,
76
83
  linked_email: res.linked_email ?? undefined,
77
84
  email_linked: res.email_linked ?? undefined,
78
85
  email_reason: res.email_reason ?? undefined,
@@ -214,8 +221,15 @@ export class Worker {
214
221
  if (this.checkMinutes) {
215
222
  this._emit(slot, { phase: 'minutes', detail: 'checking Minutes' });
216
223
  try {
217
- const avail = await fk.checkMinutes(session);
218
- res.minutes_checked = true; res.coupon_eligible = avail; res.has_minutes_order = !avail;
224
+ const m = await fk.checkMinutes(session);
225
+ // checkMinutes now returns { eligible, count, orders }; tolerate an old boolean too.
226
+ const eligible = typeof m === 'boolean' ? m : m.eligible;
227
+ res.minutes_checked = true;
228
+ res.coupon_eligible = eligible;
229
+ res.has_minutes_order = !eligible;
230
+ res.minutes_order_count = typeof m === 'boolean' ? (eligible ? 0 : 1) : m.count;
231
+ res.minutes_orders = typeof m === 'boolean' ? [] : (m.orders || []);
232
+ this._emit(slot, { phase: 'minutes', detail: eligible ? 'minutes-free (₹100 coupon)' : `has ${res.minutes_order_count} minutes order(s)` });
219
233
  } catch { /* non-fatal */ }
220
234
  }
221
235
 
@@ -466,6 +480,8 @@ export class ZeptoWorker {
466
480
  this.concurrency = Math.max(1, Math.min(concurrency, requested));
467
481
  this.certSha = certSha;
468
482
  this.onEvent = onEvent || (() => {});
483
+ // Surface OTPCart re-logins (session taken over by another login) in the UI.
484
+ if (provider && 'onNotice' in provider) provider.onNotice = (msg) => this.onEvent('warn', `⚠ ${msg}`);
469
485
  this.stats = { total: requested, succeeded: 0, failed: 0, cancelled: 0, generated: 0, attempts: 0, charges: 0 };
470
486
  this.slots = {};
471
487
  this.seq = 0;