diff --git a/bots/bots.ts b/bots/bots.ts index 5bc547e8..e1ccdfe2 100644 --- a/bots/bots.ts +++ b/bots/bots.ts @@ -1,78 +1,65 @@ -import * as path from 'path' +import { BotsConfig } from './lib/config' +import { logger } from './lib/logger' +import { ComponentBot } from './lib/bot/component' +import { DemoBot } from './lib/bot/demobot' -let demoBotConfigFile = process.argv[2] -if (!demoBotConfigFile) { +if (!process.argv[2]) { throw new Error('Missing parameter: the demobot configuration file path') } -demoBotConfigFile = path.resolve(demoBotConfigFile) +const botsConfig = new BotsConfig(process.argv[2]) -// Not necessary, but just in case: perform some path checking... -function checkBotConfigFilePath (configPath: string): void { - const parts = configPath.split(path.sep) - if (!parts.includes('peertube-plugin-livechat')) { - // Indeed, the path should contain the plugin name - // (/var/www/peertube/storage/plugins/data/peertube-plugin-livechat/...) - throw new Error('demobot configuration file path seems invalid (not in peertube-plugin-livechat folder).') +const runningBots: ComponentBot[] = [] + +async function start (botsConfig: BotsConfig): Promise { + await botsConfig.load() + + let atLeastOne: boolean = false + if (botsConfig.useDemoBot()) { + atLeastOne = true + logger.info('Starting DemoBot...') + + const config = botsConfig.getDemoBotConfig() + const instance = new DemoBot( + 'DemoBot', + { + service: config.service, + domain: config.domain, + password: config.password + }, + config.rooms, + 'DemoBot' // FIXME: handle the case where the nick is already taken. + ) + runningBots.push(instance) + instance.connect().catch(err => { throw err }) } - if (parts[parts.length - 1] !== 'demobot.js') { - throw new Error('demobot configuration file path seems invalid (filename is not demobot.js).') + if (!atLeastOne) { + logger.info('No bot to launch, exiting.') + process.exit(0) } } -checkBotConfigFilePath(demoBotConfigFile) -const demoBotConf = require(demoBotConfigFile).getConf() -if (!demoBotConf || !demoBotConf.UUIDs || !demoBotConf.UUIDs.length) { +async function shutdown (): Promise { + logger.info('Shutdown...') + for (const bot of runningBots) { + logger.info('Stopping the bot ' + bot.botName + '...') + await bot.stop() + } process.exit(0) } -const { component, xml } = require('@xmpp/component') -const xmpp = component({ - service: demoBotConf.service, - domain: demoBotConf.domain, - password: demoBotConf.password -}) -const roomId = `${demoBotConf.UUIDs[0] as string}@${demoBotConf.mucDomain as string}` - -xmpp.on('error', (err: any) => { - console.error(err) -}) - -xmpp.on('offline', () => { - console.log('offline') -}) - -xmpp.on('stanza', async (stanza: any) => { - console.log('stanza received' + (stanza?.toString ? ': ' + (stanza.toString() as string) : '')) - // if (stanza.is('message')) { - // console.log('stanza was a message: ' + (stanza.toString() as string)) - // } -}) - -xmpp.on('online', async (address: any) => { - console.log('Online with address: ' + JSON.stringify(address)) - - const presence = xml( - 'presence', - { - from: address.toString(), - to: roomId + '/DemoBot' - }, - xml('x', { - xmlns: 'http://jabber.org/protocol/muc' +// catching signals and do something before exit +['SIGHUP', 'SIGINT', 'SIGQUIT', 'SIGILL', 'SIGTRAP', 'SIGABRT', + 'SIGBUS', 'SIGFPE', 'SIGUSR1', 'SIGSEGV', 'SIGUSR2', 'SIGTERM' +].forEach((sig) => { + process.on(sig, () => { + logger.debug('Receiving signal: ' + sig) + shutdown().catch((err) => { + logger.error(`Error on shutting down: ${err as string}`) }) - ) - console.log('Sending presence...: ' + (presence.toString() as string)) - await xmpp.send(presence) - - setTimeout(() => { - const message = xml( - 'message', - { type: 'groupchat', to: roomId, from: address.toString() }, - xml('body', {}, 'Hello world') - ) - console.log('Sending message...: ' + (message.toString() as string)) - xmpp.send(message) - }, 1000) + }) }) -xmpp.start().catch(console.error) +start(botsConfig).catch((err) => { + logger.error(`Function start failed: ${err as string}`) + process.exit(1) +}) diff --git a/bots/lib/bot/component.ts b/bots/lib/bot/component.ts new file mode 100644 index 00000000..b569797c --- /dev/null +++ b/bots/lib/bot/component.ts @@ -0,0 +1,82 @@ +/* eslint-disable no-void */ +import { logger } from '../logger' +import { XMPP, XMPPXmlFunction, XMPPStanza, XMPPAddress } from './types' + +const { component, xml } = require('@xmpp/component') + +interface ComponentConnectionConfig { + service: string + domain: string + password: string +} + +abstract class ComponentBot { + protected xmpp?: XMPP + protected address?: XMPPAddress + + constructor ( + public readonly botName: string, + protected readonly connectionConfig: ComponentConnectionConfig + ) {} + + protected xml: XMPPXmlFunction = (...args) => xml(...args) + + public async connect (): Promise { + this.xmpp = component({ + service: this.connectionConfig.service, + domain: this.connectionConfig.domain, + password: this.connectionConfig.password + }) as XMPP + + this.xmpp.on('error', (err: any) => { + logger.error(err) + }) + + this.xmpp.on('offline', () => { + logger.info(`${this.botName} is now offline.`) + }) + + this.xmpp.on('stanza', (stanza: XMPPStanza) => { + logger.debug('stanza received' + (stanza?.toString ? ': ' + stanza.toString() : '')) + if (stanza.is('message')) { + void this.onMessage(stanza) + } + if (stanza.is('presence')) { + void this.onPresence(stanza) + } + if (stanza.is('iq')) { + void this.onIq(stanza) + } + }) + + this.xmpp.on('online', (address: XMPPAddress) => { + logger.debug('Online with address' + address.toString()) + + this.address = address + void this.onOnline() + }) + + this.xmpp.start() + } + + public async stop (): Promise { + const p = new Promise((resolve) => { + this.xmpp?.on('offline', () => { + logger.info(`Stoppping process: ${this.botName} is now offline.`) + resolve(true) + }) + }) + await this.xmpp?.stop() + await p + } + + protected async onMessage (_stanza: XMPPStanza): Promise {} + protected async onIq (_stanza: XMPPStanza): Promise {} + protected async onPresence (_stanza: XMPPStanza): Promise {} + protected async onOnline (): Promise {} +} + +export { + ComponentConnectionConfig, + ComponentBot +} diff --git a/bots/lib/bot/demobot.ts b/bots/lib/bot/demobot.ts new file mode 100644 index 00000000..9dd22299 --- /dev/null +++ b/bots/lib/bot/demobot.ts @@ -0,0 +1,11 @@ +import { RoomComponentBot } from './room' + +class DemoBot extends RoomComponentBot { + protected async onRoomJoin (roomId: string, nick: string): Promise { + await this.sendGroupchat(roomId, `Hello ${nick}! I'm the DemoBot, I'm here to demonstrate the chatroom.`) + } +} + +export { + DemoBot +} diff --git a/bots/lib/bot/room.ts b/bots/lib/bot/room.ts new file mode 100644 index 00000000..0266f944 --- /dev/null +++ b/bots/lib/bot/room.ts @@ -0,0 +1,132 @@ +import { ComponentBot, ComponentConnectionConfig } from './component' +import { XMPPStanza } from './types' +import { logger } from '../logger' + +interface RoomComponentBotRoomDescription { + jid: string + nick: string + users: Map +} + +abstract class RoomComponentBot extends ComponentBot { + protected readonly rooms: {[jid: string]: RoomComponentBotRoomDescription} = {} + + constructor ( + botName: string, + connectionConfig: ComponentConnectionConfig, + roomIds: string[], + protected readonly nick: string + ) { + super(botName, connectionConfig) + for (const roomId of roomIds) { + this.rooms[roomId] = { + jid: roomId, + nick: nick, + users: new Map() + } + } + } + + async onOnline (): Promise { + for (const roomId in this.rooms) { + const room = this.rooms[roomId] + logger.debug(`Connecting to room ${room.jid}...`) + const presence = this.xml( + 'presence', + { + from: this.address?.toString(), + to: room.jid + '/' + room.nick + }, + this.xml('x', { + xmlns: 'http://jabber.org/protocol/muc' + }) + ) + await this.xmpp?.send(presence) + } + await super.onOnline() + } + + protected async onPresence (stanza: XMPPStanza): Promise { + const [stanzaRoomId, stanzaNick] = stanza.attrs?.from.split('/') + if (this.rooms[stanzaRoomId]) { + await this.onRoomPresence(stanzaRoomId, stanza, stanzaNick) + } + } + + public async sendGroupchat (roomId: string, msg: string): Promise { + const room = this.rooms[roomId] + if (!room) { + logger.error('Trying to send a groupchat on an unknown room: ' + roomId) + return + } + const message = this.xml( + 'message', + { + type: 'groupchat', + to: room.jid, + from: this.address?.toString() + }, + this.xml('body', {}, msg) + ) + logger.debug('Sending message...: ' + (message.toString() as string)) + await this.xmpp?.send(message) + } + + public async stop (): Promise { + for (const roomId in this.rooms) { + const room = this.rooms[roomId] + logger.debug(`Leaving room ${room.jid}...`) + const presence = this.xml( + 'presence', + { + from: this.address?.toString(), + to: room.jid + '/' + room.nick, + type: 'unavailable' + } + ) + // FIXME: should wait for a presence stanza from the server. + await this.xmpp?.send(presence) + } + await super.stop() + } + + protected async onRoomPresence ( + roomId: string, + stanza: XMPPStanza, + nick?: string + ): Promise { + const room = this.rooms[roomId] + if (!room) { + return + } + if (!nick) { + return + } + const isPresent = stanza.attrs?.type !== 'unavailable' + // FIXME: selfPresence should better be tested by searching status=110 + const selfPresence = room.nick === nick + if (!isPresent) { + room.users.delete(nick) + if (!selfPresence) { + await this.onRoomPart(roomId, nick) + } + return + } + room.users.set(nick, { + nick + }) + if (!selfPresence) { + await this.onRoomJoin(roomId, nick) + } + } + + protected async onRoomJoin (_roomId: string, _nick: string): Promise {} + protected async onRoomPart (_roomId: string, _nick: string): Promise {} +} + +export { + RoomComponentBot +} diff --git a/bots/lib/bot/types.ts b/bots/lib/bot/types.ts new file mode 100644 index 00000000..03675bad --- /dev/null +++ b/bots/lib/bot/types.ts @@ -0,0 +1,21 @@ +import { EventEmitter } from 'events' + +export interface XMPP extends EventEmitter { + send: (xml: any) => any + start: () => any + stop: () => Promise +} + +export interface XMPPAddress { + toString: () => string +} + +export type XMPPStanzaType = 'message' | 'iq' | 'presence' + +export interface XMPPStanza { + attrs: any + is: (type: XMPPStanzaType) => boolean + toString: () => string +} + +export type XMPPXmlFunction = (type: string, attrs: object, content?: any) => any diff --git a/bots/lib/config.ts b/bots/lib/config.ts new file mode 100644 index 00000000..0f0f6048 --- /dev/null +++ b/bots/lib/config.ts @@ -0,0 +1,88 @@ +import * as path from 'path' +import * as fs from 'fs' +import decache from 'decache' +import { logger } from '../lib/logger' + +interface DemoBotConfig { + rooms: string[] + service: string + domain: string + mucDomain: string + password: string +} + +class BotsConfig { + protected readonly configDir: string + protected configs: { + demobot?: DemoBotConfig + } + + constructor (configDir: string) { + this.configDir = configDir = path.resolve(configDir) + + // Not necessary, but just in case: perform some path checking... (to limit code injection risks) + const parts = configDir.split(path.sep) + if (!parts.includes('peertube-plugin-livechat')) { + // Indeed, the path should contain the plugin name + // (/var/www/peertube/storage/plugins/data/peertube-plugin-livechat/...) + throw new Error('Bots configuration dir seems invalid (not in peertube-plugin-livechat folder).') + } + + this.configs = {} + } + + public async load (): Promise { + await this.loadDemoBot() + } + + protected async loadDemoBot (): Promise { + const configPath = path.resolve(this.configDir, 'demobot.js') + logger.debug(`Loading DemoBot config from file ${configPath}`) + if (!fs.existsSync(configPath)) { + logger.debug('The config file for DemoBot does not exist.') + delete this.configs.demobot + return + } + + decache(configPath) + + logger.debug('require DemoBot config file...') + const conf = require(configPath).getConf() as DemoBotConfig | null + if (!conf) { + logger.debug('getConf() returned null for the DemoBot.') + delete this.configs.demobot + return + } + if (!conf.rooms || !conf.domain || !conf.mucDomain || !conf.password || !conf.service) { + logger.error('Invalid DemoBot configuration: ' + JSON.stringify(conf)) + delete this.configs.demobot + return + } + + // Conf seems legit. But if there is no rooms, no need to keep it. + if (!conf.rooms.length) { + logger.debug('No room in DemoBot config.') + delete this.configs.demobot + return + } + + // TODO: detect changes? avoid reloading when not needed? or should it be by the caller? + logger.debug('Config loaded for demobot: ' + JSON.stringify(conf)) + this.configs.demobot = conf + } + + public useDemoBot (): boolean { + return (this.configs.demobot?.rooms?.length ?? 0) > 0 + } + + public getDemoBotConfig (): DemoBotConfig { + if (!this.configs.demobot) { + throw new Error('Should not call getDemoBotConfig when useDemoBot is false.') + } + return this.configs.demobot + } +} + +export { + BotsConfig +} diff --git a/bots/lib/logger.ts b/bots/lib/logger.ts new file mode 100644 index 00000000..d014a0dc --- /dev/null +++ b/bots/lib/logger.ts @@ -0,0 +1,23 @@ +class Logger { + public debug (s: string): void { + console.log(s) + } + + public info (s: string): void { + console.info(s) + } + + public warn (s: string): void { + console.warn(s) + } + + public error (s: string): void { + console.error(s) + } +} + +const logger = new Logger() + +export { + logger +} diff --git a/server/lib/prosody/config.ts b/server/lib/prosody/config.ts index ec89834a..aed905d5 100644 --- a/server/lib/prosody/config.ts +++ b/server/lib/prosody/config.ts @@ -198,7 +198,7 @@ async function getProsodyConfig (options: RegisterServerOptions): Promise 0) { useExternalComponents = true const componentSecret = await getExternalComponentKey(options, 'DEMOBOT') @@ -206,7 +206,7 @@ async function getProsodyConfig (options: RegisterServerOptions): Promise `${uuid}@room.${prosodyDomain}`), service: 'xmpp://127.0.0.1:' + externalComponentsPort, domain: 'demobot.' + prosodyDomain, mucDomain: 'room.' + prosodyDomain,