Demo Bot: WIP.

This commit is contained in:
John Livingston
2021-12-07 18:57:08 +01:00
parent 978ee83eee
commit 0e45f9a197
8 changed files with 410 additions and 66 deletions

82
bots/lib/bot/component.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
}