import { client, xml } from '@xmpp/client'; let _counter = 0; /** * Connects as the Jibri recorder, joins roomJid with nick 'recorder', and * returns the client. Authenticates as recorder@recorder.localhost (internal_hashed). * The bare JID starts with 'recorder@recorder.' so is_jibri() returns true, * matching the default recorder_prefixes in util.lib.lua. * * The caller is responsible for disconnecting the returned client. * * @param {string} roomJid full room JID, e.g. 'room@conference.localhost' * @returns {Promise} */ export async function joinWithJibri(roomJid) { const c = await createXmppClient({ domain: 'recorder.localhost', username: 'recorder', password: 'recordersecret' }); await c.joinRoom(roomJid, 'recorder'); return c; } /** * Connects as the transcriber, joins roomJid with nick 'transcriber', and * returns the client. Authenticates as transcriber@recorder.localhost. * The bare JID starts with 'transcriber@recorder.' so is_transcriber() returns * true, matching the default transcriber_prefixes in util.lib.lua. * * The caller is responsible for disconnecting the returned client. * * @param {string} roomJid full room JID, e.g. 'room@conference.localhost' * @returns {Promise} */ export async function joinWithTranscriber(roomJid) { const c = await createXmppClient({ domain: 'recorder.localhost', username: 'transcriber', password: 'transcribersecret' }); await c.joinRoom(roomJid, 'transcriber'); return c; } /** * Creates an anonymous XMPP client and joins a Jigasi brewery MUC with a * colibri stats presence extension, simulating a Jigasi SIP gateway instance. * The presence advertises supports_sip and stress_level so that * mod_muc_jigasi_invite can select this instance for dial-out. * * @param {string} breweryJid Full brewery room JID, e.g. * 'jigasibrewery@internal.auth.localhost' * @param {string} [nick] MUC nick (must not be 'focus'). Defaults to a * unique generated nick. * @param {object} [opts] * @param {boolean} [opts.supportsSip=true] Advertise SIP support. * @param {number} [opts.stressLevel=0.1] Stress level (lower = preferred). * @returns {Promise} */ export async function joinWithJigasi(breweryJid, nick, { supportsSip = true, stressLevel = 0.1 } = {}) { const statsEl = xml('stats', { xmlns: 'http://jitsi.org/protocol/colibri' }, xml('stat', { name: 'supports_sip', value: supportsSip ? 'true' : 'false' }), xml('stat', { name: 'stress_level', value: String(stressLevel) }) ); const c = await createXmppClient(); await c.joinRoom(breweryJid, nick, { extensions: [ statsEl ] }); return c; } /** * Connects as the focus (jicofo) admin participant, joins roomJid with nick * 'focus', and returns the client. This unlocks the mod_muc_meeting_id jicofo * lock so that regular clients can subsequently join the same room. * * The focus client authenticates as focus@auth.localhost (a Prosody admin), so * it is exempt from token_verification checks, is never counted against occupant * limits (auth.localhost is in muc_access_whitelist), and can act as room owner. * * The caller is responsible for disconnecting the returned client (typically * by pushing it into the test's `clients` array for afterEach cleanup). * * @param {string} roomJid full room JID, e.g. 'room@conference.localhost' * @returns {Promise} */ export async function joinWithFocus(roomJid) { const c = await createXmppClient({ domain: 'auth.localhost', username: 'focus', password: 'focussecret' }); await c.joinRoom(roomJid, 'focus'); return c; } /** * Creates a connected anonymous XMPP client. * Prosody must be configured with `authentication = "anonymous"` and no TLS. * * @param {object} opts * @param {string} [opts.host='localhost'] TCP host to connect to. * @param {string} [opts.domain] XMPP domain (stream header). Defaults to host. * Set to a different VirtualHost name (e.g. * 'whitelist.localhost') to get a JID on that * domain without changing the TCP target. * @param {object} [opts.params] Optional query parameters appended to the * WebSocket URL (e.g. { previd: 'token' }). * @param {string} [opts.username] SASL username for PLAIN auth. When omitted, * ANONYMOUS auth is used. * @param {string} [opts.password] SASL password for PLAIN auth. * @returns {Promise} */ export async function createXmppClient({ host = 'localhost', domain, params, username, password } = {}) { const url = new URL(`ws://${host}:5280/xmpp-websocket`); if (params) { for (const [ k, v ] of Object.entries(params)) { url.searchParams.set(k, v); } } const xmpp = client({ service: url.toString(), domain: domain ?? host, ...username === undefined ? {} : { username, password } }); // Suppress unhandled 'error' events (e.g. WebSocket close after auth failure). // Errors surface through the xmpp.start() promise rejection instead. // eslint-disable-next-line no-empty-function xmpp.on('error', () => {}); // id -> { resolve, reject, timer } const pendingIqs = new Map(); const stanzaQueue = []; xmpp.on('stanza', stanza => { const id = stanza.attrs.id; if (stanza.name === 'iq' && id && pendingIqs.has(id)) { const { resolve, timer } = pendingIqs.get(id); clearTimeout(timer); pendingIqs.delete(id); resolve(stanza); return; } stanzaQueue.push(stanza); }); // If start() fails (e.g. SASL error) stop the client before re-throwing so // that @xmpp/client's auto-reconnect does not keep retrying in the background. // Leaked reconnect loops can flood Prosody and cause unrelated tests to fail. try { await xmpp.start(); } catch (err) { xmpp.stop().catch(Function.prototype); throw err; } return { jid: xmpp.jid?.toString(), /** * The default MUC nick for this client: first 8 characters of the * server-assigned JID local part. Matches the UUID prefix required by * Prosody's anonymous_strict mode. Null when the JID is not yet set. * * @returns {string|null} */ get nick() { const local = xmpp.jid?.toString().split('@')[0] ?? ''; return local ? local.slice(0, 8) : null; }, /** * Returns the XEP-0198 stream management resumption token assigned by * the server. Only available after the session is online and stream * management has been negotiated. Must be read before disconnecting * (the token is cleared on offline). * * @returns {string} */ get smId() { return xmpp.streamManagement.id; }, /** * Joins a MUC room. Resolves with the self-presence stanza (may have type='error'). * Rejects with a timeout error if no presence is received within the timeout. * @param {string} roomJid e.g. 'room@conference.localhost' * @param {string} [nick] defaults to a unique generated nick * @param {object} [opts] * @param {number} [opts.timeout=5000] ms to wait for presence before rejecting * @param {string} [opts.password] room password to include in the join stanza * @param {string} [opts.displayName] if set, includes a element in the presence */ async joinRoom(roomJid, nick, { timeout = 5000, password: roomPassword, extensions = [], mucContent = [], displayName } = {}) { // Default to the first 8 characters of the local part of the // server-assigned JID. Prosody's anonymous_strict mode requires MUC // resources to match this prefix, so callers that do not pass an // explicit nick automatically satisfy the constraint. const selfLocal = xmpp.jid?.toString().split('@')[0] ?? ''; const n = nick ?? (selfLocal ? selfLocal.slice(0, 8) : `user${++_counter}`); const mucX = xml('x', { xmlns: 'http://jabber.org/protocol/muc' }); if (roomPassword !== undefined) { mucX.c('password').t(roomPassword); } // Children placed INSIDE the muc (e.g. a element), // as opposed to `extensions`, which are siblings of . for (const child of mucContent) { mucX.cnode(child).up(); } const nickEl = displayName ? xml('nick', { xmlns: 'http://jabber.org/protocol/nick' }, displayName) : undefined; await xmpp.send( xml('presence', { to: `${roomJid}/${n}` }, mucX, ...extensions, ...nickEl ? [ nickEl ] : []) ); const presence = await waitForPresence(stanzaQueue, roomJid, timeout); // Prosody 13 locks newly created rooms (XEP-0045 §10.1.3) until the // owner submits a configuration form. Status 201 means the room was // just created — submit an empty form to accept defaults and unlock. const x = presence.getChild('x', 'http://jabber.org/protocol/muc#user'); const isNewRoom = x?.getChildren('status').some(st => st.attrs.code === '201'); if (isNewRoom) { await sendIq(xmpp, pendingIqs, xml('iq', { type: 'set', to: roomJid }, xml('query', { xmlns: 'http://jabber.org/protocol/muc#owner' }, xml('x', { xmlns: 'jabber:x:data', type: 'submit' }) ) ) ); } return presence; }, /** * Sets (or clears) the password on a MUC room. The client must be the room owner. * Resolves when the server acknowledges the configuration change. * @param {string} roomJid e.g. 'room@conference.localhost' * @param {string} password pass empty string to remove the password */ setRoomPassword(roomJid, roomPassword) { return sendIq(xmpp, pendingIqs, xml('iq', { type: 'set', to: roomJid, id: `cfg-${++_counter}` }, xml('query', { xmlns: 'http://jabber.org/protocol/muc#owner' }, xml('x', { xmlns: 'jabber:x:data', type: 'submit' }, xml('field', { var: 'FORM_TYPE' }, xml('value', {}, 'http://jabber.org/protocol/muc#roomconfig') ), xml('field', { var: 'muc#roomconfig_roomsecret' }, xml('value', {}, roomPassword) ) ) ) ) ); }, /** * Sends a Jibri IQ (http://jitsi.org/protocol/jibri) to the focus * occupant of the given room. Fire-and-forget — does NOT wait for a * response, because mod_filter_iq_jibri may block it before it reaches * any handler that would reply, and the test asserts via the * /jibri-iqs HTTP endpoint instead. * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {string} action 'start' | 'stop' | 'status' etc. * @param {string} recordingMode 'file' (recording) | 'stream' (livestreaming) */ sendJibriIq(roomJid, action, recordingMode) { // Directed to focus's occupant full JID so `pre-iq/full` fires on the // main VirtualHost where mod_filter_iq_jibri is loaded. return xmpp.send( xml('iq', { type: 'set', to: `${roomJid}/focus`, id: `jibri-${++_counter}` }, xml('jibri', { xmlns: 'http://jitsi.org/protocol/jibri', action, // eslint-disable-next-line camelcase recording_mode: recordingMode }) ) ); }, /** * Sends a Rayo dial IQ (urn:xmpp:rayo:1) to the focus occupant of the * given room. Fire-and-forget — does NOT wait for a response, because * mod_filter_iq_rayo may block it, and the test asserts via the * /dial-iqs HTTP endpoint instead. * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {object} [opts] * @param {string} [opts.dialTo='sip:test@example.com'] dial `to` attribute. * @param {string|null} [opts.roomNameHeader] JvbRoomName value; defaults to roomJid. * Pass null to omit, any other string for a mismatch test. * @param {string|null} [opts.roomPassHeader] JvbRoomPassword value; omitted by default. * @param {object} [opts.extraHeaders] extra name→value header pairs (spoof/strip tests). */ sendRayoIq(roomJid, { dialTo = 'sip:test@example.com', roomNameHeader = roomJid, roomPassHeader = null, extraHeaders = {} } = {}) { const headers = []; if (roomNameHeader !== null) { headers.push(xml('header', { xmlns: 'urn:xmpp:rayo:1', name: 'JvbRoomName', value: roomNameHeader })); } if (roomPassHeader !== null) { headers.push(xml('header', { xmlns: 'urn:xmpp:rayo:1', name: 'JvbRoomPassword', value: roomPassHeader })); } for (const [ name, value ] of Object.entries(extraHeaders)) { headers.push(xml('header', { xmlns: 'urn:xmpp:rayo:1', name, value })); } return xmpp.send( xml('iq', { type: 'set', to: `${roomJid}/focus`, id: `rayo-${++_counter}` }, xml('dial', { xmlns: 'urn:xmpp:rayo:1', to: dialTo, from: 'fromdomain' }, ...headers) ) ); }, /** * Like sendRayoIq but waits for Prosody's IQ response and returns it. * Use this when the test needs to inspect the reply stanza (e.g. to * assert that Prosody sent back an error rather than routing the IQ). * * Accepts the same options as sendRayoIq. * * @param {string} roomJid * @param {object} [opts] * @param {string} [opts.dialTo] * @param {string|null} [opts.roomNameHeader] * @param {string|null} [opts.roomPassHeader] * @param {object} [opts.extraHeaders] * @returns {Promise} the response IQ stanza */ sendRayoIqAndWait(roomJid, { dialTo = 'sip:test@example.com', roomNameHeader = roomJid, roomPassHeader = null, extraHeaders = {} } = {}) { const headers = []; if (roomNameHeader !== null) { headers.push(xml('header', { xmlns: 'urn:xmpp:rayo:1', name: 'JvbRoomName', value: roomNameHeader })); } if (roomPassHeader !== null) { headers.push(xml('header', { xmlns: 'urn:xmpp:rayo:1', name: 'JvbRoomPassword', value: roomPassHeader })); } for (const [ name, value ] of Object.entries(extraHeaders)) { headers.push(xml('header', { xmlns: 'urn:xmpp:rayo:1', name, value })); } return sendIq(xmpp, pendingIqs, xml('iq', { type: 'set', to: `${roomJid}/focus`, id: `rayo-${++_counter}` }, xml('dial', { xmlns: 'urn:xmpp:rayo:1', to: dialTo, from: 'fromdomain' }, ...headers) ) ); }, /** * Sends a colibri2 conference-modify IQ to the given full JID, optionally * carrying a extension (as jicofo/JVB do when tracing is * enabled). Fire-and-forget — mod_trace does not reply; assert on the * recipient's own stanza queue (waitForIq) and/or the OTLP mock receiver. * * @param {string} to Destination full JID. * @param {object} [opts] * @param {string} [opts.traceId] 32-hex-char trace id. Omit to send no . * @param {string} [opts.parentId] 16-hex-char parent span id. Required when traceId is set. */ sendConferenceModifyIq(to, { traceId, parentId } = {}) { const children = traceId === undefined ? [] : [ xml('traceparent', { // eslint-disable-next-line camelcase trace_id: traceId, // eslint-disable-next-line camelcase parent_id: parentId }) ]; return xmpp.send( xml('iq', { type: 'set', to, id: `cm-${++_counter}` }, xml('conference-modify', { xmlns: 'jitsi:colibri2' }, ...children) ) ); }, /** * Sends a disco#info IQ and resolves with the response stanza. * @param {string} targetJid */ sendDiscoInfo(targetJid) { return sendIq(xmpp, pendingIqs, xml('iq', { type: 'get', to: targetJid, id: `disco-${++_counter}` }, xml('query', { xmlns: 'http://jabber.org/protocol/disco#info' }) ) ); }, /** * Sends a XEP-0215 extdisco services IQ and resolves with the response stanza. * @param {string} targetJid e.g. 'localhost' */ sendExtdiscoIq(targetJid) { return sendIq(xmpp, pendingIqs, xml('iq', { type: 'get', to: targetJid, id: `extdisco-${++_counter}` }, xml('services', { xmlns: 'urn:xmpp:extdisco:1' }) ) ); }, /** * Sends an message to the given component JID. * mod_end_conference uses the sender's jitsi_web_query_room session field * (populated by mod_jitsi_session from the ?room= WebSocket URL param) to * locate and destroy the target room. Fire-and-forget — the module returns * no reply stanza; verify the side-effect via getRoomState. * * @param {string} componentJid e.g. 'endconference.localhost' */ sendEndConference(componentJid) { return xmpp.send( xml('message', { to: componentJid, id: `ec-${++_counter}` }, xml('end_conference') ) ); }, /** * Sends a raw room_metadata message to a metadata component. * Use this to test malformed payloads; prefer sendMetadataUpdate for * well-formed updates. * * The session must have jitsi_web_query_room set (connect with * params: { room: '' }) for the component to process it. * * @param {string} componentJid e.g. 'metadata.localhost' * @param {string} roomJid full room JID, e.g. 'room@conference.localhost' * @param {string} rawPayload raw text for the body */ sendMetadataMessage(componentJid, roomJid, rawPayload) { const attrs = { xmlns: 'http://jitsi.org/jitmeet' }; if (roomJid !== null && roomJid !== undefined) { attrs.room = roomJid; } return xmpp.send( xml('message', { to: componentJid, id: `meta-${++_counter}` }, xml('room_metadata', attrs, rawPayload) ) ); }, /** * Sends a well-formed metadata update to a metadata component. * * @param {string} componentJid e.g. 'metadata.localhost' * @param {string} roomJid full room JID, e.g. 'room@conference.localhost' * @param {string} key metadata key * @param {*} data metadata value (JSON-serialisable) */ sendMetadataUpdate(componentJid, roomJid, key, data) { return this.sendMetadataMessage(componentJid, roomJid, JSON.stringify({ key, data })); }, /** * Sends a raw message to an audio-translation component. * Use this to test malformed payloads; prefer sendAudioTranslation for * well-formed deltas. * * The session must have jitsi_web_query_room set (connect with * params: { room: '' }) — the component resolves the room from * the session, not from a stanza attribute. * * @param {string} componentJid e.g. 'audiotranslation.localhost' * @param {string} rawPayload raw text for the body */ sendAudioTranslationRaw(componentJid, rawPayload) { return xmpp.send( xml('message', { to: componentJid, id: `at-${++_counter}` }, xml('audio-translation', { xmlns: 'http://jitsi.org/jitmeet' }, rawPayload) ) ); }, /** * Sends a well-formed audio-translation subscription delta. * * @param {string} componentJid e.g. 'audiotranslation.localhost' * @param {object} delta { senderId: language, ... }; "" removes */ sendAudioTranslation(componentJid, delta) { return this.sendAudioTranslationRaw(componentJid, JSON.stringify(delta)); }, /** * Sends a plain stanza to the given JID with no special children. * Useful for testing that the end_conference component ignores messages * that lack the child element. * * @param {string} to Destination JID. */ sendPlainMessage(to) { return xmpp.send( xml('message', { to, id: `msg-${++_counter}` }) ); }, /** * Sends a MUC groupchat message to the room. Resolves with the first * stanza received bearing the same id — either the MUC * reflection (type=groupchat) or an error reply (type=error). * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {string} [body] message body text; omit to send body-less * @param {Array} [extensions] extra XML children appended to the stanza */ async sendGroupchat(roomJid, body, extensions = []) { const id = `gc-${++_counter}`; const children = body === undefined ? [] : [ xml('body', {}, body) ]; await xmpp.send( xml('message', { to: roomJid, type: 'groupchat', id }, ...children, ...extensions) ); return this.waitForMessage(s => s.attrs.id === id); }, /** * Sets the room subject by sending a groupchat message carrying only a * element (XEP-0045 §8.1). Fire-and-forget — does NOT wait for * the room's subject broadcast. * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {string} subject the new subject text */ sendRoomSubject(roomJid, subject) { return xmpp.send( xml('message', { to: roomJid, type: 'groupchat', id: `subj-${++_counter}` }, xml('subject', {}, subject) ) ); }, /** * Sends a groupchat message with a child to the room. * Fire-and-forget — does NOT wait for the MUC reflection stanza. * Use when testing hooks that may crash or block the message before it * is reflected (waiting for reflection would time out). * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {object} payload JSON-serialisable value for the json-message body. */ sendJsonGroupchat(roomJid, payload) { return xmpp.send( xml('message', { to: roomJid, type: 'groupchat', id: `jm-${++_counter}` }, xml('json-message', { xmlns: 'http://jitsi.org/jitmeet' }, JSON.stringify(payload)) ) ); }, /** * Sends a message carrying a child to an arbitrary JID, * with full control over the outer attributes. Fire-and-forget. * * Use it to send what a well-behaved client never would — most usefully * a stanza claiming a `from` that is not the sender's own JID, to prove * the server refuses to deliver it under that identity. * * @param {string} to Destination JID. * @param {object} payload JSON-serialisable value for the json-message body. * @param {object} [attrs] Extra/override attributes for the . */ sendJsonMessageRaw(to, payload, attrs = {}) { return xmpp.send( xml('message', { to, id: `jm-${++_counter}`, ...attrs }, xml('json-message', { xmlns: 'http://jitsi.org/jitmeet' }, JSON.stringify(payload)) ) ); }, /** * Grants moderator role to the occupant identified by nick. * The caller must be the room owner (e.g. the focus client). * Resolves with the server's IQ response. * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {string} nick MUC nick of the occupant to promote. */ grantModerator(roomJid, nick) { return sendIq(xmpp, pendingIqs, xml('iq', { type: 'set', to: roomJid, id: `mod-${++_counter}` }, xml('query', { xmlns: 'http://jabber.org/protocol/muc#admin' }, xml('item', { nick, role: 'moderator' }) ) ) ); }, /** * Sends a muc#admin set IQ carrying a single with the given * attributes and resolves with the server's IQ response. Use to attempt * a kick (role='none'), ban (affiliation='outcast'), or any other * role/affiliation change on an occupant. * * Normally the caller must be a room moderator/owner, but hooks that run * before Prosody's permission check (e.g. the set admin filtering in * mod_muc_meeting_id) may reject the request regardless of privilege. * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {object} itemAttrs attributes for the , e.g. * { nick: 'focus', role: 'none' } or * { jid: 'focus@auth.localhost', affiliation: 'outcast' } */ sendMucAdmin(roomJid, itemAttrs) { return sendIq(xmpp, pendingIqs, xml('iq', { type: 'set', to: roomJid, id: `admin-${++_counter}` }, xml('query', { xmlns: 'http://jabber.org/protocol/muc#admin' }, xml('item', itemAttrs) ) ) ); }, /** * Sends a Jingle IQ (XEP-0166/XEP-0339) to an occupant's full JID in a * MUC room. Fire-and-forget — does NOT wait for a response. * * The pre-iq/full hook fires on the MUC component before routing, so any * module that hooks that event will see the stanza synchronously. * * For session-accept, a child is included when * videoType is non-null. For source-add, a content/description/source * chain with a videoType attribute is included. * * @param {string} toFullJid Occupant full JID, e.g. 'room@conference.localhost/focus' * @param {string} action 'session-accept' | 'source-add' * @param {string|null} videoType 'camera' | 'desktop' | null (audio-only: no video content) */ sendJingleIq(toFullJid, action, videoType = null) { let contentEl; if (videoType === null) { // Audio-only: a content element with name='audio' and no description. // session-accept: no content with name='video' → no flags set. // source-add: no description/source path → no flags set. contentEl = xml('content', { name: 'audio' }); } else { const sourceEl = xml('source', { xmlns: 'urn:xmpp:jingle:apps:rtp:ssma:0', videoType }); const descEl = xml('description', { xmlns: 'urn:xmpp:jingle:apps:rtp:1', media: 'video' }, sourceEl); contentEl = xml('content', { name: 'video' }, descEl); } return xmpp.send( xml('iq', { type: 'set', to: toFullJid, id: `jingle-${++_counter}` }, xml('jingle', { xmlns: 'urn:xmpp:jingle:1', action }, contentEl) ) ); }, /** * Sends a muc#admin set IQ carrying multiple children and * resolves with the server's IQ response. XEP-0045 allows batching * several affiliation/role changes in one stanza; use this to verify * that filtering hooks inspect every item and not just the first one. * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {Array} itemsAttrs attribute objects, one per */ sendMucAdminItems(roomJid, itemsAttrs) { return sendIq(xmpp, pendingIqs, xml('iq', { type: 'set', to: roomJid, id: `admin-${++_counter}` }, xml('query', { xmlns: 'http://jabber.org/protocol/muc#admin' }, ...itemsAttrs.map(attrs => xml('item', attrs)) ) ) ); }, /** * Destroys a MUC room. The client must be the room owner. * Resolves when the server acknowledges the destroy IQ. * * @param {string} roomJid e.g. 'room@conference.localhost' */ destroyRoom(roomJid) { return sendIq(xmpp, pendingIqs, xml('iq', { type: 'set', to: roomJid, id: `destroy-${++_counter}` }, xml('query', { xmlns: 'http://jabber.org/protocol/muc#owner' }, xml('destroy') ) ) ); }, /** * Waits for an incoming stanza that satisfies an optional * filter predicate and resolves with it. Non-matching presences are left * in the queue. Rejects with a timeout error if no matching presence * arrives within the timeout. * @param {Function} [filter] Predicate; defaults to accepting any presence. * @param {number} [timeout=5000] */ waitForPresence(filter = null, timeout = 5000) { const pred = filter ?? (() => true); return new Promise((resolve, reject) => { const deadline = Date.now() + timeout; const check = () => { for (let i = 0; i < stanzaQueue.length; i++) { const s = stanzaQueue[i]; if (s.name === 'presence' && pred(s)) { resolve(stanzaQueue.splice(i, 1)[0]); return; } } if (Date.now() >= deadline) { reject(new Error('Timeout waiting for presence stanza')); return; } setTimeout(check, 50); }; check(); }); }, /** * Convenience wrapper around waitForPresence that matches by full from-JID * and optional presence type. * * @param {string} from full JID, e.g. 'room@conference.localhost/nick' * @param {object} [opts] * @param {string} [opts.type] presence type to match (e.g. 'unavailable') * @param {number} [opts.timeout=5000] */ waitForPresenceFrom(from, { type, timeout = 5000 } = {}) { return this.waitForPresence( s => s.attrs.from === from && (type === undefined || s.attrs.type === type), timeout ); }, /** * Waits for an incoming stanza that satisfies an optional filter * predicate and resolves with it. Only unsolicited IQs land here; * responses to IQs sent with sendIq are handled via pendingIqs. * @param {Function} [filter] Predicate; defaults to accepting any IQ. * @param {number} [timeout=5000] */ waitForIq(filter = null, timeout = 5000) { const pred = filter ?? (() => true); return new Promise((resolve, reject) => { const deadline = Date.now() + timeout; const check = () => { for (let i = 0; i < stanzaQueue.length; i++) { const s = stanzaQueue[i]; if (s.name === 'iq' && pred(s)) { resolve(stanzaQueue.splice(i, 1)[0]); return; } } if (Date.now() >= deadline) { reject(new Error('Timeout waiting for IQ stanza')); return; } setTimeout(check, 50); }; check(); }); }, /** * Waits for an incoming stanza that satisfies an optional * filter predicate and resolves with it. Non-matching messages are left * in the queue. Rejects with a timeout error if no matching message * arrives within the timeout. * @param {Function} [filter] Predicate; defaults to accepting any message. * @param {number} [timeout=5000] */ waitForMessage(filter = null, timeout = 5000) { const pred = filter ?? (() => true); return new Promise((resolve, reject) => { const deadline = Date.now() + timeout; const check = () => { for (let i = 0; i < stanzaQueue.length; i++) { const s = stanzaQueue[i]; if (s.name === 'message' && pred(s)) { resolve(stanzaQueue.splice(i, 1)[0]); return; } } if (Date.now() >= deadline) { reject(new Error('Timeout waiting for message stanza')); return; } setTimeout(check, 50); }; check(); }); }, /** * Resolves when the underlying XMPP connection goes offline (e.g. * because Prosody closed the session). Useful for verifying that * mod_muc_auth_ban's async ban — which calls session:close() from * the HTTP callback — actually terminates the connection. * * Rejects with a timeout error if the connection does not drop within * the given timeout. * * @param {number} [timeout=5000] */ waitForDisconnect(timeout = 5000) { return new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error('Timeout waiting for disconnect')), timeout ); xmpp.once('offline', () => { clearTimeout(timer); resolve(); }); }); }, /** * Sends a file-sharing add message to a component JID. * The session must have jitsi_web_query_room set (connect with * params: { room: '' }) for the component to locate the room. * * @param {string} componentJid e.g. 'filesharing.localhost' * @param {object} fileObj file descriptor; must include fileId */ sendFileSharingAdd(componentJid, fileObj) { return xmpp.send( xml('message', { to: componentJid, id: `fs-${++_counter}` }, xml('file-sharing', { xmlns: 'http://jitsi.org/jitmeet', type: 'add' }, JSON.stringify(fileObj) ) ) ); }, /** * Sends a file-sharing remove message to a component JID. * * @param {string} componentJid e.g. 'filesharing.localhost' * @param {string} fileId ID of the file to remove */ sendFileSharingRemove(componentJid, fileId) { return xmpp.send( xml('message', { to: componentJid, id: `fs-${++_counter}` }, xml('file-sharing', { xmlns: 'http://jitsi.org/jitmeet', type: 'remove', fileId }) ) ); }, /** * Sends a raw polls json-message to a polls component. The body is sent * verbatim, so use this to exercise malformed payloads (invalid JSON, * unexpected answer shapes, oversized bodies). Prefer createPoll / * answerPoll for well-formed messages. * * The session must have jitsi_web_query_room set (connect with * params: { room: '' }) and the sender must be an occupant of * the room for the component to process the message. * * @param {string} componentJid e.g. 'polls.localhost' * @param {string} rawPayload raw text for the body */ sendPollsMessage(componentJid, rawPayload) { return xmpp.send( xml('message', { to: componentJid, id: `poll-${++_counter}` }, xml('json-message', { xmlns: 'http://jitsi.org/jitmeet' }, rawPayload) ) ); }, /** * Creates a poll by sending a well-formed new-poll json-message to the * polls component. Fire-and-forget — assert via the broadcast the * component sends back to occupants (waitForMessage on a poll broadcast). * * @param {string} componentJid e.g. 'polls.localhost' * @param {string} pollId unique poll id * @param {string} question poll question * @param {Array} answers answer objects, e.g. [ { name: 'A' } ] */ createPoll(componentJid, pollId, question, answers) { return this.sendPollsMessage(componentJid, JSON.stringify({ answers, command: 'new-poll', pollId, question, type: 'polls' })); }, /** * Answers a poll by sending a well-formed answer-poll json-message to the * polls component. Fire-and-forget. * * @param {string} componentJid e.g. 'polls.localhost' * @param {string} pollId id of the poll being answered * @param {Array} answers one boolean per poll option */ answerPoll(componentJid, pollId, answers) { return this.sendPollsMessage(componentJid, JSON.stringify({ answers, command: 'answer-poll', pollId, type: 'polls' })); }, /** * Leaves a MUC room (XEP-0045 §7.14) and resolves with the self * unavailable presence echoed back by the room. * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {string} [nick] defaults to the nick used by joinRoom * @param {object} [opts] * @param {number} [opts.timeout=5000] */ async leaveRoom(roomJid, nick, { timeout = 5000 } = {}) { const n = nick ?? this.nick; await xmpp.send(xml('presence', { to: `${roomJid}/${n}`, type: 'unavailable' })); return this.waitForPresenceFrom(`${roomJid}/${n}`, { type: 'unavailable', timeout }); }, /** * Sends a control message to the breakout rooms component. * The session must have jitsi_web_query_room set (connect with * params: { room: '' }) and the sender must be a moderator * occupant of the main room for the module to process the message. * * @param {string} componentJid e.g. 'breakout.conference.localhost' * @param {string} type operation type, e.g. 'features/breakout-rooms/add' * @param {object} [extraAttrs] extra attributes on the element, * e.g. { subject: 'Group A' } or { breakoutRoomJid: '...' } */ sendBreakoutRoomsMessage(componentJid, type, extraAttrs = {}) { return xmpp.send( xml('message', { to: componentJid, id: `br-${++_counter}` }, xml('breakout_rooms', { type, ...extraAttrs }) ) ); }, /** * Sends a raw file-sharing message. Use this to test malformed payloads * or non-standard message types (e.g. type='error'). * * @param {string} componentJid e.g. 'filesharing.localhost' * @param {object} messageAttrs extra attributes on the outer * @param {object} fsAttrs attributes on * @param {string} [body] text body for */ sendFileSharingRaw(componentJid, messageAttrs, fsAttrs, body) { const fsEl = body === undefined ? xml('file-sharing', { xmlns: 'http://jitsi.org/jitmeet', ...fsAttrs }) : xml('file-sharing', { xmlns: 'http://jitsi.org/jitmeet', ...fsAttrs }, body); return xmpp.send( xml('message', { to: componentJid, id: `fs-${++_counter}`, ...messageAttrs }, fsEl ) ); }, async disconnect() { try { await xmpp.stop(); } catch { /* ignore on teardown */ } }, /** * Sends a bare presence stanza to the given JID (no element). * Useful for presence updates after the initial MUC join, which * triggers mod_presence_identity hooks on the sender's VirtualHost. * * @param {string} to Destination full JID, e.g. 'room@conference.localhost/nick' * @param {Array} [extensions] Additional XML children to include. */ sendPresence(to, extensions = []) { return xmpp.send(xml('presence', { to }, ...extensions)); }, /** * Abruptly terminates the underlying WebSocket without a close * handshake. Prosody sees an unclean disconnect and hibernates the * SMACKS session. On reconnect the library sends , which * triggers Prosody's c2s-session-updated hook (where the * breakout-rooms bug manifested). * * Jitsi's SMACKS resume flow (mod_auth_token.lua + mod_smacks.lua): * 1. Client reconnects with ?previd= in the WebSocket URL. * 2. mod_jitsi_session.lua sets session.previd from the URL param. * 3. SASL runs; mod_auth_token.lua's get_username_from_token finds * the hibernating session whose resumption_token == session.previd * and copies its username, preserving the original UUID. * 4. Post-auth features include ; @xmpp/stream-management sends * . mod_smacks looks up the session with * registry key "uuid@host/smacks_id" — which now matches because * step 3 preserved the UUID — and resumes successfully. * 5. sessionmanager.update_session fires c2s-session-updated. * * Without ?previd in the URL, step 3 assigns a fresh UUID, the key * doesn't match, mod_smacks falls back to a new session, and the * regression is never exercised. * * IMPORTANT: must use ws.terminate() (TCP RST, no close frame), NOT * ws.close() / Socket.end(). A clean WebSocket close (code 1000) * causes Prosody to destroy the SMACKS session rather than hibernate it. * * xmpp.socket – @xmpp/websocket Socket wrapper * xmpp.socket.socket – underlying ws.WebSocket instance with terminate() * * @param {object} [urlParams] Query parameters to set on the reconnect * URL (e.g. { token: '' }). */ dropConnection(urlParams = {}) { // Patch the reconnect URL to carry ?previd= so that // mod_jitsi_session.lua sets session.previd and mod_auth_token.lua // can preserve session.username across the SASL exchange, allowing // mod_smacks.lua's registry lookup to succeed. // // `urlParams` overrides query parameters on the reconnect URL only — // the hibernating session keeps whatever it was given on the first // connection. Use it to resume while presenting a different ?token=, // which is how a client rotates its JWT across a reconnect. const smId = xmpp.streamManagement?.id; try { const serviceUrl = new URL(xmpp.options.service); if (smId) { serviceUrl.searchParams.set('previd', smId); } for (const [ k, v ] of Object.entries(urlParams)) { serviceUrl.searchParams.set(k, v); } xmpp.options.service = serviceUrl.toString(); } catch { /* ignore */ } try { const ws = xmpp.socket?.socket; if (ws?.terminate) { ws.terminate(); } else { xmpp.socket?.end(); } } catch { /* ignore */ } }, /** * Resolves when the SMACKS (XEP-0198) session resume is fully complete * after a dropConnection(). Set up this listener BEFORE calling * dropConnection() to avoid a race with a very fast reconnect. * * The @xmpp/reconnect 'reconnected' event fires after entity.open() * resolves, which happens on the stream-open header — BEFORE stream * features are processed and therefore BEFORE SMACKS sends * and receives . The SMACKS handler then sets entity.status * = 'online' directly (no event emitted). We poll that field after * 'reconnected' to detect the moment the resume is complete, so that * any stanza sent afterwards is processed on the fully-resumed session. * * @param {number} [timeout=10000] */ waitForReconnect(timeout = 10000) { return new Promise((resolve, reject) => { let done = false; const timer = setTimeout(() => { if (!done) { done = true; reject(new Error('Timeout waiting for session to reconnect')); } }, timeout); xmpp.reconnect.once('reconnected', () => { const checkOnline = () => { if (done) { return; } if (xmpp.status === 'online') { done = true; clearTimeout(timer); resolve(); } else { setTimeout(checkOnline, 50); } }; checkOnline(); }); }); } }; } /** * Sends an IQ stanza and resolves with the response, matched by id. * Attaches a generated id to the stanza if one is not already present. */ async function sendIq(xmpp, pendingIqs, stanza, timeout = 5000) { if (!stanza.attrs.id) { stanza.attrs.id = `iq-${++_counter}`; } const id = stanza.attrs.id; await xmpp.send(stanza); return new Promise((resolve, reject) => { const timer = setTimeout(() => { pendingIqs.delete(id); reject(new Error(`Timeout waiting for IQ response (id=${id})`)); }, timeout); pendingIqs.set(id, { resolve, reject, timer }); }); } /** * Waits for either: * - a self-presence from the room (status code 110), indicating successful join * - an error presence from the room, indicating the join was rejected */ function waitForPresence(queue, roomJid, timeout = 5000) { return new Promise((resolve, reject) => { const deadline = Date.now() + timeout; const check = () => { for (let i = 0; i < queue.length; i++) { const s = queue[i]; if (s.name !== 'presence') { continue; } const from = s.attrs.from ?? ''; if (!from.startsWith(`${roomJid}/`)) { continue; } // Rejected join — error presence sent directly back to client. if (s.attrs.type === 'error') { queue.splice(i, 1); resolve(s); return; } // Self-presence: Prosody adds status code 110 to confirm this is our own join. const x = s.getChild('x', 'http://jabber.org/protocol/muc#user'); if (x?.getChildren('status').some(st => st.attrs.code === '110')) { queue.splice(i, 1); resolve(s); return; } } if (Date.now() >= deadline) { reject(new Error(`Timeout waiting for presence from ${roomJid}`)); return; } setTimeout(check, 50); }; check(); }); }