comparison mod_cloud_notify/mod_cloud_notify.lua @ 3943:f5e6368a1c39

mod_cloud_notify: Cleanup code and drop support for prosody 0.9 This removes some legacy stuff that was needed for prosody 0.9 We now use util.stanza:find instead of our own patched version to be more mainstream The unneeded module unloading code was removed, too
author tmolitor <thilo@eightysoft.de>
date Wed, 11 Mar 2020 20:00:50 +0100
parents 675726ab06d3
children 6bf362008052
comparison
equal deleted inserted replaced
3942:e93e58b33bf6 3943:f5e6368a1c39
12 local st = require"util.stanza"; 12 local st = require"util.stanza";
13 local jid = require"util.jid"; 13 local jid = require"util.jid";
14 local dataform = require"util.dataforms".new; 14 local dataform = require"util.dataforms".new;
15 local filters = require"util.filters"; 15 local filters = require"util.filters";
16 local hashes = require"util.hashes"; 16 local hashes = require"util.hashes";
17 local random = require"util.random";
18 local cache = require"util.cache";
17 19
18 local xmlns_push = "urn:xmpp:push:0"; 20 local xmlns_push = "urn:xmpp:push:0";
19 21
20 -- configuration 22 -- configuration
21 local include_body = module:get_option_boolean("push_notification_with_body", false); 23 local include_body = module:get_option_boolean("push_notification_with_body", false);
25 local dummy_body = module:get_option_string("push_notification_important_body", "New Message!"); 27 local dummy_body = module:get_option_string("push_notification_important_body", "New Message!");
26 28
27 local host_sessions = prosody.hosts[module.host].sessions; 29 local host_sessions = prosody.hosts[module.host].sessions;
28 local push_errors = {}; 30 local push_errors = {};
29 local id2node = {}; 31 local id2node = {};
30 32 local id2identifier = {};
31 -- ordered table iterator, allow to iterate on the natural order of the keys of a table,
32 -- see http://lua-users.org/wiki/SortedIteration
33 local function __genOrderedIndex( t )
34 local orderedIndex = {}
35 for key in pairs(t) do
36 table.insert( orderedIndex, key )
37 end
38 -- sort in reverse order (newest one first)
39 table.sort( orderedIndex, function(a, b)
40 if a == nil or t[a] == nil or b == nil or t[b] == nil then return false end
41 -- only one timestamp given, this is the newer one
42 if t[a].timestamp ~= nil and t[b].timestamp == nil then return true end
43 if t[a].timestamp == nil and t[b].timestamp ~= nil then return false end
44 -- both timestamps given, sort normally
45 if t[a].timestamp ~= nil and t[b].timestamp ~= nil then return t[a].timestamp > t[b].timestamp end
46 return false -- normally not reached
47 end)
48 return orderedIndex
49 end
50 local function orderedNext(t, state)
51 -- Equivalent of the next function, but returns the keys in timestamp
52 -- order. We use a temporary ordered key table that is stored in the
53 -- table being iterated.
54
55 local key = nil
56 --print("orderedNext: state = "..tostring(state) )
57 if state == nil then
58 -- the first time, generate the index
59 t.__orderedIndex = __genOrderedIndex( t )
60 key = t.__orderedIndex[1]
61 else
62 -- fetch the next value
63 for i = 1, #t.__orderedIndex do
64 if t.__orderedIndex[i] == state then
65 key = t.__orderedIndex[i+1]
66 end
67 end
68 end
69
70 if key then
71 return key, t[key]
72 end
73
74 -- no more value to return, cleanup
75 t.__orderedIndex = nil
76 return
77 end
78 local function orderedPairs(t)
79 -- Equivalent of the pairs() function on tables. Allows to iterate
80 -- in order
81 return orderedNext, t, nil
82 end
83
84 -- small helper function to return new table with only "maximum" elements containing only the newest entries
85 local function reduce_table(table, maximum)
86 local count = 0;
87 local result = {};
88 for key, value in orderedPairs(table) do
89 count = count + 1;
90 if count > maximum then break end
91 result[key] = value;
92 end
93 return result;
94 end
95
96 local function stoppable_timer(delay, callback)
97 local stopped = false;
98 local timer = module:add_timer(delay, function (t)
99 if stopped then return; end
100 return callback(t);
101 end);
102 if timer and timer.stop then return timer; end -- new prosody api includes stop() function
103 return {
104 stop = function(self) stopped = true end;
105 timer;
106 };
107 end
108 33
109 -- For keeping state across reloads while caching reads 34 -- For keeping state across reloads while caching reads
35 -- This uses util.cache for caching the most recent devices and removing all old devices when max_push_devices is reached
110 local push_store = (function() 36 local push_store = (function()
111 local store = module:open_store(); 37 local store = module:open_store();
112 local push_services = {}; 38 local push_services = {};
113 local api = {}; 39 local api = {};
114 function api:get(user) 40 function api:get(user)
115 if not push_services[user] then 41 if not push_services[user] then
116 local err; 42 local loaded, err = store:get(user);
117 push_services[user], err = store:get(user); 43 if not loaded and err then
118 if not push_services[user] and err then
119 module:log("warn", "Error reading push notification storage for user '%s': %s", user, tostring(err)); 44 module:log("warn", "Error reading push notification storage for user '%s': %s", user, tostring(err));
120 push_services[user] = {}; 45 push_services[user] = cache.new(max_push_devices):table();
121 return push_services[user], false; 46 return push_services[user], false;
122 end 47 end
123 end 48 if loaded then
124 if not push_services[user] then push_services[user] = {} end 49 push_services[user] = cache.new(max_push_devices):table();
50 -- copy over plain table loaded from disk into our cache
51 for k, v in pairs(loaded) do push_services[user][k] = v; end
52 else
53 push_services[user] = cache.new(max_push_devices):table();
54 end
55 end
125 return push_services[user], true; 56 return push_services[user], true;
126 end 57 end
127 function api:set(user, data) 58 function api:flush_to_disk(user)
128 push_services[user] = reduce_table(data, max_push_devices); 59 local plain_table = {};
129 local ok, err = store:set(user, push_services[user]); 60 for k, v in pairs(push_services[user]) do plain_table[k] = v; end
61 local ok, err = store:set(user, plain_table);
130 if not ok then 62 if not ok then
131 module:log("error", "Error writing push notification storage for user '%s': %s", user, tostring(err)); 63 module:log("error", "Error writing push notification storage for user '%s': %s", user, tostring(err));
132 return false; 64 return false;
133 end 65 end
134 return true; 66 return true;
135 end 67 end
136 function api:set_identifier(user, push_identifier, data) 68 function api:set_identifier(user, push_identifier, data)
137 local services = self:get(user); 69 local services = self:get(user);
138 services[push_identifier] = data; 70 services[push_identifier] = data;
139 return self:set(user, services);
140 end 71 end
141 return api; 72 return api;
142 end)(); 73 end)();
143 74
144 75
147 78
148 function handle_push_error(event) 79 function handle_push_error(event)
149 local stanza = event.stanza; 80 local stanza = event.stanza;
150 local error_type, condition = stanza:get_error(); 81 local error_type, condition = stanza:get_error();
151 local node = id2node[stanza.attr.id]; 82 local node = id2node[stanza.attr.id];
83 local identifier = id2identifier[stanza.attr.id];
152 if node == nil then return false; end -- unknown stanza? Ignore for now! 84 if node == nil then return false; end -- unknown stanza? Ignore for now!
153 local from = stanza.attr.from; 85 local from = stanza.attr.from;
154 local user_push_services = push_store:get(node); 86 local user_push_services = push_store:get(node);
155 local changed = false; 87 local changed = false;
156 88
157 for push_identifier, _ in pairs(user_push_services) do 89 for push_identifier, _ in pairs(user_push_services) do
158 local stanza_id = hashes.sha256(push_identifier, true); 90 if push_identifier == identifier then
159 if stanza_id == stanza.attr.id then
160 if user_push_services[push_identifier] and user_push_services[push_identifier].jid == from and error_type ~= "wait" then 91 if user_push_services[push_identifier] and user_push_services[push_identifier].jid == from and error_type ~= "wait" then
161 push_errors[push_identifier] = push_errors[push_identifier] + 1; 92 push_errors[push_identifier] = push_errors[push_identifier] + 1;
162 module:log("info", "Got error of type '%s' (%s) for identifier '%s': " 93 module:log("info", "Got error of type '%s' (%s) for identifier '%s': "
163 .."error count for this identifier is now at %s", error_type, condition, push_identifier, 94 .."error count for this identifier is now at %s", error_type, condition, push_identifier,
164 tostring(push_errors[push_identifier])); 95 tostring(push_errors[push_identifier]));
177 -- save changed global config 108 -- save changed global config
178 changed = true; 109 changed = true;
179 user_push_services[push_identifier] = nil 110 user_push_services[push_identifier] = nil
180 push_errors[push_identifier] = nil; 111 push_errors[push_identifier] = nil;
181 -- unhook iq handlers for this identifier (if possible) 112 -- unhook iq handlers for this identifier (if possible)
182 if module.unhook then 113 module:unhook("iq-error/host/"..stanza.attr.id, handle_push_error);
183 module:unhook("iq-error/host/"..stanza_id, handle_push_error); 114 module:unhook("iq-result/host/"..stanza.attr.id, handle_push_success);
184 module:unhook("iq-result/host/"..stanza_id, handle_push_success); 115 id2node[stanza.attr.id] = nil;
185 id2node[stanza_id] = nil; 116 id2identifier[stanza.attr.id] = nil;
186 end
187 end 117 end
188 elseif user_push_services[push_identifier] and user_push_services[push_identifier].jid == from and error_type == "wait" then 118 elseif user_push_services[push_identifier] and user_push_services[push_identifier].jid == from and error_type == "wait" then
189 module:log("debug", "Got error of type '%s' (%s) for identifier '%s': " 119 module:log("debug", "Got error of type '%s' (%s) for identifier '%s': "
190 .."NOT increasing error count for this identifier", error_type, condition, push_identifier); 120 .."NOT increasing error count for this identifier", error_type, condition, push_identifier);
191 end 121 end
192 end 122 end
193 end 123 end
194 if changed then 124 if changed then
195 push_store:set(node, user_push_services); 125 push_store:flush_to_disk(node);
196 end 126 end
197 return true; 127 return true;
198 end 128 end
199 129
200 function handle_push_success(event) 130 function handle_push_success(event)
201 local stanza = event.stanza; 131 local stanza = event.stanza;
202 local node = id2node[stanza.attr.id]; 132 local node = id2node[stanza.attr.id];
133 local identifier = id2identifier[stanza.attr.id];
203 if node == nil then return false; end -- unknown stanza? Ignore for now! 134 if node == nil then return false; end -- unknown stanza? Ignore for now!
204 local from = stanza.attr.from; 135 local from = stanza.attr.from;
205 local user_push_services = push_store:get(node); 136 local user_push_services = push_store:get(node);
206 137
207 for push_identifier, _ in pairs(user_push_services) do 138 for push_identifier, _ in pairs(user_push_services) do
208 if hashes.sha256(push_identifier, true) == stanza.attr.id then 139 if push_identifier == identifier then
209 if user_push_services[push_identifier] and user_push_services[push_identifier].jid == from and push_errors[push_identifier] > 0 then 140 if user_push_services[push_identifier] and user_push_services[push_identifier].jid == from and push_errors[push_identifier] > 0 then
210 push_errors[push_identifier] = 0; 141 push_errors[push_identifier] = 0;
211 -- unhook iq handlers for this identifier (if possible) 142 -- unhook iq handlers for this identifier (if possible)
212 if module.unhook then 143 module:unhook("iq-error/host/"..stanza.attr.id, handle_push_error);
213 module:unhook("iq-error/host/"..stanza.attr.id, handle_push_error); 144 module:unhook("iq-result/host/"..stanza.attr.id, handle_push_success);
214 module:unhook("iq-result/host/"..stanza.attr.id, handle_push_success); 145 id2node[stanza.attr.id] = nil;
215 id2node[stanza.attr.id] = nil; 146 id2identifier[stanza.attr.id] = nil;
216 end
217 module:log("debug", "Push succeeded, error count for identifier '%s' is now at %s again", push_identifier, tostring(push_errors[push_identifier])); 147 module:log("debug", "Push succeeded, error count for identifier '%s' is now at %s again", push_identifier, tostring(push_errors[push_identifier]));
218 end 148 end
219 end 149 end
220 end 150 end
221 return true; 151 return true;
254 node = push_node; 184 node = push_node;
255 include_payload = include_payload; 185 include_payload = include_payload;
256 options = publish_options and st.preserialize(publish_options); 186 options = publish_options and st.preserialize(publish_options);
257 timestamp = os_time(); 187 timestamp = os_time();
258 }; 188 };
259 local ok = push_store:set_identifier(origin.username, push_identifier, push_service); 189 push_store:set_identifier(origin.username, push_identifier, push_service);
190 local ok = push_store:flush_to_disk(origin.username);
260 if not ok then 191 if not ok then
261 origin.send(st.error_reply(stanza, "wait", "internal-server-error")); 192 origin.send(st.error_reply(stanza, "wait", "internal-server-error"));
262 else 193 else
263 origin.push_identifier = push_identifier; 194 origin.push_identifier = push_identifier;
264 origin.push_settings = push_service; 195 origin.push_settings = push_service;
288 origin.push_settings = nil; 219 origin.push_settings = nil;
289 origin.first_hibernated_push = nil; 220 origin.first_hibernated_push = nil;
290 end 221 end
291 user_push_services[key] = nil; 222 user_push_services[key] = nil;
292 push_errors[key] = nil; 223 push_errors[key] = nil;
293 if module.unhook then 224 for stanza_id, identifier in pairs(id2identifier) do
294 local stanza_id = hashes.sha256(key, true) 225 if identifier == key then
295 module:unhook("iq-error/host/"..stanza_id, handle_push_error); 226 module:unhook("iq-error/host/"..stanza_id, handle_push_error);
296 module:unhook("iq-result/host/"..stanza_id, handle_push_success); 227 module:unhook("iq-result/host/"..stanza_id, handle_push_success);
297 id2node[stanza_id] = nil; 228 id2node[stanza_id] = nil;
298 end 229 id2identifier[stanza_id] = nil;
299 end 230 end
300 end 231 end
301 local ok = push_store:set(origin.username, user_push_services); 232 end
233 end
234 local ok = push_store:flush_to_disk(origin.username);
302 if not ok then 235 if not ok then
303 origin.send(st.error_reply(stanza, "wait", "internal-server-error")); 236 origin.send(st.error_reply(stanza, "wait", "internal-server-error"));
304 else 237 else
305 origin.send(st.reply(stanza)); 238 origin.send(st.reply(stanza));
306 end 239 end
307 return true; 240 return true;
308 end 241 end
309 module:hook("iq-set/self/"..xmlns_push..":disable", push_disable); 242 module:hook("iq-set/self/"..xmlns_push..":disable", push_disable);
310
311 -- Patched version of util.stanza:find() that supports giving stanza names
312 -- without their namespace, allowing for every namespace.
313 local function find(self, path)
314 local pos = 1;
315 local len = #path + 1;
316
317 repeat
318 local xmlns, name, text;
319 local char = s_sub(path, pos, pos);
320 if char == "@" then
321 return self.attr[s_sub(path, pos + 1)];
322 elseif char == "{" then
323 xmlns, pos = s_match(path, "^([^}]+)}()", pos + 1);
324 end
325 name, text, pos = s_match(path, "^([^@/#]*)([/#]?)()", pos);
326 name = name ~= "" and name or nil;
327 if pos == len then
328 if text == "#" then
329 local child = xmlns ~= nil and self:get_child(name, xmlns) or self:child_with_name(name);
330 return child and child:get_text() or nil;
331 end
332 return xmlns ~= nil and self:get_child(name, xmlns) or self:child_with_name(name);
333 end
334 self = xmlns ~= nil and self:get_child(name, xmlns) or self:child_with_name(name);
335 until not self
336 return nil;
337 end
338 243
339 -- is this push a high priority one (this is needed for ios apps not using voip pushes) 244 -- is this push a high priority one (this is needed for ios apps not using voip pushes)
340 local function is_important(stanza) 245 local function is_important(stanza)
341 local st_name = stanza and stanza.name or nil; 246 local st_name = stanza and stanza.name or nil;
342 if not st_name then return false; end -- nonzas are never important here 247 if not st_name then return false; end -- nonzas are never important here
346 -- unpack carbon copies 251 -- unpack carbon copies
347 local stanza_direction = "in"; 252 local stanza_direction = "in";
348 local carbon; 253 local carbon;
349 local st_type; 254 local st_type;
350 -- support carbon copied message stanzas having an arbitrary message-namespace or no message-namespace at all 255 -- support carbon copied message stanzas having an arbitrary message-namespace or no message-namespace at all
351 if not carbon then carbon = find(stanza, "{urn:xmpp:carbons:2}/forwarded/message"); end 256 if not carbon then carbon = stanza:find("{urn:xmpp:carbons:2}/{urn:xmpp:forward:0}/{jabber:client}message"); end
352 if not carbon then carbon = find(stanza, "{urn:xmpp:carbons:1}/forwarded/message"); end
353 stanza_direction = carbon and stanza:child_with_name("sent") and "out" or "in"; 257 stanza_direction = carbon and stanza:child_with_name("sent") and "out" or "in";
354 if carbon then stanza = carbon; end 258 if carbon then stanza = carbon; end
355 st_type = stanza.attr.type; 259 st_type = stanza.attr.type;
356 260
357 -- headline message are always not important 261 -- headline message are always not important
388 }; 292 };
389 293
390 -- http://xmpp.org/extensions/xep-0357.html#publishing 294 -- http://xmpp.org/extensions/xep-0357.html#publishing
391 local function handle_notify_request(stanza, node, user_push_services, log_push_decline) 295 local function handle_notify_request(stanza, node, user_push_services, log_push_decline)
392 local pushes = 0; 296 local pushes = 0;
393 if not user_push_services or next(user_push_services) == nil then return pushes end 297 if not #user_push_services then return pushes end
394 298
395 for push_identifier, push_info in pairs(user_push_services) do 299 for push_identifier, push_info in pairs(user_push_services) do
396 local send_push = true; -- only send push to this node when not already done for this stanza or if no stanza is given at all 300 local send_push = true; -- only send push to this node when not already done for this stanza or if no stanza is given at all
397 if stanza then 301 if stanza then
398 if not stanza._push_notify then stanza._push_notify = {}; end 302 if not stanza._push_notify then stanza._push_notify = {}; end
405 stanza._push_notify[push_identifier] = true; 309 stanza._push_notify[push_identifier] = true;
406 end 310 end
407 311
408 if send_push then 312 if send_push then
409 -- construct push stanza 313 -- construct push stanza
410 local stanza_id = hashes.sha256(push_identifier, true); 314 local stanza_id = hashes.sha256(random.bytes(8), true);
411 local push_publish = st.iq({ to = push_info.jid, from = module.host, type = "set", id = stanza_id }) 315 local push_publish = st.iq({ to = push_info.jid, from = module.host, type = "set", id = stanza_id })
412 :tag("pubsub", { xmlns = "http://jabber.org/protocol/pubsub" }) 316 :tag("pubsub", { xmlns = "http://jabber.org/protocol/pubsub" })
413 :tag("publish", { node = push_info.node }) 317 :tag("publish", { node = push_info.node })
414 :tag("item") 318 :tag("item")
415 :tag("notification", { xmlns = xmlns_push }); 319 :tag("notification", { xmlns = xmlns_push });
436 module:log("debug", "Sending%s push notification for %s@%s to %s (%s)", form_data["last-message-body"] and " important" or "", node, module.host, push_info.jid, tostring(push_info.node)); 340 module:log("debug", "Sending%s push notification for %s@%s to %s (%s)", form_data["last-message-body"] and " important" or "", node, module.host, push_info.jid, tostring(push_info.node));
437 -- module:log("debug", "PUSH STANZA: %s", tostring(push_publish)); 341 -- module:log("debug", "PUSH STANZA: %s", tostring(push_publish));
438 -- handle push errors for this node 342 -- handle push errors for this node
439 if push_errors[push_identifier] == nil then 343 if push_errors[push_identifier] == nil then
440 push_errors[push_identifier] = 0; 344 push_errors[push_identifier] = 0;
441 module:hook("iq-error/host/"..stanza_id, handle_push_error); 345 end
442 module:hook("iq-result/host/"..stanza_id, handle_push_success); 346 module:hook("iq-error/host/"..stanza_id, handle_push_error);
443 id2node[stanza_id] = node; 347 module:hook("iq-result/host/"..stanza_id, handle_push_success);
444 end 348 id2node[stanza_id] = node;
349 id2identifier[stanza_id] = push_identifier;
445 module:send(push_publish); 350 module:send(push_publish);
446 pushes = pushes + 1; 351 pushes = pushes + 1;
447 end 352 end
448 end 353 end
449 return pushes; 354 return pushes;
498 if not session.push_queue then session.push_queue = {}; end 403 if not session.push_queue then session.push_queue = {}; end
499 local queue = session.push_queue; 404 local queue = session.push_queue;
500 queue[#queue+1] = st.clone(stanza); 405 queue[#queue+1] = st.clone(stanza);
501 if #queue == 1 then -- first stanza --> start timer 406 if #queue == 1 then -- first stanza --> start timer
502 session.log("debug", "Invoking cloud handle_notify_request() for newly smacks queued stanza (in a moment)"); 407 session.log("debug", "Invoking cloud handle_notify_request() for newly smacks queued stanza (in a moment)");
503 session.awaiting_push_timer = stoppable_timer(1e-06, function () 408 session.awaiting_push_timer = module:add_timer(1e-06, function ()
504 session.log("debug", "Invoking cloud handle_notify_request() for newly smacks queued stanzas (now in timer)"); 409 session.log("debug", "Invoking cloud handle_notify_request() for newly smacks queued stanzas (now in timer)");
505 process_stanza_queue(session.push_queue, session, "push"); 410 process_stanza_queue(session.push_queue, session, "push");
506 session.push_queue = {}; -- clean up queue after push 411 session.push_queue = {}; -- clean up queue after push
507 end); 412 end);
508 end 413 end
550 to = to and jid.split(to) or event.origin.username; 455 to = to and jid.split(to) or event.origin.username;
551 456
552 -- only notify if the stanza destination is the mam user we store it for 457 -- only notify if the stanza destination is the mam user we store it for
553 if event.for_user == to then 458 if event.for_user == to then
554 local user_push_services = push_store:get(to); 459 local user_push_services = push_store:get(to);
555 if next(user_push_services) == nil then return end
556 460
557 -- only notify nodes with no active sessions (smacks is counted as active and handled separate) 461 -- only notify nodes with no active sessions (smacks is counted as active and handled separate)
558 local notify_push_services = {}; 462 local notify_push_services = {};
559 for identifier, push_info in pairs(user_push_services) do 463 for identifier, push_info in pairs(user_push_services) do
560 local identifier_found = nil; 464 local identifier_found = nil;
582 module:hook("archive-message-added", archive_message_added); 486 module:hook("archive-message-added", archive_message_added);
583 487
584 local function send_ping(event) 488 local function send_ping(event)
585 local user = event.user; 489 local user = event.user;
586 local push_services = event.push_services or push_store:get(user); 490 local push_services = event.push_services or push_store:get(user);
587 handle_notify_request(nil, user, push_services, true); 491 module:log("debug", "Handling event 'cloud-notify-ping' for user '%s'", user);
492 local retval = handle_notify_request(nil, user, push_services, true);
493 module:log("debug", "handle_notify_request() returned %s", tostring(retval));
588 end 494 end
589 -- can be used by other modules to ping one or more (or all) push endpoints 495 -- can be used by other modules to ping one or more (or all) push endpoints
590 module:hook("cloud-notify-ping", send_ping); 496 module:hook("cloud-notify-ping", send_ping);
591 497
592 module:log("info", "Module loaded"); 498 module:log("info", "Module loaded");
593 function module.unload() 499 function module.unload()
594 if module.unhook then
595 module:unhook("account-disco-info", account_dico_info);
596 module:unhook("iq-set/self/"..xmlns_push..":enable", push_enable);
597 module:unhook("iq-set/self/"..xmlns_push..":disable", push_disable);
598
599 module:unhook("smacks-hibernation-start", hibernate_session);
600 module:unhook("smacks-hibernation-end", restore_session);
601 module:unhook("smacks-ack-delayed", ack_delayed);
602 module:unhook("archive-message-added", archive_message_added);
603 module:unhook("cloud-notify-ping", send_ping);
604
605 for push_identifier, _ in pairs(push_errors) do
606 local stanza_id = hashes.sha256(push_identifier, true);
607 module:unhook("iq-error/host/"..stanza_id, handle_push_error);
608 module:unhook("iq-result/host/"..stanza_id, handle_push_success);
609 id2node[stanza_id] = nil;
610 end
611 end
612
613 module:log("info", "Module unloaded"); 500 module:log("info", "Module unloaded");
614 end 501 end