662 lines
20 KiB
Lua
662 lines
20 KiB
Lua
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 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 {};
|
|
-- 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;
|
|
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");
|
|
remove_duplicate_user_occupants(room, occupant, role.user_id);
|
|
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");
|
|
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);
|