mirror of
https://github.com/bitwarden/server.git
synced 2026-01-31 06:03:12 +08:00
[PM-23761] Auto-reply to tickets in Freskdesk with help from Onyx AI (#6315)
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
<ProjectReference Include="..\Core\Core.csproj" />
|
<ProjectReference Include="..\Core\Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MarkDig" Version="0.41.3" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ public class BillingSettings
|
|||||||
public virtual string Region { get; set; }
|
public virtual string Region { get; set; }
|
||||||
public virtual string UserFieldName { get; set; }
|
public virtual string UserFieldName { get; set; }
|
||||||
public virtual string OrgFieldName { 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 class OnyxSettings
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using Bit.Billing.Models;
|
|||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
using Markdig;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
@@ -184,6 +185,52 @@ public class FreshdeskController : Controller
|
|||||||
return Ok();
|
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)
|
private bool IsValidRequestFromFreshdesk(string key)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(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)
|
private async Task<HttpResponseMessage> CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
9
src/Billing/Models/FreshdeskReplyRequestModel.cs
Normal file
9
src/Billing/Models/FreshdeskReplyRequestModel.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -72,7 +72,10 @@
|
|||||||
"webhookKey": "SECRET",
|
"webhookKey": "SECRET",
|
||||||
"region": "US",
|
"region": "US",
|
||||||
"userFieldName": "cf_user",
|
"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 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.<br /><p><b>Best Regards,</b><br />The Bitwarden Customer Success Team</p>"
|
||||||
},
|
},
|
||||||
"onyx": {
|
"onyx": {
|
||||||
"apiKey": "SECRET",
|
"apiKey": "SECRET",
|
||||||
|
|||||||
Reference in New Issue
Block a user