parsers.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. import dayjs from 'dayjs';
  2. import sizzle from 'sizzle';
  3. import { Strophe } from 'strophe.js/src/strophe';
  4. import { _converse, api } from '@converse/headless/core';
  5. import { decodeHTMLEntities } from 'shared/utils';
  6. import { rejectMessage } from '@converse/headless/shared/actions';
  7. const { NS } = Strophe;
  8. export class StanzaParseError extends Error {
  9. constructor (message, stanza) {
  10. super(message, stanza);
  11. this.name = 'StanzaParseError';
  12. this.stanza = stanza;
  13. }
  14. }
  15. /**
  16. * Extract the XEP-0359 stanza IDs from the passed in stanza
  17. * and return a map containing them.
  18. * @private
  19. * @param { XMLElement } stanza - The message stanza
  20. * @returns { Object }
  21. */
  22. export function getStanzaIDs (stanza, original_stanza) {
  23. const attrs = {};
  24. // Store generic stanza ids
  25. const sids = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza);
  26. const sid_attrs = sids.reduce((acc, s) => {
  27. acc[`stanza_id ${s.getAttribute('by')}`] = s.getAttribute('id');
  28. return acc;
  29. }, {});
  30. Object.assign(attrs, sid_attrs);
  31. // Store the archive id
  32. const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop();
  33. if (result) {
  34. const by_jid = original_stanza.getAttribute('from') || _converse.bare_jid;
  35. attrs[`stanza_id ${by_jid}`] = result.getAttribute('id');
  36. }
  37. // Store the origin id
  38. const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop();
  39. if (origin_id) {
  40. attrs['origin_id'] = origin_id.getAttribute('id');
  41. }
  42. return attrs;
  43. }
  44. export function getEncryptionAttributes (stanza, _converse) {
  45. const encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop();
  46. const attrs = { 'is_encrypted': !!encrypted };
  47. if (!encrypted || api.settings.get('clear_cache_on_logout')) {
  48. return attrs;
  49. }
  50. const header = encrypted.querySelector('header');
  51. attrs['encrypted'] = { 'device_id': header.getAttribute('sid') };
  52. const device_id = _converse.omemo_store?.get('device_id');
  53. const key = device_id && sizzle(`key[rid="${device_id}"]`, encrypted).pop();
  54. if (key) {
  55. Object.assign(attrs.encrypted, {
  56. 'iv': header.querySelector('iv').textContent,
  57. 'key': key.textContent,
  58. 'payload': encrypted.querySelector('payload')?.textContent || null,
  59. 'prekey': ['true', '1'].includes(key.getAttribute('prekey'))
  60. });
  61. }
  62. return attrs;
  63. }
  64. /**
  65. * @private
  66. * @param { XMLElement } stanza - The message stanza
  67. * @param { XMLElement } original_stanza - The original stanza, that contains the
  68. * message stanza, if it was contained, otherwise it's the message stanza itself.
  69. * @returns { Object }
  70. */
  71. export function getRetractionAttributes (stanza, original_stanza) {
  72. const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
  73. if (fastening) {
  74. const applies_to_id = fastening.getAttribute('id');
  75. const retracted = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, fastening).pop();
  76. if (retracted) {
  77. const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
  78. const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString();
  79. return {
  80. 'editable': false,
  81. 'retracted': time,
  82. 'retracted_id': applies_to_id
  83. };
  84. }
  85. } else {
  86. const tombstone = sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
  87. if (tombstone) {
  88. return {
  89. 'editable': false,
  90. 'is_tombstone': true,
  91. 'retracted': tombstone.getAttribute('stamp')
  92. };
  93. }
  94. }
  95. return {};
  96. }
  97. export function getCorrectionAttributes (stanza, original_stanza) {
  98. const el = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop();
  99. if (el) {
  100. const replace_id = el.getAttribute('id');
  101. const msgid = replace_id;
  102. if (replace_id) {
  103. const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
  104. const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString();
  105. return {
  106. msgid,
  107. replace_id,
  108. 'edited': time
  109. };
  110. }
  111. }
  112. return {};
  113. }
  114. export function getOpenGraphMetadata (stanza) {
  115. const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
  116. if (fastening) {
  117. const applies_to_id = fastening.getAttribute('id');
  118. const meta = sizzle(`> meta[xmlns="${Strophe.NS.XHTML}"]`, fastening);
  119. if (meta.length) {
  120. const msg_limit = api.settings.get('message_limit');
  121. const data = meta.reduce((acc, el) => {
  122. const property = el.getAttribute('property');
  123. if (property) {
  124. let value = decodeHTMLEntities(el.getAttribute('content') || '');
  125. if (msg_limit && property === 'og:description' && value.length >= msg_limit) {
  126. value = `${value.slice(0, msg_limit)}${decodeHTMLEntities('…')}`;
  127. }
  128. acc[property] = value;
  129. }
  130. return acc;
  131. }, {
  132. 'ogp_for_id': applies_to_id,
  133. });
  134. if ("og:description" in data || "og:title" in data || "og:image" in data) {
  135. return data;
  136. }
  137. }
  138. }
  139. return {};
  140. }
  141. export function getSpoilerAttributes (stanza) {
  142. const spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, stanza).pop();
  143. return {
  144. 'is_spoiler': !!spoiler,
  145. 'spoiler_hint': spoiler?.textContent
  146. };
  147. }
  148. export function getOutOfBandAttributes (stanza) {
  149. const xform = sizzle(`x[xmlns="${Strophe.NS.OUTOFBAND}"]`, stanza).pop();
  150. if (xform) {
  151. return {
  152. 'oob_url': xform.querySelector('url')?.textContent,
  153. 'oob_desc': xform.querySelector('desc')?.textContent
  154. };
  155. }
  156. return {};
  157. }
  158. /**
  159. * Returns the human readable error message contained in a `groupchat` message stanza of type `error`.
  160. * @private
  161. * @param { XMLElement } stanza - The message stanza
  162. */
  163. export function getErrorAttributes (stanza) {
  164. if (stanza.getAttribute('type') === 'error') {
  165. const error = stanza.querySelector('error');
  166. const text = sizzle(`text[xmlns="${Strophe.NS.STANZAS}"]`, error).pop();
  167. return {
  168. 'is_error': true,
  169. 'error_text': text?.textContent,
  170. 'error_type': error.getAttribute('type'),
  171. 'error_condition': error.firstElementChild.nodeName
  172. };
  173. }
  174. return {};
  175. }
  176. export function getReferences (stanza) {
  177. const text = stanza.querySelector('body')?.textContent;
  178. return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => {
  179. const begin = ref.getAttribute('begin');
  180. const end = ref.getAttribute('end');
  181. return {
  182. 'begin': begin,
  183. 'end': end,
  184. 'type': ref.getAttribute('type'),
  185. 'value': text.slice(begin, end),
  186. 'uri': ref.getAttribute('uri')
  187. };
  188. });
  189. }
  190. export function getReceiptId (stanza) {
  191. const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop();
  192. return receipt?.getAttribute('id');
  193. }
  194. /**
  195. * Determines whether the passed in stanza is a XEP-0280 Carbon
  196. * @private
  197. * @param { XMLElement } stanza - The message stanza
  198. * @returns { Boolean }
  199. */
  200. export function isCarbon (stanza) {
  201. const xmlns = Strophe.NS.CARBONS;
  202. return (
  203. sizzle(`message > received[xmlns="${xmlns}"]`, stanza).length > 0 ||
  204. sizzle(`message > sent[xmlns="${xmlns}"]`, stanza).length > 0
  205. );
  206. }
  207. /**
  208. * Returns the XEP-0085 chat state contained in a message stanza
  209. * @private
  210. * @param { XMLElement } stanza - The message stanza
  211. */
  212. export function getChatState (stanza) {
  213. return sizzle(
  214. `
  215. composing[xmlns="${NS.CHATSTATES}"],
  216. paused[xmlns="${NS.CHATSTATES}"],
  217. inactive[xmlns="${NS.CHATSTATES}"],
  218. active[xmlns="${NS.CHATSTATES}"],
  219. gone[xmlns="${NS.CHATSTATES}"]`,
  220. stanza
  221. ).pop()?.nodeName;
  222. }
  223. export function isValidReceiptRequest (stanza, attrs) {
  224. return (
  225. attrs.sender !== 'me' &&
  226. !attrs.is_carbon &&
  227. !attrs.is_archived &&
  228. sizzle(`request[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).length
  229. );
  230. }
  231. export function rejectUnencapsulatedForward (stanza) {
  232. const bare_forward = sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length;
  233. if (bare_forward) {
  234. rejectMessage(stanza, 'Forwarded messages not part of an encapsulating protocol are not supported');
  235. const from_jid = stanza.getAttribute('from');
  236. return new StanzaParseError(`Ignoring unencapsulated forwarded message from ${from_jid}`, stanza);
  237. }
  238. }
  239. /**
  240. * Determines whether the passed in stanza is a XEP-0333 Chat Marker
  241. * @private
  242. * @method getChatMarker
  243. * @param { XMLElement } stanza - The message stanza
  244. * @returns { Boolean }
  245. */
  246. export function getChatMarker (stanza) {
  247. // If we receive more than one marker (which shouldn't happen), we take
  248. // the highest level of acknowledgement.
  249. return sizzle(`
  250. acknowledged[xmlns="${Strophe.NS.MARKERS}"],
  251. displayed[xmlns="${Strophe.NS.MARKERS}"],
  252. received[xmlns="${Strophe.NS.MARKERS}"]`,
  253. stanza
  254. ).pop();
  255. }
  256. export function isHeadline (stanza) {
  257. return stanza.getAttribute('type') === 'headline';
  258. }
  259. export function isServerMessage (stanza) {
  260. if (sizzle(`mentions[xmlns="${Strophe.NS.MENTIONS}"]`, stanza).pop()) {
  261. return false;
  262. }
  263. const from_jid = stanza.getAttribute('from');
  264. if (stanza.getAttribute('type') !== 'error' && from_jid && !from_jid.includes('@')) {
  265. // Some servers (e.g. Prosody) don't set the stanza
  266. // type to "headline" when sending server messages.
  267. // For now we check if an @ signal is included, and if not,
  268. // we assume it's a headline stanza.
  269. return true;
  270. }
  271. return false;
  272. }
  273. /**
  274. * Determines whether the passed in stanza is a XEP-0313 MAM stanza
  275. * @private
  276. * @method isArchived
  277. * @param { XMLElement } stanza - The message stanza
  278. * @returns { Boolean }
  279. */
  280. export function isArchived (original_stanza) {
  281. return !!sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop();
  282. }
  283. /**
  284. * Returns an object containing all attribute names and values for a particular element.
  285. * @method getAttributes
  286. * @param { XMLElement } stanza
  287. * @returns { Object }
  288. */
  289. export function getAttributes (stanza) {
  290. return stanza.getAttributeNames().reduce((acc, name) => {
  291. acc[name] = Strophe.xmlunescape(stanza.getAttribute(name));
  292. return acc;
  293. }, {});
  294. }