1 -- XEP-0313: Message Archive Management for Prosody MUC
2 -- Copyright (C) 2011-2017 Kim Alvefur
4 -- This file is MIT/X11 licensed.
6 if module
:get_host_type() ~= "component" then
7 module
:log("error", "mod_%s should be loaded only on a MUC component, not normal hosts", module
.name
);
11 local xmlns_mam
= "urn:xmpp:mam:2";
12 local xmlns_delay
= "urn:xmpp:delay";
13 local xmlns_forward
= "urn:xmpp:forward:0";
14 local xmlns_st_id
= "urn:xmpp:sid:0";
15 local xmlns_muc_user
= "http://jabber.org/protocol/muc#user";
16 local muc_form_enable
= "muc#roomconfig_enablearchiving"
18 local st
= require
"util.stanza";
19 local rsm
= require
"util.rsm";
20 local jid_bare
= require
"util.jid".bare
;
21 local jid_split
= require
"util.jid".split
;
22 local jid_prep
= require
"util.jid".prep
;
23 local dataform
= require
"util.dataforms".new
;
25 local mod_muc
= module
:depends
"muc";
26 local get_room_from_jid
= mod_muc
.get_room_from_jid
;
28 local is_stanza
= st
.is_stanza
;
29 local tostring = tostring;
30 local time_now
= os
.time
;
31 local m_min
= math
.min;
32 local timestamp
, timestamp_parse
, datestamp
= import( "util.datetime", "datetime", "parse", "date");
33 local default_max_items
, max_max_items
= 20, module
:get_option_number("max_archive_query_results", 50);
35 local default_history_length
= 20;
36 local max_history_length
= module
:get_option_number("max_history_messages", math
.huge
);
38 local function get_historylength(room
)
39 return math
.min(room
._data
.history_length
or default_history_length
, max_history_length
);
42 function schedule_cleanup()
43 -- replaced by non-noop later if cleanup is enabled
46 local log_all_rooms
= module
:get_option_boolean("muc_log_all_rooms", false);
47 local log_by_default
= module
:get_option_boolean("muc_log_by_default", true);
49 local archive_store
= "muc_log";
50 local archive
= module
:open_store(archive_store
, "archive");
52 if archive
.name
== "null" or not archive
.find
then
53 if not archive
.find
then
54 module
:log("error", "Attempt to open archive storage returned a driver without archive API support");
55 module
:log("error", "mod_%s does not support archiving",
56 archive
._provided_by
or archive
.name
and "storage_"..archive
.name
.."(?)" or "<unknown>");
58 module
:log("error", "Attempt to open archive storage returned null driver");
60 module
:log("info", "See https://prosody.im/doc/storage and https://prosody.im/doc/archiving for more information");
64 local function archiving_enabled(room
)
68 local enabled
= room
._data
.archiving
;
69 if enabled
== nil then
70 return log_by_default
;
75 if not log_all_rooms
then
76 module
:hook("muc-config-form", function(event
)
77 local room
, form
= event
.room
, event
.form
;
80 name
= muc_form_enable
,
82 label
= "Enable archiving?",
83 value
= archiving_enabled(room
),
88 module
:hook("muc-config-submitted/"..muc_form_enable
, function(event
)
89 event
.room
._data
.archiving
= event
.value
;
90 event
.status_codes
[event
.value
and "170" or "171"] = true;
94 -- Note: We ignore the 'with' field as this is internally used for stanza types
95 local query_form
= dataform
{
96 { name
= "FORM_TYPE"; type = "hidden"; value
= xmlns_mam
; };
97 { name
= "with"; type = "jid-single"; };
98 { name
= "start"; type = "text-single" };
99 { name
= "end"; type = "text-single"; };
103 module
:hook("iq-get/bare/"..xmlns_mam
..":query", function(event
)
104 local origin
, stanza
= event
.origin
, event
.stanza
;
105 origin
.send(st
.reply(stanza
):tag("query", { xmlns
= xmlns_mam
}):add_child(query_form
:form()));
109 -- Handle archive queries
110 module
:hook("iq-set/bare/"..xmlns_mam
..":query", function(event
)
111 local origin
, stanza
= event
.origin
, event
.stanza
;
112 local room_jid
= stanza
.attr
.to
;
113 local room_node
= jid_split(room_jid
);
114 local orig_from
= stanza
.attr
.from
;
115 local query
= stanza
.tags
[1];
117 local room
= get_room_from_jid(room_jid
);
119 origin
.send(st
.error_reply(stanza
, "cancel", "item-not-found"))
122 local from
= jid_bare(orig_from
);
124 -- Banned or not a member of a members-only room?
125 local from_affiliation
= room
:get_affiliation(from
);
126 if from_affiliation
== "outcast" -- banned
127 or room
:get_members_only() and not from_affiliation
then -- members-only, not a member
128 origin
.send(st
.error_reply(stanza
, "auth", "forbidden"))
132 local qid
= query
.attr
.queryid
;
134 -- Search query parameters
136 local form
= query
:get_child("x", "jabber:x:data");
139 form
, err
= query_form
:data(form
);
141 origin
.send(st
.error_reply(stanza
, "modify", "bad-request", select(2, next(err
))));
144 qstart
, qend
= form
["start"], form
["end"];
147 if qstart
or qend
then -- Validate timestamps
148 local vstart
, vend
= (qstart
and timestamp_parse(qstart
)), (qend
and timestamp_parse(qend
))
149 if (qstart
and not vstart
) or (qend
and not vend
) then
150 origin
.send(st
.error_reply(stanza
, "modify", "bad-request", "Invalid timestamp"))
153 qstart
, qend
= vstart
, vend
;
156 module
:log("debug", "Archive query id %s from %s until %s)",
158 qstart
and timestamp(qstart
) or "the dawn of time",
159 qend
and timestamp(qend
) or "now");
162 local qset
= rsm
.get(query
);
163 local qmax
= m_min(qset
and qset
.max or default_max_items
, max_max_items
);
164 local reverse
= qset
and qset
.before
or false;
166 local before
, after
= qset
and qset
.before
, qset
and qset
.after
;
167 if type(before
) ~= "string" then before
= nil; end
169 -- Load all the data!
170 local data
, err
= archive
:find(room_node
, {
171 start
= qstart
; ["end"] = qend
; -- Time range
173 before
= before
; after
= after
;
175 with
= "message<groupchat";
179 origin
.send(st
.error_reply(stanza
, "cancel", "internal-server-error"));
182 local total
= tonumber(err
);
184 local msg_reply_attr
= { to
= stanza
.attr
.from
, from
= stanza
.attr
.to
};
188 -- Wrap it in stuff and deliver
191 local complete
= "true";
192 for id
, item
, when
in data
do
198 local fwd_st
= st
.message(msg_reply_attr
)
199 :tag("result", { xmlns
= xmlns_mam
, queryid
= qid
, id
= id
})
200 :tag("forwarded", { xmlns
= xmlns_forward
})
201 :tag("delay", { xmlns
= xmlns_delay
, stamp
= timestamp(when
) }):up();
203 -- Strip <x> tag, containing the original senders JID, unless the room makes this public
204 if room
:get_whois() ~= "anyone" then
205 item
:maptags(function (tag)
206 if tag.name
== "x" and tag.attr
.xmlns
== xmlns_muc_user
then
212 if not is_stanza(item
) then
213 item
= st
.deserialize(item
);
216 item
.attr
.xmlns
= "jabber:client";
217 fwd_st
:add_child(item
);
219 if not first
then first
= id
; end
223 results
[count
] = fwd_st
;
230 for i
= #results
, 1, -1 do
231 origin
.send(results
[i
]);
233 first
, last
= last
, first
;
237 module
:log("debug", "Archive query %s completed", tostring(qid
));
239 origin
.send(st
.reply(stanza
)
240 :tag("fin", { xmlns
= xmlns_mam
, queryid
= qid
, complete
= complete
})
241 :add_child(rsm
.generate
{
242 first
= first
, last
= last
, count
= total
}));
246 module
:hook("muc-get-history", function (event
)
247 local room
= event
.room
;
248 if not archiving_enabled(room
) then return end
249 local room_jid
= room
.jid
;
250 local maxstanzas
= event
.maxstanzas
;
251 local maxchars
= event
.maxchars
;
252 local since
= event
.since
;
255 if maxstanzas
== 0 or maxchars
== 0 then
256 return -- No history requested
259 if not maxstanzas
or maxstanzas
> get_historylength(room
) then
260 maxstanzas
= get_historylength(room
);
263 if room
._history
and #room
._history
>= maxstanzas
then
264 return -- It can deal with this itself
267 -- Load all the data!
272 with
= "message<groupchat";
274 local data
, err
= archive
:find(jid_split(room_jid
), query
);
277 module
:log("error", "Could not fetch history: %s", tostring(err
));
281 local history
, i
= {}, 1;
283 for id
, item
, when
in data
do
285 item
:tag("delay", { xmlns
= "urn:xmpp:delay", from
= room_jid
, stamp
= timestamp(when
) }):up(); -- XEP-0203
286 item
:tag("stanza-id", { xmlns
= xmlns_st_id
, by
= room_jid
, id
= id
}):up();
287 if room
:get_whois() ~= "anyone" then
288 item
:maptags(function (tag)
289 if tag.name
== "x" and tag.attr
.xmlns
== xmlns_muc_user
then
296 local chars
= #tostring(item
);
297 if maxchars
- chars
< 0 then
300 maxchars
= maxchars
- chars
;
302 history
[i
], i
= item
, i
+1;
303 -- module:log("debug", tostring(item));
305 function event
.next_stanza()
312 module
:hook("muc-broadcast-messages", function (event
)
313 local room
, stanza
= event
.room
, event
.stanza
;
315 -- Filter out <stanza-id> that claim to be from us
316 stanza
:maptags(function (tag)
317 if tag.name
== "stanza-id" and tag.attr
.xmlns
== xmlns_st_id
318 and jid_prep(tag.attr
.by
) == room
.jid
then
321 if tag.name
== "x" and tag.attr
.xmlns
== xmlns_muc_user
then
330 local function save_to_history(self
, stanza
)
331 local room_node
, room_host
= jid_split(self
.jid
);
333 local stored_stanza
= stanza
;
335 if stanza
.name
== "message" and self
:get_whois() == "anyone" then
336 stored_stanza
= st
.clone(stanza
);
337 stored_stanza
.attr
.to
= nil;
338 local actor
= jid_bare(self
._occupants
[stanza
.attr
.from
].jid
);
339 local affiliation
= self
:get_affiliation(actor
) or "none";
340 local role
= self
:get_role(actor
) or self
:get_default_role(affiliation
);
341 stored_stanza
:add_direct_child(st
.stanza("x", { xmlns
= xmlns_muc_user
})
342 :tag("item", { affiliation
= affiliation
; role
= role
; jid
= actor
}));
346 if not archiving_enabled(self
) then return end -- Don't log
348 -- Save the type in the 'with' field, allows storing presence without conflicts
349 local with
= stanza
.name
350 if stanza
.attr
.type then
351 with
= with
.. "<" .. stanza
.attr
.type
355 local id
= archive
:append(room_node
, nil, stored_stanza
, time_now(), with
);
358 schedule_cleanup(room_node
);
359 stanza
:add_direct_child(st
.stanza("stanza-id", { xmlns
= xmlns_st_id
, by
= self
.jid
, id
= id
}));
363 module
:hook("muc-add-history", function (event
)
364 local room
, stanza
= event
.room
, event
.stanza
;
365 save_to_history(room
, stanza
);
368 if module
:get_option_boolean("muc_log_presences", false) then
369 module
:hook("muc-occupant-joined", function (event
)
370 save_to_history(event
.room
, st
.stanza("presence", { from
= event
.nick
}):tag("x", { xmlns
= "http://jabber.org/protocol/muc" }));
372 module
:hook("muc-occupant-left", function (event
)
373 save_to_history(event
.room
, st
.stanza("presence", { type = "unavailable", from
= event
.nick
}));
377 if not archive
.delete
then
378 module
:log("warn", "Storage driver %s does not support deletion", archive
._provided_by
);
379 module
:log("warn", "Archived message will persist after a room has been destroyed");
381 module
:hook("muc-room-destroyed", function(event
)
382 local room_node
= jid_split(event
.room
.jid
);
383 archive
:delete(room_node
);
387 -- And role/affiliation changes?
389 module
:add_feature(xmlns_mam
);
391 module
:hook("muc-disco#info", function(event
)
392 event
.reply
:tag("feature", {var
=xmlns_mam
}):up();
397 local cleanup_after
= module
:get_option_string("muc_log_expires_after", "1w");
398 local cleanup_interval
= module
:get_option_number("muc_log_cleanup_interval", 4 * 60 * 60);
400 if cleanup_after
~= "never" then
401 local cleanup_storage
= module
:open_store("muc_log_cleanup");
402 local cleanup_map
= module
:open_store("muc_log_cleanup", "map");
405 local multipliers
= { d
= day
, w
= day
* 7, m
= 31 * day
, y
= 365.2425 * day
};
406 local n
, m
= cleanup_after
:lower():match("(%d+)%s*([dwmy]?)");
408 module
:log("error", "Could not parse muc_log_expires_after string %q", cleanup_after
);
412 cleanup_after
= tonumber(n
) * ( multipliers
[m
] or 1 );
414 module
:log("debug", "muc_log_expires_after = %d -- in seconds", cleanup_after
);
416 if not archive
.delete
then
417 module
:log("error", "muc_log_expires_after set but mod_%s does not support deleting", archive
._provided_by
);
421 -- For each day, store a set of rooms that have new messages. To expire
422 -- messages, we collect the union of sets of rooms from dates that fall
423 -- outside the cleanup range.
425 local last_date
= require
"util.cache".new(module
:get_option_number("muc_log_cleanup_date_cache_size", 1000));
426 function schedule_cleanup(roomname
, date)
427 date = date or datestamp();
428 if last_date
:get(roomname
) == date then return end
429 local ok
= cleanup_map
:set(date, roomname
, true);
431 last_date
:set(roomname
, date);
435 cleanup_runner
= require
"util.async".runner(function ()
437 local cut_off
= datestamp(os
.time() - cleanup_after
);
438 for date in cleanup_storage
:users() do
439 if date <= cut_off
then
440 module
:log("debug", "Messages from %q should be expired", date);
441 local messages_this_day
= cleanup_storage
:get(date);
442 if messages_this_day
then
443 for room
in pairs(messages_this_day
) do
446 if date < cut_off
then
447 -- Messages from the same day as the cut-off might not have expired yet,
448 -- but all earlier will have, so clear storage for those days.
449 cleanup_storage
:set(date, nil);
454 local sum
, num_rooms
= 0, 0;
455 for room
in pairs(rooms
) do
456 local ok
, err
= archive
:delete(room
, { ["end"] = os
.time() - cleanup_after
; })
458 num_rooms
= num_rooms
+ 1;
459 sum
= sum
+ (tonumber(ok
) or 0);
462 module
:log("info", "Deleted %d expired messages for %d rooms", sum
, num_rooms
);
465 cleanup_task
= module
:add_timer(1, function ()
466 cleanup_runner
:run(true);
467 return cleanup_interval
;
470 module
:log("debug", "Archive expiry disabled");