Files
Stefan-Sanger 1a9b2262c9 fix(fcm): use dot-call for prosody.events.fire_event so cache invalidation reaches MUC
prosody.events.fire_event is a plain function (event_name, event_data), not
a method. The colon form prosody.events:fire_event(name, data) passed the
events table as event_name, so the lookup found no handlers and the
vnc-fcm-invalidate-notify-cache / vcard-cache signals were silently lost.

mod_vnc_muc_fcm hooks these via module:hook_global (which registers under
the string event name), so its notify_cache was never invalidated cross-host.
A stale empty cache entry for an MUC affiliate (left by an earlier test
before the user had a token) then suppressed FCM pushes to that affiliate,
breaking test_muc_fcm_push_to_offline_member in the full suite.

Part-of: <http://gitlab.vnc.biz/uxf/vnctalk-prosody/-/merge_requests/11>
2026-07-23 08:40:01 +02:00

693 lines
24 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- vnc_fcm_common.lua
--
-- Shared FCM notification logic for mod_vnc_fcm and mod_vnc_muc_fcm.
--
-- Both modules require this file and call new(ctx) to obtain closures
-- bound to their respective stores, caches, and behavioural flags.
-- This eliminates the ~90 % code duplication between the two modules.
--
-- This file is MIT/X11 licensed.
local http = require "net.http"
local json = require "util.json"
local jid_split = require "util.jid".split
local st = require "util.stanza"
local M = {}
-- ─── pure utility functions ────────────────────────────────────────
local function deserializeDataElement(pdElement, expectedName)
if (not pdElement) then return nil end
if (type(pdElement) ~= "table") then return nil end
if (pdElement[1] and pdElement["attr"] and pdElement["attr"]["key"] and (pdElement["attr"]["key"] == expectedName)) then
return json.decode(pdElement[1]);
end
return nil;
end
local function contains(list, x)
if (list) then
for _, v in pairs(list) do
if (v == x) then return true end
end
end
return false
end
local function starts_with(str, start)
return str:sub(1, #start) == start
end
local function trim(s)
if s and (type(s) == "string") then
return (s:gsub("^%s*(.-)%s*$", "%1"))
else
return s
end
end
local function collapseSpaces(s)
if s and (type(s) == "string") then
return (s:gsub("%s+", " "))
else
return s
end
end
local function dumpTable(t, depth)
local result = "";
if (not depth) then depth = 0 end;
local prefix = string.rep(" ", depth);
if (type(t) ~= "table") then
result = result.." "..tostring(t);
else
for key,value in pairs(t) do
result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n"
end
end
return result;
end
local function prettyUsername(userName)
if not userName then
userName = "";
end
userName = string.upper(string.sub(userName, 1, 1))..string.sub(userName, 2, -1)
if string.find(userName, "%.") then
local i = 1
while true do
local j = string.find(userName, "%.", i)
if j == nil or #userName == i then break end
userName = string.sub(userName, 1, j-1).." "..string.upper(string.sub(userName, j+1, j+1))..string.sub(userName, j+2, -1)
if #userName <= j then break end
i = j
end
end
return trim(collapseSpaces(userName));
end
M.utils = {
deserializeDataElement = deserializeDataElement,
contains = contains,
starts_with = starts_with,
trim = trim,
collapseSpaces = collapseSpaces,
dumpTable = dumpTable,
prettyUsername = prettyUsername,
}
-- ─── factory ───────────────────────────────────────────────────────
function M.new(ctx)
-- ctx fields:
-- mod prosody module object (for logging)
-- stores { private=…, vcard=…, fcmtoken=…, fcmtoken_map=… }
-- (fields updated by caller after lazy store re-open)
-- domain string domain for nto field and session lookup
-- is_muc boolean affects postFCMIOS title fallback
-- ios_always_push boolean iOS devices always get push even when online
-- global_mute_early_return boolean buildTokensForUser returns {} on globalMute
-- override_topic_on_hidden boolean override gTopic to "new message" when nType==1
-- allow_etype_or_readtarget boolean fcm_notify condition includes etype/readTarget
-- domain_check_enabled boolean skip fcm_notify when user host ≠ domain
-- fcm_api_key string
-- fcm_api_url string
-- module_is_active boolean
-- inactive_devices table (shared with calling module's CSI hooks)
local mod = ctx.mod
local stores = ctx.stores
local notify_cache = {}
local notify_cache_ttl = 300
local vcard_cache = {}
local vcard_cache_ttl = 600
-- ─── cache invalidation ──────────────────────────────────────────
local function invalidateNotifyCache(userName)
notify_cache[userName] = nil;
end
local function invalidateVcardCache(userName)
vcard_cache[userName] = nil;
end
local function invalidateNotifyCacheGlobal(userName)
invalidateNotifyCache(userName);
prosody.events.fire_event("vnc-fcm-invalidate-notify-cache", { username = userName });
end
local function invalidateVcardCacheGlobal(userName)
invalidateVcardCache(userName);
prosody.events.fire_event("vnc-fcm-invalidate-vcard-cache", { username = userName });
end
-- ─── display name ────────────────────────────────────────────────
local function getDisplayName(userName)
local vCard, err = stores.vcard:get(userName);
if vCard then
vCard = st.deserialize(vCard);
end
local name = nil;
if not vCard or err then
mod:log("error", "Unable to get vCard for %s : %s ", userName, tostring(err));
else
name = vCard:find("{vcard-temp}FN#");
end
if not name then name = prettyUsername(userName) or userName end
mod:log("debug", "name for %s : %s", userName, name)
return name;
end
local function getDisplayName_cached(userName)
local entry = vcard_cache[userName];
if entry and (os.time() - entry.ts) < vcard_cache_ttl then
return entry.name;
end
local name = getDisplayName(userName);
vcard_cache[userName] = { name = name, ts = os.time() };
return name;
end
-- ─── stale-token cleanup ─────────────────────────────────────────
local function removeKeyFromUser(userName, fcmId)
local pd, pfFetchError = stores.private:get(userName);
if (pfFetchError) then
mod:log("error", "Fetching private data for '%s' failed : %s", tostring(userName), tostring(pfFetchError));
return 0,{};
elseif (pd) then
local removed = false;
for key,value in pairs(pd) do
if ((key == "documents:stanza:io:json") and (type(value) == "table")) then
for k2,v2 in pairs(value) do
local fcm = deserializeDataElement(v2, "fcm");
if (fcm) then
mod:log("debug", "removing fcmID %s from private data record: %s", fcmId, json.encode(fcm));
fcm[fcmId] = nil;
v2[1]=json.encode(fcm);
removed = true;
break;
end
end
end
end
if (removed) then
local ok, errmsg = stores.private:set(userName, pd);
if (not ok) then
mod:log("error", "failed to store private data for %s : '%s'", userName, tostring(errmsg));
end
invalidateNotifyCacheGlobal(userName);
else
mod:log("debug", "unable to remove fcmID %s from %s. Not found in data: %s", fcmId, userName, tostring(pd));
end
end
end
-- ─── FCM HTTP callback ───────────────────────────────────────────
local function getPostCallback(_fcmId, _userName)
return function(body, code, response)
local shortKey = string.sub(_fcmId, 1, 6)
if code ~= 200 then
mod:log("error", "FCM result %s@%s : HTTP %s %s", _userName, shortKey, tostring(code), tostring(body))
else
mod:log("debug", "FCM result %s@%s : HTTP %s %s", _userName, shortKey, tostring(code), tostring(body))
local response2 = json.decode(body)
if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "NotRegistered"
then
removeKeyFromUser(_userName, _fcmId);
end
if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "MissingRegistration"
then
removeKeyFromUser(_userName, _fcmId);
end
if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "InvalidRegistration"
then
removeKeyFromUser(_userName, _fcmId);
end
if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "InvalidPackageName"
then
removeKeyFromUser(_userName, _fcmId);
end
end
return false
end
end
-- ─── FCM HTTP send (Android / Web) ───────────────────────────────
local function postFCM(_userName, _fcmId, _source, _body, _type, _senderName, _groupTopic, _attachment_type, _lang, _msgid, _vncTalkConferenceEtype, _vncTalkConferenceId, _nFromJid, _jitsiURL, _jitsiRoom, _isMessageCorrection, _enableSoundOpt, _readTarget)
if (not ctx.module_is_active) then return end;
local domainname = ctx.domain;
local v_attachment = "";
if (_attachment_type ~= "") and (_attachment_type ~= nil) then
v_attachment = _attachment_type;
end
local lsource = _source;
local ltype = _type;
local signal = nil;
local nfrom = _source;
local nJitsiURL = "";
local nJitsiRoom = "";
local replaceMsgId = "";
if (_isMessageCorrection ~= nil) then
replaceMsgId = _isMessageCorrection;
end
if (_vncTalkConferenceId ~= nil) then
if string.match(_vncTalkConferenceId, "@") then
lsource = _vncTalkConferenceId;
end
end
if (_vncTalkConferenceEtype ~= nil) then
signal = "1";
ltype = _vncTalkConferenceEtype;
end
if (_readTarget ~= nil) then
lsource = _readTarget;
ltype = "read";
end
if (_nFromJid ~= nil) then
nfrom = _nFromJid;
end
if (_jitsiURL ~= nil) then
nJitsiURL = _jitsiURL;
end
if (_jitsiRoom ~= nil) then
nJitsiRoom = _jitsiRoom;
end
local stamp = os.time();
local nto_addr;
if string.match(_userName, "@") then
nto_addr = _userName;
else
nto_addr = _userName.."@"..domainname;
end
local fcmRequest = {
to = _fcmId,
enableSoundOpt = _enableSoundOpt,
content_available = true,
priority = "high",
data = {
nType = "local_notification",
eType = ltype,
jid = lsource,
conferenceId = _vncTalkConferenceId,
nfrom = nfrom,
nto = nto_addr,
name = _senderName,
gt = _groupTopic,
aft = v_attachment,
callSignal = signal,
jitsiURL = nJitsiURL,
jitsiRoom = nJitsiRoom,
t = stamp,
lang = _lang,
msgid = _msgid,
replaceid = replaceMsgId,
body = _body
}
};
fcmRequest["android"] = { priority = "high", ttl = 345600 };
fcmRequest["webpush"] = { headers = { Urgency = "high", TTL = 345600 } };
local data = json.encode(fcmRequest);
local httpRequestOptions = {
method = "POST",
body = data,
headers = {
["Connection"] = 'close',
["Authorization"] = "key="..ctx.fcm_api_key,
["Content-Type"] = "application/json; charset=utf-8"
}
}
mod:log("info", "sending notification to %s for token %s - from: %s - msgid: %s lang: %s", nto_addr, _fcmId, _source, _msgid, _lang);
http.request(ctx.fcm_api_url, httpRequestOptions, getPostCallback(_fcmId, _userName))
end
-- ─── FCM HTTP send (iOS) ─────────────────────────────────────────
local function postFCMIOS(_userName, _fcmId, _source, _body, _type, _senderName, _groupTopic, _attachment_type, _lang, _msgid, _vncTalkConferenceEtype, _vncTalkConferenceId, _nFromJid, _jitsiURL, _jitsiRoom, _isMessageCorrection, _enableSoundOpt)
if (not ctx.module_is_active) then return end;
local domainname = ctx.domain;
local ntitle = "";
local v_attachment = "";
local nJitsiURL = "";
local nJitsiRoom = "";
local replaceMsgId = "";
if (_isMessageCorrection ~= nil) then
replaceMsgId = _isMessageCorrection;
end
if (_groupTopic ~= "") and (_groupTopic ~= nil) then
ntitle = _groupTopic;
else
if ctx.is_muc then
ntitle = jid_split(_source);
else
if (_senderName ~= "") and (_senderName ~= nil) then
ntitle = _senderName;
else
ntitle = _source;
end
end
end
if (_attachment_type ~= "") and (_attachment_type ~= nil) then
v_attachment = _attachment_type;
end
local _click_action = _type:upper();
if (_click_action == "NORMAL") then
_click_action = "CHAT";
end
local lsource = _source;
local ltype = _type;
local signal = nil;
local nfrom = _source;
if (_vncTalkConferenceId ~= nil) then
if string.match(_vncTalkConferenceId, "@") then
lsource = _vncTalkConferenceId;
end
end
if (_vncTalkConferenceEtype ~= nil) then
signal = "1";
ltype = _vncTalkConferenceEtype;
end
if (_nFromJid ~= nil) then
nfrom = _nFromJid;
end
if (_jitsiURL ~= nil) then
nJitsiURL = _jitsiURL;
end
if (_jitsiRoom ~= nil) then
nJitsiRoom = _jitsiRoom;
end
local uname;
if string.match(_userName, "@") then
uname = _userName;
else
uname = _userName.."@"..domainname;
end
local fcmRequest = {
to = _fcmId,
priority = "high",
enableSoundOpt = _enableSoundOpt,
notification = {
title = ntitle,
body = _body,
mutable_content = true,
sound = "incoming-call-loop.caff",
thread_id = lsource,
click_action = _click_action
},
data = {
nType = "local_notification",
eType = ltype,
jid = lsource,
conferenceId = _vncTalkConferenceId,
nfrom = nfrom,
nto = uname,
name = _senderName,
gt = ntitle,
aft = v_attachment,
callSignal = signal,
jitsiURL = nJitsiURL,
jitsiRoom = nJitsiRoom,
lang = _lang,
msgid = _msgid,
replaceid = replaceMsgId,
body = _body
}
};
local data = json.encode(fcmRequest);
local httpRequestOptions = {
method = "POST",
body = data,
headers = {
["Connection"] = 'close',
["Authorization"] = "key="..ctx.fcm_api_key,
["Content-Type"] = "application/json; charset=utf-8"
}
}
mod:log("debug", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", uname, _fcmId, _source, _msgid, _body.."...");
http.request(ctx.fcm_api_url, httpRequestOptions, getPostCallback(_fcmId, _userName))
end
-- ─── notify options & token building ─────────────────────────────
local function getNotifyOptionsForUser(userName)
local pd, pfFetchError = stores.private:get(userName);
if (pfFetchError) then
mod:log("error", "Fetching private data for '%s' failed : %s", tostring(userName), tostring(pfFetchError));
return { nType = 0, nLang = nil, enableSound = nil, globalMute = false, legacy_tokens = {}, devices = {} };
end
local legacy_tokens;
local nType;
local nLang;
local globalMute = false;
local enableSound;
if (pd) then
for key,value in pairs(pd) do
mod:log("debug", "PD( %s) : %s => %s ", userName, tostring(key), tostring(value));
if ((key == "documents:stanza:io:json") and (type(value) == "table")) then
for k2,v2 in pairs(value) do
if (legacy_tokens == nil) then
local fcm = deserializeDataElement(v2, "fcm");
if (type(fcm) == "table") then
legacy_tokens = {};
for token,x in pairs(fcm) do
if (x["os"] ~= nil) then
table.insert(legacy_tokens, token.."@"..x["os"])
end
end
end
end
if (not nType) then
local options = deserializeDataElement(v2, "notifyOptions");
if (options and (type(options) == "table") and options["type"]) then
nType = tonumber(options["type"]);
mod:log("info", "got nType: %s", nType);
end
end
if (not nType) then
local options = deserializeDataElement(v2, "notification");
if (options) then
nType = tonumber(options);
end
end
if (enableSound == nil) then
local enableSoundOpt = deserializeDataElement(v2, "enabledSound");
enableSound = enableSoundOpt;
end;
local pGlobalMute = deserializeDataElement(v2, "globalMute");
if (pGlobalMute) then
nType = 0;
globalMute = true;
legacy_tokens = {};
end
if (not nLang) then
local options = deserializeDataElement(v2, "lang");
mod:log("info", "PDLang: %s => %s", userName, json.encode(options));
if (options) and (type(options) == "string") then
nLang = options;
end;
end
end
break;
end
end
end
if (not nLang) then nLang = "en"; end
if (not nType) then nType = 2; end
if (legacy_tokens == nil) then legacy_tokens = {}; end
local devices = {};
local map_res = stores.fcmtoken:get(userName);
if (map_res) then
for _device, _tkr in pairs(map_res) do
table.insert(devices, { device = _device, token = _tkr.token, os = _tkr.os });
end
end
return {
nType = nType,
nLang = nLang,
enableSound = enableSound,
globalMute = globalMute,
legacy_tokens = legacy_tokens,
devices = devices,
};
end
local function buildTokensForUser(userName, data)
if ctx.global_mute_early_return and data.globalMute then
return {};
end
local tokens = {};
for _, t in ipairs(data.legacy_tokens) do
table.insert(tokens, t);
end
local sessions = prosody.bare_sessions;
local ubjid = userName.."@"..ctx.domain;
local u_sessions = sessions[ubjid] or nil;
for _, dev in ipairs(data.devices) do
local deviceonline = false;
if (u_sessions) then
local u_sessions_device = u_sessions["sessions"] or nil;
if (u_sessions_device) then
local dsession = u_sessions_device[dev.device] or nil;
if (dsession) then
local hibernated = dsession["hibernated"] or nil;
local hibernating = dsession["hibernating"] or nil;
local awaiting_ack = dsession["awaiting_ack"] or nil;
mod:log("info", "found session for _device %s - hibernating: %s, hibernated %s", dev.device, hibernating, hibernated);
if not ((hibernated) or (hibernating)) then
local dev_in_background = ctx.inactive_devices[dev.device] or nil;
if dev_in_background then
if awaiting_ack then
mod:log("debug", "device %s is still connected, but inactive and awaiting_ack", dev.device);
else
mod:log("debug", "device %s is still connected, but inactive", dev.device);
if not ctx.ios_always_push or (dev.os ~= "ios") then
deviceonline = true;
end
end
else
if awaiting_ack then
mod:log("debug", "device %s is connected and active, but awaiting_ack", dev.device);
else
mod:log("debug", "device %s is connected and active", dev.device);
if not ctx.ios_always_push or (dev.os ~= "ios") then
deviceonline = true;
end
end
end
end
end
end
end
if not(deviceonline) then
local ts = dev.token.."@"..dev.os;
mod:log("debug","already_added: %s", ts);
if not contains(tokens, ts) then
table.insert(tokens, ts);
end
end
end
return tokens;
end
local function getNotifyOptionsForUser_cached(userName)
local entry = notify_cache[userName];
local data;
if entry and (os.time() - entry.ts) < notify_cache_ttl then
data = entry;
else
data = getNotifyOptionsForUser(userName);
data.ts = os.time();
notify_cache[userName] = data;
end
local tokens = buildTokensForUser(userName, data);
return data.nType, tokens, data.nLang, data.enableSound;
end
-- ─── fcm_notify ──────────────────────────────────────────────────
local function fcm_notify(userName, title, body, type, _senderName, _groupTopic, vnc_attachment_type, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom, _jitsiURL, _jitsiRoom, _isMessageCorrection, _readTarget)
if (not ctx.module_is_active) then return end;
local uname, uhost = jid_split(userName);
if ctx.domain_check_enabled and uhost ~= ctx.domain then return end
local nType, tokens, lang, soundOpt = getNotifyOptionsForUser_cached(uname);
local should_notify = (nType == 1) or (nType == 2);
if not should_notify and ctx.allow_etype_or_readtarget then
should_notify = (vncTalkConferenceEtype ~= nil) or (_readTarget ~= nil);
end
if (tokens and (#tokens) > 0) and should_notify
then
mod:log("debug", "in fcm_notify(%s, %s, %s) => type=%s, %s tokens", userName, title, body, tostring(nType), tostring((#tokens)));
local sName = _senderName;
local gTopic = _groupTopic;
if (nType == 1) then
sName = "new message";
if ctx.override_topic_on_hidden then
gTopic = "new message";
end
body = "";
end
if (_isMessageCorrection ~= nil) then
body = " ";
end;
for i,token in pairs(tokens) do
if (token) then
local rtoken, ros = jid_split(token);
if (ros == "ios") then
if (type ~= "read") then
postFCMIOS(userName, rtoken, title, body, type, sName, gTopic, vnc_attachment_type, lang, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom, _jitsiURL, _jitsiRoom, _isMessageCorrection, soundOpt);
end
else
postFCM(userName, rtoken, title, body, type, sName, gTopic, vnc_attachment_type, lang, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom, _jitsiURL, _jitsiRoom, _isMessageCorrection, soundOpt, _readTarget);
end
end
end
else
mod:log("debug", "fcm_notify(%s, %s, %s) => type=%s, no tokens", userName, title, body, tostring(nType));
end
end
-- ─── public interface ────────────────────────────────────────────
return {
notify_cache = notify_cache,
vcard_cache = vcard_cache,
invalidateNotifyCache = invalidateNotifyCache,
invalidateVcardCache = invalidateVcardCache,
invalidateNotifyCacheGlobal = invalidateNotifyCacheGlobal,
invalidateVcardCacheGlobal = invalidateVcardCacheGlobal,
getDisplayName = getDisplayName,
getDisplayName_cached = getDisplayName_cached,
getNotifyOptionsForUser_cached = getNotifyOptionsForUser_cached,
fcm_notify = fcm_notify,
postFCM = postFCM,
postFCMIOS = postFCMIOS,
removeKeyFromUser = removeKeyFromUser,
}
end
return M