diff mod_muc_members_json/mod_muc_members_json.lua @ 6431:459cb2c2789b

mod_muc_members_json: Support private MUCs, non-member affiliation grants, async storage, dry-run mode
author Matthew Wild <mwild1@gmail.com>
date Tue, 10 Mar 2026 16:58:17 +0000
parents 80abda15a1e9
children 9a89cb9810a7
line wrap: on
line diff
--- a/mod_muc_members_json/mod_muc_members_json.lua	Tue Mar 10 16:13:25 2026 +0100
+++ b/mod_muc_members_json/mod_muc_members_json.lua	Tue Mar 10 16:58:17 2026 +0000
@@ -1,9 +1,14 @@
+local async = require "util.async";
 local http = require "net.http";
 local json = require "util.json";
+local promise = require "util.promise";
+local set = require "util.set";
 
 local json_url = assert(module:get_option_string("muc_members_json_url"), "muc_members_json_url required");
 local managed_mucs = module:get_option("muc_members_json_mucs");
 
+local dry_run_mode = module:get_option_boolean("muc_members_json_dry_run", false);
+
 local mod_muc = module:depends("muc");
 
 --[[
@@ -53,41 +58,122 @@
 	return hats;
 end
 
-function module.load()
-	http.request(json_url)
+local function async_as_promise(f, p)
+	return promise.new(function (resolve, reject)
+		local r = async.runner(f);
+		r:onready(function ()
+			resolve(true);
+		end);
+		r:onerror(function (err)
+			reject(err);
+		end);
+		if p == nil then
+			p = true;
+		end
+		local ok, state = r:run(p);
+		if ok and state == "ready" then
+			resolve(true);
+		end
+	end);
+end
+
+function update_members(dry_run)
+	module:log("debug", "Updating... (dry run: %s)", dry_run and "no" or "yes");
+	return http.request(json_url)
 		:next(function (result)
 			return json.decode(result.body);
 		end)
 		:next(function (data)
 			module:log("debug", "DATA: %s", require "util.serialization".serialize(data, "debug"));
+			return async_as_promise(function ()
+				for name, muc_config in pairs(managed_mucs) do
+					local muc_jid = name.."@"..module.host;
+					local muc = mod_muc.get_room_from_jid(muc_jid);
+					if muc then
+						local owner_roles = set.new(muc_config.owner_roles);
+						local admin_roles = set.new(muc_config.admin_roles);
+						local member_roles = set.new(muc_config.member_roles);
+						local affiliated_roles = owner_roles + admin_roles + member_roles;
+						local is_private = muc_config.private;
+						local include_all_members = muc_config.member_roles == nil;
 
-			for name, muc_config in pairs(managed_mucs) do
-				local muc_jid = name.."@"..module.host;
-				local muc = mod_muc.get_room_from_jid(muc_jid);
-				module:log("warn", "%s -> %s -> %s", name, muc_jid, muc);
-				if muc then
-					local jids = {};
-					for _, member_info in ipairs(data.members) do
-						for _, member_jid in ipairs(member_info.jids) do
-							jids[member_jid] = true;
-							local affiliation = muc:get_affiliation(member_jid);
-							if not affiliation then
-								muc:set_affiliation(true, member_jid, "member", "imported membership");
-								muc:set_affiliation_data(member_jid, "source", module.name);
+						if is_private and not muc:get_members_only() then
+							if not dry_run then
+								module:log("info", "Setting %s to members-only...", muc_jid);
+								muc:set_members_only(true);
+							else
+								module:log("info", "DRY RUN: Would set %s to members-only...", muc_jid);
 							end
-							muc:set_affiliation_data(member_jid, "hats", get_hats(member_info, muc_config));
 						end
-					end
-					-- Remove affiliation from folk who weren't in the source data but previously were
-					for jid, aff, data in muc:each_affiliation() do
-						if not jids[jid] and data and data.source == module.name then
-							muc:set_affiliation(true, jid, "none", "imported membership lost");
+
+						local jids = {};
+						for _, member_info in ipairs(data.members) do
+							local roles = set.new(member_info.roles);
+							for _, member_jid in ipairs(member_info.jids) do
+								if include_all_members or not set.intersection(affiliated_roles, roles):empty() then
+									jids[member_jid] = true;
+									local is_owner = not set.intersection(owner_roles, roles):empty();
+									local is_admin = not set.intersection(admin_roles, roles):empty();
+									local expected_affiliation = (is_owner and "owner") or (is_admin and "admin") or "member";
+									local affiliation = muc:get_affiliation(member_jid);
+									if affiliation ~= expected_affiliation then
+										if not dry_run then
+											muc:set_affiliation(true, member_jid, expected_affiliation, "imported membership");
+											muc:set_affiliation_data(member_jid, "source", module.name);
+											module:log("info", "Set affiliation of %s in %s, %s -> %s", member_jid, muc_jid, affiliation, expected_affiliation);
+										else
+											module:log("info", "DRY RUN: Would set affiliation of %s in %s, %s -> %s", member_jid, muc_jid, affiliation, expected_affiliation);
+										end
+									end
+									if not dry_run then
+										muc:set_affiliation_data(member_jid, "hats", get_hats(member_info, muc_config));
+									end
+								end
+							end
+						end
+						-- Remove affiliation from folk who weren't in the source data but previously were
+						for jid, aff, data in muc:each_affiliation() do
+							if not jids[jid] and data and data.source == module.name then
+								if not dry_run then
+									module:log("info", "Removing affiliation of %s in %s", jid, muc_jid);
+									muc:set_affiliation(true, jid, "none", "imported membership lost");
+								else
+									module:log("info", "DRY RUN: Would remove affiliation of %s in %s", jid, muc_jid);
+								end
+							end
 						end
 					end
 				end
-			end
-
+			end);
 		end):catch(function (err)
 			module:log("error", "FAILED: %s", err);
 		end);
 end
+
+function module.load()
+	update_members(dry_run_mode);
+end
+
+module:hourly(function ()
+	update_members(dry_run_mode);
+end);
+
+module:add_item("shell-command", {
+	section = "members_json";
+	section_desc = "Commands related to mod_muc_members_json";
+	name = "update";
+	desc = "Force an update of the members data";
+	args = {
+		{ name = "host", type = "string" };
+	};
+	host_selector = "host";
+	flags = {
+		bool_params = { dry_run = true };
+	};
+	handler = function (shell, host, opts)
+		return update_members(opts.dry_run)
+			:next(function ()
+				return opts.dry_run and "Updated (dry-run only, check logs)" or "Updated";
+			end);
+	end;
+});