This commit is contained in:
payacom
2026-08-31 20:45:41 +03:30
parent fa9f23c61d
commit 3f416a7a60
367 changed files with 621895 additions and 6443 deletions

View File

@@ -0,0 +1,698 @@
const mongoose = require('mongoose')
const path = require('path')
const fs = require('fs-extra')
const crypto = require('crypto')
const moment = require('moment-jalaali')
const ChatRoomModel = require('../../../models/ChatRoomModel')
const ChatRoomMessageModel = require('../../../models/ChatRoomMessageModel')
const UserModel = require('../../../models/UserModel')
const { ProvinceModel, CityModel } = require('../../../models/StateCity')
const {
slugifyTitle,
userCanSeeRoom,
resolveRoomOnlineCount,
resolveIdleTimeoutMs
} = require('./chatRoomHelpers')
const { opaquePreview } = require('../../../utils/e2ee')
const DEFAULT_ROOM = {
title: 'چت روم مدستاگرام',
slug: 'modstagram-community',
description: 'گفتگوی گروهی کاربران مدستاگرام'
}
function formatTimeOnly(date) {
return moment(date).locale('fa').format('HH:mm:ss')
}
async function ensureDefaultRoom() {
let room = await ChatRoomModel.findOne({ slug: DEFAULT_ROOM.slug })
if (!room) {
room = await ChatRoomModel.create({
...DEFAULT_ROOM,
isSystem: true,
lastActivityAt: new Date()
})
} else if (!room.isSystem) {
room.isSystem = true
if (!room.lastActivityAt) room.lastActivityAt = new Date()
await room.save()
}
return room
}
async function enrichRoomMessage(message) {
const doc = message._doc ? { ...message._doc } : { ...message }
const sender = await UserModel.findById(doc.senderId)
.select('first_name last_name user_name profile_image is_verified verify_badge')
.lean()
let replyTo = null
if (doc.replyToId) {
const parent = await ChatRoomMessageModel.findById(doc.replyToId).lean()
if (parent) {
const parentUser = await UserModel.findById(parent.senderId)
.select('first_name last_name user_name')
.lean()
replyTo = {
_id: parent._id,
content: opaquePreview(parent.content, 200),
senderName: parentUser
? `${parentUser.first_name || ''} ${parentUser.last_name || ''}`.trim() ||
parentUser.user_name
: 'کاربر'
}
}
}
return {
...doc,
_id: String(doc._id),
roomId: String(doc.roomId),
senderId: String(doc.senderId),
createdAt: formatTimeOnly(doc.createdAt),
createdAtIso: doc.createdAt ? new Date(doc.createdAt).toISOString() : undefined,
reactions: (doc.reactions || []).map((r) => ({
userId: String(r.userId),
emoji: r.emoji,
})),
sender: sender
? {
_id: String(sender._id),
first_name: sender.first_name,
last_name: sender.last_name,
user_name: sender.user_name,
profile_image: sender.profile_image,
is_verified: sender.is_verified,
verify_badge: sender.verify_badge
}
: undefined,
replyTo
}
}
function emitGroupMessage(io, roomId, message, memberIds = []) {
if (!io) return
const payload = { ...message, roomId: String(message.roomId ?? roomId) }
io.to(`group:${roomId}`).emit('newGroupMessage', payload)
const members = [...new Set((memberIds || []).map((id) => String(id)).filter(Boolean))]
for (const memberId of members) {
io.to(`user:${memberId}`).emit('newGroupMessage', payload)
}
}
async function serializeRoom(room, io, userId) {
const memberCount = room.members?.length || 0
const isMember = room.members?.some((m) => String(m) === String(userId))
const onlineCount = await resolveRoomOnlineCount(io, room)
return {
_id: String(room._id),
title: room.title,
description: room.description,
image: room.image,
province: room.province || null,
city: room.city || null,
expertise: room.expertise || null,
visibility: room.visibility || 'public',
memberCount,
isMember,
onlineCount,
isSystem: Boolean(room.isSystem),
createdBy: room.createdBy ? String(room.createdBy) : null,
lastActivityAt: room.lastActivityAt,
idleTimeoutMs: room.idleTimeoutMs || null
}
}
const listRooms = async (req, res, next) => {
try {
const userId = String(req.user._id)
const io = req.app.get('io')
await ensureDefaultRoom()
const user = await UserModel.findById(userId)
.select('province city expertise')
.lean()
const rooms = await ChatRoomModel.find({}).sort({ lastActivityAt: -1 }).lean()
const enriched = await Promise.all(
rooms
.filter((room) => userCanSeeRoom(user, room))
.map(async (room) => {
const lastMessage = await ChatRoomMessageModel.findOne({ roomId: room._id })
.sort({ createdAt: -1 })
.lean()
return {
...(await serializeRoom(room, io, userId)),
lastMessage: lastMessage
? {
content: opaquePreview(lastMessage.content, 80),
createdAt: formatTimeOnly(lastMessage.createdAt)
}
: null
}
})
)
res.json({ rooms: enriched })
} catch (error) {
next(error)
}
}
const createRoom = async (req, res, next) => {
try {
const userId = String(req.user._id)
const io = req.app.get('io')
const { title, province_id, city_id, expertise, visibility, member_ids, idle_timeout_days } = req.body
if (!title || !String(title).trim()) {
return res.status(422).json({ error: 'نام اتاق الزامی است' })
}
const roomVisibility =
String(visibility || 'public').trim() === 'private' ? 'private' : 'public'
let invitedMemberIds = []
if (member_ids) {
try {
const parsed =
typeof member_ids === 'string' ? JSON.parse(member_ids) : member_ids
if (Array.isArray(parsed)) {
invitedMemberIds = parsed
.map((id) => String(id).trim())
.filter((id) => mongoose.Types.ObjectId.isValid(id))
}
} catch {
invitedMemberIds = []
}
}
if (roomVisibility === 'private' && invitedMemberIds.length === 0) {
return res.status(422).json({
error: 'برای اتاق خصوصی، افزودن حداقل یک کاربر الزامی است',
})
}
let province = null
let city = null
if (province_id) {
province = await ProvinceModel.findOne({ id: Number(province_id) }).lean()
if (!province) {
return res.status(404).json({ error: 'استان یافت نشد' })
}
}
if (city_id) {
city = await CityModel.findOne({ id: Number(city_id) }).lean()
if (!city) {
return res.status(404).json({ error: 'شهر یافت نشد' })
}
if (!province && city.province_id) {
province = await ProvinceModel.findOne({ id: city.province_id }).lean()
}
}
let imagePath = null
const imageFile = req.files?.room_image
if (imageFile) {
const uploadDir = path.join(__dirname, '../../../storage/chat-rooms')
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true })
}
// اسمِ فایل هیچ‌وقت مستقیم از ورودیِ کاربر (imageFile.name) گرفته نمی‌شه — فقط پسوندش
const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(imageFile.name || '')}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(imageFile.path, filePath)
imagePath = `/chat-rooms/${uniqueFileName}`
}
const expertiseValue =
expertise && String(expertise).trim() ? String(expertise).trim() : undefined
const members = [userId, ...invitedMemberIds.filter((id) => id !== userId)]
const room = await ChatRoomModel.create({
title: String(title).trim(),
slug: `${slugifyTitle(title)}-${Date.now()}`,
image: imagePath,
province:
roomVisibility === 'public' && province
? { id: province.id, name: province.name }
: undefined,
city:
roomVisibility === 'public' && city
? { id: city.id, name: city.name }
: undefined,
expertise: roomVisibility === 'public' ? expertiseValue : undefined,
visibility: roomVisibility,
members,
createdBy: userId,
lastActivityAt: new Date(),
idleTimeoutMs: resolveIdleTimeoutMs(
idle_timeout_days ? Number(idle_timeout_days) * 24 * 60 * 60 * 1000 : undefined
),
isSystem: false
})
res.status(201).json({
room: await serializeRoom(room.toObject(), io, userId)
})
} catch (error) {
next(error)
}
}
const joinRoom = async (req, res, next) => {
try {
const userId = String(req.user._id)
const { roomId } = req.params
if (!mongoose.Types.ObjectId.isValid(roomId)) {
return res.status(400).json({ error: 'Invalid room id' })
}
const user = await UserModel.findById(userId)
.select('province city expertise')
.lean()
const room = await ChatRoomModel.findById(roomId)
if (!room) return res.status(404).json({ error: 'Room not found' })
if (!userCanSeeRoom(user, room)) {
return res.status(403).json({ error: 'Access denied' })
}
if (!room.members?.some((m) => String(m) === userId)) {
room.members = room.members || []
room.members.push(userId)
await room.save()
}
res.json({
room: {
_id: String(room._id),
title: room.title,
description: room.description,
image: room.image,
isMember: true
}
})
} catch (error) {
next(error)
}
}
const getRoomMessages = async (req, res, next) => {
try {
const userId = String(req.user._id)
const { roomId } = req.params
const page = parseInt(req.query.page, 10) || 1
const limit = parseInt(req.query.limit, 10) || 50
if (!mongoose.Types.ObjectId.isValid(roomId)) {
return res.status(400).json({ error: 'Invalid room id' })
}
const user = await UserModel.findById(userId)
.select('province city expertise')
.lean()
const room = await ChatRoomModel.findById(roomId)
if (!room) return res.status(404).json({ error: 'Room not found' })
if (!userCanSeeRoom(user, room)) {
return res.status(403).json({ error: 'Access denied' })
}
const isMember = room.members?.some((m) => String(m) === userId)
if (!isMember) {
return res.status(403).json({
error: 'ابتدا به اتاق بپیوندید',
code: 'NOT_A_MEMBER'
})
}
const filter = { roomId: room._id }
const total = await ChatRoomMessageModel.countDocuments(filter)
const totalPages = Math.ceil(total / limit) || 1
const skip = (page - 1) * limit
const messages = await ChatRoomMessageModel.find(filter)
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
const enriched = await Promise.all(messages.map((m) => enrichRoomMessage(m)))
res.json({ messages: enriched.reverse(), totalPages })
} catch (error) {
next(error)
}
}
const sendRoomMessage = async (req, res, next) => {
try {
const userId = String(req.user._id)
const io = req.app.get('io')
const { roomId } = req.params
const { content, replyToId, fileType } = req.body
if (!mongoose.Types.ObjectId.isValid(roomId)) {
return res.status(400).json({ error: 'Invalid room id' })
}
const textContent = content != null ? String(content).trim() : ''
const uploaded = req.files?.file
const resolvedFileType = fileType || undefined
if (!textContent && !uploaded) {
return res.status(422).json({ error: 'content or file is required' })
}
const user = await UserModel.findById(userId)
.select('province city expertise')
.lean()
const room = await ChatRoomModel.findById(roomId)
if (!room) return res.status(404).json({ error: 'Room not found' })
if (!userCanSeeRoom(user, room)) {
return res.status(403).json({ error: 'Access denied' })
}
const isMember = room.members?.some((m) => String(m) === userId)
if (!isMember) {
return res.status(403).json({
error: 'ابتدا به اتاق بپیوندید',
code: 'NOT_A_MEMBER'
})
}
let fileUrl = null
if (uploaded) {
const uploadDir = path.join(__dirname, '../../../storage/chat-rooms/messages')
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true })
}
// اسمِ فایل هیچ‌وقت مستقیم از ورودیِ کاربر (uploaded.name) گرفته نمی‌شه — فقط پسوندش
const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(uploaded.name || '')}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(uploaded.path, filePath)
fileUrl = `/chat-rooms/messages/${uniqueFileName}`
}
room.lastActivityAt = new Date()
await room.save()
const message = await ChatRoomMessageModel.create({
roomId: room._id,
senderId: userId,
content: textContent,
file: fileUrl || undefined,
fileType: fileUrl ? resolvedFileType : undefined,
replyToId: replyToId || undefined
})
const formatted = await enrichRoomMessage(message)
emitGroupMessage(io, String(room._id), formatted, room.members)
res.status(201).json({ message: formatted })
} catch (error) {
next(error)
}
}
const getRoomDetail = async (req, res, next) => {
try {
const userId = String(req.user._id)
const io = req.app.get('io')
const { roomId } = req.params
if (!mongoose.Types.ObjectId.isValid(roomId)) {
return res.status(400).json({ error: 'Invalid room id' })
}
const user = await UserModel.findById(userId)
.select('province city expertise')
.lean()
const room = await ChatRoomModel.findById(roomId).lean()
if (!room) return res.status(404).json({ error: 'Room not found' })
if (!userCanSeeRoom(user, room)) {
return res.status(403).json({ error: 'Access denied' })
}
const members = await UserModel.find({ _id: { $in: room.members || [] } })
.select('first_name last_name user_name profile_image is_verified verify_badge')
.lean()
res.json({
room: {
...(await serializeRoom(room, io, userId)),
members: members.map((m) => ({
_id: String(m._id),
first_name: m.first_name,
last_name: m.last_name,
user_name: m.user_name,
profile_image: m.profile_image,
is_verified: m.is_verified,
verify_badge: m.verify_badge
}))
}
})
} catch (error) {
next(error)
}
}
const addRoomMembers = async (req, res, next) => {
try {
const userId = String(req.user._id)
const { roomId } = req.params
const { member_ids: memberIdsRaw } = req.body
if (!mongoose.Types.ObjectId.isValid(roomId)) {
return res.status(400).json({ error: 'Invalid room id' })
}
const room = await ChatRoomModel.findById(roomId)
if (!room) return res.status(404).json({ error: 'Room not found' })
if (room.visibility !== 'private') {
return res.status(422).json({ error: 'فقط اتاق‌های خصوصی قابل دعوت هستند' })
}
if (!room.createdBy || String(room.createdBy) !== userId) {
return res.status(403).json({ error: 'فقط سازنده اتاق می‌تواند کاربر اضافه کند' })
}
let memberIds = []
if (Array.isArray(memberIdsRaw)) {
memberIds = memberIdsRaw
} else if (typeof memberIdsRaw === 'string') {
try {
memberIds = JSON.parse(memberIdsRaw)
} catch {
memberIds = []
}
}
const validIds = [...new Set(
(memberIds || [])
.map((id) => String(id).trim())
.filter((id) => mongoose.Types.ObjectId.isValid(id) && id !== userId)
)]
if (!validIds.length) {
return res.status(422).json({ error: 'کاربر معتبری برای افزودن انتخاب نشده است' })
}
const existing = new Set((room.members || []).map((id) => String(id)))
for (const id of validIds) {
if (!existing.has(id)) {
room.members.push(id)
existing.add(id)
}
}
await room.save()
const io = req.app.get('io')
res.json({ room: await serializeRoom(room.toObject(), io, userId) })
} catch (error) {
next(error)
}
}
const reactToRoomMessage = async (req, res, next) => {
try {
const userId = String(req.user._id)
const io = req.app.get('io')
const { roomId } = req.params
const { messageId, emoji } = req.body
if (!mongoose.Types.ObjectId.isValid(roomId) || !mongoose.Types.ObjectId.isValid(messageId)) {
return res.status(400).json({ error: 'Invalid id' })
}
if (!emoji) {
return res.status(400).json({ error: 'emoji is required' })
}
const user = await UserModel.findById(userId)
.select('province city expertise')
.lean()
const room = await ChatRoomModel.findById(roomId)
if (!room) return res.status(404).json({ error: 'Room not found' })
if (!userCanSeeRoom(user, room)) {
return res.status(403).json({ error: 'Access denied' })
}
if (!room.members?.some((m) => String(m) === userId)) {
return res.status(403).json({
error: 'ابتدا به اتاق بپیوندید',
code: 'NOT_A_MEMBER'
})
}
const msg = await ChatRoomMessageModel.findOne({ _id: messageId, roomId: room._id })
if (!msg) return res.status(404).json({ error: 'Message not found' })
let reactions = Array.isArray(msg.reactions) ? [...msg.reactions] : []
const idx = reactions.findIndex((r) => String(r.userId) === userId)
if (idx >= 0 && reactions[idx].emoji === emoji) {
reactions.splice(idx, 1)
} else if (idx >= 0) {
reactions[idx] = { userId, emoji }
} else {
reactions.push({ userId, emoji })
}
msg.reactions = reactions
await msg.save()
const formatted = await enrichRoomMessage(msg)
io.to(`group:${roomId}`).emit('groupMessageReaction', {
roomId: String(roomId),
messageId: String(msg._id),
reactions: formatted.reactions,
})
res.json({ message: formatted })
} catch (error) {
next(error)
}
}
const getRoomE2eeKeys = async (req, res, next) => {
try {
const userId = String(req.user._id)
const { roomId } = req.params
if (!mongoose.Types.ObjectId.isValid(roomId)) {
return res.status(400).json({ error: 'Invalid room id' })
}
const room = await ChatRoomModel.findById(roomId)
if (!room) return res.status(404).json({ error: 'Room not found' })
if (!room.members?.some((m) => String(m) === userId)) {
return res.status(403).json({
error: 'ابتدا به اتاق بپیوندید',
code: 'NOT_A_MEMBER'
})
}
const memberIds = (room.members || []).map((m) => String(m))
const users = await UserModel.find({ _id: { $in: memberIds } })
.select('e2ee_public_key')
.lean()
const wraps = Array.isArray(room.e2ee_wrapped_keys) ? room.e2ee_wrapped_keys : []
const myWrap = wraps.find((w) => String(w.userId) === userId)
const wrapUserIds = new Set(wraps.map((w) => String(w.userId)))
const missingWrapUserIds = memberIds.filter((id) => !wrapUserIds.has(id))
res.json({
e2ee_enabled: Boolean(room.e2ee_enabled),
myWrappedKey: myWrap?.wrappedKey || null,
memberPublicKeys: users.map((u) => ({
userId: String(u._id),
publicKey: u.e2ee_public_key || null
})),
missingWrapUserIds
})
} catch (error) {
next(error)
}
}
const putRoomE2eeKeys = async (req, res, next) => {
try {
const userId = String(req.user._id)
const { roomId } = req.params
const { e2ee_enabled, wrapped_keys } = req.body
if (!mongoose.Types.ObjectId.isValid(roomId)) {
return res.status(400).json({ error: 'Invalid room id' })
}
const room = await ChatRoomModel.findById(roomId)
if (!room) return res.status(404).json({ error: 'Room not found' })
if (!room.members?.some((m) => String(m) === userId)) {
return res.status(403).json({
error: 'ابتدا به اتاق بپیوندید',
code: 'NOT_A_MEMBER'
})
}
if (typeof e2ee_enabled === 'boolean') {
room.e2ee_enabled = e2ee_enabled
}
if (Array.isArray(wrapped_keys)) {
const memberSet = new Set((room.members || []).map((m) => String(m)))
const existing = Array.isArray(room.e2ee_wrapped_keys)
? [...room.e2ee_wrapped_keys]
: []
const byUser = new Map(existing.map((w) => [String(w.userId), w.wrappedKey]))
for (const entry of wrapped_keys) {
const uid = String(entry?.userId || '').trim()
const wrappedKey = String(entry?.wrappedKey || '').trim()
if (!uid || !wrappedKey || !memberSet.has(uid)) continue
if (wrappedKey.length > 8192) continue
byUser.set(uid, wrappedKey)
}
room.e2ee_wrapped_keys = [...byUser.entries()].map(([uid, key]) => ({
userId: uid,
wrappedKey: key
}))
}
await room.save()
res.json({
ok: true,
e2ee_enabled: Boolean(room.e2ee_enabled),
wrappedCount: room.e2ee_wrapped_keys?.length || 0
})
} catch (error) {
next(error)
}
}
module.exports = {
listRooms,
createRoom,
joinRoom,
getRoomMessages,
sendRoomMessage,
getRoomDetail,
addRoomMembers,
reactToRoomMessage,
getRoomE2eeKeys,
putRoomE2eeKeys,
enrichRoomMessage,
emitGroupMessage
}

