refactor(fcm): extract shared FCM logic into vnc_fcm_common.lua

- Extract ~90% duplicated code from mod_vnc_fcm and mod_vnc_muc_fcm
- Add notify_cache (300s) and vcard_cache (600s) with cross-module invalidation
- Hoist getDisplayName before MUC affiliate loop
- Defer MUC affiliate notifications via timer.add_task(0, ...)
- Remove bare_sessions diagnostic scan, dead code, and per-send option lookups
- Fix read-receipt routing and respect MUC lang preference
- Unify stale-token cleanup (NotRegistered/etc.) in both modules

Part-of: <http://gitlab.vnc.biz/uxf/vnctalk-prosody/-/merge_requests/11>
This commit is contained in:
2026-07-22 15:07:01 +02:00
parent ef8c0292d6
commit 94ee78f115
3 changed files with 913 additions and 1396 deletions
+115 -757
View File
File diff suppressed because it is too large Load Diff
+106 -639
View File
@@ -1,675 +1,126 @@
-- mod_muc_notifications
-- mod_vnc_muc_fcm.lua
--
-- FCM push notifications for MUC / group chat.
--
-- This file is MIT/X11 licensed.
--
-- A module to notify non-present members of messages in a group chat
-- Shared logic (caching, FCM HTTP, token building, stale-token cleanup)
-- lives in vnc_fcm_common.lua. This module retains the MUC broadcast
-- handler and event hooks that are specific to the conference component.
--
-- The affiliate-notification loop is deferred via timer.add_task(0, …) so
-- that FCM work (cache lookups, HTTP request setup) does not block the
-- muc-broadcast-message event path.
local http = require "net.http";
local storagemanager = require "core.storagemanager";
local json = require "util.json";
-- local ltn12 = require "ltn12"
local jid_bare = require "util.jid".bare;
local jid_split = require "util.jid".split;
local jid = require "util.jid";
local timer = require "util.timer";
local id = require"util.id"
local st = require"util.stanza"
local jid_bare = require "util.jid".bare;
local jid_split = require "util.jid".split;
local timer = require "util.timer";
local common = require "vnc_fcm_common";
local trim = common.utils.trim;
local starts_with = common.utils.starts_with;
local storage_host = module:get_option_string("storage_host");
module:log("info","founs storage_host: %s", storage_host);
local fcmAPIKey = module:get_option_string("fcm_api_key");
local fcmAPIURL = module:get_option_string("fcm_api_url");
local moduleIsActive = fcmAPIURL and fcmAPIKey;
local inactive_devices = {};
local ThisDomain = module.host;
local xmlns_sm2 = "urn:xmpp:sm:2";
local xmlns_sm3 = "urn:xmpp:sm:3";
local fcm_token_store = module:open_store("fcmtoken");
local fcm_token_map_store = module:open_store("fcmtoken", "map");
local vcard_storage = module:open_store("vcard");
local private_storage = module:open_store("private");
local stores = {
private = module:open_store("private"),
vcard = module:open_store("vcard"),
fcmtoken = module:open_store("fcmtoken"),
fcmtoken_map = module:open_store("fcmtoken", "map"),
};
timer.add_task(3, function ()
private_storage = storagemanager.open(storage_host, "private");
vcard_storage = storagemanager.open(storage_host, "vcard");
fcm_token_store = storagemanager.open(storage_host, "fcmtoken");
fcm_token_map_store = storagemanager.open(storage_host, "fcmtoken", "map");
if not private_storage or not vcard_storage then
module:log("info", "private/vcard_store %s/%s not found? - will try again", private_storage, vcard_storage);
stores.private = storagemanager.open(storage_host, "private");
stores.vcard = storagemanager.open(storage_host, "vcard");
stores.fcmtoken = storagemanager.open(storage_host, "fcmtoken");
stores.fcmtoken_map = storagemanager.open(storage_host, "fcmtoken", "map");
if not stores.private or not stores.vcard then
module:log("info", "private/vcard_store %s/%s not found? - will try again", stores.private, stores.vcard);
return 3;
else
module:log("info", "private/vcard_store are now %s/%s", private_storage, vcard_storage);
end
module:log("info", "private/vcard_store are now %s/%s", stores.private, stores.vcard);
end
);
end);
local F = common.new({
mod = module,
stores = stores,
domain = storage_host,
is_muc = true,
ios_always_push = true,
global_mute_early_return = true,
override_topic_on_hidden = false,
allow_etype_or_readtarget = false,
domain_check_enabled = true,
fcm_api_key = fcmAPIKey,
fcm_api_url = fcmAPIURL,
module_is_active = moduleIsActive,
inactive_devices = inactive_devices,
});
local fcm_notify = F.fcm_notify;
local getDisplayName_cached = F.getDisplayName_cached;
local invalidateNotifyCache = F.invalidateNotifyCache;
local invalidateVcardCache = F.invalidateVcardCache;
-- some helper functions
local function deserializeDataElement(pdElement, expectedName)
--module:log("debug", "deserializeDataElement("..expectedName..", "..dumpTable(pdElement)..")");
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
local result = json.decode(pdElement[1]);
--module:log("debug", "deserializeDataElement() = '"..dumpTable(result).."'");
return result;
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) -- upcase first char in any case
if string.find(userName, "%.") -- contains dot
then
local i = 1
while true do
local j = string.find(userName, "%.", i)
if j == nil or #userName == i then break end
-- replace dot with space and upcase next char
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
local function getDisplayName(userName)
-- module:log("info", "getDisplayName user: %s", userName);
local vCard, err = vcard_storage:get(userName);
if vCard then
vCard = st.deserialize(vCard);
end
local name = nil;
if not vCard or err then
module:log("debug", "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
-- module:log("info", "name for %s : %s", userName, name)
return name;
end
local function getPostCallback(_fcmId, _userName)
return function(body, code, response)
local shortKey = string.sub(_fcmId, 1, 6)
if code ~= 200 then
module:log("info", "FCM result %s@%s : HTTP %s %s", _userName, shortKey, tostring(code), tostring(body))
-- if body then module:log("error", body); end
else
module:log("debug", "FCM result %s@%s : HTTP %s %s", _userName, shortKey, tostring(code), tostring(body))
end
return false -- close request
--return true -- keep request open
end
end
local function postFCM(_userName, _fcmId, _source, _body, _type, _senderName, _groupTopic, _attachment_type, _lang, _msgid, _vncTalkConferenceEtype, _vncTalkConferenceId, _nFromJid, _jitsiURL, _jitsiRoom, _isMessageCorrection, _enableSoundOpt)
if (not moduleIsActive) then return end;
local domainname = "";
local v_attachment = "";
domainname = module:get_option_string("storage_host");
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 (_nFromJid ~= nil) then
nfrom = _nFromJid;
end
if (_jitsiURL ~= nil) then
nJitsiURL = _jitsiURL;
end
if (_jitsiRoom ~= nil) then
nJitsiRoom = _jitsiRoom;
end
local stamp = os.time();
module:log("info", "postFCM username %s", _userName);
local uname = "";
if string.match(_userName, "@") then
uname = _userName;
else
uname = _userName.."@"..domainname;
end
local fcmRequest = {
to = _fcmId,
enableSoundOpt = _enableSoundOpt,
content_available = true,
priority = "high",
data = {
nType = "local_notification", -- notification type
eType = ltype, -- event type
jid = lsource, -- source jid (room jid or 1:1 chat peer bare jid)
conferenceId = _vncTalkConferenceId,
nfrom = nfrom,
nto = uname,
name = _senderName, -- group chat nick name or single chat peer vCard name",
gt = _groupTopic, -- topic of room iff groupchat
aft = v_attachment,
callSignal = signal,
jitsiURL = nJitsiURL,
jitsiRoom = nJitsiRoom,
t = stamp,
lang = _lang,
msgid = _msgid,
replaceid = replaceMsgId,
body = _body -- notification body/text content
}
};
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="..fcmAPIKey,
["Content-Type"] = "application/json; charset=utf-8"
}
}
-- module:log("info", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", _userName.."@"..domainname, _fcmId, _source, _msgid, _body);
-- ssl does not seem to work with net.http, so we proxy it
--http.request("https://fcm.googleapis.com/fcm/send", httpRequestOptions, postCallback)
http.request(fcmAPIURL, httpRequestOptions, getPostCallback(_fcmId, _userName))
end
local function postFCMIOS(_userName, _fcmId, _source, _body, _type, _senderName, _groupTopic, _attachment_type, _lang, _msgid, _vncTalkConferenceEtype, _vncTalkConferenceId, _nFromJid, _jitsiURL, _jitsiRoom, _isMessageCorrection, _enableSoundOpt)
if (not moduleIsActive) then return end;
local domainname = "";
local ntitle = "";
local nbody = "";
local v_attachment = "";
local nJitsiURL = "";
local nJitsiRoom = "";
local replaceMsgId = "";
if (_isMessageCorrection ~= nil) then
replaceMsgId = _isMessageCorrection;
end
domainname = module:get_option_string("storage_host");
if (_groupTopic ~= "") and (_groupTopic ~= nil) then
ntitle = _groupTopic;
else
ntitle = jid_split(_source);
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", -- notification type
eType = ltype, -- event type
jid = lsource, -- source jid (room jid or 1:1 chat peer bare jid)
conferenceId = _vncTalkConferenceId,
nfrom = nfrom,
nto = uname,
name = _senderName, -- group chat nick name or single chat peer vCard name",
gt = ntitle, -- topic of room iff groupchat
aft = v_attachment,
callSignal = signal,
jitsiURL = nJitsiURL,
jitsiRoom = nJitsiRoom,
lang = _lang,
msgid = _msgid,
replaceid = replaceMsgId,
body = _body -- notification body/text content
}
};
local data = json.encode(fcmRequest);
local httpRequestOptions = {
method = "POST",
body = data,
headers = {
["Connection"] = 'close',
["Authorization"] = "key="..fcmAPIKey,
["Content-Type"] = "application/json; charset=utf-8"
}
}
-- module:log("debug", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", _userName.."@"..domainname, _fcmId, _source, _msgid, string.sub(_body, 20).."...");
module:log("debug", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", _userName.."@"..domainname, _fcmId, _source, _msgid, _body.."...");
-- ssl does not seem to work with net.http, so we proxy it
--http.request("https://fcm.googleapis.com/fcm/send", httpRequestOptions, postCallback)
module:log("debug", "calling http.request -- fcmAPIURL: %s", fcmAPIURL);
http.request(fcmAPIURL, httpRequestOptions, getPostCallback(_fcmId, _userName))
end
-- Given a stanza, compute if it qualifies as important (notifiable)
-- return true for message stanzas with non-empty body
-- Should probably use something similar to muc-message-is-historic event
local function is_important(stanza)
local body = stanza:find("body#")
return body and #body
end
-- get notify options for local users
local function getNotifyOptionsForUser(userName)
--module:log("debug", "getFCMtokenForUser("..userName..")");
local pd, pfFetchError = private_storage:get(userName);
if (pfFetchError) then
module:log(
"error",
"Fetching private data for '%s' failed : %s", tostring(userName), tostring(pfFetchError)
);
return 0,{};
else
local tokens;
local nType;
local isGlobalMute = false;
local nLang = "en";
local enableSound;
if (pd) then
for key,value in pairs(pd)
do
-- module:log("info", "PD("..userName..") : "..tostring(key).." => "..tostring(value));
module:log("debug", "PD( %s) : %s => %s ", userName,tostring(key),tostring(value));
if ((key == "documents:stanza:io:json") and (type(value) == "table")) then
--module:log("debug", "PD("..userName..") : "..json.encode(value));
-- module:log("debug", "PD( %s ) : %s ", userName ,json.encode(value));
for k2,v2 in pairs(value) do
if (not tokens) then
local fcm = deserializeDataElement(v2, "fcm");
if (type(fcm) == "table") then
tokens = {};
for token,x in pairs(fcm) do
-- module:log("debug", "token: %s - x[os]: %s", token, x["os"]);
if (x["os"] ~= nil) then
table.insert(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"]);
end
end
if (not nType) then
local options = deserializeDataElement(v2, "notification");
-- module:log("info", "got new NTYPE option %s", options);
if (options) then
nType = tonumber(options);
-- module:log("info", "got nType: %s", nType);
end
end
if (enableSound == nil) then
local enableSoundOpt = deserializeDataElement(v2, "enabledSound");
-- module:log("info", "got new SOUND option %s", enableSoundOpt);
enableSound = enableSoundOpt;
end;
local pGlobalMute = deserializeDataElement(v2, "globalMute");
if (pGlobalMute) then
-- module:log("info", "got pGlobalMute: %s", pGlobalMute);
nType = 0;
isGlobalMute = true;
end
if (not nLang) then
local options = deserializeDataElement(v2, "lang");
-- module:log("debug", "PDLang: %s => %s", userName, json.encode(options));
if (options) and (type(options) == "string") then
nLang = options;
end;
-- module:log("debug", "type of options: %s", type(options));
end
end
break; -- we can stop after the documents:stanza:io:json element
end
end
end
--module:log("debug", "getNotifyOptionsForUser("..userName..") = "..tostring(tokens).." => "..tostring(nType));
local map_res = fcm_token_store:get(userName);
-- module:log("debug", "result from map store for %s is: %s", userName, dumpTable(map_res));
if (map_res) then
for _device, _tkr in pairs(map_res) do
-- module:log("debug", "got for %s device %s => %s", userName, _device, dumpTable(_tkr));
-- module:log("debug", "got for %s device %s => token: %s - os: %s", userName, _device, _tkr.token, _tkr.os);
local sessions = prosody.bare_sessions;
local ubjid = userName.."@"..storage_host;
local u_sessions = sessions[ubjid] or nil;
local deviceonline = false;
if (u_sessions) then
local u_sessions_device = u_sessions["sessions"] or nil;
if (u_sessions_device) then
-- module:log("info", "devise is %s", _device);
-- module:log("debug", "u_sessions_device %s", dumpTable(u_sessions_device));
local dsession = u_sessions_device[_device] or nil;
-- module:log("debug", "dsession %s", dumpTable(dsession));
if (dsession) then
local hibernated = dsession["hibernated"] or nil;
local hibernating = dsession["hibernating"] or nil;
local awaiting_ack = dsession["awaiting_ack"] or nil;
-- module:log("info", "found session for _device %s - hibernating: %s, hibernated %s", _device, hibernating, hibernated);
if not ((hibernated) or (hibernating)) then
local dev_in_background = inactive_devices[_device] or nil;
if dev_in_background then
if awaiting_ack then
module:log("debug", "device %s is still connected, but inactive and awaiting_ack", _device);
-- module:log("info", "handling1: %s", _tkr.token.."@".._tkr.os);
else
module:log("debug", "device %s is still connected, but inactive", _device);
-- module:log("info", "handling2: %s", _tkr.token.."@".._tkr.os);
if (_tkr.os ~= "ios") then
deviceonline = true;
end
end
else
if awaiting_ack then
module:log("debug", "device %s is connected and active, but awaiting_ack", _device);
-- module:log("info", "handling3: %s", _tkr.token.."@".._tkr.os);
else
module:log("debug", "device %s is connected and active", _device);
-- module:log("info", "handling4: %s", _tkr.token.."@".._tkr.os);
if (_tkr.os ~= "ios") then
deviceonline = true;
end
end
end
end
end
end
end
if not(deviceonline) then
local already_added = contains(tokens, _tkr.token.."@".._tkr.os);
module:log("debug","already_added: %s", _tkr.token.."@".._tkr.os);
if not(tokens) then
tokens = {};
end
if not(already_added) then
table.insert(tokens, _tkr.token.."@".._tkr.os);
end
end
-- module:log("debug","tokens table is now: %s", dumpTable(tokens));
end
end
if (not tokens) then tokens = {}; end;
if (not nType) then nType = 2; end;
local sessions = prosody.bare_sessions;
local ubjid = userName.."@"..ThisDomain;
for _user, _resdetails in pairs(sessions) do
if (_user == ubjid) then
for _res, _detail1 in pairs(_resdetails["sessions"]) do
local hibernated = _detail1["hibernated"] or _detail1["hibernating"] or nil;
if (hibernated) then
module:log("debug", "for _user: %s and _res: %s got hibernated since: %s", _user, _res, hibernated);
else
module:log("debug", "for _user: %s and _res: %s got active session?", _user, _res);
--module:log("debug", "for _user: %s and _res: %s got detail: %s", _user, _res, dumpTable(_detail1));
end
end
-- module:log("debug", "session for _res %s is: %s", _res, dumpTable(_resdetails));
end
end
if (isGlobalMute) then
tokens = {};
end
-- module:log("debug", "sessions: %s", dumpTable(sessions[ubjid]));
return nType, tokens, nLang, enableSound;
end
end
local function fcm_notify(userName, title, body, type, _senderName, _groupTopic, vnc_attachment_type, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom, _jitsiURL, _jitsiRoom, _isMessageCorrection)
if (not moduleIsActive) then module:log("info", "module not active - bailung out"); return end;
-- module:log("info", "getting notify opts for %s", userName);
local uname, uhost = jid_split(userName);
if (uhost ~= storage_host) then return end;
local nType, tokens, lang, soundOpt = getNotifyOptionsForUser(uname);
if (tokens and (#tokens) > 0) and ((nType == 1) or (nType == 2))
then
-- module:log(
-- "info", "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";
-- gTopic = "new message";
body = "";
end
for i,token in pairs(tokens) do
if (token)
then
local rtoken, ros = jid_split(token);
-- module:log("debug","got rtoken: %s - os: %s ", rtoken, ros);
if (ros == "ios") then
-- module:log("debug", "postFCMIOS with (%s, %s, %s, %s, %s, %s, %s, %s, %s)", userName, rtoken, title, body, type, _senderName, _groupTopic, vnc_attachment_type, lang);
postFCMIOS(userName, rtoken, title, body, type, sName, gTopic, vnc_attachment_type, lang, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom, _jitsiURL, _jitsiRoom, _isMessageCorrection, soundOpt);
else
-- module:log("info", "postFCM with (%s, %s, %s, %s, %s, %s, %s, %s, %s)", userName, rtoken, title, body, type, _senderName, _groupTopic, vnc_attachment_type, lang, _nFrom);
postFCM(userName, rtoken, title, body, type, sName, gTopic, vnc_attachment_type, lang, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom, _jitsiURL, _jitsiRoom, _isMessageCorrection, soundOpt);
end
end
end
else
module:log("debug", "fcm_notify(%s, %s, %s) => type=%s, no tokens", userName, title, body, tostring(nType));
end
end
-- ─── MUC broadcast handler (deferred) ──────────────────────────────
--
-- handle_muc_message runs on the muc-broadcast-message event. The
-- notification work (cache lookups, HTTP request setup, affiliate loop)
-- is deferred to a zero-delay timer so it does not block message
-- delivery to room occupants. The stanza is cloned before deferral
-- because the original may be recycled after the event handler returns.
local function handle_muc_message(event)
-- event.room and event.stanza are available
local room = event.room
local stanza = event.stanza
-- module:log("info", "handling stanza %s", stanza);
-- module:log("info", "room: %s", dumpTable(room));
local stanza = event.stanza:clone()
local message_id = event.stanza.attr.id;
-- module:log("info","message id: %s", event.stanza.attr.id);
local type = stanza.attr.type or "normal";
local from = jid_bare(stanza.attr.from);
local to = jid_bare(stanza.attr.to) or from;
local vncTalkConferenceEtype = stanza:find("{xmpp:vnctalk}vncTalkConference/eventType#") or nil;
-- module:log("info", "handling message from=%s, to=%s", stanza.attr.from, to);
timer.add_task(0, function()
local message_id = stanza.attr.id;
local type = stanza.attr.type or "normal";
local from = jid_bare(stanza.attr.from);
local to = jid_bare(stanza.attr.to) or from;
local vncTalkConferenceEtype = stanza:find("{xmpp:vnctalk}vncTalkConference/eventType#") or nil;
local content = trim(stanza:find("body#"));
local delayedDelivery = stanza:get_child("delay", "urn:xmpp:delay");
-- if we have a vncTalk incoming or whiteboard element, it overrides the type
local vncTalkIncomingType = stanza:find("{xmpp:vnctalk}vncTalkConference/conferenceType#");
local vncTalkConferenceId = stanza:find("{xmpp:vnctalk}vncTalkConference/conferenceId#") or nil;
local jitsiURL = stanza:find("{xmpp:vnctalk}vncTalkConference/jitsiURL#") or nil;
local jitsiRoom = stanza:find("{xmpp:vnctalk}vncTalkConference/jitsiRoom#") or nil;
local vncTalkWhiteboard = stanza:find("{xmpp:vnctalk}whiteboard");
local jitsiURL = stanza:find("{xmpp:vnctalk}vncTalkConference/jitsiURL#") or nil;
local jitsiRoom = stanza:find("{xmpp:vnctalk}vncTalkConference/jitsiRoom#") or nil;
local vncTalkWhiteboard = stanza:find("{xmpp:vnctalk}whiteboard");
local isSentCarbonMessage = stanza:get_child("sent", "urn:xmpp:carbons:2");
local vnc_attachment_type = stanza:find("{xmpp:vnctalk}attachment/fileType#");
local vnc_attachment_type = stanza:find("{xmpp:vnctalk}attachment/fileType#");
local vnctalk_broadcast = stanza:get_child("vncTalkBroadcast", "xmpp:vnctalk");
local vnctalk_broadcast_childid = nil;
if vnctalk_broadcast then
if starts_with(to, "broadcast") then
-- module:log("message is to broadcast - bailing out");
return nil;
return;
end
vnctalk_broadcast_childid = vnctalk_broadcast.attr.id;
local vnctalk_broadcast_title = vnctalk_broadcast.attr.title;
local vnctalk_broadcast_target = vnctalk_broadcast.attr.origtarget;
local body = stanza:get_child("body");
-- module:log("debug", "broadcast body: %s", content);
-- module:log("debug", "jid_split(to): %s", jid_split(to));
-- fcm_notify(jid_split(to), vnctalk_broadcast_target, content, type, getDisplayName(jid_split(from)), "Broadcast: "..vnctalk_broadcast_title, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId);
return nil;
return;
end
local isMessageCorrection = stanza:find("{urn:xmpp:message-correct:0}replace@id") or nil;
if (isMessageCorrection) then
content = " ";
type = "CORRECTION";
end
local isMessageCorrection = stanza:find("{urn:xmpp:message-correct:0}replace@id") or nil;
if (isMessageCorrection) then
content = " ";
type = "CORRECTION";
end
local auxType = vncTalkIncomingType or (vncTalkWhiteboard and "whiteboard");
if (auxType)
then
if (auxType) then
content = auxType;
type = auxType;
end;
@@ -679,29 +130,45 @@ local function handle_muc_message(event)
);
if (doNotify) then
local sender = "";
local f2,h2,n2 = jid.split(stanza.attr.from);
if (room._affiliations[n2] ~= nil) then sender = n2; end
-- module:log("info","sender is %s", sender);
local roomTopic = room._data.subject or nil;
for jid, aff in pairs(room._affiliations) do
local affs = room._affiliations;
if not affs then return end
local sender = "";
local f2, h2, n2 = jid_split(stanza.attr.from);
if (affs[n2] ~= nil) then sender = n2; end
local roomTopic = room._data.subject or nil;
local senderDisplayName = getDisplayName_cached(sender);
local roomJid = to;
for aff_jid, aff in pairs(affs) do
if aff ~= "outcast" then
-- module:log("info", "found jid: %s as aff: %s", jid, aff);
local roomJid = to;
if (sender ~= jid) then
module:log("info", "from is: %s", from);
fcm_notify(jid, roomJid, content, type, getDisplayName(jid_split(sender)), roomTopic, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId, sender, jitsiURL, jitsiRoom, isMessageCorrection);
end
if (sender ~= aff_jid) then
module:log("info", "from is: %s", from);
fcm_notify(aff_jid, roomJid, content, type, senderDisplayName, roomTopic, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId, sender, jitsiURL, jitsiRoom, isMessageCorrection);
end
end
end
end
end)
end
-- ─── event hooks ───────────────────────────────────────────────────
module:hook("muc-broadcast-message", handle_muc_message, 5)
module:hook("host-activated", function(host)
module:log("info", "got activated event for host %s", host);
end);
module:hook_global("vnc-fcm-invalidate-notify-cache", function(event)
if event and event.username then
invalidateNotifyCache(event.username);
end
end);
module:hook_global("vnc-fcm-invalidate-vcard-cache", function(event)
if event and event.username then
invalidateVcardCache(event.username);
end
end);
module:log("debug", "Module loaded")
+692
View File
@@ -0,0 +1,692 @@
-- 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