import express from "express"; import cors from "cors"; import session from "express-session"; import MongoStore from "connect-mongo"; import { MongoClient, ObjectId } from "mongodb"; import dotenv from "dotenv"; dotenv.config(); const app = express(); const PORT = process.env.PORT || 3001; // MongoDB подключение const mongoClient = new MongoClient(process.env.MONGODB_URI); let db; async function connectDB() { await mongoClient.connect(); db = mongoClient.db("resekai"); console.log("✅ Connected to MongoDB"); } // Middleware app.use(express.json()); app.use( cors({ origin: process.env.FRONTEND_URL, credentials: true, }), ); app.use( session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, store: MongoStore.create({ mongoUrl: process.env.MONGODB_URI, dbName: "resekai", collectionName: "sessions", }), cookie: { secure: false, // true в production с HTTPS httpOnly: true, maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней }, }), ); // Discord OAuth2 конфиг const DISCORD_API = "https://discord.com/api/v10"; const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID; const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET; const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI; // ============ AUTH ROUTES ============ // Начало авторизации Discord app.get("/auth/discord", (req, res) => { const params = new URLSearchParams({ client_id: DISCORD_CLIENT_ID, redirect_uri: DISCORD_REDIRECT_URI, response_type: "code", scope: "identify email", }); res.redirect(`https://discord.com/api/oauth2/authorize?${params}`); }); // Callback от Discord app.get("/auth/discord/callback", async (req, res) => { const { code } = req.query; if (!code) { return res.redirect(`${process.env.FRONTEND_URL}?error=no_code`); } try { // Получаем токен const tokenResponse = await fetch(`${DISCORD_API}/oauth2/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ client_id: DISCORD_CLIENT_ID, client_secret: DISCORD_CLIENT_SECRET, grant_type: "authorization_code", code, redirect_uri: DISCORD_REDIRECT_URI, }), }); const tokenData = await tokenResponse.json(); if (!tokenData.access_token) { console.error("Token error:", tokenData); return res.redirect(`${process.env.FRONTEND_URL}?error=token_failed`); } // Получаем информацию о пользователе const userResponse = await fetch(`${DISCORD_API}/users/@me`, { headers: { Authorization: `Bearer ${tokenData.access_token}`, }, }); const discordUser = await userResponse.json(); // Сохраняем или обновляем пользователя в БД const users = db.collection("users"); const existingUser = await users.findOne({ discordId: discordUser.id }); let user; if (existingUser) { // Обновляем существующего пользователя await users.updateOne( { discordId: discordUser.id }, { $set: { username: discordUser.username, email: discordUser.email, avatar: discordUser.avatar, updatedAt: new Date(), }, }, ); user = await users.findOne({ discordId: discordUser.id }); } else { // Создаём нового пользователя const newUser = { discordId: discordUser.id, username: discordUser.username, email: discordUser.email, avatar: discordUser.avatar, createdAt: new Date(), updatedAt: new Date(), }; const result = await users.insertOne(newUser); user = { ...newUser, _id: result.insertedId }; } // Сохраняем в сессию req.session.userId = user._id.toString(); req.session.discordId = discordUser.id; res.redirect(`${process.env.FRONTEND_URL}?auth=success`); } catch (error) { console.error("Auth error:", error); res.redirect(`${process.env.FRONTEND_URL}?error=auth_failed`); } }); // Получить текущего пользователя app.get("/auth/me", async (req, res) => { if (!req.session.userId) { return res.json({ user: null }); } try { const users = db.collection("users"); const user = await users.findOne({ _id: new ObjectId(req.session.userId) }); if (!user) { return res.json({ user: null }); } res.json({ user: { id: user._id, discordId: user.discordId, username: user.username, email: user.email, avatar: user.avatar, }, }); } catch (error) { console.error("Get user error:", error); res.json({ user: null }); } }); // Выход app.post("/auth/logout", (req, res) => { req.session.destroy((err) => { if (err) { return res.status(500).json({ error: "Logout failed" }); } res.clearCookie("connect.sid"); res.json({ success: true }); }); }); // ============ STORIES ROUTES ============ // Middleware для проверки авторизации const requireAuth = (req, res, next) => { if (!req.session.userId) { return res.status(401).json({ error: "Unauthorized" }); } next(); }; // Получить все истории пользователя app.get("/api/stories", requireAuth, async (req, res) => { try { const stories = db.collection("stories"); const userStories = await stories .find({ userId: req.session.userId }) .sort({ updatedAt: -1 }) .toArray(); res.json(userStories); } catch (error) { console.error("Get stories error:", error); res.status(500).json({ error: "Failed to get stories" }); } }); // Получить одну историю app.get("/api/stories/:id", requireAuth, async (req, res) => { try { const stories = db.collection("stories"); const story = await stories.findOne({ _id: new ObjectId(req.params.id), userId: req.session.userId, }); if (!story) { return res.status(404).json({ error: "Story not found" }); } res.json(story); } catch (error) { console.error("Get story error:", error); res.status(500).json({ error: "Failed to get story" }); } }); // Создать историю app.post("/api/stories", requireAuth, async (req, res) => { try { const stories = db.collection("stories"); const newStory = { ...req.body, userId: req.session.userId, createdAt: new Date(), updatedAt: new Date(), }; const result = await stories.insertOne(newStory); res.json({ ...newStory, _id: result.insertedId }); } catch (error) { console.error("Create story error:", error); res.status(500).json({ error: "Failed to create story" }); } }); // Обновить историю app.put("/api/stories/:id", requireAuth, async (req, res) => { try { const stories = db.collection("stories"); const result = await stories.updateOne( { _id: new ObjectId(req.params.id), userId: req.session.userId, }, { $set: { ...req.body, updatedAt: new Date(), }, }, ); if (result.matchedCount === 0) { return res.status(404).json({ error: "Story not found" }); } res.json({ success: true }); } catch (error) { console.error("Update story error:", error); res.status(500).json({ error: "Failed to update story" }); } }); // Удалить историю app.delete("/api/stories/:id", requireAuth, async (req, res) => { try { const stories = db.collection("stories"); const result = await stories.deleteOne({ _id: new ObjectId(req.params.id), userId: req.session.userId, }); if (result.deletedCount === 0) { return res.status(404).json({ error: "Story not found" }); } // Также удаляем связанные сессии игры const sessions = db.collection("game_sessions"); await sessions.deleteMany({ storyId: req.params.id }); res.json({ success: true }); } catch (error) { console.error("Delete story error:", error); res.status(500).json({ error: "Failed to delete story" }); } }); // ============ GAME SESSIONS ROUTES ============ // Получить список сессий для истории app.get("/api/sessions/:storyId", requireAuth, async (req, res) => { try { const sessions = db.collection("game_sessions"); const userSessions = await sessions .find({ storyId: req.params.storyId, userId: req.session.userId, }) .sort({ updatedAt: -1 }) .toArray(); // Возвращаем список с базовой инфой (без полных сообщений) const sessionsList = userSessions.map((s) => ({ id: s._id.toString(), name: s.name || "Сессия", messagesCount: s.messages?.length || 0, createdAt: s.createdAt, updatedAt: s.updatedAt, })); res.json(sessionsList); } catch (error) { console.error("Get sessions list error:", error); res.status(500).json({ error: "Failed to get sessions" }); } }); // Получить конкретную сессию app.get("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => { try { const sessions = db.collection("game_sessions"); const session = await sessions.findOne({ _id: new ObjectId(req.params.sessionId), storyId: req.params.storyId, userId: req.session.userId, }); if (!session) { return res.status(404).json({ error: "Session not found" }); } res.json({ ...session, id: session._id.toString() }); } catch (error) { console.error("Get session error:", error); res.status(500).json({ error: "Failed to get session" }); } }); // Создать новую сессию app.post("/api/sessions/:storyId", requireAuth, async (req, res) => { try { const sessions = db.collection("game_sessions"); // Считаем существующие сессии для нумерации const existingCount = await sessions.countDocuments({ storyId: req.params.storyId, userId: req.session.userId, }); const sessionData = { storyId: req.params.storyId, userId: req.session.userId, name: req.body.name || `Сессия ${existingCount + 1}`, playerId: req.body.playerId || null, messages: [], currentState: { location: "start", health: 100, inventory: [], questProgress: {}, }, storySummary: "", keyEvents: [], createdAt: new Date(), updatedAt: new Date(), }; const result = await sessions.insertOne(sessionData); res.json({ id: result.insertedId.toString(), name: sessionData.name, messagesCount: 0, createdAt: sessionData.createdAt, updatedAt: sessionData.updatedAt, }); } catch (error) { console.error("Create session error:", error); res.status(500).json({ error: "Failed to create session" }); } }); // Обновить сессию app.put("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => { try { const sessions = db.collection("game_sessions"); // Получаем старую сессию для подсчёта новых токенов const oldSession = await sessions.findOne({ _id: new ObjectId(req.params.sessionId), userId: req.session.userId, }); const oldMessageCount = oldSession?.messages?.length || 0; // Преобразуем timestamp строки обратно в Date const messages = (req.body.messages || []).map((msg) => ({ ...msg, timestamp: new Date(msg.timestamp), })); // Убираем _id и createdAt из body чтобы не было конфликтов const { createdAt, _id, id, ...bodyWithoutMeta } = req.body; const sessionData = { ...bodyWithoutMeta, messages, updatedAt: new Date(), }; const result = await sessions.updateOne( { _id: new ObjectId(req.params.sessionId), storyId: req.params.storyId, userId: req.session.userId, }, { $set: sessionData }, ); if (result.matchedCount === 0) { return res.status(404).json({ error: "Session not found" }); } // Логируем новые токены (только для новых сообщений) const newMessages = messages.slice(oldMessageCount); if (newMessages.length > 0) { const tokenUsage = db.collection("token_usage"); const newTokens = newMessages.reduce((sum, msg) => { return sum + Math.round((msg.content?.length || 0) / 3); }, 0); if (newTokens > 0) { await tokenUsage.insertOne({ userId: req.session.userId, storyId: req.params.storyId, sessionId: req.params.sessionId, tokens: newTokens, messageCount: newMessages.length, createdAt: new Date(), }); } } res.json({ success: true }); } catch (error) { console.error("Update session error:", error); res.status(500).json({ error: "Failed to update session" }); } }); // Удалить сессию app.delete( "/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => { try { const sessions = db.collection("game_sessions"); const result = await sessions.deleteOne({ _id: new ObjectId(req.params.sessionId), storyId: req.params.storyId, userId: req.session.userId, }); if (result.deletedCount === 0) { return res.status(404).json({ error: "Session not found" }); } res.json({ success: true }); } catch (error) { console.error("Delete session error:", error); res.status(500).json({ error: "Failed to delete session" }); } }, ); // ============ PLAYER CHARACTERS ROUTES ============ // Получить всех персонажей пользователя app.get("/api/characters", requireAuth, async (req, res) => { try { const characters = db.collection("player_characters"); const userCharacters = await characters .find({ userId: req.session.userId }) .sort({ updatedAt: -1 }) .toArray(); res.json(userCharacters); } catch (error) { console.error("Get characters error:", error); res.status(500).json({ error: "Failed to get characters" }); } }); // Получить одного персонажа app.get("/api/characters/:id", requireAuth, async (req, res) => { try { const characters = db.collection("player_characters"); const character = await characters.findOne({ _id: new ObjectId(req.params.id), userId: req.session.userId, }); if (!character) { return res.status(404).json({ error: "Character not found" }); } res.json(character); } catch (error) { console.error("Get character error:", error); res.status(500).json({ error: "Failed to get character" }); } }); // Создать персонажа app.post("/api/characters", requireAuth, async (req, res) => { try { const characters = db.collection("player_characters"); const newCharacter = { ...req.body, userId: req.session.userId, createdAt: new Date(), updatedAt: new Date(), }; const result = await characters.insertOne(newCharacter); res.json({ ...newCharacter, _id: result.insertedId }); } catch (error) { console.error("Create character error:", error); res.status(500).json({ error: "Failed to create character" }); } }); // Обновить персонажа app.put("/api/characters/:id", requireAuth, async (req, res) => { try { const characters = db.collection("player_characters"); const result = await characters.updateOne( { _id: new ObjectId(req.params.id), userId: req.session.userId, }, { $set: { ...req.body, updatedAt: new Date(), }, }, ); if (result.matchedCount === 0) { return res.status(404).json({ error: "Character not found" }); } res.json({ success: true }); } catch (error) { console.error("Update character error:", error); res.status(500).json({ error: "Failed to update character" }); } }); // Удалить персонажа app.delete("/api/characters/:id", requireAuth, async (req, res) => { try { const characters = db.collection("player_characters"); const result = await characters.deleteOne({ _id: new ObjectId(req.params.id), userId: req.session.userId, }); if (result.deletedCount === 0) { return res.status(404).json({ error: "Character not found" }); } res.json({ success: true }); } catch (error) { console.error("Delete character error:", error); res.status(500).json({ error: "Failed to delete character" }); } }); // ============ ADMIN STATS ============ // Получить статистику по всем историям и токенам app.get("/api/admin/stats", requireAuth, async (req, res) => { try { const stories = db.collection("stories"); const gameSessions = db.collection("game_sessions"); const tokenUsage = db.collection("token_usage"); // Получаем все истории пользователя const userStories = await stories .find({ userId: req.session.userId }) .toArray(); // Получаем все сессии пользователя const userSessions = await gameSessions .find({ userId: req.session.userId }) .toArray(); // Получаем общее количество токенов из лога (не зависит от удалённых сессий) const tokenLogs = await tokenUsage .find({ userId: req.session.userId }) .toArray(); const totalTokensLogged = tokenLogs.reduce( (sum, log) => sum + (log.tokens || 0), 0, ); // Считаем токены в текущих сессиях (для сравнения) const currentTokens = userSessions.reduce((sum, session) => { const chars = (session.messages || []).reduce( (s, msg) => s + (msg.content?.length || 0), 0, ); return sum + Math.round(chars / 3); }, 0); // Берём максимум: залогированные или текущие (для обратной совместимости) const totalTokens = Math.max(totalTokensLogged, currentTokens); // Считаем статистику для каждой истории const storyStats = userStories.map((story) => { const storySessions = userSessions.filter( (s) => s.storyId === story._id.toString(), ); const messageCount = storySessions.reduce( (sum, s) => sum + (s.messages?.length || 0), 0, ); // Токены из лога для этой истории const storyTokenLogs = tokenLogs.filter( (l) => l.storyId === story._id.toString(), ); const loggedTokens = storyTokenLogs.reduce( (sum, l) => sum + (l.tokens || 0), 0, ); // Текущие токены в сессиях const currentStoryTokens = storySessions.reduce((sum, session) => { const chars = (session.messages || []).reduce( (s, msg) => s + (msg.content?.length || 0), 0, ); return sum + Math.round(chars / 3); }, 0); const tokens = Math.max(loggedTokens, currentStoryTokens); const lastSession = storySessions.sort( (a, b) => new Date(b.updatedAt) - new Date(a.updatedAt), )[0]; return { id: story._id.toString(), title: story.title, sessionsCount: storySessions.length, messageCount, tokens, lastPlayed: lastSession?.updatedAt || null, }; }); // Сортируем по токенам (больше сверху) storyStats.sort((a, b) => b.tokens - a.tokens); res.json({ totalStories: userStories.length, totalSessions: userSessions.length, totalTokens, stories: storyStats, }); } catch (error) { console.error("Get admin stats error:", error); res.status(500).json({ error: "Failed to get stats" }); } }); // Запуск сервера connectDB().then(() => { app.listen(PORT, () => { console.log(`🚀 Server running on http://localhost:${PORT}`); }); });