Converse converse.js

Source: headless/plugins/muc/parsers.js

  1. import dayjs from 'dayjs';
  2. import {
  3. StanzaParseError,
  4. getChatMarker,
  5. getChatState,
  6. getCorrectionAttributes,
  7. getEncryptionAttributes,
  8. getErrorAttributes,
  9. getMediaURLsMetadata,
  10. getOpenGraphMetadata,
  11. getOutOfBandAttributes,
  12. getReceiptId,
  13. getReferences,
  14. getRetractionAttributes,
  15. getSpoilerAttributes,
  16. getStanzaIDs,
  17. isArchived,
  18. isCarbon,
  19. isHeadline,
  20. isValidReceiptRequest,
  21. throwErrorIfInvalidForward,
  22. } from '@converse/headless/shared/parsers';
  23. import { _converse, api, converse } from '@converse/headless/core';
  24. const { Strophe, sizzle, u } = converse.env;
  25. const { NS } = Strophe;
  26. /**
  27. * Parses a message stanza for XEP-0317 MEP notification data
  28. * @param { Element } stanza - The message stanza
  29. * @returns { Array } Returns an array of objects representing <activity> elements.
  30. */
  31. export function getMEPActivities (stanza) {
  32. const items_el = sizzle(`items[node="${Strophe.NS.CONFINFO}"]`, stanza).pop();
  33. if (!items_el) {
  34. return null;
  35. }
  36. const from = stanza.getAttribute('from');
  37. const msgid = stanza.getAttribute('id');
  38. const selector = `item `+
  39. `conference-info[xmlns="${Strophe.NS.CONFINFO}"] `+
  40. `activity[xmlns="${Strophe.NS.ACTIVITY}"]`;
  41. return sizzle(selector, items_el).map(el => {
  42. const message = el.querySelector('text')?.textContent;
  43. if (message) {
  44. const references = getReferences(stanza);
  45. const reason = el.querySelector('reason')?.textContent;
  46. return { from, msgid, message, reason, references, 'type': 'mep' };
  47. }
  48. return {};
  49. });
  50. }
  51. /**
  52. * Given a MUC stanza, check whether it has extended message information that
  53. * includes the sender's real JID, as described here:
  54. * https://xmpp.org/extensions/xep-0313.html#business-storeret-muc-archives
  55. *
  56. * If so, parse and return that data and return the user's JID
  57. *
  58. * Note, this function doesn't check whether this is actually a MAM archived stanza.
  59. *
  60. * @private
  61. * @param { Element } stanza - The message stanza
  62. * @returns { Object }
  63. */
  64. function getJIDFromMUCUserData (stanza) {
  65. const item = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, stanza).pop();
  66. return item?.getAttribute('jid');
  67. }
  68. /**
  69. * @private
  70. * @param { Element } stanza - The message stanza
  71. * @param { Element } original_stanza - The original stanza, that contains the
  72. * message stanza, if it was contained, otherwise it's the message stanza itself.
  73. * @returns { Object }
  74. */
  75. function getModerationAttributes (stanza) {
  76. const fastening = sizzle(`apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
  77. if (fastening) {
  78. const applies_to_id = fastening.getAttribute('id');
  79. const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, fastening).pop();
  80. if (moderated) {
  81. const retracted = sizzle(`retract[xmlns="${Strophe.NS.RETRACT}"]`, moderated).pop();
  82. if (retracted) {
  83. return {
  84. 'editable': false,
  85. 'moderated': 'retracted',
  86. 'moderated_by': moderated.getAttribute('by'),
  87. 'moderated_id': applies_to_id,
  88. 'moderation_reason': moderated.querySelector('reason')?.textContent
  89. };
  90. }
  91. }
  92. } else {
  93. const tombstone = sizzle(`> moderated[xmlns="${Strophe.NS.MODERATE}"]`, stanza).pop();
  94. if (tombstone) {
  95. const retracted = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT}"]`, tombstone).pop();
  96. if (retracted) {
  97. return {
  98. 'editable': false,
  99. 'is_tombstone': true,
  100. 'moderated_by': tombstone.getAttribute('by'),
  101. 'retracted': tombstone.getAttribute('stamp'),
  102. 'moderation_reason': tombstone.querySelector('reason')?.textContent
  103. };
  104. }
  105. }
  106. }
  107. return {};
  108. }
  109. function getOccupantID (stanza, chatbox) {
  110. if (chatbox.features.get(Strophe.NS.OCCUPANTID)) {
  111. return sizzle(`occupant-id[xmlns="${Strophe.NS.OCCUPANTID}"]`, stanza).pop()?.getAttribute('id');
  112. }
  113. }
  114. /**
  115. * Determines whether the sender of this MUC message is the current user or
  116. * someone else.
  117. * @param { MUCMessageAttributes } attrs
  118. * @param { _converse.ChatRoom } chatbox
  119. * @returns { 'me'|'them' }
  120. */
  121. function getSender (attrs, chatbox) {
  122. let is_me;
  123. const own_occupant_id = chatbox.get('occupant_id');
  124. if (own_occupant_id) {
  125. is_me = attrs.occupant_id === own_occupant_id;
  126. } else if (attrs.from_real_jid) {
  127. is_me = Strophe.getBareJidFromJid(attrs.from_real_jid) === _converse.bare_jid;
  128. } else {
  129. is_me = attrs.nick === chatbox.get('nick')
  130. }
  131. return is_me ? 'me' : 'them';
  132. }
  133. /**
  134. * Parses a passed in message stanza and returns an object of attributes.
  135. * @param { Element } stanza - The message stanza
  136. * @param { Element } original_stanza - The original stanza, that contains the
  137. * message stanza, if it was contained, otherwise it's the message stanza itself.
  138. * @param { _converse.ChatRoom } chatbox
  139. * @param { _converse } _converse
  140. * @returns { Promise<MUCMessageAttributes|Error> }
  141. */
  142. export async function parseMUCMessage (stanza, chatbox) {
  143. throwErrorIfInvalidForward(stanza);
  144. const selector = `[xmlns="${NS.MAM}"] > forwarded[xmlns="${NS.FORWARD}"] > message`;
  145. const original_stanza = stanza;
  146. stanza = sizzle(selector, stanza).pop() || stanza;
  147. if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length) {
  148. return new StanzaParseError(
  149. `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`,
  150. stanza
  151. );
  152. }
  153. const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
  154. const from = stanza.getAttribute('from');
  155. const marker = getChatMarker(stanza);
  156. /**
  157. * @typedef { Object } MUCMessageAttributes
  158. * The object which {@link parseMUCMessage} returns
  159. * @property { ('me'|'them') } sender - Whether the message was sent by the current user or someone else
  160. * @property { Array<Object> } activities - A list of objects representing XEP-0316 MEP notification data
  161. * @property { Array<Object> } references - A list of objects representing XEP-0372 references
  162. * @property { Boolean } editable - Is this message editable via XEP-0308?
  163. * @property { Boolean } is_archived - Is this message from a XEP-0313 MAM archive?
  164. * @property { Boolean } is_carbon - Is this message a XEP-0280 Carbon?
  165. * @property { Boolean } is_delayed - Was delivery of this message was delayed as per XEP-0203?
  166. * @property { Boolean } is_encrypted - Is this message XEP-0384 encrypted?
  167. * @property { Boolean } is_error - Whether an error was received for this message
  168. * @property { Boolean } is_headline - Is this a "headline" message?
  169. * @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker?
  170. * @property { Boolean } is_marker - Is this message a XEP-0333 Chat Marker?
  171. * @property { Boolean } is_only_emojis - Does the message body contain only emojis?
  172. * @property { Boolean } is_spoiler - Is this a XEP-0382 spoiler message?
  173. * @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone?
  174. * @property { Boolean } is_unstyled - Whether XEP-0393 styling hints should be ignored
  175. * @property { Boolean } is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message)
  176. * @property { Object } encrypted - XEP-0384 encryption payload attributes
  177. * @property { String } body - The contents of the <body> tag of the message stanza
  178. * @property { String } chat_state - The XEP-0085 chat state notification contained in this message
  179. * @property { String } edited - An ISO8601 string recording the time that the message was edited per XEP-0308
  180. * @property { String } error_condition - The defined error condition
  181. * @property { String } error_text - The error text received from the server
  182. * @property { String } error_type - The type of error received from the server
  183. * @property { String } from - The sender JID (${muc_jid}/${nick})
  184. * @property { String } from_muc - The JID of the MUC from which this message was sent
  185. * @property { String } from_real_jid - The real JID of the sender, if available
  186. * @property { String } fullname - The full name of the sender
  187. * @property { String } marker - The XEP-0333 Chat Marker value
  188. * @property { String } marker_id - The `id` attribute of a XEP-0333 chat marker
  189. * @property { String } moderated - The type of XEP-0425 moderation (if any) that was applied
  190. * @property { String } moderated_by - The JID of the user that moderated this message
  191. * @property { String } moderated_id - The XEP-0359 Stanza ID of the message that this one moderates
  192. * @property { String } moderation_reason - The reason provided why this message moderates another
  193. * @property { String } msgid - The root `id` attribute of the stanza
  194. * @property { String } nick - The MUC nickname of the sender
  195. * @property { String } occupant_id - The XEP-0421 occupant ID
  196. * @property { String } oob_desc - The description of the XEP-0066 out of band data
  197. * @property { String } oob_url - The URL of the XEP-0066 out of band data
  198. * @property { String } origin_id - The XEP-0359 Origin ID
  199. * @property { String } receipt_id - The `id` attribute of a XEP-0184 <receipt> element
  200. * @property { String } received - An ISO8601 string recording the time that the message was received
  201. * @property { String } replace_id - The `id` attribute of a XEP-0308 <replace> element
  202. * @property { String } retracted - An ISO8601 string recording the time that the message was retracted
  203. * @property { String } retracted_id - The `id` attribute of a XEP-424 <retracted> element
  204. * @property { String } spoiler_hint The XEP-0382 spoiler hint
  205. * @property { String } stanza_id - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple.
  206. * @property { String } subject - The <subject> element value
  207. * @property { String } thread - The <thread> element value
  208. * @property { String } time - The time (in ISO8601 format), either given by the XEP-0203 <delay> element, or of receipt.
  209. * @property { String } to - The recipient JID
  210. * @property { String } type - The type of message
  211. */
  212. let attrs = Object.assign(
  213. {
  214. from,
  215. 'activities': getMEPActivities(stanza),
  216. 'body': stanza.querySelector(':scope > body')?.textContent?.trim(),
  217. 'chat_state': getChatState(stanza),
  218. 'from_muc': Strophe.getBareJidFromJid(from),
  219. 'is_archived': isArchived(original_stanza),
  220. 'is_carbon': isCarbon(original_stanza),
  221. 'is_delayed': !!delay,
  222. 'is_forwarded': !!stanza.querySelector('forwarded'),
  223. 'is_headline': isHeadline(stanza),
  224. 'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length,
  225. 'is_marker': !!marker,
  226. 'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
  227. 'marker_id': marker && marker.getAttribute('id'),
  228. 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
  229. 'nick': Strophe.unescapeNode(Strophe.getResourceFromJid(from)),
  230. 'occupant_id': getOccupantID(stanza, chatbox),
  231. 'receipt_id': getReceiptId(stanza),
  232. 'received': new Date().toISOString(),
  233. 'references': getReferences(stanza),
  234. 'subject': stanza.querySelector('subject')?.textContent,
  235. 'thread': stanza.querySelector('thread')?.textContent,
  236. 'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString(),
  237. 'to': stanza.getAttribute('to'),
  238. 'type': stanza.getAttribute('type')
  239. },
  240. getErrorAttributes(stanza),
  241. getOutOfBandAttributes(stanza),
  242. getSpoilerAttributes(stanza),
  243. getCorrectionAttributes(stanza, original_stanza),
  244. getStanzaIDs(stanza, original_stanza),
  245. getOpenGraphMetadata(stanza),
  246. getRetractionAttributes(stanza, original_stanza),
  247. getModerationAttributes(stanza),
  248. getEncryptionAttributes(stanza, _converse),
  249. );
  250. await api.emojis.initialize();
  251. attrs.from_real_jid = attrs.is_archived && getJIDFromMUCUserData(stanza) ||
  252. chatbox.occupants.findOccupant(attrs)?.get('jid');
  253. attrs = Object.assign( {
  254. 'is_only_emojis': attrs.body ? u.isOnlyEmojis(attrs.body) : false,
  255. 'is_valid_receipt_request': isValidReceiptRequest(stanza, attrs),
  256. 'message': attrs.body || attrs.error, // TODO: Should only be used for error and info messages
  257. 'sender': getSender(attrs, chatbox),
  258. }, attrs);
  259. if (attrs.is_archived && original_stanza.getAttribute('from') !== attrs.from_muc) {
  260. return new StanzaParseError(
  261. `Invalid Stanza: Forged MAM message from ${original_stanza.getAttribute('from')}`,
  262. stanza
  263. );
  264. } else if (attrs.is_archived && original_stanza.getAttribute('from') !== chatbox.get('jid')) {
  265. return new StanzaParseError(
  266. `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`,
  267. stanza
  268. );
  269. } else if (attrs.is_carbon) {
  270. return new StanzaParseError('Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied', stanza);
  271. }
  272. // We prefer to use one of the XEP-0359 unique and stable stanza IDs as the Model id, to avoid duplicates.
  273. attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${attrs.from_muc || attrs.from}`] || u.getUniqueId();
  274. /**
  275. * *Hook* which allows plugins to add additional parsing
  276. * @event _converse#parseMUCMessage
  277. */
  278. attrs = await api.hook('parseMUCMessage', stanza, attrs);
  279. // We call this after the hook, to allow plugins to decrypt encrypted
  280. // messages, since we need to parse the message text to determine whether
  281. // there are media urls.
  282. return Object.assign(attrs, getMediaURLsMetadata(attrs.is_encrypted ? attrs.plaintext : attrs.body));
  283. }
  284. /**
  285. * Given an IQ stanza with a member list, create an array of objects containing
  286. * known member data (e.g. jid, nick, role, affiliation).
  287. * @private
  288. * @method muc_utils#parseMemberListIQ
  289. * @returns { MemberListItem[] }
  290. */
  291. export function parseMemberListIQ (iq) {
  292. return sizzle(`query[xmlns="${Strophe.NS.MUC_ADMIN}"] item`, iq).map(item => {
  293. /**
  294. * @typedef {Object} MemberListItem
  295. * Either the JID or the nickname (or both) will be available.
  296. * @property {string} affiliation
  297. * @property {string} [role]
  298. * @property {string} [jid]
  299. * @property {string} [nick]
  300. */
  301. const data = {
  302. 'affiliation': item.getAttribute('affiliation')
  303. };
  304. const jid = item.getAttribute('jid');
  305. if (u.isValidJID(jid)) {
  306. data['jid'] = jid;
  307. } else {
  308. // XXX: Prosody sends nick for the jid attribute value
  309. // Perhaps for anonymous room?
  310. data['nick'] = jid;
  311. }
  312. const nick = item.getAttribute('nick');
  313. if (nick) {
  314. data['nick'] = nick;
  315. }
  316. const role = item.getAttribute('role');
  317. if (role) {
  318. data['role'] = nick;
  319. }
  320. return data;
  321. });
  322. }
  323. /**
  324. * Parses a passed in MUC presence stanza and returns an object of attributes.
  325. * @method parseMUCPresence
  326. * @param { Element } stanza - The presence stanza
  327. * @param { _converse.ChatRoom } chatbox
  328. * @returns { MUCPresenceAttributes }
  329. */
  330. export function parseMUCPresence (stanza, chatbox) {
  331. /**
  332. * @typedef { Object } MUCPresenceAttributes
  333. * The object which {@link parseMUCPresence} returns
  334. * @property { ("offline|online") } show
  335. * @property { Array<MUCHat> } hats - An array of XEP-0317 hats
  336. * @property { Array<string> } states
  337. * @property { String } from - The sender JID (${muc_jid}/${nick})
  338. * @property { String } nick - The nickname of the sender
  339. * @property { String } occupant_id - The XEP-0421 occupant ID
  340. * @property { String } type - The type of presence
  341. */
  342. const from = stanza.getAttribute('from');
  343. const type = stanza.getAttribute('type');
  344. const data = {
  345. 'is_me': !!stanza.querySelector("status[code='110']"),
  346. 'from': from,
  347. 'occupant_id': getOccupantID(stanza, chatbox),
  348. 'nick': Strophe.getResourceFromJid(from),
  349. 'type': type,
  350. 'states': [],
  351. 'hats': [],
  352. 'show': type !== 'unavailable' ? 'online' : 'offline'
  353. };
  354. Array.from(stanza.children).forEach(child => {
  355. if (child.matches('status')) {
  356. data.status = child.textContent || null;
  357. } else if (child.matches('show')) {
  358. data.show = child.textContent || 'online';
  359. } else if (child.matches('x') && child.getAttribute('xmlns') === Strophe.NS.MUC_USER) {
  360. Array.from(child.children).forEach(item => {
  361. if (item.nodeName === 'item') {
  362. data.affiliation = item.getAttribute('affiliation');
  363. data.role = item.getAttribute('role');
  364. data.jid = item.getAttribute('jid');
  365. data.nick = item.getAttribute('nick') || data.nick;
  366. } else if (item.nodeName == 'status' && item.getAttribute('code')) {
  367. data.states.push(item.getAttribute('code'));
  368. }
  369. });
  370. } else if (child.matches('x') && child.getAttribute('xmlns') === Strophe.NS.VCARDUPDATE) {
  371. data.image_hash = child.querySelector('photo')?.textContent;
  372. } else if (child.matches('hats') && child.getAttribute('xmlns') === Strophe.NS.MUC_HATS) {
  373. /**
  374. * @typedef { Object } MUCHat
  375. * Object representing a XEP-0371 Hat
  376. * @property { String } title
  377. * @property { String } uri
  378. */
  379. data['hats'] = Array.from(child.children).map(
  380. c =>
  381. c.matches('hat') && {
  382. 'title': c.getAttribute('title'),
  383. 'uri': c.getAttribute('uri')
  384. }
  385. );
  386. }
  387. });
  388. return data;
  389. }