Files
jitsi-meet/resources/prosody-plugins/mod_speakerstats_component.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

385 lines
14 KiB
Lua

local util = module:require "util";
local is_admin = util.is_admin;
local get_room_from_jid = util.get_room_from_jid;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local is_jibri = util.is_jibri;
local is_healthcheck_room = util.is_healthcheck_room;
local process_host_module = util.process_host_module;
local is_transcriber_jigasi = util.is_transcriber_jigasi;
local jid_resource = require "util.jid".resource;
local st = require "util.stanza";
local socket = require "socket";
local json = require 'cjson.safe';
local jid_split = require 'util.jid'.split;
-- 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", "speaker stats will not work with Prosody version 0.10 or less.");
return;
end
local muc_component_host = module:get_option_string("muc_component");
local main_virtual_host = module:get_option_string("muc_mapper_domain_base");
if muc_component_host == nil or main_virtual_host == nil then
module:log("error", "No muc_component specified. No muc to operate on!");
return;
end
local breakout_room_component_host = "breakout." .. main_virtual_host;
module:log("info", "Starting speakerstats for %s", muc_component_host);
local main_muc_service;
-- Searches all rooms in the main muc component that holds a breakout room
-- caches it if found so we don't search it again
-- we should not cache objects in _data as this is being serialized when calling room:save()
local function get_main_room(breakout_room)
if breakout_room.main_room then
return breakout_room.main_room;
end
-- let's search all rooms to find the main room
for room in main_muc_service.each_room() do
if room._data and room._data.breakout_rooms_active and room._data.breakout_rooms[breakout_room.jid] then
breakout_room.main_room = room;
return room;
end
end
end
-- receives messages from client currently connected to the room
-- clients indicates their own dominant speaker events
function on_message(event)
-- 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
local speakerStats
= event.stanza:get_child('speakerstats', 'http://jitsi.org/jitmeet');
if speakerStats then
local roomAddress = speakerStats.attr.room;
local silence = speakerStats.attr.silence == 'true';
local room = get_room_from_jid(room_jid_match_rewrite(roomAddress));
if not room then
module:log("warn", "No room found %s", roomAddress);
return false;
end
if not room.speakerStats then
module:log("warn", "No speakerStats found for %s", roomAddress);
return false;
end
local roomSpeakerStats = room.speakerStats;
local from = event.stanza.attr.from;
local occupant = room:get_occupant_by_real_jid(from);
if not occupant then
module:log("warn", "No occupant %s found for %s", from, roomAddress);
return false;
end
local newDominantSpeaker = roomSpeakerStats[occupant.jid];
local oldDominantSpeakerId = roomSpeakerStats['dominantSpeakerId'];
if oldDominantSpeakerId and occupant.jid ~= oldDominantSpeakerId then
local oldDominantSpeaker = roomSpeakerStats[oldDominantSpeakerId];
if oldDominantSpeaker then
oldDominantSpeaker:setDominantSpeaker(false, false);
end
end
if newDominantSpeaker then
newDominantSpeaker:setDominantSpeaker(true, silence);
end
room.speakerStats['dominantSpeakerId'] = occupant.jid;
end
local newFaceLandmarks = event.stanza:get_child('faceLandmarks', 'http://jitsi.org/jitmeet');
if newFaceLandmarks then
local roomAddress = newFaceLandmarks.attr.room;
local room = get_room_from_jid(room_jid_match_rewrite(roomAddress));
if not room then
module:log("warn", "No room found %s", roomAddress);
return false;
end
if not room.speakerStats then
module:log("warn", "No speakerStats found for %s", roomAddress);
return false;
end
local from = event.stanza.attr.from;
local occupant = room:get_occupant_by_real_jid(from);
if not occupant or not room.speakerStats[occupant.jid] then
module:log("warn", "No occupant %s found for %s", from, roomAddress);
return false;
end
local faceLandmarks = room.speakerStats[occupant.jid].faceLandmarks;
table.insert(faceLandmarks,
{
faceExpression = newFaceLandmarks.attr.faceExpression,
timestamp = tonumber(newFaceLandmarks.attr.timestamp),
duration = tonumber(newFaceLandmarks.attr.duration),
})
end
return true
end
--- Start SpeakerStats implementation
local SpeakerStats = {};
SpeakerStats.__index = SpeakerStats;
function new_SpeakerStats(nick, context_user)
return setmetatable({
totalDominantSpeakerTime = 0;
_dominantSpeakerStart = 0;
_isSilent = false;
_isDominantSpeaker = false;
nick = nick;
context_user = context_user;
displayName = nil;
faceLandmarks = {};
}, SpeakerStats);
end
-- Changes the dominantSpeaker data for current occupant
-- saves start time if it is new dominat speaker
-- or calculates and accumulates time of speaking
function SpeakerStats:setDominantSpeaker(isNowDominantSpeaker, silence)
-- module:log("debug", "set isDominant %s for %s", tostring(isNowDominantSpeaker), self.nick);
local now = socket.gettime()*1000;
if not self:isDominantSpeaker() and isNowDominantSpeaker and not silence then
self._dominantSpeakerStart = now;
elseif self:isDominantSpeaker() then
if not isNowDominantSpeaker then
if not self._isSilent then
local timeElapsed = math.floor(now - self._dominantSpeakerStart);
self.totalDominantSpeakerTime = self.totalDominantSpeakerTime + timeElapsed;
self._dominantSpeakerStart = 0;
end
elseif self._isSilent and not silence then
self._dominantSpeakerStart = now;
elseif not self._isSilent and silence then
local timeElapsed = math.floor(now - self._dominantSpeakerStart);
self.totalDominantSpeakerTime = self.totalDominantSpeakerTime + timeElapsed;
self._dominantSpeakerStart = 0;
end
end
self._isDominantSpeaker = isNowDominantSpeaker;
self._isSilent = silence;
end
-- Returns true if the tracked user is currently a dominant speaker.
function SpeakerStats:isDominantSpeaker()
return self._isDominantSpeaker;
end
-- Returns true if the tracked user is currently silent.
function SpeakerStats:isSilent()
return self._isSilent;
end
--- End SpeakerStats
-- create speakerStats for the room
function room_created(event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return ;
end
room.speakerStats = {};
room.speakerStats.sessionId = room._data.meetingId;
end
-- create speakerStats for the breakout
function breakout_room_created(event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return ;
end
local main_room = get_main_room(room);
room.speakerStats = {};
room.speakerStats.isBreakout = true
room.speakerStats.breakoutRoomId = jid_split(room.jid)
room.speakerStats.sessionId = main_room._data.meetingId;
end
-- Create SpeakerStats object for the joined user
function occupant_joined(event)
local occupant, room, stanza = event.occupant, event.room, event.stanza;
if is_healthcheck_room(room.jid)
or is_admin(occupant.bare_jid)
or is_transcriber_jigasi(stanza)
or is_jibri(occupant) then
return;
end
local nick = jid_resource(occupant.nick);
if room.speakerStats then
-- lets send the current speaker stats to that user, so he can update
-- its local stats
if next(room.speakerStats) ~= nil then
local users_json = {};
for jid, values in pairs(room.speakerStats) do
-- skip reporting those without a nick('dominantSpeakerId')
-- and skip focus if sneaked into the table
if values and type(values) == 'table' and values.nick ~= nil and values.nick ~= 'focus' then
local totalDominantSpeakerTime = values.totalDominantSpeakerTime;
local faceLandmarks = values.faceLandmarks;
if totalDominantSpeakerTime > 0 or room:get_occupant_jid(jid) == nil or values:isDominantSpeaker()
or next(faceLandmarks) ~= nil then
-- before sending we need to calculate current dominant speaker state
if values:isDominantSpeaker() and not values:isSilent() then
local timeElapsed = math.floor(socket.gettime()*1000 - values._dominantSpeakerStart);
totalDominantSpeakerTime = totalDominantSpeakerTime + timeElapsed;
end
users_json[values.nick] = {
displayName = values.displayName,
totalDominantSpeakerTime = totalDominantSpeakerTime,
faceLandmarks = faceLandmarks
};
end
end
end
if next(users_json) ~= nil then
local body_json = {};
body_json.type = 'speakerstats';
body_json.users = users_json;
local json_msg_str, error = json.encode(body_json);
if json_msg_str then
local stanza = st.message({
from = module.host;
to = occupant.jid; })
:tag("json-message", {xmlns='http://jitsi.org/jitmeet'})
:text(json_msg_str):up();
room:route_stanza(stanza);
else
module:log('error', 'Error encoding room:%s error:%s', room.jid, error);
end
end
end
local context_user = event.origin and event.origin.jitsi_meet_context_user or nil;
room.speakerStats[occupant.jid] = new_SpeakerStats(nick, context_user);
end
end
-- Occupant left set its dominant speaker to false and update the store the
-- display name
function occupant_leaving(event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return;
end
if not room.speakerStats then
return;
end
local occupant = event.occupant;
local speakerStatsForOccupant = room.speakerStats[occupant.jid];
if speakerStatsForOccupant then
speakerStatsForOccupant:setDominantSpeaker(false, false);
-- set display name
local displayName = occupant:get_presence():get_child_text(
'nick', 'http://jabber.org/protocol/nick');
speakerStatsForOccupant.displayName = displayName;
end
end
-- Conference ended, send speaker stats
function room_destroyed(event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return;
end
module:fire_event("send-speaker-stats", { room = room; roomSpeakerStats = room.speakerStats; });
end
module:hook("message/host", on_message);
function process_main_muc_loaded(main_muc, host_module)
-- the conference muc component
module:log("info", "Hook to muc events on %s", host_module.host);
main_muc_service = main_muc;
module:log("info", "Main muc service %s", main_muc_service)
host_module:hook("muc-room-created", room_created, -1);
host_module:hook("muc-occupant-joined", occupant_joined, -1);
host_module:hook("muc-occupant-pre-leave", occupant_leaving, -1);
host_module:hook("muc-room-destroyed", room_destroyed, 1); -- prosody handles it at 0
end
function process_breakout_muc_loaded(breakout_muc, host_module)
-- the Breakout muc component
module:log("info", "Hook to muc events on %s", host_module.host);
host_module:hook("muc-room-created", breakout_room_created, -1);
host_module:hook("muc-occupant-joined", occupant_joined, -1);
host_module:hook("muc-occupant-pre-leave", occupant_leaving, -1);
host_module:hook("muc-room-destroyed", room_destroyed, 1); -- prosody handles it at 0
end
-- process or waits to process the conference muc component
process_host_module(muc_component_host, function(host_module, host)
module:log('info', 'Conference component loaded %s', 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);
-- process or waits to process the breakout rooms muc component
process_host_module(breakout_room_component_host, function(host_module, host)
module:log('info', 'Breakout component loaded %s', host);
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
process_breakout_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_muc_loaded(prosody.hosts[host].modules.muc, host_module);
end
end);
end
end);
process_host_module(main_virtual_host, function(host_module)
module:context(host_module.host):fire_event('jitsi-add-identity', {
name = 'speakerstats'; host = module.host;
});
end);