diff --git a/apps/roster/src/rest/helpers/rest_response_helper.erl b/apps/roster/src/rest/helpers/rest_response_helper.erl index 5a83b41b8e8349566aabacd8f70d4b86f989235d..b37e074d20cc53e08ac80c713c370836f34b17c4 100644 --- a/apps/roster/src/rest/helpers/rest_response_helper.erl +++ b/apps/roster/src/rest/helpers/rest_response_helper.erl @@ -5,7 +5,8 @@ -export([ description/0, response/3, response/4, - error_405/0, error_404/0, error_401/0, error_405_response/1, + error_400/0, error_401/0, error_404/0, error_405/0, error_422/0, + error_400_response/1, error_404_response/1, error_405_response/1, error_422_response/1, error_response/0, error_response/1, error_response/2, success_response/0, success_response/1, success_response/2, success_200/0 @@ -25,14 +26,20 @@ response(Req, Status, Body, ContentType) -> %% Static +error_400() -> + error_response(?HTTP_CODE_400, ?ERROR_400). + +error_401() -> + error_response(?HTTP_CODE_401, ?ERROR_401). + error_404() -> error_response(?HTTP_CODE_404, ?ERROR_404). error_405() -> error_response(?HTTP_CODE_405, ?ERROR_405). -error_401() -> - error_response(?HTTP_CODE_401, ?ERROR_401). +error_422() -> + error_response(?HTTP_CODE_422, ?ERROR_422). %% Dynamic @@ -47,10 +54,23 @@ error_response(Status, []) -> error_response(Status, Msg) -> response_json(Status, jsx:encode([{<<"message">>, nitro:to_binary(Msg)}])). + +error_400_response(Req) -> + roster:info(?MODULE, "BadRequest:~p:~p:~p", [Req:get(method), Req:get(path), Req:parse_post()]), + response(Req, ?HTTP_CODE_400, rest_response_helper:error_400()). + +error_404_response(Req) -> + roster:info(?MODULE, "NotFound:~p:~p:~p", [Req:get(method), Req:get(path), Req:parse_post()]), + response(Req, ?HTTP_CODE_404, rest_response_helper:error_404()). + error_405_response(Req) -> roster:info(?MODULE, "MethodNotAllowed:~p:~p", [Req:get(method), Req:get(path)]), response(Req, ?HTTP_CODE_405, rest_response_helper:error_405()). +error_422_response(Req) -> + roster:info(?MODULE, "UnprocessableEntity:~p:~p:~p", [Req:get(method), Req:get(path), Req:parse_post()]), + response(Req, ?HTTP_CODE_422, rest_response_helper:error_422()). + %% - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - %% SUCCESS %% - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/roster/src/rest/rest_chat_csv.erl b/apps/roster/src/rest/rest_chat_csv.erl new file mode 100644 index 0000000000000000000000000000000000000000..60ba4d2e3884409d91592bb8bb7017bb197db465 --- /dev/null +++ b/apps/roster/src/rest/rest_chat_csv.erl @@ -0,0 +1,193 @@ +%%%----------------------------------------------------------------------------- +%%% @doc API module for converting chat history to CSV file. +%%% +%%% Here we expose a POST request for generating csv file from a private chat room. +%%% The POST request requires phone numbers + ids of both users. +%%% +%%% @see rest_handler:handle_request/3 +%%% @end +%%%----------------------------------------------------------------------------- +-module(rest_chat_csv). +-include("roster.hrl"). +-include_lib("roster/static/rest_var.hrl"). +-include_lib("kvs/include/metainfo.hrl"). + +-export([ + handle_request/3 +]). + +%% Custom data types +-type type_chat_element() :: {Date::string(), Time::string(), Username::string(), Message::string()}. +-type type_chat() :: [type_chat_element()]. +-type type_chat_history() :: list(#'Message'{}). +-type type_user_data() :: {binary(), string()}. + +%%%============================================================================= +%%% API +%%%============================================================================= + +handle_request('POST', Path, Req) when Path == ?CSV_P2P_ENDPOINT; Path == ?CSV_MUC_ENDPOINT -> + ReqData = Req:parse_post(), + roster:info(?MODULE, "~p:Request:~p:~p", [Req:get(method), Req:get(path), ReqData]), + + case parse_incomming_data(ReqData) of + {ok, {FromPhoneId, ToPhoneId}} -> + try + ChatHistory = get_chat_history({p2p, FromPhoneId, ToPhoneId}), + FromUsername = get_username_by_id(extract_user_id(FromPhoneId)), + ToUsername = get_username_by_id(extract_user_id(ToPhoneId)), + Members = [{FromPhoneId, FromUsername}, {ToPhoneId, ToUsername}], + CsvText = conversation_to_csv(ChatHistory, Members, ToUsername), + Filename = ["NynjaPrivateChat-", FromUsername, "-", ToUsername, ".csv"], + send_response(CsvText, Filename, Req) + catch + throw:no_username_match -> rest_response_helper:error_422_response(Req); + throw:no_chat_history -> rest_response_helper:error_404_response(Req); + throw:no_username -> rest_response_helper:error_422_response(Req); + throw:no_muc_name -> rest_response_helper:error_404_response(Req); + throw:no_room -> rest_response_helper:error_404_response(Req); + _:_ -> rest_response_helper:error_404_response(Req) + end; + + {ok, RoomId} -> + ChatHistory = get_chat_history({muc, RoomId}), + Members = get_group_chat_members(RoomId), + GroupChatName = get_group_chat_name(RoomId), + CsvText = conversation_to_csv(ChatHistory, Members, GroupChatName), + Filename = ["NynjaGroupChat-", GroupChatName, ".csv"], + send_response(CsvText, Filename, Req); + + {error, wrong_data} -> + rest_response_helper:error_400_response(Req) + end; + +handle_request(_, _, Req) -> + rest_response_helper:error_405_response(Req). + +%%%============================================================================= +%%% Internal functions +%%%============================================================================= + +send_response(CsvText, Filename, Req) -> + {ResponseStatus, ResponseData} = {?HTTP_CODE_200, CsvText}, + ResponseHeader = [{"Content-Type", "text/csv"}, {"Content-Disposition", "attachment; filename=" ++ Filename}], + roster:info(?MODULE, "ResponseData:~p", [ResponseData]), + Req:respond({ResponseStatus, ResponseHeader, ResponseData}). + +-spec conversation_to_csv(ChatHistory::type_chat_history(), Members::list(type_user_data()), CsvColumnsData::string()) -> string(). +conversation_to_csv(ChatHistory, Members, CsvColumnsData) -> + ChatData = extract_chat_data(ChatHistory, Members), + ChatSpan = get_chat_time_span(ChatData), + CsvColumns = build_csv_columns(ChatSpan, CsvColumnsData), + build_csv_text(ChatData, CsvColumns). + +-spec parse_incomming_data(list(tuple())) -> {ok, term()} | {error, wrong_data}. +parse_incomming_data([{"from", FromPhoneId}, {"to", ToPhoneId}]) -> {ok, {list_to_binary(FromPhoneId), list_to_binary(ToPhoneId)}}; +parse_incomming_data([{"muc", RoomId}]) -> {ok, list_to_binary(RoomId)}; +parse_incomming_data(_) -> {error, wrong_data}. + +-spec build_csv_text(type_chat(), Acc::string()) -> string(). +build_csv_text([{Date, Time, Username, Message} | T], Acc) -> + build_csv_text(T, [Acc, [$\n, Date, $,, Time, $,, Username, $,, Message]]); +build_csv_text([], Acc) -> + lists:flatten(Acc). + +-spec build_csv_columns({FirstDate::binary(), LastDate::binary()}, ToUsername::string()) -> string(). +build_csv_columns({FirstDate, LastDate}, ToUsername) -> + [ + "NYNJA Chat Export", $\n, $\n, + "From Date:,", FirstDate, $\n, + "To Date:,", LastDate, $\n, $\n, + "Chat(s):,", ToUsername, $\n, $\n, + "Included:,Text", $\n, + ",Photos", $\n, + ",Locations", $\n, + ",Files", $\n, $\n, + "Save Media as:,Links", $\n, $\n, + "Date,Time,People,Message" + ]. + +-spec extract_chat_data(ChatHistory::type_chat_history(), Members::list(type_user_data())) -> type_chat(). +extract_chat_data(ChatHistory, Members) -> + [begin + {Date, Time} = roster:msToUT(TimeCreated), + FormattedDate = format_date(Date), + FormattedTime = format_time(Time), + Username = match_member_to_phone_id(Members, PhoneId), + Message = format_message(PayloadType, Payload), + {FormattedDate, FormattedTime, Username, Message} + end + || + #'Message'{created = TimeCreated, + from = PhoneId, + files = [#'Desc'{mime = PayloadType, payload = Payload}]} <- ChatHistory + ]. + +-spec extract_user_id(binary()) -> non_neg_integer(). +extract_user_id(PhoneId) -> + [_Phone, UserId] = string:split(PhoneId, "_"), + element(1, string:to_integer(UserId)). + +-spec match_member_to_phone_id(Members::list(type_user_data()), PhoneId::binary()) -> string(). +match_member_to_phone_id(Members, PhoneId) -> + case lists:dropwhile(fun({Id, _}) -> Id /= PhoneId end, Members) of + [] -> throw(no_username_match); + [{_, Username} | _] -> Username; + _ -> throw(no_username_match) + end. + +-spec format_date(tuple()) -> string(). +format_date({Year, Month, Day}) -> io_lib:format("~2..0w/~2..0w/~w", [Day, Month, Year]). + +-spec format_time(tuple()) -> string(). +format_time({Hour, Min, Sec}) -> io_lib:format("~2..0w:~2..0w:~2..0w", [Hour, Min, Sec]). + +-spec format_message(PayloadType::binary(), Payload::binary()) -> binary() | list(binary()). +format_message(<<"text">>, Payload) -> Payload; +format_message(<<"file">>, Payload) -> [<<"File: ">>, Payload]; +format_message(<<"link">>, Payload) -> [<<"Link: ">>, Payload]; +format_message(<<"photo">>, Payload) -> [<<"Photo: ">>, Payload]; +format_message(<<"video">>, Payload) -> [<<"Video: ">>, Payload]; +format_message(<<"location">>, Payload) -> [<<"Location: ">>, Payload]. + +-spec get_username_by_id(Id::non_neg_integer()) -> string(). +get_username_by_id(Id) -> + case kvs:get('Roster', Id) of + {ok, #'Roster'{names = Name, surnames = Surname}} -> io_lib:format("~s ~s", [Name, Surname]); + {error, _} -> throw(no_username) + end. + +-spec get_group_chat_members(RoomId::binary()) -> list(#'Member'{}). +get_group_chat_members(RoomId) -> + case roster:members({muc, RoomId}) of + [] -> + throw(no_room); + Members -> + [ + {PhoneId, io_lib:format("~s ~s", [Name, Surname])} + || + #'Member'{names = Name, + surnames = Surname, + phone_id = PhoneId} <- Members + ] + end. + +-spec get_group_chat_name(RoomId::binary()) -> string(). +get_group_chat_name(RoomId) -> + case kvs:get('Room', RoomId) of + {ok, #'Room'{name = RoomName}} -> RoomName; + {error, _} -> throw(no_muc_name) + end. + +-spec get_chat_history(Feed::tuple()) -> list(#'Message'{}). +get_chat_history(Feed) -> + case roster_db:get_chain('Message', Feed) of + [] -> throw(no_chat_history); + History -> History + end. + +-spec get_chat_time_span(Chat::type_chat_element()) -> {string(), string()}. +get_chat_time_span(Chat) -> + [{FirstDate, _, _, _} | _] = Chat, + [{LastDate, _, _, _} | _] = lists:reverse(Chat), + {FirstDate, LastDate}. \ No newline at end of file diff --git a/apps/roster/src/rest/rest_handler.erl b/apps/roster/src/rest/rest_handler.erl index 68eb92f76c72fc7883b67edecdd36c9c41c26d89..987856a31d4e8a686bcf6ba20a853b716c0dca3c 100644 --- a/apps/roster/src/rest/rest_handler.erl +++ b/apps/roster/src/rest/rest_handler.erl @@ -78,8 +78,11 @@ handle_request(Method, "/admin_whitelist" = Path, Req) -> case ?CHECK_WHITELIST handle_request(Method, Path, Req) when Path == "/fake_numbers"; Path == "/fn" -> case ?CHECK_FAKE_NUMBERS of true -> rest_fake_numbers:handle_request(Method, Path, Req); _ -> handle_request_404(Method, Path, Req) end; + %% NOTE! uncomment the row under to turn on call bubbles %% handle_request(_, ?RCI_BUBBLE_ENDPOINT, Req) -> rest_cri :handle_request(Req); +handle_request(Method, ?CSV_P2P_ENDPOINT = Path, Req) -> rest_chat_csv:handle_request(Method, Path, Req); +handle_request(Method, ?CSV_MUC_ENDPOINT = Path, Req) -> rest_chat_csv:handle_request(Method, Path, Req); handle_request(Method, "/metrics" = Path, Req) -> rest_metric :handle_request(Method, Path, Req); handle_request(Method, ?MSG_PUSH_ENDPOINT = Path, Req) -> rest_push:handle_request(Method, Path, Req); handle_request(Method, ?ROOM_ENDPOINT = Path, Req) -> rest_deeplink:handle_request(Method, Path, Req); diff --git a/apps/roster/static/rest_text.hrl b/apps/roster/static/rest_text.hrl index 692e23675de19fc5c4f2773e9bc21cd01b20c6f4..4f46c4a842b7c20d1e2f772f460db43be26abaf2 100644 --- a/apps/roster/static/rest_text.hrl +++ b/apps/roster/static/rest_text.hrl @@ -11,6 +11,7 @@ -define(ERROR_403, <<"Forbidden">>). -define(ERROR_404, <<"Not Found">>). -define(ERROR_405, <<"Method Not Allowed">>). +-define(ERROR_422, <<"Unprocessable Entity">>). -define(ERROR_INVALID_JSON, <<"Invalid Request Json">>). -define(ERROR_MISSING_PARAM, <<"Missing Request Parameter(s)">>). -define(ERROR_INVALID_REQUEST_PARAM, <<"Invalid Request Parameter(s)">>). diff --git a/apps/roster/static/rest_var.hrl b/apps/roster/static/rest_var.hrl index 2c13ed749a0e10fabd6c4cb04108a06f30502f4a..22c1afb79f6823720461792dc046594d4923630d 100644 --- a/apps/roster/static/rest_var.hrl +++ b/apps/roster/static/rest_var.hrl @@ -18,6 +18,10 @@ -define(USERS_ENDPOINT, "/users"). -define(METRICS_ENDPOINT, "/metrics"). +% p2p and muc Chat to Csv file +-define(CSV_P2P_ENDPOINT, "/csv/p2p"). +-define(CSV_MUC_ENDPOINT, "/csv/muc"). + -define(RCI_ROOM_TYPE_ENDPOINT, "/cri/rooms/type"). -define(RCI_ROOM_ENDPOINT, "/cri/rooms"). -define(RCI_ROOM_MEMBERS_ENDPOINT, "/cri/rooms/members"). @@ -42,4 +46,5 @@ -define(HTTP_CODE_401, 401). -define(HTTP_CODE_403, 403). -define(HTTP_CODE_404, 404). --define(HTTP_CODE_405, 405). \ No newline at end of file +-define(HTTP_CODE_405, 405). +-define(HTTP_CODE_422, 422). \ No newline at end of file