local st = require "util.stanza"; local http = require "net.http"; local jid = require "util.jid"; local json = require "util.json"; local array = require "util.array"; local util = module:require "util"; local is_admin = util.is_admin; local is_focus = util.is_focus; local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite; 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 configured_host_reconnect_grace_seconds = module:get_option_string( "politictalk_host_reconnect_grace_seconds", os.getenv("POLITICTALK_HOST_RECONNECT_GRACE_SECONDS") ); local HOST_RECONNECT_GRACE_SECONDS = tonumber(configured_host_reconnect_grace_seconds) or 60; if HOST_RECONNECT_GRACE_SECONDS < 5 then HOST_RECONNECT_GRACE_SECONDS = 5; end 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 {}; return true; end local function enable_audio_moderation_only(room, actor_occupant) if not room or not actor_occupant then return; end -- Avoid Jitsi's av_can_unmute shortcut because it enables audio, video, and desktop moderation together. room.av_moderation = room.av_moderation or {}; room.av_moderation_actors = room.av_moderation_actors or {}; if room.av_moderation.audio then room.av_moderation.video = nil; room.av_moderation.desktop = nil; room.av_moderation_actors.video = nil; room.av_moderation_actors.desktop = nil; return; end room.av_moderation.audio = array(); room.av_moderation.video = nil; room.av_moderation.desktop = nil; for _, room_occupant in room:each_occupant() do if room:get_role(room_occupant.nick) == "moderator" and not is_focus(room_occupant.nick) then room.av_moderation.audio:push(internal_room_jid_match_rewrite(room_occupant.nick)); end end room.jitsiMetadata = room.jitsiMetadata or {}; room.jitsiMetadata.startMuted = room.jitsiMetadata.startMuted or {}; room.av_moderation_startMuted_restore = room.av_moderation_startMuted_restore or {}; room.av_moderation_startMuted_restore.audio = room.jitsiMetadata.startMuted.audio; room.jitsiMetadata.startMuted.audio = true; room.av_moderation_actors.audio = actor_occupant.nick; room.av_moderation_actors.video = nil; room.av_moderation_actors.desktop = nil; 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; room._data.politictalk_host_left_destroy_token = nil; room._data.politictalk_host_left_destroy_at = nil; 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 rebuild_room_role_lists(room) if not room then return; end ensure_room_data(room); local moderators = {}; local participants = {}; for _, occupant in room:each_occupant() do local occupant_jid = occupant and occupant.bare_jid; local user_id = occupant_jid and room._data.politictalk_jid_user_ids[occupant_jid] or nil; if user_id and table_contains(room._data.politictalk_host_jids, occupant_jid) then moderators = add_unique(moderators, user_id); elseif user_id and table_contains(room._data.politictalk_participant_jids, occupant_jid) then participants = add_unique(participants, user_id); end end room._data.moderators = moderators; room._data.participants = participants; module:fire_event("room-metadata-changed", { room = room; }); end local function remove_duplicate_user_occupants(room, current_occupant, user_id) if not room or not current_occupant or not current_occupant.bare_jid or not user_id then return 0; end ensure_room_data(room); local duplicate_nicks = {}; for _, occupant in room:each_occupant() do local occupant_jid = occupant and occupant.bare_jid; local occupant_user_id = occupant_jid and room._data.politictalk_jid_user_ids[occupant_jid] or nil; if occupant and occupant.nick and occupant_jid and occupant_jid ~= current_occupant.bare_jid and occupant_user_id == user_id then table.insert(duplicate_nicks, occupant.nick); end end local removed_count = 0; for _, duplicate_nick in ipairs(duplicate_nicks) do local ok, err = room:set_role(true, duplicate_nick, nil, "Another PoliticTalk session was opened for this account"); if ok == false then module:log( "warn", "Failed to remove duplicate PoliticTalk occupant: room=%s user=%s nick=%s error=%s", tostring(room.jid), tostring(user_id), tostring(duplicate_nick), tostring(err) ); else removed_count = removed_count + 1; end end if removed_count > 0 then rebuild_room_role_lists(room); module:log( "info", "Removed duplicate PoliticTalk occupants: room=%s user=%s count=%s", tostring(room.jid), tostring(user_id), tostring(removed_count) ); end return removed_count; 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_user_ids = get_active_participant_user_ids(room); local participant_count = table_count(participant_user_ids); 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 local function schedule_room_destroy_after_host_left(room) if not room then return; end ensure_room_data(room); local token = (room._data.politictalk_host_left_destroy_token or 0) + 1; room._data.politictalk_host_left_destroy_token = token; room._data.politictalk_host_left_destroy_at = os.time() + HOST_RECONNECT_GRACE_SECONDS; module:log( "info", "Scheduling PoliticTalk room destroy after host reconnect grace: room=%s grace=%ss token=%s", tostring(room.jid), tostring(HOST_RECONNECT_GRACE_SECONDS), tostring(token) ); notify_room_occupancy(room, "host_reconnect_pending"); module:add_timer(HOST_RECONNECT_GRACE_SECONDS, function() if not room or not room._data then return; end ensure_room_data(room); if room._data.politictalk_host_left_destroy_token ~= token then return; end if has_active_host(room) then room._data.politictalk_host_left_destroy_token = nil; room._data.politictalk_host_left_destroy_at = nil; notify_room_occupancy(room, "host_reconnected"); return; end room._data.politictalk_host_left_destroy_token = nil; room._data.politictalk_host_left_destroy_at = nil; module:log("info", "Destroying PoliticTalk room after host reconnect grace expired: %s", tostring(room.jid)); notify_room_inactive(room, "host_left"); room:destroy(nil, "The host has left the PoliticTalk room"); 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_user_ids = get_active_participant_user_ids(room); local active_participant_count = table_count(active_participant_user_ids); local active_room_occupancy_count = active_participant_count; if participant_limit and not table_contains(active_participant_user_ids, user_id) and active_room_occupancy_count >= participant_limit then module:log( "warn", "Blocking participant %s because PoliticTalk room is full: room=%s active=%s uniqueParticipants=%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"); enable_audio_moderation_only(room, occupant); remove_duplicate_user_occupants(room, occupant, role.user_id); module:log( "info", "PoliticTalk host joined as moderator with audio 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"); remove_duplicate_user_occupants(room, occupant, role.user_id); 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); rebuild_room_role_lists(room); end if was_host and not has_active_host(room) then schedule_room_destroy_after_host_left(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 room._data.politictalk_host_left_destroy_token = nil; room._data.politictalk_host_left_destroy_at = nil; notify_room_inactive(room, "room_destroyed"); end end, -30);