scripter-x 1.0.22 → 1.0.24
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 +34 -12
- package/src/providers/otpcart.js +49 -37
- package/src/update.js +48 -1
- package/src/util.js +33 -32
- package/src/worker.js +19 -0
package/package.json
CHANGED
package/src/commands.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// stdin/stdout). Each command: async (io, api, args) => void.
|
|
4
4
|
import * as config from './config.js';
|
|
5
5
|
import { ApiClient, AuthError } from './api.js';
|
|
6
|
-
import { saveSessions, CANCEL } from './util.js';
|
|
6
|
+
import { saveSessions, saveSessionsLocal, CANCEL } from './util.js';
|
|
7
7
|
import * as tp from './providers/tempotp.js';
|
|
8
8
|
import * as oc from './providers/otpcart.js';
|
|
9
9
|
import * as kuku from './providers/kuku.js';
|
|
@@ -205,18 +205,27 @@ export async function run(io, api, args = {}) {
|
|
|
205
205
|
|
|
206
206
|
io.print(` ✓ done — ${stats.succeeded} succeeded · ${stats.failed} failed · ${stats.cancelled} cancelled · ₹${stats.charges} spent`);
|
|
207
207
|
if (stats.succeeded > 0) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
208
|
+
// PRIMARY: save LOCALLY from the worker's in-memory buffer — never depends on the
|
|
209
|
+
// server, so an expired dashboard JWT can't lose the JSONs the user paid for.
|
|
210
|
+
let saved = null;
|
|
211
|
+
try {
|
|
212
|
+
saved = saveSessionsLocal(worker.results, cid, { campaignName: name, out: args.out, checkMinutes });
|
|
213
|
+
} catch (e) {
|
|
214
|
+
io.print(` ! local save error: ${e.message}`, 'danger');
|
|
215
|
+
}
|
|
216
|
+
// FALLBACK: only if the local buffer was somehow empty, try the server export.
|
|
217
|
+
if (!saved && worker.results.length === 0) {
|
|
218
|
+
for (let a = 0; a < 3 && !saved; a++) {
|
|
219
|
+
if (a > 0) await new Promise((r) => setTimeout(r, 1000));
|
|
220
|
+
try { saved = await saveSessions(api, cid, { campaignName: name, out: args.out }); } catch { /* */ }
|
|
221
|
+
}
|
|
212
222
|
}
|
|
213
223
|
if (saved) {
|
|
214
224
|
io.print(' ✓ saved session(s) to:');
|
|
215
|
-
for (const res of saved) {
|
|
216
|
-
|
|
217
|
-
|
|
225
|
+
for (const res of saved) io.print(` ${res.label} ${res.path} (${res.count} sessions)`, 'accent');
|
|
226
|
+
} else {
|
|
227
|
+
io.print(' ! could not write the session file — check disk permissions', 'danger');
|
|
218
228
|
}
|
|
219
|
-
else io.print(` ! couldn't auto-save${lastErr ? ` (${lastErr.message})` : ''} — run \`export ${name}\` to retry`);
|
|
220
229
|
} else io.print(' ◉ no sessions extracted — nothing to save.');
|
|
221
230
|
if (worker.logPath) io.print(` ◉ log saved to: ${worker.logPath}`);
|
|
222
231
|
}
|
|
@@ -624,10 +633,23 @@ export function help(io) {
|
|
|
624
633
|
|
|
625
634
|
export async function update(io) {
|
|
626
635
|
io.print(' ◉ updating ScripterX to the latest version…');
|
|
627
|
-
const { runUpdate } = await import('./update.js');
|
|
636
|
+
const { runUpdate, restartCli } = await import('./update.js');
|
|
628
637
|
const r = await runUpdate();
|
|
629
|
-
if (r.ok)
|
|
630
|
-
|
|
638
|
+
if (!r.ok) {
|
|
639
|
+
io.print(` ✗ update failed: ${r.output.split('\n')[0]} — try: npm install -g scripter-x@latest`);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
io.print(' ✓ updated! restarting to load the new version…');
|
|
643
|
+
// Tear down the interactive ink UI first (restores the terminal) so the relaunched
|
|
644
|
+
// process paints onto a clean screen. exitApp exists only in the interactive shell;
|
|
645
|
+
// in one-shot mode there's nothing to tear down.
|
|
646
|
+
if (typeof io.exitApp === 'function') {
|
|
647
|
+
try { io.exitApp(); } catch { /* */ }
|
|
648
|
+
}
|
|
649
|
+
// Give ink a beat to unmount + the alt-screen restore to flush, then re-exec.
|
|
650
|
+
await new Promise((res) => setTimeout(res, 250));
|
|
651
|
+
const ok = restartCli();
|
|
652
|
+
if (!ok) io.print(' ◉ could not auto-restart — please run `scripter-x` again.');
|
|
631
653
|
}
|
|
632
654
|
|
|
633
655
|
// dispatch table used by the interactive shell
|
package/src/providers/otpcart.js
CHANGED
|
@@ -155,12 +155,15 @@ export class OTPCartProvider {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
158
|
+
// Concurrency note: OTPCart happily serves MANY concurrent WS connections on ONE valid
|
|
159
|
+
// session — each rented number gets its own socket and its own OTP push. The hazard is
|
|
160
|
+
// NOT the parallel sockets; it's rotating the shared JWT. So the watchdog below must
|
|
161
|
+
// NEVER relogin just because a socket is *quiet* (a slow SMS would then rotate the token
|
|
162
|
+
// and knock out every other concurrent slot's open socket — a relogin storm). We only
|
|
163
|
+
// relogin on hard evidence the token is dead: the HANDSHAKE itself fails (close/error
|
|
164
|
+
// before we ever bound the socket). A connected-but-silent socket just re-subscribes.
|
|
165
|
+
const WS_RESUBSCRIBE_MS = 20000; // periodically re-send the bind frame on a quiet socket
|
|
166
|
+
const WS_PING_MS = 20000; // keep-alive so an idle socket isn't dropped before the SMS lands
|
|
164
167
|
const WS_DEBUG = process.env.OTPCART_WS_DEBUG === '1';
|
|
165
168
|
|
|
166
169
|
class OTPCartStream {
|
|
@@ -176,10 +179,11 @@ class OTPCartStream {
|
|
|
176
179
|
this._openP = null; // resolves when the WS is OPEN
|
|
177
180
|
this._reconnectTimer = null;
|
|
178
181
|
this._connectedAt = 0;
|
|
179
|
-
this.
|
|
182
|
+
this._everOpened = false; // did this socket ever reach OPEN?
|
|
183
|
+
this._resubTimer = null; // re-subscribe on a quiet socket (no token rotation)
|
|
180
184
|
this._pingTimer = null; // keep-alive
|
|
181
185
|
this._reloggingIn = false;
|
|
182
|
-
this.
|
|
186
|
+
this._handshakeReloginsLeft = 1; // recover a taken-over session once on a bad handshake
|
|
183
187
|
this._gotServerAck = false; // server sent the {serialNumber,mobileId} connect frame
|
|
184
188
|
this._connect();
|
|
185
189
|
}
|
|
@@ -187,38 +191,34 @@ class OTPCartStream {
|
|
|
187
191
|
_currentToken() { return this.provider.token; }
|
|
188
192
|
_dbg(...a) { if (WS_DEBUG) { try { console.error('[otpcart-ws]', ...a); } catch { /* */ } } }
|
|
189
193
|
|
|
190
|
-
|
|
191
|
-
if (this.
|
|
194
|
+
_clearResubTimer() {
|
|
195
|
+
if (this._resubTimer) { clearInterval(this._resubTimer); this._resubTimer = null; }
|
|
192
196
|
}
|
|
193
197
|
_clearPingTimer() {
|
|
194
198
|
if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
|
|
195
199
|
}
|
|
196
200
|
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (this.closed || this.otp ||
|
|
203
|
-
this.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
this.
|
|
201
|
+
// A socket that errored/closed WITHOUT ever opening is the dead-token signal: the
|
|
202
|
+
// server rejected the handshake. Relogin (de-duped + epoch-guarded so concurrent
|
|
203
|
+
// slots don't storm) and reconnect with the fresh token. A socket that DID open is
|
|
204
|
+
// a valid session — we never relogin it, so other concurrent sockets stay intact.
|
|
205
|
+
async _handleHandshakeFailure() {
|
|
206
|
+
if (this.closed || this.otp || this._reloggingIn) return;
|
|
207
|
+
if (!this.provider.canRelogin || this._handshakeReloginsLeft <= 0) {
|
|
208
|
+
// No creds (or budget spent) — just retry the socket with the current token.
|
|
209
|
+
if (!this.closed && !this.otp) this._reconnectTimer = setTimeout(() => this._connect(), 1000);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
this._handshakeReloginsLeft--;
|
|
209
213
|
this._reloggingIn = true;
|
|
210
|
-
this._dbg('
|
|
214
|
+
this._dbg('handshake failed before open — relogin + reconnect');
|
|
211
215
|
try {
|
|
212
|
-
// Re-login (de-duped at the provider) to get a token that the WS will accept,
|
|
213
|
-
// then reconnect this socket with it. If the token was actually fine, this is
|
|
214
|
-
// a cheap no-op reconnect that costs nothing but a fresh socket.
|
|
215
216
|
const epoch = this.provider.tokenEpoch;
|
|
216
217
|
await this.provider.relogin(epoch);
|
|
217
|
-
} catch { /*
|
|
218
|
+
} catch { /* nothing better to do */ }
|
|
218
219
|
finally { this._reloggingIn = false; }
|
|
219
220
|
if (this.closed || this.otp) return;
|
|
220
|
-
|
|
221
|
-
this._connect(); // reconnect with the (possibly refreshed) current token
|
|
221
|
+
this._connect();
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
// The browser sends an Origin (and a UA) on the WS handshake. The `ws` library
|
|
@@ -253,15 +253,21 @@ class OTPCartStream {
|
|
|
253
253
|
this._dbg('connecting', WS_URL, 'token', String(token).slice(0, 10) + '…');
|
|
254
254
|
this.ws = new WebSocket(`${WS_URL}?token=${token}`, this._wsOptions());
|
|
255
255
|
this._gotServerAck = false;
|
|
256
|
+
let openedThisSocket = false; // did THIS socket reach OPEN (vs. fail the handshake)?
|
|
256
257
|
this._openP = new Promise((resolve) => {
|
|
257
258
|
this.ws.once('open', resolve);
|
|
258
259
|
this.ws.once('error', resolve); // resolve anyway so ready() never hangs
|
|
259
260
|
});
|
|
260
261
|
this.ws.on('open', () => {
|
|
261
262
|
this._connectedAt = Date.now();
|
|
263
|
+
this._everOpened = true;
|
|
264
|
+
openedThisSocket = true;
|
|
262
265
|
this._dbg('open');
|
|
263
266
|
this._subscribe(); // bind this socket to our number (browser parity)
|
|
264
|
-
|
|
267
|
+
// Quiet-socket handling = RE-SUBSCRIBE only (NEVER relogin). This keeps concurrency
|
|
268
|
+
// safe: a slow SMS on one slot won't rotate the shared token and break other slots.
|
|
269
|
+
this._clearResubTimer();
|
|
270
|
+
this._resubTimer = setInterval(() => { if (!this.otp && !this.closed) this._subscribe(); }, WS_RESUBSCRIBE_MS);
|
|
265
271
|
this._clearPingTimer();
|
|
266
272
|
this._pingTimer = setInterval(() => { try { this.ws.ping(); } catch { /* */ } }, WS_PING_MS);
|
|
267
273
|
});
|
|
@@ -272,7 +278,7 @@ class OTPCartStream {
|
|
|
272
278
|
// OTP frame: {message, otpMessage}.
|
|
273
279
|
if (msg.otpMessage) {
|
|
274
280
|
const m = String(msg.otpMessage).match(OTP_RE);
|
|
275
|
-
if (m) { this.otp = m[1]; this.arrived = true; this.
|
|
281
|
+
if (m) { this.otp = m[1]; this.arrived = true; this._clearResubTimer(); this._dbg('OTP', m[1]); }
|
|
276
282
|
return;
|
|
277
283
|
}
|
|
278
284
|
// Ack frame: {serialNumber, mobileId, resend}. Confirms the socket is bound.
|
|
@@ -285,14 +291,20 @@ class OTPCartStream {
|
|
|
285
291
|
}
|
|
286
292
|
});
|
|
287
293
|
this.ws.on('pong', () => this._dbg('pong'));
|
|
288
|
-
this.ws.on('error', (e) => { this._dbg('error', e?.message); /* swallow —
|
|
289
|
-
// Auto-reconnect if the socket drops before the OTP arrives (so a dropped WS doesn't
|
|
290
|
-
// silently lose the push — this is the "purchased but never got OTP" symptom).
|
|
294
|
+
this.ws.on('error', (e) => { this._dbg('error', e?.message); /* swallow — handled in close */ });
|
|
291
295
|
this.ws.on('close', (code) => {
|
|
292
|
-
this._dbg('close', code);
|
|
293
|
-
this.
|
|
296
|
+
this._dbg('close', code, 'opened=' + openedThisSocket);
|
|
297
|
+
this._clearResubTimer();
|
|
294
298
|
this._clearPingTimer();
|
|
295
299
|
if (this.closed || this.otp || this._reloggingIn) return;
|
|
300
|
+
if (!openedThisSocket) {
|
|
301
|
+
// Never reached OPEN → the handshake was rejected → likely a dead token.
|
|
302
|
+
// Relogin (epoch-guarded, capped) then reconnect — this is the ONLY relogin path.
|
|
303
|
+
this._handleHandshakeFailure();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// Was a healthy socket that dropped before the OTP — just reconnect with the SAME
|
|
307
|
+
// token (no relogin, so we don't disturb other concurrent slots).
|
|
296
308
|
this._reconnectTimer = setTimeout(() => { if (!this.closed && !this.otp) this._connect(); }, 1000);
|
|
297
309
|
});
|
|
298
310
|
}
|
|
@@ -324,7 +336,7 @@ class OTPCartStream {
|
|
|
324
336
|
|
|
325
337
|
close() {
|
|
326
338
|
this.closed = true;
|
|
327
|
-
this.
|
|
339
|
+
this._clearResubTimer();
|
|
328
340
|
this._clearPingTimer();
|
|
329
341
|
if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
|
|
330
342
|
try { this.ws.close(); } catch { /* */ }
|
package/src/update.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// • checkForUpdate() — quietly asks the npm registry for the latest version (cached 6h),
|
|
3
3
|
// returns the newer version string or null. Used to show a notice on launch.
|
|
4
4
|
// • runUpdate() — runs `npm install -g scripter-x@latest`.
|
|
5
|
-
|
|
5
|
+
// • restartCli() — re-exec the freshly-installed CLI so the new version takes over.
|
|
6
|
+
import { execFile, spawn } from 'node:child_process';
|
|
6
7
|
import { promisify } from 'node:util';
|
|
8
|
+
import process from 'node:process';
|
|
7
9
|
import * as config from './config.js';
|
|
8
10
|
|
|
9
11
|
const execFileP = promisify(execFile);
|
|
@@ -45,3 +47,48 @@ export async function runUpdate() {
|
|
|
45
47
|
return { ok: false, output: e.message };
|
|
46
48
|
}
|
|
47
49
|
}
|
|
50
|
+
|
|
51
|
+
// Re-exec the CLI so the just-installed version takes over. Spawns a fresh, DETACHED
|
|
52
|
+
// process that inherits this terminal, then exits the current one. The caller MUST have
|
|
53
|
+
// already torn down the ink/alt-screen UI (so the terminal is restored) before calling
|
|
54
|
+
// this — otherwise the child renders over a dirty screen.
|
|
55
|
+
//
|
|
56
|
+
// We re-run the CURRENT entry file with node (`process.argv[1]`): after `npm install -g`,
|
|
57
|
+
// the global bin symlink points at the new version, so this loads the updated code. If
|
|
58
|
+
// that entry path is somehow missing, we fall back to the `scripterx` bin on PATH.
|
|
59
|
+
//
|
|
60
|
+
// NOTE: on success it exits the process and never returns. Returns false only if BOTH
|
|
61
|
+
// spawn attempts throw synchronously, so the caller can show "restart manually".
|
|
62
|
+
export function restartCli({ binName = 'scripterx', delayMs = 150 } = {}) {
|
|
63
|
+
// Re-run with the SAME args the user launched with (drop node + the script path), so
|
|
64
|
+
// `scripterx` → interactive shell, `scripterx zepto` → zepto, etc.
|
|
65
|
+
const argv = process.argv.slice(2);
|
|
66
|
+
const entry = process.argv[1];
|
|
67
|
+
|
|
68
|
+
const trySpawn = (cmd, args) => {
|
|
69
|
+
const child = spawn(cmd, args, { stdio: 'inherit', detached: true, shell: false });
|
|
70
|
+
child.unref();
|
|
71
|
+
return child;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const primary = trySpawn(process.execPath, [entry, ...argv]);
|
|
76
|
+
// If the entry re-exec fails to even start, fall back to the named bin on PATH.
|
|
77
|
+
primary.on('error', () => {
|
|
78
|
+
try { trySpawn(binName, argv); } catch { /* nothing more we can do */ }
|
|
79
|
+
});
|
|
80
|
+
// Exit the old process; its `process.on('exit', restore)` cleans the terminal and the
|
|
81
|
+
// detached child takes over the same TTY running the new version.
|
|
82
|
+
setTimeout(() => process.exit(0), delayMs);
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
// process.execPath spawn threw synchronously — try the named bin directly.
|
|
86
|
+
try {
|
|
87
|
+
trySpawn(binName, argv);
|
|
88
|
+
setTimeout(() => process.exit(0), delayMs);
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/util.js
CHANGED
|
@@ -31,34 +31,19 @@ export const log = {
|
|
|
31
31
|
err: (m) => console.log(` ${paint.err('✗')} ${m}`),
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
let name = (campaignName || cid.slice(0, 8)).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || cid.slice(0, 8);
|
|
39
|
-
const accounts = await api.exportCampaign(cid);
|
|
34
|
+
// Core: partition an accounts array by Minutes status and write the JSON file(s).
|
|
35
|
+
// accounts = [{ session, coupon_eligible? }]. Pure local I/O — no server.
|
|
36
|
+
// Returns [{label, path, count}] or null.
|
|
37
|
+
function writeAccounts(accounts, { name, out, checkMinutes } = {}) {
|
|
40
38
|
if (!accounts || !accounts.length) return null;
|
|
41
39
|
|
|
42
|
-
let checkMinutes = false;
|
|
43
|
-
try {
|
|
44
|
-
const { campaign } = await api.getCampaign(cid);
|
|
45
|
-
checkMinutes = !!campaign.check_minutes;
|
|
46
|
-
} catch (e) {
|
|
47
|
-
// ignore
|
|
48
|
-
}
|
|
49
|
-
|
|
50
40
|
// Resolve base directory and optional forced filename.
|
|
51
41
|
let baseDir, forcedName = null;
|
|
52
|
-
const defaultName = `${name}-${cid.slice(0, 8)}.json`;
|
|
53
42
|
if (out) {
|
|
54
43
|
const expanded = out.startsWith('~') ? join(homedir(), out.slice(1)) : out;
|
|
55
44
|
const isDir = (existsSync(expanded) && statSync(expanded).isDirectory()) || /[/\\]$/.test(out);
|
|
56
|
-
if (isDir)
|
|
57
|
-
|
|
58
|
-
} else {
|
|
59
|
-
baseDir = dirname(expanded);
|
|
60
|
-
forcedName = expanded.split(/[/\\]/).pop();
|
|
61
|
-
}
|
|
45
|
+
if (isDir) baseDir = expanded;
|
|
46
|
+
else { baseDir = dirname(expanded); forcedName = expanded.split(/[/\\]/).pop(); }
|
|
62
47
|
} else {
|
|
63
48
|
const downloads = join(homedir(), 'Downloads');
|
|
64
49
|
const base = existsSync(downloads) ? downloads : homedir();
|
|
@@ -67,22 +52,19 @@ export async function saveSessions(api, cid, { campaignName, out } = {}) {
|
|
|
67
52
|
|
|
68
53
|
function write(suffix, sessionsToWrite) {
|
|
69
54
|
if (!sessionsToWrite || !sessionsToWrite.length) return null;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
} else {
|
|
74
|
-
fname = `${name}-${cid.slice(0, 8)}${suffix}.json`;
|
|
75
|
-
}
|
|
55
|
+
const fname = forcedName
|
|
56
|
+
? (suffix === '' ? forcedName : forcedName.replace(/\.json$/i, `${suffix}.json`))
|
|
57
|
+
: `${name}${suffix}.json`;
|
|
76
58
|
const dest = join(baseDir, fname);
|
|
77
59
|
mkdirSync(baseDir, { recursive: true });
|
|
78
60
|
writeFileSync(dest, JSON.stringify(sessionsToWrite, null, 2));
|
|
79
61
|
return dest;
|
|
80
62
|
}
|
|
81
63
|
|
|
82
|
-
// Partition by coupon_eligible when
|
|
64
|
+
// Partition by coupon_eligible when the Minutes check was requested.
|
|
83
65
|
const minutesFree = []; // coupon_eligible=true → no Minutes order, ₹100 coupon available
|
|
84
66
|
const minutesUsed = []; // coupon_eligible=false → already placed a Minutes order
|
|
85
|
-
const unchecked = []; // coupon_eligible absent → check wasn't done
|
|
67
|
+
const unchecked = []; // coupon_eligible absent → check wasn't done
|
|
86
68
|
|
|
87
69
|
let hasChecked = false;
|
|
88
70
|
for (const a of accounts) {
|
|
@@ -101,10 +83,8 @@ export async function saveSessions(api, cid, { campaignName, out } = {}) {
|
|
|
101
83
|
if (hasChecked || (checkMinutes && (minutesFree.length || minutesUsed.length))) {
|
|
102
84
|
const destFree = write('-minutes-free', minutesFree);
|
|
103
85
|
if (destFree) results.push({ label: '🟢 minutes-free (₹100 coupon)', path: destFree, count: minutesFree.length });
|
|
104
|
-
|
|
105
86
|
const destUsed = write('-minutes-used', minutesUsed);
|
|
106
87
|
if (destUsed) results.push({ label: '🔴 minutes-used (coupon gone)', path: destUsed, count: minutesUsed.length });
|
|
107
|
-
|
|
108
88
|
if (unchecked.length) {
|
|
109
89
|
const destUnk = write('-unchecked', unchecked);
|
|
110
90
|
if (destUnk) results.push({ label: '⚪ unchecked', path: destUnk, count: unchecked.length });
|
|
@@ -114,6 +94,27 @@ export async function saveSessions(api, cid, { campaignName, out } = {}) {
|
|
|
114
94
|
const dest = write('', allSessions);
|
|
115
95
|
if (dest) results.push({ label: 'combined', path: dest, count: allSessions.length });
|
|
116
96
|
}
|
|
117
|
-
|
|
118
97
|
return results.length ? results : null;
|
|
119
98
|
}
|
|
99
|
+
|
|
100
|
+
function campaignFileStem(campaignName, cid) {
|
|
101
|
+
const name = (campaignName || cid.slice(0, 8)).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || cid.slice(0, 8);
|
|
102
|
+
return `${name}-${cid.slice(0, 8)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// LOCAL autosave — the primary save path. Writes directly from the worker's
|
|
106
|
+
// in-memory results, so a campaign's JSONs are ALWAYS saved even if the dashboard
|
|
107
|
+
// JWT expired mid-run (no server round-trip). Returns [{label,path,count}] or null.
|
|
108
|
+
export function saveSessionsLocal(accounts, cid, { campaignName, out, checkMinutes } = {}) {
|
|
109
|
+
return writeAccounts(accounts, { name: campaignFileStem(campaignName, cid), out, checkMinutes });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Resolve + write a campaign's sessions to a file, fetching from the SERVER.
|
|
113
|
+
// Used by the standalone `export` command (no local buffer available there).
|
|
114
|
+
export async function saveSessions(api, cid, { campaignName, out } = {}) {
|
|
115
|
+
const accounts = await api.exportCampaign(cid);
|
|
116
|
+
if (!accounts || !accounts.length) return null;
|
|
117
|
+
let checkMinutes = false;
|
|
118
|
+
try { const { campaign } = await api.getCampaign(cid); checkMinutes = !!campaign.check_minutes; } catch { /* ignore */ }
|
|
119
|
+
return writeAccounts(accounts, { name: campaignFileStem(campaignName, cid), out, checkMinutes });
|
|
120
|
+
}
|
package/src/worker.js
CHANGED
|
@@ -39,6 +39,10 @@ export class Worker {
|
|
|
39
39
|
this.slots = {};
|
|
40
40
|
this.seq = 0;
|
|
41
41
|
this.stopped = false;
|
|
42
|
+
// Local buffer of every successful session — the source of truth for autosave.
|
|
43
|
+
// We NEVER depend on a server round-trip to save: even if the dashboard JWT
|
|
44
|
+
// expires mid-run, the file still gets written from here.
|
|
45
|
+
this.results = [];
|
|
42
46
|
this.logger = new CampaignLogger({ campaignId, name: name || campaignId, provider: provider.name });
|
|
43
47
|
}
|
|
44
48
|
|
|
@@ -69,6 +73,21 @@ export class Worker {
|
|
|
69
73
|
else { this.stats.failed++; this.stats.charges += res.cost || 0; } // failed w/ SMS = charged by provider
|
|
70
74
|
this.onEvent('progress', this.stats);
|
|
71
75
|
this.onEvent('row', res);
|
|
76
|
+
// Buffer every successful session LOCALLY first — autosave reads from here, so a
|
|
77
|
+
// failed/expired server ingest can never lose the JSON the user paid for.
|
|
78
|
+
if (res.status === 'success' && res.session) {
|
|
79
|
+
this.results.push({
|
|
80
|
+
id_no: res.id_no, mobile: res.mobile || '', session: res.session,
|
|
81
|
+
minutes_checked: !!res.minutes_checked,
|
|
82
|
+
coupon_eligible: res.coupon_eligible ?? undefined,
|
|
83
|
+
has_minutes_order: res.has_minutes_order ?? undefined,
|
|
84
|
+
minutes_order_count: res.minutes_order_count ?? undefined,
|
|
85
|
+
minutes_orders: res.minutes_orders ?? undefined,
|
|
86
|
+
linked_email: res.linked_email ?? undefined,
|
|
87
|
+
email_linked: res.email_linked ?? undefined,
|
|
88
|
+
email_reason: res.email_reason ?? undefined,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
72
91
|
try {
|
|
73
92
|
await this.api.ingestAccount(this.cid, {
|
|
74
93
|
id_no: res.id_no, mobile: res.mobile || '', status: res.status, cost: res.cost || 0,
|