scripter-x 1.0.2 → 1.0.7
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/api.js +3 -1
- package/src/commands.js +135 -12
- package/src/config.js +2 -0
- package/src/flipkart.js +79 -2
- package/src/index.js +6 -1
- package/src/logger.js +52 -0
- package/src/providers/kuku.js +238 -0
- package/src/providers/otpcart.js +31 -4
- package/src/providers/zauth.js +155 -0
- package/src/providers/zepto.js +131 -0
- package/src/ui/App.js +58 -1
- package/src/ui/fullscreen.js +10 -2
- package/src/worker.js +191 -17
package/src/ui/fullscreen.js
CHANGED
|
@@ -32,6 +32,9 @@ export function FullScreen({ children }) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// enterAltScreen / leaveAltScreen — call around render(). Returns a cleanup fn.
|
|
35
|
+
// CRITICAL: restore() must ALSO disable mouse tracking — otherwise on any abrupt exit the
|
|
36
|
+
// shell is left with mouse mode on and spews `35;91;28M…` coordinate codes on every move.
|
|
37
|
+
// We register restore on exit + the kill signals so it runs no matter how we leave.
|
|
35
38
|
export function enterAltScreen() {
|
|
36
39
|
process.stdout.write('\x1b[?1049h'); // switch to alternate buffer
|
|
37
40
|
process.stdout.write('\x1b[2J\x1b[H'); // clear + home
|
|
@@ -40,9 +43,14 @@ export function enterAltScreen() {
|
|
|
40
43
|
const restore = () => {
|
|
41
44
|
if (restored) return;
|
|
42
45
|
restored = true;
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
try {
|
|
47
|
+
process.stdout.write('\x1b[?1000l\x1b[?1003l\x1b[?1006l'); // disable ALL mouse modes
|
|
48
|
+
process.stdout.write('\x1b[?25h'); // show cursor
|
|
49
|
+
process.stdout.write('\x1b[?1049l'); // back to normal buffer (history intact)
|
|
50
|
+
} catch { /* */ }
|
|
45
51
|
};
|
|
46
52
|
process.on('exit', restore);
|
|
53
|
+
process.on('SIGTERM', () => { restore(); process.exit(0); });
|
|
54
|
+
process.on('SIGHUP', () => { restore(); process.exit(0); });
|
|
47
55
|
return restore;
|
|
48
56
|
}
|
package/src/worker.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// goal = N JSONs, 4× attempt cap; refund rule (charge only if SMS arrived);
|
|
4
4
|
// OTP timing + wrong-OTP resend; every result pushed to the server (ingest).
|
|
5
5
|
import { FlipkartLogin, WrongOTP, BlockedError, TransientError } from './flipkart.js';
|
|
6
|
+
import { CampaignLogger } from './logger.js';
|
|
7
|
+
import * as kuku from './providers/kuku.js';
|
|
6
8
|
|
|
7
9
|
const OTP_RESEND_AFTER = 60_000;
|
|
8
10
|
const OTPCART_DEADLINE = 120_000;
|
|
@@ -17,7 +19,7 @@ const ATTEMPT_CAP_MULT = 4;
|
|
|
17
19
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
18
20
|
|
|
19
21
|
export class Worker {
|
|
20
|
-
constructor(api, provider, { campaignId, requested, concurrency, checkMinutes, onEvent, onFailure }) {
|
|
22
|
+
constructor(api, provider, { campaignId, requested, concurrency, checkMinutes, onEvent, onFailure, name, emailMode, kukuAccount }) {
|
|
21
23
|
this.api = api;
|
|
22
24
|
this.provider = provider;
|
|
23
25
|
this.cid = campaignId;
|
|
@@ -27,12 +29,19 @@ export class Worker {
|
|
|
27
29
|
this.onEvent = onEvent || (() => {});
|
|
28
30
|
// onFailure({mobile, reason}) → Promise<boolean>: true = keep going, false = stop campaign.
|
|
29
31
|
this.onFailure = onFailure || null;
|
|
32
|
+
this.emailMode = !!emailMode; // also attach a newaddr.com email to each account
|
|
33
|
+
this.kukuAccount = kukuAccount || null; // ONE shared premium kuku account for all emails
|
|
34
|
+
this._kukuLock = Promise.resolve(); // serialize email generation (one premium account)
|
|
30
35
|
this.stats = { total: requested, succeeded: 0, failed: 0, cancelled: 0, generated: 0, attempts: 0, charges: 0 };
|
|
31
36
|
this.slots = {};
|
|
32
37
|
this.seq = 0;
|
|
33
38
|
this.stopped = false;
|
|
39
|
+
this.logger = new CampaignLogger({ campaignId, name: name || campaignId, provider: provider.name });
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
// append one line to the campaign log file (clean, timestamped)
|
|
43
|
+
log(line) { try { this.logger.write(line); } catch { /* */ } }
|
|
44
|
+
|
|
36
45
|
stop() { this.stopped = true; }
|
|
37
46
|
_nextSeq() { return ++this.seq; }
|
|
38
47
|
|
|
@@ -61,14 +70,21 @@ export class Worker {
|
|
|
61
70
|
fail_reason: res.detail || '', session: res.session,
|
|
62
71
|
minutes_checked: !!res.minutes_checked, coupon_eligible: res.coupon_eligible ?? undefined,
|
|
63
72
|
has_minutes_order: res.has_minutes_order ?? undefined,
|
|
73
|
+
linked_email: res.linked_email ?? undefined,
|
|
74
|
+
email_linked: res.email_linked ?? undefined,
|
|
75
|
+
email_reason: res.email_reason ?? undefined,
|
|
64
76
|
});
|
|
65
77
|
} catch (e) { this.onEvent('warn', `ingest failed: ${e.message}`); }
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
async run() {
|
|
69
81
|
await this.api.markRunning(this.cid);
|
|
82
|
+
this.log(`campaign started — goal ${this.requested} JSONs, concurrency ${this.concurrency}`);
|
|
70
83
|
const loops = Array.from({ length: this.concurrency }, (_, i) => this._loop(i + 1));
|
|
71
84
|
await Promise.all(loops);
|
|
85
|
+
// Wait for any in-flight background releases to finish BEFORE we report done, so the
|
|
86
|
+
// log + summary reflect that every rented number was actually cancelled.
|
|
87
|
+
await this._drainReleases();
|
|
72
88
|
const status = this.stopped ? 'cancelled' : 'completed';
|
|
73
89
|
const s = this.stats;
|
|
74
90
|
try {
|
|
@@ -77,10 +93,20 @@ export class Worker {
|
|
|
77
93
|
numbers_generated: s.generated, attempts: s.attempts, charges: s.charges, status,
|
|
78
94
|
});
|
|
79
95
|
} catch (e) { this.onEvent('warn', `finish failed: ${e.message}`); }
|
|
96
|
+
try { this.logger.summary(s); } catch { /* */ }
|
|
97
|
+
this.logPath = this.logger.path;
|
|
80
98
|
this.onEvent('done', s);
|
|
81
99
|
return s;
|
|
82
100
|
}
|
|
83
101
|
|
|
102
|
+
// track + await background number-cancel goroutines so the campaign doesn't "finish"
|
|
103
|
+
// while a number is still being released (which would risk a charge leak going unlogged).
|
|
104
|
+
async _drainReleases() {
|
|
105
|
+
if (!this._releases || !this._releases.length) return;
|
|
106
|
+
this.log(`waiting for ${this._releases.length} pending number cancellation(s)…`);
|
|
107
|
+
await Promise.allSettled(this._releases);
|
|
108
|
+
}
|
|
109
|
+
|
|
84
110
|
async _loop(slot) {
|
|
85
111
|
for (;;) {
|
|
86
112
|
if (this._claim() === 0) return;
|
|
@@ -99,45 +125,65 @@ export class Worker {
|
|
|
99
125
|
const rentedAt = Date.now();
|
|
100
126
|
res.mobile = number.mobile;
|
|
101
127
|
this._emit(slot, { mobile: number.mobile, phase: 'rented', detail: 'got a number' });
|
|
128
|
+
this.log(`rented ${number.mobile} (txn ${number.txn}, cost ₹${number.cost})`);
|
|
102
129
|
|
|
103
130
|
const smsArrived = { v: false };
|
|
104
131
|
const fk = new FlipkartLogin();
|
|
105
132
|
let stream = null;
|
|
133
|
+
let released = false; // GUARANTEE: a rented number is released exactly once, no leak
|
|
134
|
+
|
|
135
|
+
// releaseOnce — the single chokepoint that schedules the provider cancel. Called from
|
|
136
|
+
// every terminal path (fail, success, error). Without this a blocked/errored number
|
|
137
|
+
// could stay rented → we'd be charged.
|
|
138
|
+
const releaseOnce = () => {
|
|
139
|
+
if (released) return;
|
|
140
|
+
released = true;
|
|
141
|
+
this._emit(slot, { phase: 'cancelling', detail: 'releasing number' });
|
|
142
|
+
this._release(number, rentedAt);
|
|
143
|
+
};
|
|
106
144
|
|
|
107
145
|
const fail = (detail) => {
|
|
108
146
|
const charged = smsArrived.v;
|
|
109
147
|
res.status = charged ? 'failed' : 'cancelled';
|
|
110
148
|
res.cost = charged ? number.cost : 0;
|
|
111
149
|
res.detail = detail;
|
|
112
|
-
this.
|
|
113
|
-
|
|
150
|
+
this.log(`FAILED ${number.mobile}: ${detail} (charged: ${charged})`);
|
|
151
|
+
releaseOnce();
|
|
114
152
|
return res;
|
|
115
153
|
};
|
|
116
154
|
|
|
117
155
|
try {
|
|
118
156
|
this._emit(slot, { phase: 'ws', detail: 'opening OTP channel' });
|
|
119
157
|
stream = this.provider.startOtp(number);
|
|
158
|
+
// WAIT for the OTP channel to be OPEN before triggering the SMS — otherwise the OTP
|
|
159
|
+
// push can arrive before the WS is listening and be lost (the "purchased but no OTP"
|
|
160
|
+
// bug). OTPCart streams expose ready(); TempOTP (HTTP poll) has no ready() → skip.
|
|
161
|
+
if (typeof stream.ready === 'function') await stream.ready();
|
|
120
162
|
|
|
163
|
+
// 1. SEND — retry 3× on transient (529/throttle/network) with a 2s gap.
|
|
121
164
|
this._emit(slot, { phase: 'send_otp', detail: 'sending OTP' });
|
|
122
|
-
|
|
123
|
-
|
|
165
|
+
const sent = await this._fkRetry(slot, 'send OTP', () => fk.sendOtp(number.mobile), number);
|
|
166
|
+
if (!sent.ok) {
|
|
167
|
+
if (sent.stop) { this.stopped = true; return fail(`send blocked: ${sent.error}`); }
|
|
168
|
+
return fail(`send: ${sent.error}`);
|
|
169
|
+
}
|
|
124
170
|
|
|
171
|
+
// 2. AWAIT OTP
|
|
125
172
|
const deadline = this.provider.name === 'tempotp' ? TEMPOTP_DEADLINE : OTPCART_DEADLINE;
|
|
126
173
|
const doResend = this.provider.name === 'otpcart';
|
|
127
174
|
const otp = await this._awaitOtp(slot, stream, fk, number, deadline, doResend, smsArrived);
|
|
128
175
|
if (!otp) return fail(`no OTP within ${deadline / 1000}s — abandoned`);
|
|
129
176
|
|
|
177
|
+
// 3. VERIFY (already retries transients internally via _verify; re-verify on blockage)
|
|
130
178
|
this._emit(slot, { phase: 'verify', detail: 'verifying OTP' });
|
|
131
179
|
const { session, error: verifyErr } = await this._verify(slot, fk, stream, number, otp, smsArrived);
|
|
132
180
|
if (!session) {
|
|
133
|
-
// surface the REAL Flipkart reason, and (since the OTP SMS was delivered) charge it.
|
|
134
181
|
const failed = fail(verifyErr || 'verify failed');
|
|
135
|
-
// ask the user whether to keep going after a failure (one prompt at a time)
|
|
136
182
|
if (this.onFailure && !this.stopped && !this._asking) {
|
|
137
183
|
this._asking = true;
|
|
138
184
|
try {
|
|
139
185
|
const cont = await this.onFailure({ mobile: number.mobile, reason: verifyErr || 'verify failed' });
|
|
140
|
-
if (!cont) this.stopped = true;
|
|
186
|
+
if (!cont) this.stopped = true;
|
|
141
187
|
} finally { this._asking = false; }
|
|
142
188
|
}
|
|
143
189
|
return failed;
|
|
@@ -151,16 +197,119 @@ export class Worker {
|
|
|
151
197
|
} catch { /* non-fatal */ }
|
|
152
198
|
}
|
|
153
199
|
|
|
200
|
+
// 5. EMAIL LINK (optional) — keep the SAME number's OTP stream alive: FK-6 triggers a
|
|
201
|
+
// 2nd phone OTP that arrives on `stream`, plus an email OTP from kuku. Partial-success:
|
|
202
|
+
// if linking fails, the JSON is STILL a success (email_linked=false + reason).
|
|
203
|
+
if (this.emailMode) {
|
|
204
|
+
try {
|
|
205
|
+
const linked = await this._linkEmail(slot, fk, stream, number, session, smsArrived);
|
|
206
|
+
if (linked.email) {
|
|
207
|
+
session.email = linked.email;
|
|
208
|
+
session.emailVerified = !!linked.ok;
|
|
209
|
+
res.linked_email = linked.email;
|
|
210
|
+
res.email_linked = !!linked.ok;
|
|
211
|
+
if (!linked.ok) res.email_reason = linked.reason;
|
|
212
|
+
} else {
|
|
213
|
+
res.email_linked = false; res.email_reason = linked.reason;
|
|
214
|
+
}
|
|
215
|
+
} catch (e) {
|
|
216
|
+
res.email_linked = false; res.email_reason = e.message;
|
|
217
|
+
this.log(`email link ${number.mobile} failed (JSON still saved): ${e.message}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
154
221
|
this._emit(slot, { phase: 'saving', detail: 'saving session' });
|
|
155
222
|
res.status = 'success'; res.cost = number.cost; res.session = session;
|
|
156
|
-
this.
|
|
157
|
-
this.
|
|
223
|
+
this.log(`SUCCESS ${number.mobile} — session extracted (₹${number.cost})${res.email_linked ? ` + email ${res.linked_email}` : ''}`);
|
|
224
|
+
this._emit(slot, { phase: 'done', detail: res.email_linked ? `extracted + ${res.linked_email}` : 'extracted' });
|
|
225
|
+
releaseOnce(); // success still releases the number (OTP already consumed)
|
|
158
226
|
return res;
|
|
227
|
+
} catch (e) {
|
|
228
|
+
// ANY unexpected error → still release the number so we're never charged for a leak
|
|
229
|
+
this.log(`ERROR ${number.mobile}: ${e.message}`);
|
|
230
|
+
return fail(`unexpected: ${e.message}`);
|
|
159
231
|
} finally {
|
|
232
|
+
releaseOnce(); // belt-and-suspenders: never leave a rented number un-released
|
|
160
233
|
if (stream) try { stream.close(); } catch { /* */ }
|
|
161
234
|
}
|
|
162
235
|
}
|
|
163
236
|
|
|
237
|
+
// _linkEmail — attach a newaddr.com email to the just-logged-in account.
|
|
238
|
+
// Returns { email, ok, reason }. The number's OTP `stream` is kept OPEN by the caller so
|
|
239
|
+
// the FK-6 phone OTP arrives on it; the email OTP comes from kuku (subject line).
|
|
240
|
+
async _linkEmail(slot, fk, stream, number, session, smsArrived) {
|
|
241
|
+
// FK-5: skip if an email is already attached
|
|
242
|
+
try {
|
|
243
|
+
const prof = await fk.profileInfo(session);
|
|
244
|
+
if (prof.emailAlready) return { email: prof.email || '', ok: true, reason: 'already linked' };
|
|
245
|
+
} catch { /* non-fatal — proceed to attach */ }
|
|
246
|
+
|
|
247
|
+
// 1. mint a newaddr.com email from the shared premium account (serialized — one account)
|
|
248
|
+
this._emit(slot, { phase: 'minutes', detail: 'generating email' });
|
|
249
|
+
let email;
|
|
250
|
+
const gen = this._kukuLock.then(() => kuku.generateEmail(this.kukuAccount, 'newaddr.com'));
|
|
251
|
+
this._kukuLock = gen.catch(() => {});
|
|
252
|
+
try { email = await gen; } catch (e) { return { email: '', ok: false, reason: `email gen: ${e.message}` }; }
|
|
253
|
+
this.log(`email ${number.mobile} → ${email}`);
|
|
254
|
+
|
|
255
|
+
// 2. FK-6: send OTP to BOTH the email and the phone (retry transients)
|
|
256
|
+
this._emit(slot, { phase: 'send_otp', detail: 'sending email+phone OTP' });
|
|
257
|
+
let reqs;
|
|
258
|
+
const sent = await this._fkRetry(slot, 'email OTP', async () => { reqs = await fk.sendEmailOtp(session, email); }, number);
|
|
259
|
+
if (!sent.ok) return { email, ok: false, reason: `email OTP send: ${sent.error}` };
|
|
260
|
+
|
|
261
|
+
// 3. catch BOTH OTPs in parallel: email from kuku, phone from the open stream
|
|
262
|
+
this._emit(slot, { phase: 'await_otp', detail: 'awaiting email + phone OTP' });
|
|
263
|
+
const emailOtpP = kuku.waitForOtp(this.kukuAccount, { toEmail: email, timeoutMs: 120000, pollMs: 4000 });
|
|
264
|
+
const phoneOtpP = this._awaitOtp(slot, stream, fk, number, 120000, false, smsArrived);
|
|
265
|
+
const [emailOtp, phoneOtp] = await Promise.all([emailOtpP, phoneOtpP]);
|
|
266
|
+
if (!emailOtp) return { email, ok: false, reason: 'no email OTP received' };
|
|
267
|
+
if (!phoneOtp) return { email, ok: false, reason: 'no phone OTP for email-link' };
|
|
268
|
+
|
|
269
|
+
// 4. FK-7: submit both OTPs
|
|
270
|
+
this._emit(slot, { phase: 'verify', detail: 'linking email' });
|
|
271
|
+
try {
|
|
272
|
+
await fk.verifyEmailOtp(session, {
|
|
273
|
+
email, emailOtp, emailReqId: reqs.emailReqId,
|
|
274
|
+
phoneUserId: reqs.phoneUserId, phoneOtp, phoneReqId: reqs.phoneReqId });
|
|
275
|
+
this.log(`EMAIL LINKED ${number.mobile} ↔ ${email}`);
|
|
276
|
+
return { email, ok: true };
|
|
277
|
+
} catch (e) {
|
|
278
|
+
return { email, ok: false, reason: `link verify: ${e.message}` };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// _fkRetry — run a Flipkart call up to 3× with a 2s gap on transient failures (529 /
|
|
283
|
+
// throttle / network). On final failure, asks the user continue-or-stop (one at a time).
|
|
284
|
+
// Returns { ok, error?, stop? }.
|
|
285
|
+
async _fkRetry(slot, label, fn, number) {
|
|
286
|
+
const MAX = 3, GAP = 2000;
|
|
287
|
+
let lastErr = '';
|
|
288
|
+
for (let attempt = 1; attempt <= MAX; attempt++) {
|
|
289
|
+
if (this.stopped) return { ok: false, error: 'stopped' };
|
|
290
|
+
try { await fn(); return { ok: true }; }
|
|
291
|
+
catch (e) {
|
|
292
|
+
lastErr = e.message || String(e);
|
|
293
|
+
const transient = e instanceof TransientError || e instanceof BlockedError;
|
|
294
|
+
this.log(`${label} attempt ${attempt}/${MAX} failed: ${lastErr}${transient ? ' (transient)' : ''}`);
|
|
295
|
+
if (!transient) return { ok: false, error: lastErr };
|
|
296
|
+
if (attempt < MAX) {
|
|
297
|
+
this._emit(slot, { phase: 'send_otp', detail: `Flipkart busy — retry ${attempt}/${MAX} in 2s` });
|
|
298
|
+
await sleep(GAP);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// all 3 attempts failed → ask the user whether to keep going
|
|
303
|
+
if (this.onFailure && !this.stopped && !this._asking) {
|
|
304
|
+
this._asking = true;
|
|
305
|
+
try {
|
|
306
|
+
const cont = await this.onFailure({ mobile: number?.mobile || '', reason: `${label} blocked after ${MAX} tries: ${lastErr}` });
|
|
307
|
+
if (!cont) return { ok: false, error: lastErr, stop: true };
|
|
308
|
+
} finally { this._asking = false; }
|
|
309
|
+
}
|
|
310
|
+
return { ok: false, error: lastErr };
|
|
311
|
+
}
|
|
312
|
+
|
|
164
313
|
async _rent(slot) {
|
|
165
314
|
let attempt = 0;
|
|
166
315
|
while (!this.stopped) {
|
|
@@ -193,7 +342,8 @@ export class Worker {
|
|
|
193
342
|
const rem = Math.floor((end - Date.now()) / 1000);
|
|
194
343
|
if (doResend && !resent && Date.now() >= resendAt) {
|
|
195
344
|
try { await fk.sendOtp(number.mobile); } catch { /* */ }
|
|
196
|
-
stream.resend();
|
|
345
|
+
try { await stream.resend(); } catch { /* */ }
|
|
346
|
+
resent = true;
|
|
197
347
|
this._emit(slot, { phase: 'otp_resend', detail: 'OTP resent', wait: rem });
|
|
198
348
|
} else {
|
|
199
349
|
this._emit(slot, { phase: 'await_otp', detail: 'waiting for OTP', wait: rem });
|
|
@@ -208,15 +358,20 @@ export class Worker {
|
|
|
208
358
|
// not mislabeled as "OTP rejected".
|
|
209
359
|
async _verify(slot, fk, stream, number, otp, smsArrived) {
|
|
210
360
|
let lastErr = 'verify failed';
|
|
361
|
+
// RE-VERIFY on blockage: a 529/transient is NOT the OTP's fault, so retry the same
|
|
362
|
+
// (still-valid) OTP 3× with a 2s gap before giving up — never cancel a good number
|
|
363
|
+
// just because Flipkart hiccupped.
|
|
211
364
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
212
365
|
try {
|
|
213
366
|
const session = await fk.verifyOtp(number.mobile, otp);
|
|
367
|
+
if (attempt > 1) this.log(`verify ${number.mobile} succeeded on retry ${attempt}`);
|
|
214
368
|
return { session, error: null };
|
|
215
369
|
} catch (e) {
|
|
216
|
-
if (e instanceof TransientError) {
|
|
370
|
+
if (e instanceof TransientError || e instanceof BlockedError) {
|
|
217
371
|
lastErr = e.message;
|
|
218
|
-
this.
|
|
219
|
-
|
|
372
|
+
this.log(`verify ${number.mobile} transient attempt ${attempt}/3: ${lastErr} — re-verifying`);
|
|
373
|
+
this._emit(slot, { phase: 'verify', detail: `Flipkart busy — re-verify ${attempt}/3` });
|
|
374
|
+
if (attempt < 3) await sleep(2000);
|
|
220
375
|
continue; // retry the SAME otp (it's still valid; the server just hiccupped)
|
|
221
376
|
}
|
|
222
377
|
if (e instanceof WrongOTP) {
|
|
@@ -233,7 +388,7 @@ export class Worker {
|
|
|
233
388
|
async _resendAndVerify(slot, fk, stream, number, smsArrived, why) {
|
|
234
389
|
this._emit(slot, { phase: 'otp_resend', detail: `${why} — resending`, wait: WRONG_OTP_WAIT / 1000 });
|
|
235
390
|
try { await fk.sendOtp(number.mobile); } catch (e) { return { session: null, error: `resend failed: ${e.message}` }; }
|
|
236
|
-
stream.resend();
|
|
391
|
+
try { await stream.resend(); } catch { /* */ }
|
|
237
392
|
const end = Date.now() + WRONG_OTP_WAIT;
|
|
238
393
|
let otp2 = null;
|
|
239
394
|
while (Date.now() < end && !this.stopped) {
|
|
@@ -249,13 +404,32 @@ export class Worker {
|
|
|
249
404
|
catch (e) { return { session: null, error: e.message }; }
|
|
250
405
|
}
|
|
251
406
|
|
|
407
|
+
// _release — GUARANTEE the rented number is cancelled so we're never charged for a
|
|
408
|
+
// number that didn't succeed. TempOTP can't be cancelled before its 2-min lock, so we
|
|
409
|
+
// wait that out first. The cancel is RETRIED up to 5× until it confirms; the outcome is
|
|
410
|
+
// logged. The returned promise is tracked so the campaign waits for it before finishing.
|
|
252
411
|
_release(number, rentedAt) {
|
|
253
|
-
|
|
412
|
+
if (!this._releases) this._releases = [];
|
|
413
|
+
const p = (async () => {
|
|
254
414
|
if (this.provider.name === 'tempotp') {
|
|
255
415
|
const wait = NUMBER_CANCEL_LOCK - (Date.now() - rentedAt);
|
|
256
416
|
if (wait > 0) await sleep(wait);
|
|
257
417
|
}
|
|
258
|
-
|
|
418
|
+
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
419
|
+
try {
|
|
420
|
+
await this.provider.cancel(number);
|
|
421
|
+
this.log(`cancelled ${number.mobile} (txn ${number.txn}) — refunded`);
|
|
422
|
+
return;
|
|
423
|
+
} catch (e) {
|
|
424
|
+
this.log(`cancel ${number.mobile} attempt ${attempt}/5 failed: ${e.message}`);
|
|
425
|
+
await sleep(3000);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// Could NOT cancel after 5 tries — flag it loudly so the user knows a number may be charged.
|
|
429
|
+
this.log(`⚠ COULD NOT CANCEL ${number.mobile} (txn ${number.txn}) after 5 tries — may be charged! cancel manually on the provider.`);
|
|
430
|
+
this.onEvent('warn', `⚠ couldn't cancel ${number.mobile.slice(-10)} — cancel it on the provider to avoid a charge`);
|
|
259
431
|
})();
|
|
432
|
+
this._releases.push(p);
|
|
433
|
+
return p;
|
|
260
434
|
}
|
|
261
435
|
}
|