// SPDX-FileCopyrightText: 2024 John Livingston // // SPDX-License-Identifier: AGPL-3.0-only import type { RegisterServerOptions } from '@peertube/peertube-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 * as fs from 'fs' import * as path from 'path' // FIXME: should be exported by xmppjs-chat-bot type ConfigHandlers = ChannelCommonRoomConf['handlers'] type ConfigHandler = ConfigHandlers[0] /** * Get saved configuration options for the given channel. * Can throw an exception. * @param options Peertube server options * @param channelInfos Info from channel from which we want to get infos * @returns Channel configuration data, or null if nothing is stored */ async function getChannelConfigurationOptions ( options: RegisterServerOptions, channelId: number | string ): Promise { const logger = options.peertubeHelpers.logger const filePath = _getFilePath(options, channelId) if (!fs.existsSync(filePath)) { logger.debug('No stored data for channel, returning null') return null } const content = await fs.promises.readFile(filePath, { encoding: 'utf-8' }) const sanitized = await sanitizeChannelConfigurationOptions(options, channelId, JSON.parse(content)) return sanitized } function getDefaultChannelConfigurationOptions (_options: RegisterServerOptions): ChannelConfigurationOptions { return { bot: { enabled: false, nickname: 'Sepia', forbiddenWords: [], forbidSpecialChars: { enabled: false, reason: '', tolerance: 0, applyToModerators: false }, noDuplicate: { enabled: false, reason: '', delay: 60, applyToModerators: false }, quotes: [], commands: [] }, slowMode: { duration: 0 }, mute: { anonymous: false }, moderation: { delay: 0, anonymize: false }, terms: undefined } } /** * Save channel configuration options. * Can throw an exception. * @param options Peertube server options * @param ChannelConfigurationOptions data to save */ async function storeChannelConfigurationOptions ( options: RegisterServerOptions, channelId: number | string, channelConfigurationOptions: ChannelConfigurationOptions ): Promise { const filePath = _getFilePath(options, channelId) if (!fs.existsSync(filePath)) { const dir = path.dirname(filePath) if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }) } } const jsonContent = JSON.stringify(channelConfigurationOptions) await fs.promises.writeFile(filePath, jsonContent, { 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 * @param previousRoomConf the previous saved room conf, if available. Used to merge handlers. * @returns Partial bot room configuration */ function channelConfigurationOptionsToBotRoomConf ( options: RegisterServerOptions, channelConfigurationOptions: ChannelConfigurationOptions, previousRoomConf: ChannelCommonRoomConf | null ): ChannelCommonRoomConf { // Note concerning handlers: // If we want the bot to correctly enable/disable the handlers, // we must always define all handlers, even if not used. // That's why we are gathering handlers ids in handlersId, and disabling missing handlers at the end of this function. const handlersIds: Map = new Map() const handlers: ConfigHandlers = [] channelConfigurationOptions.bot.forbiddenWords.forEach((v, i) => { const id = 'forbidden_words_' + i.toString() handlersIds.set(id, true) handlers.push(_getForbiddenWordsHandler(id, v)) }) if (channelConfigurationOptions.bot.forbidSpecialChars.enabled) { const id = 'forbid_special_chars' handlersIds.set(id, true) handlers.push(_getForbidSpecialCharsHandler(id, channelConfigurationOptions.bot.forbidSpecialChars)) } if (channelConfigurationOptions.bot.noDuplicate.enabled) { const id = 'no_duplicate' handlersIds.set(id, true) handlers.push(_getNoDuplicateHandler(id, channelConfigurationOptions.bot.noDuplicate)) } channelConfigurationOptions.bot.quotes.forEach((v, i) => { const id = 'quote_' + i.toString() handlersIds.set(id, true) handlers.push(_getQuotesHandler(id, v)) }) channelConfigurationOptions.bot.commands.forEach((v, i) => { const id = 'command_' + i.toString() handlersIds.set(id, true) handlers.push(_getCommandsHandler(id, v)) }) // Disabling missing handlers: if (previousRoomConf) { for (const handler of previousRoomConf.handlers) { if (!handlersIds.has(handler.id)) { // cloning to avoid issues... const disabledHandler = JSON.parse(JSON.stringify(handler)) as typeof handler disabledHandler.enabled = false handlers.push(disabledHandler) } } } const roomConf: ChannelCommonRoomConf = { enabled: channelConfigurationOptions.bot.enabled, handlers } if (channelConfigurationOptions.bot.nickname && channelConfigurationOptions.bot.nickname !== '') { roomConf.nick = channelConfigurationOptions.bot.nickname } return roomConf } function _getForbiddenWordsHandler ( id: string, forbiddenWords: ChannelConfigurationOptions['bot']['forbiddenWords'][0] ): ConfigHandler { const handler: ConfigHandler = { type: 'moderate', id, enabled: false, options: { rules: [] } } if (forbiddenWords.entries.length === 0) { return handler } handler.enabled = true const rule: any = { name: id } if (forbiddenWords.regexp) { // Note: on the Peertube frontend, channelConfigurationOptions.forbiddenWords // is an array of RegExp definition (strings). // They are validated one by bone. // To increase the bot performance, we will join them all (hopping the bot will optimize them). rule.regexp = '(?:' + forbiddenWords.entries.join(')|(?:') + ')' } else { // Here we must add word-breaks and escape entries. // We join all entries in one Regexp (for the same reason as above). rule.regexp = '(?:' + forbiddenWords.entries.map( s => { s = _stringToWordRegexp(s) // Must add the \b... // ... but... won't work if the first (or last) char is an emoji. // So, doing this trick: if (/^\w/.test(s)) { s = '\\b' + s } if (/\w$/.test(s)) { s = s + '\\b' } // FIXME: this solution wont work for non-latin charsets. return s } ).join(')|(?:') + ')' } if (forbiddenWords.reason) { rule.reason = forbiddenWords.reason } handler.options.rules.push(rule) handler.options.applyToModerators = !!forbiddenWords.applyToModerators return handler } function _getForbidSpecialCharsHandler ( id: string, forbidSpecialChars: ChannelConfigurationOptions['bot']['forbidSpecialChars'] ): ConfigHandler { const handler: ConfigHandler = { type: 'moderate', id, enabled: true, options: { rules: [] } } // The regexp to find one invalid character: // (Note: Emoji_Modifier and Emoji_Component should not be matched alones, but seems a reasonnable compromise to avoid // complex regex). let regexp = '[^' + '\\s\\p{Letter}\\p{Number}\\p{Punctuation}\\p{Currency_Symbol}\\p{Emoji}\\p{Emoji_Component}\\p{Emoji_Modifier}' + ']' if (forbidSpecialChars.tolerance > 0) { // we must repeat ! const a = [] for (let i = 0; i <= forbidSpecialChars.tolerance; i++) { // N+1 values a.push(regexp) } regexp = a.join('.*') } const rule: any = { name: id, regexp, modifiers: 'us', reason: forbidSpecialChars.reason } handler.options.rules.push(rule) handler.options.applyToModerators = !!forbidSpecialChars.applyToModerators return handler } function _getNoDuplicateHandler ( id: string, noDuplicate: ChannelConfigurationOptions['bot']['noDuplicate'] ): ConfigHandler { const handler: ConfigHandler = { type: 'no-duplicate', id, enabled: true, options: { reason: noDuplicate.reason, delay: noDuplicate.delay, applyToModerators: !!noDuplicate.applyToModerators } } return handler } function _getQuotesHandler ( id: string, quotes: ChannelConfigurationOptions['bot']['quotes'][0] ): ConfigHandler { const handler: ConfigHandler = { type: 'quotes_random', id, enabled: false, options: { quotes: [], delay: 5 * 60 } } if (quotes.messages.length === 0) { return handler } handler.enabled = true handler.options.quotes = quotes.messages handler.options.delay = quotes.delay return handler } function _getCommandsHandler ( id: string, command: ChannelConfigurationOptions['bot']['commands'][0] ): ConfigHandler { const handler: ConfigHandler = { type: 'command_say', id, enabled: false, options: { quotes: [], command: 'undefined' // This is arbitrary, and does not matter as enabled=false } } if (!command.message || command.message === '') { return handler } handler.enabled = true handler.options.command = command.command handler.options.quotes = [command.message] return handler } const stringToWordRegexpSpecials = [ // order matters for these '-', '[', ']', // order doesn't matter for any of these '/', '{', '}', '(', ')', '*', '+', '?', '.', '\\', '^', '$', '|' ] // I choose to escape every character with '\' // even though only some strictly require it when inside of [] const stringToWordRegexp = RegExp('[' + stringToWordRegexpSpecials.join('\\') + ']', 'g') function _stringToWordRegexp (s: string): string { return s.replace(stringToWordRegexp, '\\$&') } function _getFilePath ( options: RegisterServerOptions, channelId: number | string ): string { // some sanitization, just in case... channelId = parseInt(channelId.toString()) if (isNaN(channelId)) { throw new Error(`Invalid channelId: ${channelId}`) } return path.resolve( options.peertubeHelpers.plugin.getDataDirectoryPath(), 'channelConfigurationOptions', channelId.toString() + '.json' ) } export { getChannelConfigurationOptions, getDefaultChannelConfigurationOptions, channelConfigurationOptionsToBotRoomConf, storeChannelConfigurationOptions }