scripter-x 1.0.20 → 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/providers/otpcart.js +86 -9
- package/src/providers/tempotp.js +2 -2
- package/src/worker.js +91 -46
package/package.json
CHANGED
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
|
@@ -141,7 +141,7 @@ export class Worker {
|
|
|
141
141
|
if (!number) { res.status = 'cancelled'; res.detail = 'stopped while renting'; return res; }
|
|
142
142
|
if (this.stats.succeeded >= this.requested) {
|
|
143
143
|
this.log(`goal reached by another slot; releasing ${number.mobile} immediately`);
|
|
144
|
-
this._release(number, Date.now());
|
|
144
|
+
this._release(number, Date.now(), slot);
|
|
145
145
|
this._emit(slot, { phase: 'done', detail: 'goal reached' });
|
|
146
146
|
res.status = 'cancelled';
|
|
147
147
|
res.detail = 'goal reached';
|
|
@@ -160,15 +160,19 @@ export class Worker {
|
|
|
160
160
|
// releaseOnce — the single chokepoint that schedules the provider cancel. Called from
|
|
161
161
|
// every terminal path (fail, success, error). Without this a blocked/errored number
|
|
162
162
|
// could stay rented → we'd be charged.
|
|
163
|
-
const releaseOnce = (charged = false, doneDetail = 'done') => {
|
|
163
|
+
const releaseOnce = (charged = false, doneDetail = 'done', consumed = false) => {
|
|
164
164
|
if (released) return;
|
|
165
165
|
released = true;
|
|
166
|
-
|
|
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)) {
|
|
167
171
|
this._emit(slot, { phase: 'done', detail: doneDetail });
|
|
168
172
|
return;
|
|
169
173
|
}
|
|
170
174
|
this._emit(slot, { phase: 'cancelling', detail: 'releasing number' });
|
|
171
|
-
this._release(number, rentedAt);
|
|
175
|
+
this._release(number, rentedAt, slot);
|
|
172
176
|
};
|
|
173
177
|
|
|
174
178
|
const fail = (detail) => {
|
|
@@ -261,7 +265,7 @@ export class Worker {
|
|
|
261
265
|
res.session = session;
|
|
262
266
|
const doneDetail = res.email_linked ? `extracted + ${res.linked_email}` : 'extracted';
|
|
263
267
|
this._emit(slot, { phase: 'done', detail: doneDetail });
|
|
264
|
-
releaseOnce(true, doneDetail); // success
|
|
268
|
+
releaseOnce(true, doneDetail, true); // success: OTP consumed → no cancel
|
|
265
269
|
return res;
|
|
266
270
|
} catch (e) {
|
|
267
271
|
// ANY unexpected error → still release the number so we're never charged for a leak
|
|
@@ -444,35 +448,71 @@ export class Worker {
|
|
|
444
448
|
}
|
|
445
449
|
|
|
446
450
|
// _release — GUARANTEE the rented number is cancelled so we're never charged for a
|
|
447
|
-
// number that didn't succeed. TempOTP
|
|
448
|
-
// wait that out
|
|
449
|
-
//
|
|
450
|
-
_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) {
|
|
451
455
|
if (!this._releases) this._releases = [];
|
|
452
|
-
const p = (
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
458
|
-
try {
|
|
459
|
-
await this.provider.cancel(number);
|
|
460
|
-
this.log(`cancelled ${number.mobile} (txn ${number.txn}) — refunded`);
|
|
461
|
-
return;
|
|
462
|
-
} catch (e) {
|
|
463
|
-
this.log(`cancel ${number.mobile} attempt ${attempt}/5 failed: ${e.message}`);
|
|
464
|
-
await sleep(3000);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
// Could NOT cancel after 5 tries — flag it loudly so the user knows a number may be charged.
|
|
468
|
-
this.log(`⚠ COULD NOT CANCEL ${number.mobile} (txn ${number.txn}) after 5 tries — may be charged! cancel manually on the provider.`);
|
|
469
|
-
this.onEvent('warn', `⚠ couldn't cancel ${number.mobile.slice(-10)} — cancel it on the provider to avoid a charge`);
|
|
470
|
-
})();
|
|
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
|
+
});
|
|
471
461
|
this._releases.push(p);
|
|
472
462
|
return p;
|
|
473
463
|
}
|
|
474
464
|
}
|
|
475
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
|
+
|
|
476
516
|
export class ZeptoWorker {
|
|
477
517
|
constructor(provider, { requested, concurrency, certSha, onEvent }) {
|
|
478
518
|
this.provider = provider;
|
|
@@ -518,25 +558,25 @@ export class ZeptoWorker {
|
|
|
518
558
|
async run() {
|
|
519
559
|
const loops = Array.from({ length: this.concurrency }, (_, i) => this._loop(i + 1));
|
|
520
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.
|
|
521
564
|
if (this._releases.length) {
|
|
565
|
+
this.onEvent('warn', `finishing up — releasing ${this._releases.length} number(s)…`);
|
|
522
566
|
await Promise.allSettled(this._releases);
|
|
523
567
|
}
|
|
524
568
|
this.onEvent('done', this.stats);
|
|
525
569
|
return this.stats;
|
|
526
570
|
}
|
|
527
571
|
|
|
528
|
-
_release(number) {
|
|
529
|
-
const p = (
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
} catch {
|
|
535
|
-
await sleep(3000);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
})();
|
|
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
|
+
});
|
|
539
578
|
this._releases.push(p);
|
|
579
|
+
return p;
|
|
540
580
|
}
|
|
541
581
|
|
|
542
582
|
async _rent(slot) {
|
|
@@ -581,29 +621,32 @@ export class ZeptoWorker {
|
|
|
581
621
|
this._emit(slot, { mobile: '', phase: 'renting', detail: 'requesting a number', wait: 0 });
|
|
582
622
|
const number = await this._rent(slot);
|
|
583
623
|
if (!number) { res.status = 'cancelled'; res.detail = 'stopped while renting'; return res; }
|
|
624
|
+
const rentedAt = Date.now();
|
|
584
625
|
if (this.stats.succeeded >= this.requested) {
|
|
585
|
-
this._release(number);
|
|
626
|
+
this._release(number, rentedAt, slot);
|
|
586
627
|
this._emit(slot, { phase: 'done', detail: 'goal reached' });
|
|
587
628
|
res.status = 'cancelled';
|
|
588
629
|
res.detail = 'goal reached';
|
|
589
630
|
return res;
|
|
590
631
|
}
|
|
591
|
-
const rentedAt = Date.now();
|
|
592
632
|
res.mobile = number.mobile;
|
|
593
633
|
this._emit(slot, { mobile: number.mobile, phase: 'rented', detail: 'got a number' });
|
|
594
634
|
|
|
595
635
|
let stream = null;
|
|
596
636
|
let released = false;
|
|
597
637
|
|
|
598
|
-
const releaseOnce = (charged = false, doneDetail = 'done') => {
|
|
638
|
+
const releaseOnce = (charged = false, doneDetail = 'done', consumed = false) => {
|
|
599
639
|
if (released) return;
|
|
600
640
|
released = true;
|
|
601
|
-
|
|
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)) {
|
|
602
645
|
this._emit(slot, { phase: 'done', detail: doneDetail });
|
|
603
646
|
return;
|
|
604
647
|
}
|
|
605
648
|
this._emit(slot, { phase: 'cancelling', detail: 'releasing number' });
|
|
606
|
-
this._release(number);
|
|
649
|
+
this._release(number, rentedAt, slot);
|
|
607
650
|
};
|
|
608
651
|
|
|
609
652
|
const fail = (detail) => {
|
|
@@ -645,12 +688,14 @@ export class ZeptoWorker {
|
|
|
645
688
|
const envelope = zepto.encodeEnvelope(zepto.toSessionKeys(session), this.certSha);
|
|
646
689
|
this._emit(slot, { phase: 'saving', detail: 'encoding envelope' });
|
|
647
690
|
res.status = 'success'; res.cost = number.cost; res.session = zepto.toSessionKeys(session); res.envelope = envelope;
|
|
648
|
-
releaseOnce(true, 'extracted');
|
|
691
|
+
releaseOnce(true, 'extracted', true); // success: OTP consumed → no cancel
|
|
649
692
|
return res;
|
|
650
693
|
} catch (e) {
|
|
651
694
|
return fail(`unexpected: ${e.message}`);
|
|
652
695
|
} finally {
|
|
653
|
-
|
|
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);
|
|
654
699
|
if (stream) try { stream.close(); } catch { /* */ }
|
|
655
700
|
}
|
|
656
701
|
}
|