diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index 64a52ed290..2830f603ac 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -9,10 +9,7 @@ public class BillingSettings public virtual string StripeWebhookKey { get; set; } public virtual string StripeWebhookSecret20250827Basil { get; set; } public virtual string AppleWebhookKey { get; set; } - 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 { @@ -21,35 +18,4 @@ public class BillingSettings public virtual string WebhookKey { get; set; } } - public class FreshDeskSettings - { - public virtual string ApiKey { get; set; } - public virtual string WebhookKey { get; set; } - /// - /// Indicates the data center region. Valid values are "US" and "EU" - /// - public virtual string Region { get; set; } - public virtual string UserFieldName { get; set; } - public virtual string OrgFieldName { get; set; } - - public virtual bool RemoveNewlinesInReplies { get; set; } = false; - public virtual string AutoReplyGreeting { get; set; } = string.Empty; - public virtual string AutoReplySalutation { get; set; } = string.Empty; - } - - public class OnyxSettings - { - public virtual string ApiKey { get; set; } - public virtual string BaseUrl { get; set; } - public virtual string Path { get; set; } - public virtual int PersonaId { get; set; } - public virtual bool UseAnswerWithCitationModels { get; set; } = true; - - public virtual SearchSettings SearchSettings { get; set; } = new SearchSettings(); - } - public class SearchSettings - { - public virtual string RunSearch { get; set; } = "auto"; // "always", "never", "auto" - public virtual bool RealTime { get; set; } = true; - } } diff --git a/src/Billing/Controllers/FreshdeskController.cs b/src/Billing/Controllers/FreshdeskController.cs deleted file mode 100644 index 38ed05cfdf..0000000000 --- a/src/Billing/Controllers/FreshdeskController.cs +++ /dev/null @@ -1,395 +0,0 @@ -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; -using Bit.Core.Settings; -using Bit.Core.Utilities; -using Markdig; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; - -namespace Bit.Billing.Controllers; - -[Route("freshdesk")] -public class FreshdeskController : Controller -{ - private readonly BillingSettings _billingSettings; - private readonly IUserRepository _userRepository; - private readonly IOrganizationRepository _organizationRepository; - private readonly ILogger _logger; - private readonly GlobalSettings _globalSettings; - private readonly IHttpClientFactory _httpClientFactory; - - public FreshdeskController( - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOptions billingSettings, - ILogger logger, - GlobalSettings globalSettings, - IHttpClientFactory httpClientFactory) - { - _billingSettings = billingSettings?.Value ?? throw new ArgumentNullException(nameof(billingSettings)); - _userRepository = userRepository; - _organizationRepository = organizationRepository; - _logger = logger; - _globalSettings = globalSettings; - _httpClientFactory = httpClientFactory; - } - - [HttpPost("webhook")] - public async Task PostWebhook([FromQuery, Required] string key, - [FromBody, Required] FreshdeskWebhookModel model) - { - if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey)) - { - return new BadRequestResult(); - } - - try - { - var ticketId = model.TicketId; - var ticketContactEmail = model.TicketContactEmail; - var ticketTags = model.TicketTags; - if (string.IsNullOrWhiteSpace(ticketId) || string.IsNullOrWhiteSpace(ticketContactEmail)) - { - return new BadRequestResult(); - } - - var updateBody = new Dictionary(); - var note = string.Empty; - note += $"
  • Region: {_billingSettings.FreshDesk.Region}
  • "; - var customFields = new Dictionary(); - var user = await _userRepository.GetByEmailAsync(ticketContactEmail); - if (user == null) - { - note += $"
  • No user found: {ticketContactEmail}
  • "; - await CreateNote(ticketId, note); - } - - if (user != null) - { - var userLink = $"{_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}"; - note += $"
  • User, {user.Email}: {userLink}
  • "; - customFields.Add(_billingSettings.FreshDesk.UserFieldName, userLink); - var tags = new HashSet(); - if (user.Premium) - { - tags.Add("Premium"); - } - var orgs = await _organizationRepository.GetManyByUserIdAsync(user.Id); - - foreach (var org in orgs) - { - // Prevent org names from injecting any additional HTML - var orgName = HttpUtility.HtmlEncode(org.Name); - var orgNote = $"{orgName} ({org.Seats.GetValueOrDefault()}): " + - $"{_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}"; - note += $"
  • Org, {orgNote}
  • "; - if (!customFields.Any(kvp => kvp.Key == _billingSettings.FreshDesk.OrgFieldName)) - { - customFields.Add(_billingSettings.FreshDesk.OrgFieldName, orgNote); - } - else - { - customFields[_billingSettings.FreshDesk.OrgFieldName] += $"\n{orgNote}"; - } - - var displayAttribute = GetAttribute(org.PlanType); - var planName = displayAttribute?.Name?.Split(" ").FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(planName)) - { - tags.Add(string.Format("Org: {0}", planName)); - } - } - if (tags.Any()) - { - var tagsToUpdate = tags.ToList(); - if (!string.IsNullOrWhiteSpace(ticketTags)) - { - var splitTicketTags = ticketTags.Split(','); - for (var i = 0; i < splitTicketTags.Length; i++) - { - tagsToUpdate.Insert(i, splitTicketTags[i]); - } - } - updateBody.Add("tags", tagsToUpdate); - } - - if (customFields.Any()) - { - updateBody.Add("custom_fields", customFields); - } - var updateRequest = new HttpRequestMessage(HttpMethod.Put, - string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}", ticketId)) - { - Content = JsonContent.Create(updateBody), - }; - await CallFreshdeskApiAsync(updateRequest); - await CreateNote(ticketId, note); - } - - return new OkResult(); - } - catch (Exception e) - { - _logger.LogError(e, "Error processing freshdesk webhook."); - return new BadRequestResult(); - } - } - - [HttpPost("webhook-onyx-ai")] - public async Task PostWebhookOnyxAi([FromQuery, Required] string key, - [FromBody, Required] FreshdeskOnyxAiWebhookModel model) - { - // ensure that the key is from Freshdesk - if (!IsValidRequestFromFreshdesk(key)) - { - return new BadRequestResult(); - } - - // if there is no description, then we don't send anything to onyx - if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim())) - { - return Ok(); - } - - // Get response from Onyx AI - var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model); - - // the CallOnyxApi will return a null if we have an error response - if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg)) - { - _logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ", - JsonSerializer.Serialize(model), - JsonSerializer.Serialize(onyxRequest), - JsonSerializer.Serialize(onyxResponse)); - - return Ok(); // return ok so we don't retry - } - - // add the answer as a note to the ticket - await AddAnswerNoteToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId); - - return Ok(); - } - - [HttpPost("webhook-onyx-ai-reply")] - public async Task PostWebhookOnyxAiReply([FromQuery, Required] string key, - [FromBody, Required] FreshdeskOnyxAiWebhookModel model) - { - // NOTE: - // at this time, this endpoint is a duplicate of `webhook-onyx-ai` - // eventually, we will merge both endpoints into one webhook for Freshdesk - - // ensure that the key is from Freshdesk - if (!IsValidRequestFromFreshdesk(key) || !ModelState.IsValid) - { - return new BadRequestResult(); - } - - // if there is no description, then we don't send anything to onyx - if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim())) - { - return Ok(); - } - - // create the onyx `answer-with-citation` request - var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model); - - // the CallOnyxApi will return a null if we have an error response - if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg)) - { - _logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ", - JsonSerializer.Serialize(model), - JsonSerializer.Serialize(onyxRequest), - JsonSerializer.Serialize(onyxResponse)); - - return Ok(); // return ok so we don't retry - } - - // add the reply to the ticket - await AddReplyToTicketAsync(onyxResponse?.Answer ?? string.Empty, 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 CreateNote(string ticketId, string note) - { - var noteBody = new Dictionary - { - { "body", $"
      {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), - }; - await CallFreshdeskApiAsync(noteRequest); - } - - 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 AddReplyToTicketAsync(string note, string ticketId) - { - // if there is no content, then we don't need to add a note - if (string.IsNullOrWhiteSpace(note)) - { - return; - } - - // convert note from markdown to html - var htmlNote = note; - try - { - var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); - htmlNote = Markdig.Markdown.ToHtml(note, pipeline); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error converting markdown to HTML for Freshdesk reply. Ticket Id: {0}. Note: {1}", - ticketId, note); - htmlNote = note; // fallback to the original note - } - - // clear out any new lines that Freshdesk doesn't like - if (_billingSettings.FreshDesk.RemoveNewlinesInReplies) - { - htmlNote = htmlNote.Replace(Environment.NewLine, string.Empty); - } - - var replyBody = new FreshdeskReplyRequestModel - { - Body = $"{_billingSettings.FreshDesk.AutoReplyGreeting}{htmlNote}{_billingSettings.FreshDesk.AutoReplySalutation}", - }; - - var replyRequest = new HttpRequestMessage(HttpMethod.Post, - string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/reply", ticketId)) - { - Content = JsonContent.Create(replyBody), - }; - - var addReplyResponse = await CallFreshdeskApiAsync(replyRequest); - if (addReplyResponse.StatusCode != System.Net.HttpStatusCode.Created) - { - _logger.LogError("Error adding reply to Freshdesk ticket. Ticket Id: {0}. Status: {1}", - ticketId, addReplyResponse.ToString()); - } - } - - private async Task CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0) - { - try - { - var freshdeskAuthkey = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_billingSettings.FreshDesk.ApiKey}:X")); - var httpClient = _httpClientFactory.CreateClient("FreshdeskApi"); - request.Headers.Add("Authorization", $"Basic {freshdeskAuthkey}"); - var response = await httpClient.SendAsync(request); - if (response.StatusCode != System.Net.HttpStatusCode.TooManyRequests || retriedCount > 3) - { - return response; - } - } - catch - { - if (retriedCount > 3) - { - throw; - } - } - await Task.Delay(30000 * (retriedCount + 1)); - return await CallFreshdeskApiAsync(request, retriedCount++); - } - - async Task<(OnyxRequestModel onyxRequest, OnyxResponseModel onyxResponse)> GetAnswerFromOnyx(FreshdeskOnyxAiWebhookModel model) - { - // TODO: remove the use of the deprecated answer-with-citation models after we are sure - if (_billingSettings.Onyx.UseAnswerWithCitationModels) - { - var onyxRequest = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx); - var onyxAnswerWithCitationRequest = new HttpRequestMessage(HttpMethod.Post, - string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl)) - { - Content = JsonContent.Create(onyxRequest, mediaType: new MediaTypeHeaderValue("application/json")), - }; - var onyxResponse = await CallOnyxApi(onyxAnswerWithCitationRequest); - return (onyxRequest, onyxResponse); - } - - var request = new OnyxSendMessageSimpleApiRequestModel(model.TicketDescriptionText, _billingSettings.Onyx); - var onyxSimpleRequest = new HttpRequestMessage(HttpMethod.Post, - string.Format("{0}{1}", _billingSettings.Onyx.BaseUrl, _billingSettings.Onyx.Path)) - { - Content = JsonContent.Create(request, mediaType: new MediaTypeHeaderValue("application/json")), - }; - var onyxSimpleResponse = await CallOnyxApi(onyxSimpleRequest); - return (request, onyxSimpleResponse); - } - - private async Task CallOnyxApi(HttpRequestMessage request) where T : class, new() - { - 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 new T(); - } - var responseStr = await response.Content.ReadAsStringAsync(); - var responseJson = JsonSerializer.Deserialize(responseStr, options: new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - }); - - return responseJson ?? new T(); - } - - private TAttribute? GetAttribute(Enum enumValue) where TAttribute : Attribute - { - var memberInfo = enumValue.GetType().GetMember(enumValue.ToString()).FirstOrDefault(); - return memberInfo != null ? memberInfo.GetCustomAttribute() : null; - } -} diff --git a/src/Billing/Controllers/FreshsalesController.cs b/src/Billing/Controllers/FreshsalesController.cs deleted file mode 100644 index 68382fbd5d..0000000000 --- a/src/Billing/Controllers/FreshsalesController.cs +++ /dev/null @@ -1,248 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Net.Http.Headers; -using System.Text.Json.Serialization; -using Bit.Core.Billing.Enums; -using Bit.Core.Repositories; -using Bit.Core.Settings; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; - -namespace Bit.Billing.Controllers; - -[Route("freshsales")] -public class FreshsalesController : Controller -{ - private readonly IUserRepository _userRepository; - private readonly IOrganizationRepository _organizationRepository; - private readonly ILogger _logger; - private readonly GlobalSettings _globalSettings; - - private readonly string _freshsalesApiKey; - - private readonly HttpClient _httpClient; - - public FreshsalesController(IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOptions billingSettings, - ILogger logger, - GlobalSettings globalSettings) - { - _userRepository = userRepository; - _organizationRepository = organizationRepository; - _logger = logger; - _globalSettings = globalSettings; - - _httpClient = new HttpClient - { - BaseAddress = new Uri("https://bitwarden.freshsales.io/api/") - }; - - _freshsalesApiKey = billingSettings.Value.FreshsalesApiKey; - - _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Token", - $"token={_freshsalesApiKey}"); - } - - - [HttpPost("webhook")] - public async Task PostWebhook([FromHeader(Name = "Authorization")] string key, - [FromBody] CustomWebhookRequestModel request, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(_freshsalesApiKey, key)) - { - return Unauthorized(); - } - - try - { - var leadResponse = await _httpClient.GetFromJsonAsync>( - $"leads/{request.LeadId}", - cancellationToken); - - var lead = leadResponse.Lead; - - var primaryEmail = lead.Emails - .Where(e => e.IsPrimary) - .FirstOrDefault(); - - if (primaryEmail == null) - { - return BadRequest(new { Message = "Lead has not primary email." }); - } - - var user = await _userRepository.GetByEmailAsync(primaryEmail.Value); - - if (user == null) - { - return NoContent(); - } - - var newTags = new HashSet(); - - if (user.Premium) - { - newTags.Add("Premium"); - } - - var noteItems = new List - { - $"User, {user.Email}: {_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}" - }; - - var orgs = await _organizationRepository.GetManyByUserIdAsync(user.Id); - - foreach (var org in orgs) - { - noteItems.Add($"Org, {org.DisplayName()}: {_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}"); - if (TryGetPlanName(org.PlanType, out var planName)) - { - newTags.Add($"Org: {planName}"); - } - } - - if (newTags.Any()) - { - var allTags = newTags.Concat(lead.Tags); - var updateLeadResponse = await _httpClient.PutAsJsonAsync( - $"leads/{request.LeadId}", - CreateWrapper(new { tags = allTags }), - cancellationToken); - updateLeadResponse.EnsureSuccessStatusCode(); - } - - var createNoteResponse = await _httpClient.PostAsJsonAsync( - "notes", - CreateNoteRequestModel(request.LeadId, string.Join('\n', noteItems)), cancellationToken); - createNoteResponse.EnsureSuccessStatusCode(); - return NoContent(); - } - catch (Exception ex) - { - Console.WriteLine(ex); - _logger.LogError(ex, "Error processing freshsales webhook"); - return BadRequest(new { ex.Message }); - } - } - - private static LeadWrapper CreateWrapper(T lead) - { - return new LeadWrapper - { - Lead = lead, - }; - } - - private static CreateNoteRequestModel CreateNoteRequestModel(long leadId, string content) - { - return new CreateNoteRequestModel - { - Note = new EditNoteModel - { - Description = content, - TargetableType = "Lead", - TargetableId = leadId, - }, - }; - } - - private static bool TryGetPlanName(PlanType planType, out string planName) - { - switch (planType) - { - case PlanType.Free: - planName = "Free"; - return true; - case PlanType.FamiliesAnnually: - case PlanType.FamiliesAnnually2025: - case PlanType.FamiliesAnnually2019: - planName = "Families"; - return true; - case PlanType.TeamsAnnually: - case PlanType.TeamsAnnually2023: - case PlanType.TeamsAnnually2020: - case PlanType.TeamsAnnually2019: - case PlanType.TeamsMonthly: - case PlanType.TeamsMonthly2023: - case PlanType.TeamsMonthly2020: - case PlanType.TeamsMonthly2019: - case PlanType.TeamsStarter: - case PlanType.TeamsStarter2023: - planName = "Teams"; - return true; - case PlanType.EnterpriseAnnually: - case PlanType.EnterpriseAnnually2023: - case PlanType.EnterpriseAnnually2020: - case PlanType.EnterpriseAnnually2019: - case PlanType.EnterpriseMonthly: - case PlanType.EnterpriseMonthly2023: - case PlanType.EnterpriseMonthly2020: - case PlanType.EnterpriseMonthly2019: - planName = "Enterprise"; - return true; - case PlanType.Custom: - planName = "Custom"; - return true; - default: - planName = null; - return false; - } - } -} - -public class CustomWebhookRequestModel -{ - [JsonPropertyName("leadId")] - public long LeadId { get; set; } -} - -public class LeadWrapper -{ - [JsonPropertyName("lead")] - public T Lead { get; set; } - - public static LeadWrapper Create(TItem lead) - { - return new LeadWrapper - { - Lead = lead, - }; - } -} - -public class FreshsalesLeadModel -{ - public string[] Tags { get; set; } - public FreshsalesEmailModel[] Emails { get; set; } -} - -public class FreshsalesEmailModel -{ - [JsonPropertyName("value")] - public string Value { get; set; } - - [JsonPropertyName("is_primary")] - public bool IsPrimary { get; set; } -} - -public class CreateNoteRequestModel -{ - [JsonPropertyName("note")] - public EditNoteModel Note { get; set; } -} - -public class EditNoteModel -{ - [JsonPropertyName("description")] - public string Description { get; set; } - - [JsonPropertyName("targetable_type")] - public string TargetableType { get; set; } - - [JsonPropertyName("targetable_id")] - public long TargetableId { get; set; } -} diff --git a/src/Billing/Models/FreshdeskReplyRequestModel.cs b/src/Billing/Models/FreshdeskReplyRequestModel.cs deleted file mode 100644 index 3927039769..0000000000 --- a/src/Billing/Models/FreshdeskReplyRequestModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Bit.Billing.Models; - -public class FreshdeskReplyRequestModel -{ - [JsonPropertyName("body")] - public required string Body { get; set; } -} diff --git a/src/Billing/Models/FreshdeskWebhookModel.cs b/src/Billing/Models/FreshdeskWebhookModel.cs deleted file mode 100644 index aac0e9339d..0000000000 --- a/src/Billing/Models/FreshdeskWebhookModel.cs +++ /dev/null @@ -1,24 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Text.Json.Serialization; - -namespace Bit.Billing.Models; - -public class FreshdeskWebhookModel -{ - [JsonPropertyName("ticket_id")] - public string TicketId { get; set; } - - [JsonPropertyName("ticket_contact_email")] - public string TicketContactEmail { get; set; } - - [JsonPropertyName("ticket_tags")] - public string TicketTags { get; set; } -} - -public class FreshdeskOnyxAiWebhookModel : FreshdeskWebhookModel -{ - [JsonPropertyName("ticket_description_text")] - public string TicketDescriptionText { get; set; } -} diff --git a/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs b/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs deleted file mode 100644 index 9a753be4bc..0000000000 --- a/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Text.Json.Serialization; -using static Bit.Billing.BillingSettings; - -namespace Bit.Billing.Models; - -public class OnyxRequestModel -{ - [JsonPropertyName("persona_id")] - public int PersonaId { get; set; } = 1; - - [JsonPropertyName("retrieval_options")] - public RetrievalOptions RetrievalOptions { get; set; } = new RetrievalOptions(); - - public OnyxRequestModel(OnyxSettings onyxSettings) - { - PersonaId = onyxSettings.PersonaId; - RetrievalOptions.RunSearch = onyxSettings.SearchSettings.RunSearch; - RetrievalOptions.RealTime = onyxSettings.SearchSettings.RealTime; - } -} - -/// -/// This is used with the onyx endpoint /query/answer-with-citation -/// which has been deprecated. This can be removed once later -/// -public class OnyxAnswerWithCitationRequestModel : OnyxRequestModel -{ - [JsonPropertyName("messages")] - public List Messages { get; set; } = new List(); - - public OnyxAnswerWithCitationRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings) - { - message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' '); - Messages = new List() { new Message() { MessageText = message } }; - } -} - -/// -/// This is used with the onyx endpoint /chat/send-message-simple-api -/// -public class OnyxSendMessageSimpleApiRequestModel : OnyxRequestModel -{ - [JsonPropertyName("message")] - public string Message { get; set; } = string.Empty; - - public OnyxSendMessageSimpleApiRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings) - { - Message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' '); - } -} - -public class Message -{ - [JsonPropertyName("message")] - public string MessageText { get; set; } = string.Empty; - - [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; -} - -public class RetrievalOptionsRunSearch -{ - public const string Always = "always"; - public const string Never = "never"; - public const string Auto = "auto"; -} diff --git a/src/Billing/Models/OnyxResponseModel.cs b/src/Billing/Models/OnyxResponseModel.cs deleted file mode 100644 index 96fa134c40..0000000000 --- a/src/Billing/Models/OnyxResponseModel.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Bit.Billing.Models; - -public class OnyxResponseModel -{ - [JsonPropertyName("answer")] - public string Answer { get; set; } = string.Empty; - - [JsonPropertyName("answer_citationless")] - public string AnswerCitationless { get; set; } = string.Empty; - - [JsonPropertyName("error_msg")] - public string ErrorMsg { get; set; } = string.Empty; -} diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 1343dc0895..30f4f5f562 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -2,7 +2,6 @@ #nullable disable using System.Globalization; -using System.Net.Http.Headers; using Bit.Billing.Services; using Bit.Billing.Services.Implementations; using Bit.Commercial.Core.Utilities; @@ -98,13 +97,6 @@ public class Startup // Authentication services.AddAuthentication(); - // Set up HttpClients - services.AddHttpClient("FreshdeskApi"); - services.AddHttpClient("OnyxApi", client => - { - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", billingSettings.Onyx.ApiKey); - }); - services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Billing/appsettings.Development.json b/src/Billing/appsettings.Development.json index fe8e47b2f6..77057fde7f 100644 --- a/src/Billing/appsettings.Development.json +++ b/src/Billing/appsettings.Development.json @@ -32,10 +32,5 @@ "connectionString": "UseDevelopmentStorage=true" } }, - "billingSettings": { - "onyx": { - "personaId": 68 - } - }, "pricingUri": "https://billingpricing.qa.bitwarden.pw" } diff --git a/src/Billing/appsettings.Production.json b/src/Billing/appsettings.Production.json index 4be5d51a52..819986181f 100644 --- a/src/Billing/appsettings.Production.json +++ b/src/Billing/appsettings.Production.json @@ -26,10 +26,7 @@ "payPal": { "production": true, "businessId": "4ZDA7DLUUJGMN" - }, - "onyx": { - "personaId": 7 - } + } }, "Logging": { "IncludeScopes": false, diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index aa14f1d377..7093b6a923 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -61,27 +61,6 @@ "production": false, "businessId": "AD3LAUZSNVPJY", "webhookKey": "SECRET" - }, - "freshdesk": { - "apiKey": "SECRET", - "webhookKey": "SECRET", - "region": "US", - "userFieldName": "cf_user", - "orgFieldName": "cf_org", - "removeNewlinesInReplies": true, - "autoReplyGreeting": "Greetings,

    Thank you for contacting Bitwarden. The reply below was generated by our AI agent based on your message:

    ", - "autoReplySalutation": "

    If this response doesn’t fully address your question, simply reply to this email and a member of our Customer Success team will be happy to assist you further.

    Best Regards,
    The Bitwarden Customer Success Team

    " - }, - "onyx": { - "apiKey": "SECRET", - "baseUrl": "https://cloud.onyx.app/api", - "path": "/chat/send-message-simple-api", - "useAnswerWithCitationModels": true, - "personaId": 7, - "searchSettings": { - "runSearch": "always", - "realTime": true - } } } } diff --git a/test/Billing.Test/Controllers/FreshdeskControllerTests.cs b/test/Billing.Test/Controllers/FreshdeskControllerTests.cs deleted file mode 100644 index 5c9199d29a..0000000000 --- a/test/Billing.Test/Controllers/FreshdeskControllerTests.cs +++ /dev/null @@ -1,251 +0,0 @@ -using System.Text.Json; -using Bit.Billing.Controllers; -using Bit.Billing.Models; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Entities; -using Bit.Core.Repositories; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using NSubstitute; -using NSubstitute.ReceivedExtensions; -using Xunit; - -namespace Bit.Billing.Test.Controllers; - -[ControllerCustomize(typeof(FreshdeskController))] -[SutProviderCustomize] -public class FreshdeskControllerTests -{ - private const string ApiKey = "TESTFRESHDESKAPIKEY"; - private const string WebhookKey = "TESTKEY"; - - private const string UserFieldName = "cf_user"; - private const string OrgFieldName = "cf_org"; - - [Theory] - [BitAutoData((string)null, null)] - [BitAutoData((string)null)] - [BitAutoData(WebhookKey, null)] - public async Task PostWebhook_NullRequiredParameters_BadRequest(string freshdeskWebhookKey, FreshdeskWebhookModel model, - BillingSettings billingSettings, SutProvider sutProvider) - { - sutProvider.GetDependency>().Value.FreshDesk.WebhookKey.Returns(billingSettings.FreshDesk.WebhookKey); - - var response = await sutProvider.Sut.PostWebhook(freshdeskWebhookKey, model); - - var statusCodeResult = Assert.IsAssignableFrom(response); - Assert.Equal(StatusCodes.Status400BadRequest, statusCodeResult.StatusCode); - } - - [Theory] - [BitAutoData] - public async Task PostWebhook_Success(User user, FreshdeskWebhookModel model, - List organizations, SutProvider sutProvider) - { - model.TicketContactEmail = user.Email; - - sutProvider.GetDependency().GetByEmailAsync(user.Email).Returns(user); - sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(organizations); - - var mockHttpMessageHandler = Substitute.ForPartsOf(); - var mockResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK); - mockHttpMessageHandler.Send(Arg.Any(), Arg.Any()) - .Returns(mockResponse); - var httpClient = new HttpClient(mockHttpMessageHandler); - - sutProvider.GetDependency().CreateClient("FreshdeskApi").Returns(httpClient); - - sutProvider.GetDependency>().Value.FreshDesk.WebhookKey.Returns(WebhookKey); - sutProvider.GetDependency>().Value.FreshDesk.ApiKey.Returns(ApiKey); - sutProvider.GetDependency>().Value.FreshDesk.UserFieldName.Returns(UserFieldName); - sutProvider.GetDependency>().Value.FreshDesk.OrgFieldName.Returns(OrgFieldName); - - var response = await sutProvider.Sut.PostWebhook(WebhookKey, model); - - var statusCodeResult = Assert.IsAssignableFrom(response); - Assert.Equal(StatusCodes.Status200OK, statusCodeResult.StatusCode); - - _ = mockHttpMessageHandler.Received(1).Send(Arg.Is(m => m.Method == HttpMethod.Put && m.RequestUri.ToString().EndsWith(model.TicketId)), Arg.Any()); - _ = mockHttpMessageHandler.Received(1).Send(Arg.Is(m => m.Method == HttpMethod.Post && m.RequestUri.ToString().EndsWith($"{model.TicketId}/notes")), Arg.Any()); - } - - [Theory] - [BitAutoData(WebhookKey)] - public async Task PostWebhook_add_note_when_user_is_invalid( - string freshdeskWebhookKey, FreshdeskWebhookModel model, - SutProvider sutProvider) - { - // Arrange - for an invalid user - model.TicketContactEmail = "invalid@user"; - sutProvider.GetDependency().GetByEmailAsync(model.TicketContactEmail).Returns((User)null); - sutProvider.GetDependency>().Value.FreshDesk.WebhookKey.Returns(WebhookKey); - - var mockHttpMessageHandler = Substitute.ForPartsOf(); - var mockResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK); - mockHttpMessageHandler.Send(Arg.Any(), Arg.Any()) - .Returns(mockResponse); - var httpClient = new HttpClient(mockHttpMessageHandler); - sutProvider.GetDependency().CreateClient("FreshdeskApi").Returns(httpClient); - - // Act - var response = await sutProvider.Sut.PostWebhook(freshdeskWebhookKey, model); - - // Assert - var statusCodeResult = Assert.IsAssignableFrom(response); - Assert.Equal(StatusCodes.Status200OK, statusCodeResult.StatusCode); - - await mockHttpMessageHandler - .Received(1).Send( - Arg.Is( - m => m.Method == HttpMethod.Post - && m.RequestUri.ToString().EndsWith($"{model.TicketId}/notes") - && m.Content.ReadAsStringAsync().Result.Contains("No user found")), - Arg.Any()); - } - - - [Theory] - [BitAutoData((string)null, null)] - [BitAutoData((string)null)] - [BitAutoData(WebhookKey, null)] - public async Task PostWebhookOnyxAi_InvalidWebhookKey_results_in_BadRequest( - string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel 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_onyx_response_results_is_logged( - string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model, - 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 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 statusCodeResult = Assert.IsAssignableFrom(response); - Assert.Equal(StatusCodes.Status200OK, statusCodeResult.StatusCode); - - var _logger = sutProvider.GetDependency>(); - - // workaround because _logger.Received(1).LogWarning(...) does not work - _logger.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Log" && c.GetArguments()[1].ToString().Contains("Error getting answer from Onyx AI")); - - // sent call to Onyx API - but we got an error response - _ = mockOnyxHttpMessageHandler.Received(1).Send(Arg.Any(), Arg.Any()); - // did not call freshdesk to add a note since onyx failed - _ = mockFreshdeskHttpMessageHandler.DidNotReceive().Send(Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData(WebhookKey)] - public async Task PostWebhookOnyxAi_success( - string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model, - OnyxResponseModel 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 add note request (POST) - var mockFreshdeskHttpMessageHandler = Substitute.ForPartsOf(); - 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); - } - - [Theory] - [BitAutoData(WebhookKey)] - public async Task PostWebhookOnyxAi_ticket_description_is_empty_return_success( - string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model, - SutProvider sutProvider) - { - var billingSettings = sutProvider.GetDependency>().Value; - billingSettings.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey); - billingSettings.Onyx.BaseUrl.Returns("http://simulate-onyx-api.com/api"); - - model.TicketDescriptionText = " "; // empty description - - // mocking freshdesk api add note request (POST) - var mockFreshdeskHttpMessageHandler = Substitute.ForPartsOf(); - var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler); - - // mocking Onyx api response given a ticket description - var mockOnyxHttpMessageHandler = Substitute.ForPartsOf(); - 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); - _ = mockFreshdeskHttpMessageHandler.DidNotReceive().Send(Arg.Any(), Arg.Any()); - _ = mockOnyxHttpMessageHandler.DidNotReceive().Send(Arg.Any(), Arg.Any()); - } - - public class MockHttpMessageHandler : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - return Send(request, cancellationToken); - } - - public new virtual Task Send(HttpRequestMessage request, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - } -} diff --git a/test/Billing.Test/Controllers/FreshsalesControllerTests.cs b/test/Billing.Test/Controllers/FreshsalesControllerTests.cs deleted file mode 100644 index c9ae6efb1a..0000000000 --- a/test/Billing.Test/Controllers/FreshsalesControllerTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -using Bit.Billing.Controllers; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Entities; -using Bit.Core.Repositories; -using Bit.Core.Settings; -using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using NSubstitute; -using Xunit; - -namespace Bit.Billing.Test.Controllers; - -public class FreshsalesControllerTests -{ - private const string ApiKey = "TEST_FRESHSALES_APIKEY"; - private const string TestLead = "TEST_FRESHSALES_TESTLEAD"; - - private static (FreshsalesController, IUserRepository, IOrganizationRepository) CreateSut( - string freshsalesApiKey) - { - var userRepository = Substitute.For(); - var organizationRepository = Substitute.For(); - - var billingSettings = Options.Create(new BillingSettings - { - FreshsalesApiKey = freshsalesApiKey, - }); - var globalSettings = new GlobalSettings(); - globalSettings.BaseServiceUri.Admin = "https://test.com"; - - var sut = new FreshsalesController( - userRepository, - organizationRepository, - billingSettings, - Substitute.For>(), - globalSettings - ); - - return (sut, userRepository, organizationRepository); - } - - [RequiredEnvironmentTheory(ApiKey, TestLead), EnvironmentData(ApiKey, TestLead)] - public async Task PostWebhook_Success(string freshsalesApiKey, long leadId) - { - // This test is only for development to use: - // `export TEST_FRESHSALES_APIKEY=[apikey]` - // `export TEST_FRESHSALES_TESTLEAD=[lead id]` - // `dotnet test --filter "FullyQualifiedName~FreshsalesControllerTests.PostWebhook_Success"` - var (sut, userRepository, organizationRepository) = CreateSut(freshsalesApiKey); - - var user = new User - { - Id = Guid.NewGuid(), - Email = "test@email.com", - Premium = true, - }; - - userRepository.GetByEmailAsync(user.Email) - .Returns(user); - - organizationRepository.GetManyByUserIdAsync(user.Id) - .Returns(new List - { - new Organization - { - Id = Guid.NewGuid(), - Name = "Test Org", - } - }); - - var response = await sut.PostWebhook(freshsalesApiKey, new CustomWebhookRequestModel - { - LeadId = leadId, - }, new CancellationToken(false)); - - var statusCodeResult = Assert.IsAssignableFrom(response); - Assert.Equal(StatusCodes.Status204NoContent, statusCodeResult.StatusCode); - } -}