1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-04 20:50:21 -05:00

PM-6939 - Onyx Integration into freshdesk controller (#5365)

This commit is contained in:
Vijay Oommen 2025-02-06 08:13:17 -06:00 committed by GitHub
parent a12b61cc9e
commit 17f5c97891
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 451 additions and 2 deletions

View File

@ -10,5 +10,8 @@
<ProjectReference Include="..\SharedWeb\SharedWeb.csproj" />
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
</ItemGroup>
</Project>

View File

@ -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; }
}
}

View File

@ -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;

View File

@ -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<IActionResult> 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<OnyxAnswerWithCitationResponseModel>(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<string, object>
{
{ "body", $"<b>Onyx AI:</b><ul>{note}</ul>" },
{ "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<FreshdeskViewTicketModel> ExtractTicketInfoFromResponse(HttpResponseMessage getTicketResponse)
{
var responseString = string.Empty;
try
{
responseString = await getTicketResponse.Content.ReadAsStringAsync();
var ticketInfo = JsonSerializer.Deserialize<FreshdeskViewTicketModel>(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<HttpResponseMessage> 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<T>(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<T>(responseStr, options: new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
return (response, responseJson);
}
private TAttribute GetAttribute<TAttribute>(Enum enumValue) where TAttribute : Attribute
{
return enumValue.GetType().GetMember(enumValue.ToString()).First().GetCustomAttribute<TAttribute>();

View File

@ -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<string> Tags { get; set; }
}

View File

@ -0,0 +1,54 @@

using System.Text.Json.Serialization;
namespace Bit.Billing.Models;
public class OnyxAnswerWithCitationRequestModel
{
[JsonPropertyName("messages")]
public List<Message> 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<Message>() { 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";
}

View File

@ -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<Citation> Citations { get; set; }
[JsonPropertyName("llm_selected_doc_indices")]
public List<int> 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; }
}

View File

@ -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<BillingSettings>(Configuration.GetSection("BillingSettings"));
var billingSettings = Configuration.GetSection("BillingSettings").Get<BillingSettings>();
// 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<IStripeFacade, StripeFacade>();
services.AddScoped<IStripeEventService, StripeEventService>();
@ -112,6 +118,10 @@ public class Startup
// Jobs service
Jobs.JobsHostedService.AddJobsServices(services);
services.AddHostedService<Jobs.JobsHostedService>();
// 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();

View File

@ -73,6 +73,10 @@
"region": "US",
"userFieldName": "cf_user",
"orgFieldName": "cf_org"
}
},
"onyx": {
"apiKey": "SECRET",
"baseUrl": "https://cloud.onyx.app/api"
}
}
}

View File

@ -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<HttpRequestMessage>(m => m.Method == HttpMethod.Post && m.RequestUri.ToString().EndsWith($"{model.TicketId}/notes")), Arg.Any<CancellationToken>());
}
[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<FreshdeskController> sutProvider)
{
sutProvider.GetDependency<IOptions<BillingSettings>>()
.Value.FreshDesk.WebhookKey.Returns(billingSettings.FreshDesk.WebhookKey);
var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model);
var statusCodeResult = Assert.IsAssignableFrom<StatusCodeResult>(response);
Assert.Equal(StatusCodes.Status400BadRequest, statusCodeResult.StatusCode);
}
[Theory]
[BitAutoData(WebhookKey)]
public async Task PostWebhookOnyxAi_invalid_ticketid_results_in_BadRequest(
string freshdeskWebhookKey, FreshdeskWebhookModel model, SutProvider<FreshdeskController> sutProvider)
{
sutProvider.GetDependency<IOptions<BillingSettings>>()
.Value.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey);
var mockHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
var mockResponse = new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest);
mockHttpMessageHandler.Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>())
.Returns(mockResponse);
var httpClient = new HttpClient(mockHttpMessageHandler);
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("FreshdeskApi").Returns(httpClient);
var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model);
var result = Assert.IsAssignableFrom<BadRequestObjectResult>(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<FreshdeskController> sutProvider)
{
sutProvider.GetDependency<IOptions<BillingSettings>>()
.Value.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey);
var mockHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
var mockResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("non json content. expect json deserializer to throw error")
};
mockHttpMessageHandler.Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>())
.Returns(mockResponse);
var httpClient = new HttpClient(mockHttpMessageHandler);
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("FreshdeskApi").Returns(httpClient);
var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model);
var result = Assert.IsAssignableFrom<BadRequestObjectResult>(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<FreshdeskController> sutProvider)
{
var billingSettings = sutProvider.GetDependency<IOptions<BillingSettings>>().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<MockHttpMessageHandler>();
var mockFreshdeskResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(freshdeskTicketInfo))
};
mockFreshdeskHttpMessageHandler.Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>())
.Returns(mockFreshdeskResponse);
var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler);
// mocking Onyx api response given a ticket description
var mockOnyxHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
var mockOnyxResponse = new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest);
mockOnyxHttpMessageHandler.Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>())
.Returns(mockOnyxResponse);
var onyxHttpClient = new HttpClient(mockOnyxHttpMessageHandler);
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("FreshdeskApi").Returns(freshdeskHttpClient);
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("OnyxApi").Returns(onyxHttpClient);
var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model);
var result = Assert.IsAssignableFrom<BadRequestObjectResult>(response);
Assert.Equal(StatusCodes.Status400BadRequest, result.StatusCode);
}
[Theory]
[BitAutoData(WebhookKey)]
public async Task PostWebhookOnyxAi_success(
string freshdeskWebhookKey, FreshdeskWebhookModel model,
FreshdeskViewTicketModel freshdeskTicketInfo,
OnyxAnswerWithCitationResponseModel onyxResponse,
SutProvider<FreshdeskController> sutProvider)
{
var billingSettings = sutProvider.GetDependency<IOptions<BillingSettings>>().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<MockHttpMessageHandler>();
var mockFreshdeskResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(freshdeskTicketInfo))
};
mockFreshdeskHttpMessageHandler.Send(
Arg.Is<HttpRequestMessage>(_ => _.Method == HttpMethod.Get),
Arg.Any<CancellationToken>())
.Returns(mockFreshdeskResponse);
// mocking freshdesk api add note request (POST)
var mockFreshdeskAddNoteResponse = new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest);
mockFreshdeskHttpMessageHandler.Send(
Arg.Is<HttpRequestMessage>(_ => _.Method == HttpMethod.Post),
Arg.Any<CancellationToken>())
.Returns(mockFreshdeskAddNoteResponse);
var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler);
// mocking Onyx api response given a ticket description
var mockOnyxHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
onyxResponse.ErrorMsg = string.Empty;
var mockOnyxResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(onyxResponse))
};
mockOnyxHttpMessageHandler.Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>())
.Returns(mockOnyxResponse);
var onyxHttpClient = new HttpClient(mockOnyxHttpMessageHandler);
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("FreshdeskApi").Returns(freshdeskHttpClient);
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("OnyxApi").Returns(onyxHttpClient);
var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model);
var result = Assert.IsAssignableFrom<OkResult>(response);
Assert.Equal(StatusCodes.Status200OK, result.StatusCode);
}
public class MockHttpMessageHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)