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 |
106 |
#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 |
106 |
#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 |
32 |
Req = maps:from_list(SubEl#xmlel.attrs), |
140 |
32 |
MaybePeer = jid:from_binary(maps:get(<<"peer">>, Req, undefined)), |
141 |
32 |
MaybeAfter = parse_ts(maps:get(<<"after">>, Req, undefined)), |
142 |
32 |
MaybeThread = maps:get(<<"thread">>, Req, undefined), |
143 |
32 |
Res = fetch_markers(IQ, Acc, From, MaybePeer, MaybeThread, MaybeAfter, Private), |
144 |
32 |
{Acc, Res}. |
145 |
|
|
146 |
|
-spec parse_ts(undefined | binary()) -> integer() | error. |
147 |
|
parse_ts(undefined) -> |
148 |
24 |
0; |
149 |
|
parse_ts(BinTS) -> |
150 |
8 |
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 |
26 |
HostType = mongoose_acc:host_type(Acc), |
169 |
26 |
Markers = mod_smart_markers_backend:get_conv_chat_marker(HostType, From, Peer, Thread, TS, Private), |
170 |
26 |
SubEl = #xmlel{name = <<"query">>, |
171 |
|
attrs = [{<<"xmlns">>, ?NS_ESL_SMART_MARKERS}, |
172 |
|
{<<"peer">>, jid:to_bare_binary(Peer)}], |
173 |
|
children = build_result(Markers)}, |
174 |
26 |
IQ#iq{type = result, sub_el = SubEl}. |
175 |
|
|
176 |
|
build_result(Markers) -> |
177 |
26 |
[ #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 |
26 |
|| #{from := From, thread := MsgThread, type := Type, timestamp := MsgTS, id := MsgId} <- Markers ]. |
184 |
|
|
185 |
|
ts_to_bin(TS) -> |
186 |
22 |
list_to_binary(calendar:system_time_to_rfc3339(TS, [{offset, "Z"}, {unit, microsecond}])). |
187 |
|
|
188 |
|
maybe_thread(undefined) -> |
189 |
18 |
[]; |
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). |