1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

[PM-23761] Auto-reply to tickets in Freskdesk with help from Onyx AI (#6315)

This commit is contained in:
Vijay Oommen
2025-09-15 08:22:39 -05:00
committed by GitHub
parent b4a0555a72
commit b249c4e4d7
5 changed files with 112 additions and 1 deletions

View File

@@ -11,6 +11,7 @@
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MarkDig" Version="0.41.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.2" />
</ItemGroup>

View File

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

View File

@@ -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<IActionResult> 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<OnyxAnswerWithCitationResponseModel>(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<HttpResponseMessage> CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0)
{
try

View File

@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Bit.Billing.Models;
public class FreshdeskReplyRequestModel
{
[JsonPropertyName("body")]
public required string Body { get; set; }
}

View File

@@ -72,7 +72,10 @@
"webhookKey": "SECRET",
"region": "US",
"userFieldName": "cf_user",
"orgFieldName": "cf_org"
"orgFieldName": "cf_org",
"removeNewlinesInReplies": true,
"autoReplyGreeting": "<b>Greetings,</b><br /><br />Thank you for contacting Bitwarden. The reply below was generated by our AI agent based on your message:<br /><br />",
"autoReplySalutation": "<br /><br />If this response doesnt fully address your question, simply reply to this email and a member of our Customer Success team will be happy to assist you further.<br /><p><b>Best Regards,</b><br />The Bitwarden Customer Success Team</p>"
},
"onyx": {
"apiKey": "SECRET",