Store Bot configuration

This commit is contained in:
John Livingston 2023-09-18 12:23:35 +02:00
parent 35c9494ed7
commit a8c71fbadf
No known key found for this signature in database
GPG Key ID: B17B5640CE66CDBC
6 changed files with 192 additions and 67 deletions

View File

@ -1,7 +1,6 @@
import type { RegisterServerOptions } from '@peertube/peertube-types' import type { RegisterServerOptions } from '@peertube/peertube-types'
import { RoomConf } from 'xmppjs-chat-bot' import type { RoomConf } from 'xmppjs-chat-bot'
import { getProsodyDomain } from '../prosody/config/domain' import { getProsodyDomain } from '../prosody/config/domain'
import { RoomChannel } from '../room-channel'
import * as path from 'path' import * as path from 'path'
import * as fs from 'fs' import * as fs from 'fs'
@ -89,30 +88,34 @@ class BotConfiguration {
} }
/** /**
* Recompute the room configuration, and save it to disk. * Update the bot configuration for a given room.
* @param roomJIDParam room JID (local part only, or full JID) * @param roomJIDParam Room full or local JID
* @param conf Configuration to write
*/ */
public async updateChannelConf (channelId: number | string, conf: ChannelCommonRoomConf): Promise<void> { public async update (roomJIDParam: string, conf: ChannelCommonRoomConf): Promise<void> {
const jids = RoomChannel.singleton().getChannelRoomJIDs(channelId) const roomJID = this._canonicJID(roomJIDParam)
if (!roomJID) {
this.logger.error('Invalid room JID')
return
}
// cloning to avoid issues: const roomConf: RoomConf = Object.assign({
const roomConf: RoomConf = JSON.parse(JSON.stringify(conf)) local: roomJID,
roomConf.domain = this.prosodyDomain domain: this.prosodyDomain
}, conf)
for (const jid of jids) {
roomConf.local = jid
if (!(roomConf.enabled ?? true)) { if (!(roomConf.enabled ?? true)) {
// Bot disabled... If the room config file does not exist, no need to create // Bot disabled... If the room config file does not exist, no need to create
const current = await this._getRoomConf(jid) const current = await this._getRoomConf(roomJID)
if (!current) { if (!current) {
this.logger.debug(`Bot is disabled for channel ${channelId}, room ${jid} has not current conf, skipping`) this.logger.debug(`Bot is disabled for room ${roomJID}, and room has not current conf, skipping`)
return return
} }
} }
this.roomConfCache.set(jid, roomConf)
await this._writeRoomConf(jid) this.logger.debug(`Setting and writing a new conf for room ${roomJID}`)
} this.roomConfCache.set(roomJID, roomConf)
await this._writeRoomConf(roomJID)
} }
/** /**
@ -137,6 +140,14 @@ class BotConfiguration {
await this._writeRoomConf(roomJID) await this._writeRoomConf(roomJID)
} }
/**
* frees the singleton
*/
public static async destroySingleton (): Promise<void> {
if (!singleton) { return }
singleton = undefined
}
/** /**
* Get the room conf. * Get the room conf.
* Note: the returned object is not cloned. So it can be modified * Note: the returned object is not cloned. So it can be modified
@ -215,5 +226,6 @@ class BotConfiguration {
} }
export { export {
BotConfiguration BotConfiguration,
ChannelCommonRoomConf
} }

View File

@ -1,5 +1,5 @@
import type { RegisterServerOptions } from '@peertube/peertube-types' import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { ChannelConfigurationOptions, ChannelInfos } from '../../../../shared/lib/types' import type { ChannelConfigurationOptions } from '../../../../shared/lib/types'
/** /**
* Sanitize data so that they can safely be used/stored for channel configuration configuration. * Sanitize data so that they can safely be used/stored for channel configuration configuration.
@ -11,7 +11,7 @@ import type { ChannelConfigurationOptions, ChannelInfos } from '../../../../shar
*/ */
async function sanitizeChannelConfigurationOptions ( async function sanitizeChannelConfigurationOptions (
_options: RegisterServerOptions, _options: RegisterServerOptions,
_channelInfos: ChannelInfos, _channelId: number | string,
data: any data: any
): Promise<ChannelConfigurationOptions> { ): Promise<ChannelConfigurationOptions> {
const result = { const result = {

View File

@ -1,7 +1,8 @@
import type { RegisterServerOptions } from '@peertube/peertube-types' import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { ChannelConfiguration, ChannelInfos } from '../../../../shared/lib/types' import type { ChannelConfigurationOptions } from '../../../../shared/lib/types'
import type { ChannelCommonRoomConf } from '../../configuration/bot'
import { RoomChannel } from '../../room-channel'
import { sanitizeChannelConfigurationOptions } from '../../configuration/channel/sanitize' import { sanitizeChannelConfigurationOptions } from '../../configuration/channel/sanitize'
import { BotConfiguration } from '../../configuration/bot'
import * as fs from 'fs' import * as fs from 'fs'
import * as path from 'path' import * as path from 'path'
@ -10,32 +11,30 @@ import * as path from 'path'
* Can throw an exception. * Can throw an exception.
* @param options Peertube server options * @param options Peertube server options
* @param channelInfos Info from channel from which we want to get infos * @param channelInfos Info from channel from which we want to get infos
* @returns Channel configuration data * @returns Channel configuration data, or null if nothing is stored
*/ */
async function getChannelConfigurationOptions ( async function getChannelConfigurationOptions (
options: RegisterServerOptions, options: RegisterServerOptions,
channelInfos: ChannelInfos channelId: number | string
): Promise<ChannelConfiguration> { ): Promise<ChannelConfigurationOptions | null> {
const logger = options.peertubeHelpers.logger const logger = options.peertubeHelpers.logger
const filePath = _getFilePath(options, channelInfos) const filePath = _getFilePath(options, channelId)
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
logger.debug('No stored data for channel, returning default values') logger.debug('No stored data for channel, returning default values')
return { return null
channel: channelInfos,
configuration: {
bot: false,
bannedJIDs: [],
forbiddenWords: []
}
}
} }
const content = await fs.promises.readFile(filePath, { const content = await fs.promises.readFile(filePath, {
encoding: 'utf-8' encoding: 'utf-8'
}) })
const sanitized = await sanitizeChannelConfigurationOptions(options, channelInfos, JSON.parse(content)) const sanitized = await sanitizeChannelConfigurationOptions(options, channelId, JSON.parse(content))
return sanitized
}
function getDefaultChannelConfigurationOptions (_options: RegisterServerOptions): ChannelConfigurationOptions {
return { return {
channel: channelInfos, bot: false,
configuration: sanitized bannedJIDs: [],
forbiddenWords: []
} }
} }
@ -43,14 +42,14 @@ async function getChannelConfigurationOptions (
* Save channel configuration options. * Save channel configuration options.
* Can throw an exception. * Can throw an exception.
* @param options Peertube server options * @param options Peertube server options
* @param channelConfiguration data to save * @param ChannelConfigurationOptions data to save
*/ */
async function storeChannelConfigurationOptions ( async function storeChannelConfigurationOptions (
options: RegisterServerOptions, options: RegisterServerOptions,
channelConfiguration: ChannelConfiguration channelId: number | string,
channelConfigurationOptions: ChannelConfigurationOptions
): Promise<void> { ): Promise<void> {
const channelInfos = channelConfiguration.channel const filePath = _getFilePath(options, channelId)
const filePath = _getFilePath(options, channelInfos)
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
const dir = path.dirname(filePath) const dir = path.dirname(filePath)
@ -59,27 +58,40 @@ async function storeChannelConfigurationOptions (
} }
} }
const jsonContent = JSON.stringify(channelConfiguration.configuration) const jsonContent = JSON.stringify(channelConfigurationOptions)
await fs.promises.writeFile(filePath, jsonContent, { await fs.promises.writeFile(filePath, jsonContent, {
encoding: 'utf-8' encoding: 'utf-8'
}) })
RoomChannel.singleton().refreshChannelConfigurationOptions(channelId)
}
/**
* Converts the channel configuration to the bot room configuration object (minus the room JID and domain)
* @param options server options
* @param channelConfigurationOptions The channel configuration
* @returns Partial bot room configuration
*/
function channelConfigurationOptionsToBotRoomConf (
options: RegisterServerOptions,
channelConfigurationOptions: ChannelConfigurationOptions
): ChannelCommonRoomConf {
const roomConf = { const roomConf = {
enabled: channelConfiguration.configuration.bot, enabled: channelConfigurationOptions.bot,
// TODO: nick // TODO: nick
handlers: [] handlers: []
} }
await BotConfiguration.singleton().updateChannelConf(channelInfos.id, roomConf) return roomConf
} }
function _getFilePath ( function _getFilePath (
options: RegisterServerOptions, options: RegisterServerOptions,
channelInfos: ChannelInfos channelId: number | string
): string { ): string {
const channelId = channelInfos.id
// some sanitization, just in case... // some sanitization, just in case...
if (!/^\d+$/.test(channelId.toString())) { channelId = parseInt(channelId.toString())
if (isNaN(channelId)) {
throw new Error(`Invalid channelId: ${channelId}`) throw new Error(`Invalid channelId: ${channelId}`)
} }
@ -92,5 +104,7 @@ function _getFilePath (
export { export {
getChannelConfigurationOptions, getChannelConfigurationOptions,
getDefaultChannelConfigurationOptions,
channelConfigurationOptionsToBotRoomConf,
storeChannelConfigurationOptions storeChannelConfigurationOptions
} }

View File

@ -1,7 +1,14 @@
import type { RegisterServerOptions } from '@peertube/peertube-types' import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { RoomConf } from 'xmppjs-chat-bot'
import { getProsodyDomain } from '../prosody/config/domain' import { getProsodyDomain } from '../prosody/config/domain'
import { listProsodyRooms } from '../prosody/api/list-rooms' import { listProsodyRooms } from '../prosody/api/list-rooms'
import { getChannelInfosById } from '../database/channel' import { getChannelInfosById } from '../database/channel'
import { ChannelConfigurationOptions } from '../../../shared/lib/types'
import {
getChannelConfigurationOptions,
channelConfigurationOptionsToBotRoomConf
} from '../configuration/channel/storage'
import { BotConfiguration } from '../configuration/bot'
import * as path from 'path' import * as path from 'path'
import * as fs from 'fs' import * as fs from 'fs'
@ -24,6 +31,7 @@ class RoomChannel {
protected room2Channel: Map<string, number> = new Map<string, number>() protected room2Channel: Map<string, number> = new Map<string, number>()
protected channel2Rooms: Map<number, Map<string, true>> = new Map<number, Map<string, true>>() protected channel2Rooms: Map<number, Map<string, true>> = new Map<number, Map<string, true>>()
protected needSync: boolean = false protected needSync: boolean = false
protected roomConfToUpdate: Map<string, true> = new Map<string, true>()
protected syncTimeout: ReturnType<typeof setTimeout> | undefined protected syncTimeout: ReturnType<typeof setTimeout> | undefined
protected isWriting: boolean = false protected isWriting: boolean = false
@ -145,13 +153,14 @@ class RoomChannel {
const c2r = new Map<string, true>() const c2r = new Map<string, true>()
this.channel2Rooms.set(channelId, c2r) this.channel2Rooms.set(channelId, c2r)
for (const jid of rooms) { for (const roomJID of rooms) {
if (typeof jid !== 'string') { if (typeof roomJID !== 'string') {
this.logger.error('Invalid room jid for Channel ' + channelId.toString() + ', dropping') this.logger.error('Invalid room jid for Channel ' + channelId.toString() + ', dropping')
continue continue
} }
c2r.set(jid, true) c2r.set(roomJID, true)
this.room2Channel.set(jid, channelId) this.room2Channel.set(roomJID, channelId)
this.roomConfToUpdate.set(roomJID, true)
} }
} }
@ -234,6 +243,58 @@ class RoomChannel {
await fs.promises.mkdir(path.dirname(this.dataFilePath), { recursive: true }) await fs.promises.mkdir(path.dirname(this.dataFilePath), { recursive: true })
await fs.promises.writeFile(this.dataFilePath, JSON.stringify(data)) 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<number, ChannelConfigurationOptions | null>()
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 botConf: RoomConf = Object.assign(
{
local: roomJID,
domain: this.prosodyDomain
},
channelConfigurationOptionsToBotRoomConf(this.options, channelConfigurationOptions)
)
await BotConfiguration.singleton().update(roomJID, botConf)
}
this.logger.info('Syncing done.') this.logger.info('Syncing done.')
} catch (err) { } catch (err) {
this.logger.error(err as string) this.logger.error(err as string)
@ -296,11 +357,13 @@ class RoomChannel {
if (previousChannelId) { if (previousChannelId) {
if (this.room2Channel.delete(roomJID)) { if (this.room2Channel.delete(roomJID)) {
this.needSync = true this.needSync = true
this.roomConfToUpdate.set(roomJID, true)
} }
const previousRooms = this.channel2Rooms.get(previousChannelId) const previousRooms = this.channel2Rooms.get(previousChannelId)
if (previousRooms) { if (previousRooms) {
if (previousRooms.delete(roomJID)) { if (previousRooms.delete(roomJID)) {
this.needSync = true this.needSync = true
this.roomConfToUpdate.set(roomJID, true)
} }
} }
} }
@ -308,6 +371,7 @@ class RoomChannel {
if (this.room2Channel.get(roomJID) !== channelId) { if (this.room2Channel.get(roomJID) !== channelId) {
this.room2Channel.set(roomJID, channelId) this.room2Channel.set(roomJID, channelId)
this.needSync = true this.needSync = true
this.roomConfToUpdate.set(roomJID, true)
} }
let rooms = this.channel2Rooms.get(channelId) let rooms = this.channel2Rooms.get(channelId)
if (!rooms) { if (!rooms) {
@ -318,6 +382,7 @@ class RoomChannel {
if (!rooms.has(roomJID)) { if (!rooms.has(roomJID)) {
rooms.set(roomJID, true) rooms.set(roomJID, true)
this.needSync = true this.needSync = true
this.roomConfToUpdate.set(roomJID, true)
} }
this.scheduleSync() this.scheduleSync()
@ -340,12 +405,14 @@ class RoomChannel {
if (rooms) { if (rooms) {
if (rooms.delete(roomJID)) { if (rooms.delete(roomJID)) {
this.needSync = true this.needSync = true
this.roomConfToUpdate.set(roomJID, true)
} }
} }
} }
if (this.room2Channel.delete(roomJID)) { if (this.room2Channel.delete(roomJID)) {
this.needSync = true this.needSync = true
this.roomConfToUpdate.set(roomJID, true)
} }
this.scheduleSync() this.scheduleSync()
@ -364,11 +431,12 @@ class RoomChannel {
const rooms = this.channel2Rooms.get(channelId) const rooms = this.channel2Rooms.get(channelId)
if (rooms) { if (rooms) {
for (const jid of rooms.keys()) { for (const roomJID of rooms.keys()) {
// checking the consistency... only removing if the channel is the current one // checking the consistency... only removing if the channel is the current one
if (this.room2Channel.get(jid) === channelId) { if (this.room2Channel.get(roomJID) === channelId) {
this.room2Channel.delete(jid) this.room2Channel.delete(roomJID)
this.needSync = true this.needSync = true
this.roomConfToUpdate.set(roomJID, true)
} }
} }
} }
@ -414,6 +482,25 @@ class RoomChannel {
return Array.from(rooms.keys()) 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 { protected _canonicJID (roomJID: string): string | null {
const splits = roomJID.split('@') const splits = roomJID.split('@')
if (splits.length < 2) { if (splits.length < 2) {

View File

@ -4,7 +4,11 @@ import type { ChannelInfos } from '../../../../shared/lib/types'
import { asyncMiddleware } from '../../middlewares/async' import { asyncMiddleware } from '../../middlewares/async'
import { getCheckConfigurationChannelMiddleware } from '../../middlewares/configuration/channel' import { getCheckConfigurationChannelMiddleware } from '../../middlewares/configuration/channel'
import { checkConfigurationEnabledMiddleware } from '../../middlewares/configuration/configuration' import { checkConfigurationEnabledMiddleware } from '../../middlewares/configuration/configuration'
import { getChannelConfigurationOptions, storeChannelConfigurationOptions } from '../../configuration/channel/storage' import {
getChannelConfigurationOptions,
getDefaultChannelConfigurationOptions,
storeChannelConfigurationOptions
} from '../../configuration/channel/storage'
import { sanitizeChannelConfigurationOptions } from '../../configuration/channel/sanitize' import { sanitizeChannelConfigurationOptions } from '../../configuration/channel/sanitize'
async function initConfigurationApiRouter (options: RegisterServerOptions, router: Router): Promise<void> { async function initConfigurationApiRouter (options: RegisterServerOptions, router: Router): Promise<void> {
@ -21,7 +25,14 @@ async function initConfigurationApiRouter (options: RegisterServerOptions, route
} }
const channelInfos = res.locals.channelInfos as ChannelInfos const channelInfos = res.locals.channelInfos as ChannelInfos
const result = await getChannelConfigurationOptions(options, channelInfos) const channelOptions =
await getChannelConfigurationOptions(options, channelInfos.id) ??
getDefaultChannelConfigurationOptions(options)
const result = {
channel: channelInfos,
configuration: channelOptions
}
res.status(200) res.status(200)
res.json(result) res.json(result)
} }
@ -39,9 +50,9 @@ async function initConfigurationApiRouter (options: RegisterServerOptions, route
const channelInfos = res.locals.channelInfos as ChannelInfos const channelInfos = res.locals.channelInfos as ChannelInfos
logger.debug('Trying to save ChannelConfigurationOptions') logger.debug('Trying to save ChannelConfigurationOptions')
let configuration let channelOptions
try { try {
configuration = await sanitizeChannelConfigurationOptions(options, channelInfos, req.body) channelOptions = await sanitizeChannelConfigurationOptions(options, channelInfos.id, req.body)
} catch (err) { } catch (err) {
logger.warn(err) logger.warn(err)
res.sendStatus(400) res.sendStatus(400)
@ -51,9 +62,9 @@ async function initConfigurationApiRouter (options: RegisterServerOptions, route
logger.debug('Data seems ok, storing them.') logger.debug('Data seems ok, storing them.')
const result = { const result = {
channel: channelInfos, channel: channelInfos,
configuration configuration: channelOptions
} }
await storeChannelConfigurationOptions(options, result) await storeChannelConfigurationOptions(options, channelInfos.id, channelOptions)
res.status(200) res.status(200)
res.json(result) res.json(result)
} }

View File

@ -73,6 +73,7 @@ async function unregister (): Promise<any> {
unloadDebugMode() unloadDebugMode()
await RoomChannel.destroySingleton() await RoomChannel.destroySingleton()
await BotConfiguration.destroySingleton()
const module = __filename const module = __filename
OPTIONS?.peertubeHelpers.logger.info(`Unloading module ${module}...`) OPTIONS?.peertubeHelpers.logger.info(`Unloading module ${module}...`)