view mod_muc_inject_mentions/mod_muc_inject_mentions.lua @ 5295:98d5acb93439

mod_authz_delegate: make resistant against startup order issues There is no guarantee that the target_host gets activated and initialized before the host this module is loaded on. As add_default_permission is called during load time by many modules, we need to be prepared to queue stuff.
author Jonas Schäfer <jonas@wielicki.name>
date Fri, 31 Mar 2023 16:56:42 +0200
parents 33a41503b9e3
children
line wrap: on
line source

module:depends("muc");

local jid_resource = require "util.jid".resource;
local st = require "util.stanza";

local prefixes = module:get_option_set("muc_inject_mentions_prefixes", {})
local suffixes = module:get_option_set("muc_inject_mentions_suffixes", {})
local enabled_rooms = module:get_option("muc_inject_mentions_enabled_rooms", nil)
local disabled_rooms = module:get_option("muc_inject_mentions_disabled_rooms", nil)
local mention_delimiters = module:get_option_set("muc_inject_mentions_mention_delimiters",  {" ", "", "\n", "\t"})
local append_mentions = module:get_option("muc_inject_mentions_append_mentions", false)
local strip_out_prefixes = module:get_option("muc_inject_mentions_strip_out_prefixes", false)
local reserved_nicks = module:get_option("muc_inject_mentions_reserved_nicks", false)
local use_bare_jid = module:get_option("muc_inject_mentions_use_bare_jid", true)
local prefix_mandatory = module:get_option("muc_inject_mentions_prefix_mandatory", false)
local reserved_nicknames = {}

local reference_xmlns = "urn:xmpp:reference:0"

local function update_reserved_nicknames(event)
    local room, data, jid = event.room.jid, event.data, event.jid
    load_room_reserved_nicknames(event.room)
    local nickname = (data or {})["reserved_nickname"]

    if nickname then
        reserved_nicknames[room][nickname] = jid
    else
        local nickname_to_remove
        for _nickname, _jid in pairs(reserved_nicknames[room]) do
            if _jid == jid then
                nickname_to_remove = _nickname
                break
            end
        end
        if nickname_to_remove then
            reserved_nicknames[room][nickname_to_remove] = nil
        end
    end
end

function load_room_reserved_nicknames(room)
    if not reserved_nicknames[room.jid] then
        reserved_nicknames[room.jid] = {}
        for jid, data in pairs(room._affiliations_data or {}) do
            local reserved_nickname = data["reserved_nickname"]
            if reserved_nicknames then
                reserved_nicknames[room.jid][reserved_nickname] = jid
            end
        end
    end
end

local function get_jid(room, nickname)
    local bare_jid = reserved_nicknames[room.jid][nickname]
    if bare_jid and use_bare_jid then
        return bare_jid
    end

    if bare_jid and not use_bare_jid then
        return room.jid .. "/" .. nickname
    end
end

local function get_participants(room)
    if not reserved_nicks then
        local occupants = room._occupants
        local key, occupant = next(occupants)
        return function ()
            while occupant do -- luacheck: ignore
                local nick = jid_resource(occupant.nick);
                local bare_jid = occupant.bare_jid
                key, occupant = next(occupants, key)
                return bare_jid, nick
            end
        end
    else
        local generator = room:each_affiliation()
        local jid, _, affiliation_data = generator(nil, nil)
        return function ()
           while jid do
                local bare_jid, nick = jid, (affiliation_data or {})["reserved_nickname"]
                jid, _, affiliation_data = generator(nil, bare_jid)
                if nick then
                    return bare_jid, nick
                end
           end
        end
    end
end

local function add_mention(mentions, bare_jid, first, last, prefix_indices, has_prefix)
    if strip_out_prefixes then
        if has_prefix then
            table.insert(prefix_indices, first-1)
        end
        first = first - #prefix_indices
        last = last - #prefix_indices
    end
    mentions[first] = {bare_jid=bare_jid, first=first, last=last}
end

local function get_client_mentions(stanza)
    local has_mentions = false
    local client_mentions = {}

    for element in stanza:childtags("reference", reference_xmlns) do
        if element.attr.type == "mention" then
            local key = tonumber(element.attr.begin) + 1 -- count starts at 0
            client_mentions[key] = {bare_jid=element.attr.uri, first=element.attr.begin, last=element.attr["end"]}
            has_mentions = true
        end
    end

    return has_mentions, client_mentions
end

