feat: add universal chat room with language-aware translation
This commit is contained in:
238
routes/chat.js
Normal file
238
routes/chat.js
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user