%%%-----------------------------------------------------------------------------
%%% @copyright (C) 2011-2019, 2600Hz
%%% @doc Provide a menu to caller.
%%%
%%%
Data options:
%%%
%%% - `id'
%%% - ID of menu document.
%%%
%%% - `interdigit_timeout'
%%% - Optional: How long to wait for the next DTMF, in milliseconds
%%%
%%%
%%% @author Karl Anderson
%%% @end
%%%-----------------------------------------------------------------------------
-module(cf_menu).
-behaviour(gen_cf_action).
-export([handle/2]).
-include("callflow.hrl").
-define(MOD_CONFIG_CAT, <<(?CF_CONFIG_CAT)/binary, ".menu">>).
-record(menu_keys, {save = <<"1">> :: kz_term:ne_binary() %% Record Review
,listen = <<"2">> :: kz_term:ne_binary()
,record = <<"3">> :: kz_term:ne_binary()
}).
-type menu_keys() :: #menu_keys{}.
-define(MENU_KEY_LENGTH, 1).
-record(cf_menu_data, {menu_id :: kz_term:api_ne_binary()
,name = <<>> :: binary()
,retries = 3 :: pos_integer()
,timeout = 10000 :: pos_integer()
,max_length = 4 :: pos_integer()
,hunt = 'false' :: boolean()
,hunt_deny = <<>> :: binary()
,hunt_allow = <<>> :: binary()
,record_pin = <<>> :: binary()
,record_from_offnet = 'false' :: boolean()
,greeting_id :: kz_term:api_ne_binary()
,exit_media = 'true' :: boolean() | kz_term:ne_binary()
,transfer_media = 'true' :: boolean() | kz_term:ne_binary()
,invalid_media = 'true' :: boolean() | kz_term:ne_binary()
,keys = #menu_keys{} :: menu_keys()
,interdigit_timeout = kapps_call_command:default_interdigit_timeout() :: pos_integer()
}).
-type menu() :: #cf_menu_data{}.
%%------------------------------------------------------------------------------
%% @doc Entry point for this module
%% @end
%%------------------------------------------------------------------------------
-spec handle(kz_json:object(), kapps_call:call()) -> 'ok'.
handle(Data, Call) ->
Menu = get_menu_profile(Data, Call),
kapps_call_command:answer(Call),
menu_loop(Menu, Call).
%%------------------------------------------------------------------------------
%% @doc The main auto-attendant loop, will execute for the number
%% of retries playing the greeting and collecting digits till the
%% digits are routable
%% @end
%%------------------------------------------------------------------------------
-spec menu_loop(menu(), kapps_call:call()) -> 'ok'.
menu_loop(#cf_menu_data{retries=Retries}=Menu, Call) when Retries =< 0 ->
lager:info("maximum number of retries reached"),
_ = kapps_call_command:flush_dtmf(Call),
_ = play_exit_prompt(Menu, Call),
case cf_exe:attempt(<<"max_retries">>, Call) of
{'attempt_resp', 'ok'} -> 'ok';
{'attempt_resp', {'error', _}} ->
_ = case cf_exe:wildcard_is_empty(Call) of
'true' -> kapps_call_command:b_prompt(<<"vm-goodbye">>, Call);
'false' -> play_transferring_prompt(Menu, Call)
end,
cf_exe:continue(Call)
end;
menu_loop(#cf_menu_data{max_length=MaxLength
,timeout=Timeout
,interdigit_timeout=Interdigit
}=Menu, Call) ->
NoopId = kapps_call_command:play(get_prompt(Menu, Call), Call),
case kapps_call_command:collect_digits(MaxLength, Timeout, Interdigit, NoopId, Call) of
{'ok', <<>>} ->
menu_handle_no_digits(Menu, Call);
{'ok', Digits} ->
menu_handle_digits(Menu, Call, Digits);
{'error', _} ->
lager:info("caller hungup while in the menu"),
cf_exe:stop(Call)
end.
menu_handle_digits(#cf_menu_data{retries=Retries
,record_from_offnet=RecOffnet
,record_pin=RecordPin
}=Menu, Call, Digits) ->
%% this try_match_digits calls hunt_for_callflow() based on the digits dialed
%% if it finds a callflow, the main CFPid will move on to it and try_match_digits
%% will return true, matching here, and causing menu_loop to exit; this is
%% expected behaviour as CFPid has moved on from this invocation
AllowRecord = (RecOffnet
orelse kapps_call:inception(Call) =:= 'undefined'
),
case try_match_digits(Digits, Menu, Call) of
'true' -> lager:debug("hunt callflow found");
'false' when Digits =:= RecordPin, AllowRecord ->
menu_handle_record(Menu, Call);
'false' ->
lager:info("invalid selection ~w", [Digits]),
_ = play_invalid_prompt(Menu, Call),
menu_loop(Menu#cf_menu_data{retries=Retries - 1}, Call)
end.
-spec menu_handle_record(menu(), kapps_call:call()) -> 'ok'.
menu_handle_record(Menu, Call) ->
lager:info("selection matches recording pin"),
case record_greeting(tmp_file(), Menu, Call) of
{'ok', M} ->
lager:info("returning caller to menu"),
_ = kapps_call_command:b_prompt(<<"menu-return">>, Call),
menu_loop(M, Call);
{'error', _} ->
cf_exe:stop(Call)
end.
-spec menu_handle_no_digits(menu(), kapps_call:call()) -> 'ok'.
menu_handle_no_digits(#cf_menu_data{retries=Retries}=Menu, Call) ->
lager:info("menu entry timeout"),
case try_match_digits(<<"timeout">>, Menu, Call) of
'true' -> lager:debug("timeout hunt callflow found");
'false' ->
case cf_exe:attempt(<<"timeout">>, Call) of
{'attempt_resp', 'ok'} -> 'ok';
{'attempt_resp', {'error', _}} ->
menu_loop(Menu#cf_menu_data{retries=Retries - 1}, Call)
end
end.
%%------------------------------------------------------------------------------
%% @doc The primary sequence logic to route the collected digits
%% @end
%%------------------------------------------------------------------------------
-spec try_match_digits(kz_term:ne_binary(), menu(), kapps_call:call()) -> boolean().
try_match_digits(Digits, Menu, Call) ->
lager:info("trying to match digits ~s", [Digits]),
is_callflow_child(Digits, Menu, Call)
orelse (is_hunt_enabled(Digits, Menu, Call)
andalso is_hunt_allowed(Digits, Menu, Call)
andalso not is_hunt_denied(Digits, Menu, Call)
andalso hunt_for_callflow(Digits, Menu, Call)
).
%%------------------------------------------------------------------------------
%% @doc Check if the digits are a exact match for the auto-attendant children
%% @end
%%------------------------------------------------------------------------------
-spec is_callflow_child(kz_term:ne_binary(), menu(), kapps_call:call()) -> boolean().
is_callflow_child(Digits, _, Call) ->
case cf_exe:attempt(Digits, Call) of
{'attempt_resp', 'ok'} ->
lager:info("selection is a callflow child"),
'true';
{'attempt_resp', {'error', _}} -> 'false'
end.
%%------------------------------------------------------------------------------
%% @doc Check if hunting is enabled
%% @end
%%------------------------------------------------------------------------------
-spec is_hunt_enabled(kz_term:ne_binary(), menu(), kapps_call:call()) -> boolean().
is_hunt_enabled(_, #cf_menu_data{hunt=Hunt}, _) ->
Hunt.
%%------------------------------------------------------------------------------
%% @doc Check whitelist hunt digit patterns
%% @end
%%------------------------------------------------------------------------------
-spec is_hunt_allowed(kz_term:ne_binary(), menu(), kapps_call:call()) -> boolean().
is_hunt_allowed(_, #cf_menu_data{hunt_allow = <<>>}, _) ->
lager:info("hunt_allow implicitly accepted digits"),
'true';
is_hunt_allowed(Digits, #cf_menu_data{hunt_allow=RegEx}, _) ->
try re:run(Digits, RegEx) of
{'match', _} ->
lager:info("hunt_allow accepted digits"),
'true';
'nomatch' ->
lager:info("hunt_allow denied digits"),
'false'
catch
_E:_R ->
lager:info("failed to run regex ~s: ~s: ~p", [RegEx, _E, _R]),
'false'
end.
%%------------------------------------------------------------------------------
%% @doc Check blacklisted hunt digit patterns
%% @end
%%------------------------------------------------------------------------------
-spec is_hunt_denied(kz_term:ne_binary(), menu(), kapps_call:call()) -> boolean().
is_hunt_denied(_, #cf_menu_data{hunt_deny = <<>>}, _) ->
lager:info("hunt_deny implicitly accepted digits"),
'false';
is_hunt_denied(Digits, #cf_menu_data{hunt_deny=RegEx}, _) ->
try re:run(Digits, RegEx) of
{'match', _} ->
lager:info("hunt_deny denied digits"),
'true';
'nomatch' ->
lager:info("hunt_deny accepted digits"),
'false'
catch
_E:_R ->
lager:info("failed to run regex ~s: ~s: ~p", [RegEx, _E, _R]),
'false'
end.
%%------------------------------------------------------------------------------
%% @doc Hunt for a callflow with these numbers
%% @end
%%------------------------------------------------------------------------------
-spec hunt_for_callflow(kz_term:ne_binary(), menu(), kapps_call:call()) -> boolean().
hunt_for_callflow(Digits, Menu, Call) ->
AccountId = kapps_call:account_id(Call),
lager:info("hunting for ~s in account ~s", [Digits, AccountId]),
case cf_flow:lookup(Digits, AccountId) of
{'ok', Flow, 'false'} ->
lager:info("callflow hunt succeeded, branching"),
_ = kapps_call_command:flush_dtmf(Call),
_ = play_transferring_prompt(Menu, Call),
Props = [{'cf_capture_group', kz_json:get_ne_value(<<"capture_group">>, Flow)}
,{'cf_capture_groups', kz_json:get_value(<<"capture_groups">>, Flow, kz_json:new())}
],
UpdatedCall = kapps_call:kvs_store_proplist(Props, Call),
cf_exe:set_call(UpdatedCall),
cf_exe:branch(kz_json:get_value(<<"flow">>, Flow, kz_json:new()), UpdatedCall),
'true';
_ ->
lager:info("callflow hunt failed"),
'false'
end.
%%------------------------------------------------------------------------------
%% @doc
%% @end
%%------------------------------------------------------------------------------
-spec record_greeting(binary(), menu(), kapps_call:call()) ->
{'ok', menu()} |
{'error', kz_json:object()}.
record_greeting(AttachmentName, #cf_menu_data{greeting_id='undefined'}=Menu, Call) ->
MediaId = recording_media_doc(<<"greeting">>, Menu, Call),
record_greeting(AttachmentName, Menu#cf_menu_data{greeting_id=MediaId}, Call);
record_greeting(AttachmentName, #cf_menu_data{greeting_id=MediaId}=Menu, Call) ->
lager:info("recording new menu greeting"),
_ = kapps_call_command:audio_macro([{'prompt', <<"vm-record_greeting">>}
,{'tones', [kz_json:from_list([{<<"Frequencies">>, [440]}
,{<<"Duration-ON">>, 500}
,{<<"Duration-OFF">>, 100}
])
]}
], Call),
case kapps_call_command:b_record(AttachmentName, Call) of
{'error', _}=E -> E;
{'ok', JObj} ->
NoRec = kapps_config:get_integer(?MOD_CONFIG_CAT, <<"min_greeting_length">>, 1500)
> kz_json:get_integer_value(<<"Length">>, JObj),
case review_recording(AttachmentName, Menu, Call) of
{'ok', 'record'} ->
record_greeting(tmp_file(), Menu, Call);
{'ok', 'save'} when NoRec ->
_ = kapps_call_command:b_prompt(<<"vm-recording_to_short">>, Call),
record_greeting(tmp_file(), Menu, Call);
{'ok', 'save'} ->
{'ok', _} = store_recording(AttachmentName, MediaId, Call),
'ok' = update_doc([<<"media">>, <<"greeting">>], MediaId, Menu, Call),
_ = kapps_call_command:b_prompt(<<"vm-saved">>, Call),
{'ok', Menu};
{'ok', 'no_selection'} ->
lager:info("abandoning recorded greeting"),
_ = kapps_call_command:b_prompt(<<"vm-deleted">>, Call),
{'ok', Menu}
end
end.
%%------------------------------------------------------------------------------
%% @doc
%% @end
%%------------------------------------------------------------------------------
-spec play_invalid_prompt(menu(), kapps_call:call()) ->
{'ok', kz_json:object()} |
{'error', atom()}.
play_invalid_prompt(#cf_menu_data{invalid_media='false'}, _) ->
{'ok', kz_json:new()};
play_invalid_prompt(#cf_menu_data{invalid_media='true'}, Call) ->
kapps_call_command:b_prompt(<<"menu-invalid_entry">>, Call);
play_invalid_prompt(#cf_menu_data{invalid_media=Id}, Call) ->
kapps_call_command:b_play(<<$/, (kapps_call:account_db(Call))/binary, $/, Id/binary>>, Call).
%%------------------------------------------------------------------------------
%% @doc
%% @end
%%------------------------------------------------------------------------------
-spec play_transferring_prompt(menu(), kapps_call:call()) ->
{'ok', kz_json:object()} |
{'error', atom()}.
play_transferring_prompt(#cf_menu_data{transfer_media='false'}, _) ->
{'ok', kz_json:new()};
play_transferring_prompt(#cf_menu_data{transfer_media='true'}, Call) ->
kapps_call_command:b_prompt(<<"menu-transferring_call">>, Call);
play_transferring_prompt(#cf_menu_data{transfer_media=Id}, Call) ->
kapps_call_command:b_play(<<$/, (kapps_call:account_db(Call))/binary, $/, Id/binary>>, Call).
%%------------------------------------------------------------------------------
%% @doc
%% @end
%%------------------------------------------------------------------------------
-spec play_exit_prompt(menu(), kapps_call:call()) ->
{'ok', kz_json:object()} |
{'error', atom()}.
play_exit_prompt(#cf_menu_data{exit_media='false'}, _) ->
{'ok', kz_json:new()};
play_exit_prompt(#cf_menu_data{exit_media='true'}, Call) ->
kapps_call_command:b_prompt(<<"menu-exit">>, Call);
play_exit_prompt(#cf_menu_data{exit_media=Id}, Call) ->
kapps_call_command:b_play(<<$/, (kapps_call:account_db(Call))/binary, $/, Id/binary>>, Call).
%%------------------------------------------------------------------------------
%% @doc
%% @end
%%------------------------------------------------------------------------------
-spec get_prompt(menu(), kapps_call:call()) -> kz_term:ne_binary().
get_prompt(#cf_menu_data{greeting_id='undefined'}, Call) ->
kapps_call:get_prompt(Call, <<"menu-no_prompt">>);
get_prompt(#cf_menu_data{greeting_id=Id}, Call) ->
kz_media_util:media_path(Id, kapps_call:account_id(Call)).
%%------------------------------------------------------------------------------
%% @doc
%% @end
%%------------------------------------------------------------------------------
-spec store_recording(kz_term:ne_binary(), kz_term:ne_binary(), kapps_call:call()) ->
{'ok', kz_json:object()} |
{'error', kz_json:object()}.
store_recording(AttachmentName, MediaId, Call) ->
lager:info("storing recording ~s as media ~s", [AttachmentName, MediaId]),
CallerIdName = kapps_call:caller_id_name(Call),
Description = <<"recorded by ", CallerIdName/binary>>,
Updates = [{<<"content_type">>, <<"audio/mpeg">>}
,{<<"media_source">>, <<"recording">>}
,{<<"description">>, Description}
],
'ok' = update_doc(Updates, MediaId, Call),
kapps_call_command:b_store(AttachmentName, get_new_attachment_url(AttachmentName, MediaId, Call), Call).
%%------------------------------------------------------------------------------
%% @doc
%% @end
%%------------------------------------------------------------------------------
-spec get_new_attachment_url(binary(), binary(), kapps_call:call()) -> kz_term:ne_binary().
get_new_attachment_url(AttachmentName, MediaId, Call) ->
AccountDb = kapps_call:account_db(Call),
_ = case kz_datamgr:open_cache_doc(AccountDb, MediaId) of
{'ok', JObj} ->
maybe_delete_attachments(AccountDb, MediaId, JObj);
{'error', _} -> 'ok'
end,
kz_media_url:store(AccountDb, MediaId, AttachmentName).
-spec maybe_delete_attachments(kz_term:ne_binary(), kz_term:ne_binary(), kz_json:object()) -> 'ok'.
maybe_delete_attachments(AccountDb, _MediaId, JObj) ->
case kz_doc:maybe_remove_attachments(JObj) of
{'false', _} -> 'ok';
{'true', Removed} ->
_ = kz_datamgr:save_doc(AccountDb, Removed),
lager:debug("removing attachments from ~s", [_MediaId])
end.
%%------------------------------------------------------------------------------
%% @doc
%% @end
%%------------------------------------------------------------------------------
-spec tmp_file() -> kz_term:ne_binary().
tmp_file() ->
<<(kz_term:to_hex_binary(crypto:strong_rand_bytes(16)))/binary, ".mp3">>.
%%------------------------------------------------------------------------------
%% @doc
%% @end
%%------------------------------------------------------------------------------
-spec review_recording(kz_term:ne_binary(), menu(), kapps_call:call()) ->
{'ok', 'record' | 'save' | 'no_selection'}.
review_recording(MediaName, #cf_menu_data{keys=#menu_keys{listen=ListenKey
,record=RecordKey
,save=SaveKey
}
,timeout=Timeout
,interdigit_timeout=Interdigit
}=Menu, Call) ->
lager:info("playing menu greeting review options"),
_ = kapps_call_command:flush_dtmf(Call),
NoopId = kapps_call_command:prompt(<<"vm-review_recording">>, Call),
case kapps_call_command:collect_digits(?MENU_KEY_LENGTH, Timeout, Interdigit, NoopId, Call) of
{'ok', ListenKey} ->
_ = kapps_call_command:b_play(MediaName, Call),
review_recording(MediaName, Menu, Call);
{'ok', RecordKey} -> {'ok', 'record'};
{'ok', SaveKey} -> {'ok', 'save'};
{'ok', _} -> review_recording(MediaName, Menu, Call);
{'error', _} -> {'ok', 'no_selection'}
end.
%%------------------------------------------------------------------------------
%% @doc
%% @end
%%------------------------------------------------------------------------------
-spec recording_media_doc(kz_term:ne_binary(), menu(), kapps_call:call()) -> kz_term:ne_binary().
recording_media_doc(Type, #cf_menu_data{name=MenuName
,menu_id=Id
}, Call) ->
AccountDb = kapps_call:account_db(Call),
Name = <>,
Props = [{<<"name">>, Name}
,{<<"description">>, <<"menu recorded/prompt media">>}
,{<<"source_type">>, <<"menu">>}
,{<<"source_id">>, Id}
,{<<"media_source">>, <<"recording">>}
,{<<"streamable">>, 'true'}
],
Doc = kz_doc:update_pvt_parameters(kz_json:from_list(Props), AccountDb, [{'type', <<"media">>}]),
{'ok', JObj} = kz_datamgr:save_doc(AccountDb, Doc),
kz_doc:id(JObj).
%%------------------------------------------------------------------------------
%% @doc
%% @end
%%------------------------------------------------------------------------------
-spec update_doc(kz_term:text(), kz_json:json_term(), menu() | kz_term:ne_binary(), kapps_call:call() | kz_term:ne_binary()) ->
'ok' | {'error', atom()}.
update_doc(Key, Value, #cf_menu_data{menu_id=Id}, Db) ->
update_doc(Key, Value, Id, Db);
update_doc(Key, Value, Id, <<_/binary>> = Db) ->
case kz_datamgr:open_doc(Db, Id) of
{'error', _}=E -> lager:info("unable to update ~s in ~s, ~p", [Id, Db, E]);
{'ok', JObj} ->
case kz_datamgr:save_doc(Db, kz_json:set_value(Key, Value, JObj)) of
{'error', 'conflict'} -> update_doc(Key, Value, Id, Db);
{'ok', _} -> 'ok';
{'error', _}=E -> lager:info("unable to update ~s in ~s, ~p", [Id, Db, E])
end
end;
update_doc(Key, Value, Id, Call) ->
update_doc(Key, Value, Id, kapps_call:account_db(Call)).
-spec update_doc(kz_term:proplist(), kz_term:ne_binary(), kapps_call:call() | kz_term:ne_binary()) ->
'ok' | {'error', atom()}.
update_doc(Updates, Id, <<_/binary>> = Db) ->
case kz_datamgr:open_doc(Db, Id) of
{'error', _}=E -> lager:info("unable to update ~s in ~s, ~p", [Id, Db, E]);
{'ok', JObj} ->
case kz_datamgr:save_doc(Db, kz_json:set_values(Updates, JObj)) of
{'error', 'conflict'} -> update_doc(Updates, Id, Db);
{'ok', _} -> 'ok';
{'error', _}=E -> lager:info("unable to update ~s in ~s, ~p", [Id, Db, E])
end
end;
update_doc(Updates, Id, Call) ->
update_doc(Updates, Id, kapps_call:account_db(Call)).
%%------------------------------------------------------------------------------
%% @doc
%% @end
%%------------------------------------------------------------------------------
-spec get_menu_profile(kz_json:object(), kapps_call:call()) -> menu().
get_menu_profile(Data, Call) ->
Id = kz_json:get_ne_binary_value(<<"id">>, Data),
AccountDb = kapps_call:account_db(Call),
case kz_datamgr:open_doc(AccountDb, Id) of
{'ok', JObj} ->
lager:info("loaded menu route ~s", [Id]),
Default = #cf_menu_data{},
#cf_menu_data{menu_id = Id
,name =
kz_json:get_ne_value(<<"name">>, JObj, Id)
,retries =
kz_json:get_integer_value(<<"retries">>, JObj, Default#cf_menu_data.retries)
,timeout =
kz_json:get_integer_value(<<"timeout">>, JObj, Default#cf_menu_data.timeout)
,max_length =
kz_json:get_integer_value(<<"max_extension_length">>, JObj, Default#cf_menu_data.max_length)
,hunt =
kz_json:is_true(<<"hunt">>, JObj, Default#cf_menu_data.hunt)
,hunt_deny =
kz_json:get_value(<<"hunt_deny">>, JObj, Default#cf_menu_data.hunt_deny)
,hunt_allow =
kz_json:get_value(<<"hunt_allow">>, JObj, Default#cf_menu_data.hunt_allow)
,record_pin =
kz_json:get_value(<<"record_pin">>, JObj, Default#cf_menu_data.record_pin)
,record_from_offnet =
kz_json:is_true(<<"allow_record_from_offnet">>, JObj, Default#cf_menu_data.record_from_offnet)
,greeting_id =
kz_json:get_ne_value([<<"media">>, <<"greeting">>], JObj)
,exit_media =
(not kz_json:is_false([<<"media">>, <<"exit_media">>], JObj))
andalso kz_json:get_ne_value([<<"media">>, <<"exit_media">>], JObj, 'true')
,transfer_media =
(not kz_json:is_false([<<"media">>, <<"transfer_media">>], JObj))
andalso kz_json:get_ne_value([<<"media">>, <<"transfer_media">>], JObj, 'true')
,invalid_media =
(not kz_json:is_false([<<"media">>, <<"invalid_media">>], JObj))
andalso kz_json:get_ne_value([<<"media">>, <<"invalid_media">>], JObj, 'true')
,interdigit_timeout =
kz_term:to_integer(
kz_json:find(<<"interdigit_timeout">>
,[JObj, Data]
,kapps_call_command:default_interdigit_timeout()
))
};
{'error', R} ->
lager:info("failed to load menu route ~s, ~w", [Id, R]),
#cf_menu_data{}
end.