const express = require('express');
const crypto = require('crypto');
const path = require('path');
const User = require('./models/User');
const { editMsg, editInlineMsg } = require('./utils/editMsg');
const app = express();
app.use(express.json());
// ── CORS ──
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', process.env.WEBAPP_ORIGIN || '*');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Init-Data');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
// ── Static files ──
app.use(express.static(path.join(__dirname, 'webapp')));
app.get('/carrom', (req, res) => res.sendFile(path.join(__dirname, 'webapp', 'carrom.html')));
app.get('/cards', (req, res) => res.sendFile(path.join(__dirname, 'webapp', 'cards.html')));
app.get('/omi', (req, res) => res.sendFile(path.join(__dirname, 'webapp', 'omi.html')));
app.get('/deck-demo', (req, res) => res.sendFile(path.join(__dirname, 'webapp', 'deck-demo.html')));
// ── Auth ──
function verifyInitData(raw) {
try {
const params = new URLSearchParams(raw);
const hash = params.get('hash'); if (!hash) return null;
params.delete('hash');
const cs = [...params.entries()].sort(([a],[b])=>a.localeCompare(b)).map(([k,v])=>`${k}=${v}`).join('\n');
const secret = crypto.createHmac('sha256','WebAppData').update(process.env.BOT_TOKEN).digest();
if (crypto.createHmac('sha256',secret).update(cs).digest('hex') !== hash) return null;
return JSON.parse(params.get('user') || 'null');
} catch { return null; }
}
function auth(req, res, next) {
const raw = req.headers['x-init-data'] || req.query.initData;
if (!raw) return res.status(401).json({ ok: false, error: 'Missing auth' });
const user = verifyInitData(raw);
if (!user) return res.status(403).json({ ok: false, error: 'Invalid auth' });
req.tgUser = user; next();
}
// ── Helper: DB query with timeout ──
async function dbFind(query, timeout = 5000) {
return Promise.race([
User.findOne(query).lean(),
new Promise((_, reject) => setTimeout(() => reject(new Error('DB timeout')), timeout))
]);
}
// ── Helper: getName with timeout (won't hang server) ──
async function safeGetName(userId) {
try {
const user = await User.findOne({ userId }).select('username').lean().maxTimeMS(3000);
if (user?.username) return user.username;
if (global._tgClient) {
const ent = await Promise.race([
global._tgClient.getEntity(userId),
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 3000))
]);
const name = ent.firstName || ent.username || `User${userId.slice(-4)}`;
// Save for future use
await User.updateOne({ userId }, { $set: { username: name } }).catch(() => {});
return name;
}
} catch {}
return `User${userId.toString().slice(-4)}`;
}
// ── GET /status ──
app.get('/status', auth, async (req, res) => {
try {
const userId = req.tgUser.id.toString();
const user = await User.findOne({ userId }).select('lastDaily lastAdWatchDate dailyAdsCount').lean().maxTimeMS(5000);
const today = new Date().toISOString().split('T')[0];
if (!user) return res.json({ canClaim: true, hoursLeft: null, adsLeft: 50 });
if (req.query.type === 'mission_inpage') {
const ld = user.lastAdWatchDate?.toISOString().split('T')[0];
const cnt = ld !== today ? 0 : (user.dailyAdsCount || 0);
return res.json({ canClaim: 50 - cnt > 0, adsLeft: Math.max(0, 50 - cnt) });
}
const ld = user.lastDaily?.toISOString().split('T')[0];
res.json({ canClaim: ld !== today, hoursLeft: ld === today ? 24 : null });
} catch (err) { res.status(500).json({ error: 'Error' }); }
});
// ── POST /claim ──
app.post('/claim', auth, async (req, res) => {
try {
const { type } = req.body;
const userId = req.tgUser.id.toString();
let user = await User.findOne({ userId }) || await User.create({ userId, username: req.tgUser.username });
const today = new Date().toISOString().split('T')[0];
if (type === 'daily') {
if (user.lastDaily?.toISOString().split('T')[0] === today) return res.json({ ok: false, error: 'Already claimed' });
user.wallet += 3000; user.xp += 100; user.lastDaily = new Date(); await user.save();
if (global._tgClient) try { await global._tgClient.sendMessage(userId, { message: `🎉 +$3000 daily reward!` }); } catch {}
return res.json({ ok: true, reward: 3000, wallet: user.wallet });
} else if (type === 'mission') {
const ld = user.lastAdWatchDate?.toISOString().split('T')[0];
if (ld !== today) user.dailyAdsCount = 0;
if (user.dailyAdsCount >= 50) return res.json({ ok: false, error: 'Limit reached' });
user.wallet += 200; user.dailyAdsCount++; user.lastAdWatchDate = new Date(); user.lastMissionClaim = new Date();
await user.save();
return res.json({ ok: true, reward: 200, adsLeft: 50 - user.dailyAdsCount, wallet: user.wallet });
}
res.status(400).json({ ok: false, error: 'Invalid type' });
} catch (err) { res.status(500).json({ ok: false, error: 'Error' }); }
});
// ── GET /leaderboard — getEntity with timeout ──
app.get('/leaderboard', auth, async (req, res) => {
try {
const { type = 'wallet', period = 'all' } = req.query;
const sorts = { bank:{bank:-1}, wallet:{wallet:-1}, xp:{xp:-1}, kills:{kills:-1}, robs:{robs:-1} };
let df = {};
const now = new Date();
if (period === 'daily') df = { lastDaily: { $gte: new Date(now.toISOString().split('T')[0]) } };
else if (period === 'weekly') df = { lastDaily: { $gte: new Date(now - 7*86400000) } };
else if (period === 'monthly') df = { lastDaily: { $gte: new Date(now - 30*86400000) } };
const users = await User.find(df).sort(sorts[type]||{wallet:-1}).limit(50)
.select('userId username wallet bank xp level kills robs').lean().maxTimeMS(5000);
// Resolve names with timeout — won't hang
for (const u of users) {
if (!u.username) u.username = await safeGetName(u.userId);
}
res.json({ ok: true, leaderboard: users, type, period });
} catch (err) { console.error('[API] leaderboard:', err.message); res.status(500).json({ error: 'Error' }); }
});
// ── GET /me ──
app.get('/me', auth, async (req, res) => {
try {
const userId = req.tgUser.id.toString();
let user = await User.findOne({ userId }).lean().maxTimeMS(5000);
if (!user) { user = (await User.create({ userId, username: req.tgUser.username })).toObject(); }
res.json({ ok: true, user });
} catch (err) { res.status(500).json({ error: 'Error' }); }
});
async function editGameChatResult(game, text) {
if (!global._tgClient) return;
try {
if (game?.inlineMsgId) {
await editInlineMsg(global._tgClient, game.inlineMsgId, text, null);
return;
}
if (game?.chatId && game?.chatMsgId) {
await editMsg(global._tgClient, game.chatId, game.chatMsgId, text, null);
}
} catch (e) {
console.error('[API] editGameChatResult:', e.message);
}
}
// ── Carrom API ──
app.post('/carrom/mode', auth, (req, res) => {
try {
const { gameId, modeKey } = req.body;
const sessions = require('./games/sessions');
const game = sessions.get(gameId);
if (!game?.selectMode) return res.json({ ok: false, error: 'Not found' });
const r = game.selectMode(req.tgUser.id.toString(), modeKey);
if (r.error) return res.json({ ok: false, error: r.error });
res.json({ ok: true, state: game.getState(req.tgUser.id.toString()) });
} catch (err) {
console.error('[API] carrom/mode:', err.message);
res.status(500).json({ ok: false, error: 'Error' });
}
});
app.post('/carrom/start', auth, async (req, res) => {
try {
const { gameId, modeKey } = req.body;
const sessions = require('./games/sessions');
const game = sessions.get(gameId);
if (!game) return res.json({ ok: false, error: 'Not found' });
if (game.status !== 'lobby') return res.json({ ok: false, error: 'Already started' });
if (game.hostId !== req.tgUser.id.toString()) return res.json({ ok: false, error: 'Only host' });
if (game.players.length < 2) return res.json({ ok: false, error: 'Need 2+' });
const started = game.start(modeKey);
if (started.error) return res.json({ ok: false, error: started.error });
if (game._timer) clearTimeout(game._timer);
if (game._autoStart) clearTimeout(game._autoStart);
for (const p of game.players) {
let u = await User.findOne({ userId: p.userId }) || await User.create({ userId: p.userId });
u.wallet -= game.bet;
await u.save();
}
res.json({ ok: true, state: game.getState(req.tgUser.id.toString()) });
} catch (err) {
console.error('[API] carrom/start:', err.message);
res.status(500).json({ ok: false, error: 'Error' });
}
});
app.get('/carrom/state', auth, (req, res) => {
try {
const sessions = require('./games/sessions');
const game = sessions.get(req.query.gameId);
if (!game?.getState) return res.json({ ok: false, error: 'Not found' });
res.json({ ok: true, state: game.getState(req.tgUser.id.toString()) });
} catch (err) {
res.status(500).json({ ok: false, error: 'Error' });
}
});
app.post('/carrom/move', auth, (req, res) => {
try {
const sessions = require('./games/sessions');
const game = sessions.get(req.body.gameId);
if (!game?.moveStriker) return res.json({ ok: false, error: 'Not found' });
const r = game.moveStriker(req.tgUser.id.toString(), req.body);
res.json(r.error ? { ok: false, error: r.error } : { ok: true, state: game.getState(req.tgUser.id.toString()) });
} catch (err) {
res.status(500).json({ ok: false, error: 'Error' });
}
});
app.post('/carrom/shoot', auth, async (req, res) => {
try {
const sessions = require('./games/sessions');
const leveling = require('./utils/leveling');
const game = sessions.get(req.body.gameId);
if (!game?.shoot) return res.json({ ok: false, error: 'Not found' });
const r = game.shoot(req.tgUser.id.toString(), req.body);
if (r.error) return res.json({ ok: false, error: r.error });
if (game.status === 'ended') {
const winners = game.getWinnerPlayers();
const pot = game.bet * game.players.length;
const share = winners.length ? Math.floor(pot / winners.length) : 0;
for (const w of winners) {
let u = await User.findOne({ userId: w.userId }) || await User.create({ userId: w.userId });
u.wallet += share;
await leveling.addXP(w.userId, 200);
await u.save();
}
await editGameChatResult(game, `🏆 CARROM OVER!\n${winners.map(w => w.name).join(' & ')} win $${pot}!`);
setTimeout(() => sessions.delete(req.body.gameId), 5000);
}
res.json({ ok: true, state: game.getState(req.tgUser.id.toString()) });
} catch (err) {
console.error('[API] carrom/shoot:', err.message);
res.status(500).json({ ok: false, error: 'Error' });
}
});
app.post('/carrom/forfeit', auth, async (req, res) => {
try {
const { gameId } = req.body;
const sessions = require('./games/sessions');
const leveling = require('./utils/leveling');
const game = sessions.get(gameId);
if (!game?.forfeit) return res.json({ ok: false, error: 'Not found' });
const userId = req.tgUser.id.toString();
const wasPlaying = game.status === 'playing' || game.status === 'board_over';
const r = game.forfeit(userId);
if (r.error) return res.json({ ok: false, error: r.error });
const quitter = game.forfeitedBy;
if (wasPlaying) {
const winners = game.getWinnerPlayers();
const pot = game.bet * game.players.length;
const share = winners.length ? Math.floor(pot / winners.length) : 0;
for (const w of winners) {
let u = await User.findOne({ userId: w.userId }) || await User.create({ userId: w.userId });
u.wallet += share;
await leveling.addXP(w.userId, 100);
await u.save();
}
await editGameChatResult(game, `🏳️ ${quitter} gave up!\n${winners.map(w => w.name).join(' & ')} win $${pot}!`);
}
setTimeout(() => sessions.delete(gameId), 5000);
res.json({ ok: true, state: game.getState(userId) });
} catch (err) {
console.error('[API] carrom/forfeit:', err.message);
res.status(500).json({ ok: false, error: 'Error' });
}
});
// ── Card Web Game API ──
app.get('/wcards/state', auth, (req, res) => {
try {
const sessions = require('./games/sessions');
const game = sessions.get(req.query.gameId);
if (!game?.getState) return res.json({ ok: false, error: 'Not found' });
res.json({ ok: true, state: game.getState(req.tgUser.id.toString()) });
} catch (err) { res.status(500).json({ ok: false, error: 'Error' }); }
});
app.post('/wcards/start', auth, async (req, res) => {
try {
const sessions = require('./games/sessions');
const game = sessions.get(req.body.gameId);
if (!game) return res.json({ ok: false, error: 'Not found' });
if (game.hostId !== req.tgUser.id.toString()) return res.json({ ok: false, error: 'Only host' });
if (!game.start()) return res.json({ ok: false, error: 'Need 2+' });
if (game._timer) clearTimeout(game._timer);
if (game._autoStart) clearTimeout(game._autoStart);
for (const p of game.players) { let u = await User.findOne({userId:p.userId})||await User.create({userId:p.userId}); u.wallet-=game.bet; await u.save(); }
res.json({ ok: true, state: game.getState(req.tgUser.id.toString()) });
} catch (err) { res.status(500).json({ ok: false, error: 'Error' }); }
});
app.post('/wcards/play', auth, async (req, res) => {
try {
const sessions = require('./games/sessions');
const leveling = require('./utils/leveling');
const game = sessions.get(req.body.gameId);
if (!game) return res.json({ ok: false, error: 'Not found' });
const r = game.playCard(req.tgUser.id.toString(), req.body.cardIndex);
if (r.error) return res.json({ ok: false, error: r.error });
if (game.status === 'ended') {
const winners = game.getWinners();
const pot = game.bet * game.players.length;
const share = Math.floor(pot / winners.length);
for (const w of winners) { let u = await User.findOne({userId:w.userId})||await User.create({userId:w.userId}); u.wallet+=share; await leveling.addXP(w.userId,150); await u.save(); }
await editGameChatResult(game, `🃏 CARDS OVER!\n${winners.map(w=>w.name).join(' & ')} win $${pot}!`);
setTimeout(() => sessions.delete(req.body.gameId), 5000);
}
res.json({ ok: true, state: game.getState(req.tgUser.id.toString()) });
} catch (err) { res.status(500).json({ ok: false, error: 'Error' }); }
});
app.post('/wcards/next', auth, (req, res) => {
try {
const sessions = require('./games/sessions');
const game = sessions.get(req.body.gameId);
if (!game) return res.json({ ok: false, error: 'Not found' });
game.nextRound();
res.json({ ok: true, state: game.getState(req.tgUser.id.toString()) });
} catch (err) { res.status(500).json({ ok: false, error: 'Error' }); }
});
app.post('/wcards/forfeit', auth, async (req, res) => {
try {
const sessions = require('./games/sessions');
const leveling = require('./utils/leveling');
const game = sessions.get(req.body.gameId);
if (!game?.forfeit) return res.json({ ok: false, error: 'Not found' });
const userId = req.tgUser.id.toString();
const wasPlaying = game.status === 'playing' || game.status === 'reveal';
const r = game.forfeit(userId);
if (r.error) return res.json({ ok: false, error: r.error });
const quitter = game.forfeitedBy;
if (wasPlaying) {
const winners = game.getWinners();
const pot = game.bet * game.players.length;
const share = winners.length ? Math.floor(pot / winners.length) : 0;
for (const w of winners) { let u = await User.findOne({userId:w.userId})||await User.create({userId:w.userId}); u.wallet+=share; await leveling.addXP(w.userId,75); await u.save(); }
await editGameChatResult(game, `🏳️ ${quitter} gave up!\n${winners.map(w=>w.name).join(' & ')} win $${pot}!`);
}
setTimeout(() => sessions.delete(req.body.gameId), 4000);
res.json({ ok: true, state: game.getState(userId) });
} catch (err) { console.error('[API] wcards/forfeit:', err.message); res.status(500).json({ ok: false, error: 'Error' }); }
});
// ── Omi API ──
app.get('/omi/state', auth, (req, res) => {
try {
const sessions = require('./games/sessions');
const game = sessions.get(req.query.gameId);
if (!game?.getState) return res.json({ ok: false, error: 'Not found' });
res.json({ ok: true, state: game.getState(req.tgUser.id.toString()) });
} catch { res.status(500).json({ ok: false, error: 'Error' }); }
});
app.post('/omi/start', auth, async (req, res) => {
try {
const sessions = require('./games/sessions');
const game = sessions.get(req.body.gameId);
if (!game) return res.json({ ok: false, error: 'Not found' });
if (game.hostId !== req.tgUser.id.toString()) return res.json({ ok: false, error: 'Only host' });
if (!game.start()) return res.json({ ok: false, error: 'Need 2+' });
if (game._timer) clearTimeout(game._timer);
if (game._autoStart) clearTimeout(game._autoStart);
for (const p of game.players) { let u = await User.findOne({userId:p.userId})||await User.create({userId:p.userId}); u.wallet-=game.bet; await u.save(); }
res.json({ ok: true, state: game.getState(req.tgUser.id.toString()) });
} catch (err) { res.status(500).json({ ok: false, error: 'Error' }); }
});
app.post('/omi/trump', auth, (req, res) => {
try {
const sessions = require('./games/sessions');
const game = sessions.get(req.body.gameId);
if (!game) return res.json({ ok: false, error: 'Not found' });
const r = game.chooseTrump(req.tgUser.id.toString(), req.body.suit);
if (r.error) return res.json({ ok: false, error: r.error });
res.json({ ok: true, state: game.getState(req.tgUser.id.toString()) });
} catch { res.status(500).json({ ok: false, error: 'Error' }); }
});
app.post('/omi/play', auth, async (req, res) => {
try {
const sessions = require('./games/sessions');
const leveling = require('./utils/leveling');
const game = sessions.get(req.body.gameId);
if (!game) return res.json({ ok: false, error: 'Not found' });
const r = game.playCard(req.tgUser.id.toString(), req.body.cardIndex);
if (r.error) return res.json({ ok: false, error: r.error });
if (game.status === 'ended') {
const winners = game.getWinners();
const pot = game.bet * game.players.length;
const share = Math.floor(pot / winners.length);
for (const w of winners) { let u = await User.findOne({userId:w.userId})||await User.create({userId:w.userId}); u.wallet+=share; await leveling.addXP(w.userId,150); await u.save(); }
await editGameChatResult(game, `🃏 OMI OVER!\n${winners.map(w=>w.name).join(' & ')} win $${pot}!`);
setTimeout(() => sessions.delete(req.body.gameId), 5000);
}
res.json({ ok: true, state: game.getState(req.tgUser.id.toString()) });
} catch (err) { res.status(500).json({ ok: false, error: 'Error' }); }
});
app.post('/omi/forfeit', auth, async (req, res) => {
try {
const sessions = require('./games/sessions');
const leveling = require('./utils/leveling');
const game = sessions.get(req.body.gameId);
if (!game?.forfeit) return res.json({ ok: false, error: 'Not found' });
const userId = req.tgUser.id.toString();
const wasPlaying = game.status === 'playing' || game.status === 'chooseTrump';
const r = game.forfeit(userId);
if (r.error) return res.json({ ok: false, error: r.error });
const quitter = game.forfeitedBy;
if (wasPlaying) {
const winners = game.getWinners();
const pot = game.bet * game.players.length;
const share = winners.length ? Math.floor(pot / winners.length) : 0;
for (const w of winners) { let u = await User.findOne({userId:w.userId})||await User.create({userId:w.userId}); u.wallet+=share; await leveling.addXP(w.userId,75); await u.save(); }
await editGameChatResult(game, `🏳️ ${quitter} gave up!\n${winners.map(w=>w.name).join(' & ')} win $${pot}!`);
}
setTimeout(() => sessions.delete(req.body.gameId), 4000);
res.json({ ok: true, state: game.getState(userId) });
} catch (err) { console.error('[API] omi/forfeit:', err.message); res.status(500).json({ ok: false, error: 'Error' }); }
});
// ── Start ──
const PORT = parseInt(process.env.PORT || '7860');
function startApi() { app.listen(PORT, () => console.log(`[API] Port ${PORT}`)); }
module.exports = { startApi };