converse.js
Version:
Browser based XMPP chat client
446 lines (416 loc) • 17 kB
JavaScript
/**
* @module:plugin-muc-parsers
* @typedef {import('../muc/muc.js').default} MUC
* @typedef {import('./types').MUCMessageAttributes} MUCMessageAttributes
*/
import dayjs from 'dayjs';
import _converse from '../../shared/_converse.js';
import api from '../../shared/api/index.js';
import converse from '../../shared/api/public.js';
import { StanzaParseError } from '../../shared/errors.js';
import {
getChatMarker,
getChatState,
getCorrectionAttributes,
getEncryptionAttributes,
getErrorAttributes,
getOpenGraphMetadata,
getOutOfBandAttributes,
getReceiptId,
getReferences,
getRetractionAttributes,
getSpoilerAttributes,
getStanzaIDs,
isArchived,
isCarbon,
isHeadline,
isValidReceiptRequest,
throwErrorIfInvalidForward,
} from '../../shared/parsers';
import { STATUS_CODE_STANZAS } from './constants.js';
const { Strophe, sizzle, u } = converse.env;
const { NS } = Strophe;
/**
* Parses a message stanza for XEP-0316 MEP notification data
* @param {Element} stanza - The message stanza
* @returns {Array} Returns an array of objects representing <activity> elements.
*/
export function getMEPActivities(stanza) {
const items_el = sizzle(`items[node="${Strophe.NS.CONFINFO}"]`, stanza).pop();
if (!items_el) {
return null;
}
const from = stanza.getAttribute('from');
const msgid = stanza.getAttribute('id');
const selector =
`item ` + `conference-info[xmlns="${Strophe.NS.CONFINFO}"] ` + `activity[xmlns="${Strophe.NS.ACTIVITY}"]`;
return sizzle(selector, items_el).map(
/** @param {Element} el */ (el) => {
const message = el.querySelector('text')?.textContent;
if (message) {
const references = getReferences(stanza);
const reason = el.querySelector('reason')?.textContent;
return { from, msgid, message, reason, references, 'type': 'mep' };
}
return {};
}
);
}
/**
* Given a MUC stanza, check whether it has extended message information that
* includes the sender's real JID, as described here:
* https://xmpp.org/extensions/xep-0313.html#business-storeret-muc-archives
*
* If so, parse and return that data and return the user's JID
*
* Note, this function doesn't check whether this is actually a MAM archived stanza.
*
* @param {Element} stanza - The message stanza
* @returns {Object}
*/
function getJIDFromMUCUserData(stanza) {
const item = sizzle(`message > x[xmlns="${Strophe.NS.MUC_USER}"] item`, stanza).pop();
return item?.getAttribute('jid');
}
/**
* @param {Element} stanza - The message stanza
* message stanza, if it was contained, otherwise it's the message stanza itself.
* @returns {Object}
*/
function getDeprecatedModerationAttributes(stanza) {
const fastening = sizzle(`apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
if (fastening) {
const applies_to_id = fastening.getAttribute('id');
const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE0}"]`, fastening).pop();
if (moderated) {
const retracted = sizzle(`retract[xmlns="${Strophe.NS.RETRACT0}"]`, moderated).pop();
if (retracted) {
return {
editable: false,
moderated: 'retracted',
moderated_by: moderated.getAttribute('by'),
moderated_id: applies_to_id,
moderation_reason: moderated.querySelector('reason')?.textContent,
};
}
}
} else {
const tombstone = sizzle(`> moderated[xmlns="${Strophe.NS.MODERATE0}"]`, stanza).pop();
if (tombstone) {
const retracted = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT0}"]`, tombstone).pop();
if (retracted) {
return {
editable: false,
is_tombstone: true,
moderated_by: tombstone.getAttribute('by'),
retracted: tombstone.getAttribute('stamp'),
moderation_reason: tombstone.querySelector('reason')?.textContent,
};
}
}
}
return {};
}
/**
* @param {Element} stanza - The message stanza
* message stanza, if it was contained, otherwise it's the message stanza itself.
* @returns {Object}
*/
function getModerationAttributes(stanza) {
const retract = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
if (retract) {
const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, retract).pop();
if (moderated) {
return {
editable: false,
moderated: 'retracted',
moderated_by: moderated.getAttribute('by'),
moderated_by_id: moderated.querySelector('occupant-id')?.getAttribute('id'),
moderated_id: retract.getAttribute('id'),
moderation_reason: retract.querySelector('reason')?.textContent,
};
}
} else {
const tombstone = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
if (tombstone) {
return {
editable: false,
is_tombstone: true,
moderated_by: tombstone.getAttribute('by'),
moderated_by_id: tombstone.querySelector('occupant-id')?.getAttribute('id'),
retracted: tombstone.getAttribute('stamp'),
moderation_reason: tombstone.querySelector('reason')?.textContent,
};
}
}
return getDeprecatedModerationAttributes(stanza);
}
/**
* @param {Element} stanza
* @param {'presence'|'message'} type
* @returns {{codes: Array<import('./types').MUCStatusCode>, is_self: boolean}}
*/
function getStatusCodes(stanza, type) {
/**
* @typedef {import('./types').MUCStatusCode} MUCStatusCode
*/
const codes = sizzle(`${type} > x[xmlns="${Strophe.NS.MUC_USER}"] status`, stanza)
.map(/** @param {Element} s */ (s) => s.getAttribute('code'))
.filter(
/** @param {MUCStatusCode} c */
(c) => STATUS_CODE_STANZAS[c]?.includes(type)
);
if (type === 'presence' && codes.includes('333') && codes.includes('307')) {
// See: https://github.com/xsf/xeps/pull/969/files#diff-ac5113766e59219806793c1f7d967f1bR4966
codes.splice(codes.indexOf('307'), 1);
}
return {
codes,
is_self: codes.includes('110'),
};
}
/**
* @param {Element} stanza
* @param {MUC} chatbox
*/
function getOccupantID(stanza, chatbox) {
if (chatbox.features.get(Strophe.NS.OCCUPANTID)) {
return sizzle(`occupant-id[xmlns="${Strophe.NS.OCCUPANTID}"]`, stanza).pop()?.getAttribute('id');
}
}
/**
* Determines whether the sender of this MUC message is the current user or
* someone else.
* @param {MUCMessageAttributes} attrs
* @param {MUC} chatbox
* @returns {'me'|'them'}
*/
function getSender(attrs, chatbox) {
let is_me;
const own_occupant_id = chatbox.get('occupant_id');
if (own_occupant_id) {
is_me = attrs.occupant_id === own_occupant_id;
} else if (attrs.from_real_jid) {
const bare_jid = _converse.session.get('bare_jid');
is_me = Strophe.getBareJidFromJid(attrs.from_real_jid) === bare_jid;
} else {
is_me = attrs.nick === chatbox.get('nick');
}
return is_me ? 'me' : 'them';
}
/**
* Parses a passed in message stanza and returns an object of attributes.
* @param {Element} original_stanza - The message stanza
* @param {MUC} chatbox
* @returns {Promise<MUCMessageAttributes|StanzaParseError>}
*/
export async function parseMUCMessage(original_stanza, chatbox) {
throwErrorIfInvalidForward(original_stanza);
const forwarded_stanza = sizzle(
`result[xmlns="${NS.MAM}"] > forwarded[xmlns="${NS.FORWARD}"] > message`,
original_stanza
).pop();
const stanza = forwarded_stanza || original_stanza;
if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length) {
return new StanzaParseError(
stanza,
`Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`
);
}
let delay;
let body;
if (forwarded_stanza) {
if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, forwarded_stanza).length) {
return new StanzaParseError(
original_stanza,
`Invalid Stanza: Forged MAM groupchat message from ${original_stanza.getAttribute('from')}`
);
}
delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, forwarded_stanza.parentElement).pop();
body = forwarded_stanza.querySelector(':scope > body')?.textContent?.trim();
} else {
delay = sizzle(`message > delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
body = original_stanza.querySelector(':scope > body')?.textContent?.trim();
}
const from = stanza.getAttribute('from');
const marker = getChatMarker(stanza);
let attrs = /** @type {MUCMessageAttributes} */ (
Object.assign(
{
from,
body,
'activities': getMEPActivities(stanza),
'chat_state': getChatState(stanza),
'from_muc': Strophe.getBareJidFromJid(from),
'is_archived': isArchived(original_stanza),
'is_carbon': isCarbon(original_stanza),
'is_delayed': !!delay,
'is_forwarded': !!sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length,
'is_headline': isHeadline(stanza),
'is_markable': !!sizzle(`message > markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length,
'is_marker': !!marker,
'is_unstyled': !!sizzle(`message > unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
'marker_id': marker && marker.getAttribute('id'),
'nick': Strophe.unescapeNode(Strophe.getResourceFromJid(from)),
'occupant_id': getOccupantID(stanza, chatbox),
'receipt_id': getReceiptId(stanza),
'received': new Date().toISOString(),
'references': getReferences(stanza),
'subject': stanza.querySelector(':scope > subject')?.textContent,
'thread': stanza.querySelector(':scope > thread')?.textContent,
'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString(),
'to': stanza.getAttribute('to'),
'type': stanza.getAttribute('type'),
},
getErrorAttributes(stanza),
getOutOfBandAttributes(stanza),
getSpoilerAttributes(stanza),
getCorrectionAttributes(stanza, original_stanza),
getStanzaIDs(stanza, original_stanza),
getOpenGraphMetadata(stanza),
getRetractionAttributes(stanza, original_stanza),
getModerationAttributes(stanza),
getEncryptionAttributes(stanza),
getStatusCodes(stanza, 'message')
)
);
attrs.from_real_jid =
(attrs.is_archived && getJIDFromMUCUserData(stanza)) || chatbox.occupants.findOccupant(attrs)?.get('jid');
attrs = Object.assign(
{
'is_valid_receipt_request': isValidReceiptRequest(stanza, attrs),
'message': attrs.body || attrs.error, // TODO: Should only be used for error and info messages
'sender': getSender(attrs, chatbox),
},
attrs
);
if (attrs.is_archived && original_stanza.getAttribute('from') !== attrs.from_muc) {
return new StanzaParseError(
original_stanza,
`Invalid Stanza: Forged MAM message from ${original_stanza.getAttribute('from')}`
);
} else if (attrs.is_archived && original_stanza.getAttribute('from') !== chatbox.get('jid')) {
return new StanzaParseError(
original_stanza,
`Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`
);
} else if (attrs.is_carbon) {
return new StanzaParseError(
original_stanza,
'Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied'
);
}
// We prefer to use one of the XEP-0359 unique and stable stanza IDs as the Model id, to avoid duplicates.
attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${attrs.from_muc || attrs.from}`] || u.getUniqueId();
/**
* *Hook* which allows plugins to add additional parsing
* @event _converse#parseMUCMessage
*/
attrs = await api.hook('parseMUCMessage', original_stanza, attrs);
// We call this after the hook, to allow plugins to decrypt encrypted
// messages, since we need to parse the message text to determine whether
// there are media urls.
const metadata = await u.getMediaURLsMetadata(attrs.is_encrypted ? attrs.plaintext : attrs.body);
return Object.assign(attrs, metadata);
}
/**
* Given an IQ stanza with a member list, create an array of objects containing
* known member data (e.g. jid, nick, role, affiliation).
*
* @param {Element} iq
* @returns {import('./types').MemberListItem[]}
*/
export function parseMemberListIQ(iq) {
return sizzle(`query[xmlns="${Strophe.NS.MUC_ADMIN}"] item`, iq).map(
/** @param {Element} item */ (item) => {
const data = {
'affiliation': item.getAttribute('affiliation'),
};
const jid = item.getAttribute('jid');
if (u.isValidJID(jid)) {
data['jid'] = jid;
} else {
// XXX: Prosody sends nick for the jid attribute value
// Perhaps for anonymous room?
data['nick'] = jid;
}
const nick = item.getAttribute('nick');
if (nick) {
data['nick'] = nick;
}
const role = item.getAttribute('role');
if (role) {
data['role'] = nick;
}
return data;
}
);
}
/**
* @param {Element} stanza - The presence stanza
* @param {string} nick
* @returns {import('./types').MUCPresenceItemAttributes}
*/
function parsePresenceUserItem(stanza, nick) {
/**
* @typedef {import('./types').MUCAffiliation} MUCAffiliation
* @typedef {import('./types').MUCRole} MUCRole
*/
const item = sizzle(`presence > x[xmlns="${Strophe.NS.MUC_USER}"] item`, stanza).pop();
if (item) {
const actor = item.querySelector('actor');
return {
affiliation: /** @type {MUCAffiliation} */ (item.getAttribute('affiliation')),
role: /** @type {MUCRole} */ (item.getAttribute('role')),
jid: item.getAttribute('jid'),
nick: item.getAttribute('nick') || nick,
...(actor
? {
actor: {
nick: actor?.getAttribute('nick') ?? null,
jid: actor?.getAttribute('jid') ?? null,
},
}
: {}),
reason: item.querySelector('reason')?.textContent ?? null,
};
}
}
/**
* Parses a passed in MUC presence stanza and returns an object of attributes.
* @param {Element} stanza - The presence stanza
* @param {MUC} chatbox
* @returns {Promise<import('./types').MUCPresenceAttributes>}
*/
export async function parseMUCPresence(stanza, chatbox) {
await chatbox.initialized;
/**
* @typedef {import('./types').MUCPresenceAttributes} MUCPresenceAttributes
*/
const from = stanza.getAttribute('from');
const type = stanza.getAttribute('type');
const nick = Strophe.getResourceFromJid(from);
const attrs = /** @type {MUCPresenceAttributes} */ ({
from,
nick,
type,
muc_jid: Strophe.getBareJidFromJid(from),
occupant_id: getOccupantID(stanza, chatbox),
status: stanza.querySelector(':scope > status')?.textContent ?? undefined,
show: stanza.querySelector(':scope > show')?.textContent ?? undefined,
image_hash: sizzle(`presence > x[xmlns="${Strophe.NS.VCARDUPDATE}"] photo`, stanza).pop()?.textContent,
hats: sizzle(`presence > hats[xmlns="${Strophe.NS.MUC_HATS}"] hat`, stanza).map(
/** @param {Element} h */ (h) => ({
title: h.getAttribute('title'),
uri: h.getAttribute('uri'),
})
),
...getStatusCodes(stanza, 'presence'),
...parsePresenceUserItem(stanza, nick),
});
/**
* *Hook* which allows plugins to add additional parsing
* @event _converse#parseMUCPresence
*/
return /** @type {import('./types').MUCPresenceAttributes}*/ (await api.hook('parseMUCPresence', stanza, attrs));
}