A prosody module to auto start and stop jibri recording and suppress recording notifications

I’ve written a new prosody module mod_autojibri to handle automatic starting and stopping of server-side meeting recording based on a minimum occupant count, including the option to delay the recording stop (in case someone disconnects and quickly reconnects), and optionally suppress the recording notification for users and/or moderators in the meeting, so that the recording can be completely unobtrusive. Here’s the code:

local MAX_RETRY = 3;

local is_healthcheck_room = module:require "util".is_healthcheck_room;
local get_room_from_jid = module:require "util".get_room_from_jid;
local uuid_generate = require "util.uuid".generate;
local util_filters = require "util.filters";
local util_jid = require "util.jid";
local util_stanza = require "util.stanza";

module:log("info", "autojibri module loaded");

local opt_recorder;
local opt_start_occupants;
local opt_stop_delay;
local opt_notify_users;
local opt_notify_mods;
local function load_config()
    opt_recorder = module:get_option_string("autojibri_recorder", "recorder@");
    opt_start_occupants = module:get_option_number("autojibri_start_occupants", 1);
    opt_stop_delay = module:get_option_number("autojibri_stop_delay", 0);
    if opt_stop_delay < 0 then
        opt_stop_delay = 0;
    end
    opt_notify_users = module:get_option_boolean("autojibri_notify_users", true);
    opt_notify_mods = module:get_option_boolean("autojibri_notify_mods", true);
    module:log("info", "autojibri configuration loaded");
end
load_config();
module:hook_global('config-reloaded', load_config);


local function set_jibri_state(room, from, state)
    local to = room.jid .. "/focus"
    module:log("info", "set_jibri_state: from=%s to=%s state=%s", from, to, state and "true" or "false");
    local iq = util_stanza.iq({
        type = "set",
        id = uuid_generate() .. ":sendIQ",
        from = from,
        to = to
    }):tag("jibri", {
        xmlns = "http://jitsi.org/protocol/jibri",
        action = state and "start" or "stop",
        recording_mode = "file",
        app_data = '{"file_recording_metadata":{"share":true}}'
    })
    module:send(iq);
    room.autojibri_recording = state;
end


local function check_occupants(room, session, try)
    -- count actual occupants and identify recorder and moderator
    local num = 0;
    local recorder = nil;
    local moderator = nil;
    if room._occupants then
        for _,occupant in room:each_occupant() do
            for rjid,presence in occupant:each_session() do
                local role = occupant.role or "";
                module:log("debug",
                    "check_occupants: rjid=%s jid=%s name=%s role=%s",
                    rjid,
                    occupant.nick or "",
                    presence:get_child_text("nick", "http://jabber.org/protocol/nick") or "",
                    role
                );
                if util_jid.node(rjid) == "focus" then
                    -- don't count focus
                elseif string.sub(rjid, 1, string.len(opt_recorder)) == opt_recorder then
                    -- don't count recorder, but make sure it's a moderator
                    recorder = rjid;
                    if role ~= "moderator" then
                        room:set_affiliation(true, rjid, "owner");
                    end
                else
                    if role == "moderator" then
                        moderator = rjid;
                    end
                    num = num + 1;
                end
            end
        end
    end
    module:log("info",
        "check_occupants: total=%d min=%d recording=%s",
        num,
        opt_start_occupants,
        room.autojibri_recording and "auto" or (recorder and "manual" or "no")
    );

    -- decide if recording should start or stop
    if num >= opt_start_occupants then
        if room.autojibri_delay then
            module:log("info", "canceling delayed autostop");
            room.autojibri_delay:stop();
            room.autojibri_delay = nil;
        end
        if not room.autojibri_recording then
            if session ~= nil and session.jitsi_meet_context_features ~= nil and session.jitsi_meet_context_features["recording"] ~= true then
                module:log("error", "cannot autostart, recording not enabled in context_features");
            elseif recorder ~= nil then
                module:log("warn", "skipping autostart, manual recording already in progress");
            elseif not moderator then
                try = (try or 0) + 1;
                if try > MAX_RETRY then
                    module:log("error", "cannot autostart, no moderator present");
                else
                    module:log("debug", "no moderator yet, retrying in 1s");
                    module:add_timer(1, function()
                        check_occupants(room, session, try);
                    end);
                end
            else
                module:log("debug", "autostarting");
                set_jibri_state(room, moderator, true);
            end
        end
    else
        if room.autojibri_delay then
            module:log("debug", "delayed autostop already pending");
        elseif room.autojibri_recording then
            local from = recorder or moderator
            if not from then
                module:log("error", "cannot autostop, no moderator present");
            else
                module:log("debug", "autostop in %d seconds", opt_stop_delay);
                room.autojibri_delay = module:add_timer(opt_stop_delay, function()
                    room.autojibri_delay = nil;
                    set_jibri_state(room, from, false);
                end);
            end
        end
    end
