BTC_BETTING_SERVER_CONNECTOR/server.js
2025-08-28 15:21:02 +05:30

889 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require("express");
const net = require("net");
const ABS_HOST = process.env.ABS_HOST || "192.0.0.14";
const ABS_PORT = Number(process.env.ABS_PORT || 7000);
const ABS_POLL = 178;
const LOG = 100;
const LOGBT = 6013;
const BTI = 6014;
const RPI = 6015;
const SUCCESS = 0;
const PKT_SIZE = 512;
const PAYLOAD_PER_PKT = PKT_SIZE - 1;
const DEFAULT_TIMEOUT_MS = Number(process.env.BRIDGE_TIMEOUT_MS || 20000);
const RETRIES = Number(process.env.BRIDGE_RETRIES || 2);
const RETRY_DELAY_MS = Number(process.env.BRIDGE_RETRY_DELAY_MS || 500);
const MAX_VENUES = 10;
const MAX_RACES = 14;
const MAX_POOLS = 8;
const MAX_HORSES = 24;
const app = express();
// Venue and pool mappings
const venueMapping = {
1: "BLR", 2: "MUM", 3: "MAA", 4: "CAL", 5: "HYD",
6: "MYS", 7: "OTY", 8: "PUN", 9: "GYM", 10: "OVS"
};
const poolMapping = {
1: "WNP", 2: "PLP", 3: "SHP", 4: "THP", 5: "QNP",
6: "FRP", 7: "TNP", 8: "EXP", 9: "TBP", 10: "MJP", 11: "JPP"
};
const multiLegPoolMapping = {
"T1": "TBP", "T2": "TBP", "T3": "TBP",
"M1": "MJP", "M2": "MJP", "M3": "MJP",
"J1": "JPP", "J2": "JPP", "J3": "JPP"
};
function getDateStr(nDate) {
const s = nDate.toString().padStart(8, "0");
return `${s.slice(0, 4)}/${s.slice(4, 6)}/${s.slice(6, 8)}`;
}
function getTimeStr(nTime) {
const s = nTime.toString().padStart(6, "0");
return `${s.slice(0, 2)}:${s.slice(2, 4)}:${s.slice(4, 6)}`;
}
function getVenueIdStr(nVenue) {
return venueMapping[nVenue] || "";
}
function getPoolIdStr(nPoolId) {
return poolMapping[nPoolId] || "";
}
function getMultiLegPoolIdStr(szMultiLegRace) {
return multiLegPoolMapping[szMultiLegRace] || "";
}
function readJsonBody(req) {
return new Promise((resolve) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
if (!chunks.length) return resolve({});
const txt = Buffer.concat(chunks).toString("utf8").trim();
if (!txt) return resolve({});
try { resolve(JSON.parse(txt)); } catch { resolve({}); }
});
});
}
function writeFixedAscii(buf, offset, s, len) {
const str = (s ?? "").toString();
for (let i = 0; i < len; i++) buf[offset + i] = i < str.length ? (str.charCodeAt(i) & 0xff) : 0x00;
return offset + len;
}
function readFixedAscii(buf, offset, len) {
let end = offset;
const max = offset + len;
while (end < max && buf[end] !== 0) end++;
const raw = buf.subarray(offset, end).toString("ascii");
return { value: raw.trimEnd(), next: offset + len };
}
function hexSlice(buf, off, len) {
const s = Math.max(0, off);
const e = Math.min(buf.length, off + len);
return buf.subarray(s, e).toString("hex");
}
function remaining(buf, off) {
return Math.max(0, buf.length - off);
}
function safeAdvanceOrBreak(buf, off, want, outObj) {
if (remaining(buf, off) < want) {
outObj._truncated = true;
return false;
}
return true;
}
function looksLikeHeaderAt(buf, off, expected_nTxnCd = null) {
if (remaining(buf, off) < 18) return false;
try {
const nTxnCd = buf.readInt32LE(off);
const nRetCd = buf.readInt32LE(off + 4);
const nNumRecs = buf.readInt32LE(off + 8);
// Allow nTxnCd: 100 as a fallback for any section, since the server may send it
const validTxnCd = expected_nTxnCd ? (nTxnCd === expected_nTxnCd || nTxnCd === 100) : (nTxnCd >= 100 && nTxnCd <= 105);
return validTxnCd && nRetCd >= 0 && nRetCd < 10000 && nNumRecs >= 0 && nNumRecs < 100;
} catch { return false; }
}
function alignToPossibleHeader(reply, off, expected_nTxnCd = null) {
// Try current offset and additional offsets for alignment
const tryOffsets = [0, 2, 4, -4, 8, 12];
for (const d of tryOffsets) {
const o = off + d;
if (o >= 0 && looksLikeHeaderAt(reply, o, expected_nTxnCd)) return o;
}
// Fallback: align to valid nRaceDt (2000010121001231)
for (const d of tryOffsets) {
const o = off + d;
if (o >= 0 && remaining(reply, o) >= 4) {
const nRaceDt = reply.readInt32LE(o);
if (nRaceDt >= 20000101 && nRaceDt <= 21001231) return o;
}
}
return off;
}
function adjustForOptionalOp(replyBuf, bodyOffset) {
if (replyBuf.length >= bodyOffset + 4) {
const maybeOp = replyBuf.readInt32LE(bodyOffset);
if (maybeOp === LOGBT || maybeOp === LOG || maybeOp === 0 || (maybeOp >= 100 && maybeOp <= 10000)) {
return { offset: bodyOffset + 4, extraOp: maybeOp };
}
}
return { offset: bodyOffset, extraOp: null };
}
function absSend(sock, payload) {
return new Promise((resolve, reject) => {
let off = 0;
function sendNext() {
const remaining = payload.length - off;
const toCopy = Math.min(PAYLOAD_PER_PKT, Math.max(remaining, 0));
const pkt = Buffer.alloc(PKT_SIZE, 0);
pkt[0] = (off + toCopy >= payload.length) ? 48 : 49;
if (toCopy > 0) payload.copy(pkt, 1, off, off + toCopy);
off += toCopy;
sock.write(pkt, (err) => {
if (err) return reject(err);
if (pkt[0] === 48) return resolve();
sendNext();
});
}
if (!payload || payload.length === 0) {
const pkt = Buffer.alloc(PKT_SIZE, 0);
pkt[0] = 48;
sock.write(pkt, (err) => err ? reject(err) : resolve());
} else {
sendNext();
}
});
}
function absRecv(sock, timeoutMs = DEFAULT_TIMEOUT_MS) {
return new Promise((resolve, reject) => {
const chunks = [];
let buf = Buffer.alloc(0);
const timer = timeoutMs ? setTimeout(() => done(new Error("TCP timeout")), timeoutMs) : null;
function done(err) {
if (timer) clearTimeout(timer);
sock.off("data", onData);
if (err) reject(err);
else resolve(Buffer.concat(chunks));
}
function onData(data) {
buf = Buffer.concat([buf, data]);
while (buf.length >= PKT_SIZE) {
const pkt = buf.subarray(0, PKT_SIZE);
buf = buf.subarray(PKT_SIZE);
const flag = pkt[0];
chunks.push(pkt.subarray(1));
if (flag === 48) return done();
}
}
sock.on("data", onData);
sock.on("error", (e) => done(e));
sock.on("end", () => done(new Error("Socket ended before final '0' packet")));
});
}
async function absRoundtrip(payload, timeoutMs = DEFAULT_TIMEOUT_MS) {
const sock = new net.Socket();
await new Promise((res, rej) => sock.connect(ABS_PORT, ABS_HOST, res).once("error", rej));
try {
await absSend(sock, payload || Buffer.alloc(0));
const replyConcat = await absRecv(sock, timeoutMs);
sock.end();
return replyConcat;
} catch (e) {
try { sock.destroy(); } catch {}
throw e;
}
}
async function withRetry(fn, tries = RETRIES, delayMs = RETRY_DELAY_MS) {
let lastErr;
for (let i = 0; i < tries; i++) {
try { return await fn(); }
catch (e) { lastErr = e; if (i + 1 < tries) await new Promise(r => setTimeout(r, delayMs)); }
}
throw lastErr;
}
function encryptPasswordLikeMFC(plain) {
const sec = new Date().getUTCSeconds();
const key = Number(String(sec)[0] || "0");
let out = "";
const s = (plain ?? "").toString();
for (let i = 0; i < s.length; i++) {
const ch = s[i];
const d = (ch >= "0" && ch <= "9") ? (ch.charCodeAt(0) - 48) : 0;
out += String((d + key) % 10);
}
return { enc: out + String(key), key, utcSeconds: sec };
}
function packSndHeader({ nTxnCd, nOpCd = 0, nNumRecsSent = 0, nNumRecsRqrd = 0, nTxnId = 0, cBtMake = 0 }) {
const b = Buffer.alloc(24, 0);
let o = 0;
b.writeInt32LE(nTxnCd, o); o += 4;
b.writeInt32LE(nOpCd, o); o += 4;
b.writeInt32LE(nNumRecsSent, o); o += 4;
b.writeInt32LE(nNumRecsRqrd, o); o += 4;
b.writeInt32LE(nTxnId, o); o += 4;
b.writeUInt8(cBtMake & 0xff, o);
return b;
}
function parseRcvHeaderFlexible(buf) {
if (buf.length < 18) throw new Error(`Reply too short for RcvHeader: ${buf.length} bytes`);
function readAs(len) {
let o = 0;
const nTxnCd = buf.readInt32LE(o); o += 4;
const nRetCd = buf.readInt32LE(o); o += 4;
const nNumRecs = buf.readInt32LE(o); o += 4;
const nTxnId = buf.readInt32LE(o); o += 4;
const cBtMake = buf.readUInt8(o);
return { nTxnCd, nRetCd, nNumRecs, nTxnId, cBtMake, size: len };
}
const h18 = readAs(18);
const ok18 = (h18.nTxnCd >= 50 && h18.nTxnCd < 10000) && (h18.nRetCd >= 0 && h18.nRetCd < 10000) && (h18.nNumRecs >= 0 && h18.nNumRecs < 100);
let h24 = null, ok24 = false;
if (buf.length >= 24) {
h24 = readAs(24);
ok24 = (h24.nTxnCd >= 50 && h24.nTxnCd < 10000) && (h24.nRetCd >= 0 && h24.nRetCd < 10000) && (h24.nNumRecs >= 0 && h24.nNumRecs < 100);
}
if (ok18 && !ok24) return h18;
if (!ok18 && ok24) return h24;
if (ok18 && ok24) return h18;
return h18;
}
function buildRcvHeader18({ nTxnCd, nRetCd, nNumRecs = 0, nTxnId = 0, cBtMake = 0 }) {
const b = Buffer.alloc(18, 0);
let o = 0;
b.writeInt32LE(nTxnCd, o); o += 4;
b.writeInt32LE(nRetCd, o); o += 4;
b.writeInt32LE(nNumRecs, o); o += 4;
b.writeInt32LE(nTxnId, o); o += 4;
b.writeUInt8(cBtMake & 0xff, o);
return b;
}
function alignToAsciiYear(reply, off) {
const tryOffsets = [0, 2, 4, -4, 8, 12];
for (const d of tryOffsets) {
const o = off + d;
if (o >= 0 && o + 1 < reply.length) {
const b0 = reply[o], b1 = reply[o + 1];
if (b0 === 0x32 && b1 === 0x30) return o;
if (reply[o] === 0x2f && o >= 4 && reply[o - 4] === 0x32 && reply[o - 3] === 0x30) return o - 4;
}
}
return off;
}
function packSndLog({ usrId = "", opCard, passwd, btId }) {
const b = Buffer.alloc(38, 0);
let o = 0;
o = writeFixedAscii(b, o, usrId, 5);
o = writeFixedAscii(b, o, opCard, 17);
o = writeFixedAscii(b, o, passwd, 11);
o = writeFixedAscii(b, o, btId, 5);
return b;
}
function parseRcvLog(buf, offset = 0) {
let o = offset;
const t1 = readFixedAscii(buf, o, 20); const cDateTime = t1.value; o = t1.next;
const t2 = readFixedAscii(buf, o, 31); const cUsrNm = t2.value; o = t2.next;
const t3 = readFixedAscii(buf, o, 5); const cUsrId = t3.value; o = t3.next;
const t4 = readFixedAscii(buf, o, 31); const cSupNm = t4.value; o = t4.next;
const t5 = readFixedAscii(buf, o, 5); const cSupId = t5.value; o = t5.next;
const t6 = readFixedAscii(buf, o, 5); const cUsrTyp = t6.value; o = t6.next;
function rf() { const v = buf.readFloatLE(o); o += 4; return v; }
function ri() { const v = buf.readInt32LE(o); o += 4; return v; }
const fOpenBal = rf();
const fTktSalesByVoucher = rf();
const fTktSalesByCash = rf();
const fTktSalesByMemCard = rf();
const nTktSalesByVoucherCount = ri();
const nTktSalesByCashCount = ri();
const nTktSalesByMemCardCount = ri();
const fPayoutByVoucher = rf();
const fPayoutByCash = rf();
const fPayoutByMemCard = rf();
const nPayoutByVoucherCount = ri();
const nPayoutByCashCount = ri();
const nPayoutByMemCardCount = ri();
const fCancelByVoucher = rf();
const fCancelByCash = rf();
const fCancelByMemCard = rf();
const nCancelByVoucherCount = ri();
const nCancelByCashCount = ri();
const nCancelByMemCardCount = ri();
const fDeposit = rf();
const fWithdrawAmt = rf();
const fVoucherSales = rf();
const fVoucherEncash = rf();
const fCloseBal = rf();
const fSaleTarget = rf();
return {
cDateTime, cUsrNm, cUsrId, cSupNm, cSupId, cUsrTyp,
fOpenBal, fTktSalesByVoucher, fTktSalesByCash, fTktSalesByMemCard,
nTktSalesByVoucherCount, nTktSalesByCashCount, nTktSalesByMemCardCount,
fPayoutByVoucher, fPayoutByCash, fPayoutByMemCard,
nPayoutByVoucherCount, nPayoutByCashCount, nPayoutByMemCardCount,
fCancelByVoucher, fCancelByCash, fCancelByMemCard,
nCancelByVoucherCount, nCancelByCashCount, nCancelByMemCardCount,
fDeposit, fWithdrawAmt, fVoucherSales, fVoucherEncash,
fCloseBal, fSaleTarget,
size: o - offset
};
}
function parseRcvBtiMst(buf, offset) {
let o = offset;
const a1 = readFixedAscii(buf, o, 5); const cBtId = a1.value; o = a1.next;
const a2 = readFixedAscii(buf, o, 31); const cBtName = a2.value; o = a2.next;
const a3 = readFixedAscii(buf, o, 5); const cEnclCd = a3.value; o = a3.next;
const a4 = readFixedAscii(buf, o, 4); const cGrpCd = a4.value; o = a4.next;
const cBtMode = String.fromCharCode(buf[o]); o += 1;
const a5 = readFixedAscii(buf, o, 4); const cMoneyTyp = a5.value; o = a5.next;
o = (o + 3) & ~3;
const fMinSaleBet = buf.readFloatLE(o); o += 4;
const fMaxSaleBet = buf.readFloatLE(o); o += 4;
const fPayMaxAmt = buf.readFloatLE(o); o += 4;
return { cBtId, cBtName, cEnclCd, cGrpCd, cBtMode, cMoneyTyp, fMinSaleBet, fMaxSaleBet, fPayMaxAmt, size: o - offset };
}
function parseRcvBtiDtl(buf, offset) {
let o = offset;
const cDtlTyp = String.fromCharCode(buf[o]); o += 1;
if (remaining(buf, o) < 4) {
const avail = Math.max(0, remaining(buf, o));
const aRem = readFixedAscii(buf, o, avail);
const cPoolOrVenue = aRem.value;
o = aRem.next;
const cDtlSts = o < buf.length ? String.fromCharCode(buf[o]) : "";
if (o < buf.length) o += 1;
return { cDtlTyp, cPoolOrVenue, cDtlSts, size: o - offset };
}
const a3 = readFixedAscii(buf, o, 3);
const cPoolOrVenue3 = a3.value;
const after3 = a3.next;
const statusCandidate = buf[after3];
const isPrintable = (statusCandidate >= 0x20 && statusCandidate <= 0x7E);
if (isPrintable) {
o = after3;
const cDtlSts = String.fromCharCode(buf[o]); o += 1;
return { cDtlTyp, cPoolOrVenue: cPoolOrVenue3, cDtlSts, size: o - offset };
}
const a4 = readFixedAscii(buf, o, 4);
const cPoolOrVenue4 = a4.value;
o = a4.next;
const cDtlSts = o < buf.length ? String.fromCharCode(buf[o]) : "";
if (o < buf.length) o += 1;
return { cDtlTyp, cPoolOrVenue: cPoolOrVenue4, cDtlSts, size: o - offset };
}
function parseRcvRpi1(buf, offset) {
let o = offset;
const nRaceDt = buf.readInt32LE(o); o += 4;
const cVenueNum = buf.readUInt8(o); o += 1;
const cVenueSts = String.fromCharCode(buf[o]); o += 1;
const aR = readFixedAscii(buf, o, 15); const cRaces = aR.value; o = aR.next;
const aAd = readFixedAscii(buf, o, 41); const cAdvertisement = aAd.value; o = aAd.next;
return { nRaceDt, cVenueNum, cVenueSts, cRaces, cAdvertisement, size: o - offset };
}
function parseRcvRpi2(buf, offset) {
let o = offset;
const nRaceDt = buf.readInt32LE(o); o += 4;
const cVenueNum = buf.readUInt8(o); o += 1;
const cRaceNum = buf.readUInt8(o); o += 1;
const aH = readFixedAscii(buf, o, 25); const cHorses = aH.value; o = aH.next;
const nRaceStartTm = buf.readInt32LE(o); o += 4;
return { nRaceDt, cVenueNum, cRaceNum, cHorses, nRaceStartTm, size: o - offset };
}
function parseRcvRpi3(buf, offset) {
let o = offset;
const nRaceDt = buf.readInt32LE(o); o += 4;
const cVenueNum = buf.readUInt8(o); o += 1;
const cRaceNum = buf.readUInt8(o); o += 1;
const aP = readFixedAscii(buf, o, 9); const cPools = aP.value; o = aP.next;
return { nRaceDt, cVenueNum, cRaceNum, cPools, size: o - offset };
}
function parseRcvRpi4(buf, offset) {
let o = offset;
const nRaceDt = buf.readInt32LE(o); o += 4;
const cVenueNum = buf.readUInt8(o); o += 1;
const cRaceNum = buf.subarray(o, o + 3).toString("ascii").replace(/\0/g, ""); o += 3;
const aSel = readFixedAscii(buf, o, 11); const cSelectRaceNum = aSel.value; o = aSel.next;
const cPoolSts = String.fromCharCode(buf[o]); o += 1;
return { nRaceDt, cVenueNum, cRaceNum, cSelectRaceNum, cPoolSts, size: o - offset };
}
function parseRcvPvm(buf, offset) {
let o = offset;
const a1 = readFixedAscii(buf, o, 4); const cRaceVenue = a1.value; o = a1.next;
const a2 = readFixedAscii(buf, o, 4); const cPoolId = a2.value; o = a2.next;
const fUnitPrice = buf.readFloatLE(o); o += 4;
const fTaxRate = buf.readFloatLE(o); o += 4;
const fDeductRate = buf.readFloatLE(o); o += 4;
const a3 = readFixedAscii(buf, o, 31); const cRemarks = a3.value; o = a3.next;
return { cRaceVenue, cPoolId, fUnitPrice, fTaxRate, fDeductRate, cRemarks, size: o - offset };
}
app.get("/", (_req, res) => res.send("ABS bridge is up"));
app.get("/health", (_req, res) => {
const s = new net.Socket();
s.setTimeout(1500);
s.connect(ABS_PORT, ABS_HOST, () => { s.destroy(); res.json({ ok: true, target: `${ABS_HOST}:${ABS_PORT}` }); });
s.on("timeout", () => { s.destroy(); res.status(504).json({ ok: false, error: "TCP timeout" }); });
s.on("error", (e) => { s.destroy(); res.status(502).json({ ok: false, error: String(e) }); });
});
app.post("/abs/poll", async (req, res) => {
try {
const body = await readJsonBody(req);
const btMake = (typeof body.btMake === "string")
? body.btMake.charCodeAt(0)
: (Number.isFinite(body.btMake) ? (body.btMake | 0) : 0x00);
const header = packSndHeader({ nTxnCd: ABS_POLL, nOpCd: 0, nNumRecsSent: 0, nNumRecsRqrd: 0, nTxnId: 0, cBtMake: btMake });
const reply = await withRetry(() => absRoundtrip(header, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS);
const hdr = parseRcvHeaderFlexible(reply.subarray(0, Math.min(reply.length, 24)));
res.json({
ok: true,
target: `${ABS_HOST}:${ABS_PORT}`,
sentHeaderHex: header.toString("hex"),
replyBytes: reply.length,
replyHexFirst64: reply.subarray(0, 64).toString("hex"),
parsedRcvHeaderRaw: hdr,
normalizedRcvHeader: { ...hdr, size: 18 },
normalizedHeaderHex: buildRcvHeader18(hdr).toString("hex"),
success: hdr.nRetCd === SUCCESS
});
} catch (e) {
res.status(502).json({ ok: false, error: String(e) });
}
});
app.post("/login", async (req, res) => {
try {
const body = await readJsonBody(req);
const opCard = (body.opCard ?? "").toString();
const btId = (body.btId ?? "").toString();
const usrId = (body.usrId ?? "").toString();
const plain = (body.password ?? "");
const givenEnc = (body.passwordEnc ?? "");
if (!opCard || !btId || (!plain && !givenEnc)) {
return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" });
}
let encInfo = null;
let passwd = givenEnc ? String(givenEnc) : (encInfo = encryptPasswordLikeMFC(String(plain))).enc;
const btMake = (typeof body.btMake === "string")
? body.btMake.charCodeAt(0)
: (Number.isFinite(body.btMake) ? (body.btMake | 0) : 0x00);
const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: LOGBT, nNumRecsSent: 1, nNumRecsRqrd: 1, nTxnId: 0, cBtMake: btMake });
const sndLog = packSndLog({ usrId, opCard, passwd, btId });
const sendBuf = Buffer.concat([sndHeader, sndLog]);
const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS);
const rcvHeader = parseRcvHeaderFlexible(reply.subarray(0, Math.min(reply.length, 24)));
const json = {
ok: true,
target: `${ABS_HOST}:${ABS_PORT}`,
sentHeaderHex: sndHeader.toString("hex"),
sentBodyHex: sndLog.toString("hex"),
replyBytes: reply.length,
replyHexFirst64: reply.subarray(0, 64).toString("hex"),
rcvHeaderRaw: rcvHeader,
success: rcvHeader.nRetCd === SUCCESS
};
if (!givenEnc && encInfo) {
json.encryption = { used: true, utcSeconds: encInfo.utcSeconds, key: encInfo.key, encPasswd: passwd };
} else if (givenEnc) {
json.encryption = { used: false, providedPasswordEnc: true };
}
let bodyOffset = rcvHeader.size;
bodyOffset = adjustForOptionalOp(reply, bodyOffset).offset;
bodyOffset = alignToAsciiYear(reply, bodyOffset);
json.offsetProbe = {
bodyOffset,
around: reply.subarray(Math.max(0, bodyOffset - 8), Math.min(reply.length, bodyOffset + 48)).toString("ascii")
};
if (json.success && reply.length >= bodyOffset + 20) {
try { json.log = parseRcvLog(reply, bodyOffset); } catch (e) { json.parseLogError = String(e); }
}
res.json(json);
} catch (e) {
res.status(502).json({ ok: false, error: String(e) });
}
});
app.post("/download/btinfo", async (req, res) => {
try {
const body = await readJsonBody(req);
const opCard = (body.opCard ?? "").toString();
const btId = (body.btId ?? "").toString();
const usrId = (body.usrId ?? "").toString();
const plain = (body.password ?? "");
const encPass = (body.passwordEnc ?? "");
if (!opCard || !btId || (!plain && !encPass)) {
return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" });
}
const passwd = encPass ? String(encPass) : encryptPasswordLikeMFC(String(plain)).enc;
const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: BTI, nNumRecsSent: 1, nNumRecsRqrd: 1, nTxnId: 0, cBtMake: 0 });
const sndLog = packSndLog({ usrId, opCard, passwd, btId });
const sendBuf = Buffer.concat([sndHeader, sndLog]);
const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS);
const result = { ok: true, replyBytes: reply.length, replyHexFirst64: reply.subarray(0, 64).toString("hex") };
let offset = 0;
if (remaining(reply, offset) < 18) return res.json({ ok: false, error: "truncated: no header" });
const hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24)));
result.primaryHeader = hdr;
const hdrLenAligned = (hdr.size + 3) & ~3;
if (!safeAdvanceOrBreak(reply, 0, hdrLenAligned, result)) {
result.error = "truncated: incomplete header (after alignment)";
return res.json(result);
}
offset = hdrLenAligned;
if (hdr.nRetCd !== SUCCESS) {
result.error = `server returned nRetCd=${hdr.nRetCd}`;
return res.json(result);
}
if (!safeAdvanceOrBreak(reply, offset, 5 + 31 + 5 + 4 + 1 + 4 + 4 + 4 + 4, result)) {
result.error = "truncated before BTI master";
return res.json(result);
}
const btiMst = parseRcvBtiMst(reply, offset);
offset += btiMst.size;
result.btiMst = btiMst;
const details = [];
let readCount = 0;
while (readCount < hdr.nNumRecs) {
if (looksLikeHeaderAt(reply, offset)) break;
if (!safeAdvanceOrBreak(reply, offset, 1 + 3 + 1, result)) break;
const dt = parseRcvBtiDtl(reply, offset);
offset += dt.size;
details.push(dt);
readCount++;
}
result.btiDetails = details;
result.btiReadCount = readCount;
result.parsedBytes = offset;
res.json(result);
} catch (e) {
res.status(502).json({ ok: false, error: String(e), stack: e.stack });
}
});
app.post("/download/rpinfo", async (req, res) => {
try {
const body = await readJsonBody(req);
const debug = String((req.query.debug ?? "")).toLowerCase() === "1";
const opCard = (body.opCard ?? "").toString();
const btId = (body.btId ?? "").toString();
const usrId = (body.usrId ?? "").toString();
const plain = (body.password ?? "");
const encPass = (body.passwordEnc ?? "");
if (!opCard || !btId || (!plain && !encPass)) {
return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" });
}
const passwd = encPass ? String(encPass) : encryptPasswordLikeMFC(String(plain)).enc;
const btAdvSalesFlg = body.btAdvSalesFlg || "N";
const nBtAdvSaleEnb = btAdvSalesFlg === "A" ? 1 : 0;
const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: RPI, nNumRecsSent: 1, nNumRecsRqrd: nBtAdvSaleEnb, nTxnId: 0, cBtMake: 0 });
const sndLog = packSndLog({ usrId, opCard, passwd, btId });
const sendBuf = Buffer.concat([sndHeader, sndLog]);
const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS);
const result = { ok: true, replyBytes: reply.length, replyHexFirst64: reply.subarray(0, 64).toString("hex") };
let offset = 0;
if (remaining(reply, offset) < 18) return res.json({ ok: false, error: "truncated: no top header" });
const topHdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24)));
offset += topHdr.size;
result.topHeader = topHdr;
if (topHdr.nRetCd !== SUCCESS) {
result.error = `server returned nRetCd=${topHdr.nRetCd}`;
return res.json(result);
}
if (debug) result.debug_afterTopHdr = { offset, hex: hexSlice(reply, offset, 128) };
function parseSection(minRecBytes, recParser, outArrName, outHeaderName, expected_nTxnCd) {
const items = [];
let read = 0;
let hdr = null;
if (remaining(reply, offset) < 18) {
result[outArrName] = items;
result[outHeaderName] = null;
result[outArrName.replace(/s?$/, "ReadCount")] = read;
if (debug) result[`debug_${outArrName}_no_data`] = `No data for ${outArrName}, offset=${offset}, remaining=${remaining(reply, offset)}`;
return items;
}
// Try parsing header
offset = alignToPossibleHeader(reply, offset, expected_nTxnCd);
if (looksLikeHeaderAt(reply, offset, expected_nTxnCd)) {
hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24)));
offset += hdr.size;
const adj = adjustForOptionalOp(reply, offset);
offset = adj.offset;
if (debug) result[`debug_${outArrName}_header`] = { offset, hdr, hex: hexSlice(reply, offset - hdr.size, 64), expected_nTxnCd };
while (read < hdr.nNumRecs && remaining(reply, offset) >= minRecBytes) {
try {
const rec = recParser(reply, offset);
if (rec && rec.nRaceDt >= 20000101 && rec.nRaceDt <= 21001231 && rec.cVenueNum >= 1 && rec.cVenueNum <= MAX_VENUES) {
items.push(rec);
offset += rec.size;
read++;
} else {
offset += 1;
if (debug) result[`debug_${outArrName}_invalid`] = `Invalid record at offset ${offset}: ${JSON.stringify(rec)}`;
}
} catch (e) {
if (debug) result[`debug_${outArrName}_error`] = `Error at offset ${offset}: ${e.message}`;
offset += 1;
break;
}
}
} else {
// Fallback: try parsing data directly if no valid header
if (remaining(reply, offset) >= minRecBytes) {
try {
const rec = recParser(reply, offset);
if (rec && rec.nRaceDt >= 20000101 && rec.nRaceDt <= 21001231 && rec.cVenueNum >= 1 && rec.cVenueNum <= MAX_VENUES) {
items.push(rec);
offset += rec.size;
read++;
if (debug) result[`debug_${outArrName}_fallback`] = `Parsed ${outArrName} data without valid header at offset ${offset - rec.size}`;
} else {
if (debug) result[`debug_${outArrName}_fallback_invalid`] = `Fallback invalid record at offset ${offset}: ${JSON.stringify(rec)}`;
}
} catch (e) {
if (debug) result[`debug_${outArrName}_fallback_error`] = `Fallback parse error at offset ${offset}: ${e.message}`;
}
}
if (debug) result[`debug_${outArrName}_noheader`] = `No valid header for ${outArrName} at offset ${offset}, expected nTxnCd=${expected_nTxnCd}, hex=${hexSlice(reply, offset, 64)}`;
}
result[outHeaderName] = hdr;
result[outArrName] = items;
result[outArrName.replace(/s?$/, "ReadCount")] = read;
if (debug) result[`debug_${outArrName}_after`] = { offset, hex: hexSlice(reply, offset, 128) };
return items;
}
// Parse sections with expected nTxnCd
const sections = [
{ minRecBytes: 4 + 1 + 1 + 15 + 41, parser: parseRcvRpi1, arrName: "rpi1", headerName: "rpi1Header", nTxnCd: 101 },
{ minRecBytes: 4 + 1 + 1 + 25 + 4, parser: parseRcvRpi2, arrName: "rpi2", headerName: "rpi2Header", nTxnCd: 102 },
{ minRecBytes: 4 + 1 + 1 + 9, parser: parseRcvRpi3, arrName: "rpi3", headerName: "rpi3Header", nTxnCd: 103 },
{ minRecBytes: 4 + 1 + 3 + 11 + 1, parser: parseRcvRpi4, arrName: "rpi4", headerName: "rpi4Header", nTxnCd: 104 },
{ minRecBytes: 4 + 4 + 4 + 4 + 4 + 31, parser: parseRcvPvm, arrName: "pvms", headerName: "pvmHeader", nTxnCd: 105 }
];
for (const section of sections) {
parseSection(section.minRecBytes, section.parser, section.arrName, section.headerName, section.nTxnCd);
}
result.parsedBytes = offset;
// Build raceCard
const raceCard = [];
for (const v of result.rpi1 || []) {
const raceDate = getDateStr(v.nRaceDt);
const venueId = getVenueIdStr(v.cVenueNum);
const venueSts = v.cVenueSts;
for (let nRace = 0; nRace < MAX_RACES; nRace++) {
if (v.cRaces[nRace] === "0" || !v.cRaces[nRace]) continue;
const raceNum = (nRace + 1).toString().padStart(2, "0");
raceCard.push({
RACE_DATE: raceDate,
RACE_VENUE_ID: venueId,
RACE_NO: raceNum,
STOPBET_STATUS: v.cRaces[nRace] === "C" ? "S" : v.cRaces[nRace],
RACE_STATUS: v.cRaces[nRace] === "C" ? "C" : "V",
VENUE_STATUS: venueSts,
START_TIME: "",
ADVT: v.cAdvertisement,
horses: [],
pools: []
});
}
}
for (const r of result.rpi2 || []) {
const raceDate = getDateStr(r.nRaceDt);
const venueId = getVenueIdStr(r.cVenueNum);
const raceNum = r.cRaceNum.toString().padStart(2, "0");
for (let nHorse = 0; nHorse < MAX_HORSES; nHorse++) {
if (r.cHorses[nHorse] === "0" || !r.cHorses[nHorse]) continue;
const horseNum = (nHorse + 1).toString().padStart(2, "0");
const entry = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId && e.RACE_NO === raceNum);
if (entry) {
entry.horses.push({
RACE_HORSE_NO: horseNum,
HORSE_STATUS: r.cHorses[nHorse],
REFUND_FILE_STATUS: ""
});
if (!entry.START_TIME) {
entry.START_TIME = getTimeStr(r.nRaceStartTm);
}
}
}
}
for (const p of result.rpi3 || []) {
const raceDate = getDateStr(p.nRaceDt);
const venueId = getVenueIdStr(p.cVenueNum);
const raceNum = p.cRaceNum.toString().padStart(2, "0");
for (let nPoolId = 0; nPoolId < MAX_POOLS; nPoolId++) {
if (p.cPools[nPoolId] === "0" || !p.cPools[nPoolId]) continue;
const entry = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId && e.RACE_NO === raceNum);
if (entry) {
entry.pools.push({
POOL_ID: getPoolIdStr(nPoolId + 1),
POOL_STATUS: p.cPools[nPoolId],
BETTING_CENTRE_ID: "",
SELECT_RACE_NO: ""
});
}
}
}
for (const s of result.rpi4 || []) {
const raceDate = getDateStr(s.nRaceDt);
const venueId = getVenueIdStr(s.cVenueNum);
const raceNum = s.cRaceNum;
const venueSts = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId)?.VENUE_STATUS || "V";
let entry = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId && e.RACE_NO === raceNum);
if (!entry) {
entry = {
RACE_DATE: raceDate,
RACE_VENUE_ID: venueId,
RACE_NO: raceNum,
STOPBET_STATUS: "",
RACE_STATUS: s.cPoolSts,
VENUE_STATUS: venueSts,
START_TIME: "",
ADVT: "",
horses: [],
pools: []
};
raceCard.push(entry);
}
entry.pools.push({
POOL_ID: getMultiLegPoolIdStr(s.cRaceNum),
POOL_STATUS: s.cPoolSts,
BETTING_CENTRE_ID: "",
SELECT_RACE_NO: s.cSelectRaceNum
});
}
for (const p of result.rpi3 || []) {
if (!p.cPools.includes("TBP") && !p.cPools.includes("MJP") && !p.cPools.includes("JPP")) continue;
const raceDate = getDateStr(p.nRaceDt);
const venueId = getVenueIdStr(p.cVenueNum);
const raceNum = p.cRaceNum.toString().padStart(2, "0");
for (const s of result.rpi4 || []) {
if (s.cSelectRaceNum && s.nRaceDt === p.nRaceDt && s.cVenueNum === p.cVenueNum) {
const selectRaceNum = s.cSelectRaceNum.slice(0, 2);
const selectEntry = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId && e.RACE_NO === selectRaceNum);
const targetEntry = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId && e.RACE_NO === raceNum);
if (selectEntry && targetEntry) {
targetEntry.STOPBET_STATUS = selectEntry.STOPBET_STATUS;
}
}
}
}
const pvmData = (result.pvms || []).map(p => ({
RACE_VENUE: p.cRaceVenue,
POOL_ID: p.cPoolId,
UNIT_PRICE: p.fUnitPrice,
TAX_RATE: p.fTaxRate,
DEDUCT_RATE: p.fDeductRate,
REMARKS: p.cRemarks
}));
result.raceCard = raceCard;
result.pvm = pvmData;
res.json(result);
} catch (e) {
res.status(502).json({ ok: false, error: String(e), stack: e.stack });
}
});
const HTTP_PORT = Number(process.env.HTTP_BRIDGE_PORT || 8080);
app.listen(HTTP_PORT, () => {
console.log(`ABS HTTP bridge listening on :${HTTP_PORT}`);
console.log(`Target ABS server: ${ABS_HOST}:${ABS_PORT}`);
});