Moderation Bot integration WIP:

* Start and stop the bot WIP
* Prosody: removing the BOSH module from the global scope (must only be present on relevant virtualhosts)
* Some refactoring
This commit is contained in:
John Livingston 2023-09-18 18:53:07 +02:00
parent 65fd49a81c
commit f97e54d499
No known key found for this signature in database
GPG Key ID: B17B5640CE66CDBC
15 changed files with 348 additions and 26 deletions

View File

@ -1,6 +1,6 @@
# Changelog
## XXX (Unreleased Yet)
## 8.0.0 (Unreleased Yet)
### New features
@ -13,6 +13,7 @@
* Links to documentation are now using the front-end language to point to the translated documentation page (except for some links generated from the backend, in the diagnostic tool for example).
* Some code refactoring.
* You can now configure on which network interfaces Prosody will listen for external components.
* Prosody: removing the BOSH module from the global scope (must only be present on relevant virtualhosts).
## 7.2.2

View File

@ -11,7 +11,7 @@ async function registerConfiguration (clientOptions: RegisterClientOptions): Pro
const { peertubeHelpers, registerClientRoute, registerHook } = clientOptions
const settings = await peertubeHelpers.getSettings()
if (settings['disable-configuration']) { return }
if (settings['disable-channel-configuration']) { return }
registerClientRoute({
route: 'livechat/configuration',

View File

@ -302,7 +302,7 @@ configuration_description: |
users will be able to add some customization on their channels,
activate the moderation bot, ...
disable_configuration_label: "Disable the advanced channel configuration and the chatbot"
disable_channel_configuration_label: "Disable the advanced channel configuration and the chatbot"
save: "Save"
cancel: "Cancel"

14
package-lock.json generated
View File

@ -15,7 +15,7 @@
"http-proxy": "^1.18.1",
"log-rotate": "^0.2.8",
"validate-color": "^2.2.1",
"xmppjs-chat-bot": "^0.2.1"
"xmppjs-chat-bot": "^0.2.2"
},
"devDependencies": {
"@peertube/feed": "^5.1.0",
@ -12069,9 +12069,9 @@
}
},
"node_modules/xmppjs-chat-bot": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/xmppjs-chat-bot/-/xmppjs-chat-bot-0.2.1.tgz",
"integrity": "sha512-O2uiVBlgio/5psb+DhCYjBU2HfW0pnQwTuKjutBTLoaTtfG+A+oxFmwarZKFDUjiQK6DrytW+LL+M8792rBaxw==",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/xmppjs-chat-bot/-/xmppjs-chat-bot-0.2.2.tgz",
"integrity": "sha512-/t1L2fSW04M/5zEGYQzXqgTa7CVaE1dAT9kO1C5iSOrd4HzksQ6vVsk0PlAbEZere08pbea8Kw8uxBSwGlTiXw==",
"funding": [
"https://paypal.me/JohnXLivingston",
"https://liberapay.com/JohnLivingston/"
@ -21291,9 +21291,9 @@
}
},
"xmppjs-chat-bot": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/xmppjs-chat-bot/-/xmppjs-chat-bot-0.2.1.tgz",
"integrity": "sha512-O2uiVBlgio/5psb+DhCYjBU2HfW0pnQwTuKjutBTLoaTtfG+A+oxFmwarZKFDUjiQK6DrytW+LL+M8792rBaxw==",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/xmppjs-chat-bot/-/xmppjs-chat-bot-0.2.2.tgz",
"integrity": "sha512-/t1L2fSW04M/5zEGYQzXqgTa7CVaE1dAT9kO1C5iSOrd4HzksQ6vVsk0PlAbEZere08pbea8Kw8uxBSwGlTiXw==",
"requires": {
"@xmpp/client": "^0.13.1",
"@xmpp/component": "^0.13.1",

View File

@ -38,7 +38,7 @@
"http-proxy": "^1.18.1",
"log-rotate": "^0.2.8",
"validate-color": "^2.2.1",
"xmppjs-chat-bot": "^0.2.1"
"xmppjs-chat-bot": "^0.2.2"
},
"devDependencies": {
"@peertube/feed": "^5.1.0",

182
server/lib/bots/ctl.ts Normal file
View File

@ -0,0 +1,182 @@
import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { Config as XMPPChatBotConfig } from 'xmppjs-chat-bot'
import { BotConfiguration } from '../configuration/bot'
import * as child_process from 'child_process'
let singleton: BotsCtl | undefined
/**
* This class is to control the plugin bots.
* For now there is only one, the Moderation bot.
* But all public methods are made as if there was several bots, so it will be easier to add bots.
*/
class BotsCtl {
protected readonly options: RegisterServerOptions
protected readonly moderationGlobalConf: XMPPChatBotConfig
protected readonly logger: {
debug: (s: string) => void
info: (s: string) => void
warn: (s: string) => void
error: (s: string) => void
}
protected moderationBotProcess: ReturnType<typeof child_process.spawn> | undefined
constructor (params: {
options: RegisterServerOptions
moderationGlobalConf: XMPPChatBotConfig
}) {
this.options = params.options
this.moderationGlobalConf = params.moderationGlobalConf
const logger = params.options.peertubeHelpers.logger
this.logger = {
debug: (s) => logger.debug('[Bots] ' + s),
info: (s) => logger.info('[Bots] ' + s),
warn: (s) => logger.warn('[Bots] ' + s),
error: (s) => logger.error('[Bots] ' + s)
}
}
/**
* Starts all the required bots.
* If bots are already running, does nothing.
*/
public async start (): Promise<void> {
if (await this.options.settingsManager.getSetting('disable-channel-configuration')) {
this.logger.info('Advanced channel configuration is disabled, no bot to start')
return
}
this.logger.info('Starting moderation bot...')
if (this.moderationBotProcess?.exitCode === null) {
this.logger.info('Moderation bot still running, nothing to do')
return
}
const paths = BotConfiguration.singleton().configurationPaths()
// We will run: npm exec -- xmppjs-chat-bot [...]
const execArgs = [
'exec',
'--',
'xmppjs-chat-bot',
'run',
'-f',
paths.moderation.globalFile,
'--room-conf-dir',
paths.moderation.roomConfDir
]
const moderationBotProcess = child_process.spawn('npm', execArgs, {
cwd: __dirname, // must be in the livechat plugin tree, so that npm can found the package.
env: {
...process.env // will include NODE_ENV and co
}
})
moderationBotProcess.stdout?.on('data', (data) => {
this.logger.debug(`ModerationBot stdout: ${data as string}`)
})
moderationBotProcess.stderr?.on('data', (data) => {
this.logger.error(`ModerationBot stderr: ${data as string}`)
})
moderationBotProcess.on('error', (error) => {
this.logger.error(`ModerationBot exec error: ${JSON.stringify(error)}`)
})
moderationBotProcess.on('exit', (code) => {
this.logger.info(`ModerationBot process exited with code ${code ?? 'null'}`)
})
moderationBotProcess.on('close', (code) => {
this.logger.info(`ModerationBot process closed all stdio with code ${code ?? 'null'}`)
})
this.moderationBotProcess = moderationBotProcess
}
/**
* Stops all the bots
*/
public async stop (): Promise<void> {
this.logger.info('Stopping bots...')
if (!this.moderationBotProcess) {
this.logger.info('moderationBot was never running, everything is fine.')
return
}
if (this.moderationBotProcess.exitCode !== null) {
this.logger.info('The moderation bot has an exitCode, already stopped.')
return
}
const p = new Promise<void>((resolve, reject) => {
try {
if (!this.moderationBotProcess) { resolve() }
const moderationBotProcess: ReturnType<typeof child_process.spawn> =
this.moderationBotProcess as ReturnType<typeof child_process.spawn>
let resolved = false
// Trying to kill, and force kill if it takes more than 2 seconds
const timeout = setTimeout(() => {
this.logger.error('Moderation bot was not killed within 2 seconds, force killing')
moderationBotProcess.kill('SIGKILL')
resolved = true
resolve()
}, 2000)
moderationBotProcess.on('exit', () => {
if (resolved) { return }
resolved = true
if (timeout) { clearTimeout(timeout) }
resolve()
})
moderationBotProcess.on('close', () => {
if (resolved) { return }
resolved = true
if (timeout) { clearTimeout(timeout) }
resolve()
})
moderationBotProcess.kill()
} catch (err) {
this.logger.error(err as string)
reject(err)
}
})
return p
}
/**
* Instanciate a new singleton
* @param options server options
*/
public static async initSingleton (options: RegisterServerOptions): Promise<BotsCtl> {
// forceRefresh the bot global configuration file:
const moderationGlobalConf = await BotConfiguration.singleton().getModerationBotGlobalConf(true)
singleton = new BotsCtl({
options,
moderationGlobalConf
})
return singleton
}
/**
* Returns the singleton, of thrown an exception if it is not initialized yet.
* @returns the singleton
*/
public static singleton (): BotsCtl {
if (!singleton) {
throw new Error('Bots singleton not initialized yet')
}
return singleton
}
public static async destroySingleton (): Promise<void> {
if (!singleton) { return }
await singleton.stop()
singleton = undefined
}
}
export {
BotsCtl
}

View File

@ -1,6 +1,7 @@
import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { RoomConf } from 'xmppjs-chat-bot'
import type { RoomConf, Config } from 'xmppjs-chat-bot'
import { getProsodyDomain } from '../prosody/config/domain'
import { isDebugMode } from '../debug'
import * as path from 'path'
import * as fs from 'fs'
@ -18,8 +19,10 @@ type ChannelCommonRoomConf = Omit<RoomConf, 'local' | 'domain'>
class BotConfiguration {
protected readonly options: RegisterServerOptions
protected readonly mucDomain: string
protected readonly botsDomain: string
protected readonly confDir: string
protected readonly roomConfDir: string
protected readonly moderationBotGlobalConf: string
protected readonly logger: {
debug: (s: string) => void
info: (s: string) => void
@ -32,13 +35,17 @@ class BotConfiguration {
constructor (params: {
options: RegisterServerOptions
mucDomain: string
botsDomain: string
confDir: string
roomConfDir: string
moderationBotGlobalConf: string
}) {
this.options = params.options
this.mucDomain = params.mucDomain
this.botsDomain = params.botsDomain
this.confDir = params.confDir
this.roomConfDir = params.roomConfDir
this.moderationBotGlobalConf = params.moderationBotGlobalConf
const logger = params.options.peertubeHelpers.logger
this.logger = {
@ -55,15 +62,21 @@ class BotConfiguration {
public static async initSingleton (options: RegisterServerOptions): Promise<BotConfiguration> {
const prosodyDomain = await getProsodyDomain(options)
const mucDomain = 'room.' + prosodyDomain
const botsDomain = 'bot.' + prosodyDomain
const confDir = path.resolve(
options.peertubeHelpers.plugin.getDataDirectoryPath(),
'bot',
mucDomain
)
const roomConfDir = path.resolve(
confDir,
'rooms'
)
const moderationBotGlobalConf = path.resolve(
confDir,
'moderation.json'
)
await fs.promises.mkdir(confDir, { recursive: true })
await fs.promises.mkdir(roomConfDir, { recursive: true })
@ -71,8 +84,10 @@ class BotConfiguration {
singleton = new BotConfiguration({
options,
mucDomain,
botsDomain,
confDir,
roomConfDir
roomConfDir,
moderationBotGlobalConf
})
return singleton
@ -93,7 +108,7 @@ class BotConfiguration {
* @param roomJIDParam Room full or local JID
* @param conf Configuration to write
*/
public async update (roomJIDParam: string, conf: ChannelCommonRoomConf): Promise<void> {
public async updateRoom (roomJIDParam: string, conf: ChannelCommonRoomConf): Promise<void> {
const roomJID = this._canonicJID(roomJIDParam)
if (!roomJID) {
this.logger.error('Invalid room JID')
@ -141,6 +156,65 @@ class BotConfiguration {
await this._writeRoomConf(roomJID)
}
/**
* 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)
} catch (err) {
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',
password: Math.random().toString(36).slice(2, 12),
domain: this.botsDomain,
// Note: using localhost, and not currentProsody.host, because it does not always resolve correctly
service: 'xmpp://localhost:' + port
},
name: 'Moderator',
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
roomConfDir: string
}
} {
return {
moderation: {
globalFile: this.moderationBotGlobalConf,
roomConfDir: this.roomConfDir
}
}
}
/**
* frees the singleton
*/

View File

@ -10,9 +10,9 @@ import type { RequestPromiseHandler } from '../async'
function checkConfigurationEnabledMiddleware (options: RegisterServerOptions): RequestPromiseHandler {
return async (req: Request, res: Response, next: NextFunction) => {
const settings = await options.settingsManager.getSettings([
'disable-configuration'
'disable-channel-configuration'
])
if (!settings['disable-configuration']) {
if (!settings['disable-channel-configuration']) {
next()
return
}

View File

@ -1,4 +1,5 @@
import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { Config as XMPPBotConfig } from 'xmppjs-chat-bot'
import type { ProsodyLogLevel } from './config/content'
import * as fs from 'fs'
import * as path from 'path'
@ -9,6 +10,7 @@ import { getProsodyDomain } from './config/domain'
import { getAPIKey } from '../apikey'
import { parseExternalComponents } from './config/components'
import { getRemoteServerInfosDir } from '../federation/storage'
import { BotConfiguration } from '../configuration/bot'
async function getWorkingDir (options: RegisterServerOptions): Promise<string> {
const peertubeHelpers = options.peertubeHelpers
@ -125,6 +127,9 @@ interface ProsodyConfig {
logExpiration: ConfigLogExpiration
valuesToHideInDiagnostic: Map<string, string>
certificates: ProsodyConfigCertificates
bots: {
moderation?: XMPPBotConfig
}
}
async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<ProsodyConfig> {
const logger = options.peertubeHelpers.logger
@ -147,7 +152,8 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
'prosody-components-interfaces',
'prosody-components-list',
'chat-no-anonymous',
'federation-dont-publish-remotely'
'federation-dont-publish-remotely',
'disable-channel-configuration'
])
const valuesToHideInDiagnostic = new Map<string, string>()
@ -168,6 +174,7 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
// enableRemoteChatConnections: local users can communicate with external rooms
const enableRemoteChatConnections = !(settings['federation-dont-publish-remotely'] as boolean)
let certificates: ProsodyConfigCertificates = false
const bots: ProsodyConfig['bots'] = {}
const apikey = await getAPIKey(options)
valuesToHideInDiagnostic.set('APIKey', apikey)
@ -287,6 +294,12 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
config.usePeertubeVCards(basePeertubeUrl)
config.useAnonymousRandomVCards(paths.avatars)
if (!settings['disable-channel-configuration']) {
config.useBotsVirtualHost()
bots.moderation = await BotConfiguration.singleton().getModerationBotGlobalConf()
valuesToHideInDiagnostic.set('BotPassword', bots.moderation.connection.password)
}
config.useTestModule(apikey, testApiUrl)
let logLevel: ProsodyLogLevel | undefined
@ -315,7 +328,8 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
logByDefault,
logExpiration,
valuesToHideInDiagnostic,
certificates
certificates,
bots
}
}

View File

@ -140,6 +140,7 @@ class ProsodyConfigContent {
authenticated?: ProsodyConfigVirtualHost
anon?: ProsodyConfigVirtualHost
muc: ProsodyConfigComponent
bot?: ProsodyConfigVirtualHost
externalComponents: ProsodyConfigComponent[] = []
log: string
prosodyDomain: string
@ -170,7 +171,7 @@ class ProsodyConfigContent {
'version', // Replies to server version requests
'uptime', // Report how long server has been running
'ping', // Replies to XMPP pings with pongs
'bosh', // Enable BOSH clients, aka "Jabber over HTTP"
// 'bosh', // Enable BOSH clients, aka "Jabber over HTTP"
// 'websocket', // Enable Websocket clients
'posix', // POSIX functionality, sends server to background, enables syslog, etc.
// 'pep', // Enables users to publish their avatar, mood, activity, playing music and more
@ -192,6 +193,7 @@ class ProsodyConfigContent {
this.global.set('certificates', this.paths.certs)
}
this.muc.set('admins', [])
this.muc.set('muc_room_locking', false)
this.muc.set('muc_tombstones', false)
this.muc.set('muc_room_default_language', 'en')
@ -403,6 +405,16 @@ class ProsodyConfigContent {
}
}
/**
* Enable the bots virtualhost.
*/
useBotsVirtualHost (): void {
this.bot = new ProsodyConfigVirtualHost('bot.' + this.prosodyDomain)
this.bot.set('modules_enabled', ['ping'])
// TODO: bot vcards
}
setLog (level: ProsodyLogLevel, syslog?: ProsodyLogLevel[]): void {
let log = ''
log += 'log = {\n'
@ -431,6 +443,10 @@ class ProsodyConfigContent {
content += this.anon.write()
content += '\n\n'
}
if (this.bot) {
content += this.bot.write()
content += '\n\n'
}
content += this.muc.write()
content += '\n\n'
for (const externalComponent of this.externalComponents) {

View File

@ -342,7 +342,7 @@ class RoomChannel {
channelConfigurationOptionsToBotRoomConf(this.options, channelConfigurationOptions)
)
await BotConfiguration.singleton().update(roomJID, botConf)
await BotConfiguration.singleton().updateRoom(roomJID, botConf)
this.roomConfToUpdate.delete(roomJID)
}

View File

@ -2,6 +2,7 @@ import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { ConverseJSTheme } from '../../shared/lib/types'
import { ensureProsodyRunning } from './prosody/ctl'
import { RoomChannel } from './room-channel'
import { BotsCtl } from './bots/ctl'
import { loc } from './loc'
async function initSettings (options: RegisterServerOptions): Promise<void> {
@ -94,9 +95,9 @@ Please read
descriptionHTML: loc('experimental_warning')
})
registerSetting({
name: 'disable-configuration',
label: loc('disable_configuration_label'),
// descriptionHTML: loc('disable_configuration_description'),
name: 'disable-channel-configuration',
label: loc('disable_channel_configuration_label'),
// descriptionHTML: loc('disable_channel_configuration_description'),
type: 'input-checkbox',
default: false,
private: false
@ -401,8 +402,16 @@ Please read
// ********** settings changes management
settingsManager.onSettingsChange(async (settings: any) => {
// In case the Prosody port has changed, we must rewrite the Bot configuration file.
// To avoid race condition, we will just stop and start the bots at every settings saving.
await BotsCtl.destroySingleton()
await BotsCtl.initSingleton(options)
peertubeHelpers.logger.info('Saving settings, ensuring prosody is running')
await ensureProsodyRunning(options)
await BotsCtl.singleton().start()
// In case prosody-room-type changed, we must rebuild room-channel links.
if (settings['prosody-room-type'] !== currentProsodyRoomtype) {
peertubeHelpers.logger.info('Setting prosody-room-type has changed value, must rebuild room-channel infos')

View File

@ -11,6 +11,7 @@ import { unloadDebugMode } from './lib/debug'
import { loadLoc } from './lib/loc'
import { RoomChannel } from './lib/room-channel'
import { BotConfiguration } from './lib/configuration/bot'
import { BotsCtl } from './lib/bots/ctl'
import decache from 'decache'
// FIXME: Peertube unregister don't have any parameter.
@ -35,6 +36,9 @@ async function register (options: RegisterServerOptions): Promise<any> {
// roomChannelNeedsDataInit: if true, means that the data file does not exist (or is invalid), so we must initiate it
const roomChannelNeedsDataInit = !await roomChannelSingleton.readData()
// BotsCtl.initSingleton() will force reload the bots conf files, so must be done before generating Prosody Conf.
await BotsCtl.initSingleton(options)
await migrateSettings(options)
await initSettings(options)
@ -48,20 +52,35 @@ async function register (options: RegisterServerOptions): Promise<any> {
await prepareProsody(options)
await ensureProsodyRunning(options)
let preBotPromise: Promise<void>
if (roomChannelNeedsDataInit) {
logger.info('The RoomChannel singleton has not found any data, we must rebuild')
// no need to wait here, can be done without await.
roomChannelSingleton.rebuildData().then(
preBotPromise = roomChannelSingleton.rebuildData().then(
() => { logger.info('RoomChannel singleton rebuild done') },
(reason) => { logger.error('RoomChannel singleton rebuild failed: ' + (reason as string)) }
)
} else {
preBotPromise = Promise.resolve()
}
// Don't need to wait for the bot to start.
preBotPromise.then(
async () => {
await BotsCtl.singleton().start()
},
() => {}
)
} catch (error) {
options.peertubeHelpers.logger.error('Error when launching Prosody: ' + (error as string))
}
}
async function unregister (): Promise<any> {
try {
await BotsCtl.destroySingleton()
} catch (_error) {} // BotsCtl will log errors.
if (OPTIONS) {
try {
await ensureProsodyNotRunning(OPTIONS)

View File

@ -29,7 +29,7 @@ Following settings concern the federation with other Peertube instances, and oth
Following settings concern the advanced channel options:
users will be able to add some customization on their channels, activate the moderation bot, ...
### {{% livechat_label disable_configuration_label %}}
### {{% livechat_label disable_channel_configuration_label %}}
If you encounter any issue with this feature, you can disable it.

View File

@ -93,9 +93,16 @@ There will be some cleaning batch, to delete deprecated files.
The `bot/muc_domain` (where muc_domain is the current MUC domain) folder contains configuration files that are read by the moderation bot.
This bot uses the [xmppjs-chat-bot](https://github.com/JohnXLivingston/xmppjs-chat-bot) package.
Note: we include the MUC domain (`room.instance.tld`) in the filename in case the instance domain changes.
Note: we include the MUC domain (`room.instance.tld`) in the dirname in case the instance domain changes.
In such case, existing rooms could get lost, and we want a way to ignore them to avoid gettings errors.
## bot/muc_domain/moderation.json
The `bot/muc_domain/moderation.json` file contains the moderation bot global configuration.
This bot uses the [xmppjs-chat-bot](https://github.com/JohnXLivingston/xmppjs-chat-bot) package, see it's README file for more information.
Note: this includes the bot username and password. Don't let it leak.
### bot/muc_domain/rooms
The `bot/muc_domain/rooms` folder contains room configuration files.