end


local function is_system_event(event)
    if is_healthcheck_room(event.room.jid) then
        return true;
    end
    if event.occupant then
        if util_jid.node(event.occupant.jid) == "focus" then
            return true;
        end
        if string.sub(event.occupant.jid, 1, string.len(opt_recorder)) == opt_recorder then
            return true;
        end
    end
    return false;
end


module:hook("muc-occupant-joined", function (event)
    if is_system_event(event) then
        return;
    end
    module:log("debug", "muc-occupant-joined: %s", event.occupant.jid);
    check_occupants(event.room, event.origin);
end)


module:hook("muc-occupant-left", function (event)
    if is_system_event(event) then
        return;
    end
    module:log("debug", "muc-occupant-left: %s", event.occupant.jid);
    check_occupants(event.room);
end)


local function filter_presence(stanza, session)
    -- if we're notifying everyone anyway, the stanza details don't matter
    if opt_notify_users and opt_notify_mods then
        return stanza;
    end
    -- if it's not a jibri-recording-status presence stanza, let it through
    if getmetatable(stanza) ~= util_stanza.stanza_mt
    or stanza.name ~= "presence"
    or not stanza.attr
    or not stanza.attr.from
    or not stanza.attr.to
    or not stanza:get_child("jibri-recording-status", "http://jitsi.org/protocol/jibri")
    then
        return stanza;
    end
    -- if it's addressed to the focus or recorder users, let it through
    if util_jid.resource(stanza.attr.to) == "focus"
    or string.sub(stanza.attr.to, 1, string.len(opt_recorder)) == opt_recorder then
        module:log("debug", "allowed jibri-recording-status presence from=%s to=%s", stanza.attr.from, stanza.attr.to);
        return stanza;
    end
    -- if we're not notifying any recipients, drop it
    if not (opt_notify_users or opt_notify_mods) then
        module:log("debug", "dropped jibri-recording-status presence from=%s to=%s", stanza.attr.from, stanza.attr.to);
        return nil;
    end
    -- check the recipient role and allow or drop accordingly
    local room = get_room_from_jid(util_jid.bare(stanza.attr.from));
    local occupant = room and room:get_occupant_by_real_jid(stanza.attr.to);
    local notify = (not occupant) or (occupant.role == "moderator" and opt_notify_mods) or (occupant.role ~= "moderator" and opt_notify_users);
    module:log("debug",
        "%s jibri-recording-status presence from=%s to=%s role=%s",
        notify and "allowed" or "dropped",
        stanza.attr.from,
        stanza.attr.to,
        occupant and occupant.role or "?"
    );
    if notify then
        return stanza;
    end
    return nil;
end


local function init_hooks(session)
    module:log("debug", "adding session stanza filter");
    util_filters.add_filter(session, "stanzas/out", filter_presence, -999);
end


util_filters.add_filter_hook(init_hooks);


-- TODO: is this needed? mod_muc_allowners doesn't do it, for example
function module.unload()
    util_filters.remove_filter_hook(init_hooks);
end

I plan to submit this to GitHub - jitsi-contrib/prosody-plugins: Prosody plugins for Jitsi but wanted to post it here for feedback first. Specifically, I’m wondering:

  • Should I rename this module to replace the existing mod_jibri_autostart? This code is of course heavily based on that script, but so much is added and changed that the git diff would have very few lines in common.

  • Is there a more efficient way to handle the stanza filtering? I used mod_stanza_debug and mod_presence_dedup as references, but I think right now it’s hooking every session’s stanzas/out when probably we only need to hook the focus user’s session, which is where the jibri-recording-status presence stanzas originate.

  • Is there a more appropriate way to detect both the special recorder user and focus users in order to ignore them in the muc-occupant-joined/left handlers? Other modules seem to use core.usermanager.is_admin but I saw some indication that that method is deprecated, so I’m using a simpler jid node test here, and an even hackier string prefix test for the recorder. Is there a better way?

  • Some other modules seem to use util.timer to set async timer callbacks, but the prosody module API includes module:add_timer() which even returns a handle to be able to cancel the timer, so I’ve used that here. Is there some reason to prefer util.timer?

  • Is there any way to trigger jibri to start recording even if the focus user is the only moderator in the room? Right now the recording can’t start until there’s a moderator, because the script needs to impersonate that moderator as the origin of the ‘start recording’ stanza sent to the room focus; trying to send from the focus to itself didn’t work. For stopping the recording I have the script make the hidden jibri recorder user into a moderator so that the recorder can issue the ‘stop recording’ command even if the actual moderator has already left, but it’d be great to be able to do this for starting as well.

Thanks for any comments and suggestions!

1 Like

Please use a different name and don’t overwrite the existing module.