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:" },
+ { "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)