scripter-x 1.0.17 → 1.0.18

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.18",
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
@@ -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);
@@ -337,7 +339,9 @@ export async function zeptoCmd(io, api, args = {}) {
337
339
  if (!(await io.confirm(`start ${count} Zepto auto extractions at concurrency ${concurrency}?`, true))) return;
338
340
 
339
341
  const ZEPTO_SERVICE_ID = '68b2cf55980e8cf480b28c96';
340
- const provider = new oc.OTPCartProvider(jwt, false, ZEPTO_SERVICE_ID);
342
+ // Pass creds so the provider can transparently re-login if OTPCart invalidates the
343
+ // session (another login with the same account takes it over → WS stops pushing OTPs).
344
+ const provider = new oc.OTPCartProvider(jwt, false, ZEPTO_SERVICE_ID, { email, password });
341
345
 
342
346
  const { ZeptoWorker } = await import('./worker.js');
343
347
  const { RunController } = await import('./controller.js');
@@ -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
@@ -466,6 +468,8 @@ export class ZeptoWorker {
466
468
  this.concurrency = Math.max(1, Math.min(concurrency, requested));
467
469
  this.certSha = certSha;
468
470
  this.onEvent = onEvent || (() => {});
471
+ // Surface OTPCart re-logins (session taken over by another login) in the UI.
472
+ if (provider && 'onNotice' in provider) provider.onNotice = (msg) => this.onEvent('warn', `⚠ ${msg}`);
469
473
  this.stats = { total: requested, succeeded: 0, failed: 0, cancelled: 0, generated: 0, attempts: 0, charges: 0 };
470
474
  this.slots = {};
471
475
  this.seq = 0;