Demo Bot: WIP.
This commit is contained in:
parent
978ee83eee
commit
0e45f9a197
115
bots/bots.ts
115
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 (!process.argv[2]) {
|
||||||
if (!demoBotConfigFile) {
|
|
||||||
throw new Error('Missing parameter: the demobot configuration file path')
|
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...
|
const runningBots: ComponentBot[] = []
|
||||||
function checkBotConfigFilePath (configPath: string): void {
|
|
||||||
const parts = configPath.split(path.sep)
|
async function start (botsConfig: BotsConfig): Promise<void> {
|
||||||
if (!parts.includes('peertube-plugin-livechat')) {
|
await botsConfig.load()
|
||||||
// Indeed, the path should contain the plugin name
|
|
||||||
// (/var/www/peertube/storage/plugins/data/peertube-plugin-livechat/...)
|
let atLeastOne: boolean = false
|
||||||
throw new Error('demobot configuration file path seems invalid (not in peertube-plugin-livechat folder).')
|
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') {
|
if (!atLeastOne) {
|
||||||
throw new Error('demobot configuration file path seems invalid (filename is not demobot.js).')
|
logger.info('No bot to launch, exiting.')
|
||||||
|
process.exit(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkBotConfigFilePath(demoBotConfigFile)
|
|
||||||
|
|
||||||
const demoBotConf = require(demoBotConfigFile).getConf()
|
async function shutdown (): Promise<void> {
|
||||||
if (!demoBotConf || !demoBotConf.UUIDs || !demoBotConf.UUIDs.length) {
|
logger.info('Shutdown...')
|
||||||
|
for (const bot of runningBots) {
|
||||||
|
logger.info('Stopping the bot ' + bot.botName + '...')
|
||||||
|
await bot.stop()
|
||||||
|
}
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { component, xml } = require('@xmpp/component')
|
// catching signals and do something before exit
|
||||||
const xmpp = component({
|
['SIGHUP', 'SIGINT', 'SIGQUIT', 'SIGILL', 'SIGTRAP', 'SIGABRT',
|
||||||
service: demoBotConf.service,
|
'SIGBUS', 'SIGFPE', 'SIGUSR1', 'SIGSEGV', 'SIGUSR2', 'SIGTERM'
|
||||||
domain: demoBotConf.domain,
|
].forEach((sig) => {
|
||||||
password: demoBotConf.password
|
process.on(sig, () => {
|
||||||
})
|
logger.debug('Receiving signal: ' + sig)
|
||||||
const roomId = `${demoBotConf.UUIDs[0] as string}@${demoBotConf.mucDomain as string}`
|
shutdown().catch((err) => {
|
||||||
|
logger.error(`Error on shutting down: ${err 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'
|
|
||||||
})
|
})
|
||||||
)
|
|
||||||
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)
|
||||||
|
})
|
||||||
|
82
bots/lib/bot/component.ts
Normal file
82
bots/lib/bot/component.ts
Normal file
@ -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<void> {
|
||||||
|
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<any> {
|
||||||
|
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<void> {}
|
||||||
|
protected async onIq (_stanza: XMPPStanza): Promise<void> {}
|
||||||
|
protected async onPresence (_stanza: XMPPStanza): Promise<void> {}
|
||||||
|
protected async onOnline (): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ComponentConnectionConfig,
|
||||||
|
ComponentBot
|
||||||
|
}
|
11
bots/lib/bot/demobot.ts
Normal file
11
bots/lib/bot/demobot.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { RoomComponentBot } from './room'
|
||||||
|
|
||||||
|
class DemoBot extends RoomComponentBot {
|
||||||
|
protected async onRoomJoin (roomId: string, nick: string): Promise<void> {
|
||||||
|
await this.sendGroupchat(roomId, `Hello ${nick}! I'm the DemoBot, I'm here to demonstrate the chatroom.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DemoBot
|
||||||
|
}
|
132
bots/lib/bot/room.ts
Normal file
132
bots/lib/bot/room.ts
Normal file
@ -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<string, {
|
||||||
|
// TODO: add the current user status somewhere.
|
||||||
|
nick: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<void> {}
|
||||||
|
protected async onRoomPart (_roomId: string, _nick: string): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
RoomComponentBot
|
||||||
|
}
|
21
bots/lib/bot/types.ts
Normal file
21
bots/lib/bot/types.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { EventEmitter } from 'events'
|
||||||
|
|
||||||
|
export interface XMPP extends EventEmitter {
|
||||||
|
send: (xml: any) => any
|
||||||
|
start: () => any
|
||||||
|
stop: () => Promise<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
88
bots/lib/config.ts
Normal file
88
bots/lib/config.ts
Normal file
@ -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<void> {
|
||||||
|
await this.loadDemoBot()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async loadDemoBot (): Promise<void> {
|
||||||
|
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
|
||||||
|
}
|
23
bots/lib/logger.ts
Normal file
23
bots/lib/logger.ts
Normal file
@ -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
|
||||||
|
}
|
@ -198,7 +198,7 @@ async function getProsodyConfig (options: RegisterServerOptions): Promise<Prosod
|
|||||||
config.setLog(logLevel)
|
config.setLog(logLevel)
|
||||||
|
|
||||||
const demoBotUUIDs = parseConfigDemoBotUUIDs((settings['chat-videos-list'] as string) || '')
|
const demoBotUUIDs = parseConfigDemoBotUUIDs((settings['chat-videos-list'] as string) || '')
|
||||||
let demoBotContentObj: string = JSON.stringify({})
|
let demoBotContentObj: string = 'null'
|
||||||
if (demoBotUUIDs?.length > 0) {
|
if (demoBotUUIDs?.length > 0) {
|
||||||
useExternalComponents = true
|
useExternalComponents = true
|
||||||
const componentSecret = await getExternalComponentKey(options, 'DEMOBOT')
|
const componentSecret = await getExternalComponentKey(options, 'DEMOBOT')
|
||||||
@ -206,7 +206,7 @@ async function getProsodyConfig (options: RegisterServerOptions): Promise<Prosod
|
|||||||
config.useDemoBot(componentSecret)
|
config.useDemoBot(componentSecret)
|
||||||
bots.demobot = demoBotUUIDs
|
bots.demobot = demoBotUUIDs
|
||||||
demoBotContentObj = JSON.stringify({
|
demoBotContentObj = JSON.stringify({
|
||||||
UUIDs: demoBotUUIDs,
|
rooms: demoBotUUIDs.map((uuid) => `${uuid}@room.${prosodyDomain}`),
|
||||||
service: 'xmpp://127.0.0.1:' + externalComponentsPort,
|
service: 'xmpp://127.0.0.1:' + externalComponentsPort,
|
||||||
domain: 'demobot.' + prosodyDomain,
|
domain: 'demobot.' + prosodyDomain,
|
||||||
mucDomain: 'room.' + prosodyDomain,
|
mucDomain: 'room.' + prosodyDomain,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user