Scripts/Runtime/WitRequest.cs (601 lines of code) (raw):

/* * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the license found in the * LICENSE file in the root directory of this source tree. */ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Text; using System.Threading; using Facebook.WitAi.Configuration; using Facebook.WitAi.Data; using Facebook.WitAi.Data.Configuration; using Facebook.WitAi.Lib; using UnityEngine; using SystemInfo = UnityEngine.SystemInfo; using Facebook.WitAi.Utilities; #if UNITY_EDITOR using UnityEditor; #endif namespace Facebook.WitAi { /// <summary> /// Manages a single request lifecycle when sending/receiving data from Wit.ai. /// /// Note: This is not intended to be instantiated directly. Requests should be created with the /// WitRequestFactory /// </summary> public class WitRequest { /// <summary> /// Error code thrown when an exception is caught during processing or /// some other general error happens that is not an error from the server /// </summary> public const int ERROR_CODE_GENERAL = -1; /// <summary> /// Error code returned when no configuration is defined /// </summary> public const int ERROR_CODE_NO_CONFIGURATION = -2; /// <summary> /// Error code returned when the client token has not been set in the /// Wit configuration. /// </summary> public const int ERROR_CODE_NO_CLIENT_TOKEN = -3; /// <summary> /// No data was returned from the server. /// </summary> public const int ERROR_CODE_NO_DATA_FROM_SERVER = -4; /// <summary> /// Invalid data was returned from the server. /// </summary> public const int ERROR_CODE_INVALID_DATA_FROM_SERVER = -5; /// <summary> /// Request was aborted /// </summary> public const int ERROR_CODE_ABORTED = -6; /// <summary> /// Request to the server timeed out /// </summary> public const int ERROR_CODE_TIMEOUT = -7; public const string URI_SCHEME = "https"; public const string URI_AUTHORITY = "api.wit.ai"; public const int URI_DEFAULT_PORT = 0; public const string WIT_API_VERSION = "20220222"; public const string WIT_SDK_VERSION = "0.0.35"; public const string WIT_ENDPOINT_SPEECH = "speech"; public const string WIT_ENDPOINT_MESSAGE = "message"; public const string WIT_ENDPOINT_ENTITIES = "entities"; public const string WIT_ENDPOINT_INTENTS = "intents"; public const string WIT_ENDPOINT_TRAITS = "traits"; public const string WIT_ENDPOINT_APPS = "apps"; public const string WIT_ENDPOINT_UTTERANCES = "utterances"; private WitConfiguration configuration; private string command; private string path; public QueryParam[] queryParams; //private HttpWebRequest request; private IRequest _request; private HttpWebResponse response; private WitResponseNode responseData; private bool isActive; private bool responseStarted; public byte[] postData; public string postContentType; private object streamLock = new object(); private int bytesWritten; private bool requestRequiresBody; /// <summary> /// Callback called when a response is received from the server /// </summary> public Action<WitRequest> onResponse; /// <summary> /// Callback called when the server is ready to receive data from the WitRequest's input /// stream. See WitRequest.Write() /// </summary> public Action<WitRequest> onInputStreamReady; /// <summary> /// Returns the raw string response that was received before converting it to a JSON object. /// /// NOTE: This response comes back on a different thread. Do not attempt ot set UI control /// values or other interactions from this callback. This is intended to be used for demo /// and test UI, not for regular use. /// </summary> public Action<string> onRawResponse; /// <summary> /// Returns a partial utterance from an in process request /// /// NOTE: This response comes back on a different thread. /// </summary> public Action<string> onPartialTranscription; /// <summary> /// Returns a full utterance from a completed request /// /// NOTE: This response comes back on a different thread. /// </summary> public Action<string> onFullTranscription; public delegate void PreSendRequestDelegate(ref Uri src_uri, out Dictionary<string,string> headers); /// <summary> /// Allows customization of the request before it is sent out. /// /// Note: This is for devs who are routing requests to their servers /// before sending data to Wit.ai. This allows adding any additional /// headers, url modifications, or customization of the request. /// </summary> public static PreSendRequestDelegate onPreSendRequest; public delegate Uri OnCustomizeUriEvent(UriBuilder uriBuilder); /// <summary> /// Provides an opportunity to customize the url just before a request executed /// </summary> public OnCustomizeUriEvent onCustomizeUri; public delegate Dictionary<string, string> OnProvideCustomHeadersEvent(); /// <summary> /// Provides an opportunity to provide custom headers for the request just before it is /// executed. /// </summary> public OnProvideCustomHeadersEvent onProvideCustomHeaders; /// <summary> /// Returns true if a request is pending. Will return false after data has been populated /// from the response. /// </summary> public bool IsActive => isActive; /// <summary> /// JSON data that was received as a response from the server after onResponse has been /// called /// </summary> public WitResponseNode ResponseData => responseData; /// <summary> /// Encoding settings for audio based requests /// </summary> public AudioEncoding audioEncoding = new AudioEncoding(); private int statusCode; public int StatusCode => statusCode; private string statusDescription; private bool isRequestStreamActive; public bool IsRequestStreamActive => IsActive && isRequestStreamActive; public bool HasResponseStarted => responseStarted; private bool isServerAuthRequired; public string StatusDescription => statusDescription; public int Timeout => configuration ? configuration.timeoutMS : 10000; public IRequest RequestProvider { get; internal set; } private static string operatingSystem; private static string deviceModel; private static string deviceName; private static string appIdentifier; private bool configurationRequired; private string serverToken; private string callingStackTrace; private DateTime requestStartTime; private ConcurrentQueue<byte[]> writeBuffer = new ConcurrentQueue<byte[]>(); public override string ToString() { return path; } public WitRequest(WitConfiguration configuration, string path, params QueryParam[] queryParams) { if (!configuration) throw new ArgumentException("Configuration is not set."); configurationRequired = true; this.configuration = configuration; this.command = path.Split('/').First(); this.path = path; this.queryParams = queryParams; if (null == operatingSystem) operatingSystem = SystemInfo.operatingSystem; if (null == deviceModel) deviceModel = SystemInfo.deviceModel; if (null == deviceName) deviceName = SystemInfo.deviceName; if (null == appIdentifier) appIdentifier = Application.identifier; } public WitRequest(WitConfiguration configuration, string path, bool isServerAuthRequired, params QueryParam[] queryParams) { if (!isServerAuthRequired && !configuration) throw new ArgumentException("Configuration is not set."); configurationRequired = true; this.configuration = configuration; this.isServerAuthRequired = isServerAuthRequired; this.command = path.Split('/').First(); this.path = path; this.queryParams = queryParams; if (isServerAuthRequired) { serverToken = WitAuthUtility.GetAppServerToken(configuration?.application?.id); } } public WitRequest(string serverToken, string path, params QueryParam[] queryParams) { configurationRequired = false; this.isServerAuthRequired = true; this.command = path.Split('/').First(); this.path = path; this.queryParams = queryParams; this.serverToken = serverToken; } /// <summary> /// Key value pair that is sent as a query param in the Wit.ai uri /// </summary> public class QueryParam { public string key; public string value; } /// <summary> /// Start the async request for data from the Wit.ai servers /// </summary> public void Request() { responseStarted = false; UriBuilder uriBuilder = new UriBuilder(); var endpointConfig = WitEndpointConfig.GetEndpointConfig(configuration); uriBuilder.Scheme = endpointConfig.UriScheme; uriBuilder.Host = endpointConfig.Authority; var api = endpointConfig.WitApiVersion; if (endpointConfig.Port > 0) { uriBuilder.Port = endpointConfig.Port; } uriBuilder.Query = $"v={api}"; uriBuilder.Path = path; callingStackTrace = Environment.StackTrace; if (queryParams.Any()) { var p = queryParams.Select(par => $"{par.key}={Uri.EscapeDataString(par.value)}"); uriBuilder.Query += "&" + string.Join("&", p); } var uri = null == onCustomizeUri ? uriBuilder.Uri : onCustomizeUri(uriBuilder); StartRequest(uri); } private void StartRequest(Uri uri) { if (!configuration && configurationRequired) { statusDescription = "Configuration is not set. Cannot start request."; Debug.LogError(statusDescription); statusCode = ERROR_CODE_NO_CONFIGURATION; SafeInvoke(onResponse); return; } if (!isServerAuthRequired && string.IsNullOrEmpty(configuration.clientAccessToken)) { statusDescription = "Client access token is not defined. Cannot start request."; Debug.LogError(statusDescription); statusCode = ERROR_CODE_NO_CLIENT_TOKEN; SafeInvoke(onResponse); return; } //allow app to intercept request and potentially modify uri or add custom headers //NOTE: the callback depends on knowing the original Uri, before it is modified Dictionary<string, string> customHeaders = null; if (onPreSendRequest != null) { onPreSendRequest(ref uri, out customHeaders); } WrapHttpWebRequest wr = new WrapHttpWebRequest((HttpWebRequest)WebRequest.Create(uri.AbsoluteUri)); //request = (IRequest)(HttpWebRequest) WebRequest.Create(uri); _request = wr; if (isServerAuthRequired) { _request.Headers["Authorization"] = $"Bearer {serverToken}"; } else { _request.Headers["Authorization"] = $"Bearer {configuration.clientAccessToken.Trim()}"; } if (null != postContentType) { _request.Method = "POST"; _request.ContentType = postContentType; _request.ContentLength = postData.Length; } // Configure additional headers if (WitEndpointConfig.GetEndpointConfig(configuration).Speech == command) { _request.ContentType = audioEncoding.ToString(); _request.Method = "POST"; _request.SendChunked = true; } requestRequiresBody = RequestRequiresBody(command); var configId = "not-yet-configured"; #if UNITY_EDITOR if (configuration) { if (string.IsNullOrEmpty(configuration.configId)) { configuration.configId = Guid.NewGuid().ToString(); EditorUtility.SetDirty(configuration); } configId = configuration.configId; } #endif _request.UserAgent = $"wit-unity-{WIT_SDK_VERSION},{operatingSystem},{deviceModel},{configId},{appIdentifier}"; #if UNITY_EDITOR _request.UserAgent += ",Editor"; #else _request.UserAgent += ",Runtime"; #endif requestStartTime = DateTime.UtcNow; isActive = true; statusCode = 0; statusDescription = "Starting request"; _request.Timeout = configuration ? configuration.timeoutMS : 10000; WatchMainThreadCallbacks(); if (null != onProvideCustomHeaders) { foreach (var header in onProvideCustomHeaders()) { _request.Headers[header.Key] = header.Value; } } //apply any modified headers last, as this allows us to overwrite headers if need be if (customHeaders != null) { foreach (var pair in customHeaders) { _request.Headers[pair.Key] = pair.Value; } } if (_request.Method == "POST") { var getRequestTask = _request.BeginGetRequestStream(HandleRequestStream, _request); ThreadPool.RegisterWaitForSingleObject(getRequestTask.AsyncWaitHandle, HandleTimeoutTimer, _request, Timeout, true); } else { StartResponse(); } } private bool RequestRequiresBody(string command) { return command == WitEndpointConfig.GetEndpointConfig(configuration).Speech; } private void StartResponse() { var result = _request.BeginGetResponse(HandleResponse, _request); ThreadPool.RegisterWaitForSingleObject(result.AsyncWaitHandle, HandleTimeoutTimer, _request, Timeout, true); } private void HandleTimeoutTimer(object state, bool timedout) { if (!timedout) return; // Clean up the current request if it is still going //var request = (HttpWebRequest) state; var request = (IRequest)state; if (null != _request) { Debug.Log("Request timed out after " + (DateTime.UtcNow - requestStartTime)); request.Abort(); } isActive = false; // Close any open stream resources and clean up streaming state flags CloseRequestStream(); // Update the error state to indicate the request timed out statusCode = ERROR_CODE_TIMEOUT; statusDescription = "Request timed out."; SafeInvoke(onResponse); } private void HandleResponse(IAsyncResult ar) { string stringResponse = ""; responseStarted = true; try { response = (HttpWebResponse) _request.EndGetResponse(ar); statusCode = (int) response.StatusCode; statusDescription = response.StatusDescription; try { var responseStream = response.GetResponseStream(); if (response.Headers["Transfer-Encoding"] == "chunked") { byte[] buffer = new byte[10240]; int bytes = 0; int offset = 0; int totalRead = 0; while ((bytes = responseStream.Read(buffer, offset, buffer.Length - offset)) > 0) { totalRead += bytes; stringResponse = Encoding.UTF8.GetString(buffer, 0, totalRead); if (stringResponse.EndsWith("\r\n")) { try { offset = 0; totalRead = 0; ProcessStringResponse(stringResponse); } catch (JSONParseException e) { offset = bytes; Debug.LogWarning("Received what appears to be a partial response or invalid json. Attempting to continue reading. Parsing error: " + e.Message + "\n" + stringResponse); } } else { offset = totalRead; } } // If the final transmission didn't end with \r\n process it as the final // result if (!stringResponse.EndsWith("\r\n") && !string.IsNullOrEmpty(stringResponse)) { ProcessStringResponse(stringResponse); } if (stringResponse.Length > 0 && null != responseData) { MainThreadCallback(() => onFullTranscription?.Invoke(responseData["text"])); MainThreadCallback(() => onRawResponse?.Invoke(stringResponse)); } } else { using (StreamReader reader = new StreamReader(responseStream)) { stringResponse = reader.ReadToEnd(); MainThreadCallback(() => onRawResponse?.Invoke(stringResponse)); responseData = WitResponseJson.Parse(stringResponse); } } responseStream.Close(); } catch (JSONParseException e) { Debug.LogError("Server returned invalid data: " + e.Message + "\n" + stringResponse); statusCode = ERROR_CODE_INVALID_DATA_FROM_SERVER; statusDescription = "Server returned invalid data."; } catch (Exception e) { Debug.LogError( $"{e.Message}\nRequest Stack Trace:\n{callingStackTrace}\nResponse Stack Trace:\n{e.StackTrace}"); statusCode = ERROR_CODE_GENERAL; statusDescription = e.Message; } response.Close(); } catch (WebException e) { statusCode = (int) e.Status; if (e.Response is HttpWebResponse errorResponse) { statusCode = (int) errorResponse.StatusCode; try { var stream = errorResponse.GetResponseStream(); if (null != stream) { using (StreamReader reader = new StreamReader(stream)) { stringResponse = reader.ReadToEnd(); MainThreadCallback(() => onRawResponse?.Invoke(stringResponse)); responseData = WitResponseJson.Parse(stringResponse); } } } catch (JSONParseException) { // Response wasn't encoded error, ignore it. } catch (Exception errorResponseError) { // We've already caught that there is an error, we'll ignore any errors // reading error response data and use the status/original error for validation Debug.LogWarning(errorResponseError); } } statusDescription = e.Message; if (e.Status != WebExceptionStatus.RequestCanceled) { Debug.LogError( $"Http Request Failed [{statusCode}]: {e.Message}\nRequest Stack Trace:\n{callingStackTrace}\nResponse Stack Trace:\n{e.StackTrace}"); } } finally { isActive = false; } CloseRequestStream(); if (null != responseData) { var error = responseData["error"]; if (!string.IsNullOrEmpty(error)) { statusDescription = $"Error: {responseData["code"]}. {error}"; statusCode = statusCode == 200 ? ERROR_CODE_GENERAL : statusCode; } } else if (statusCode == 200) { statusCode = ERROR_CODE_NO_DATA_FROM_SERVER; statusDescription = "Server did not return a valid json response."; Debug.LogWarning( "No valid data was received from the server even though the request was successful. Actual potential response data: \n" + stringResponse); } SafeInvoke(onResponse); } private void ProcessStringResponse(string stringResponse) { responseData = WitResponseJson.Parse(stringResponse); if (null != responseData) { var transcription = responseData["text"]; if (!string.IsNullOrEmpty(transcription)) { MainThreadCallback(() => onPartialTranscription?.Invoke(transcription)); } } } private void HandleRequestStream(IAsyncResult ar) { try { StartResponse(); var stream = _request.EndGetRequestStream(ar); bytesWritten = 0; if (null != postData) { bytesWritten += postData.Length; stream.Write(postData, 0, postData.Length); CloseRequestStream(); } else { if (null == onInputStreamReady) { CloseRequestStream(); } else { isRequestStreamActive = true; SafeInvoke(onInputStreamReady); } } new Thread(ExecuteWriteThread).Start(stream); } catch (WebException e) { if (e.Status != WebExceptionStatus.RequestCanceled) { statusCode = (int) e.Status; statusDescription = e.Message; SafeInvoke(onResponse); } } } private void ExecuteWriteThread(object obj) { bytesWritten = 0; Stream stream = (Stream) obj; try { while (isRequestStreamActive) { FlushBuffer(stream); Thread.Yield(); } FlushBuffer(stream); stream.Close(); } catch (ObjectDisposedException) { // Handling edge case where stream is closed remotely // This problem occurs when the Web server resets or closes the connection after // the client application sends the HTTP header. // https://support.microsoft.com/en-us/topic/fix-you-receive-a-system-objectdisposedexception-exception-when-you-try-to-access-a-stream-object-that-is-returned-by-the-endgetrequeststream-method-in-the-net-framework-2-0-bccefe57-0a61-517a-5d5f-2dce0cc63265 Debug.LogWarning( "Stream already disposed. It is likely the server reset the connection before streaming started."); } catch (IOException e) { Debug.LogWarning(e.Message); } catch (Exception e) { Debug.LogError(e); } if (requestRequiresBody && bytesWritten == 0) { Debug.LogWarning("Stream was closed with no data written. Aborting request."); AbortRequest(); } } private void FlushBuffer(Stream stream) { while (writeBuffer.Count > 0) { if (writeBuffer.TryDequeue(out var buffer)) { bytesWritten += buffer.Length; stream.Write(buffer, 0, buffer.Length); } } } private void SafeInvoke(Action<WitRequest> action) { MainThreadCallback(() => { // We want to allow each invocation to run even if there is an exception thrown by one // of the callbacks in the invocation list. This protects shared invocations from // clients blocking things like UI updates from other parts of the sdk being invoked. foreach (var responseDelegate in action.GetInvocationList()) { try { responseDelegate.DynamicInvoke(this); } catch (Exception e) { Debug.LogError(e); } } }); } public void AbortRequest() { CloseActiveStream(); _request.Abort(); if (statusCode == 0) { statusCode = ERROR_CODE_ABORTED; statusDescription = "Request was aborted"; } isActive = false; } /// <summary> /// Method to close the input stream of data being sent during the lifecycle of this request /// /// If a post method was used, this will need to be called before the request will complete. /// </summary> public void CloseRequestStream() { if (requestRequiresBody && bytesWritten == 0) { AbortRequest(); Debug.LogWarning("Stream was closed with no data written. Aborting request."); } else { CloseActiveStream(); } } private void CloseActiveStream() { lock (streamLock) { isRequestStreamActive = false; } } /// <summary> /// Write request data to the Wit.ai post's body input stream /// /// Note: If the stream is not open (IsActive) this will throw an IOException. /// Data will be written synchronously. This should not be called from the main thread. /// </summary> /// <param name="data"></param> /// <param name="offset"></param> /// <param name="length"></param> public void Write(byte[] data, int offset, int length) { // TODO: This is going to cause additional allocations, we can probably improve this var buffer = new byte[data.Length]; Array.Copy(data, offset, buffer, 0, length); writeBuffer.Enqueue(buffer); } #region CALLBACKS // Check performing private bool _performing = false; // All actions private ConcurrentQueue<Action> _mainThreadCallbacks = new ConcurrentQueue<Action>(); // Called from background thread private void MainThreadCallback(Action action) { _mainThreadCallbacks.Enqueue(action); } // While active, perform any sent callbacks private void WatchMainThreadCallbacks() { // Ifnore if already performing if (_performing) { return; } // Check callbacks every frame (editor or runtime) CoroutineUtility.StartCoroutine(PerformMainThreadCallbacks()); } // Every frame check for callbacks & perform any found private System.Collections.IEnumerator PerformMainThreadCallbacks() { // Begin performing _performing = true; // While checking, continue while (HasMainThreadCallbacks()) { // Wait for frame yield return new WaitForEndOfFrame(); // Perform if possible while (_mainThreadCallbacks.Count > 0 && _mainThreadCallbacks.TryDequeue(out var result)) { result(); } } // Done performing _performing = false; } // Check actions private bool HasMainThreadCallbacks() { return IsActive || isRequestStreamActive || _mainThreadCallbacks.Count > 0; } #endregion } }