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 +1 -1
- package/src/commands.js +56 -3
- package/src/flipkart.js +81 -14
- package/src/index.js +3 -1
- package/src/providers/otpcart.js +86 -9
- package/src/providers/tempotp.js +2 -2
- package/src/worker.js +105 -48
package/package.json
CHANGED
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
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
return
|
|
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];
|
package/src/providers/otpcart.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
209
|
-
|
|
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('
|
|
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
|
}
|
package/src/providers/tempotp.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
220
|
-
|
|
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
|
|
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
|
|
436
|
-
// wait that out
|
|
437
|
-
//
|
|
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 = (
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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 = (
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|