diff --git a/dbTools/chat.js b/dbTools/chat.js new file mode 100644 index 0000000..f603cfe --- /dev/null +++ b/dbTools/chat.js @@ -0,0 +1,62 @@ +const DBName = "EMI_SOCIAL"; + +const chatDB = (DB) => { + DB.chatMessagesCol = DB.db.db(DBName).collection("chat_messages"); + DB.chatMessagesCol.createIndex({ createdAt: -1 }).catch(console.error); + + DB.addChatMessage = async ({ senderId, senderProfileId, senderName, text, sourceLang }) => { + const safeText = (text || "").trim(); + if (!safeText) return false; + const message = { + senderId: senderId ? senderId + "" : "", + senderProfileId: senderProfileId ? senderProfileId + "" : "", + senderName: senderName || "Anonymous", + text: safeText, + sourceLang: sourceLang || "en", + translations: {}, + createdAt: new Date(), + }; + const result = await DB.chatMessagesCol.insertOne(message).catch((err) => { + console.log(err); + return false; + }); + if (!result || !result.insertedId) return false; + return { + ...message, + _id: result.insertedId, + }; + }; + + DB.getRecentChatMessages = async (limit = 100) => { + const safeLimit = Math.min(Math.max(parseInt(limit, 10) || 100, 1), 200); + const messages = await DB.chatMessagesCol.find({}) + .sort({ createdAt: -1 }) + .limit(safeLimit) + .toArray() + .catch((err) => { + console.log(err); + return []; + }); + return messages.reverse(); + }; + + DB.setChatMessageTranslation = async ({ messageId, targetLang, text, provider, model }) => { + if (!messageId || !targetLang || !text) return false; + const _id = typeof messageId === "string" ? DB.ObjectID(messageId) : messageId; + const fieldBase = `translations.${targetLang}`; + const update = { + $set: { + [`${fieldBase}.text`]: text, + [`${fieldBase}.provider`]: provider || "openai", + [`${fieldBase}.model`]: model || "", + [`${fieldBase}.updatedAt`]: new Date(), + }, + }; + return DB.chatMessagesCol.updateOne({ _id }, update).catch((err) => { + console.log(err); + return false; + }); + }; +}; + +module.exports = chatDB; diff --git a/index.js b/index.js index 9d96721..7c4ed23 100644 --- a/index.js +++ b/index.js @@ -183,6 +183,7 @@ const postRoute = require('./routes/post.js'); const songsRoute = require('./routes/songs.js'); const paymentsRoute = require('./routes/payments.js'); const bibleRoute = require('./routes/bible.js'); +const chatRoute = require('./routes/chat.js'); const sessionChecker = require('./middleware/sessionChecker'); // -- Private Routes app.use('/user', sessionChecker, profileRoute); @@ -190,6 +191,7 @@ app.use('/post', sessionChecker, postRoute); app.use('/payments', paymentsRoute); app.use('/bible', sessionChecker, bibleRoute); app.use('/songs', sessionChecker, songsRoute); +app.use('/chat', sessionChecker, chatRoute); // -- Public Routes const subsplashRoute = require('./routes/subsplash.js'); app.use('/subsplash', subsplashRoute); diff --git a/mongoDB.js b/mongoDB.js index d226868..4ed313b 100644 --- a/mongoDB.js +++ b/mongoDB.js @@ -9,6 +9,7 @@ const postDB = require("./dbTools/post.js"); const profileDB = require("./dbTools/profile.js"); const paymentDB = require("./dbTools/payments.js"); const songsDB = require("./dbTools/songs.js"); +const chatDB = require("./dbTools/chat.js"); console.log("Connecting to MongoDB..."); const nodeMajorVersion = parseInt((process.versions.node || "0").split(".")[0], 10); @@ -177,6 +178,7 @@ const getDB = new Promise((resolve, reject) => { profileDB(DB); paymentDB(DB); songsDB(DB); + chatDB(DB); resolve(DB); }); diff --git a/routes/chat.js b/routes/chat.js new file mode 100644 index 0000000..a15097b --- /dev/null +++ b/routes/chat.js @@ -0,0 +1,238 @@ +var express = require('express'); +var router = express.Router(); + +const DB = require("../mongoDB.js"); +const { getUserId, getProfileId } = require("../utils/sessionUtils.js"); +const { normalizeLanguageCode, translateText } = require("../utils/chatTranslation.js"); + +const ACTIVE_WINDOW_MS = 120000; +const MESSAGE_MAX_LENGTH = 500; +const activeUsers = new Map(); +const translationInflight = new Map(); + +const toDisplayName = (profile, fallbackName) => { + const firstName = profile?.profile?.firstName || ""; + const lastName = profile?.profile?.lastName || ""; + const displayName = (firstName + " " + lastName).trim(); + return displayName || fallbackName || "Anonymous"; +}; + +const pruneActiveUsers = () => { + const now = Date.now(); + for (const [profileId, entry] of activeUsers.entries()) { + if (now - entry.lastSeen > ACTIVE_WINDOW_MS) { + activeUsers.delete(profileId); + } + } +}; + +const getActiveUsersList = () => { + pruneActiveUsers(); + return Array.from(activeUsers.values()) + .sort((a, b) => b.lastSeen - a.lastSeen) + .map((entry) => ({ + profileId: entry.profileId, + userId: entry.userId, + displayName: entry.displayName, + lastSeen: entry.lastSeen, + })); +}; + +DB.getDB.then((DB) => { + const resolveTargetLanguage = (req) => { + const requested = req.query?.lang || req.headers["x-app-language"] || req.headers["accept-language"] || "en"; + return normalizeLanguageCode(requested); + }; + + const mapChatMessageForLanguage = async (message, targetLang) => { + const normalizedTarget = normalizeLanguageCode(targetLang); + const sourceLang = normalizeLanguageCode(message?.sourceLang || "auto"); + const originalText = message?.text || ""; + + if (!originalText) { + return { + ...message, + textOriginal: "", + text: "", + displayLang: sourceLang, + }; + } + + if (sourceLang === normalizedTarget) { + return { + ...message, + textOriginal: originalText, + text: originalText, + displayLang: sourceLang, + }; + } + + const cachedTranslation = message?.translations?.[normalizedTarget]?.text; + if (cachedTranslation) { + return { + ...message, + textOriginal: originalText, + text: cachedTranslation, + displayLang: normalizedTarget, + }; + } + + const translationKey = `${message?._id?.toString?.() || ""}:${normalizedTarget}`; + if (translationInflight.has(translationKey)) { + await translationInflight.get(translationKey); + const refreshed = await DB.chatMessagesCol.findOne({ _id: message._id }).catch(() => null); + const refreshedCached = refreshed?.translations?.[normalizedTarget]?.text; + if (refreshedCached) { + return { + ...message, + translations: refreshed.translations, + textOriginal: originalText, + text: refreshedCached, + displayLang: normalizedTarget, + }; + } + return { + ...message, + textOriginal: originalText, + text: originalText, + displayLang: sourceLang, + }; + } + + const inFlightTask = (async () => { + const translated = await translateText({ + text: originalText, + sourceLang, + targetLang: normalizedTarget, + }); + if (!translated?.translatedText) return null; + await DB.setChatMessageTranslation({ + messageId: message._id, + targetLang: normalizedTarget, + text: translated.translatedText, + provider: translated.provider, + model: translated.model, + }); + return translated.translatedText; + })(); + + translationInflight.set(translationKey, inFlightTask); + let translatedText = null; + try { + translatedText = await inFlightTask; + } finally { + translationInflight.delete(translationKey); + } + + return { + ...message, + textOriginal: originalText, + text: translatedText || originalText, + displayLang: translatedText ? normalizedTarget : sourceLang, + }; + }; + + const markActiveUser = async (req) => { + const userId = getUserId(req); + const profileId = req.profileInfo?._id || getProfileId(req); + if (!profileId || !userId) return null; + const profile = await DB.getProfileCache(profileId); + const displayName = toDisplayName(profile, req.userInfo?.username); + activeUsers.set(profileId + "", { + profileId: profileId + "", + userId: userId + "", + displayName, + lastSeen: Date.now(), + }); + return activeUsers.get(profileId + ""); + }; + + router.get("/messages", async (req, res) => { + try { + await markActiveUser(req); + const targetLang = resolveTargetLanguage(req); + const messages = await DB.getRecentChatMessages(req.query.limit || 100); + const translatedMessages = await Promise.all(messages.map((message) => mapChatMessageForLanguage(message, targetLang))); + return res.json({ + status: "ok", + requestedLang: targetLang, + messages: translatedMessages, + }); + } catch (error) { + console.error("Error getting chat messages", error); + return res.status(500).json({ status: "Internal server error", messages: [] }); + } + }); + + router.post("/messages", async (req, res) => { + try { + const userId = getUserId(req); + const profileId = req.profileInfo?._id || getProfileId(req); + const text = typeof req.body?.text === "string" ? req.body.text.trim() : ""; + const sourceLang = normalizeLanguageCode(req.body?.sourceLang || req.headers["x-app-language"] || "en"); + if (!text) { + return res.status(400).json({ status: "Message text is required" }); + } + if (text.length > MESSAGE_MAX_LENGTH) { + return res.status(400).json({ status: `Message too long (${MESSAGE_MAX_LENGTH} max chars)` }); + } + + const profile = await DB.getProfileCache(profileId); + const senderName = toDisplayName(profile, req.userInfo?.username); + const message = await DB.addChatMessage({ + senderId: userId, + senderProfileId: profileId, + senderName, + text, + sourceLang, + }); + if (!message) { + return res.status(500).json({ status: "Could not save message" }); + } + + activeUsers.set(profileId + "", { + profileId: profileId + "", + userId: userId + "", + displayName: senderName, + lastSeen: Date.now(), + }); + + return res.json({ + status: "ok", + message, + activeUsers: getActiveUsersList(), + }); + } catch (error) { + console.error("Error posting chat message", error); + return res.status(500).json({ status: "Internal server error" }); + } + }); + + router.get("/active", async (req, res) => { + try { + await markActiveUser(req); + return res.json({ + status: "ok", + activeUsers: getActiveUsersList(), + }); + } catch (error) { + console.error("Error getting active chat users", error); + return res.status(500).json({ status: "Internal server error", activeUsers: [] }); + } + }); + + router.post("/ping", async (req, res) => { + try { + await markActiveUser(req); + return res.json({ + status: "ok", + activeUsers: getActiveUsersList(), + }); + } catch (error) { + console.error("Error updating chat presence", error); + return res.status(500).json({ status: "Internal server error", activeUsers: [] }); + } + }); +}); + +module.exports = router; diff --git a/utils/chatTranslation.js b/utils/chatTranslation.js new file mode 100644 index 0000000..bdcc4ac --- /dev/null +++ b/utils/chatTranslation.js @@ -0,0 +1,97 @@ +const axios = require("axios"); + +const DEFAULT_MODEL = process.env.OPENAI_TRANSLATION_MODEL || process.env.OPENAI_MODEL || "gpt-4o-mini"; + +const normalizeLanguageCode = (rawLanguage) => { + if (!rawLanguage || typeof rawLanguage !== "string") return "en"; + const firstValue = rawLanguage.split(",")[0].trim().toLowerCase(); + if (!firstValue) return "en"; + const noQuality = firstValue.split(";")[0].trim(); + const shortCode = noQuality.split("-")[0].trim(); + return shortCode || "en"; +}; + +const extractOutputText = (data) => { + if (!data) return ""; + if (typeof data.output_text === "string" && data.output_text.trim()) { + return data.output_text.trim(); + } + if (!Array.isArray(data.output)) return ""; + const chunks = []; + data.output.forEach((item) => { + if (!Array.isArray(item?.content)) return; + item.content.forEach((entry) => { + if (entry?.type === "output_text" && typeof entry?.text === "string") { + chunks.push(entry.text); + } + }); + }); + return chunks.join("\n").trim(); +}; + +const translateText = async ({ text, sourceLang, targetLang }) => { + const normalizedSource = normalizeLanguageCode(sourceLang); + const normalizedTarget = normalizeLanguageCode(targetLang); + if (!text || !normalizedTarget || normalizedSource === normalizedTarget) { + return { + translatedText: text, + provider: "none", + model: "none", + }; + } + + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) return null; + + try { + const response = await axios.post( + "https://api.openai.com/v1/responses", + { + model: DEFAULT_MODEL, + input: [ + { + role: "system", + content: [ + { + type: "input_text", + text: "You translate chat messages. Keep meaning, tone, emojis, names, and references. Return only the translated text.", + }, + ], + }, + { + role: "user", + content: [ + { + type: "input_text", + text: `Translate this message from ${normalizedSource} to ${normalizedTarget}:\n\n${text}`, + }, + ], + }, + ], + }, + { + timeout: 15000, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + } + ); + + const translatedText = extractOutputText(response?.data); + if (!translatedText) return null; + return { + translatedText, + provider: "openai", + model: DEFAULT_MODEL, + }; + } catch (error) { + console.error("Error translating chat message", error?.response?.data || error?.message || error); + return null; + } +}; + +module.exports = { + normalizeLanguageCode, + translateText, +};