peertube-plugin-livechat/conversejs/custom/shared/lib/pubsub-manager.js
2024-07-31 22:48:15 +02:00

374 lines
10 KiB
JavaScript

// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { converse, _converse, api } from '../../../src/headless/index.js'
const { $build, Strophe, $iq, sizzle } = converse.env
/**
* This class helps to manage some objects that are stored on pubsub nodes.
* This if for example used for livechat-taskslists and livechat-tasks,
* but could be used for other object types that would be added one day.
*/
export class PubSubManager {
roomJID
node
types
stanzaHandler
/**
* Created a new pubsub manager.
* @param {string} roomJID the room JID
* @param {string} node the node name to which subscribe
* @param {Array} types an array with object describing the object type, and how to handle them. Here is its format:
* {
* itemTag: 'task',
* xmlns: XMLNS_TASK,
* collection: mucModel.tasks,
* fields: { // these are item child nodes (tag names)
* name: String,
* },
* attributes: { // these are attribute on the node
* order: Number
* done: Boolean,
* list: String,
* }
* }
*/
constructor (roomJID, node, types) {
this.roomJID = roomJID
this.node = node
this.types = types
this.stanzaHandler = undefined
}
/**
* Starts the manager.
* This will subscribe to the node, and retrieve all existing objects.
*/
async start () {
// FIXME: handle errors. Find a way to display to user that this failed.
this.stanzaHandler = api.connection.get().addHandler(
(message) => {
try {
this._handleMessage(message)
} catch (err) {
console.log(err)
}
return true // if returning anything else, the handler will not be called again!
},
null, // The namespace to match.
'message', // The stanza name to match.
'headline', // The stanza type attribute to match.
null, // The stanza id attribute to match.
this.roomJID, // The stanza from attribute to match.
{
matchBareFromJid: true
} // The handler options
)
await this._subscribe()
await this._retrieveAllItems()
}
/**
* Stops the manager
*/
async stop () {
// Note: no need to unsubscribe from the pubsub node, the backend will do when users leave the room.
if (this.stanzaHandler) {
api.connection.get().deleteHandler(this.stanzaHandler)
this.stanzaHandler = undefined
}
}
/**
* Created a new item
* @param collection The collection handling this object.
* @param data Object data
*/
async createItem (collection, data) {
const type = this._typeFromCollection(collection)
if (!type) {
throw new Error('Collection not found in manager')
}
console.log('Creating item...')
await this._save(type, data)
console.log(`Node ${this.node} created on ${this.roomJID}.`)
}
async saveItem (item) {
const id = item.get('id')
if (!id) {
throw new Error('Can\'t delete an empty without ID')
}
const type = this._typeFromCollection(item.collection)
if (!type) {
throw new Error('Collection not found in manager')
}
const data = {}
for (const attr in (type.attributes ?? [])) {
const v = item.get(attr)
if (v === undefined) { continue }
if (type.attributes[attr] === Boolean && !v) { continue }
data[attr] = v
}
for (const field in (type.fields ?? [])) {
const v = item.get(field)
if (v === undefined) { continue }
data[field] = v
}
this._additionalModelToData(item, data)
console.log('Saving item...')
await this._save(type, data, id)
console.log(`Node ${this.node} created on ${this.roomJID}.`)
}
async deleteItems (items) {
await Promise.all[items.map(item => this.deleteItem(item))]
}
async deleteItem (item) {
const id = item.get('id')
if (!id) {
throw new Error('Can\'t delete an empty without ID')
}
const type = this._typeFromCollection(item.collection)
if (!type) {
throw new Error('Can\'t get type definition from item collection')
}
console.log('Deleting item ' + id + ' on node ' + this.node + ' for room ' + this.roomJID + '...')
const stanza = $iq({
from: _converse.bare_jid,
type: 'set',
to: this.roomJID
}).c('pubsub', { xmlns: Strophe.NS.PUBSUB })
.c('retract', { node: this.node, notify: '1' })
.c('item', { id })
await api.sendIQ(stanza)
console.log('Item deleted.')
}
async _save (type, data, id) {
const itemAttributes = {}
if (id) {
itemAttributes.id = id
}
const attributes = { xmlns: type.xmlns }
for (const attrName in type.attributes ?? []) {
if (!(attrName in data)) { continue }
attributes[attrName] = data[attrName]
}
const item = $build('item', itemAttributes).c(type.itemTag, attributes)
for (const fieldName in type.fields ?? []) {
if (!(fieldName in data)) { continue }
item.c(fieldName).t(data[fieldName]).up()
}
this._additionalDataToItemNode(data, item)
await api.pubsub.publish(this.roomJID, this.node, item)
}
/**
* Subscribed to the pubsub node.
*/
async _subscribe () {
const stanza = $iq({
type: 'set',
from: _converse.jid,
to: this.roomJID
}).c('pubsub', { xmlns: Strophe.NS.PUBSUB })
.c('subscribe', { node: this.node, jid: _converse.bare_jid })
try {
const iq = await api.sendIQ(stanza)
console.debug('subscribtion ok: ', iq)
} catch (iq) {
console.error('Failed to subscribe to ' + this.node, iq)
throw iq
}
}
/**
* Retrieves all items
*
* TODO: handle pagination if results are not all sent.
* See https://xmpp.org/extensions/xep-0060.html#subscriber-retrieve-returnsome
* (seems Prosody does not handle pagination for now)
*/
async _retrieveAllItems () {
// Requesting all items.
const stanza = $iq({
type: 'get',
from: _converse.jid,
to: this.roomJID
}).c('pubsub', { xmlns: Strophe.NS.PUBSUB })
.c('items', { node: this.node })
try {
const iq = await api.sendIQ(stanza)
this._handleIQ(iq)
} catch (iq) {
if (iq === null || !iq?.querySelector) {
console.error('Failed to retrieve objects from ' + this.node, iq)
throw iq
}
if (!iq.querySelector('error[type="cancel"] item-not-found')) {
console.error('Failed to retrieve objects from ' + this.node + ':', iq)
throw iq
}
// This is totally normal when you open an empty node.
console.log('Not ' + this.node + ' node for now')
}
}
/**
* Check if an incomming message contains data to dispatch.
* @param message The incoming stanza
*/
_handleMessage (message) {
const itemsNodes = sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"] items[node="${this.node}"]`, message)
if (!itemsNodes.length) {
return
}
for (const itemsNode of itemsNodes) {
this._dispatchStanza(itemsNode)
}
}
/**
* As _handleMessage, but for IQ.
* @param iq stanza
*/
_handleIQ (iq) {
const itemsNodes = sizzle(`pubsub items[node="${this.node}"]`, iq)
if (!itemsNodes.length) { return }
for (const itemsNode of itemsNodes) {
this._dispatchStanza(itemsNode)
}
}
/**
* Parse items in a stanza response, and dispatch them to collections.
* @param {stanza} itemsNode the 'items' part of the incomming stanza.
*/
_dispatchStanza (itemsNode) {
console.debug('Dispatching items for node ' + this.node + ' from stanza items: ', itemsNode)
for (const key in this.types) {
const type = this.types[key]
const selector = `item ${type.itemTag}[xmlns="${type.xmlns}"]`
const items = sizzle(selector, itemsNode)
if (!items.length) { continue }
console.log('Found ' + items.length + ' ' + type.itemTag + ' in stanza, dispatching...')
for (const item of items) {
// Note: we consider that there is only one object in an item.
const id = item.parentNode?.getAttribute('id')
if (!id) {
console.error('Missing id for this item', item)
continue
}
const data = this._parseItem(item, type)
if (data === null) { continue }
const existing = type.collection.get(id)
if (existing) {
existing.save(data)
} else {
type.collection.create(Object.assign(data, { id }))
}
}
}
this._handleRetractations(itemsNode)
}
/**
* Parse retractations in IQ or Message stanza, and process them.
* @param stanza IQ response
*/
_handleRetractations (stanza) {
// Note: here we don't know the object type. We must try on each collection.
const ids = sizzle('retract', stanza).map(i => i.getAttribute('id'))
for (const id of ids) {
for (const key in this.types) {
const type = this.types[key]
const item = type.collection.get(id)
if (!item) { continue }
console.log('Removing Item ' + id + ' that was found in collection ' + key)
type.collection.remove(item)
}
}
}
_parseItem (itemNode, type) {
const data = {}
if (type.fields) {
for (const child of itemNode.children ?? []) {
const fieldName = child.tagName
if (!(fieldName in type.fields)) {
continue
}
data[fieldName] = this._readValue(child.textContent, type.fields[fieldName])
}
}
if (type.attributes) {
for (const attr in type.attributes) {
const value = itemNode.getAttribute(attr)
if (attr !== undefined) {
data[attr] = this._readValue(value, type.attributes[attr])
}
}
}
this._additionalParseItemNode(itemNode, type, data)
return data
}
_readValue (v, t) {
switch (t) {
case String: return (v ?? '').toString()
case Number: return parseInt(v) // only integers?
case Boolean: return !!v
default: return v // dont know what to do
}
}
_typeFromCollection (collection) {
return Object.values(this.types).find(type => type.collection === collection)
}
/**
* Overload to add some custom code for model to data conversion.
*/
_additionalModelToData (_item, _data) {}
/**
* Overload to add some custom code for data to stanza conversion.
*/
_additionalDataToItemNode (_data, _item) {}
/**
* Overload to add some custom code item parsing.
*/
_additionalParseItemNode (_itemNode, _type, _data) {}
}