diff --git a/apps/roster/src/api/google_api.erl b/apps/roster/src/api/google_api.erl index 562a10bb6ce6020aadd0f84d956055338761ba00..9c83dc5b846a45af12cf8a06c07e7b0ef61cd7ac 100644 --- a/apps/roster/src/api/google_api.erl +++ b/apps/roster/src/api/google_api.erl @@ -4,8 +4,23 @@ -include_lib("kernel/include/logger.hrl"). -include_lib("enenra/include/enenra.hrl"). --define(GOOGLE_API_KEY, proplists:get_value(key, application:get_env(roster, google_api, []))). --define(CONTENT_TYPE, "application/json"). +%% In google cloud endpoints look like: +%% "https://www.googleapis.com/storage/storage/v1/b/{bucketName}/o/{objectName}" +%% See documentation for details: +%% https://cloud.google.com/storage/docs/json_api/ +%% +%% Here we only use googles notation for bucketName and objectName +%% encoded as binary as gs://{bucketName}/{objectName} +%% +%% Enenra uses credentials to create a token. It automatically refreshes tokens, +%% It is sufficient to use Creds and we don't need the token itself in any +%% of the APIs to storage. +%% We do, however, need the token to access the speech API. +%% For that a specific token in generated to authenticate for cloud-platform as +%% described in: +%% https://cloud.google.com/speech-to-text/docs/reference/rest/v1/speech/recognize +%% + -define(DETECT_LANGUAGE_URL, <<"https://translation.googleapis.com/language/translate/v2/detect">>). -define(TRANSLATION_URL, <<"https://translation.googleapis.com/language/translate/v2">>). -define(TRANSCRIBE_URL, "https://speech.googleapis.com/v1/speech:recognize"). @@ -15,9 +30,6 @@ -define(LONG_TRANSCRIBE_COUNTER, 100). --define(SHELL_ACCESS_TOKEN_CMD, "echo $(gcloud auth application-default print-access-token)"). --define(TOKEN_INFO_URL, "https://www.googleapis.com/oauth2/v3/tokeninfo?access_token="). - -define(DOWNLOAD_TIMEOUT, 30000). -define(DOWNLOAD_DIR, proplists:get_value(download_dir, application:get_env(roster, google_api, []), "/tmp/")). -define(GS_BUCKET, proplists:get_value(gs_bucket, application:get_env(roster, google_api, []), <<"cryoflamer">>)). @@ -28,30 +40,30 @@ %% Handlers %% ------------------------------------------------------------------ -detect_language(Text) -> - ?LOG_INFO("DetectLanguage: ~p", [Text]), -%% TODO encode url symbols. Not all text, just whitespaces, dots, etc. -%% NOTE! Dont use http_uri:encode. This lib encodes all text, fuuuu - QueryParams = iolist_to_binary(["key=", ?GOOGLE_API_KEY]), - PostVariables = jsx:encode([{<<"q">>, Text}]), - ?LOG_INFO("PostVariables: ~p", [PostVariables]), - Request = binary_to_list(iolist_to_binary([?DETECT_LANGUAGE_URL, "?", QueryParams])), - ?LOG_INFO("Request: ~p", [Request]), - {RequestStatus, RequestResult} = roster_rest:send_request(post, {Request, [], ?CONTENT_TYPE, PostVariables}, [], []), - ?LOG_INFO("RequestStatus: ~p", [RequestStatus]), - ?LOG_INFO("RequestResult: ~p", [RequestResult]), - ok. +%% detect_language(Text) -> +%% ?LOG_INFO("DetectLanguage: ~p", [Text]), +%% %% TODO encode url symbols. Not all text, just whitespaces, dots, etc. +%% %% NOTE! Dont use http_uri:encode. This lib encodes all text, fuuuu +%% QueryParams = iolist_to_binary(["key=", ?GOOGLE_API_KEY]), +%% PostVariables = jsx:encode([{<<"q">>, Text}]), +%% ?LOG_INFO("PostVariables: ~p", [PostVariables]), +%% Request = binary_to_list(iolist_to_binary([?DETECT_LANGUAGE_URL, "?", QueryParams])), +%% ?LOG_INFO("Request: ~p", [Request]), +%% {RequestStatus, RequestResult} = roster_rest:send_request(post, {Request, [], "application/json", PostVariables}, [], []), +%% ?LOG_INFO("RequestStatus: ~p", [RequestStatus]), +%% ?LOG_INFO("RequestResult: ~p", [RequestResult]), +%% ok. -translate(Text, Target) -> - ?LOG_DEBUG("Debug.Target:~p.Text: ~p", [Target, Text]), - QueryParams = iolist_to_binary(["key=", ?GOOGLE_API_KEY]), - PostVariables = jsx:encode([{<<"q">>, Text}, {<<"target">>, Target}]), - ?LOG_INFO("PostVariables: ~p", [PostVariables]), - Request = binary_to_list(iolist_to_binary([?TRANSLATION_URL, "?", QueryParams])), - {RequestStatus, RequestResult} = roster_rest:send_request(post, {Request, [], ?CONTENT_TYPE, PostVariables}, [], []), - ?LOG_INFO("RequestStatus: ~p", [RequestStatus]), - ?LOG_INFO("RequestResult: ~p", [RequestResult]), - ok. +%% translate(Text, Target) -> +%% ?LOG_DEBUG("Debug.Target:~p.Text: ~p", [Target, Text]), +%% QueryParams = iolist_to_binary(["key=", ?GOOGLE_API_KEY]), +%% PostVariables = jsx:encode([{<<"q">>, Text}, {<<"target">>, Target}]), +%% ?LOG_INFO("PostVariables: ~p", [PostVariables]), +%% Request = binary_to_list(iolist_to_binary([?TRANSLATION_URL, "?", QueryParams])), +%% {RequestStatus, RequestResult} = roster_rest:send_request(post, {Request, [], "application/json", PostVariables}, [], []), +%% ?LOG_INFO("RequestStatus: ~p", [RequestStatus]), +%% ?LOG_INFO("RequestResult: ~p", [RequestResult]), +%% ok. %% ------------------------------------------------------------------ %% Transcribe API @@ -65,13 +77,17 @@ start() -> case filelib:is_file(PathFile) of true -> case enenra:load_credentials(PathFile) of - {ok, Creds} -> application:set_env(roster, google_creds, Creds); - Err-> ?LOG_ERROR("cannot load google credentials: ~p", [Err]) + {ok, Creds} -> + application:set_env(roster, google_creds, Creds), + get_access_token(); + Err-> + ?LOG_ERROR("cannot load google credentials: ~p", [Err]), + {error, google_credentials} end; _ -> - ?LOG_ERROR("google credential file ~p not found", PathFile) - end, - get_access_token(). + ?LOG_ERROR("google credential file ~p not found", PathFile), + {error, credentials_not_found} + end. del_gs_object(<<"gs://", Path/binary>>) -> Bucket = ?GS_BUCKET, @@ -86,7 +102,8 @@ del_gs_object(<<"gs://", Path/binary>>) -> ?LOG_ERROR("cannot delete object ~p: ~p", [Object, Err]), Err end; - [_|_] -> + _ -> + ?LOG_ERROR("unable to delete object ~p", [Path]), ok end. del_gs_object(Bucket, Object) -> @@ -98,63 +115,61 @@ del_gs_object(Bucket, Object) -> {error, creds_not_found} end. -uri_to_gs(<<"https://www.googleapis.com/storage/", _/binary>> = Uri) -> - try - {ok, {https, _, _, _, <<"/storage/v1/b/", BucketObject/binary>>, _}} = http_uri:parse(Uri), - [Bucket, Object] = binary:split(BucketObject, <<"/o/">>), - <<"gs://", Bucket/binary, "/", (http_uri:decode(Object))/binary>> - catch _:_ -> Uri end; -uri_to_gs(Uri) -> Uri. - -access_url(Url, <<"AIza", _/binary>> = AccessKey) -> - binary_to_list(iolist_to_binary([Url, "?key=", AccessKey])); -access_url(Url, _AccessKey) -> Url. -access_headers(<<"AIza", _/binary>>) -> []; access_headers(AccessToken) -> - [{"Authorization", "Bearer " ++ AccessToken}]. - -token_expiration(Token) -> - case roster_rest:send_request(get, {?TOKEN_INFO_URL++Token, []}, [], []) of - {ok, Body} -> - #{<<"exp">> := ExpTime} = jsx:decode(list_to_binary(Body), [return_maps]), - {Token, binary_to_integer(ExpTime)}; - {error, E} -> - ?LOG_ERROR("invalid access token: ~p", [E]), - {error, invalid_access_token} - end. + [{"Authorization", "Bearer " ++ AccessToken}]. get_access_token() -> - case ?GOOGLE_API_KEY of - [] -> - Now = round(roster:now_msec()/1000), - case application:get_env(roster, access_token, {[], 0}) of - {T, ExpTime} when Now > ExpTime; T == error -> - case lists:droplast(os:cmd(?SHELL_ACCESS_TOKEN_CMD)) of - "ya29"++_ = Token-> - application:set_env(roster, access_token, token_expiration(Token)), - Token; - ErrInfo -> - ?LOG_ERROR("invalid acess token: ~p", [ErrInfo]), - application:set_env(roster, access_token, {error, invalid_access_token}), - {error, invalid_access_token} - end; - {Token, _ExpTime} -> Token - end; - <<"AIza", _/binary>> = Key -> Key + Now = os:system_time(second), + case application:get_env(roster, access_token, {[], 0}) of + {T, ExpTime} when Now > ExpTime; T == error -> + {ok, Creds} = application:get_env(roster, google_creds), + get_access_token(Creds); + {Token, _ExpTime} -> + Token + end. + +%% The storage component uses the same credentials to create an access token +%% for storage. Here we create one for speech-to-text +get_access_token(Creds) -> + Now = os:system_time(second), + Expiration = Now + 3600, + ClaimSet = #{ + <<"iss">> => Creds#credentials.client_email, + <<"scope">> => <<"https://www.googleapis.com/auth/cloud-platform">>, + <<"aud">> => <<"https://www.googleapis.com/oauth2/v4/token">>, + <<"exp">> => Expiration, + <<"iat">> => Now + }, + {ok, Jwt} = jwt:encode(<<"RS256">>, ClaimSet, Creds#credentials.private_key), + Form = [{<<"grant_type">>, <<"urn:ietf:params:oauth:grant-type:jwt-bearer">>}, + {<<"assertion">>, Jwt}], + Request = {"https://www.googleapis.com/oauth2/v4/token", [], "application/x-www-form-urlencoded", + uri_string:compose_query(Form)}, + try + {ok, Body} = roster_rest:send_request(post, Request, [], []), + #{ <<"access_token">> := AccessToken, + <<"token_type">> := <<"Bearer">> } = jsx:decode(iolist_to_binary(Body), [return_maps]), + Token = binary_to_list(AccessToken), + application:set_env(roster, access_token, {Token, Expiration}), + Token + catch _:ErrInfo -> + ?LOG_ERROR("invalid access token: ~p", [ErrInfo]), + application:set_env(roster, access_token, {error, invalid_access_token}), + {error, invalid_access_token} end. transcribe_config(Content, Lang, Encoding) -> jsx:encode(#{config => - #{encoding => Encoding, -%% sampleRateHertz => 16000, %% TODO may be needed to add in future -%% enableWordTimeOffsets => false, - languageCode => Lang}, - audio => Content}). + #{encoding => Encoding, + %% sampleRateHertz => 16000, %% TODO may be needed to add in future + %% enableWordTimeOffsets => false, + languageCode => Lang}, + audio => Content}). transcribe(Type, Data, Lang) -> transcribe(Type, Data, Lang, <<"ENCODING_UNSPECIFIED">>). transcribe(Type, Data, Lang, Encoding) when Type == short; Type == long -> {Url, Content} = - case {Type, uri_to_gs(Data)} of + case {Type, Data} of {long, <<"gs://", _/binary>> = GS} -> {?TRANSCRIBE_LONG_URL, #{uri => GS}}; {short, <<"gs://", _/binary>> = GS} -> {?TRANSCRIBE_URL, #{uri => GS}}; {short, _} -> {?TRANSCRIBE_URL, #{content => Data}}; @@ -171,18 +186,19 @@ transcribe(Type, Data, Lang, Encoding) when Type == short; Type == long -> Err; AccessToken -> AuthHeaders = access_headers(AccessToken), - Request = {access_url(Url, AccessToken), AuthHeaders, "application/json", Config}, + Request = {Url, AuthHeaders, "application/json", Config}, + %% Speech recognition is limited by Google per project + %% https://cloud.google.com/speech-to-text/quotas case roster_rest:send_request(post, Request, [], []) of {ok, Result} -> case jsx:decode(list_to_binary(Result), [return_maps]) of #{<<"results">> := Alternatives} when Type == short -> - %% ?LOG_INFO("alternative transcribes: ~p", [Alternatives]), case merge_transcribe(Alternatives) of <<>> -> {error, invalid_transcribe}; Text -> Text end; #{<<"name">> := OperationName} -> - LongRequest = {access_url(?TRANSCRIBE_OPERATIONS_URL ++ binary_to_list(OperationName), AccessToken), AuthHeaders}, + LongRequest = {?TRANSCRIBE_OPERATIONS_URL ++ binary_to_list(OperationName), AuthHeaders}, send_operation(LongRequest, OperationName, ?LONG_TRANSCRIBE_COUNTER, ?LONG_TRANSCRIBE_TIMEOUT); #{} -> ?LOG_ERROR("no text for transcribe in audio file", []), diff --git a/apps/roster/src/protocol/roster_message.erl b/apps/roster/src/protocol/roster_message.erl index e1350816cafb5b4063c97c43ff9e905099375ccb..5b7d1b5a3f5650bdd445f5b8f7b91a30893a8882 100644 --- a/apps/roster/src/protocol/roster_message.erl +++ b/apps/roster/src/protocol/roster_message.erl @@ -345,12 +345,12 @@ info(#'Message'{status = update, type = [draft], feed_id = Feed0, from = From, t info(#'Message'{status = update, id = Id, files = [#'Desc'{mime = <<"transcribe">>, payload = [], data = Data} = Desc | _]} = Msg, Req, #cx{params = ClientId, state = ack} = State) when is_integer(Id), not is_tuple(Req) -> - ?ROSTER_LOG_REQ('Message', update, ClientId, "Type=transcribe, Id=~p", [Id]), Type = case roster:get_data_val(<<"TYPE">>, Data) of [] -> <<"short">>; T -> string:lowercase(T) end, + ?ROSTER_LOG_REQ('Message', update, ClientId, "Type=transcribe (~s), Id=~p", [Type, Id]), Lang = roster:get_data_val(?LANG_KEY, Data), Pid = n2o_async:pid(system, roster_message), case kvs:get('Message', Id) of diff --git a/sys.config b/sys.config index 144dd9d752bb86637727059263c67bb4ff7b043f..c47edb9462db44a2d7eafdef5230935fec00b8ca 100644 --- a/sys.config +++ b/sys.config @@ -91,7 +91,6 @@ {google_api, [ {download_dir, "./priv/tmp/"}, {gs_bucket, <<"transcribe-store">>}, - {key, []}, {app_credentials, "etc/certs/transcribe-dacb4306ab76.json"} ]}, {push_api,[