diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index 18c627c5de..d6eb2b4411 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index 5609879eeb..32630e4a4a 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -34,6 +34,10 @@ public class BillingSettings 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 diff --git a/src/Billing/Controllers/FreshdeskController.cs b/src/Billing/Controllers/FreshdeskController.cs index a854d2d49f..66d4f47d92 100644 --- a/src/Billing/Controllers/FreshdeskController.cs +++ b/src/Billing/Controllers/FreshdeskController.cs @@ -11,6 +11,7 @@ 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; @@ -184,6 +185,52 @@ public class FreshdeskController : Controller 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 onyxRequestModel = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx.PersonaId); + 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)) + { + _logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ", + JsonSerializer.Serialize(model), + JsonSerializer.Serialize(onyxRequestModel), + JsonSerializer.Serialize(onyxJsonResponse)); + + return Ok(); // return ok so we don't retry + } + + // add the reply to the ticket + await AddReplyToTicketAsync(onyxJsonResponse.Answer, model.TicketId); + + return Ok(); + } + private bool IsValidRequestFromFreshdesk(string key) { if (string.IsNullOrWhiteSpace(key) @@ -238,6 +285,53 @@ public class FreshdeskController : Controller } } + 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 diff --git a/src/Billing/Models/FreshdeskReplyRequestModel.cs b/src/Billing/Models/FreshdeskReplyRequestModel.cs new file mode 100644 index 0000000000..3927039769 --- /dev/null +++ b/src/Billing/Models/FreshdeskReplyRequestModel.cs @@ -0,0 +1,9 @@ +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/appsettings.json b/src/Billing/appsettings.json index aae25dde0b..0074b5aafe 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -72,7 +72,10 @@ "webhookKey": "SECRET", "region": "US", "userFieldName": "cf_user", - "orgFieldName": "cf_org" + "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",