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_packet 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_packet/4, filter_local_packet/1, |
73 |
|
remove_user/3, remove_domain/3, forget_room/4, room_new_affiliations/4]). |
74 |
|
-ignore_xref([process_iq/5, user_send_packet/4, filter_local_packet/1, |
75 |
|
remove_user/3, remove_domain/3, forget_room/4, room_new_affiliations/4]). |
76 |
|
|
77 |
|
%%-------------------------------------------------------------------- |
78 |
|
%% Type declarations |
79 |
|
%%-------------------------------------------------------------------- |
80 |
|
-type maybe_thread() :: undefined | binary(). |
81 |
|
-type chat_type() :: one2one | groupchat. |
82 |
|
-type chat_marker() :: #{from := jid:jid(), |
83 |
|
to := jid:jid(), |
84 |
|
thread := maybe_thread(), % it is not optional! |
85 |
|
type := mongoose_chat_markers:chat_marker_type(), |
86 |
|
timestamp := integer(), % microsecond |
87 |
|
id := binary()}. |
88 |
|
|
89 |
|
-export_type([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 |
:-( |
mod_smart_markers_backend:init(HostType, Opts), |
95 |
:-( |
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 |
:-( |
ejabberd_hooks:add(hooks(HostType, Opts)). |
99 |
|
|
100 |
|
-spec stop(mongooseim:host_type()) -> ok. |
101 |
|
stop(HostType) -> |
102 |
:-( |
Opts = gen_mod:get_module_opts(HostType, ?MODULE), |
103 |
:-( |
case gen_mod:get_opt(backend, Opts) of |
104 |
:-( |
rdbms_async -> mod_smart_markers_rdbms_async:stop(HostType); |
105 |
:-( |
_ -> ok |
106 |
|
end, |
107 |
:-( |
gen_iq_handler:remove_iq_handler_for_domain(HostType, ?NS_ESL_SMART_MARKERS, ejabberd_sm), |
108 |
:-( |
ejabberd_hooks:delete(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 |
164 |
#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 |
|
format_items = map |
125 |
|
}. |
126 |
|
|
127 |
|
async_config_spec() -> |
128 |
164 |
#section{ |
129 |
|
items = #{<<"pool_size">> => #option{type = integer, validate = non_negative}}, |
130 |
|
defaults = #{<<"pool_size">> => 2 * erlang:system_info(schedulers_online)}, |
131 |
|
format_items = map, |
132 |
|
include = always |
133 |
|
}. |
134 |
|
|
135 |
|
%% IQ handlers |
136 |
|
-spec process_iq(mongoose_acc:t(), jid:jid(), jid:jid(), jlib:iq(), map()) -> |
137 |
|
{mongoose_acc:t(), jlib:iq()}. |
138 |
|
process_iq(Acc, _From, _To, #iq{type = set, sub_el = SubEl} = IQ, _Extra) -> |
139 |
:-( |
{Acc, IQ#iq{type = error, sub_el = [SubEl, mongoose_xmpp_errors:not_allowed()]}}; |
140 |
|
process_iq(Acc, From, _To, #iq{type = get, sub_el = SubEl} = IQ, #{keep_private := Private}) -> |
141 |
:-( |
Req = maps:from_list(SubEl#xmlel.attrs), |
142 |
:-( |
MaybePeer = jid:from_binary(maps:get(<<"peer">>, Req, undefined)), |
143 |
:-( |
MaybeAfter = parse_ts(maps:get(<<"after">>, Req, undefined)), |
144 |
:-( |
MaybeThread = maps:get(<<"thread">>, Req, undefined), |
145 |
:-( |
Res = fetch_markers(IQ, Acc, From, MaybePeer, MaybeThread, MaybeAfter, Private), |
146 |
:-( |
{Acc, Res}. |
147 |
|
|
148 |
|
-spec parse_ts(undefined | binary()) -> integer() | error. |
149 |
|
parse_ts(undefined) -> |
150 |
:-( |
0; |
151 |
|
parse_ts(BinTS) -> |
152 |
:-( |
try calendar:rfc3339_to_system_time(binary_to_list(BinTS)) |
153 |
:-( |
catch error:_Error -> error |
154 |
|
end. |
155 |
|
|
156 |
|
-spec fetch_markers(jlib:iq(), |
157 |
|
mongoose_acc:t(), |
158 |
|
From :: jid:jid(), |
159 |
|
MaybePeer :: error | jid:jid(), |
160 |
|
maybe_thread(), |
161 |
|
MaybeTS :: error | integer(), |
162 |
|
MaybePrivate :: boolean()) -> jlib:iq(). |
163 |
|
fetch_markers(IQ, _, _, error, _, _, _) -> |
164 |
:-( |
IQ#iq{type = error, |
165 |
|
sub_el = [mongoose_xmpp_errors:bad_request(<<"en">>, <<"invalid-peer">>)]}; |
166 |
|
fetch_markers(IQ, _, _, _, _, error, _) -> |
167 |
:-( |
IQ#iq{type = error, |
168 |
|
sub_el = [mongoose_xmpp_errors:bad_request(<<"en">>, <<"invalid-timestamp">>)]}; |
169 |
|
fetch_markers(IQ, Acc, From, Peer, Thread, TS, Private) -> |
170 |
:-( |
HostType = mongoose_acc:host_type(Acc), |
171 |
:-( |
Markers = mod_smart_markers_backend:get_conv_chat_marker(HostType, From, Peer, Thread, TS, Private), |
172 |
:-( |
SubEl = #xmlel{name = <<"query">>, |
173 |
|
attrs = [{<<"xmlns">>, ?NS_ESL_SMART_MARKERS}, |
174 |
|
{<<"peer">>, jid:to_binary(jid:to_lus(Peer))}], |
175 |
|
children = build_result(Markers)}, |
176 |
:-( |
IQ#iq{type = result, sub_el = SubEl}. |
177 |
|
|
178 |
|
build_result(Markers) -> |
179 |
:-( |
[ #xmlel{name = <<"marker">>, |
180 |
|
attrs = [{<<"id">>, MsgId}, |
181 |
|
{<<"from">>, jid:to_binary(From)}, |
182 |
|
{<<"type">>, atom_to_binary(Type)}, |
183 |
|
{<<"timestamp">>, ts_to_bin(MsgTS)} |
184 |
|
| maybe_thread(MsgThread) ]} |
185 |
:-( |
|| #{from := From, thread := MsgThread, type := Type, timestamp := MsgTS, id := MsgId} <- Markers ]. |
186 |
|
|
187 |
|
ts_to_bin(TS) -> |
188 |
:-( |
list_to_binary(calendar:system_time_to_rfc3339(TS, [{offset, "Z"}, {unit, microsecond}])). |
189 |
|
|
190 |
|
maybe_thread(undefined) -> |
191 |
:-( |
[]; |
192 |
|
maybe_thread(Bin) -> |
193 |
:-( |
[{<<"thread">>, Bin}]. |
194 |
|
|
195 |
|
%% HOOKS |
196 |
|
-spec hooks(mongooseim:host_type(), gen_mod:module_opts()) -> [ejabberd_hooks:hook()]. |
197 |
|
hooks(HostType, #{keep_private := KeepPrivate}) -> |
198 |
:-( |
[{user_send_packet, HostType, ?MODULE, user_send_packet, 90} | |
199 |
|
private_hooks(HostType, KeepPrivate) ++ removal_hooks(HostType)]. |
200 |
|
|
201 |
|
private_hooks(_HostType, false) -> |
202 |
:-( |
[]; |
203 |
|
private_hooks(HostType, true) -> |
204 |
:-( |
[{filter_local_packet, HostType, ?MODULE, filter_local_packet, 20}]. |
205 |
|
|
206 |
|
removal_hooks(HostType) -> |
207 |
:-( |
[{remove_user, HostType, ?MODULE, remove_user, 60}, |
208 |
|
{remove_domain, HostType, ?MODULE, remove_domain, 60}, |
209 |
|
{forget_room, HostType, ?MODULE, forget_room, 85}, |
210 |
|
{room_new_affiliations, HostType, ?MODULE, room_new_affiliations, 60}]. |
211 |
|
|
212 |
|
-spec user_send_packet(mongoose_acc:t(), jid:jid(), jid:jid(), exml:element()) -> |
213 |
|
mongoose_acc:t(). |
214 |
|
user_send_packet(Acc, From, To, Packet = #xmlel{name = <<"message">>}) -> |
215 |
:-( |
case has_valid_markers(Acc, From, To, Packet) of |
216 |
|
{true, HostType, Markers} -> |
217 |
:-( |
update_chat_markers(Acc, HostType, Markers); |
218 |
|
_ -> |
219 |
:-( |
Acc |
220 |
|
end; |
221 |
|
user_send_packet(Acc, _From, _To, _Packet) -> |
222 |
:-( |
Acc. |
223 |
|
|
224 |
|
-spec filter_local_packet(mongoose_hooks:filter_packet_acc() | drop) -> |
225 |
|
mongoose_hooks:filter_packet_acc() | drop. |
226 |
|
filter_local_packet(Filter = {_From, _To, _Acc, Msg = #xmlel{name = <<"message">>}}) -> |
227 |
:-( |
case mongoose_chat_markers:has_chat_markers(Msg) of |
228 |
:-( |
false -> Filter; |
229 |
:-( |
true -> drop |
230 |
|
end; |
231 |
|
filter_local_packet(Filter) -> |
232 |
:-( |
Filter. |
233 |
|
|
234 |
|
remove_user(Acc, User, Server) -> |
235 |
:-( |
HostType = mongoose_acc:host_type(Acc), |
236 |
:-( |
mod_smart_markers_backend:remove_user(HostType, jid:make_bare(User, Server)), |
237 |
:-( |
Acc. |
238 |
|
|
239 |
|
-spec remove_domain(mongoose_hooks:simple_acc(), |
240 |
|
mongooseim:host_type(), jid:lserver()) -> |
241 |
|
mongoose_hooks:simple_acc(). |
242 |
|
remove_domain(Acc, HostType, Domain) -> |
243 |
:-( |
mod_smart_markers_backend:remove_domain(HostType, Domain), |
244 |
:-( |
Acc. |
245 |
|
|
246 |
|
-spec forget_room(mongoose_hooks:simple_acc(), mongooseim:host_type(), jid:lserver(), jid:luser()) -> |
247 |
|
mongoose_hooks:simple_acc(). |
248 |
|
forget_room(Acc, HostType, RoomS, RoomU) -> |
249 |
:-( |
mod_smart_markers_backend:remove_to(HostType, jid:make_noprep(RoomU, RoomS, <<>>)), |
250 |
:-( |
Acc. |
251 |
|
|
252 |
|
%% The new affs can be found in the Acc:element, where we can scan for 'none' ones |
253 |
|
-spec room_new_affiliations(mongoose_acc:t(), jid:jid(), mod_muc_light:aff_users(), binary()) -> |
254 |
|
mongoose_acc:t(). |
255 |
|
room_new_affiliations(Acc, RoomJid, _NewAffs, _NewVersion) -> |
256 |
:-( |
HostType = mod_muc_light_utils:acc_to_host_type(Acc), |
257 |
:-( |
case mongoose_acc:element(Acc) of |
258 |
:-( |
undefined -> Acc; |
259 |
|
Packet -> |
260 |
:-( |
case exml_query:paths(Packet, [{element_with_ns, ?NS_MUC_LIGHT_AFFILIATIONS}, |
261 |
|
{element_with_attr, <<"affiliation">>, <<"none">>}, |
262 |
|
cdata]) of |
263 |
:-( |
[] -> Acc; |
264 |
|
Users -> |
265 |
:-( |
[begin |
266 |
:-( |
FromJid = jid:to_bare(jid:from_binary(User)), |
267 |
:-( |
mod_smart_markers_backend:remove_to_for_user(HostType, FromJid, RoomJid) |
268 |
:-( |
end || User <- Users ], |
269 |
:-( |
Acc |
270 |
|
end |
271 |
|
end. |
272 |
|
|
273 |
|
%%-------------------------------------------------------------------- |
274 |
|
%% Other API |
275 |
|
%%-------------------------------------------------------------------- |
276 |
|
-spec get_chat_markers(jid:jid(), maybe_thread(), integer()) -> |
277 |
|
[chat_marker()]. |
278 |
|
get_chat_markers(#jid{lserver = LServer} = To, Thread, TS) -> |
279 |
|
%% internal API, no room access rights verification here! |
280 |
:-( |
{ok, HostType} = mongoose_domain_api:get_host_type(LServer), |
281 |
:-( |
mod_smart_markers_backend:get_chat_markers(HostType, To, Thread, TS). |
282 |
|
|
283 |
|
%%-------------------------------------------------------------------- |
284 |
|
%% Local functions |
285 |
|
%%-------------------------------------------------------------------- |
286 |
|
-spec update_chat_markers(mongoose_acc:t(), mongooseim:host_type(), [chat_marker()]) -> |
287 |
|
mongoose_acc:t(). |
288 |
|
update_chat_markers(Acc, HostType, Markers) -> |
289 |
:-( |
TS = mongoose_acc:timestamp(Acc), |
290 |
:-( |
[mod_smart_markers_backend:update_chat_marker(HostType, CM) || CM <- Markers], |
291 |
:-( |
mongoose_acc:set_permanent(?MODULE, timestamp, TS, Acc). |
292 |
|
|
293 |
|
-spec has_valid_markers(mongoose_acc:t(), jid:jid(), jid:jid(), exml:element()) -> |
294 |
|
false | {true, mongooseim:host_type(), Markers :: [chat_marker()]}. |
295 |
|
has_valid_markers(Acc, From, To, Packet) -> |
296 |
:-( |
case extract_chat_markers(Acc, From, To, Packet) of |
297 |
:-( |
[] -> false; |
298 |
|
Markers -> |
299 |
:-( |
case is_valid_host(Acc, From, To) of |
300 |
:-( |
false -> false; |
301 |
:-( |
{true, HostType} -> {true, HostType, Markers} |
302 |
|
end |
303 |
|
end. |
304 |
|
|
305 |
|
-spec is_valid_host(mongoose_acc:t(), jid:jid(), jid:jid()) -> |
306 |
|
false | {true, mongooseim:host_type()}. |
307 |
|
is_valid_host(Acc, From, To) -> |
308 |
:-( |
case mongoose_acc:stanza_type(Acc) of |
309 |
:-( |
<<"groupchat">> -> get_host(Acc, From, To, groupchat); |
310 |
:-( |
_ -> get_host(Acc, From, To, one2one) |
311 |
|
end. |
312 |
|
|
313 |
|
-spec extract_chat_markers(mongoose_acc:t(), jid:jid(), jid:jid(), exml:element()) -> |
314 |
|
[chat_marker()]. |
315 |
|
extract_chat_markers(Acc, From, To, Packet) -> |
316 |
:-( |
case mongoose_chat_markers:list_chat_markers(Packet) of |
317 |
:-( |
[] -> []; |
318 |
|
ChatMarkers -> |
319 |
:-( |
TS = mongoose_acc:timestamp(Acc), |
320 |
:-( |
CM = #{from => From, |
321 |
|
to => jid:to_bare(To), |
322 |
|
thread => get_thread(Packet), |
323 |
|
timestamp => TS, |
324 |
|
type => undefined, |
325 |
|
id => undefined}, |
326 |
:-( |
[CM#{type => Type, id => Id} || {Type, Id} <- ChatMarkers] |
327 |
|
end. |
328 |
|
|
329 |
|
-spec get_thread(exml:element()) -> maybe_thread(). |
330 |
|
get_thread(El) -> |
331 |
:-( |
case exml_query:path(El, [{element, <<"thread">>}, cdata]) of |
332 |
:-( |
Thread when Thread =/= <<>> -> Thread; |
333 |
:-( |
_ -> undefined |
334 |
|
end. |
335 |
|
|
336 |
|
-spec get_host(mongoose_acc:t(), jid:jid(), jid:jid(), chat_type()) -> |
337 |
|
false | {true, mongooseim:host_type()}. |
338 |
|
get_host(Acc, From, To, groupchat) -> |
339 |
:-( |
HostType = mod_muc_light_utils:room_jid_to_host_type(To), |
340 |
:-( |
can_access_room(HostType, Acc, From, To) andalso {true, HostType}; |
341 |
|
get_host(_Acc, _From, To, one2one) -> |
342 |
:-( |
LServer = To#jid.lserver, |
343 |
:-( |
case mongoose_domain_api:get_domain_host_type(LServer) of |
344 |
:-( |
{ok, HostType} -> {true, HostType}; |
345 |
:-( |
{error, not_found} -> false |
346 |
|
end. |
347 |
|
|
348 |
|
-spec can_access_room(HostType :: mongooseim:host_type(), |
349 |
|
Acc :: mongoose_acc:t(), |
350 |
|
User :: jid:jid(), |
351 |
|
Room :: jid:jid()) -> boolean(). |
352 |
|
can_access_room(HostType, Acc, User, Room) -> |
353 |
:-( |
mongoose_hooks:can_access_room(HostType, Acc, Room, User). |