// SPDX-FileCopyrightText: 2024 John Livingston
//
// SPDX-License-Identifier: AGPL-3.0-only
import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { RoomConf } from 'xmppjs-chat-bot'
import { getProsodyDomain } from '../prosody/config/domain'
import { listProsodyRooms, updateProsodyRoom } from '../prosody/api/manage-rooms'
import { getChannelInfosById } from '../database/channel'
import { ChannelConfigurationOptions } from '../../../shared/lib/types'
import {
getChannelConfigurationOptions,
channelConfigurationOptionsToBotRoomConf
} from '../configuration/channel/storage'
import { BotConfiguration } from '../configuration/bot'
import { fillVideoCustomFields } from '../custom-fields'
import { videoHasWebchat } from '../../../shared/lib/video'
import * as path from 'path'
import * as fs from 'fs'
let singleton: RoomChannel | undefined
/**
* Class used to request and store some informations about relation between rooms and channels.
*/
class RoomChannel {
protected readonly options: RegisterServerOptions
protected readonly mucDomain: string
protected readonly dataFilePath: string
protected readonly logger: {
debug: (s: string) => void
info: (s: string) => void
warn: (s: string) => void
error: (s: string) => void
}
protected room2Channel: Map = new Map()
protected channel2Rooms: Map> = new Map>()
protected needSync: boolean = false
protected roomConfToUpdate: Map = new Map()
protected syncTimeout: ReturnType | undefined
protected isWriting: boolean = false
constructor (params: {
options: RegisterServerOptions
mucDomain: string
dataFilePath: string
}) {
this.options = params.options
this.mucDomain = params.mucDomain
this.dataFilePath = params.dataFilePath
const logger = params.options.peertubeHelpers.logger
this.logger = {
debug: (s) => logger.debug('[RoomChannel] ' + s),
info: (s) => logger.info('[RoomChannel] ' + s),
warn: (s) => logger.warn('[RoomChannel] ' + s),
error: (s) => logger.error('[RoomChannel] ' + s)
}
}
/**
* Instanciate the singleton
*/
public static async initSingleton (options: RegisterServerOptions): Promise {
const prosodyDomain = await getProsodyDomain(options)
const mucDomain = 'room.' + prosodyDomain
const dataFilePath = path.resolve(
options.peertubeHelpers.plugin.getDataDirectoryPath(),
'room-channel',
mucDomain + '.json'
)
singleton = new RoomChannel({
options,
mucDomain,
dataFilePath
})
return singleton
}
/**
* frees the singleton
*/
public static async destroySingleton (): Promise {
if (!singleton) { return }
singleton.cancelScheduledSync()
await singleton.sync()
singleton.cancelScheduledSync() // in case sync rescheduled... we will lose data, but they could be rebuild later
singleton = undefined
}
/**
* Gets the singleton, or raise an exception if it is too soon.
* @returns the singleton
*/
public static singleton (): RoomChannel {
if (!singleton) {
throw new Error('RoomChannel singleton is not initialized yet')
}
return singleton
}
/**
* Reads data from the room-channel data file.
* @return Returns true if the data where found and valid. If there is no data (or no valid data), returns false.
*/
public async readData (): Promise {
// Reading the data file (see https://livingston.frama.io/peertube-plugin-livechat/fr/technical/data/)
let content: string
try {
content = (await fs.promises.readFile(this.dataFilePath)).toString()
} catch (err) {
this.logger.info('Failed reading room-channel data file (' + this.dataFilePath + '), assuming it does not exists')
return false
}
content ??= '{}'
let data: any
try {
data = JSON.parse(content)
} catch (err) {
this.logger.error('Unable to parse the content of the room-channel data file, will start with an empty database.')
return false
}
// This part must be done atomicly:
return this._readData(data)
}
/**
* _readData is the atomic part of readData:
* once the date are read from disk, object data must be emptied and filled atomicly.
*/
protected _readData (data: any): boolean {
this.room2Channel.clear()
this.channel2Rooms.clear()
this.needSync = true
if (typeof data !== 'object') {
this.logger.error('Invalid room-channel data file content')
return false
}
for (const k in data) {
if (!/^\d+$/.test(k)) {
this.logger.error('Invalid channel ID type, should be a number, dropping')
continue
}
const channelId = parseInt(k)
const rooms = data[k]
if (!Array.isArray(rooms)) {
this.logger.error('Invalid room list for Channel ' + channelId.toString() + ', dropping')
continue
}
const c2r = new Map()
this.channel2Rooms.set(channelId, c2r)
for (const roomJID of rooms) {
if (typeof roomJID !== 'string') {
this.logger.error('Invalid room jid for Channel ' + channelId.toString() + ', dropping')
continue
}
c2r.set(roomJID, true)
this.room2Channel.set(roomJID, channelId)
}
}
return true
}
/**
* Rebuilt the data from scratch.
* Can be used for the initial migration.
* Can also be scheduled daily, or on an admin action (not sure it will be done, at the time of the writing).
*/
public async rebuildData (): Promise {
const data: any = {}
const rooms = await listProsodyRooms(this.options)
const settings = await this.options.settingsManager.getSettings([
'chat-per-live-video',
'chat-all-lives',
'chat-all-non-lives',
'chat-videos-list',
'prosody-room-type'
])
for (const room of rooms) {
let channelId: string | number | undefined
const matches = room.localpart.match(/^channel\.(\d+)$/)
if (matches?.[1]) {
if (settings['prosody-room-type'] !== 'channel') {
this.logger.debug(
`Room ${room.localpart} is a channel-wide room, but prosody-room-type!== channel. Ignoring it`
)
continue
}
channelId = parseInt(matches[1])
if (isNaN(channelId)) {
this.logger.error(`Invalid room JID '${room.localpart}'`)
continue
}
// Checking that channel still exists
const channelInfos = await getChannelInfosById(this.options, channelId)
if (!channelInfos) {
this.logger.debug(
`Ignoring room ${room.localpart}, because channel ${channelId} seems to not exist anymore`
)
continue
}
} else {
if (settings['prosody-room-type'] !== 'video') {
this.logger.debug(
`Room ${room.localpart} is a video-related room, but prosody-room-type!== room. Ignoring it`
)
continue
}
const uuid = room.localpart
const video = await this.options.peertubeHelpers.videos.loadByIdOrUUID(uuid)
if (!video) {
this.logger.debug(
`Ignoring room ${room.localpart}, because video ${uuid} seems to not exist anymore`
)
continue
}
await fillVideoCustomFields(this.options, video)
const hasChat = await videoHasWebchat({
'chat-per-live-video': !!settings['chat-per-live-video'],
'chat-all-lives': !!settings['chat-all-lives'],
'chat-all-non-lives': !!settings['chat-all-non-lives'],
'chat-videos-list': settings['chat-videos-list'] as string
}, video)
if (!hasChat) {
// Either there were never any chat, either it was disabled...
this.logger.debug(`Video ${video.uuid} has no chat, ignoring it during the rebuild`)
continue
}
channelId = video.channelId
}
if (!channelId) {
this.logger.error(`Did not find channelId for ${room.localpart}`)
continue
}
channelId = channelId.toString()
if (!(channelId in data)) {
this.logger.debug(`Room ${room.localpart} is associated to channel ${channelId}`)
data[channelId] = []
}
data[channelId].push(room.localpart)
}
// ************ ATOMIC PART ****************
// The rebuild process can remove some rooms (for example if prosody-room-type is changed),
// So we must mark all previous rooms as to refresh:
// Now we must mark all rooms for conf update.
for (const roomJID of this.room2Channel.keys()) {
this.roomConfToUpdate.set(roomJID, true)
}
// This part must be done atomicly:
this._readData(data)
// Now we must mark all rooms for conf update.
for (const roomJID of this.room2Channel.keys()) {
this.roomConfToUpdate.set(roomJID, true)
}
// ************ END OF ATOMIC PART ****************
await this.sync() // FIXME: or maybe scheduleSync ?
}
/**
* Syncs data to disk.
*/
public async sync (): Promise {
if (!this.needSync) { return }
if (this.isWriting) {
this.logger.info('Already writing, scheduling a new sync')
this.scheduleSync()
return
}
this.logger.info('Syncing...')
this.isWriting = true
const prosodyRoomUpdates = new Map[2]>()
try {
const data = this._serializeData() // must be atomic
this.needSync = false // Note: must be done atomicly with the read
await fs.promises.mkdir(path.dirname(this.dataFilePath), { recursive: true })
await fs.promises.writeFile(this.dataFilePath, JSON.stringify(data))
this.logger.debug('room-channel sync done, must sync room conf now')
// Note: getChannelConfigurationOptions has no cache for now, so we will handle it here
const channelConfigurationOptionsCache = new Map()
const roomJIDs = Array.from(this.roomConfToUpdate.keys())
for (const roomJID of roomJIDs) {
const channelId = this.room2Channel.get(roomJID) // roomJID already normalized, so bypassing getRoomChannelId
if (channelId === undefined) {
// No more channel, must disable room!
this.logger.info(`Room ${roomJID} has no associated channel, ensuring there is no active bot conf`)
await BotConfiguration.singleton().disableRoom(roomJID)
this.roomConfToUpdate.delete(roomJID)
continue
}
// Must write the correct Channel conf for the room.
if (!channelConfigurationOptionsCache.has(channelId)) {
try {
channelConfigurationOptionsCache.set(
channelId,
await getChannelConfigurationOptions(this.options, channelId)
)
} catch (err) {
this.logger.error(err as string)
this.logger.error('Failed reading channel configuration, will assume there is none.')
channelConfigurationOptionsCache.set(
channelId,
null
)
}
}
const channelConfigurationOptions = channelConfigurationOptionsCache.get(channelId)
if (!channelConfigurationOptions) {
// no channel configuration, disabling
this.logger.info(`Room ${roomJID} has not associated channel options, ensuring there is no active bot conf`)
await BotConfiguration.singleton().disableRoom(roomJID)
this.roomConfToUpdate.delete(roomJID)
continue
}
this.logger.info(`Room ${roomJID} has associated channel options, writing it`)
const previousRoomConf = await BotConfiguration.singleton().getRoom(roomJID)
const botConf: RoomConf = Object.assign(
{
local: roomJID,
domain: this.mucDomain
},
channelConfigurationOptionsToBotRoomConf(this.options, channelConfigurationOptions, previousRoomConf)
)
await BotConfiguration.singleton().updateRoom(roomJID, botConf)
// Now we also must update some room metadata on Prosody side (livechat_muc_terms, ...)
// This can be done without waiting for the API call to finish, but we don't want to send thousands of
// API calls at the same time. So storing data in a map, and we well launch it sequentially at the end
prosodyRoomUpdates.set(roomJID, {
livechat_muc_terms: channelConfigurationOptions.terms ?? '' // must pass a string, else wont update
})
this.roomConfToUpdate.delete(roomJID)
}
this.logger.info('Syncing done.')
} catch (err) {
this.logger.error(err as string)
this.logger.error('Syncing failed.')
this.needSync = true
} finally {
this.isWriting = false
}
if (prosodyRoomUpdates.size) {
// Here we don't have to wait.
// If it fails (for example because we are turning off prosody), it is not a big deal.
// Does not worth the cost to wait.
// eslint-disable-next-line @typescript-eslint/no-misused-promises
setTimeout(async () => {
this.logger.info('Syncing done, but still some data to send to Prosody')
for (const [roomJID, data] of prosodyRoomUpdates.entries()) {
try {
await updateProsodyRoom(this.options, roomJID, data)
} catch (err) {
this.logger.error(`Failed updating prosody room info: "${err as string}".`)
}
}
}, 0)
}
}
/**
* Schedules a sync.
* Each times data are modified, we can schedule a sync, but we don't have to wait the file writing to be done.
*/
public scheduleSync (): void {
if (!this.needSync) { return }
if (this.syncTimeout) {
// Already scheduled... nothing to do
this.logger.debug('There is already a sync scheduled, skipping.')
return
}
this.logger.info('Scheduling a new sync...')
this.syncTimeout = setTimeout(() => {
this.syncTimeout = undefined
this.logger.info('Running scheduled sync')
this.sync().then(() => {}, (err) => {
this.logger.error(err)
// We will not re-schedule the sync, to avoid flooding error log if there is an issue with the server
})
}, 100)
}
public cancelScheduledSync (): void {
if (this.syncTimeout) {
clearTimeout(this.syncTimeout)
}
}
/**
* Sets a relation between room and channel id
* @param channelId The channel ID
* @param roomJID The room JID. Can be the local part only, or the full JID.
* In the second case, the domain will be checked.
*/
public link (channelId: number | string, roomJIDParam: string): void {
channelId = parseInt(channelId.toString())
if (isNaN(channelId)) {
this.logger.error('Invalid channelId, we wont link')
return
}
const roomJID = this._canonicJID(roomJIDParam)
if (!roomJID) {
this.logger.error('Invalid room JID, we wont link')
return
}
// First, if the room was linked to another channel, we must unlink.
const previousChannelId = this.room2Channel.get(roomJID)
if (previousChannelId) {
if (this.room2Channel.delete(roomJID)) {
this.needSync = true
this.roomConfToUpdate.set(roomJID, true)
}
const previousRooms = this.channel2Rooms.get(previousChannelId)
if (previousRooms) {
if (previousRooms.delete(roomJID)) {
this.needSync = true
this.roomConfToUpdate.set(roomJID, true)
}
}
}
if (this.room2Channel.get(roomJID) !== channelId) {
this.room2Channel.set(roomJID, channelId)
this.needSync = true
this.roomConfToUpdate.set(roomJID, true)
}
let rooms = this.channel2Rooms.get(channelId)
if (!rooms) {
rooms = new Map()
this.channel2Rooms.set(channelId, rooms)
this.needSync = true
}
if (!rooms.has(roomJID)) {
rooms.set(roomJID, true)
this.needSync = true
this.roomConfToUpdate.set(roomJID, true)
}
this.scheduleSync()
}
/**
* Removes all relations for this room
* @param roomJID the room JID
*/
public removeRoom (roomJIDParam: string): void {
const roomJID = this._canonicJID(roomJIDParam)
if (!roomJID) {
this.logger.error('Invalid room JID, we wont link')
return
}
const channelId = this.room2Channel.get(roomJID)
if (channelId) {
const rooms = this.channel2Rooms.get(channelId)
if (rooms) {
if (rooms.delete(roomJID)) {
this.needSync = true
this.roomConfToUpdate.set(roomJID, true)
}
}
}
if (this.room2Channel.delete(roomJID)) {
this.needSync = true
this.roomConfToUpdate.set(roomJID, true)
}
this.scheduleSync()
}
/**
* Removes all relations for this channel
* @param channelId the channel id
*/
public removeChannel (channelId: number | string): void {
channelId = parseInt(channelId.toString())
if (isNaN(channelId)) {
this.logger.error('Invalid channelId, we wont remove')
return
}
const rooms = this.channel2Rooms.get(channelId)
if (rooms) {
for (const roomJID of rooms.keys()) {
// checking the consistency... only removing if the channel is the current one
if (this.room2Channel.get(roomJID) === channelId) {
this.room2Channel.delete(roomJID)
this.needSync = true
this.roomConfToUpdate.set(roomJID, true)
}
}
}
if (this.channel2Rooms.delete(channelId)) {
this.needSync = true
}
this.scheduleSync()
}
/**
* Get the channel ID for a given room.
* Returns null if not found.
* @param roomJIDParam room JID (local part, or full JID)
*/
public getRoomChannelId (roomJIDParam: string): number | null {
const roomJID = this._canonicJID(roomJIDParam)
if (!roomJID) {
this.logger.error('Invalid room JID: ' + roomJIDParam)
return null
}
return this.room2Channel.get(roomJID) ?? null
}
/**
* Returns room local JID parts for all room linked to given channel.
* @param channelId channel id
* @returns list of room JIDs local parts
*/
public getChannelRoomJIDs (channelId: number | string): string[] {
channelId = parseInt(channelId.toString())
if (isNaN(channelId)) {
this.logger.error('Invalid channelId, we wont link')
return []
}
const rooms = this.channel2Rooms.get(channelId)
if (!rooms) {
return []
}
return Array.from(rooms.keys())
}
/**
* Call this method when the channel configuration options changed, to refresh all files.
* @param channelId channel ID
*/
public refreshChannelConfigurationOptions (channelId: number | string): void {
channelId = parseInt(channelId.toString())
if (isNaN(channelId)) {
this.logger.error('Invalid channelId, we wont link')
return
}
const roomJIDs = this.getChannelRoomJIDs(channelId)
this.needSync = true
for (const roomJID of roomJIDs) {
this.roomConfToUpdate.set(roomJID, true)
}
this.scheduleSync()
}
protected _canonicJID (roomJID: string): string | null {
const splits = roomJID.split('@')
if (splits.length < 2) {
return roomJID
}
if (splits.length > 2) {
this.logger.error('The room JID contains multiple @, not valid')
return null
}
if (splits[1] !== this.mucDomain) {
this.logger.error('The room JID is not on the correct domain')
return null
}
return splits[0]
}
protected _serializeData (): any {
const data: any = {}
this.channel2Rooms.forEach((rooms, channelId) => {
const a: string[] = []
rooms.forEach((_val, roomJID) => {
a.push(roomJID)
})
data[channelId.toString()] = a
})
return data
}
}
export {
RoomChannel
}
// TODO: schedule rebuild every X hours/days