./ct_report/coverage/mod_smart_markers.COVER.html

1 %%%----------------------------------------------------------------------------
2 %%% @copyright (C) 2020, Erlang Solutions Ltd.
3 %%% @doc
4 %%% This module implements storage of the latest chat markers
5 %%% sent by the users. This can be used to optimize mod_offline
6 %%% functionality, or to implement custom fetching protocol and
7 %%% avoid storage of chat markers in MAM.
8 %%%
9 %%% Please be aware of the next implementation details:
10 %%%
11 %%% 1) Current implementation is based on user_send_message hook.
12 %%% It doesn't work for s2s connections, but usage of another
13 %%% hook (e.g. filter_local_packet) makes implementation harder
14 %%% and results in multiple processing of one and the same
15 %%% chat marker notification (sent to different users by MUC).
16 %%% However that is the only possible way to deal with group
17 %%% chat messages sent from the room to the user over s2s.
18 %%%
19 %%% ```
20 %%% S2S
21 %%% +
22 %%% |
23 %%% +--------------------+ |
24 %%% | | | filter
25 %%% | +--------------->
26 %%% send | | | filter
27 %%% +------->+ ROOM +--------------->
28 %%% | | | filter
29 %%% | +--------------->
30 %%% | | |
31 %%% +--------------------+ |
32 %%% |
33 %%% +
34 %%% '''
35 %%%
36 %%% 2) DB backend requires us to provide host information, and
37 %%% the host is always the recipient's server in case one2one
38 %%% messages, and a master domain of the MUC service in case
39 %%% of groupchat.
40 %%%
41 %%% 3) It is the client application's responsibility to ensure that
42 %%% chat markers move only forward. There is no verification of
43 %%% chat markers in this module, it just stores the latest chat
44 %%% marker information sent by the user.
45 %%%
46 %%% 4) MUC light doesn't have message serialization! So it doesn't
47 %%% guarantee one and the same message order for different users.
48 %%% This can result in a race condition situation when different
49 %%% users track (and mark) different messages as the last in a
50 %%% chat history. However, this is a rare situation, and it self
51 %%% recovers on the next message in the room. Anyway storing chat
52 %%% markers in MAM doesn't fix this problem.
53 %%%
54 %%% @end
55 %%%----------------------------------------------------------------------------
56 -module(mod_smart_markers).
57
58 -include("jlib.hrl").
59 -include("mod_muc_light.hrl").
60 -include("mongoose_config_spec.hrl").
61
62 -xep([{xep, 333}, {version, "0.4"}]).
63 -behaviour(gen_mod).
64
65 %% gen_mod API
66 -export([start/2, stop/1, supported_features/0, config_spec/0]).
67
68 %% Internal API
69 -export([get_chat_markers/3]).
70
71 %% Hook handlers
72 -export([process_iq/5, user_send_message/3, filter_local_packet/3,
73 remove_user/3, remove_domain/3, forget_room/3, room_new_affiliations/3]).
74 -ignore_xref([process_iq/5]).
75
76 %%--------------------------------------------------------------------
77 %% Type declarations
78 %%--------------------------------------------------------------------
79 -type maybe_thread() :: undefined | binary().
80 -type chat_type() :: one2one | groupchat.
81 -type chat_marker() :: #{from := jid:jid(),
82 to := jid:jid(),
83 thread := maybe_thread(), % it is not optional!
84 type := mongoose_chat_markers:chat_marker_type(),
85 timestamp := integer(), % microsecond
86 id := binary()}.
87
88 -export_type([maybe_thread/0,
89 chat_marker/0]).
90
91 %% gen_mod API
92 -spec start(mongooseim:host_type(), gen_mod:module_opts()) -> any().
93 start(HostType, #{iqdisc := IQDisc, keep_private := Private} = Opts) ->
94 9 mod_smart_markers_backend:init(HostType, Opts),
95 9 gen_iq_handler:add_iq_handler_for_domain(
96 HostType, ?NS_ESL_SMART_MARKERS, ejabberd_sm,
97 fun ?MODULE:process_iq/5, #{keep_private => Private}, IQDisc),
98 9 gen_hook:add_handlers(hooks(HostType, Opts)).
99
100 -spec stop(mongooseim:host_type()) -> ok.
101 stop(HostType) ->
102 9 Opts = gen_mod:get_module_opts(HostType, ?MODULE),
103 9 case gen_mod:get_opt(backend, Opts) of
104 3 rdbms_async -> mod_smart_markers_rdbms_async:stop(HostType);
105 6 _ -> ok
106 end,
107 9 gen_iq_handler:remove_iq_handler_for_domain(HostType, ?NS_ESL_SMART_MARKERS, ejabberd_sm),
108 9 gen_hook:delete_handlers(hooks(HostType, Opts)).
109
110 -spec supported_features() -> [atom()].
111 supported_features() ->
112
:-(
[dynamic_domains].
113
114 -spec config_spec() -> mongoose_config_spec:config_section().
115 config_spec() ->
116 66 #section{
117 items = #{<<"keep_private">> => #option{type = boolean},
118 <<"backend">> => #option{type = atom, validate = {enum, [rdbms, rdbms_async]}},
119 <<"async_writer">> => async_config_spec(),
120 <<"iqdisc">> => mongoose_config_spec:iqdisc()},
121 defaults = #{<<"keep_private">> => false,
122 <<"backend">> => rdbms,
123 <<"iqdisc">> => no_queue}
124 }.
125
126 async_config_spec() ->
127 66 #section{
128 items = #{<<"pool_size">> => #option{type = integer, validate = non_negative}},
129 defaults = #{<<"pool_size">> => 2 * erlang:system_info(schedulers_online)},
130 include = always
131 }.
132
133 %% IQ handlers
134 -spec process_iq(mongoose_acc:t(), jid:jid(), jid:jid(), jlib:iq(), map()) ->
135 {mongoose_acc:t(), jlib:iq()}.
136 process_iq(Acc, _From, _To, #iq{type = set, sub_el = SubEl} = IQ, _Extra) ->
137 2 {Acc, IQ#iq{type = error, sub_el = [SubEl, mongoose_xmpp_errors:not_allowed()]}};
138 process_iq(Acc, From, _To, #iq{type = get, sub_el = SubEl} = IQ, #{keep_private := Private}) ->
139 29 Req = maps:from_list(SubEl#xmlel.attrs),
140 29 MaybePeer = jid:from_binary(maps:get(<<"peer">>, Req, undefined)),
141 29 MaybeAfter = parse_ts(maps:get(<<"after">>, Req, undefined)),
142 29 MaybeThread = maps:get(<<"thread">>, Req, undefined),
143 29 Res = fetch_markers(IQ, Acc, From, MaybePeer, MaybeThread, MaybeAfter, Private),
144 29 {Acc, Res}.
145
146 -spec parse_ts(undefined | binary()) -> integer() | error.
147 parse_ts(undefined) ->
148 23 0;
149 parse_ts(BinTS) ->
150 6 try calendar:rfc3339_to_system_time(binary_to_list(BinTS))
151 2 catch error:_Error -> error
152 end.
153
154 -spec fetch_markers(jlib:iq(),
155 mongoose_acc:t(),
156 From :: jid:jid(),
157 MaybePeer :: error | jid:jid(),
158 maybe_thread(),
159 MaybeTS :: error | integer(),
160 MaybePrivate :: boolean()) -> jlib:iq().
161 fetch_markers(IQ, _, _, error, _, _, _) ->
162 4 IQ#iq{type = error,
163 sub_el = [mongoose_xmpp_errors:bad_request(<<"en">>, <<"invalid-peer">>)]};
164 fetch_markers(IQ, _, _, _, _, error, _) ->
165 2 IQ#iq{type = error,
166 sub_el = [mongoose_xmpp_errors:bad_request(<<"en">>, <<"invalid-timestamp">>)]};
167 fetch_markers(IQ, Acc, From, Peer, Thread, TS, Private) ->
168 23 HostType = mongoose_acc:host_type(Acc),
169 23 Markers = mod_smart_markers_backend:get_conv_chat_marker(HostType, From, Peer, Thread, TS, Private),
170 23 SubEl = #xmlel{name = <<"query">>,
171 attrs = [{<<"xmlns">>, ?NS_ESL_SMART_MARKERS},
172 {<<"peer">>, jid:to_bare_binary(Peer)}],
173 children = build_result(Markers)},
174 23 IQ#iq{type = result, sub_el = SubEl}.
175
176 build_result(Markers) ->
177 23 [ #xmlel{name = <<"marker">>,
178 attrs = [{<<"id">>, MsgId},
179 {<<"from">>, jid:to_binary(From)},
180 {<<"type">>, atom_to_binary(Type)},
181 {<<"timestamp">>, ts_to_bin(MsgTS)}
182 | maybe_thread(MsgThread) ]}
183 23 || #{from := From, thread := MsgThread, type := Type, timestamp := MsgTS, id := MsgId} <- Markers ].
184
185 ts_to_bin(TS) ->
186 21 list_to_binary(calendar:system_time_to_rfc3339(TS, [{offset, "Z"}, {unit, microsecond}])).
187
188 maybe_thread(undefined) ->
189 17 [];
190 maybe_thread(Bin) ->
191 4 [{<<"thread">>, Bin}].
192
193 %% HOOKS
194 -spec hooks(mongooseim:host_type(), gen_mod:module_opts()) -> gen_hook:hook_list().
195 hooks(HostType, #{keep_private := KeepPrivate}) ->
196 18 [{user_send_message, HostType, fun ?MODULE:user_send_message/3, #{}, 90} |
197 private_hooks(HostType, KeepPrivate) ++ removal_hooks(HostType)].
198
199 private_hooks(_HostType, false) ->
200 14 [];
201 private_hooks(HostType, true) ->
202 4 [{filter_local_packet, HostType, fun ?MODULE:filter_local_packet/3, #{}, 20}].
203
204 removal_hooks(HostType) ->
205 18 [{remove_user, HostType, fun ?MODULE:remove_user/3, #{}, 60},
206 {remove_domain, HostType, fun ?MODULE:remove_domain/3, #{}, 60},
207 {forget_room, HostType, fun ?MODULE:forget_room/3, #{}, 85},
208 {room_new_affiliations, HostType, fun ?MODULE:room_new_affiliations/3, #{}, 60}].
209
210 -spec user_send_message(mongoose_acc:t(), mongoose_c2s_hooks:params(), gen_hook:extra()) ->
211 mongoose_c2s_hooks:result().
212 user_send_message(Acc, _, _) ->
213 88 {From, To, Packet} = mongoose_acc:packet(Acc),
214 88 case has_valid_markers(Acc, From, To, Packet) of
215 {true, HostType, Markers} ->
216 47 {ok, update_chat_markers(Acc, HostType, Markers)};
217 _ ->
218 41 {ok, Acc}
219 end.
220
221 -spec filter_local_packet(Acc, Params, Extra) -> {ok, Acc} | {stop, drop} when
222 Acc :: mongoose_hooks:filter_packet_acc(),
223 Params :: map(),
224 Extra :: gen_hook:extra().
225 filter_local_packet(Filter = {_From, _To, _Acc, Msg = #xmlel{name = <<"message">>}}, _, _) ->
226 44 case mongoose_chat_markers:has_chat_markers(Msg) of
227 38 false -> {ok, Filter};
228 6 true -> {stop, drop}
229 end;
230 filter_local_packet(Filter, _, _) ->
231 66 {ok, Filter}.
232
233 -spec remove_user(Acc, Params, Extra) -> {ok, Acc} when
234 Acc :: mongoose_acc:t(),
235 Params :: #{jid := jid:jid()},
236 Extra :: gen_hook:extra().
237 remove_user(Acc, #{jid := #jid{luser = User, lserver = Server}}, _) ->
238 66 HostType = mongoose_acc:host_type(Acc),
239 66 mod_smart_markers_backend:remove_user(HostType, jid:make_bare(User, Server)),
240 66 {ok, Acc}.
241
242 -spec remove_domain(Acc, Params, Extra) -> {ok, Acc} when
243 Acc :: mongoose_domain_api:remove_domain_acc(),
244 Params :: #{domain := jid:lserver()},
245 Extra :: #{host_type := mongooseim:host_type()}.
246 remove_domain(Acc, #{domain := Domain}, #{host_type := HostType}) ->
247 1 mod_smart_markers_backend:remove_domain(HostType, Domain),
248 1 {ok, Acc}.
249
250 -spec forget_room(Acc, Params, Extra) -> {ok, Acc} when
251 Acc :: mongoose_acc:t(),
252 Params :: #{muc_host := jid:lserver(), room := jid:luser()},
253 Extra :: #{host_type := mongooseim:host_type()}.
254 forget_room(Acc, #{muc_host := RoomS, room := RoomU}, #{host_type := HostType}) ->
255 4 mod_smart_markers_backend:remove_to(HostType, jid:make_noprep(RoomU, RoomS, <<>>)),
256 4 {ok, Acc}.
257
258 %% The new affs can be found in the Acc:element, where we can scan for 'none' ones
259 -spec room_new_affiliations(Acc, Params, Extra) -> {ok, Acc} when
260 Acc :: mongoose_acc:t(),
261 Params :: #{room := jid:jid()},
262 Extra :: gen_hook:extra().
263 room_new_affiliations(Acc, #{room := RoomJID}, _) ->
264 6 HostType = mod_muc_light_utils:acc_to_host_type(Acc),
265 6 Packet = mongoose_acc:element(Acc),
266 6 maybe_remove_to_for_users(Packet, RoomJID, HostType),
267 6 {ok, Acc}.
268
269 -spec maybe_remove_to_for_users(exml:element() | undefined, jid:jid(), mongooseim:host_type()) -> ok.
270 4 maybe_remove_to_for_users(undefined, _, _) -> ok;
271 maybe_remove_to_for_users(Packet, RoomJID, HostType) ->
272 2 Users = exml_query:paths(Packet, [{element_with_ns, ?NS_MUC_LIGHT_AFFILIATIONS},
273 {element_with_attr, <<"affiliation">>, <<"none">>},
274 cdata]),
275 2 FromJIDs = lists:map(fun(U) -> jid:to_bare(jid:from_binary(U)) end, Users),
276 2 RemoveFun = fun(FromJID) -> mod_smart_markers_backend:remove_to_for_user(HostType, FromJID, RoomJID) end,
277 2 lists:foreach(RemoveFun, FromJIDs).
278
279 %%--------------------------------------------------------------------
280 %% Other API
281 %%--------------------------------------------------------------------
282 -spec get_chat_markers(jid:jid(), maybe_thread(), integer()) ->
283 [chat_marker()].
284 get_chat_markers(#jid{lserver = LServer} = To, Thread, TS) ->
285 %% internal API, no room access rights verification here!
286 4 {ok, HostType} = mongoose_domain_api:get_host_type(LServer),
287 4 mod_smart_markers_backend:get_chat_markers(HostType, To, Thread, TS).
288
289 %%--------------------------------------------------------------------
290 %% Local functions
291 %%--------------------------------------------------------------------
292 -spec update_chat_markers(mongoose_acc:t(), mongooseim:host_type(), [chat_marker()]) ->
293 mongoose_acc:t().
294 update_chat_markers(Acc, HostType, Markers) ->
295 47 TS = mongoose_acc:timestamp(Acc),
296 47 [mod_smart_markers_backend:update_chat_marker(HostType, CM) || CM <- Markers],
297 47 mongoose_acc:set_permanent(?MODULE, timestamp, TS, Acc).
298
299 -spec has_valid_markers(mongoose_acc:t(), jid:jid(), jid:jid(), exml:element()) ->
300 false | {true, mongooseim:host_type(), Markers :: [chat_marker()]}.
301 has_valid_markers(Acc, From, To, Packet) ->
302 88 case extract_chat_markers(Acc, From, To, Packet) of
303 41 [] -> false;
304 Markers ->
305 47 case is_valid_host(Acc, From, To) of
306
:-(
false -> false;
307 47 {true, HostType} -> {true, HostType, Markers}
308 end
309 end.
310
311 -spec is_valid_host(mongoose_acc:t(), jid:jid(), jid:jid()) ->
312 false | {true, mongooseim:host_type()}.
313 is_valid_host(Acc, From, To) ->
314 47 case mongoose_acc:stanza_type(Acc) of
315 17 <<"groupchat">> -> get_host(Acc, From, To, groupchat);
316 30 _ -> get_host(Acc, From, To, one2one)
317 end.
318
319 -spec extract_chat_markers(mongoose_acc:t(), jid:jid(), jid:jid(), exml:element()) ->
320 [chat_marker()].
321 extract_chat_markers(Acc, From, To, Packet) ->
322 88 case mongoose_chat_markers:list_chat_markers(Packet) of
323 41 [] -> [];
324 ChatMarkers ->
325 47 TS = mongoose_acc:timestamp(Acc),
326 47 CM = #{from => From,
327 to => jid:to_bare(To),
328 thread => get_thread(Packet),
329 timestamp => TS,
330 type => undefined,
331 id => undefined},
332 47 [CM#{type => Type, id => Id} || {Type, Id} <- ChatMarkers]
333 end.
334
335 -spec get_thread(exml:element()) -> maybe_thread().
336 get_thread(El) ->
337 47 case exml_query:path(El, [{element, <<"thread">>}, cdata]) of
338 47 Thread when Thread =/= <<>> -> Thread;
339
:-(
_ -> undefined
340 end.
341
342 -spec get_host(mongoose_acc:t(), jid:jid(), jid:jid(), chat_type()) ->
343 false | {true, mongooseim:host_type()}.
344 get_host(Acc, From, To, groupchat) ->
345 17 HostType = mod_muc_light_utils:room_jid_to_host_type(To),
346 17 can_access_room(HostType, Acc, From, To) andalso {true, HostType};
347 get_host(_Acc, _From, To, one2one) ->
348 30 LServer = To#jid.lserver,
349 30 case mongoose_domain_api:get_domain_host_type(LServer) of
350 30 {ok, HostType} -> {true, HostType};
351
:-(
{error, not_found} -> false
352 end.
353
354 -spec can_access_room(HostType :: mongooseim:host_type(),
355 Acc :: mongoose_acc:t(),
356 User :: jid:jid(),
357 Room :: jid:jid()) -> boolean().
358 can_access_room(HostType, Acc, User, Room) ->
359 17 mongoose_hooks:can_access_room(HostType, Acc, Room, User).
Line Hits Source