diff --git a/bots/bots.ts b/bots/bots.ts index e1ccdfe2..22aca0dd 100644 --- a/bots/bots.ts +++ b/bots/bots.ts @@ -1,14 +1,14 @@ import { BotsConfig } from './lib/config' import { logger } from './lib/logger' -import { ComponentBot } from './lib/bot/component' -import { DemoBot } from './lib/bot/demobot' +import { BotComponent } from './lib/bot/component' +import { BotHandlerDemo } from './lib/bot/handlers/demo' if (!process.argv[2]) { throw new Error('Missing parameter: the demobot configuration file path') } const botsConfig = new BotsConfig(process.argv[2]) -const runningBots: ComponentBot[] = [] +const runningBots: BotComponent[] = [] async function start (botsConfig: BotsConfig): Promise { await botsConfig.load() @@ -19,18 +19,23 @@ async function start (botsConfig: BotsConfig): Promise { logger.info('Starting DemoBot...') const config = botsConfig.getDemoBotConfig() - const instance = new DemoBot( + const instance = new BotComponent( 'DemoBot', { service: config.service, domain: config.domain, password: config.password }, - config.rooms, - 'DemoBot' // FIXME: handle the case where the nick is already taken. + config.mucDomain ) runningBots.push(instance) - instance.connect().catch(err => { throw err }) + + instance.connect().then(async () => { + for (const roomId of config.rooms) { + const room = await instance.joinRoom(roomId, 'DemoBot') + room.attachHandler(new BotHandlerDemo(room)) + } + }).catch(err => { throw err }) } if (!atLeastOne) { logger.info('No bot to launch, exiting.') @@ -42,7 +47,7 @@ async function shutdown (): Promise { logger.info('Shutdown...') for (const bot of runningBots) { logger.info('Stopping the bot ' + bot.botName + '...') - await bot.stop() + await bot.disconnect() } process.exit(0) } diff --git a/bots/lib/bot/component.ts b/bots/lib/bot/component.ts index d54a1ed1..5984a1ff 100644 --- a/bots/lib/bot/component.ts +++ b/bots/lib/bot/component.ts @@ -1,23 +1,20 @@ -/* eslint-disable no-void */ +import type { XMPPStanza, XMPPStanzaType } from './types' +import type { Node } from '@xmpp/xml' import { logger } from '../logger' -import type { XMPPStanza } from './types' -import { component, xml, Component } from '@xmpp/component' -import type { JID } from '@xmpp/jid' +import { component, xml, Component, Options } from '@xmpp/component' +import { parse, JID } from '@xmpp/jid' +import { BotRoom } from './room' -interface ComponentConnectionConfig { - service: string - domain: string - password: string -} - -abstract class ComponentBot { +class BotComponent { protected xmpp?: Component protected address?: JID - protected xml = xml + public readonly xml = xml + protected rooms: Map = new Map() constructor ( public readonly botName: string, - protected readonly connectionConfig: ComponentConnectionConfig + protected readonly connectionConfig: Options, + protected readonly mucDomain: string ) {} public async connect (): Promise { @@ -36,46 +33,78 @@ abstract class ComponentBot { }) 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) + logger.debug('stanza received' + stanza.toString()) + if (!stanza.attrs.from) { return } + const jid = parse(stanza.attrs.from) + const roomJid = jid.bare() // removing the «resource» part of the jid. + const room = this.rooms.get(roomJid.toString()) + if (!room) { + return } + room.emit('stanza', stanza, jid.getResource()) }) this.xmpp.on('online', (address) => { logger.debug('Online with address' + address.toString()) this.address = address - void this.onOnline() + + // 'online' is emitted at reconnection, so we must reset rooms rosters + this.rooms.forEach(room => room.emit('reset')) + }) + + this.xmpp.on('offline', () => { + logger.info(`Stoppping process: ${this.botName} is now offline.`) }) await 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) - }) - }) + public async disconnect (): Promise { + for (const [roomId, room] of this.rooms) { + logger.debug(`Leaving room ${roomId}...`) + await room.part() + } await this.xmpp?.stop() - await p + this.xmpp = undefined } - protected async onMessage (_stanza: XMPPStanza): Promise {} - protected async onIq (_stanza: XMPPStanza): Promise {} - protected async onPresence (_stanza: XMPPStanza): Promise {} - protected async onOnline (): Promise {} + public async sendStanza ( + type: XMPPStanzaType, + attrs: object, + ...children: Node[] + ): Promise { + attrs = Object.assign({ + from: this.address?.toString() + }, attrs) + + const stanza = this.xml(type, attrs, ...children) + logger.debug('stanza to emit: ' + stanza.toString()) + await this.xmpp?.send(stanza) + } + + public async joinRoom (roomId: string, nick: string): Promise { + const roomJID = new JID(roomId, this.mucDomain) + const roomJIDstr = roomJID.toString() + let room: BotRoom | undefined = this.rooms.get(roomJIDstr) + if (!room) { + room = new BotRoom(this, roomJID) + this.rooms.set(roomJIDstr, room) + } + await room.join(nick) + return room + } + + public async partRoom (roomId: string): Promise { + const roomJID = new JID(roomId, this.mucDomain) + const room = this.rooms.get(roomJID.toString()) + if (!room) { + return + } + await room.part() + } } export { - ComponentConnectionConfig, - ComponentBot + BotComponent } diff --git a/bots/lib/bot/demobot.ts b/bots/lib/bot/demobot.ts deleted file mode 100644 index 9dd22299..00000000 --- a/bots/lib/bot/demobot.ts +++ /dev/null @@ -1,11 +0,0 @@ -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/handlers/base.ts b/bots/lib/bot/handlers/base.ts new file mode 100644 index 00000000..db4c1ed8 --- /dev/null +++ b/bots/lib/bot/handlers/base.ts @@ -0,0 +1,11 @@ +import type { BotRoom } from '../room' + +export abstract class BotHandler { + constructor ( + protected readonly room: BotRoom + ) { + this.init() + } + + protected abstract init (): void +} diff --git a/bots/lib/bot/handlers/demo.ts b/bots/lib/bot/handlers/demo.ts new file mode 100644 index 00000000..7adc51d7 --- /dev/null +++ b/bots/lib/bot/handlers/demo.ts @@ -0,0 +1,19 @@ +import type { XMPPUser } from '../types' +import { BotHandler } from './base' + +export class BotHandlerDemo extends BotHandler { + protected init (): void { + const room = this.room + room.on('room_join', (user: XMPPUser) => { + if (user.isMe) { + return + } + if (!room.isOnline()) { + return + } + room.sendGroupchat( + `Hello ${user.nick}! I'm the DemoBot, I'm here to demonstrate the chatroom.` + ).catch(() => {}) + }) + } +} diff --git a/bots/lib/bot/room.ts b/bots/lib/bot/room.ts index 0266f944..bf6d3ee1 100644 --- a/bots/lib/bot/room.ts +++ b/bots/lib/bot/room.ts @@ -1,132 +1,130 @@ -import { ComponentBot, ComponentConnectionConfig } from './component' -import { XMPPStanza } from './types' +import type { BotComponent } from './component' +import type { BotHandler } from './handlers/base' +import type { XMPPStanza, XMPPUser } from './types' +import EventEmitter from 'events' +import { JID } from '@xmpp/jid' import { logger } from '../logger' -interface RoomComponentBotRoomDescription { - jid: string - nick: string - users: Map -} +export class BotRoom extends EventEmitter { + protected state: 'offline' | 'online' = 'offline' + protected userJID: JID | undefined + protected readonly roster: Map = new Map() -abstract class RoomComponentBot extends ComponentBot { - protected readonly rooms: {[jid: string]: RoomComponentBotRoomDescription} = {} + protected readonly handlers: BotHandler[] = [] constructor ( - botName: string, - connectionConfig: ComponentConnectionConfig, - roomIds: string[], - protected readonly nick: string + protected readonly component: BotComponent, + protected readonly roomJID: JID ) { - super(botName, connectionConfig) - for (const roomId of roomIds) { - this.rooms[roomId] = { - jid: roomId, - nick: nick, - users: new Map() - } - } + super() + + this.on('reset', () => { + this.state = 'offline' + this.roster.clear() + }) + this.on('stanza', (stanza: XMPPStanza, resource?: string) => { + this.receiveStanza(stanza, resource) + }) } - 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() + public isOnline (): boolean { + return this.state === 'online' } - 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 join (nick: string): Promise { + this.userJID = new JID(this.roomJID.getLocal(), this.roomJID.getDomain(), nick) + logger.debug(`Emitting a presence for room ${this.roomJID.toString()}...`) + await this.component.sendStanza('presence', + { + to: this.userJID.toString() + }, + this.component.xml('x', { + xmlns: 'http://jabber.org/protocol/muc' + }) + ) + // FIXME: should wait for a presence stanza from the server. + // FIXME: should handle used nick errors. } - 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( + public async part (): Promise { + if (!this.userJID) { return } + logger.debug(`Emitting a presence=unavailable for room ${this.roomJID.toString()}...`) + await this.component.sendStanza('presence', { + to: this.userJID.toString(), + type: 'unavailable' + }) + // FIXME: should wait for a presence stanza from the server. + } + + public async sendGroupchat (msg: string): Promise { + if (!this.userJID) { return } + logger.debug(`Emitting a groupchat message for room ${this.roomJID.toString()}...`) + await this.component.sendStanza( 'message', { type: 'groupchat', - to: room.jid, - from: this.address?.toString() + to: this.roomJID.toString() }, - this.xml('body', {}, msg) + this.component.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) + public receiveStanza (stanza: XMPPStanza, fromResource?: string): void { + if (stanza.name === 'presence') { + this.receivePresenceStanza(stanza, fromResource) } - await super.stop() } - protected async onRoomPresence ( - roomId: string, - stanza: XMPPStanza, - nick?: string - ): Promise { - const room = this.rooms[roomId] - if (!room) { + public receivePresenceStanza (stanza: XMPPStanza, fromResource?: string): void { + if (!fromResource) { 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) + + const isPresent = stanza.attrs.type !== 'unavailable' + + const statusElems = stanza.getChild('x')?.getChildren('status') + const statusCodes = [] + if (statusElems) { + for (const s of statusElems) { + statusCodes.push(parseInt(s.attrs.code)) } - return } - room.users.set(nick, { - nick - }) - if (!selfPresence) { - await this.onRoomJoin(roomId, nick) + const isMe = statusCodes.includes(110) // status 110 means that is concern the current user. + + let user = this.roster.get(fromResource) + const previousState = user?.state + if (!isPresent) { + if (!user) { + return + } + user.state = 'offline' + if (isMe) { + this.state = 'offline' + } + if (previousState === 'online') { + this.emit('room_part', user) + } + } else { + if (!user) { + user = { + state: 'online', + nick: fromResource, + isMe: isMe + } + this.roster.set(fromResource, user) + } else { + user.state = 'online' + } + if (isMe) { + this.state = 'online' + } + if (previousState !== 'online') { + this.emit('room_join', user) + } } } - protected async onRoomJoin (_roomId: string, _nick: string): Promise {} - protected async onRoomPart (_roomId: string, _nick: string): Promise {} -} - -export { - RoomComponentBot + public attachHandler (handler: BotHandler): void { + this.handlers.push(handler) + } } diff --git a/bots/lib/bot/types.ts b/bots/lib/bot/types.ts index e197a8cd..5fc1dd2c 100644 --- a/bots/lib/bot/types.ts +++ b/bots/lib/bot/types.ts @@ -5,3 +5,9 @@ export type XMPPStanzaType = 'message' | 'iq' | 'presence' export interface XMPPStanza extends Element { name: XMPPStanzaType } + +export interface XMPPUser { + state: 'offline' | 'online' + nick: string + isMe: boolean +} diff --git a/package.json b/package.json index a0e63ffb..198ead6a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ ], "dependencies": { "@xmpp/component": "^0.13.0", + "@xmpp/jid": "^0.13.0", "async": "^3.2.2", "body-parser": "^1.19.0", "decache": "^4.6.0", @@ -50,6 +51,7 @@ "@types/node": "^16.11.6", "@types/winston": "^2.4.4", "@types/xmpp__component": "^0.13.0", + "@types/xmpp__jid": "^1.3.2", "@typescript-eslint/eslint-plugin": "^4.29.0", "@typescript-eslint/parser": "^4.29.0", "eslint": "^7.32.0", diff --git a/server/lib/prosody/config.ts b/server/lib/prosody/config.ts index aed905d5..163be2dc 100644 --- a/server/lib/prosody/config.ts +++ b/server/lib/prosody/config.ts @@ -206,7 +206,7 @@ async function getProsodyConfig (options: RegisterServerOptions): Promise `${uuid}@room.${prosodyDomain}`), + rooms: demoBotUUIDs, service: 'xmpp://127.0.0.1:' + externalComponentsPort, domain: 'demobot.' + prosodyDomain, mucDomain: 'room.' + prosodyDomain,