1 |
|
%%%------------------------------------------------------------------- |
2 |
|
%%% @author Uvarov Michael <arcusfelis@gmail.com> |
3 |
|
%%% @copyright (C) 2013, Uvarov Michael |
4 |
|
%%% @doc XEP-0313: Message Archive Management |
5 |
|
%%% |
6 |
|
%%% The module uses several backend modules: |
7 |
|
%%% |
8 |
|
%%% <ul> |
9 |
|
%%% <li>Preference manager ({@link mod_mam_muc_rdbms_prefs});</li> |
10 |
|
%%% <li>Writer ({@link mod_mam_muc_rdbms_arch} or {@link mod_mam_muc_rdbms_async_pool_writer});</li> |
11 |
|
%%% <li>Archive manager ({@link mod_mam_muc_rdbms_arch});</li> |
12 |
|
%%% <li>User's ID generator ({@link mod_mam_muc_user}).</li> |
13 |
|
%%% </ul> |
14 |
|
%%% |
15 |
|
%%% Preferences can be also stored in Mnesia ({@link mod_mam_mnesia_prefs}). |
16 |
|
%%% This module handles MUC archives. |
17 |
|
%%% |
18 |
|
%%% This module should be started for each host. |
19 |
|
%%% Message archivation is not shaped here (use standard support for this). |
20 |
|
%%% MAM's IQs are shaped inside {@link opuntia_srv}. |
21 |
|
%%% |
22 |
|
%%% Message identifiers (or UIDs in the spec) are generated based on: |
23 |
|
%%% |
24 |
|
%%% <ul> |
25 |
|
%%% <li>date (using `timestamp()');</li> |
26 |
|
%%% <li>node number (using {@link mongoose_node_num}).</li> |
27 |
|
%%% </ul> |
28 |
|
%%% @end |
29 |
|
%%%------------------------------------------------------------------- |
30 |
|
-module(mod_mam_muc). |
31 |
|
-behaviour(gen_mod). |
32 |
|
%% ---------------------------------------------------------------------- |
33 |
|
%% Exports |
34 |
|
|
35 |
|
%% Client API |
36 |
|
-export([delete_archive/2, |
37 |
|
archive_size/2, |
38 |
|
archive_id/2]). |
39 |
|
|
40 |
|
%% gen_mod handlers |
41 |
|
-export([start/2, stop/1, supported_features/0, hooks/1, instrumentation/1]). |
42 |
|
|
43 |
|
%% ejabberd room handlers |
44 |
|
-export([disco_muc_features/3, |
45 |
|
filter_room_packet/3, |
46 |
|
forget_room/3]). |
47 |
|
|
48 |
|
-export([room_process_mam_iq/5]). |
49 |
|
|
50 |
|
%% gdpr callback |
51 |
|
-export([get_personal_data/3]). |
52 |
|
|
53 |
|
%% private |
54 |
|
-export([archive_message_for_ct/1]). |
55 |
|
-export([lookup_messages/2]). |
56 |
|
-export([archive_id_int/2]). |
57 |
|
|
58 |
|
-ignore_xref([archive_id/2, archive_message_for_ct/1, archive_size/2, delete_archive/2]). |
59 |
|
|
60 |
|
-include_lib("mongoose.hrl"). |
61 |
|
-include_lib("jlib.hrl"). |
62 |
|
-include_lib("exml/include/exml.hrl"). |
63 |
|
|
64 |
|
-callback is_complete_message(Module :: atom(), Dir :: atom(), Packet :: any()) -> |
65 |
|
boolean(). |
66 |
|
|
67 |
|
%% ---------------------------------------------------------------------- |
68 |
|
%% Other types |
69 |
|
-type packet() :: any(). |
70 |
|
-type row_batch() :: {TotalCount :: non_neg_integer(), |
71 |
|
Offset :: non_neg_integer(), |
72 |
|
MessageRows :: [row()]}. |
73 |
|
-type row() :: mod_mam:message_row(). |
74 |
|
-type host_type() :: mongooseim:host_type(). |
75 |
|
-type muc_action() :: atom(). |
76 |
|
|
77 |
|
-export_type([row/0, row_batch/0]). |
78 |
|
|
79 |
|
%% ---------------------------------------------------------------------- |
80 |
|
%% API |
81 |
|
|
82 |
|
-spec get_personal_data(Acc, Params, Extra) -> {ok, Acc} when |
83 |
|
Acc :: gdpr:personal_data(), |
84 |
|
Params :: #{jid := jid:jid()}, |
85 |
|
Extra :: gen_hook:extra(). |
86 |
|
get_personal_data(Acc, #{jid := ArcJID}, #{host_type := HostType}) -> |
87 |
47 |
Schema = ["id", "message"], |
88 |
47 |
Entries = mongoose_hooks:get_mam_muc_gdpr_data(HostType, ArcJID), |
89 |
47 |
{ok, [{mam_muc, Schema, Entries} | Acc]}. |
90 |
|
|
91 |
|
-spec delete_archive(jid:server(), jid:user()) -> ok. |
92 |
|
delete_archive(MucHost, RoomName) when is_binary(MucHost), is_binary(RoomName) -> |
93 |
568 |
?LOG_DEBUG(#{what => mam_delete_room, room => RoomName, sub_host => MucHost}), |
94 |
568 |
ArcJID = jid:make_bare(RoomName, MucHost), |
95 |
568 |
HostType = mod_muc_light_utils:room_jid_to_host_type(ArcJID), |
96 |
568 |
ArcID = archive_id_int(HostType, ArcJID), |
97 |
568 |
remove_archive(HostType, ArcID, ArcJID), |
98 |
567 |
ok. |
99 |
|
|
100 |
|
-spec archive_size(jid:server(), jid:user()) -> integer(). |
101 |
|
archive_size(MucHost, RoomName) when is_binary(MucHost), is_binary(RoomName) -> |
102 |
554 |
ArcJID = jid:make_bare(RoomName, MucHost), |
103 |
554 |
HostType = mod_muc_light_utils:room_jid_to_host_type(ArcJID), |
104 |
554 |
ArcID = archive_id_int(HostType, ArcJID), |
105 |
554 |
archive_size(HostType, ArcID, ArcJID). |
106 |
|
|
107 |
|
-spec archive_id(jid:server(), jid:user()) -> integer(). |
108 |
|
archive_id(MucHost, RoomName) when is_binary(MucHost), is_binary(RoomName) -> |
109 |
56 |
ArcJID = jid:make_bare(RoomName, MucHost), |
110 |
56 |
HostType = mod_muc_light_utils:room_jid_to_host_type(ArcJID), |
111 |
56 |
archive_id_int(HostType, ArcJID). |
112 |
|
|
113 |
|
%% ---------------------------------------------------------------------- |
114 |
|
%% gen_mod callbacks |
115 |
|
%% Starting and stopping functions for MUC archives |
116 |
|
|
117 |
|
-spec start(host_type(), gen_mod:module_opts()) -> any(). |
118 |
|
start(HostType, Opts) -> |
119 |
68 |
?LOG_DEBUG(#{what => mam_muc_starting}), |
120 |
68 |
add_iq_handlers(HostType, Opts), |
121 |
68 |
ok. |
122 |
|
|
123 |
|
-spec stop(host_type()) -> any(). |
124 |
|
stop(HostType) -> |
125 |
68 |
?LOG_DEBUG(#{what => mam_muc_stopping}), |
126 |
68 |
remove_iq_handlers(HostType), |
127 |
68 |
ok. |
128 |
|
|
129 |
|
-spec supported_features() -> [atom()]. |
130 |
|
supported_features() -> |
131 |
:-( |
[dynamic_domains]. |
132 |
|
|
133 |
|
%% ---------------------------------------------------------------------- |
134 |
|
%% hooks and handlers for MUC |
135 |
|
|
136 |
|
-spec disco_muc_features(Acc, Params, Extra) -> {ok, Acc} when |
137 |
|
Acc :: mongoose_disco:feature_acc(), |
138 |
|
Params :: map(), |
139 |
|
Extra :: gen_hook:extra(). |
140 |
|
disco_muc_features(Acc = #{host_type := HostType, node := <<>>}, _Params, _Extra) -> |
141 |
20 |
{ok, mongoose_disco:add_features(mod_mam_utils:features(?MODULE, HostType), Acc)}; |
142 |
|
disco_muc_features(Acc, _Params, _Extra) -> |
143 |
:-( |
{ok, Acc}. |
144 |
|
|
145 |
|
%% @doc Handle public MUC-message. |
146 |
|
-spec filter_room_packet(Packet, EventData, Extra) -> {ok, Packet} when |
147 |
|
Packet :: exml:element(), |
148 |
|
EventData :: mod_muc:room_event_data(), |
149 |
|
Extra :: gen_hook:extra(). |
150 |
|
filter_room_packet(Packet, EventData, #{host_type := HostType}) -> |
151 |
636 |
?LOG_DEBUG(#{what => mam_room_packet, text => <<"Incoming room packet">>, |
152 |
636 |
packet => Packet, event_data => EventData}), |
153 |
636 |
IsArchivable = is_archivable_message(HostType, incoming, Packet), |
154 |
636 |
case IsArchivable of |
155 |
|
true -> |
156 |
614 |
#{from_nick := FromNick, from_jid := FromJID, room_jid := RoomJID, |
157 |
|
role := Role, affiliation := Affiliation, timestamp := TS} = EventData, |
158 |
614 |
{ok, archive_room_packet(HostType, Packet, FromNick, FromJID, |
159 |
|
RoomJID, Role, Affiliation, TS)}; |
160 |
|
false -> |
161 |
22 |
{ok, Packet} |
162 |
|
end. |
163 |
|
|
164 |
|
%% @doc Archive without validation. |
165 |
|
-spec archive_room_packet(HostType :: host_type(), |
166 |
|
Packet :: packet(), FromNick :: jid:user(), |
167 |
|
FromJID :: jid:jid(), RoomJID :: jid:jid(), |
168 |
|
Role :: mod_muc:role(), Affiliation :: mod_muc:affiliation(), |
169 |
|
TS :: integer()) -> packet(). |
170 |
|
archive_room_packet(HostType, Packet, FromNick, FromJID = #jid{}, |
171 |
|
RoomJID = #jid{}, Role, Affiliation, TS) -> |
172 |
614 |
ArcID = archive_id_int(HostType, RoomJID), |
173 |
|
%% Occupant JID <room@service/nick> |
174 |
614 |
SrcJID = jid:replace_resource(RoomJID, FromNick), |
175 |
614 |
IsMamMucEnabled = mod_mam_utils:is_mam_muc_enabled(RoomJID#jid.lserver, HostType), |
176 |
614 |
IsInteresting = |
177 |
|
case get_behaviour(HostType, ArcID, RoomJID, SrcJID) of |
178 |
614 |
always -> true; |
179 |
:-( |
never -> false; |
180 |
:-( |
roster -> true |
181 |
|
end, |
182 |
614 |
case IsInteresting andalso IsMamMucEnabled of |
183 |
|
true -> |
184 |
610 |
MessID = mod_mam_utils:generate_message_id(TS), |
185 |
610 |
Packet1 = mod_mam_utils:replace_x_user_element(FromJID, Role, Affiliation, Packet), |
186 |
610 |
OriginID = mod_mam_utils:get_origin_id(Packet), |
187 |
610 |
Params = #{message_id => MessID, |
188 |
|
archive_id => ArcID, |
189 |
|
local_jid => RoomJID, |
190 |
|
remote_jid => FromJID, |
191 |
|
source_jid => SrcJID, |
192 |
|
origin_id => OriginID, |
193 |
|
direction => incoming, |
194 |
|
packet => Packet1}, |
195 |
|
%% Packet to be broadcasted and packet to be archived are |
196 |
|
%% not 100% the same |
197 |
610 |
Result = archive_message(HostType, Params), |
198 |
610 |
case Result of |
199 |
|
ok -> |
200 |
610 |
ExtID = mod_mam_utils:mess_id_to_external_binary(MessID), |
201 |
610 |
ShouldAdd = mod_mam_params:add_stanzaid_element(?MODULE, HostType), |
202 |
610 |
mod_mam_utils:maybe_add_arcid_elems(RoomJID, ExtID, Packet, ShouldAdd); |
203 |
:-( |
{error, _} -> Packet |
204 |
|
end; |
205 |
4 |
false -> Packet |
206 |
|
end. |
207 |
|
|
208 |
|
%% @doc `To' is an account or server entity hosting the archive. |
209 |
|
%% Servers that archive messages on behalf of local users SHOULD expose archives |
210 |
|
%% to the user on their bare JID (i.e. `From.luser'), |
211 |
|
%% while a MUC service might allow MAM queries to be sent to the room's bare JID |
212 |
|
%% (i.e `To.luser'). |
213 |
|
-spec room_process_mam_iq(Acc :: mongoose_acc:t(), |
214 |
|
From :: jid:jid(), |
215 |
|
To :: jid:jid(), |
216 |
|
IQ :: jlib:iq(), |
217 |
|
Extra :: gen_hook:extra()) -> {mongoose_acc:t(), jlib:iq() | ignore}. |
218 |
|
room_process_mam_iq(Acc, From, To, IQ, #{host_type := HostType}) -> |
219 |
451 |
mod_mam_utils:maybe_log_deprecation(IQ), |
220 |
451 |
Action = mam_iq:action(IQ), |
221 |
451 |
MucAction = action_to_muc_action(Action), |
222 |
451 |
case check_action_allowed(HostType, Acc, To#jid.lserver, Action, MucAction, From, To) of |
223 |
|
ok -> |
224 |
437 |
case mod_mam_utils:wait_shaper(HostType, To#jid.lserver, MucAction, From) of |
225 |
|
continue -> |
226 |
437 |
handle_error_iq(Acc, HostType, To, Action, |
227 |
|
handle_mam_iq(HostType, Action, From, To, IQ)); |
228 |
|
{error, max_delay_reached} -> |
229 |
:-( |
mongoose_instrument:execute(mod_mam_muc_dropped_iq, |
230 |
|
#{host_type => HostType}, #{acc => Acc, count => 1}), |
231 |
:-( |
{Acc, return_max_delay_reached_error_iq(IQ)} |
232 |
|
end; |
233 |
|
{error, Reason} -> |
234 |
14 |
?LOG_WARNING(#{what => action_not_allowed, |
235 |
|
action => Action, acc => Acc, reason => Reason, |
236 |
:-( |
can_access_room => can_access_room(HostType, Acc, From, To)}), |
237 |
14 |
{Acc, return_action_not_allowed_error_iq(Reason, IQ)} |
238 |
|
end. |
239 |
|
|
240 |
|
-spec forget_room(Acc, Params, Extra) -> {ok, Acc} when |
241 |
|
Acc :: term(), |
242 |
|
Params :: #{muc_host := jid:server(), room := jid:luser()}, |
243 |
|
Extra :: gen_hook:extra(). |
244 |
|
forget_room(Acc, #{muc_host := MucServer, room := RoomName}, _Extra) -> |
245 |
272 |
delete_archive(MucServer, RoomName), |
246 |
271 |
{ok, Acc}. |
247 |
|
|
248 |
|
%% ---------------------------------------------------------------------- |
249 |
|
%% Internal functions |
250 |
|
|
251 |
|
-spec check_action_allowed(host_type(), mongoose_acc:t(), jid:lserver(), mam_iq:action(), muc_action(), |
252 |
|
jid:jid(), jid:jid()) -> ok | {error, binary()}. |
253 |
|
check_action_allowed(HostType, Acc, Domain, Action, MucAction, From, To) -> |
254 |
451 |
case acl:match_rule(HostType, Domain, MucAction, From, default) of |
255 |
:-( |
allow -> ok; |
256 |
:-( |
deny -> {false, <<"Blocked by service policy.">>}; |
257 |
451 |
default -> check_room_action_allowed_by_default(HostType, Acc, Action, From, To) |
258 |
|
end. |
259 |
|
|
260 |
|
-spec action_to_muc_action(mam_iq:action()) -> atom(). |
261 |
|
action_to_muc_action(Action) -> |
262 |
451 |
list_to_atom("muc_" ++ atom_to_list(Action)). |
263 |
|
|
264 |
|
-spec check_room_action_allowed_by_default(HostType :: host_type(), |
265 |
|
Acc :: mongoose_acc:t(), |
266 |
|
Action :: mam_iq:action(), |
267 |
|
From :: jid:jid(), |
268 |
|
To :: jid:jid()) -> ok | {error, binary()}. |
269 |
|
check_room_action_allowed_by_default(HostType, Acc, Action, From, To) -> |
270 |
451 |
case mam_iq:action_type(Action) of |
271 |
|
set -> |
272 |
:-( |
case is_room_owner(HostType, Acc, From, To) of |
273 |
:-( |
true -> ok; |
274 |
:-( |
false -> {error, <<"Not a room owner.">>} |
275 |
|
end; |
276 |
|
get -> |
277 |
451 |
case can_access_room(HostType, Acc, From, To) of |
278 |
437 |
true -> ok; |
279 |
14 |
false -> {error, <<"Not allowed to enter the room.">>} |
280 |
|
end |
281 |
|
end. |
282 |
|
|
283 |
|
-spec is_room_owner(HostType :: host_type(), |
284 |
|
Acc :: mongoose_acc:t(), |
285 |
|
UserJid :: jid:jid(), |
286 |
|
RoomJid :: jid:jid()) -> boolean(). |
287 |
|
is_room_owner(HostType, Acc, UserJid, RoomJid) -> |
288 |
:-( |
mongoose_hooks:is_muc_room_owner(HostType, Acc, UserJid, RoomJid). |
289 |
|
|
290 |
|
%% @doc Return true if user element should be removed from results |
291 |
|
-spec is_user_identity_hidden(HostType :: host_type(), |
292 |
|
UserJid :: jid:jid(), |
293 |
|
RoomJid :: jid:jid()) -> boolean(). |
294 |
|
is_user_identity_hidden(HostType, UserJid, RoomJid) -> |
295 |
318 |
case mongoose_hooks:can_access_identity(HostType, RoomJid, UserJid) of |
296 |
318 |
CanAccess when is_boolean(CanAccess) -> not CanAccess |
297 |
|
end. |
298 |
|
|
299 |
|
-spec can_access_room(HostType :: host_type(), |
300 |
|
Acc :: mongoose_acc:t(), |
301 |
|
UserJid :: jid:jid(), |
302 |
|
RoomJid :: jid:jid()) -> boolean(). |
303 |
|
can_access_room(HostType, Acc, UserJid, RoomJid) -> |
304 |
465 |
mongoose_hooks:can_access_room(HostType, Acc, RoomJid, UserJid). |
305 |
|
|
306 |
|
-spec handle_mam_iq(HostType :: host_type(), mam_iq:action(), |
307 |
|
From :: jid:jid(), jid:jid(), jlib:iq()) -> |
308 |
|
jlib:iq() | {error, any(), jlib:iq()} | ignore. |
309 |
|
handle_mam_iq(HostType, Action, From, To, IQ) -> |
310 |
437 |
case Action of |
311 |
|
mam_get_prefs -> |
312 |
:-( |
handle_get_prefs(HostType, To, IQ); |
313 |
|
mam_set_prefs -> |
314 |
:-( |
handle_set_prefs(HostType, To, IQ); |
315 |
|
mam_set_message_form -> |
316 |
416 |
handle_set_message_form(HostType, From, To, IQ); |
317 |
|
mam_get_message_form -> |
318 |
:-( |
handle_get_message_form(HostType, From, To, IQ); |
319 |
|
mam_get_metadata -> |
320 |
21 |
handle_get_metadata(HostType, From, To, IQ) |
321 |
|
end. |
322 |
|
|
323 |
|
-spec handle_set_prefs(host_type(), jid:jid(), jlib:iq()) -> |
324 |
|
jlib:iq() | {error, any(), jlib:iq()}. |
325 |
|
handle_set_prefs(HostType, ArcJID = #jid{}, |
326 |
|
IQ = #iq{sub_el = PrefsEl}) -> |
327 |
:-( |
{DefaultMode, AlwaysJIDs, NeverJIDs} = mod_mam_utils:parse_prefs(PrefsEl), |
328 |
:-( |
?LOG_DEBUG(#{what => mam_muc_set_prefs, archive_jid => ArcJID, |
329 |
|
default_mode => DefaultMode, |
330 |
:-( |
always_jids => AlwaysJIDs, never_jids => NeverJIDs, iq => IQ}), |
331 |
:-( |
ArcID = archive_id_int(HostType, ArcJID), |
332 |
:-( |
Res = set_prefs(HostType, ArcID, ArcJID, DefaultMode, AlwaysJIDs, NeverJIDs), |
333 |
:-( |
handle_set_prefs_result(Res, DefaultMode, AlwaysJIDs, NeverJIDs, IQ). |
334 |
|
|
335 |
|
handle_set_prefs_result(ok, DefaultMode, AlwaysJIDs, NeverJIDs, IQ) -> |
336 |
:-( |
ResultPrefsEl = mod_mam_utils:result_prefs(DefaultMode, AlwaysJIDs, NeverJIDs, IQ#iq.xmlns), |
337 |
:-( |
IQ#iq{type = result, sub_el = [ResultPrefsEl]}; |
338 |
|
handle_set_prefs_result({error, Reason}, |
339 |
|
_DefaultMode, _AlwaysJIDs, _NeverJIDs, IQ) -> |
340 |
:-( |
return_error_iq(IQ, Reason). |
341 |
|
|
342 |
|
-spec handle_get_prefs(host_type(), jid:jid(), jlib:iq()) -> |
343 |
|
jlib:iq() | {error, any(), jlib:iq()}. |
344 |
|
handle_get_prefs(HostType, ArcJID=#jid{}, IQ=#iq{}) -> |
345 |
:-( |
ArcID = archive_id_int(HostType, ArcJID), |
346 |
:-( |
Res = get_prefs(HostType, ArcID, ArcJID, always), |
347 |
:-( |
handle_get_prefs_result(ArcJID, Res, IQ). |
348 |
|
|
349 |
|
handle_get_prefs_result(ArcJID, {DefaultMode, AlwaysJIDs, NeverJIDs}, IQ) -> |
350 |
:-( |
?LOG_DEBUG(#{what => mam_muc_get_prefs_result, archive_jid => ArcJID, |
351 |
|
default_mode => DefaultMode, |
352 |
:-( |
always_jids => AlwaysJIDs, never_jids => NeverJIDs, iq => IQ}), |
353 |
:-( |
ResultPrefsEl = mod_mam_utils:result_prefs(DefaultMode, AlwaysJIDs, NeverJIDs, IQ#iq.xmlns), |
354 |
:-( |
IQ#iq{type = result, sub_el = [ResultPrefsEl]}; |
355 |
|
handle_get_prefs_result(_ArcJID, {error, Reason}, IQ) -> |
356 |
:-( |
return_error_iq(IQ, Reason). |
357 |
|
|
358 |
|
-spec handle_set_message_form(HostType :: host_type(), |
359 |
|
From :: jid:jid(), ArcJID :: jid:jid(), |
360 |
|
IQ :: jlib:iq()) -> |
361 |
|
jlib:iq() | ignore | {error, term(), jlib:iq()}. |
362 |
|
handle_set_message_form(HostType, #jid{} = From, #jid{} = ArcJID, IQ) -> |
363 |
416 |
ArcID = archive_id_int(HostType, ArcJID), |
364 |
416 |
ResLimit = mod_mam_params:max_result_limit(?MODULE, HostType), |
365 |
416 |
DefLimit = mod_mam_params:default_result_limit(?MODULE, HostType), |
366 |
416 |
ExtMod = mod_mam_params:extra_params_module(?MODULE, HostType), |
367 |
416 |
Sim = mod_mam_params:enforce_simple_queries(?MODULE, HostType), |
368 |
416 |
try mam_iq:form_to_lookup_params(IQ, ResLimit, DefLimit, ExtMod, Sim) of |
369 |
|
Params0 -> |
370 |
409 |
do_handle_set_message_form(HostType, From, ArcID, ArcJID, IQ, Params0) |
371 |
|
catch _C:R:S -> |
372 |
7 |
report_issue({R, S}, mam_lookup_failed, ArcJID, IQ), |
373 |
7 |
return_error_iq(IQ, R) |
374 |
|
end. |
375 |
|
|
376 |
|
|
377 |
|
-spec do_handle_set_message_form(HostType :: mongooseim:host_type(), |
378 |
|
From :: jid:jid(), |
379 |
|
ArcId :: mod_mam:archive_id(), |
380 |
|
ArcJID :: jid:jid(), |
381 |
|
IQ :: jlib:iq(), |
382 |
|
Params :: mam_iq:lookup_params()) -> |
383 |
|
jlib:iq() | ignore | {error, term(), jlib:iq()}. |
384 |
|
do_handle_set_message_form(HostType, From, ArcID, ArcJID, IQ, Params0) -> |
385 |
409 |
Params = mam_iq:lookup_params_with_archive_details(Params0, ArcID, ArcJID, From), |
386 |
409 |
Result = mod_mam_utils:lookup(HostType, Params, fun lookup_messages/2), |
387 |
409 |
handle_lookup_result(Result, HostType, From, IQ, Params). |
388 |
|
|
389 |
|
-spec handle_lookup_result({ok, mod_mam:lookup_result()} | {error, term()}, |
390 |
|
host_type(), jid:jid(), jlib:iq(), map()) -> |
391 |
|
jlib:iq() | ignore | {error, term(), jlib:iq()}. |
392 |
|
handle_lookup_result(Result, HostType, From, IQ, #{owner_jid := ArcJID} = Params) -> |
393 |
409 |
case Result of |
394 |
|
{error, Reason} -> |
395 |
21 |
report_issue(Reason, mam_muc_lookup_failed, ArcJID, IQ), |
396 |
21 |
return_error_iq(IQ, Reason); |
397 |
|
{ok, Res} -> |
398 |
388 |
send_messages_and_iq_result(Res, HostType, From, IQ, Params) |
399 |
|
end. |
400 |
|
|
401 |
|
send_messages_and_iq_result(#{total_count := TotalCount, offset := Offset, |
402 |
|
messages := MessageRows, is_complete := IsComplete}, |
403 |
|
HostType, From, |
404 |
|
#iq{xmlns = MamNs, sub_el = QueryEl} = IQ, |
405 |
|
#{owner_jid := ArcJID} = Params) -> |
406 |
|
%% Reverse order of messages if the client requested it |
407 |
388 |
MessageRows1 = mod_mam_utils:maybe_reverse_messages(Params, MessageRows), |
408 |
|
%% Forward messages |
409 |
388 |
QueryID = exml_query:attr(QueryEl, <<"queryid">>, <<>>), |
410 |
388 |
{FirstMessID, LastMessID} = forward_messages(HostType, From, ArcJID, MamNs, |
411 |
|
QueryID, MessageRows1, true), |
412 |
|
%% Make fin iq |
413 |
388 |
IsStable = true, |
414 |
388 |
ResultSetEl = mod_mam_utils:result_set(FirstMessID, LastMessID, Offset, TotalCount), |
415 |
388 |
ExtFinMod = mod_mam_params:extra_fin_element_module(?MODULE, HostType), |
416 |
388 |
FinElem = mod_mam_utils:make_fin_element(HostType, Params, IQ#iq.xmlns, |
417 |
|
IsComplete, IsStable, |
418 |
|
ResultSetEl, ExtFinMod), |
419 |
388 |
IQ#iq{type = result, sub_el = [FinElem]}. |
420 |
|
|
421 |
|
forward_messages(HostType, From, ArcJID, MamNs, QueryID, MessageRows, SetClientNs) -> |
422 |
|
%% Forward messages |
423 |
388 |
{FirstMessID, LastMessID, HideUser} = |
424 |
|
case MessageRows of |
425 |
70 |
[] -> {undefined, undefined, undefined}; |
426 |
318 |
[_ | _] -> {message_row_to_ext_id(hd(MessageRows)), |
427 |
|
message_row_to_ext_id(lists:last(MessageRows)), |
428 |
|
is_user_identity_hidden(HostType, From, ArcJID)} |
429 |
|
end, |
430 |
388 |
SendModule = mod_mam_params:send_message_mod(?MODULE, HostType), |
431 |
388 |
[send_message(SendModule, Row, ArcJID, From, |
432 |
|
message_row_to_xml(MamNs, From, HideUser, SetClientNs, Row, |
433 |
|
QueryID)) |
434 |
388 |
|| Row <- MessageRows], |
435 |
388 |
{FirstMessID, LastMessID}. |
436 |
|
|
437 |
|
send_message(SendModule, Row, ArcJID, From, Packet) -> |
438 |
1634 |
mam_send_message:call_send_message(SendModule, Row, ArcJID, From, Packet). |
439 |
|
|
440 |
|
-spec handle_get_message_form(host_type(), jid:jid(), jid:jid(), jlib:iq()) -> |
441 |
|
jlib:iq(). |
442 |
|
handle_get_message_form(HostType, |
443 |
|
_From = #jid{}, _ArcJID = #jid{}, IQ = #iq{}) -> |
444 |
:-( |
return_message_form_iq(HostType, IQ). |
445 |
|
|
446 |
|
-spec handle_get_metadata(host_type(), jid:jid(), jid:jid(), jlib:iq()) -> |
447 |
|
jlib:iq() | {error, term(), jlib:iq()}. |
448 |
|
handle_get_metadata(HostType, #jid{} = From, #jid{} = ArcJID, IQ) -> |
449 |
21 |
ArcID = archive_id_int(HostType, ArcJID), |
450 |
21 |
case mod_mam_utils:lookup_first_and_last_messages(HostType, ArcID, From, |
451 |
|
ArcJID, fun lookup_messages/2) of |
452 |
|
{error, Reason} -> |
453 |
:-( |
report_issue(Reason, mam_lookup_failed, ArcJID, IQ), |
454 |
:-( |
return_error_iq(IQ, Reason); |
455 |
|
{FirstMsg, LastMsg} -> |
456 |
14 |
{FirstMsgID, FirstMsgTS} = mod_mam_utils:get_msg_id_and_timestamp(FirstMsg), |
457 |
14 |
{LastMsgID, LastMsgTS} = mod_mam_utils:get_msg_id_and_timestamp(LastMsg), |
458 |
14 |
MetadataElement = |
459 |
|
mod_mam_utils:make_metadata_element(FirstMsgID, FirstMsgTS, LastMsgID, LastMsgTS), |
460 |
14 |
IQ#iq{type = result, sub_el = [MetadataElement]}; |
461 |
|
empty_archive -> |
462 |
7 |
MetadataElement = mod_mam_utils:make_metadata_element(), |
463 |
7 |
IQ#iq{type = result, sub_el = [MetadataElement]} |
464 |
|
end. |
465 |
|
|
466 |
|
%% ---------------------------------------------------------------------- |
467 |
|
%% Backend wrappers |
468 |
|
|
469 |
|
-spec archive_id_int(HostType :: host_type(), ArcJID :: jid:jid()) -> |
470 |
|
integer() | undefined. |
471 |
|
archive_id_int(HostType, ArcJID = #jid{}) -> |
472 |
2256 |
mongoose_hooks:mam_muc_archive_id(HostType, ArcJID). |
473 |
|
|
474 |
|
-spec archive_size(HostType :: host_type(), ArcID :: mod_mam:archive_id(), |
475 |
|
ArcJID ::jid:jid()) -> non_neg_integer(). |
476 |
|
archive_size(HostType, ArcID, ArcJID = #jid{}) -> |
477 |
554 |
mongoose_hooks:mam_muc_archive_size(HostType, ArcID, ArcJID). |
478 |
|
|
479 |
|
-spec get_behaviour(HostType :: host_type(), ArcID :: mod_mam:archive_id(), |
480 |
|
LocJID :: jid:jid(), RemJID :: jid:jid()) -> any(). |
481 |
|
get_behaviour(HostType, ArcID, LocJID = #jid{}, RemJID = #jid{}) -> |
482 |
614 |
mongoose_hooks:mam_muc_get_behaviour(HostType, ArcID, LocJID, RemJID). |
483 |
|
|
484 |
|
-spec set_prefs(HostType :: host_type(), ArcID :: mod_mam:archive_id(), |
485 |
|
ArcJID :: jid:jid(), DefaultMode :: mod_mam:archive_behaviour(), |
486 |
|
AlwaysJIDs :: [jid:literal_jid()], |
487 |
|
NeverJIDs :: [jid:literal_jid()]) -> any(). |
488 |
|
set_prefs(HostType, ArcID, ArcJID, DefaultMode, AlwaysJIDs, NeverJIDs) -> |
489 |
:-( |
Result = mongoose_hooks:mam_muc_set_prefs(HostType, ArcID, ArcJID, DefaultMode, |
490 |
|
AlwaysJIDs, NeverJIDs), |
491 |
:-( |
mongoose_instrument:execute(mod_mam_muc_set_prefs, #{host_type => HostType}, |
492 |
|
#{jid => ArcJID, count => 1}), |
493 |
:-( |
Result. |
494 |
|
|
495 |
|
%% @doc Load settings from the database. |
496 |
|
-spec get_prefs(HostType :: host_type(), ArcID :: mod_mam:archive_id(), |
497 |
|
ArcJID :: jid:jid(), GlobalDefaultMode :: mod_mam:archive_behaviour()) |
498 |
|
-> mod_mam:preference() | {error, Reason :: term()}. |
499 |
|
get_prefs(HostType, ArcID, ArcJID, GlobalDefaultMode) -> |
500 |
:-( |
Result = mongoose_hooks:mam_muc_get_prefs(HostType, GlobalDefaultMode, ArcID, ArcJID), |
501 |
:-( |
mongoose_instrument:execute(mod_mam_muc_get_prefs, #{host_type => HostType}, |
502 |
|
#{jid => ArcJID, count => 1}), |
503 |
:-( |
Result. |
504 |
|
|
505 |
|
-spec remove_archive(host_type(), mod_mam:archive_id() | undefined, |
506 |
|
jid:jid()) -> ok. |
507 |
|
remove_archive(HostType, ArcID, ArcJID = #jid{}) -> |
508 |
568 |
mongoose_hooks:mam_muc_remove_archive(HostType, ArcID, ArcJID), |
509 |
568 |
mongoose_instrument:execute(mod_mam_muc_remove_archive, #{host_type => HostType}, |
510 |
|
#{jid => ArcJID, count => 1}). |
511 |
|
|
512 |
|
%% See description in mod_mam_pm. |
513 |
|
-spec lookup_messages(HostType :: host_type(), Params :: map()) -> |
514 |
|
{ok, mod_mam:lookup_result()} |
515 |
|
| {error, 'policy-violation'} |
516 |
|
| {error, Reason :: term()}. |
517 |
|
lookup_messages(HostType, Params) -> |
518 |
470 |
Result = lookup_messages_without_policy_violation_check(HostType, Params), |
519 |
|
%% If a query returns a number of stanzas greater than this limit and the |
520 |
|
%% client did not specify a limit using RSM then the server should return |
521 |
|
%% a policy-violation error to the client. |
522 |
470 |
mod_mam_utils:check_result_for_policy_violation(Params, Result). |
523 |
|
|
524 |
|
lookup_messages_without_policy_violation_check(HostType, |
525 |
|
#{search_text := SearchText} = Params) -> |
526 |
470 |
case SearchText /= undefined andalso |
527 |
7 |
not mod_mam_params:has_full_text_search(?MODULE, HostType) of |
528 |
|
true -> %% Use of disabled full text search |
529 |
:-( |
{error, 'not-supported'}; |
530 |
|
false -> |
531 |
470 |
mongoose_instrument:span(mod_mam_muc_lookup, #{host_type => HostType}, |
532 |
|
fun perform_lookup/2, [HostType, Params], |
533 |
470 |
fun(Time, Result) -> measure_lookup(Params, Time, Result) end) |
534 |
|
end. |
535 |
|
|
536 |
|
perform_lookup(HostType, Params) -> |
537 |
470 |
case maps:get(message_ids, Params, undefined) of |
538 |
|
undefined -> |
539 |
449 |
mongoose_hooks:mam_muc_lookup_messages(HostType, Params#{message_id => undefined}); |
540 |
|
IDs -> |
541 |
21 |
mod_mam_utils:lookup_specific_messages(HostType, Params, IDs, |
542 |
|
fun mongoose_hooks:mam_muc_lookup_messages/2) |
543 |
|
end. |
544 |
|
|
545 |
|
measure_lookup(Params, Time, {ok, {_TotalCount, _Offset, MessageRows}}) -> |
546 |
449 |
#{params => Params, count => 1, time => Time, size => length(MessageRows)}; |
547 |
|
measure_lookup(_Params, _Time, _OtherResult) -> |
548 |
21 |
#{}. |
549 |
|
|
550 |
|
archive_message_for_ct(Params = #{local_jid := RoomJid}) -> |
551 |
1912 |
HostType = mod_muc_light_utils:room_jid_to_host_type(RoomJid), |
552 |
1912 |
archive_message(HostType, Params). |
553 |
|
|
554 |
|
-spec archive_message(host_type(), mod_mam:archive_message_params()) -> ok | {error, timeout}. |
555 |
|
archive_message(HostType, Params) -> |
556 |
2522 |
mongoose_instrument:span(mod_mam_muc_archive_message, #{host_type => HostType}, |
557 |
|
fun mongoose_hooks:mam_muc_archive_message/2, [HostType, Params], |
558 |
2522 |
fun(Time, _Result) -> #{params => Params, time => Time, count => 1} end). |
559 |
|
|
560 |
|
%% ---------------------------------------------------------------------- |
561 |
|
%% Helpers |
562 |
|
|
563 |
|
-spec message_row_to_xml(binary(), jid:jid(), boolean(), boolean(), row(), binary() | undefined) -> |
564 |
|
exml:element(). |
565 |
|
message_row_to_xml(MamNs, ReceiverJID, HideUser, SetClientNs, |
566 |
|
#{id := MessID, jid := SrcJID, packet := Packet}, QueryID) -> |
567 |
1634 |
{Microseconds, _NodeMessID} = mod_mam_utils:decode_compact_uuid(MessID), |
568 |
1634 |
TS = calendar:system_time_to_rfc3339(Microseconds, [{offset, "Z"}, {unit, microsecond}]), |
569 |
1634 |
BExtMessID = mod_mam_utils:mess_id_to_external_binary(MessID), |
570 |
1634 |
Packet1 = maybe_delete_x_user_element(HideUser, ReceiverJID, Packet), |
571 |
1634 |
Packet2 = mod_mam_utils:maybe_set_client_xmlns(SetClientNs, Packet1), |
572 |
1634 |
Packet3 = replace_from_to_attributes(SrcJID, Packet2), |
573 |
1634 |
mod_mam_utils:wrap_message(MamNs, Packet3, QueryID, BExtMessID, TS, SrcJID). |
574 |
|
|
575 |
|
maybe_delete_x_user_element(true, ReceiverJID, Packet) -> |
576 |
122 |
PacketJID = mod_mam_utils:packet_to_x_user_jid(Packet), |
577 |
122 |
case jid:are_bare_equal(ReceiverJID, PacketJID) of |
578 |
|
false -> |
579 |
100 |
mod_mam_utils:delete_x_user_element(Packet); |
580 |
|
true -> %% expose identity for user's own messages |
581 |
22 |
Packet |
582 |
|
end; |
583 |
|
maybe_delete_x_user_element(false, _ReceiverJID, Packet) -> |
584 |
1512 |
Packet. |
585 |
|
|
586 |
|
%% From XEP-0313: |
587 |
|
%% When sending out the archives to a requesting client, |
588 |
|
%% the forwarded stanza MUST NOT have a 'to' attribute, and |
589 |
|
%% the 'from' MUST be the occupant JID of the sender of the archived message. |
590 |
|
replace_from_to_attributes(SrcJID, Packet = #xmlel{attrs = Attrs}) -> |
591 |
1634 |
NewAttrs = jlib:replace_from_to_attrs(jid:to_binary(SrcJID), undefined, Attrs), |
592 |
1634 |
Packet#xmlel{attrs = NewAttrs}. |
593 |
|
|
594 |
|
-spec message_row_to_ext_id(row()) -> binary(). |
595 |
|
message_row_to_ext_id(#{id := MessID}) -> |
596 |
636 |
mod_mam_utils:mess_id_to_external_binary(MessID). |
597 |
|
|
598 |
|
-spec handle_error_iq(mongoose_acc:t(), host_type(), jid:jid(), atom(), |
599 |
|
{error, term(), jlib:iq()} | jlib:iq() | ignore) -> {mongoose_acc:t(), jlib:iq() | ignore}. |
600 |
|
handle_error_iq(Acc, HostType, _To, _Action, {error, _Reason, IQ}) -> |
601 |
28 |
mongoose_instrument:execute(mod_mam_muc_dropped_iq, #{host_type => HostType}, |
602 |
|
#{acc => Acc, count => 1}), |
603 |
28 |
{Acc, IQ}; |
604 |
|
handle_error_iq(Acc, _HostType, _To, _Action, IQ) -> |
605 |
409 |
{Acc, IQ}. |
606 |
|
|
607 |
|
return_error_iq(IQ, {Reason, {stacktrace, _Stacktrace}}) -> |
608 |
:-( |
return_error_iq(IQ, Reason); |
609 |
|
return_error_iq(IQ, timeout) -> |
610 |
:-( |
{error, timeout, IQ#iq{type = error, sub_el = [mongoose_xmpp_errors:service_unavailable(<<"en">>, <<"Timeout in mod_mam_muc">>)]}}; |
611 |
|
return_error_iq(IQ, invalid_stanza_id) -> |
612 |
7 |
Text = mongoose_xmpp_errors:not_acceptable(<<"en">>, <<"Invalid stanza id provided">>), |
613 |
7 |
{error, invalid_stanza_id, IQ#iq{type = error, sub_el = [Text]}}; |
614 |
|
return_error_iq(IQ, item_not_found) -> |
615 |
21 |
{error, item_not_found, IQ#iq{type = error, sub_el = [mongoose_xmpp_errors:item_not_found()]}}; |
616 |
|
return_error_iq(IQ, not_implemented) -> |
617 |
:-( |
{error, not_implemented, IQ#iq{type = error, sub_el = [mongoose_xmpp_errors:feature_not_implemented(<<"en">>, <<"From mod_mam_muc">>)]}}; |
618 |
|
return_error_iq(IQ, missing_with_jid) -> |
619 |
:-( |
Error = mongoose_xmpp_errors:bad_request(<<"en">>, |
620 |
|
<<"Limited set of queries allowed in the conversation mode.", |
621 |
|
"Missing with_jid filter">>), |
622 |
:-( |
{error, bad_request, IQ#iq{type = error, sub_el = [Error]}}; |
623 |
|
return_error_iq(IQ, Reason) -> |
624 |
:-( |
{error, Reason, IQ#iq{type = error, sub_el = [mongoose_xmpp_errors:internal_server_error()]}}. |
625 |
|
|
626 |
|
-spec return_action_not_allowed_error_iq(Reason :: binary(), jlib:iq()) -> jlib:iq(). |
627 |
|
return_action_not_allowed_error_iq(Reason, IQ) -> |
628 |
14 |
ErrorEl = jlib:stanza_errort(<<"">>, <<"cancel">>, <<"not-allowed">>, |
629 |
|
<<"en">>, <<"The action is not allowed. ", Reason/binary>>), |
630 |
14 |
IQ#iq{type = error, sub_el = [ErrorEl]}. |
631 |
|
|
632 |
|
-spec return_max_delay_reached_error_iq(jlib:iq()) -> jlib:iq(). |
633 |
|
return_max_delay_reached_error_iq(IQ) -> |
634 |
|
%% Message not found. |
635 |
:-( |
ErrorEl = mongoose_xmpp_errors:resource_constraint( |
636 |
|
<<"en">>, <<"The action is cancelled because of flooding.">>), |
637 |
:-( |
IQ#iq{type = error, sub_el = [ErrorEl]}. |
638 |
|
|
639 |
|
return_message_form_iq(HostType, IQ) -> |
640 |
:-( |
Form = mod_mam_utils:message_form(?MODULE, HostType, IQ#iq.xmlns), |
641 |
:-( |
IQ#iq{type = result, sub_el = [Form]}. |
642 |
|
|
643 |
|
% the stacktrace is a big lie |
644 |
|
report_issue({Reason, {stacktrace, Stacktrace}}, Issue, ArcJID, IQ) -> |
645 |
:-( |
report_issue(Reason, Stacktrace, Issue, ArcJID, IQ); |
646 |
|
report_issue(Reason, Issue, ArcJID, IQ) -> |
647 |
28 |
report_issue(Reason, [], Issue, ArcJID, IQ). |
648 |
|
|
649 |
|
report_issue(invalid_stanza_id, _Stacktrace, _Issue, _ArcJID, _IQ) -> |
650 |
:-( |
expected; |
651 |
|
report_issue(item_not_found, _Stacktrace, _Issue, _ArcJID, _IQ) -> |
652 |
21 |
expected; |
653 |
|
report_issue(missing_with_jid, _Stacktrace, _Issue, _ArcJID, _IQ) -> |
654 |
:-( |
expected; |
655 |
|
report_issue(not_implemented, _Stacktrace, _Issue, _ArcJID, _IQ) -> |
656 |
:-( |
expected; |
657 |
|
report_issue(timeout, _Stacktrace, _Issue, _ArcJID, _IQ) -> |
658 |
:-( |
expected; |
659 |
|
report_issue(Reason, Stacktrace, Issue, #jid{lserver = LServer, luser = LUser}, IQ) -> |
660 |
7 |
?LOG_ERROR(#{what => mam_muc_error, issue => Issue, reason => Reason, |
661 |
:-( |
user => LUser, server => LServer, iq => IQ, stacktrace => Stacktrace}). |
662 |
|
|
663 |
|
-spec is_archivable_message(HostType :: host_type(), |
664 |
|
Dir :: incoming | outgoing, |
665 |
|
Packet :: exml:element()) -> boolean(). |
666 |
|
is_archivable_message(HostType, Dir, Packet) -> |
667 |
636 |
M = mod_mam_params:is_archivable_message_module(?MODULE, HostType), |
668 |
636 |
ArchiveChatMarkers = mod_mam_params:archive_chat_markers(?MODULE, HostType), |
669 |
636 |
erlang:apply(M, is_archivable_message, [?MODULE, Dir, Packet, ArchiveChatMarkers]). |
670 |
|
|
671 |
|
-spec hooks(mongooseim:host_type()) -> gen_hook:hook_list(). |
672 |
|
hooks(HostType) -> |
673 |
136 |
[{disco_muc_features, HostType, fun ?MODULE:disco_muc_features/3, #{}, 99}, |
674 |
|
{filter_room_packet, HostType, fun ?MODULE:filter_room_packet/3, #{}, 60}, |
675 |
|
{forget_room, HostType, fun ?MODULE:forget_room/3, #{}, 90}, |
676 |
|
{get_personal_data, HostType, fun ?MODULE:get_personal_data/3, #{}, 50}]. |
677 |
|
|
678 |
|
add_iq_handlers(HostType, Opts) -> |
679 |
68 |
IQDisc = gen_mod:get_opt(iqdisc, Opts, parallel), |
680 |
68 |
MUCSubdomainPattern = gen_mod:get_module_opt(HostType, ?MODULE, host), |
681 |
|
|
682 |
68 |
gen_iq_handler:add_iq_handler_for_subdomain(HostType, MUCSubdomainPattern, |
683 |
|
?NS_MAM_04, mod_muc_iq, |
684 |
|
fun ?MODULE:room_process_mam_iq/5, |
685 |
|
#{}, IQDisc), |
686 |
68 |
gen_iq_handler:add_iq_handler_for_subdomain(HostType, MUCSubdomainPattern, |
687 |
|
?NS_MAM_06, mod_muc_iq, |
688 |
|
fun ?MODULE:room_process_mam_iq/5, |
689 |
|
#{}, IQDisc), |
690 |
68 |
ok. |
691 |
|
|
692 |
|
remove_iq_handlers(HostType) -> |
693 |
68 |
MUCSubdomainPattern = gen_mod:get_module_opt(HostType, ?MODULE, host), |
694 |
68 |
gen_iq_handler:remove_iq_handler_for_subdomain(HostType, MUCSubdomainPattern, |
695 |
|
?NS_MAM_04, mod_muc_iq), |
696 |
68 |
gen_iq_handler:remove_iq_handler_for_subdomain(HostType, MUCSubdomainPattern, |
697 |
|
?NS_MAM_06, mod_muc_iq), |
698 |
68 |
ok. |
699 |
|
|
700 |
|
-spec instrumentation(host_type()) -> [mongoose_instrument:spec()]. |
701 |
|
instrumentation(HostType) -> |
702 |
137 |
[{mod_mam_muc_archive_message, #{host_type => HostType}, |
703 |
|
#{metrics => #{count => spiral, time => histogram}}}, |
704 |
|
{mod_mam_muc_lookup, #{host_type => HostType}, |
705 |
|
#{metrics => #{count => spiral, size => histogram, time => histogram}}}, |
706 |
|
{mod_mam_muc_dropped_iq, #{host_type => HostType}, |
707 |
|
#{metrics => #{count => spiral}}}, |
708 |
|
{mod_mam_muc_dropped, #{host_type => HostType}, |
709 |
|
#{metrics => #{count => spiral}}}, |
710 |
|
{mod_mam_muc_remove_archive, #{host_type => HostType}, |
711 |
|
#{metrics => #{count => spiral}}}, |
712 |
|
{mod_mam_muc_get_prefs, #{host_type => HostType}, |
713 |
|
#{metrics => #{count => spiral}}}, |
714 |
|
{mod_mam_muc_set_prefs, #{host_type => HostType}, |
715 |
|
#{metrics => #{count => spiral}}}]. |