diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index b30d987a95..f32eccfe8c 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -10,5 +10,8 @@ + + + diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index 91ea8f1221..ffe73808d4 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -12,6 +12,7 @@ public class BillingSettings public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings(); public virtual string FreshsalesApiKey { get; set; } public virtual PayPalSettings PayPal { get; set; } = new PayPalSettings(); + public virtual OnyxSettings Onyx { get; set; } = new OnyxSettings(); public class PayPalSettings { @@ -31,4 +32,10 @@ public class BillingSettings public virtual string UserFieldName { get; set; } public virtual string OrgFieldName { get; set; } } + + public class OnyxSettings + { + public virtual string ApiKey { get; set; } + public virtual string BaseUrl { get; set; } + } } diff --git a/src/Billing/Controllers/BitPayController.cs b/src/Billing/Controllers/BitPayController.cs index 026909aed1..4caf57aa20 100644 --- a/src/Billing/Controllers/BitPayController.cs +++ b/src/Billing/Controllers/BitPayController.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Options; namespace Bit.Billing.Controllers; [Route("bitpay")] +[ApiExplorerSettings(IgnoreApi = true)] public class BitPayController : Controller { private readonly BillingSettings _billingSettings; diff --git a/src/Billing/Controllers/FreshdeskController.cs b/src/Billing/Controllers/FreshdeskController.cs index 7aeb60a67f..4bf6b7bad4 100644 --- a/src/Billing/Controllers/FreshdeskController.cs +++ b/src/Billing/Controllers/FreshdeskController.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; +using System.Net.Http.Headers; using System.Reflection; using System.Text; +using System.Text.Json; using System.Web; using Bit.Billing.Models; using Bit.Core.Repositories; @@ -142,6 +144,121 @@ public class FreshdeskController : Controller } } + [HttpPost("webhook-onyx-ai")] + public async Task PostWebhookOnyxAi([FromQuery, Required] string key, + [FromBody, Required] FreshdeskWebhookModel model) + { + // ensure that the key is from Freshdesk + if (!IsValidRequestFromFreshdesk(key)) + { + return new BadRequestResult(); + } + + // get ticket info from Freshdesk + var getTicketRequest = new HttpRequestMessage(HttpMethod.Get, + string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}", model.TicketId)); + var getTicketResponse = await CallFreshdeskApiAsync(getTicketRequest); + + // check if we have a valid response from freshdesk + if (getTicketResponse.StatusCode != System.Net.HttpStatusCode.OK) + { + _logger.LogError("Error getting ticket info from Freshdesk. Ticket Id: {0}. Status code: {1}", + model.TicketId, getTicketResponse.StatusCode); + return BadRequest("Failed to retrieve ticket info from Freshdesk"); + } + + // extract info from the response + var ticketInfo = await ExtractTicketInfoFromResponse(getTicketResponse); + if (ticketInfo == null) + { + return BadRequest("Failed to extract ticket info from Freshdesk response"); + } + + // create the onyx `answer-with-citation` request + var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(ticketInfo.DescriptionText); + var onyxRequest = new HttpRequestMessage(HttpMethod.Post, + string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl)) + { + Content = JsonContent.Create(onyxRequestModel, mediaType: new MediaTypeHeaderValue("application/json")), + }; + var (_, onyxJsonResponse) = await CallOnyxApi(onyxRequest); + + // the CallOnyxApi will return a null if we have an error response + if (onyxJsonResponse?.Answer == null || !string.IsNullOrEmpty(onyxJsonResponse?.ErrorMsg)) + { + return BadRequest( + string.Format("Failed to get a valid response from Onyx API. Response: {0}", + JsonSerializer.Serialize(onyxJsonResponse ?? new OnyxAnswerWithCitationResponseModel()))); + } + + // add the answer as a note to the ticket + await AddAnswerNoteToTicketAsync(onyxJsonResponse.Answer, model.TicketId); + + return Ok(); + } + + private bool IsValidRequestFromFreshdesk(string key) + { + if (string.IsNullOrWhiteSpace(key) + || !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey)) + { + return false; + } + + return true; + } + + private async Task AddAnswerNoteToTicketAsync(string note, string ticketId) + { + // if there is no content, then we don't need to add a note + if (string.IsNullOrWhiteSpace(note)) + { + return; + } + + var noteBody = new Dictionary + { + { "body", $"Onyx AI:
    {note}
" }, + { "private", true } + }; + + var noteRequest = new HttpRequestMessage(HttpMethod.Post, + string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId)) + { + Content = JsonContent.Create(noteBody), + }; + + var addNoteResponse = await CallFreshdeskApiAsync(noteRequest); + if (addNoteResponse.StatusCode != System.Net.HttpStatusCode.Created) + { + _logger.LogError("Error adding note to Freshdesk ticket. Ticket Id: {0}. Status: {1}", + ticketId, addNoteResponse.ToString()); + } + } + + private async Task ExtractTicketInfoFromResponse(HttpResponseMessage getTicketResponse) + { + var responseString = string.Empty; + try + { + responseString = await getTicketResponse.Content.ReadAsStringAsync(); + var ticketInfo = JsonSerializer.Deserialize(responseString, + options: new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }); + + return ticketInfo; + } + catch (System.Exception ex) + { + _logger.LogError("Error deserializing ticket info from Freshdesk response. Response: {0}. Exception {1}", + responseString, ex.ToString()); + } + + return null; + } + private async Task CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0) { try @@ -166,6 +283,26 @@ public class FreshdeskController : Controller return await CallFreshdeskApiAsync(request, retriedCount++); } + private async Task<(HttpResponseMessage, T)> CallOnyxApi(HttpRequestMessage request) + { + var httpClient = _httpClientFactory.CreateClient("OnyxApi"); + var response = await httpClient.SendAsync(request); + + if (response.StatusCode != System.Net.HttpStatusCode.OK) + { + _logger.LogError("Error calling Onyx AI API. Status code: {0}. Response {1}", + response.StatusCode, JsonSerializer.Serialize(response)); + return (null, default); + } + var responseStr = await response.Content.ReadAsStringAsync(); + var responseJson = JsonSerializer.Deserialize(responseStr, options: new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }); + + return (response, responseJson); + } + private TAttribute GetAttribute(Enum enumValue) where TAttribute : Attribute { return enumValue.GetType().GetMember(enumValue.ToString()).First().GetCustomAttribute(); diff --git a/src/Billing/Models/FreshdeskViewTicketModel.cs b/src/Billing/Models/FreshdeskViewTicketModel.cs new file mode 100644 index 0000000000..2aa6eff94d --- /dev/null +++ b/src/Billing/Models/FreshdeskViewTicketModel.cs @@ -0,0 +1,44 @@ +namespace Bit.Billing.Models; + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +public class FreshdeskViewTicketModel +{ + [JsonPropertyName("spam")] + public bool? Spam { get; set; } + + [JsonPropertyName("priority")] + public int? Priority { get; set; } + + [JsonPropertyName("source")] + public int? Source { get; set; } + + [JsonPropertyName("status")] + public int? Status { get; set; } + + [JsonPropertyName("subject")] + public string Subject { get; set; } + + [JsonPropertyName("support_email")] + public string SupportEmail { get; set; } + + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("description_text")] + public string DescriptionText { get; set; } + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public DateTime UpdatedAt { get; set; } + + [JsonPropertyName("tags")] + public List Tags { get; set; } +} diff --git a/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs b/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs new file mode 100644 index 0000000000..e7bd29b2f5 --- /dev/null +++ b/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs @@ -0,0 +1,54 @@ + +using System.Text.Json.Serialization; + +namespace Bit.Billing.Models; + +public class OnyxAnswerWithCitationRequestModel +{ + [JsonPropertyName("messages")] + public List Messages { get; set; } + + [JsonPropertyName("persona_id")] + public int PersonaId { get; set; } = 1; + + [JsonPropertyName("prompt_id")] + public int PromptId { get; set; } = 1; + + [JsonPropertyName("retrieval_options")] + public RetrievalOptions RetrievalOptions { get; set; } + + public OnyxAnswerWithCitationRequestModel(string message) + { + message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' '); + Messages = new List() { new Message() { MessageText = message } }; + RetrievalOptions = new RetrievalOptions(); + } +} + +public class Message +{ + [JsonPropertyName("message")] + public string MessageText { get; set; } + + [JsonPropertyName("sender")] + public string Sender { get; set; } = "user"; +} + +public class RetrievalOptions +{ + [JsonPropertyName("run_search")] + public string RunSearch { get; set; } = RetrievalOptionsRunSearch.Auto; + + [JsonPropertyName("real_time")] + public bool RealTime { get; set; } = true; + + [JsonPropertyName("limit")] + public int? Limit { get; set; } = 3; +} + +public class RetrievalOptionsRunSearch +{ + public const string Always = "always"; + public const string Never = "never"; + public const string Auto = "auto"; +} diff --git a/src/Billing/Models/OnyxAnswerWithCitationResponseModel.cs b/src/Billing/Models/OnyxAnswerWithCitationResponseModel.cs new file mode 100644 index 0000000000..e85ee9a674 --- /dev/null +++ b/src/Billing/Models/OnyxAnswerWithCitationResponseModel.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace Bit.Billing.Models; + +public class OnyxAnswerWithCitationResponseModel +{ + [JsonPropertyName("answer")] + public string Answer { get; set; } + + [JsonPropertyName("rephrase")] + public string Rephrase { get; set; } + + [JsonPropertyName("citations")] + public List Citations { get; set; } + + [JsonPropertyName("llm_selected_doc_indices")] + public List LlmSelectedDocIndices { get; set; } + + [JsonPropertyName("error_msg")] + public string ErrorMsg { get; set; } +} + +public class Citation +{ + [JsonPropertyName("citation_num")] + public int CitationNum { get; set; } + + [JsonPropertyName("document_id")] + public string DocumentId { get; set; } +} diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 2d2f109e77..e9f2f53488 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Net.Http.Headers; using Bit.Billing.Services; using Bit.Billing.Services.Implementations; using Bit.Core.Billing.Extensions; @@ -34,6 +35,7 @@ public class Startup // Settings var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment); services.Configure(Configuration.GetSection("BillingSettings")); + var billingSettings = Configuration.GetSection("BillingSettings").Get(); // Stripe Billing StripeConfiguration.ApiKey = globalSettings.Stripe.ApiKey; @@ -97,6 +99,10 @@ public class Startup // Set up HttpClients services.AddHttpClient("FreshdeskApi"); + services.AddHttpClient("OnyxApi", client => + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", billingSettings.Onyx.ApiKey); + }); services.AddScoped(); services.AddScoped(); @@ -112,6 +118,10 @@ public class Startup // Jobs service Jobs.JobsHostedService.AddJobsServices(services); services.AddHostedService(); + + // Swagger + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(); } public void Configure( @@ -128,6 +138,11 @@ public class Startup if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Billing API V1"); + }); } app.UseStaticFiles(); diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index 84a67434f5..2a2864b246 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -73,6 +73,10 @@ "region": "US", "userFieldName": "cf_user", "orgFieldName": "cf_org" - } + }, + "onyx": { + "apiKey": "SECRET", + "baseUrl": "https://cloud.onyx.app/api" + } } } diff --git a/test/Billing.Test/Controllers/FreshdeskControllerTests.cs b/test/Billing.Test/Controllers/FreshdeskControllerTests.cs index f07c64dad9..26ce310b9c 100644 --- a/test/Billing.Test/Controllers/FreshdeskControllerTests.cs +++ b/test/Billing.Test/Controllers/FreshdeskControllerTests.cs @@ -1,4 +1,5 @@ -using Bit.Billing.Controllers; +using System.Text.Json; +using Bit.Billing.Controllers; using Bit.Billing.Models; using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; @@ -70,6 +71,159 @@ public class FreshdeskControllerTests _ = mockHttpMessageHandler.Received(1).Send(Arg.Is(m => m.Method == HttpMethod.Post && m.RequestUri.ToString().EndsWith($"{model.TicketId}/notes")), Arg.Any()); } + [Theory] + [BitAutoData((string)null, null)] + [BitAutoData((string)null)] + [BitAutoData(WebhookKey, null)] + public async Task PostWebhookOnyxAi_InvalidWebhookKey_results_in_BadRequest( + string freshdeskWebhookKey, FreshdeskWebhookModel model, + BillingSettings billingSettings, SutProvider sutProvider) + { + sutProvider.GetDependency>() + .Value.FreshDesk.WebhookKey.Returns(billingSettings.FreshDesk.WebhookKey); + + var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model); + + var statusCodeResult = Assert.IsAssignableFrom(response); + Assert.Equal(StatusCodes.Status400BadRequest, statusCodeResult.StatusCode); + } + + [Theory] + [BitAutoData(WebhookKey)] + public async Task PostWebhookOnyxAi_invalid_ticketid_results_in_BadRequest( + string freshdeskWebhookKey, FreshdeskWebhookModel model, SutProvider sutProvider) + { + sutProvider.GetDependency>() + .Value.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey); + + var mockHttpMessageHandler = Substitute.ForPartsOf(); + var mockResponse = new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest); + mockHttpMessageHandler.Send(Arg.Any(), Arg.Any()) + .Returns(mockResponse); + var httpClient = new HttpClient(mockHttpMessageHandler); + + sutProvider.GetDependency().CreateClient("FreshdeskApi").Returns(httpClient); + + var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model); + + var result = Assert.IsAssignableFrom(response); + Assert.Equal(StatusCodes.Status400BadRequest, result.StatusCode); + } + + [Theory] + [BitAutoData(WebhookKey)] + public async Task PostWebhookOnyxAi_invalid_freshdesk_response_results_in_BadRequest( + string freshdeskWebhookKey, FreshdeskWebhookModel model, + SutProvider sutProvider) + { + sutProvider.GetDependency>() + .Value.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey); + + var mockHttpMessageHandler = Substitute.ForPartsOf(); + var mockResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent("non json content. expect json deserializer to throw error") + }; + mockHttpMessageHandler.Send(Arg.Any(), Arg.Any()) + .Returns(mockResponse); + var httpClient = new HttpClient(mockHttpMessageHandler); + + sutProvider.GetDependency().CreateClient("FreshdeskApi").Returns(httpClient); + + var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model); + + var result = Assert.IsAssignableFrom(response); + Assert.Equal(StatusCodes.Status400BadRequest, result.StatusCode); + } + + [Theory] + [BitAutoData(WebhookKey)] + public async Task PostWebhookOnyxAi_invalid_onyx_response_results_in_BadRequest( + string freshdeskWebhookKey, FreshdeskWebhookModel model, + FreshdeskViewTicketModel freshdeskTicketInfo, SutProvider sutProvider) + { + var billingSettings = sutProvider.GetDependency>().Value; + billingSettings.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey); + billingSettings.Onyx.BaseUrl.Returns("http://simulate-onyx-api.com/api"); + + // mocking freshdesk Api request for ticket info + var mockFreshdeskHttpMessageHandler = Substitute.ForPartsOf(); + var mockFreshdeskResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(freshdeskTicketInfo)) + }; + mockFreshdeskHttpMessageHandler.Send(Arg.Any(), Arg.Any()) + .Returns(mockFreshdeskResponse); + var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler); + + // mocking Onyx api response given a ticket description + var mockOnyxHttpMessageHandler = Substitute.ForPartsOf(); + var mockOnyxResponse = new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest); + mockOnyxHttpMessageHandler.Send(Arg.Any(), Arg.Any()) + .Returns(mockOnyxResponse); + var onyxHttpClient = new HttpClient(mockOnyxHttpMessageHandler); + + sutProvider.GetDependency().CreateClient("FreshdeskApi").Returns(freshdeskHttpClient); + sutProvider.GetDependency().CreateClient("OnyxApi").Returns(onyxHttpClient); + + var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model); + + var result = Assert.IsAssignableFrom(response); + Assert.Equal(StatusCodes.Status400BadRequest, result.StatusCode); + } + + [Theory] + [BitAutoData(WebhookKey)] + public async Task PostWebhookOnyxAi_success( + string freshdeskWebhookKey, FreshdeskWebhookModel model, + FreshdeskViewTicketModel freshdeskTicketInfo, + OnyxAnswerWithCitationResponseModel onyxResponse, + SutProvider sutProvider) + { + var billingSettings = sutProvider.GetDependency>().Value; + billingSettings.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey); + billingSettings.Onyx.BaseUrl.Returns("http://simulate-onyx-api.com/api"); + + // mocking freshdesk Api request for ticket info (GET) + var mockFreshdeskHttpMessageHandler = Substitute.ForPartsOf(); + var mockFreshdeskResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(freshdeskTicketInfo)) + }; + mockFreshdeskHttpMessageHandler.Send( + Arg.Is(_ => _.Method == HttpMethod.Get), + Arg.Any()) + .Returns(mockFreshdeskResponse); + + // mocking freshdesk api add note request (POST) + var mockFreshdeskAddNoteResponse = new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest); + mockFreshdeskHttpMessageHandler.Send( + Arg.Is(_ => _.Method == HttpMethod.Post), + Arg.Any()) + .Returns(mockFreshdeskAddNoteResponse); + var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler); + + + // mocking Onyx api response given a ticket description + var mockOnyxHttpMessageHandler = Substitute.ForPartsOf(); + onyxResponse.ErrorMsg = string.Empty; + var mockOnyxResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(onyxResponse)) + }; + mockOnyxHttpMessageHandler.Send(Arg.Any(), Arg.Any()) + .Returns(mockOnyxResponse); + var onyxHttpClient = new HttpClient(mockOnyxHttpMessageHandler); + + sutProvider.GetDependency().CreateClient("FreshdeskApi").Returns(freshdeskHttpClient); + sutProvider.GetDependency().CreateClient("OnyxApi").Returns(onyxHttpClient); + + var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model); + + var result = Assert.IsAssignableFrom(response); + Assert.Equal(StatusCodes.Status200OK, result.StatusCode); + } + public class MockHttpMessageHandler : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)