local st = require "util.stanza"; local http = require "net.http"; local jid = require "util.jid"; local json = require "util.json"; local util = module:require "util"; local is_admin = util.is_admin; local ROLE_JOINING = module:shared("politictalk/roles/joining"); local ROOM_INACTIVE_CALLBACK_URL = module:get_option_string( "politictalk_room_inactive_callback_url", os.getenv("POLITICTALK_ROOM_INACTIVE_CALLBACK_URL") ); local configured_room_occupancy_callback_url = module:get_option_string( "politictalk_room_occupancy_callback_url", os.getenv("POLITICTALK_ROOM_OCCUPANCY_CALLBACK_URL") ); local ROOM_INACTIVE_CALLBACK_SECRET = module:get_option_string( "politictalk_room_inactive_callback_secret", os.getenv("POLITICTALK_ROOM_INACTIVE_CALLBACK_SECRET") ); local function derive_room_occupancy_callback_url(inactive_url) if not inactive_url or inactive_url == "" then return nil; end local derived_url, replacement_count = inactive_url:gsub("room%-inactive$", "room-occupancy"); if replacement_count > 0 then return derived_url; end return nil; end local ROOM_OCCUPANCY_CALLBACK_URL = configured_room_occupancy_callback_url; if not ROOM_OCCUPANCY_CALLBACK_URL or ROOM_OCCUPANCY_CALLBACK_URL == "" then ROOM_OCCUPANCY_CALLBACK_URL = derive_room_occupancy_callback_url(ROOM_INACTIVE_CALLBACK_URL); if ROOM_OCCUPANCY_CALLBACK_URL then module:log("info", "Derived PoliticTalk occupancy callback URL from inactive callback URL"); end end module:log("info", "Loaded PoliticTalk JWT role enforcement"); local function get_user(session) return session and session.jitsi_meet_context_user or nil; end local function get_user_id(session) local user = get_user(session); return user and user.id or nil; end local function get_participant_limit(session) local user = get_user(session); local limit = user and (user.participantLimit or user.participant_limit) or nil; local numeric_limit = tonumber(limit); if numeric_limit and numeric_limit > 0 then return math.floor(numeric_limit); end return nil; end local function is_moderator_session(session) local user = get_user(session); local moderator = user and user.moderator; return moderator == true or moderator == "true"; end local function table_contains(values, value) if not values or not value then return false; end for _, current in ipairs(values) do if current == value then return true; end end return false; end local function add_unique(values, value) if not value then return values or {}; end values = values or {}; if not table_contains(values, value) then table.insert(values, value); end return values; end local function remove_value(values, value) if not values or not value then return values or {}; end local filtered = {}; for _, current in ipairs(values) do if current ~= value then table.insert(filtered, current); end end return filtered; end local function table_count(values) local count = 0; for _ in ipairs(values or {}) do count = count + 1; end return count; end local function ensure_room_data(room) if not room then return false; end room._data = room._data or {}; room._data.moderators = room._data.moderators or {}; room._data.participants = room._data.participants or {}; room._data.politictalk_host_jids = room._data.politictalk_host_jids or {}; room._data.politictalk_participant_jids = room._data.politictalk_participant_jids or {}; room._data.politictalk_jid_user_ids = room._data.politictalk_jid_user_ids or {}; -- Enables Jitsi AV moderation automatically: hosts can speak, participants -- must be explicitly allowed by a host before they can unmute. room._data.av_can_unmute = false; return true; end local function update_room_role_lists(room, user_id, is_moderator) if not room or not user_id then return; end ensure_room_data(room); if is_moderator then room._data.moderators = add_unique(room._data.moderators, user_id); room._data.participants = remove_value(room._data.participants, user_id); else room._data.participants = add_unique(room._data.participants, user_id); room._data.moderators = remove_value(room._data.moderators, user_id); end module:fire_event("room-metadata-changed", { room = room; }); end local function mark_host(room, occupant, user_id) if not room or not occupant or not occupant.bare_jid or not user_id then return; end ensure_room_data(room); room._data.politictalk_host_jids = add_unique(room._data.politictalk_host_jids, occupant.bare_jid); room._data.politictalk_participant_jids = remove_value(room._data.politictalk_participant_jids, occupant.bare_jid); room._data.politictalk_jid_user_ids[occupant.bare_jid] = user_id; end local function mark_participant(room, occupant, user_id) if not room or not occupant or not occupant.bare_jid or not user_id then return; end ensure_room_data(room); room._data.politictalk_participant_jids = add_unique(room._data.politictalk_participant_jids, occupant.bare_jid); room._data.politictalk_jid_user_ids[occupant.bare_jid] = user_id; end local function unmark_occupant(room, occupant) if not room or not occupant or not occupant.bare_jid then return nil; end ensure_room_data(room); local user_id = room._data.politictalk_jid_user_ids[occupant.bare_jid]; room._data.politictalk_jid_user_ids[occupant.bare_jid] = nil; room._data.politictalk_host_jids = remove_value(room._data.politictalk_host_jids, occupant.bare_jid); room._data.politictalk_participant_jids = remove_value(room._data.politictalk_participant_jids, occupant.bare_jid); return user_id; end local function has_active_host(room) if not room then return false; end ensure_room_data(room); for _, occupant in room:each_occupant() do if table_contains(room._data.politictalk_host_jids, occupant.bare_jid) then return true; end end return false; end local function get_active_host_count(room) if not room then return 0; end ensure_room_data(room); local count = 0; for _, occupant in room:each_occupant() do if table_contains(room._data.politictalk_host_jids, occupant.bare_jid) then count = count + 1; end end return count; end local function get_active_participant_user_ids(room) if not room then return {}; end ensure_room_data(room); local user_ids = {}; for _, occupant in room:each_occupant() do if table_contains(room._data.politictalk_participant_jids, occupant.bare_jid) then user_ids = add_unique(user_ids, room._data.politictalk_jid_user_ids[occupant.bare_jid]); end end return user_ids; end local function get_occupant_count(room) if not room then return 0; end local count = 0; for _ in room:each_occupant() do count = count + 1; end return count; end local function deny_join(event, reason) if event.origin and event.stanza then event.origin.send(st.error_reply(event.stanza, "cancel", "not-allowed", reason)); end return true; end local function get_room_meeting_code(room) if not room or not room.jid then return nil; end return jid.split(room.jid); end local function notify_room_inactive(room, reason) if not ROOM_INACTIVE_CALLBACK_URL or ROOM_INACTIVE_CALLBACK_URL == "" or not ROOM_INACTIVE_CALLBACK_SECRET or ROOM_INACTIVE_CALLBACK_SECRET == "" then module:log("warn", "Skipping PoliticTalk inactive-room callback because callback URL or secret is missing"); return; end local meeting_code = get_room_meeting_code(room); if not meeting_code then module:log("warn", "Skipping PoliticTalk inactive-room callback because room meeting code is missing"); return; end ensure_room_data(room); if room._data.politictalk_inactive_notified then return; end room._data.politictalk_inactive_notified = true; http.request(ROOM_INACTIVE_CALLBACK_URL, { method = "POST"; headers = { ["Content-Type"] = "application/json"; ["X-PoliticTalk-Jitsi-Secret"] = ROOM_INACTIVE_CALLBACK_SECRET; }; body = json.encode({ meetingCode = meeting_code; reason = reason or "host_left"; roomJid = room.jid; }); }, function(_, code) if code and code >= 200 and code < 300 then module:log("info", "Marked PoliticTalk room inactive in PgApi: %s", tostring(meeting_code)); else module:log( "warn", "Failed to mark PoliticTalk room inactive in PgApi: room=%s status=%s", tostring(meeting_code), tostring(code) ); end end); end local function notify_room_occupancy(room, reason) if not ROOM_OCCUPANCY_CALLBACK_URL or ROOM_OCCUPANCY_CALLBACK_URL == "" or not ROOM_INACTIVE_CALLBACK_SECRET or ROOM_INACTIVE_CALLBACK_SECRET == "" then module:log("warn", "Skipping PoliticTalk room-occupancy callback because callback URL or secret is missing"); return; end local meeting_code = get_room_meeting_code(room); if not meeting_code then module:log("warn", "Skipping PoliticTalk room-occupancy callback because room meeting code is missing"); return; end ensure_room_data(room); local participant_count = table_count(room._data.politictalk_participant_jids); local participant_user_ids = get_active_participant_user_ids(room); local moderator_count = table_count(room._data.moderators); local occupant_count = get_occupant_count(room); local host_count = get_active_host_count(room); http.request(ROOM_OCCUPANCY_CALLBACK_URL, { method = "POST"; headers = { ["Content-Type"] = "application/json"; ["X-PoliticTalk-Jitsi-Secret"] = ROOM_INACTIVE_CALLBACK_SECRET; }; body = json.encode({ meetingCode = meeting_code; reason = reason or "occupancy_changed"; roomJid = room.jid; participantCount = participant_count; participantUserIds = participant_user_ids; moderatorCount = moderator_count; occupantCount = occupant_count; hostCount = host_count; }); }, function(_, code) if code and code >= 200 and code < 300 then module:log( "info", "Updated PoliticTalk room occupancy in PgApi: room=%s participants=%s occupants=%s hosts=%s", tostring(meeting_code), tostring(participant_count), tostring(occupant_count), tostring(host_count) ); else module:log( "warn", "Failed to update PoliticTalk room occupancy in PgApi: room=%s status=%s", tostring(meeting_code), tostring(code) ); end end); end module:hook("muc-room-pre-create", function(event) if event.stanza and event.stanza.attr and is_admin(event.stanza.attr.from) then return; end if is_moderator_session(event.origin) then return; end module:log("warn", "Blocking non-host from creating PoliticTalk room: %s", tostring(event.stanza and event.stanza.attr.to)); return deny_join(event, "PoliticTalk room is waiting for the host"); end, 90); module:hook("muc-occupant-pre-join", function(event) local session = event.origin; local room = event.room; local occupant = event.occupant; local user_id = get_user_id(session); local is_moderator = is_moderator_session(session); local occupant_jid = occupant and occupant.bare_jid; if occupant_jid and is_admin(occupant_jid) then return; end if not occupant_jid or not user_id then module:log("warn", "Blocking participant without PoliticTalk JWT user id from room: %s", tostring(room and room.jid)); return deny_join(event, "PoliticTalk identity is required"); end if not is_moderator and not has_active_host(room) then module:log("warn", "Blocking participant %s from room without host: %s", tostring(user_id), tostring(room and room.jid)); return deny_join(event, "PoliticTalk room is waiting for the host"); end if not is_moderator then ensure_room_data(room); local participant_limit = get_participant_limit(session); local active_participant_count = table_count(room._data.politictalk_participant_jids); local active_room_occupancy_count = active_participant_count; if participant_limit and active_room_occupancy_count >= participant_limit then module:log( "warn", "Blocking participant %s because PoliticTalk room is full: room=%s active=%s participantSeats=%s limit=%s", tostring(user_id), tostring(room and room.jid), tostring(active_room_occupancy_count), tostring(active_participant_count), tostring(participant_limit) ); return deny_join(event, "PoliticTalk room is full"); end end ROLE_JOINING[occupant_jid] = { is_moderator = is_moderator; user_id = user_id; }; update_room_role_lists(room, user_id, is_moderator); end, 80); module:hook("muc-occupant-joined", function(event) local room = event.room; local occupant = event.occupant; local occupant_jid = occupant and occupant.bare_jid; local role = occupant_jid and ROLE_JOINING[occupant_jid] or nil; if occupant_jid then ROLE_JOINING[occupant_jid] = nil; end if not role then return; end if role.is_moderator then mark_host(room, occupant, role.user_id); update_room_role_lists(room, role.user_id, true); room:set_affiliation(true, occupant.bare_jid, "owner"); module:log( "info", "PoliticTalk host joined as moderator with AV moderation: %s room=%s", tostring(role.user_id), tostring(room.jid) ); else mark_participant(room, occupant, role.user_id); update_room_role_lists(room, role.user_id, false); room:set_affiliation(true, occupant.bare_jid, "member"); module:log("info", "PoliticTalk participant joined as member: %s room=%s", tostring(role.user_id), tostring(room.jid)); end notify_room_occupancy(room, "occupant_joined"); end, 1); module:hook("muc-occupant-left", function(event) local room = event.room; local occupant = event.occupant; local was_host = room and occupant and room._data and table_contains(room._data.politictalk_host_jids, occupant.bare_jid); local user_id = unmark_occupant(room, occupant) or get_user_id(event.origin); if room and user_id then ensure_room_data(room); room._data.moderators = remove_value(room._data.moderators, user_id); room._data.participants = remove_value(room._data.participants, user_id); end if was_host and not has_active_host(room) then module:log("info", "Destroying PoliticTalk room after host left: %s", tostring(room.jid)); notify_room_inactive(room, "host_left"); room:destroy(nil, "The host has left the PoliticTalk room"); else notify_room_occupancy(room, "occupant_left"); end end, -20); module:hook("muc-room-destroyed", function(event) local room = event.room; if room and room._data and room._data.politictalk_host_jids then notify_room_inactive(room, "room_destroyed"); end end, -30);