local function is_room_eligible(jid)
    if not enabled_rooms and not disabled_rooms then return true; end

    if enabled_rooms then
        for _, _jid in ipairs(enabled_rooms) do
            if _jid == jid then
                return true
            end
        end
        return false
    end

    if disabled_rooms then
        for _, _jid in ipairs(disabled_rooms) do
            if _jid == jid then
                return false
            end
        end
        return true
    end
end

local function has_nick_prefix(body, first)
    -- There are no configured prefixes
    if not prefixes or #prefixes < 1 then return false end

    -- Prefix must have a space before it,
    -- be the first character of the body
    -- or be the first character after a new line
    if not mention_delimiters:contains(body:sub(first - 2, first - 2)) then
        return false
    end

    local prefix = body:sub(first - 1, first - 1)
    for _, _prefix in ipairs(prefixes) do
        if prefix == _prefix then
            return true
        end
    end

    return false
end

local function has_nick_suffix(body, last)
    -- There are no configured suffixes
    if not suffixes or #suffixes < 1 then return false end

    -- Suffix must have a space after it,
    -- be the last character of the body
    -- or be the last character before a new line
    if not mention_delimiters:contains(body:sub(last + 2, last + 2)) then
        return false
    end

    local suffix = body:sub(last+1, last+1)
    for _, _suffix in ipairs(suffixes) do
        if suffix == _suffix then
            return true
        end
    end

    return false
end

local function search_mentions(room, body, client_mentions)
    load_room_reserved_nicknames(room)
    local mentions, prefix_indices = {}, {}
    local current_word = ""
    local current_word_start
    for i = 1, #body+1 do
        local char = body:sub(i,i)
        -- Mention delimiter found, current_word is completed now
        if mention_delimiters:contains(char) and current_word_start then
            -- Check for nickname without prefix
            local jid = get_jid(room, current_word)
            if jid then
                if not prefix_mandatory then
                    add_mention(mentions, jid, current_word_start, i - 1, prefix_indices, false)
                end
            else
                -- Check for nickname with affixes
                local prefix = prefixes:contains(current_word:sub(1,1))
                local suffix = suffixes:contains(current_word:sub(-1))
                if prefix and suffix then
                    jid = get_jid(room, current_word:sub(2, -2))
                    if jid then
                        add_mention(mentions, jid, current_word_start + 1, i - 2, prefix_indices, true)
                    end
                elseif prefix then
                    jid = get_jid(room, current_word:sub(2))
                    if jid then
                        add_mention(mentions, jid, current_word_start + 1, i - 1, prefix_indices, true)
                    end
                elseif suffix and not prefix_mandatory then
                    jid = get_jid(room, current_word:sub(1, -2))
                    if jid then
                        add_mention(mentions, jid, current_word_start, i - 2, prefix_indices, false)
                    end
                end
            end

            current_word = ""
            current_word_start = nil
        elseif not mention_delimiters:contains(char) then
            current_word_start = current_word_start or i
            current_word = current_word .. char
        end
    end

    return mentions, prefix_indices
end

local function muc_inject_mentions(event)
    local room, stanza = event.room, event.stanza;
    local body = stanza:get_child_text("body")

    if not body or #body < 1 then return; end

    -- Inject mentions only if the room is configured for them
    if not is_room_eligible(room.jid) then return; end

    -- Only act on messages that do not include mentions
    -- unless configuration states otherwise.
    local has_mentions, client_mentions = get_client_mentions(stanza)
    if has_mentions and not append_mentions then return; end

    local mentions, prefix_indices = search_mentions(room, body, client_mentions)
    for _, mention in pairs(mentions) do
        -- https://xmpp.org/extensions/xep-0372.html#usecase_mention
        stanza:tag(
            "reference", {
                xmlns=reference_xmlns,
                begin=tostring(mention.first - 1), -- count starts at 0
                ["end"]=tostring(mention.last),
                type="mention",
                uri="xmpp:" .. mention.bare_jid,
            }
        ):up()
    end

    if strip_out_prefixes then
        local body_without_prefixes = ""
        local from = 0
        if #prefix_indices > 0 then
            for _, prefix_index in ipairs(prefix_indices) do
                body_without_prefixes = body_without_prefixes .. body:sub(from, prefix_index-1)
                from = prefix_index + 1
            end
            body_without_prefixes = body_without_prefixes .. body:sub(from, #body)

            -- Replace original body containing prefixes
            stanza:maptags(
                function(tag)
                    if tag.name ~= "body" then
                        return tag
                    end
                    return st.stanza("body"):text(body_without_prefixes)
                end
            )
        end
    end
end

module:hook("muc-occupant-groupchat", muc_inject_mentions)
module:hook("muc-set-affiliation", update_reserved_nicknames)