View File

@@ -0,0 +1,156 @@
const mongoose = require('mongoose')
const path = require('path')
const fs = require('fs-extra')
const ChatRoomModel = require('../../../models/ChatRoomModel')
const ChatRoomMessageModel = require('../../../models/ChatRoomMessageModel')
const UserModel = require('../../../models/UserModel')
const DEFAULT_IDLE_MS = 30 * 24 * 60 * 60 * 1000 // 1 month
const MIN_IDLE_MS = 60 * 60 * 1000 // 1 hour
const MAX_IDLE_MS = 365 * 24 * 60 * 60 * 1000 // 1 year
const STORAGE_ROOT = path.join(__dirname, '../../../storage')
const ONLINE_WINDOW_MS = 60 * 1000
function resolveIdleTimeoutMs (value) {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_IDLE_MS
return Math.min(MAX_IDLE_MS, Math.max(MIN_IDLE_MS, parsed))
}
function slugifyTitle(title) {
return String(title)
.trim()
.replace(/\s+/g, '-')
.replace(/[^\u0600-\u06FFa-zA-Z0-9-]/g, '')
.slice(0, 48) || 'room'
}
function userCanSeeRoom(user, room) {
if (!room) return false
if (room.isSystem) return true
const userId = user?._id != null ? String(user._id) : ''
if (room.visibility === 'private') {
if (!userId) return false
if (room.createdBy && String(room.createdBy) === userId) return true
return Boolean(room.members?.some((memberId) => String(memberId) === userId))
}
if (userId && room.createdBy && String(room.createdBy) === userId) {
return true
}
const hasProvince = room.province?.id != null
const hasCity = room.city?.id != null
const hasExpertise = Boolean(room.expertise && String(room.expertise).trim())
if (!hasProvince && !hasCity && !hasExpertise) return true
if (hasExpertise) {
const userExp = String(user?.expertise || '').trim()
if (userExp !== String(room.expertise).trim()) return false
}
if (hasCity) {
const userCityId = user?.city?.id
return userCityId != null && String(userCityId) === String(room.city.id)
}
if (hasProvince) {
const userProvId = user?.province?.id
return userProvId != null && String(userProvId) === String(room.province.id)
}
return true
}
async function countRoomOnlineMembers(memberIds = []) {
const ids = [...new Set((memberIds || []).map((id) => String(id)).filter(Boolean))]
if (!ids.length) return 0
const objectIds = ids
.filter((id) => mongoose.Types.ObjectId.isValid(id))
.map((id) => new mongoose.Types.ObjectId(id))
if (!objectIds.length) return 0
const cutoff = new Date(Date.now() - ONLINE_WINDOW_MS)
return UserModel.countDocuments({
_id: { $in: objectIds },
ghost_mode: { $ne: true },
last_online: { $gte: cutoff },
})
}
function getRoomOnlineCount(io, roomId) {
if (!io || !roomId) return 0
const key = `group:${String(roomId)}`
const adapterRoom = io.sockets?.adapter?.rooms?.get(key)
return adapterRoom ? adapterRoom.size : 0
}
async function resolveRoomOnlineCount(io, room) {
const memberOnline = await countRoomOnlineMembers(room?.members)
const socketOnline = getRoomOnlineCount(io, room?._id)
return Math.max(memberOnline, socketOnline)
}
async function removeRoomImage(imagePath) {
if (!imagePath) return
const relative = String(imagePath).replace(/^\//, '')
const filePath = path.join(STORAGE_ROOT, relative)
if (fs.existsSync(filePath)) {
await fs.remove(filePath).catch(() => {})
}
}
async function deleteRoomFully(io, room) {
if (!room?._id) return
const messages = await ChatRoomMessageModel.find({ roomId: room._id }).lean()
for (const msg of messages) {
if (msg.file) await removeRoomImage(msg.file)
}
await ChatRoomMessageModel.deleteMany({ roomId: room._id })
await removeRoomImage(room.image)
await ChatRoomModel.deleteOne({ _id: room._id })
if (io) {
io.to(`group:${room._id}`).emit('chatRoomDeleted', { roomId: String(room._id) })
}
}
async function purgeInactiveChatRooms(io) {
const now = new Date()
const stale = await ChatRoomModel.find({
isSystem: { $ne: true },
$expr: {
$lt: [
{ $add: ['$lastActivityAt', { $ifNull: ['$idleTimeoutMs', DEFAULT_IDLE_MS] }] },
now
]
}
})
for (const room of stale) {
await deleteRoomFully(io, room)
}
return stale.length
}
module.exports = {
DEFAULT_IDLE_MS,
MIN_IDLE_MS,
MAX_IDLE_MS,
resolveIdleTimeoutMs,
slugifyTitle,
userCanSeeRoom,
countRoomOnlineMembers,
getRoomOnlineCount,
resolveRoomOnlineCount,
deleteRoomFully,
purgeInactiveChatRooms,
removeRoomImage
}