2024-05-23 09:42:14 +00:00
|
|
|
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
|
|
|
|
//
|
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
2023-09-15 15:55:07 +00:00
|
|
|
import type { RegisterServerOptions } from '@peertube/peertube-types'
|
2023-09-18 16:53:07 +00:00
|
|
|
import type { RoomConf, Config } from 'xmppjs-chat-bot'
|
2023-09-15 15:55:07 +00:00
|
|
|
import { getProsodyDomain } from '../prosody/config/domain'
|
2023-09-18 16:53:07 +00:00
|
|
|
import { isDebugMode } from '../debug'
|
2023-09-15 15:55:07 +00:00
|
|
|
import * as path from 'path'
|
|
|
|
import * as fs from 'fs'
|
|
|
|
|
|
|
|
let singleton: BotConfiguration | undefined
|
|
|
|
|
|
|
|
type RoomConfCache =
|
|
|
|
null // already loaded, but file does not exist
|
|
|
|
| RoomConf // loaded, and contains the room conf
|
|
|
|
|
|
|
|
type ChannelCommonRoomConf = Omit<RoomConf, 'local' | 'domain'>
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handles Bot configuration files.
|
|
|
|
*/
|
|
|
|
class BotConfiguration {
|
|
|
|
protected readonly options: RegisterServerOptions
|
2023-09-18 12:37:33 +00:00
|
|
|
protected readonly mucDomain: string
|
2023-09-18 16:53:07 +00:00
|
|
|
protected readonly botsDomain: string
|
2023-09-15 15:55:07 +00:00
|
|
|
protected readonly confDir: string
|
|
|
|
protected readonly roomConfDir: string
|
2023-09-18 16:53:07 +00:00
|
|
|
protected readonly moderationBotGlobalConf: string
|
2023-09-15 15:55:07 +00:00
|
|
|
protected readonly logger: {
|
|
|
|
debug: (s: string) => void
|
|
|
|
info: (s: string) => void
|
|
|
|
warn: (s: string) => void
|
|
|
|
error: (s: string) => void
|
|
|
|
}
|
|
|
|
|
|
|
|
protected readonly roomConfCache: Map<string, RoomConfCache> = new Map<string, RoomConfCache>()
|
|
|
|
|
|
|
|
constructor (params: {
|
|
|
|
options: RegisterServerOptions
|
2023-09-18 12:37:33 +00:00
|
|
|
mucDomain: string
|
2023-09-18 16:53:07 +00:00
|
|
|
botsDomain: string
|
2023-09-15 15:55:07 +00:00
|
|
|
confDir: string
|
|
|
|
roomConfDir: string
|
2023-09-18 16:53:07 +00:00
|
|
|
moderationBotGlobalConf: string
|
2023-09-15 15:55:07 +00:00
|
|
|
}) {
|
|
|
|
this.options = params.options
|
2023-09-18 12:37:33 +00:00
|
|
|
this.mucDomain = params.mucDomain
|
2023-09-18 16:53:07 +00:00
|
|
|
this.botsDomain = params.botsDomain
|
2023-09-15 15:55:07 +00:00
|
|
|
this.confDir = params.confDir
|
|
|
|
this.roomConfDir = params.roomConfDir
|
2023-09-18 16:53:07 +00:00
|
|
|
this.moderationBotGlobalConf = params.moderationBotGlobalConf
|
2023-09-15 15:55:07 +00:00
|
|
|
|
|
|
|
const logger = params.options.peertubeHelpers.logger
|
|
|
|
this.logger = {
|
|
|
|
debug: (s) => logger.debug('[BotConfiguration] ' + s),
|
|
|
|
info: (s) => logger.info('[BotConfiguration] ' + s),
|
|
|
|
warn: (s) => logger.warn('[BotConfiguration] ' + s),
|
|
|
|
error: (s) => logger.error('[BotConfiguration] ' + s)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Instanciate the BotConfiguration singleton
|
|
|
|
*/
|
|
|
|
public static async initSingleton (options: RegisterServerOptions): Promise<BotConfiguration> {
|
|
|
|
const prosodyDomain = await getProsodyDomain(options)
|
2023-09-18 12:37:33 +00:00
|
|
|
const mucDomain = 'room.' + prosodyDomain
|
2023-09-18 16:53:07 +00:00
|
|
|
const botsDomain = 'bot.' + prosodyDomain
|
2023-09-15 15:55:07 +00:00
|
|
|
const confDir = path.resolve(
|
|
|
|
options.peertubeHelpers.plugin.getDataDirectoryPath(),
|
|
|
|
'bot',
|
2023-09-18 12:37:33 +00:00
|
|
|
mucDomain
|
2023-09-15 15:55:07 +00:00
|
|
|
)
|
2023-09-18 16:53:07 +00:00
|
|
|
|
2023-09-15 15:55:07 +00:00
|
|
|
const roomConfDir = path.resolve(
|
|
|
|
confDir,
|
|
|
|
'rooms'
|
|
|
|
)
|
2023-09-18 16:53:07 +00:00
|
|
|
const moderationBotGlobalConf = path.resolve(
|
|
|
|
confDir,
|
|
|
|
'moderation.json'
|
|
|
|
)
|
2023-09-15 15:55:07 +00:00
|
|
|
|
|
|
|
await fs.promises.mkdir(confDir, { recursive: true })
|
|
|
|
await fs.promises.mkdir(roomConfDir, { recursive: true })
|
|
|
|
|
|
|
|
singleton = new BotConfiguration({
|
|
|
|
options,
|
2023-09-18 12:37:33 +00:00
|
|
|
mucDomain,
|
2023-09-18 16:53:07 +00:00
|
|
|
botsDomain,
|
2023-09-15 15:55:07 +00:00
|
|
|
confDir,
|
2023-09-18 16:53:07 +00:00
|
|
|
roomConfDir,
|
|
|
|
moderationBotGlobalConf
|
2023-09-15 15:55:07 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
return singleton
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns the singleton, of thrown an exception if it is not initialized yet.
|
|
|
|
*/
|
|
|
|
public static singleton (): BotConfiguration {
|
|
|
|
if (!singleton) {
|
|
|
|
throw new Error('BotConfiguration singleton not initialized yet')
|
|
|
|
}
|
|
|
|
return singleton
|
|
|
|
}
|
|
|
|
|
2023-09-22 14:30:12 +00:00
|
|
|
/**
|
|
|
|
* Get the current room conf content.
|
|
|
|
* @param roomJIDParam room JID (local or full)
|
|
|
|
* @returns the room conf, or null if does not exist
|
|
|
|
*/
|
|
|
|
public async getRoom (roomJIDParam: string): Promise<ChannelCommonRoomConf | null> {
|
|
|
|
const roomJID = this._canonicJID(roomJIDParam)
|
|
|
|
if (!roomJID) {
|
|
|
|
this.logger.error('Invalid room JID')
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
const conf = await this._getRoomConf(roomJID)
|
|
|
|
return conf
|
|
|
|
}
|
|
|
|
|
2023-09-15 15:55:07 +00:00
|
|
|
/**
|
2023-09-18 10:23:35 +00:00
|
|
|
* Update the bot configuration for a given room.
|
|
|
|
* @param roomJIDParam Room full or local JID
|
|
|
|
* @param conf Configuration to write
|
2023-09-15 15:55:07 +00:00
|
|
|
*/
|
2023-09-18 16:53:07 +00:00
|
|
|
public async updateRoom (roomJIDParam: string, conf: ChannelCommonRoomConf): Promise<void> {
|
2023-09-18 10:23:35 +00:00
|
|
|
const roomJID = this._canonicJID(roomJIDParam)
|
|
|
|
if (!roomJID) {
|
|
|
|
this.logger.error('Invalid room JID')
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
const roomConf: RoomConf = Object.assign({
|
|
|
|
local: roomJID,
|
2023-09-18 12:37:33 +00:00
|
|
|
domain: this.mucDomain
|
2023-09-18 10:23:35 +00:00
|
|
|
}, conf)
|
|
|
|
|
|
|
|
if (!(roomConf.enabled ?? true)) {
|
|
|
|
// Bot disabled... If the room config file does not exist, no need to create
|
|
|
|
const current = await this._getRoomConf(roomJID)
|
|
|
|
if (!current) {
|
|
|
|
this.logger.debug(`Bot is disabled for room ${roomJID}, and room has not current conf, skipping`)
|
|
|
|
return
|
2023-09-15 15:55:07 +00:00
|
|
|
}
|
|
|
|
}
|
2023-09-18 10:23:35 +00:00
|
|
|
|
|
|
|
this.logger.debug(`Setting and writing a new conf for room ${roomJID}`)
|
|
|
|
this.roomConfCache.set(roomJID, roomConf)
|
|
|
|
await this._writeRoomConf(roomJID)
|
2023-09-15 15:55:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Disable the bot for the room.
|
|
|
|
* Can be used when a video/channel is deleted, for example.
|
|
|
|
* @param roomJIDParam Room JID (can be local part only, or full JID)
|
|
|
|
*/
|
|
|
|
public async disableRoom (roomJIDParam: string): Promise<void> {
|
|
|
|
const roomJID = this._canonicJID(roomJIDParam)
|
|
|
|
if (!roomJID) {
|
|
|
|
this.logger.error('Invalid room JID')
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
const conf = await this._getRoomConf(roomJID)
|
|
|
|
if (!conf) {
|
|
|
|
// no conf, so nothing to write.
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
conf.enabled = false
|
|
|
|
await this._writeRoomConf(roomJID)
|
|
|
|
}
|
|
|
|
|
2023-09-18 16:53:07 +00:00
|
|
|
/**
|
|
|
|
* Returns the moderation bot global configuration.
|
|
|
|
* It it does not exists, creates it.
|
|
|
|
* @param forceRefresh if true, regenerates the configuration file, even if exists.
|
|
|
|
*/
|
|
|
|
public async getModerationBotGlobalConf (forceRefresh?: boolean): Promise<Config> {
|
|
|
|
let config: Config | undefined
|
|
|
|
if (!forceRefresh) {
|
|
|
|
try {
|
|
|
|
const content = (await fs.promises.readFile(this.moderationBotGlobalConf, {
|
|
|
|
encoding: 'utf-8'
|
|
|
|
})).toString()
|
|
|
|
|
|
|
|
config = JSON.parse(content)
|
2024-09-07 12:49:27 +00:00
|
|
|
} catch (_err) {
|
2023-09-18 16:53:07 +00:00
|
|
|
this.logger.info('Error reading the moderation bot global configuration file, assuming it does not exists.')
|
|
|
|
config = undefined
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!config) {
|
|
|
|
// FIXME: use existing lib to get the port, dont hardcode default value here.
|
|
|
|
const portSetting = await this.options.settingsManager.getSetting('prosody-port')
|
|
|
|
const port = (portSetting as string) || '52800'
|
|
|
|
|
|
|
|
config = {
|
|
|
|
type: 'client',
|
|
|
|
connection: {
|
|
|
|
username: 'moderator',
|
2023-09-20 08:29:18 +00:00
|
|
|
password: Math.random().toString(36).slice(2, 12) + Math.random().toString(36).slice(2, 12),
|
2023-09-18 16:53:07 +00:00
|
|
|
domain: this.botsDomain,
|
|
|
|
// Note: using localhost, and not currentProsody.host, because it does not always resolve correctly
|
|
|
|
service: 'xmpp://localhost:' + port
|
|
|
|
},
|
2023-09-21 14:09:50 +00:00
|
|
|
name: 'Sepia',
|
2023-09-18 16:53:07 +00:00
|
|
|
logger: 'ConsoleLogger',
|
|
|
|
log_level: isDebugMode(this.options) ? 'debug' : 'info'
|
|
|
|
}
|
|
|
|
await fs.promises.writeFile(this.moderationBotGlobalConf, JSON.stringify(config), {
|
|
|
|
encoding: 'utf-8'
|
|
|
|
})
|
|
|
|
}
|
|
|
|
return config
|
|
|
|
}
|
|
|
|
|
|
|
|
public configurationPaths (): {
|
|
|
|
moderation: {
|
|
|
|
globalFile: string
|
2023-09-19 13:54:56 +00:00
|
|
|
globalDir: string
|
2023-09-18 16:53:07 +00:00
|
|
|
roomConfDir: string
|
|
|
|
}
|
|
|
|
} {
|
|
|
|
return {
|
|
|
|
moderation: {
|
|
|
|
globalFile: this.moderationBotGlobalConf,
|
2023-09-19 13:54:56 +00:00
|
|
|
globalDir: this.confDir,
|
2023-09-18 16:53:07 +00:00
|
|
|
roomConfDir: this.roomConfDir
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-09-19 16:02:57 +00:00
|
|
|
/**
|
|
|
|
* Returns the moderation bot JID
|
|
|
|
*/
|
|
|
|
public moderationBotJID (): string {
|
|
|
|
return 'moderator@' + this.botsDomain
|
|
|
|
}
|
|
|
|
|
2023-09-18 10:23:35 +00:00
|
|
|
/**
|
|
|
|
* frees the singleton
|
|
|
|
*/
|
|
|
|
public static async destroySingleton (): Promise<void> {
|
|
|
|
if (!singleton) { return }
|
|
|
|
singleton = undefined
|
|
|
|
}
|
|
|
|
|
2023-09-15 15:55:07 +00:00
|
|
|
/**
|
|
|
|
* Get the room conf.
|
|
|
|
* Note: the returned object is not cloned. So it can be modified
|
|
|
|
* (and that's one reason why this is a protected method)
|
|
|
|
* The other reason why it is protected, is because it assumes roomJID is already canonical.
|
|
|
|
* @param roomJID room JID, canonic form
|
|
|
|
* @returns the room conf, or null if does not exists
|
|
|
|
*/
|
|
|
|
protected async _getRoomConf (roomJID: string): Promise<RoomConf | null> {
|
|
|
|
const cached = this.roomConfCache.get(roomJID)
|
|
|
|
if (cached !== undefined) {
|
|
|
|
return cached
|
|
|
|
}
|
|
|
|
|
|
|
|
const filePath = path.resolve(
|
|
|
|
this.roomConfDir,
|
|
|
|
roomJID + '.json'
|
|
|
|
)
|
|
|
|
|
|
|
|
let content: string
|
|
|
|
try {
|
|
|
|
content = (await fs.promises.readFile(filePath, {
|
|
|
|
encoding: 'utf-8'
|
|
|
|
})).toString()
|
2024-09-07 12:49:27 +00:00
|
|
|
} catch (_err) {
|
2023-09-15 15:55:07 +00:00
|
|
|
this.logger.debug('Failed to read room conf file, assuming it does not exists')
|
|
|
|
this.roomConfCache.set(roomJID, null)
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
|
|
|
|
let json: RoomConf
|
|
|
|
try {
|
|
|
|
json = JSON.parse(content) as RoomConf
|
2024-09-07 12:49:27 +00:00
|
|
|
} catch (_err) {
|
2023-09-15 15:55:07 +00:00
|
|
|
this.logger.error(`Error parsing JSON file ${filePath}, assuming empty`)
|
|
|
|
this.roomConfCache.set(roomJID, null)
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
|
|
|
|
this.roomConfCache.set(roomJID, json)
|
|
|
|
return json
|
|
|
|
}
|
|
|
|
|
|
|
|
protected async _writeRoomConf (roomJID: string): Promise<void> {
|
|
|
|
const conf = this.roomConfCache.get(roomJID)
|
|
|
|
if (!conf) {
|
|
|
|
throw new Error(`No conf for room ${roomJID}, cant write it`)
|
|
|
|
}
|
|
|
|
|
|
|
|
const filePath = path.resolve(
|
|
|
|
this.roomConfDir,
|
|
|
|
roomJID + '.json'
|
|
|
|
)
|
|
|
|
|
|
|
|
await fs.promises.writeFile(filePath, JSON.stringify(conf), {
|
|
|
|
encoding: 'utf-8'
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
2023-09-18 12:37:33 +00:00
|
|
|
if (splits[1] !== this.mucDomain) {
|
2023-09-15 15:55:07 +00:00
|
|
|
this.logger.error('The room JID is not on the correct domain')
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
|
|
|
|
return splits[0]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export {
|
2023-09-18 10:23:35 +00:00
|
|
|
BotConfiguration,
|
|
|
|
ChannelCommonRoomConf
|
2023-09-15 15:55:07 +00:00
|
|
|
}
|