// SPDX-FileCopyrightText: 2024 John Livingston // // 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 } 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() } 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]) } } } 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) } }