Files
jitsi-meet/resources/prosody-plugins/mod_muc_breakout_rooms.lua
damencho f4c61e4760 fix(prosody): Order room-destroyed event.
Make sure we execute before prosody cleans it up from the list of room. If we try to look it up after that we will not find it. If we also add at 0 we cannot guarantee the order of hook execution.
2025-11-14 14:01:12 -06:00

670 lines
23 KiB
Lua

-- This module is added under the main virtual host domain
-- It needs a breakout rooms muc component
--
-- VirtualHost "jitmeet.example.com"
-- modules_enabled = {
-- "muc_breakout_rooms"
-- }
-- breakout_rooms_muc = "breakout.jitmeet.example.com"
-- main_muc = "muc.jitmeet.example.com"
--
-- Component "breakout.jitmeet.example.com" "muc"
-- restrict_room_creation = true
-- storage = "memory"
-- admins = { "focusUser@auth.jitmeet.example.com" }
-- muc_room_locking = false
-- muc_room_default_public_jids = true
--
module:depends('room_destroy');
-- we use async to detect Prosody 0.10 and earlier
local have_async = pcall(require, 'util.async');
if not have_async then
module:log('warn', 'Breakout rooms will not work with Prosody version 0.10 or less.');
return;
end
local jid_node = require 'util.jid'.node;
local jid_host = require 'util.jid'.host;
local jid_split = require 'util.jid'.split;
local json = require 'cjson.safe';
local st = require 'util.stanza';
local uuid_gen = require 'util.uuid'.generate;
local util = module:require 'util';
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local is_healthcheck_room = util.is_healthcheck_room;
local process_host_module = util.process_host_module;
local BREAKOUT_ROOMS_IDENTITY_TYPE = 'breakout_rooms';
-- Available breakout room functionality
local RENAME_FEATURE = 'http://jitsi.org/protocol/breakout_rooms#rename';
-- only send at most this often updates on breakout rooms to avoid flooding.
local BROADCAST_ROOMS_INTERVAL = .3;
-- close conference after this amount of seconds if all leave.
local ROOMS_TTL_IF_ALL_LEFT = 5;
local JSON_TYPE_ADD_BREAKOUT_ROOM = 'features/breakout-rooms/add';
local JSON_TYPE_MOVE_TO_ROOM_REQUEST = 'features/breakout-rooms/move-to-room';
local JSON_TYPE_REMOVE_BREAKOUT_ROOM = 'features/breakout-rooms/remove';
local JSON_TYPE_RENAME_BREAKOUT_ROOM = 'features/breakout-rooms/rename';
local JSON_TYPE_UPDATE_BREAKOUT_ROOMS = 'features/breakout-rooms/update';
local main_muc_component_config = module:get_option_string('main_muc');
if main_muc_component_config == nil then
module:log('error', 'breakout rooms not enabled missing main_muc config');
return ;
end
local breakout_rooms_muc_component_config = module:get_option_string('breakout_rooms_muc', 'breakout.'..module.host);
module:depends('jitsi_session');
local breakout_rooms_muc_service;
local main_muc_service;
-- Maps a breakout room jid to the main room jid
local main_rooms_map = {};
-- Utility functions
function get_main_room_jid(room_jid)
local _, host = jid_split(room_jid);
return
host == main_muc_component_config
and room_jid
or main_rooms_map[room_jid];
end
function get_main_room(room_jid)
local main_room_jid = get_main_room_jid(room_jid);
return main_muc_service.get_room_from_jid(main_room_jid), main_room_jid;
end
function get_room_from_jid(room_jid)
local host = jid_host(room_jid);
return
host == main_muc_component_config
and main_muc_service.get_room_from_jid(room_jid)
or breakout_rooms_muc_service.get_room_from_jid(room_jid);
end
function send_json_msg(to_jid, json_msg)
local stanza = st.message({ from = breakout_rooms_muc_component_config; to = to_jid; })
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }):text(json_msg):up();
module:send(stanza);
end
function get_participants(room)
local participants = {};
if room then
for room_nick, occupant in room:each_occupant() do
-- Filter focus as we keep it as a hidden participant
if jid_node(occupant.jid) ~= 'focus' then
local display_name = occupant:get_presence():get_child_text(
'nick', 'http://jabber.org/protocol/nick');
local real_nick = internal_room_jid_match_rewrite(room_nick);
participants[real_nick] = {
jid = occupant.jid,
role = occupant.role,
displayName = display_name
};
end
end
end
return participants;
end
function broadcast_breakout_rooms(room_jid)
local main_room = get_main_room(room_jid);
if not main_room or main_room.broadcast_timer then
return;
end
-- Only send each BROADCAST_ROOMS_INTERVAL seconds to prevent flooding of messages.
main_room.broadcast_timer = module:add_timer(BROADCAST_ROOMS_INTERVAL, function()
local main_room, main_room_jid = get_main_room(room_jid);
if not main_room then
return;
end
main_room.broadcast_timer = nil;
local real_jid = internal_room_jid_match_rewrite(main_room_jid);
local real_node = jid_node(real_jid);
local rooms = {
[real_node] = {
isMainRoom = true,
id = real_node,
jid = real_jid,
name = main_room._data.subject,
participants = get_participants(main_room)
};
}
for breakout_room_jid, subject in pairs(main_room._data.breakout_rooms or {}) do
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
local breakout_room_node = jid_node(breakout_room_jid)
rooms[breakout_room_node] = {
id = breakout_room_node,
jid = breakout_room_jid,
name = subject,
participants = {}
}
-- The room may not physically exist yet.
if breakout_room then
rooms[breakout_room_node].participants = get_participants(breakout_room);
end
end
local json_msg, error = json.encode({
type = BREAKOUT_ROOMS_IDENTITY_TYPE,
event = JSON_TYPE_UPDATE_BREAKOUT_ROOMS,
roomCounter = main_room._data.breakout_rooms_counter,
rooms = rooms
});
if not json_msg then
module:log('error', 'not broadcasting breakout room information room:%s error:%s', main_room_jid, error);
return;
end
for _, occupant in main_room:each_occupant() do
if jid_node(occupant.jid) ~= 'focus' then
send_json_msg(occupant.jid, json_msg)
end
end
for breakout_room_jid in pairs(main_room._data.breakout_rooms or {}) do
local room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
if room then
for _, occupant in room:each_occupant() do
if jid_node(occupant.jid) ~= 'focus' then
send_json_msg(occupant.jid, json_msg)
end
end
end
end
end);
end
-- Managing breakout rooms
function create_breakout_room(orig_room, subject)
local main_room, main_room_jid = get_main_room(orig_room.jid);
if orig_room ~= main_room then
module:log('warn', 'Invalid create breakout room request for %s', orig_room.jid);
return;
end
local breakout_room_jid = uuid_gen() .. '@' .. breakout_rooms_muc_component_config;
if not main_room._data.breakout_rooms then
main_room._data.breakout_rooms = {};
main_room._data.breakout_rooms_counter = 0;
end
main_room._data.breakout_rooms_counter = main_room._data.breakout_rooms_counter + 1;
main_room._data.breakout_rooms[breakout_room_jid] = subject;
main_room._data.breakout_rooms_active = true;
-- Make room persistent - not to be destroyed - if all participants join breakout rooms.
main_room:set_persistent(true);
main_room:save(true);
main_rooms_map[breakout_room_jid] = main_room_jid;
broadcast_breakout_rooms(main_room_jid);
end
function destroy_breakout_room(orig_room, room_jid, message)
local main_room, main_room_jid = get_main_room(room_jid);
if room_jid == main_room_jid then
return;
end
if orig_room ~= main_room then
module:log('warn', 'Invalid destroy breakout room request for %s', orig_room.jid);
return;
end
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(room_jid);
if breakout_room then
message = message or 'Breakout room removed.';
breakout_room:destroy(main_room and main_room_jid or nil, message);
end
if main_room then
if main_room._data.breakout_rooms then
main_room._data.breakout_rooms[room_jid] = nil;
end
main_room:save(true);
main_rooms_map[room_jid] = nil;
broadcast_breakout_rooms(main_room_jid);
end
end
function rename_breakout_room(orig_room, room_jid, name)
local main_room, main_room_jid = get_main_room(room_jid);
if room_jid == main_room_jid then
return;
end
if orig_room ~= main_room then
module:log('warn', 'Invalid rename breakout room request for %s', orig_room.jid);
return;
end
if main_room then
if main_room._data.breakout_rooms then
main_room._data.breakout_rooms[room_jid] = name;
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(room_jid);
if breakout_room then
breakout_room:set_subject(breakout_room.jid, name);
end
end
main_room:save(true);
broadcast_breakout_rooms(main_room_jid);
end
end
-- Handling events
function on_message(event)
local session = event.origin;
-- Check the type of the incoming stanza to avoid loops:
if event.stanza.attr.type == 'error' then
return; -- We do not want to reply to these, so leave.
end
if not session or not session.jitsi_web_query_room then
return false;
end
local message = event.stanza:get_child(BREAKOUT_ROOMS_IDENTITY_TYPE);
if not message then
return false;
end
-- get room name with tenant and find room
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if not room then
module:log('warn', 'No room found for %s/%s',
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
return false;
end
-- check that the participant requesting is a moderator and is an occupant in the room
local from = event.stanza.attr.from;
local occupant = room:get_occupant_by_real_jid(from);
if not occupant then
-- Check if the participant is in any breakout room.
for breakout_room_jid in pairs(room._data.breakout_rooms or {}) do
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
if breakout_room then
occupant = breakout_room:get_occupant_by_real_jid(from);
if occupant then
break;
end
end
end
if not occupant then
module:log('warn', 'No occupant %s found for %s', from, room.jid);
return false;
end
end
if occupant.role ~= 'moderator' then
module:log('warn', 'Occupant %s is not moderator and not allowed this operation for %s', from, room.jid);
return false;
end
if message.attr.type == JSON_TYPE_ADD_BREAKOUT_ROOM then
create_breakout_room(room, message.attr.subject);
return true;
elseif message.attr.type == JSON_TYPE_REMOVE_BREAKOUT_ROOM then
destroy_breakout_room(room, message.attr.breakoutRoomJid);
return true;
elseif message.attr.type == JSON_TYPE_RENAME_BREAKOUT_ROOM then
rename_breakout_room(room, message.attr.breakoutRoomJid, message.attr.subject);
return true;
elseif message.attr.type == JSON_TYPE_MOVE_TO_ROOM_REQUEST then
local participant_jid = message.attr.participantJid;
local target_room_jid = message.attr.roomJid;
if not room._data.breakout_rooms or not (
room._data.breakout_rooms[target_room_jid] or target_room_jid == internal_room_jid_match_rewrite(room.jid))
then
module:log('warn', 'Invalid breakout room %s for %s', target_room_jid, room.jid);
return false
end
local json_msg, error = json.encode({
type = BREAKOUT_ROOMS_IDENTITY_TYPE,
event = JSON_TYPE_MOVE_TO_ROOM_REQUEST,
roomJid = target_room_jid
});
if not json_msg then
module:log('error', 'skip sending request room:%s error:%s', room.jid, error);
return false
end
send_json_msg(participant_jid, json_msg)
return true;
end
-- return error.
return false;
end
function on_breakout_room_pre_create(event)
local breakout_room = event.room;
local main_room, main_room_jid = get_main_room(breakout_room.jid);
-- Only allow existent breakout rooms to be started.
-- Authorisation of breakout rooms is done by their random uuid name
if main_room and main_room._data.breakout_rooms and main_room._data.breakout_rooms[breakout_room.jid] then
breakout_room:set_subject(breakout_room.jid, main_room._data.breakout_rooms[breakout_room.jid]);
else
module:log('debug', 'Invalid breakout room %s will not be created.', breakout_room.jid);
breakout_room:destroy(main_room_jid, 'Breakout room is invalid.');
return true;
end
end
function on_occupant_joined(event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return;
end
local main_room, main_room_jid = get_main_room(room.jid);
if main_room and main_room._data.breakout_rooms_active then
if jid_node(event.occupant.jid) ~= 'focus' then
broadcast_breakout_rooms(main_room_jid);
end
-- Prevent closing all rooms if a participant has joined (see on_occupant_left).
if main_room.close_timer then
main_room.close_timer:stop();
main_room.close_timer = nil;
end
end
end
function exist_occupants_in_room(room)
if not room then
return false;
end
for _, occupant in room:each_occupant() do
if jid_node(occupant.jid) ~= 'focus' then
return true;
end
end
return false;
end
function exist_occupants_in_rooms(main_room)
if exist_occupants_in_room(main_room) then
return true;
end
for breakout_room_jid in pairs(main_room._data.breakout_rooms or {}) do
local room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
if exist_occupants_in_room(room) then
return true;
end
end
return false;
end
function on_occupant_pre_leave(event)
local room, occupant, session, stanza = event.room, event.occupant, event.origin, event.stanza;
local main_room = get_main_room(room.jid);
prosody.events.fire_event('jitsi-breakout-occupant-leaving', {
room = room; main_room = main_room; occupant = occupant; stanza = stanza; session = session;
});
end
function on_occupant_left(event)
local room_jid = event.room.jid;
if is_healthcheck_room(room_jid) then
return;
end
local main_room, main_room_jid = get_main_room(room_jid);
if not main_room then
return;
end
if main_room._data.breakout_rooms_active and jid_node(event.occupant.jid) ~= 'focus' then
broadcast_breakout_rooms(main_room_jid);
end
-- Close the conference if all left for good.
if main_room._data.breakout_rooms_active and not main_room.close_timer and not exist_occupants_in_rooms(main_room) then
main_room.close_timer = module:add_timer(ROOMS_TTL_IF_ALL_LEFT, function()
-- we need to look up again the room as till the timer is fired, the room maybe already destroyed/recreated
-- and we will have the old instance
local main_room, main_room_jid = get_main_room(room_jid);
if main_room and main_room.close_timer then
prosody.events.fire_event("maybe-destroy-room", {
room = main_room;
reason = 'All occupants left.';
caller = module:get_name();
});
end
end);
end
end
-- Stop other modules from destroying room if breakout rooms not empty
function handle_maybe_destroy_main_room(event)
local main_room = event.room;
local caller = event.caller;
if caller == module:get_name() then
-- we were the one that requested the deletion. Do not override.
return nil; -- stop room destruction
end
-- deletion was requested by another module. Check for break room occupants.
for breakout_room_jid, _ in pairs(main_room._data.breakout_rooms or {}) do
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
if breakout_room and breakout_room:has_occupant() then
module:log('info', 'Suppressing room destroy. Breakout room still occupied %s', breakout_room_jid);
return true; -- stop room destruction
end
end
end
module:hook_global("maybe-destroy-room", handle_maybe_destroy_main_room)
function on_main_room_destroyed(event)
local main_room = event.room;
if is_healthcheck_room(main_room.jid) then
return;
end
for breakout_room_jid in pairs(main_room._data.breakout_rooms or {}) do
destroy_breakout_room(main_room, breakout_room_jid, event.reason)
end
end
-- Module operations
-- operates on already loaded breakout rooms muc module
function process_breakout_rooms_muc_loaded(breakout_rooms_muc, host_module)
module:log('debug', 'Breakout rooms muc loaded');
-- Advertise the breakout rooms component so clients can pick up the address and use it
module:add_identity('component', BREAKOUT_ROOMS_IDENTITY_TYPE, breakout_rooms_muc_component_config);
-- Tag the disco#info response with available features of breakout rooms.
host_module:hook('host-disco-info-node', function (event)
local session, reply, node = event.origin, event.reply, event.node;
if node == BREAKOUT_ROOMS_IDENTITY_TYPE and session.jitsi_web_query_room then
reply:tag('feature', { var = RENAME_FEATURE }):up();
end
event.exists = true;
end);
breakout_rooms_muc_service = breakout_rooms_muc;
module:log("info", "Hook to muc events on %s", breakout_rooms_muc_component_config);
host_module:hook('message/host', on_message);
host_module:hook('muc-occupant-joined', on_occupant_joined);
host_module:hook('muc-occupant-left', on_occupant_left);
host_module:hook('muc-room-pre-create', on_breakout_room_pre_create);
host_module:hook('muc-occupant-pre-leave', on_occupant_pre_leave);
host_module:hook('muc-disco#info', function (event)
local room = event.room;
local main_room, main_room_jid = get_main_room(room.jid);
-- Breakout room metadata.
table.insert(event.form, {
name = 'muc#roominfo_isbreakout';
label = 'Is this a breakout room?';
type = "boolean";
});
event.formdata['muc#roominfo_isbreakout'] = true;
table.insert(event.form, {
name = 'muc#roominfo_breakout_main_room';
label = 'The main room associated with this breakout room';
});
event.formdata['muc#roominfo_breakout_main_room'] = internal_room_jid_match_rewrite(main_room_jid);
-- If the main room has a lobby, make it so this breakout room also uses it.
if (main_room and main_room._data.lobbyroom and main_room:get_members_only()) then
table.insert(event.form, {
name = 'muc#roominfo_lobbyroom';
label = 'Lobby room jid';
});
event.formdata['muc#roominfo_lobbyroom'] = main_room._data.lobbyroom;
end
end);
host_module:hook("muc-config-form", function(event)
local room = event.room;
local _, main_room_jid = get_main_room(room.jid);
-- Breakout room metadata.
table.insert(event.form, {
name = 'muc#roominfo_isbreakout';
label = 'Is this a breakout room?';
type = "boolean";
value = true;
});
table.insert(event.form, {
name = 'muc#roominfo_breakout_main_room';
label = 'The main room associated with this breakout room';
value = internal_room_jid_match_rewrite(main_room_jid);
});
end);
local room_mt = breakout_rooms_muc_service.room_mt;
room_mt.get_members_only = function(room)
local main_room = get_main_room(room.jid);
if not main_room then
module:log('error', 'No main room (%s)!', room.jid);
return false;
end
return main_room.get_members_only(main_room)
end
-- we base affiliations (roles) in breakout rooms muc component to be based on the roles in the main muc
room_mt.get_affiliation = function(room, jid)
local main_room, _ = get_main_room(room.jid);
if not main_room then
module:log('error', 'No main room(%s) for %s!', room.jid, jid);
return 'none';
end
-- moderators in main room are moderators here
local role = main_room.get_affiliation(main_room, jid);
if role then
return role;
end
return 'none';
end
end
-- process or waits to process the breakout rooms muc component
process_host_module(breakout_rooms_muc_component_config, function(host_module, host)
module:log('info', 'Breakout rooms component created %s', host);
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
process_breakout_rooms_muc_loaded(muc_module, host_module);
else
module:log('debug', 'Will wait for muc to be available');
prosody.hosts[host].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
process_breakout_rooms_muc_loaded(prosody.hosts[host].modules.muc, host_module);
end
end);
end
end);
-- operates on already loaded main muc module
function process_main_muc_loaded(main_muc, host_module)
module:log('debug', 'Main muc loaded');
main_muc_service = main_muc;
module:log("info", "Hook to muc events on %s", main_muc_component_config);
host_module:hook('muc-occupant-joined', on_occupant_joined);
host_module:hook('muc-occupant-left', on_occupant_left);
host_module:hook('muc-room-destroyed', on_main_room_destroyed, 1); -- prosody handles it at 0
end
-- process or waits to process the main muc component
process_host_module(main_muc_component_config, function(host_module, host)
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
process_main_muc_loaded(muc_module, host_module);
else
module:log('debug', 'Will wait for muc to be available');
prosody.hosts[host].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
process_main_muc_loaded(prosody.hosts[host].modules.muc, host_module);
end
end);
end
end);