changeset 6530:8e87fbbb6cd3

mod_muc_mav: Add module for XEP-0463: MUC Affiliations Versioning
author Marvin W <hg@larma.de>
date Mon, 04 May 2026 20:55:39 +0200
parents 971a531654dc
children 2d17c39ede4d
files mod_muc_mav/README.md mod_muc_mav/mod_muc_mav.lua
diffstat 2 files changed, 129 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_muc_mav/README.md	Mon May 04 20:55:39 2026 +0200
@@ -0,0 +1,27 @@
+---
+labels:
+- 'Stage-Alpha'
+summary: Basic support for XEP-0463: MUC Affiliations Versioning
+---
+
+Introduction
+============
+
+This module implements [XEP-0463](https://xmpp.org/extensions/xep-0463.html),
+specifically version 0.2 of it. It does so without any additional storage
+requirements and everything needed is handled in-memory only.
+
+Details
+=======
+
+This module does not preserve a version history, it only knows about the state
+before and after a change and the current set of affiliations. This means that
+if affiliations are changed while a user device is offline, they will receive
+a full response when reconnecting with the room. This is in line with the
+behavior the XEP describes for the case of the client sending a version unknown
+to the server.
+
+Compatibility
+=============
+
+Tested only on Prosody 13.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_muc_mav/mod_muc_mav.lua	Mon May 04 20:55:39 2026 +0200
@@ -0,0 +1,102 @@
+local st = require "util.stanza";
+local hashes = require "util.hashes";
+local base64 = require "util.encodings".base64;
+
+local mod_muc = module:depends"muc";
+
+local xmlns_mav = "urn:xmpp:muc:affiliations:1";
+local xmlns_muc = "http://jabber.org/protocol/muc";
+local xmlns_muc_user = "http://jabber.org/protocol/muc#user";
+
+local mav_versions = {}
+
+local function get_affiliations(room)
+	local jid_affiliations = {}
+	local x = st.stanza("x", {xmlns = xmlns_muc_user;});
+	for jid, affiliation, affiliation_data in room:each_affiliation() do
+		table.insert(jid_affiliations, jid.."\0"..affiliation);
+		local nick = affiliation_data and affiliation_data.reserved_nickname;
+		x:tag("item", {affiliation = affiliation or "none"; jid = jid; nick = nick;}):up();
+	end
+	table.sort(jid_affiliations);
+	local hash_input = table.concat(jid_affiliations, "\0");
+	local hash = base64.encode(hashes.sha256(hash_input))
+	x:tag("mav", {xmlns = xmlns_mav; ["until"] = hash;}):up();
+	return hash, x;
+end
+
+local function handle_stanza_with_x(room, stanza, mav_current)
+	local muc_x = stanza:get_child("x", xmlns_muc_user);
+	local is_initial = false;
+	for child in muc_x:childtags("status") do
+		if child.attr.code == "110" then is_initial = true; end;
+	end
+	if is_initial then
+		muc_x:tag("mav", {xmlns = xmlns_mav; ["until"] = mav_current;}):up();
+	else
+		local mav_new = get_affiliations(room);
+		if mav_current ~= mav_new then
+			module:log("info", "Update version of %s from %s to %s in affiliation update", stanza.attr.to, mav_current, mav_new);
+			muc_x:tag("mav", {xmlns = xmlns_mav; ["since"] = mav_current; ["until"] = mav_new; }):up();
+			mav_versions[room.jid.."\0"..stanza.attr.to] = mav_new;
+		end
+	end
+end
+
+local function room_route_stanza(room, stanza)
+	local muc_x = stanza:get_child("x", xmlns_muc_user);
+	if muc_x then
+		local mav_current = mav_versions[room.jid.."\0"..stanza.attr.to];
+		if mav_current then
+			if stanza.name == "presence" or stanza.name == "message" then
+				handle_stanza_with_x(room, stanza, mav_current);
+			end
+		end
+	end
+	module:send(stanza);
+end
+
+-- TODO: Stop this hack
+local original_room_route_stanza = mod_muc.room_mt.route_stanza;
+mod_muc.room_mt.route_stanza = room_route_stanza;
+function module.unload()
+	mod_muc.room_mt.route_stanza = original_room_route_stanza;
+end
+
+
+module:hook("muc-disco#info", function(event)
+	event.reply:tag("feature", {var = xmlns_mav}):up();
+end);
+
+module:hook("muc-occupant-pre-join", function(event)
+	local room, stanza = event.room, event.stanza;
+	local muc_x = stanza:get_child("x", xmlns_muc);
+	if not muc_x then return; end
+	local mav_child = muc_x:get_child("mav", xmlns_mav);
+	if not mav_child then return; end
+	local mav_since = mav_child.attr["since"];
+	mav_versions[room.jid.."\0"..stanza.attr.from] = mav_since;
+	local mav_current, x = get_affiliations(room);
+	if mav_current ~= mav_since then
+		-- TODO: Support for diffs
+		-- Send full response
+		module:log("info", "Old version of %s is %s, sending full response for %s", stanza.attr.from, mav_since, mav_current);
+		local announce_msg = st.message({ from = room.jid, to = stanza.attr.from }):add_child(x);
+		room:route_stanza(announce_msg);
+		mav_versions[room.jid.."\0"..stanza.attr.from] = mav_current;
+	else
+		module:log("info", "Old version of %s is %s, still current", stanza.attr.from, mav_since);
+	end
+end, -100);
+
+module:hook("muc-occupant-left", function(event)
+	local room, occupant = event.room, event.occupant;
+	for jid in occupant:each_session() do
+		mav_versions[room.jid.."\0"..jid] = nil;
+	end
+end);
+
+module:hook("muc-occupant-pre-leave", function(event)
+	local room, stanza = event.room, event.stanza;
+	mav_versions[room.jid.."\0"..stanza.attr.from] = nil;
+end);