1 |
|
%%%------------------------------------------------------------------- |
2 |
|
%%% @author Rafal Slota |
3 |
|
%%% @copyright (C) 2017 Erlang Solutions Ltd. |
4 |
|
%%% This software is released under the Apache License, Version 2.0 |
5 |
|
%%% cited in 'LICENSE.txt'. |
6 |
|
%%% @end |
7 |
|
%%%------------------------------------------------------------------- |
8 |
|
%%% @doc |
9 |
|
%%% Adapter for MongoosePush service. |
10 |
|
%%% @end |
11 |
|
%%%------------------------------------------------------------------- |
12 |
|
-module(mod_push_service_mongoosepush). |
13 |
|
-author('rafal.slota@erlang-solutions.com'). |
14 |
|
-behavior(gen_mod). |
15 |
|
-behaviour(mongoose_module_metrics). |
16 |
|
|
17 |
|
-include("mongoose.hrl"). |
18 |
|
-include("mongoose_config_spec.hrl"). |
19 |
|
|
20 |
|
%%-------------------------------------------------------------------- |
21 |
|
%% Exports |
22 |
|
%%-------------------------------------------------------------------- |
23 |
|
|
24 |
|
%% gen_mod handlers |
25 |
|
-export([start/2, stop/1, config_spec/0]). |
26 |
|
|
27 |
|
%% Hooks and IQ handlers |
28 |
|
-export([push_notifications/4]). |
29 |
|
|
30 |
|
-export([http_notification/5]). |
31 |
|
|
32 |
|
%% Types |
33 |
|
-export_type([]). |
34 |
|
|
35 |
|
-export([config_metrics/1]). |
36 |
|
|
37 |
|
-ignore_xref([behaviour_info/1, http_notification/5, push_notifications/4]). |
38 |
|
|
39 |
|
%%-------------------------------------------------------------------- |
40 |
|
%% Definitions |
41 |
|
%%-------------------------------------------------------------------- |
42 |
|
|
43 |
|
-define(DEFAULT_API_VERSION, "v3"). |
44 |
|
|
45 |
|
-callback init(term(), term()) -> ok. |
46 |
|
|
47 |
|
%% Types |
48 |
|
|
49 |
|
%%-------------------------------------------------------------------- |
50 |
|
%% Module callbacks |
51 |
|
%%-------------------------------------------------------------------- |
52 |
|
|
53 |
|
-spec start(Host :: mongooseim:host_type(), Opts :: gen_mod:module_opts()) -> any(). |
54 |
|
start(Host, Opts) -> |
55 |
14 |
?LOG_INFO(#{what => push_service_starting, server => Host}), |
56 |
14 |
start_pool(Host, Opts), |
57 |
|
%% Hooks |
58 |
14 |
ejabberd_hooks:add(push_notifications, Host, ?MODULE, push_notifications, 10), |
59 |
14 |
ok. |
60 |
|
|
61 |
|
-spec start_pool(mongooseim:host_type(), gen_mod:module_opts()) -> term(). |
62 |
|
start_pool(Host, Opts) -> |
63 |
14 |
{ok, _} = mongoose_wpool:start(generic, Host, mongoosepush_service, pool_opts(Opts)). |
64 |
|
|
65 |
|
-spec pool_opts(gen_mod:module_opts()) -> mongoose_wpool:pool_opts(). |
66 |
|
pool_opts(Opts) -> |
67 |
14 |
MaxHTTPConnections = gen_mod:get_opt(max_http_connections, Opts, 100), |
68 |
14 |
[{strategy, available_worker}, |
69 |
|
{workers, MaxHTTPConnections}]. |
70 |
|
|
71 |
|
-spec stop(Host :: jid:server()) -> ok. |
72 |
|
stop(Host) -> |
73 |
14 |
ejabberd_hooks:delete(push_notifications, Host, ?MODULE, push_notifications, 10), |
74 |
14 |
mongoose_wpool:stop(generic, Host, mongoosepush_service), |
75 |
|
|
76 |
14 |
ok. |
77 |
|
|
78 |
|
-spec config_spec() -> mongoose_config_spec:config_section(). |
79 |
|
config_spec() -> |
80 |
160 |
#section{ |
81 |
|
items = #{<<"pool_name">> => #option{type = atom, |
82 |
|
validate = pool_name}, |
83 |
|
<<"api_version">> => #option{type = string, |
84 |
|
validate = {enum, ["v2", "v3"]}}, |
85 |
|
<<"max_http_connections">> => #option{type = integer, |
86 |
|
validate = non_negative} |
87 |
|
} |
88 |
|
}. |
89 |
|
|
90 |
|
%%-------------------------------------------------------------------- |
91 |
|
%% Hooks |
92 |
|
%%-------------------------------------------------------------------- |
93 |
|
|
94 |
|
%% Hook 'push_notifications' |
95 |
|
-spec push_notifications(AccIn :: ok | mongoose_acc:t(), Host :: jid:server(), |
96 |
|
Notifications :: [#{binary() => binary()}], |
97 |
|
Options :: #{binary() => binary()}) -> |
98 |
|
ok | {error, Reason :: term()}. |
99 |
|
push_notifications(AccIn, Host, Notifications, Options) -> |
100 |
72 |
?LOG_DEBUG(#{what => push_notifications, notifications => Notifications, |
101 |
72 |
opts => Options, acc => AccIn}), |
102 |
|
|
103 |
72 |
DeviceId = maps:get(<<"device_id">>, Options), |
104 |
72 |
ProtocolVersionOpt = gen_mod:get_module_opt(Host, ?MODULE, api_version, ?DEFAULT_API_VERSION), |
105 |
72 |
{ok, ProtocolVersion} = parse_api_version(ProtocolVersionOpt), |
106 |
72 |
Path = <<ProtocolVersion/binary, "/notification/", DeviceId/binary>>, |
107 |
72 |
Fun = fun(Notification) -> |
108 |
72 |
ReqHeaders = [{<<"content-type">>, <<"application/json">>}], |
109 |
72 |
{ok, JSON} = |
110 |
|
make_notification(Notification, Options), |
111 |
72 |
Payload = jiffy:encode(JSON), |
112 |
72 |
call(Host, ?MODULE, http_notification, [Host, post, Path, ReqHeaders, Payload]) |
113 |
|
end, |
114 |
72 |
send_push_notifications(Notifications, Fun, ok). |
115 |
|
|
116 |
|
send_push_notifications([], _, Result) -> |
117 |
70 |
Result; |
118 |
|
send_push_notifications([Notification | Notifications], Fun, Result) -> |
119 |
72 |
case Fun(Notification) of |
120 |
|
{ok, ok} -> |
121 |
64 |
send_push_notifications(Notifications, Fun, Result); |
122 |
|
{ok, {error, device_not_registered} = Err} -> |
123 |
|
%% In this case there is no point in sending other push notifications |
124 |
|
%% so we can finish immediately |
125 |
2 |
Err; |
126 |
|
{ok, Other} -> |
127 |
|
%% The publish IQ allows to put more notifications into one request |
128 |
|
%% but this is currently not used in MongooseIM. |
129 |
|
%% In case it's used we try sending other notifications |
130 |
6 |
send_push_notifications(Notifications, Fun, Other) |
131 |
|
end. |
132 |
|
|
133 |
|
|
134 |
|
%%-------------------------------------------------------------------- |
135 |
|
%% Module API |
136 |
|
%%-------------------------------------------------------------------- |
137 |
|
|
138 |
|
-spec http_notification(Host :: jid:server(), post, |
139 |
|
binary(), proplists:proplist(), binary()) -> |
140 |
|
ok | {error, Reason :: term()}. |
141 |
|
http_notification(Host, Method, URL, ReqHeaders, Payload) -> |
142 |
72 |
PoolName = gen_mod:get_module_opt(Host, ?MODULE, pool_name, undefined), |
143 |
72 |
case mongoose_http_client:Method(Host, PoolName, URL, ReqHeaders, Payload) of |
144 |
|
{ok, {BinStatusCode, Body}} -> |
145 |
72 |
case binary_to_integer(BinStatusCode) of |
146 |
|
StatusCode when StatusCode >= 200 andalso StatusCode < 300 -> |
147 |
64 |
ok; |
148 |
|
410 -> |
149 |
2 |
?LOG_WARNING(#{what => push_send_failed, |
150 |
|
text => <<"Unable to send push notification. " |
151 |
|
"Device not registered.">>, |
152 |
:-( |
code => 410, reason => device_not_registered}), |
153 |
2 |
{error, device_not_registered}; |
154 |
|
StatusCode when StatusCode >= 400 andalso StatusCode < 500 -> |
155 |
:-( |
?LOG_ERROR(#{what => push_send_failed, |
156 |
|
text => <<"Unable to send push notification. " |
157 |
|
"Possible API mismatch">>, |
158 |
|
code => StatusCode, url => URL, |
159 |
:-( |
response => Body, payload => Payload}), |
160 |
:-( |
{error, {invalid_status_code, StatusCode}}; |
161 |
|
StatusCode -> |
162 |
6 |
?LOG_ERROR(#{what => push_send_failed, |
163 |
|
text => <<"Unable to send push notification">>, |
164 |
:-( |
code => StatusCode, response => Body}), |
165 |
6 |
{error, {invalid_status_code, StatusCode}} |
166 |
|
end; |
167 |
|
{error, Reason} -> |
168 |
:-( |
?LOG_ERROR(#{what => connection_error, reason => Reason}), |
169 |
:-( |
{error, Reason} |
170 |
|
end. |
171 |
|
|
172 |
|
%%-------------------------------------------------------------------- |
173 |
|
%% Helper functions |
174 |
|
%%-------------------------------------------------------------------- |
175 |
|
|
176 |
|
parse_api_version("v3") -> |
177 |
64 |
{ok, <<"v3">>}; |
178 |
|
parse_api_version("v2") -> |
179 |
8 |
{ok, <<"v2">>}; |
180 |
|
parse_api_version(_) -> |
181 |
:-( |
{error, not_supported}. |
182 |
|
|
183 |
|
%% Create notification for API v2 and v3 |
184 |
|
make_notification(Notification, Options) -> |
185 |
72 |
RequiredParameters = #{service => maps:get(<<"service">>, Options)}, |
186 |
|
%% The full list of supported optional parameters can be found here: |
187 |
|
%% https://github.com/esl/MongoosePush/blob/master/README.md#request |
188 |
|
%% |
189 |
|
%% Note that <<"tags">> parameter is explicitely excluded to avoid any |
190 |
|
%% security issues. User should not be allowed to select pools other than |
191 |
|
%% prod and dev (see <<"mode">> parameter description). |
192 |
72 |
OptionalKeys = [<<"mode">>, <<"priority">>, <<"topic">>, |
193 |
|
<<"mutable_content">>, <<"time_to_live">>], |
194 |
72 |
OptionalParameters = maps:with(OptionalKeys, Options), |
195 |
72 |
NotificationParams = maps:merge(RequiredParameters, OptionalParameters), |
196 |
|
|
197 |
72 |
MessageCount = binary_to_integer(maps:get(<<"message-count">>, Notification)), |
198 |
|
|
199 |
72 |
DataOrAlert = case Options of |
200 |
|
#{<<"silent">> := <<"true">>} -> |
201 |
17 |
Data = Notification#{<<"message-count">> := MessageCount}, |
202 |
17 |
#{data => Data}; |
203 |
|
_ -> |
204 |
55 |
BasicAlert = #{body => maps:get(<<"last-message-body">>, Notification), |
205 |
|
title => maps:get(<<"last-message-sender">>, Notification), |
206 |
|
tag => maps:get(<<"last-message-sender">>, Notification), |
207 |
|
badge => MessageCount}, |
208 |
55 |
OptionalAlert = maps:with([<<"click_action">>, <<"sound">>], Options), |
209 |
55 |
#{alert => maps:merge(BasicAlert, OptionalAlert)} |
210 |
|
end, |
211 |
72 |
{ok, maps:merge(NotificationParams, DataOrAlert)}. |
212 |
|
|
213 |
|
-spec call(Host :: jid:server(), M :: atom(), F :: atom(), A :: [any()]) -> any(). |
214 |
|
call(Host, M, F, A) -> |
215 |
72 |
mongoose_wpool:call(generic, Host, mongoosepush_service, {M, F, A}). |
216 |
|
|
217 |
|
config_metrics(Host) -> |
218 |
:-( |
OptsToReport = [{api_version, ?DEFAULT_API_VERSION}], %list of tuples {option, defualt_value} |
219 |
:-( |
mongoose_module_metrics:opts_for_module(Host, ?MODULE, OptsToReport). |