@accounter/scraper-app
Version:
Scraper app with Fastify server and React UI
846 lines (799 loc) • 32.3 kB
text/typescript
import type { FastifyInstance } from 'fastify';
import type { RawData, WebSocket } from 'ws';
import websocketPlugin from '@fastify/websocket';
import {
HapoalimForeignTransactionsBusiness,
HapoalimForeignTransactionsPersonal,
} from '@accounter/modern-poalim-scraper';
import type { SourceType } from '../shared/source-types.js';
import { ClientMessageSchema, type ServerMessage } from '../shared/ws-protocol.js';
import { registerDiscoveredAccounts } from './account-discovery.js';
import { checkAccounts, type ValidatedPayload } from './check-accounts.js';
import { filterPayload, type FilterableCreds } from './filter-payload.js';
import { ChangedTransaction, InsertedTransactionSummary } from './gql/index.js';
import { createUploadClient, ScraperUploadResult, type UploadClient } from './graphql/client.js';
import { appendRun } from './history.js';
import { OtpManager } from './otp-manager.js';
import type { CalPayload } from './payload-schemas/cal.schema.js';
import type { DiscountPayload } from './payload-schemas/discount.schema.js';
import type { MaxPayload } from './payload-schemas/max.schema.js';
import { BlockedError, ERR_RUN_IN_PROGRESS, startRun, type ScrapeTask } from './scrape-runner.js';
import { scrapeAmex, type MonthlyIsracardPayload as MonthlyAmexPayload } from './scrapers/amex.js';
import { scrapeCal } from './scrapers/cal.js';
import { scrapeCurrencyRates } from './scrapers/currency-rates.js';
import { scrapeDiscount } from './scrapers/discount.js';
import {
countIsracardTransactions,
scrapeIsracard,
type MonthlyIsracardPayload,
} from './scrapers/isracard.js';
import { scrapeMax } from './scrapers/max.js';
import { scrapeOtsarHahayal } from './scrapers/otsar-hahayal.js';
import { scrapePoalim } from './scrapers/poalim.js';
import { getVault, isLocked } from './vault-store.js';
import type { Vault } from './vault.js';
function send(socket: WebSocket, msg: ServerMessage): void {
socket.send(JSON.stringify(msg));
}
type SourceRef = { id: string; type: SourceType };
type HapoalimForeignTransactions =
| HapoalimForeignTransactionsPersonal
| HapoalimForeignTransactionsBusiness;
function collectSourceRefs(vault: Vault): SourceRef[] {
return [
...vault.poalimAccounts.map(a => ({ id: a.id, type: 'poalim' as const })),
...vault.discountAccounts.map(a => ({ id: a.id, type: 'discount' as const })),
...vault.isracardAccounts.map(a => ({ id: a.id, type: 'isracard' as const })),
...vault.amexAccounts.map(a => ({ id: a.id, type: 'amex' as const })),
...vault.calAccounts.map(a => ({ id: a.id, type: 'cal' as const })),
...vault.maxAccounts.map(a => ({ id: a.id, type: 'max' as const })),
...vault.otsarHahayalAccounts.map(a => ({ id: a.id, type: 'otsar-hahayal' as const })),
];
}
function buildTask(
src: SourceRef,
vault: Vault,
emit: (msg: ServerMessage) => void,
otpManager: OtpManager,
uploadClient: UploadClient | null,
dateFrom: Date,
dateTo: Date,
): ScrapeTask {
return {
sourceId: src.id,
nickname: src.id,
type: src.type,
run: async () => {
const headless = !vault.settings.showBrowser;
if (src.type === 'otsar-hahayal') {
try {
const creds = vault.otsarHahayalAccounts.find(a => a.id === src.id);
if (!creds) throw new Error(`Otsar HaHayal account ${src.id} not found in vault`);
const { ilsData, foreignData, creditCardData } = await scrapeOtsarHahayal(
creds,
dateFrom,
dateTo,
headless,
emit,
);
// Register discovered accounts and check they're approved
// Merge ILS and foreign accounts — both are identified by account-branch
const foreignAccountEntries = foreignData.map(d => ({
account: `${String(d.metadata.account)}-${d.metadata.subAccount}`,
branch: String(d.metadata.branch),
}));
const accountPayload = [
...ilsData.map(d => ({ account: d.account.account, branch: d.account.branch })),
...foreignAccountEntries,
];
await registerDiscoveredAccounts('otsar-hahayal', creds.id, [accountPayload]);
const check = checkAccounts('otsar-hahayal', accountPayload, getVault().accountRecords);
if (check.unknown.length > 0) {
emit({
type: 'task-blocked',
sourceId: src.id,
sourceType: 'otsar-hahayal',
unknownAccounts: check.unknown,
});
throw new BlockedError(check.unknown);
}
// Filter out ignored bank accounts before uploading
const ignoredAccountIds = new Set(check.ignored);
const acceptedIlsData = ilsData.filter(
d => !ignoredAccountIds.has(`${d.account.branch}-${d.account.account}`),
);
const acceptedForeignData = foreignData.filter(
d =>
!ignoredAccountIds.has(
`${d.metadata.branch}-${d.metadata.account}-${d.metadata.subAccount}`,
),
);
// Register and validate credit cards separately
const creditCardPayload = creditCardData.map(d => ({ card: d.card }));
let acceptedCreditCardData = creditCardData;
if (creditCardPayload.length > 0) {
await registerDiscoveredAccounts('otsar-hahayal-credit-card', creds.id, [
creditCardPayload,
]);
const ccCheck = checkAccounts(
'otsar-hahayal-credit-card',
creditCardPayload,
getVault().accountRecords,
);
if (ccCheck.unknown.length > 0) {
emit({
type: 'task-blocked',
sourceId: src.id,
sourceType: 'otsar-hahayal',
unknownAccounts: ccCheck.unknown,
});
throw new BlockedError(ccCheck.unknown);
}
// Filter out ignored credit cards before uploading
const ignoredCards = new Set(ccCheck.ignored);
acceptedCreditCardData = creditCardData.filter(
d => !ignoredCards.has(d.card.maskedPan),
);
}
if (!uploadClient)
return {
inserted: 0,
skipped: 0,
insertedIds: [],
insertedTransactions: [],
changedTransactions: [],
};
let totalInserted = 0;
let totalSkipped = 0;
const allIds: string[] = [];
const insertedTransactions: InsertedTransactionSummary[] = [];
const changedTransactions: ChangedTransaction[] = [];
for (const d of acceptedIlsData) {
const accountId = `${d.account.branch}-${d.account.account}`;
emit({
type: 'task-account-txns-uploading',
sourceId: src.id,
accountId,
txnType: 'ils',
count: d.transactions.length,
});
const r = await uploadClient.uploadOtsarIls([d]);
emit({
type: 'task-account-txns-done',
sourceId: src.id,
accountId,
txnType: 'ils',
inserted: r.inserted,
skipped: r.skipped,
});
totalInserted += r.inserted;
totalSkipped += r.skipped;
allIds.push(...r.insertedIds);
insertedTransactions.push(...(r.insertedTransactions ?? []));
changedTransactions.push(...(r.changedTransactions ?? []));
}
for (const d of acceptedForeignData) {
const accountId = `${d.metadata.branch}-${d.metadata.account}-${d.metadata.subAccount}`;
emit({
type: 'task-account-txns-uploading',
sourceId: src.id,
accountId,
txnType: 'foreign',
count: d.transactions.length,
});
const rf = await uploadClient.uploadOtsarForeign([d]);
emit({
type: 'task-account-txns-done',
sourceId: src.id,
accountId,
txnType: 'foreign',
inserted: rf.inserted,
skipped: rf.skipped,
});
totalInserted += rf.inserted;
totalSkipped += rf.skipped;
allIds.push(...rf.insertedIds);
insertedTransactions.push(...(rf.insertedTransactions ?? []));
changedTransactions.push(...(rf.changedTransactions ?? []));
}
if (acceptedCreditCardData.length > 0) {
const ccCount = acceptedCreditCardData.reduce((s, d) => s + d.transactions.length, 0);
emit({
type: 'task-month-uploading',
sourceId: src.id,
month: 'credit-cards',
transactionCount: ccCount,
});
const rc = await uploadClient.uploadOtsarCreditCard(acceptedCreditCardData);
emit({
type: 'task-month-uploaded',
sourceId: src.id,
month: 'credit-cards',
inserted: rc.inserted,
skipped: rc.skipped,
});
totalInserted += rc.inserted;
totalSkipped += rc.skipped;
allIds.push(...rc.insertedIds);
insertedTransactions.push(...(rc.insertedTransactions ?? []));
changedTransactions.push(...(rc.changedTransactions ?? []));
}
return {
inserted: totalInserted,
skipped: totalSkipped,
insertedIds: allIds,
insertedTransactions,
changedTransactions,
};
} catch (err) {
console.error({ err }, '[task] Otsar HaHayal scrape error');
throw err;
}
}
if (src.type === 'poalim') {
try {
const creds = vault.poalimAccounts.find(a => a.id === src.id);
if (!creds) throw new Error(`Poalim account ${src.id} not found in vault`);
const accountsData = await scrapePoalim(
creds,
dateFrom,
dateTo,
headless,
otpManager,
emit,
);
if (!uploadClient)
return {
inserted: 0,
skipped: 0,
insertedIds: [],
insertedTransactions: [],
changedTransactions: [],
};
let totalInserted = 0;
let totalSkipped = 0;
const allIds: string[] = [];
const insertedTransactions: InsertedTransactionSummary[] = [];
const changedTransactions: ChangedTransaction[] = [];
for (const accountData of accountsData) {
const accountId = `${accountData.bankAccount.branchNumber}-${accountData.bankAccount.accountNumber}`;
const ilsPayload = accountData.ils;
if (ilsPayload) {
const pendingIlsTxns =
ilsPayload.transactions?.filter(
t => t.transactionType === 'TODAY' || t.transactionType === 'FUTURE',
) ?? [];
for (const t of pendingIlsTxns) {
console.info(
`[poalim] Skipping ${t.transactionType} ILS transaction: ${t.activityDescription}, ILS ${t.eventAmount}, account ${ilsPayload.retrievalTransactionData.accountNumber}`,
);
}
const filteredIlsPayload = {
...ilsPayload,
transactions:
ilsPayload.transactions?.filter(
t => t.transactionType !== 'TODAY' && t.transactionType !== 'FUTURE',
) ?? [],
};
const ilsCount = filteredIlsPayload.transactions.length;
emit({
type: 'task-account-txns-uploading',
sourceId: src.id,
accountId,
txnType: 'ils',
count: ilsCount,
});
const r = await uploadClient.uploadPoalimIls(filteredIlsPayload);
emit({
type: 'task-account-txns-done',
sourceId: src.id,
accountId,
txnType: 'ils',
inserted: r.inserted,
skipped: r.skipped + pendingIlsTxns.length,
});
totalInserted += r.inserted;
totalSkipped += r.skipped + pendingIlsTxns.length;
allIds.push(...r.insertedIds);
insertedTransactions.push(...(r.insertedTransactions ?? []));
changedTransactions.push(...(r.changedTransactions ?? []));
}
const foreignPayload = accountData.foreign;
if (foreignPayload) {
let foreignPendingCount = 0;
const filteredForeignPayload = {
...foreignPayload,
balancesAndLimitsDataList: foreignPayload.balancesAndLimitsDataList.map(entry => {
const pending = entry.transactions.filter(
t => t.transactionType === 'TODAY' || t.transactionType === 'FUTURE',
);
for (const t of pending) {
console.info(
`[poalim] Skipping ${t.transactionType} foreign transaction: ${t.activityDescription}, account ${accountData.bankAccount.accountNumber}`,
);
}
foreignPendingCount += pending.length;
return {
...entry,
transactions: entry.transactions.filter(
t => t.transactionType !== 'TODAY' && t.transactionType !== 'FUTURE',
),
};
}),
} as HapoalimForeignTransactions;
const foreignCount = filteredForeignPayload.balancesAndLimitsDataList.reduce(
(sum, e) => sum + e.transactions.length,
0,
);
emit({
type: 'task-account-txns-uploading',
sourceId: src.id,
accountId,
txnType: 'foreign',
count: foreignCount,
});
const rf = await uploadClient.uploadPoalimForeign(
filteredForeignPayload,
accountData.bankAccount,
);
emit({
type: 'task-account-txns-done',
sourceId: src.id,
accountId,
txnType: 'foreign',
inserted: rf.inserted,
skipped: rf.skipped + foreignPendingCount,
});
totalInserted += rf.inserted;
totalSkipped += rf.skipped + foreignPendingCount;
allIds.push(...rf.insertedIds);
insertedTransactions.push(...(rf.insertedTransactions ?? []));
changedTransactions.push(...(rf.changedTransactions ?? []));
}
const swiftPayload = accountData.swift;
if (swiftPayload) {
const swiftCount =
(swiftPayload as { swiftsList?: unknown[] }).swiftsList?.length ?? 0;
emit({
type: 'task-account-txns-uploading',
sourceId: src.id,
accountId,
txnType: 'swift',
count: swiftCount,
});
const rs = await uploadClient.uploadPoalimSwift(
swiftPayload,
accountData.bankAccount,
);
emit({
type: 'task-account-txns-done',
sourceId: src.id,
accountId,
txnType: 'swift',
inserted: rs.inserted,
skipped: rs.skipped,
});
totalInserted += rs.inserted;
totalSkipped += rs.skipped;
allIds.push(...rs.insertedIds);
insertedTransactions.push(...(rs.insertedTransactions ?? []));
changedTransactions.push(...(rs.changedTransactions ?? []));
}
}
return {
inserted: totalInserted,
skipped: totalSkipped,
insertedIds: allIds,
insertedTransactions,
changedTransactions,
};
} catch (err) {
console.error({ err }, '[task] Poalim scrape error');
throw err;
}
}
// isracard/amex return one payload per month; other scrapers return a single payload
let isracardPayloads: (MonthlyIsracardPayload | MonthlyAmexPayload)[] | null = null;
let payloads: ValidatedPayload[];
switch (src.type) {
case 'isracard': {
const creds = vault.isracardAccounts.find(a => a.id === src.id);
if (!creds) throw new Error(`Isracard account ${src.id} not found in vault`);
isracardPayloads = await scrapeIsracard(creds, dateFrom, dateTo, emit, headless);
payloads = isracardPayloads.map(p => p.data);
break;
}
case 'amex': {
const creds = vault.amexAccounts.find(a => a.id === src.id);
if (!creds) throw new Error(`Amex account ${src.id} not found in vault`);
isracardPayloads = await scrapeAmex(creds, dateFrom, dateTo, emit, headless);
payloads = isracardPayloads.map(p => p.data);
break;
}
case 'cal': {
const creds = vault.calAccounts.find(a => a.id === src.id);
if (!creds) throw new Error(`Cal account ${src.id} not found in vault`);
payloads = [await scrapeCal(creds, dateFrom, dateTo, emit, headless)];
break;
}
case 'discount': {
const creds = vault.discountAccounts.find(a => a.id === src.id);
if (!creds) throw new Error(`Discount account ${src.id} not found in vault`);
payloads = [await scrapeDiscount(creds, dateFrom, dateTo, emit, headless)];
break;
}
case 'max': {
const creds = vault.maxAccounts.find(a => a.id === src.id);
if (!creds) throw new Error(`Max account ${src.id} not found in vault`);
payloads = [await scrapeMax(creds, dateFrom, dateTo, emit, headless)];
break;
}
default:
throw new Error(`Unhandled source type: ${src.type}`);
}
// Use the first non-empty payload to check for unknown accounts (card identifiers
// are stable across months for isracard/amex, so any month suffices)
const representativePayload = payloads[0];
if (!representativePayload)
return {
inserted: 0,
skipped: 0,
insertedIds: [],
insertedTransactions: [],
changedTransactions: [],
};
// Apply per-source accepted/ignored filter after validation, before account check
let creds: FilterableCreds | undefined;
switch (src.type) {
case 'isracard':
creds = vault.isracardAccounts.find(a => a.id === src.id);
break;
case 'amex':
creds = vault.amexAccounts.find(a => a.id === src.id);
break;
case 'cal':
creds = vault.calAccounts.find(a => a.id === src.id);
break;
case 'max':
creds = vault.maxAccounts.find(a => a.id === src.id);
break;
case 'discount':
creds = vault.discountAccounts.find(a => a.id === src.id);
break;
}
if (creds) {
payloads = payloads.map(p => filterPayload(src.type, p, creds!) as ValidatedPayload);
isracardPayloads &&= isracardPayloads.map((p, i) => ({
...p,
data: payloads[i] as (typeof p)['data'],
}));
}
// Register any newly discovered accounts as pending, then re-read to avoid stale closure
await registerDiscoveredAccounts(src.type, src.id, payloads);
const check = checkAccounts(src.type, payloads[0]!, getVault().accountRecords);
if (check.unknown.length > 0) {
emit({
type: 'task-blocked',
sourceId: src.id,
sourceType: src.type,
unknownAccounts: check.unknown,
});
throw new BlockedError(check.unknown);
}
if (!uploadClient)
return {
inserted: 0,
skipped: 0,
insertedIds: [],
insertedTransactions: [],
changedTransactions: [],
};
// Upload all accepted payloads
let totalInserted = 0;
let totalSkipped = 0;
const allIds: string[] = [];
const changedTransactions: ChangedTransaction[] = [];
const insertedTransactions: InsertedTransactionSummary[] = [];
if ((src.type === 'isracard' || src.type === 'amex') && isracardPayloads) {
for (const { month, data: monthData } of isracardPayloads) {
const txnCount = countIsracardTransactions(monthData);
emit({
type: 'task-month-uploading',
sourceId: src.id,
month,
transactionCount: txnCount,
});
let result: ScraperUploadResult;
if (src.type === 'isracard') {
result = await uploadClient.uploadIsracard([monthData]);
} else {
result = await uploadClient.uploadAmex([monthData]);
}
emit({
type: 'task-month-uploaded',
sourceId: src.id,
month,
inserted: result.inserted,
skipped: result.skipped,
});
totalInserted += result.inserted;
totalSkipped += result.skipped;
allIds.push(...result.insertedIds);
changedTransactions.push(...(result.changedTransactions ?? []));
insertedTransactions.push(...(result.insertedTransactions ?? []));
}
} else {
for (const payload of payloads) {
let result: ScraperUploadResult;
switch (src.type) {
case 'cal': {
for (const entry of payload as CalPayload) {
emit({
type: 'task-month-uploading',
sourceId: src.id,
month: entry.month,
transactionCount: entry.transactions.length,
});
result = await uploadClient.uploadCal([entry]);
emit({
type: 'task-month-uploaded',
sourceId: src.id,
month: entry.month,
inserted: result.inserted,
skipped: result.skipped,
});
totalInserted += result.inserted;
totalSkipped += result.skipped;
allIds.push(...result.insertedIds);
changedTransactions.push(...(result.changedTransactions ?? []));
insertedTransactions.push(...(result.insertedTransactions ?? []));
}
result = {
inserted: 0,
skipped: 0,
insertedIds: [],
changedTransactions: [],
insertedTransactions: [],
};
break;
}
case 'discount': {
for (const entry of payload as DiscountPayload) {
emit({
type: 'task-month-uploading',
sourceId: src.id,
month: entry.month,
transactionCount: entry.transactions.length,
});
result = await uploadClient.uploadDiscount([entry]);
emit({
type: 'task-month-uploaded',
sourceId: src.id,
month: entry.month,
inserted: result.inserted,
skipped: result.skipped,
});
totalInserted += result.inserted;
totalSkipped += result.skipped;
allIds.push(...result.insertedIds);
changedTransactions.push(...(result.changedTransactions ?? []));
insertedTransactions.push(...(result.insertedTransactions ?? []));
}
result = {
inserted: 0,
skipped: 0,
insertedIds: [],
changedTransactions: [],
insertedTransactions: [],
};
break;
}
case 'max': {
for (const account of payload as MaxPayload) {
emit({
type: 'task-month-uploading',
sourceId: src.id,
month: account.accountNumber,
transactionCount: account.txns.length,
});
result = await uploadClient.uploadMax([account]);
emit({
type: 'task-month-uploaded',
sourceId: src.id,
month: account.accountNumber,
inserted: result.inserted,
skipped: result.skipped,
});
totalInserted += result.inserted;
totalSkipped += result.skipped;
allIds.push(...result.insertedIds);
changedTransactions.push(...(result.changedTransactions ?? []));
insertedTransactions.push(...(result.insertedTransactions ?? []));
}
result = {
inserted: 0,
skipped: 0,
insertedIds: [],
changedTransactions: [],
insertedTransactions: [],
};
break;
}
default:
result = {
inserted: 0,
skipped: 0,
insertedIds: [],
changedTransactions: [],
insertedTransactions: [],
};
}
totalInserted += result.inserted;
totalSkipped += result.skipped;
allIds.push(...result.insertedIds);
}
}
return {
inserted: totalInserted,
skipped: totalSkipped,
insertedIds: allIds,
changedTransactions,
insertedTransactions,
};
},
};
}
export async function registerWebSocketRoute(app: FastifyInstance): Promise<void> {
await app.register(websocketPlugin);
app.get(
'/ws',
{
websocket: true,
preValidation: async (_req, reply) => {
if (isLocked()) {
await reply.status(401).send({ error: 'vault-locked' });
}
},
},
(socket: WebSocket, _req) => {
socket.on('error', err => app.log.error({ err }, '[ws] socket error'));
send(socket, { type: 'connected' });
const emit = (msg: ServerMessage) => send(socket, msg);
let activeOtpManager: OtpManager | null = null;
socket.on('message', (raw: RawData) => {
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString());
} catch {
app.log.warn('[ws] non-JSON message received');
return;
}
const result = ClientMessageSchema.safeParse(parsed);
if (!result.success) {
app.log.warn({ received: parsed }, '[ws] unknown message type');
send(socket, { type: 'error', message: 'Unknown message type' });
return;
}
const msg = result.data;
switch (msg.type) {
case 'ping':
send(socket, { type: 'pong' });
break;
case 'run-start': {
let vault: Vault;
try {
vault = getVault();
} catch (e) {
send(socket, { type: 'error', message: String(e) });
break;
}
const allRefs = collectSourceRefs(vault);
const sourceIds = msg.sourceIds;
const sources = sourceIds ? allRefs.filter(s => sourceIds.includes(s.id)) : allRefs;
if (sources.length === 0 && !vault.settings.fetchBankOfIsraelRates) {
send(socket, { type: 'run-error', message: 'No matching sources found' });
break;
}
if (activeOtpManager) {
send(socket, { type: 'run-error', message: ERR_RUN_IN_PROGRESS });
break;
}
// Resolve date range: message overrides settings default
const runDateTo = msg.dateTo ? new Date(msg.dateTo) : new Date();
const months = vault.settings.defaultDateRangeMonths ?? 3;
const runDateFrom = msg.dateFrom
? new Date(msg.dateFrom)
: new Date(
runDateTo.getFullYear(),
runDateTo.getMonth() - months,
runDateTo.getDate(),
);
activeOtpManager = new OtpManager();
const uploadClient =
vault.settings.serverUrl && vault.settings.apiKey
? createUploadClient(vault.settings.serverUrl, vault.settings.apiKey)
: null;
const tasks: ScrapeTask[] = sources.map(src =>
buildTask(src, vault, emit, activeOtpManager!, uploadClient, runDateFrom, runDateTo),
);
// Append currency-rates task if enabled
if (vault.settings.fetchBankOfIsraelRates) {
tasks.push({
sourceId: 'currency-rates',
nickname: 'Currency Rates (Bank of Israel)',
type: 'currency-rates',
run: async () => {
const payload = await scrapeCurrencyRates(emit, runDateFrom, runDateTo);
if (!uploadClient)
return {
inserted: 0,
skipped: 0,
insertedIds: [],
changedTransactions: [],
insertedTransactions: [],
};
emit({
type: 'task-month-uploading',
sourceId: 'currency-rates',
month: 'rates',
transactionCount: payload.length,
});
const result = await uploadClient.uploadCurrencyRates(payload);
emit({
type: 'task-month-uploaded',
sourceId: 'currency-rates',
month: 'rates',
inserted: result.inserted,
skipped: result.skipped,
});
return result;
},
});
}
const { saveHistory, historyFilePath } = vault.settings;
void startRun(tasks, vault.settings.concurrentScraping, emit)
.then(async runRecord => {
if (saveHistory) {
try {
await appendRun(
{
...runRecord,
startedAt: runRecord.startedAt.toISOString(),
completedAt: runRecord.completedAt.toISOString(),
},
historyFilePath,
);
} catch (err) {
app.log.error(err, '[ws] failed to append run history');
}
}
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
if (message === ERR_RUN_IN_PROGRESS) {
send(socket, { type: 'run-error', message });
} else {
app.log.error(err, '[ws] startRun error');
send(socket, { type: 'error', message });
}
})
.finally(() => {
activeOtpManager = null;
});
break;
}
case 'cancel-scrape':
// TODO: cancel in-progress scrape
break;
case 'otp-submit':
activeOtpManager?.submitOtp(msg.sourceId, msg.otp);
app.log.info({ sourceId: msg.sourceId }, '[ws] otp-submit received');
break;
}
});
socket.on('close', () => {
app.log.info('[ws] client disconnected');
});
},
);
}