Files
live_recorder/src/LiveRecorder.Infrastructure/Services/WebhookNotificationService.cs
T

450 lines
15 KiB
C#

using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.Reports;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Entities;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed class WebhookNotificationService : IWebhookNotificationService
{
private const string AppName = "LiveRecorder";
private static readonly Regex WholeValueTemplateRegex = new(
"\"\\{\\{\\s*(?<name>[a-zA-Z0-9_.]+)\\s*\\}\\}\"",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex TokenRegex = new(
"\\{\\{\\s*(?<name>[a-zA-Z0-9_.]+)\\s*\\}\\}",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly ISystemSettingsService _systemSettingsService;
private readonly ILogger<WebhookNotificationService> _logger;
public WebhookNotificationService(
ISystemSettingsService systemSettingsService,
ILogger<WebhookNotificationService> logger)
{
_systemSettingsService = systemSettingsService;
_logger = logger;
}
public async Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
if (!settings.EnableWebhookNotification || !settings.NotifyWebhookOnLiveStarted)
{
return;
}
var payload = BuildDefaultPayload(
"live_started",
summary: $"Live started: {liveRoom.AnchorName ?? liveRoom.RoomId}",
detail: liveRoom.Title,
source: "LiveRoomStatus",
liveRoom: liveRoom,
recordTask: null,
report: null);
var variables = BuildTemplateVariables(payload, report: null);
await SendInternalAsync(
settings,
payload,
variables,
cancellationToken,
swallowErrors: true);
}
public async Task SendExceptionAsync(
string source,
string summary,
string? detail = null,
LiveRoom? liveRoom = null,
RecordTask? recordTask = null,
CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
if (!settings.EnableWebhookNotification || !settings.NotifyWebhookOnException)
{
return;
}
var payload = BuildDefaultPayload(
"exception",
summary,
detail,
source,
liveRoom,
recordTask,
report: null);
var variables = BuildTemplateVariables(payload, report: null);
await SendInternalAsync(
settings,
payload,
variables,
cancellationToken,
swallowErrors: true);
}
public async Task SendDailyReviewAsync(
DailyReviewReportDto report,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(report);
var settings = await _systemSettingsService.GetAsync(cancellationToken);
if (!settings.EnableWebhookNotification)
{
return;
}
var payload = BuildDefaultPayload(
"daily_review",
$"Daily review {report.Date}",
$"rooms={report.Summary.ActiveLiveRoomCount}; sessions={report.Summary.SessionCount}; segments={report.Summary.SegmentCount}; danmaku={report.Summary.TotalDanmakuCount}",
"DailyReview",
liveRoom: null,
recordTask: null,
report);
var variables = BuildTemplateVariables(payload, report);
await SendInternalAsync(
settings,
payload,
variables,
cancellationToken,
swallowErrors: false);
}
public async Task<WebhookTestResultDto> SendTestAsync(
SendTestWebhookRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var settings = new SystemSettingsDto
{
EnableWebhookNotification = true,
NotifyWebhookOnLiveStarted = true,
NotifyWebhookOnException = true,
WebhookUrl = request.WebhookUrl.Trim(),
WebhookHeaders = request.WebhookHeaders,
WebhookBodyTemplate = request.WebhookBodyTemplate,
WebhookTimeoutSeconds = request.WebhookTimeoutSeconds
};
var sampleLiveRoom = new LiveRoom(
Domain.Enums.LivePlatformType.Douyin,
"https://live.douyin.com/123456789",
"123456789",
"https://live.douyin.com/123456789",
DateTimeOffset.UtcNow);
sampleLiveRoom.UpdateMetadata(
title: "Sample Live Title",
anchorName: "Sample Anchor",
anchorId: "anchor-123",
avatarUrl: null,
coverUrl: null,
updatedAt: DateTimeOffset.UtcNow);
var payload = BuildDefaultPayload(
"test",
"Webhook test event",
"This is a test payload generated from the current settings form values.",
"SettingsTest",
sampleLiveRoom,
recordTask: null,
report: null);
var variables = BuildTemplateVariables(payload, report: null);
try
{
var detail = await SendInternalAsync(
settings,
payload,
variables,
cancellationToken,
swallowErrors: false);
return new WebhookTestResultDto
{
Success = true,
Message = "Webhook test sent successfully.",
Detail = detail
};
}
catch (Exception ex)
{
return new WebhookTestResultDto
{
Success = false,
Message = $"Webhook test failed: {ex.Message}",
Detail = ex.InnerException?.Message
};
}
}
private async Task<string> SendInternalAsync(
SystemSettingsDto settings,
Dictionary<string, object?> payload,
IReadOnlyDictionary<string, object?> variables,
CancellationToken cancellationToken,
bool swallowErrors)
{
if (string.IsNullOrWhiteSpace(settings.WebhookUrl))
{
if (swallowErrors)
{
return "Webhook URL is empty.";
}
throw new InvalidOperationException("Webhook URL is empty.");
}
try
{
var body = BuildRequestBody(settings.WebhookBodyTemplate, payload, variables);
using var httpClient = CreateHttpClient(settings.WebhookTimeoutSeconds);
using var request = new HttpRequestMessage(HttpMethod.Post, settings.WebhookUrl.Trim())
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
ApplyHeaders(request.Headers, settings.WebhookHeaders);
using var response = await httpClient.SendAsync(request, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"Webhook returned {(int)response.StatusCode} {response.ReasonPhrase}. {Truncate(responseBody, 600)}");
}
return $"status={(int)response.StatusCode}; body={Truncate(responseBody, 600)}";
}
catch (Exception ex)
{
if (swallowErrors)
{
_logger.LogWarning(ex, "Webhook send failed");
return ex.Message;
}
throw;
}
}
private static HttpClient CreateHttpClient(int timeoutSeconds)
{
var client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, 1, 120));
return client;
}
private static void ApplyHeaders(HttpRequestHeaders headers, string rawHeaders)
{
if (string.IsNullOrWhiteSpace(rawHeaders))
{
return;
}
var lines = rawHeaders.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var line in lines)
{
var separatorIndex = line.IndexOf(':');
if (separatorIndex <= 0)
{
continue;
}
var name = line[..separatorIndex].Trim();
var value = line[(separatorIndex + 1)..].Trim();
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
headers.TryAddWithoutValidation(name, value);
}
}
private static string BuildRequestBody(
string? template,
IReadOnlyDictionary<string, object?> payload,
IReadOnlyDictionary<string, object?> variables)
{
if (string.IsNullOrWhiteSpace(template))
{
return JsonSerializer.Serialize(payload);
}
var rendered = WholeValueTemplateRegex.Replace(
template,
match =>
{
var name = match.Groups["name"].Value;
variables.TryGetValue(name, out var value);
return JsonSerializer.Serialize(value);
});
rendered = TokenRegex.Replace(
rendered,
match =>
{
var name = match.Groups["name"].Value;
variables.TryGetValue(name, out var value);
return EscapeTemplateStringValue(value);
});
try
{
using var jsonDocument = JsonDocument.Parse(rendered);
return jsonDocument.RootElement.GetRawText();
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Webhook body template must produce valid JSON. {ex.Message}", ex);
}
}
private static string EscapeTemplateStringValue(object? value)
{
if (value is null)
{
return string.Empty;
}
if (value is string text)
{
return JsonEncodedText.Encode(text).ToString();
}
if (value is DateTimeOffset dateTimeOffset)
{
return JsonEncodedText.Encode(dateTimeOffset.ToString("O")).ToString();
}
return JsonEncodedText.Encode(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty).ToString();
}
private static Dictionary<string, object?> BuildDefaultPayload(
string eventType,
string summary,
string? detail,
string source,
LiveRoom? liveRoom,
RecordTask? recordTask,
DailyReviewReportDto? report)
{
var payload = new Dictionary<string, object?>
{
["appName"] = AppName,
["eventType"] = eventType,
["sentAtUtc"] = DateTimeOffset.UtcNow.ToString("O"),
["summary"] = summary,
["detail"] = detail,
["source"] = source,
["liveRoom"] = liveRoom is null ? null : new Dictionary<string, object?>
{
["id"] = liveRoom.Id,
["platform"] = liveRoom.Platform.ToString(),
["roomId"] = liveRoom.RoomId,
["title"] = liveRoom.Title,
["anchorName"] = liveRoom.AnchorName,
["sourceUrl"] = liveRoom.SourceUrl
},
["recordTask"] = recordTask is null ? null : new Dictionary<string, object?>
{
["id"] = recordTask.Id,
["recordSessionId"] = recordTask.RecordSessionId,
["status"] = recordTask.Status.ToString(),
["segmentIndex"] = recordTask.SegmentIndex,
["outputFilePath"] = recordTask.OutputFilePath
}
};
if (report is not null)
{
payload["report"] = new Dictionary<string, object?>
{
["date"] = report.Date,
["summary"] = new Dictionary<string, object?>
{
["activeLiveRoomCount"] = report.Summary.ActiveLiveRoomCount,
["sessionCount"] = report.Summary.SessionCount,
["segmentCount"] = report.Summary.SegmentCount,
["totalDurationSeconds"] = report.Summary.TotalDurationSeconds,
["warningCount"] = report.Summary.WarningCount,
["errorCount"] = report.Summary.ErrorCount,
["totalDanmakuCount"] = report.Summary.TotalDanmakuCount
}
};
}
return payload;
}
private static IReadOnlyDictionary<string, object?> BuildTemplateVariables(
IReadOnlyDictionary<string, object?> payload,
DailyReviewReportDto? report)
{
var variables = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["appName"] = payload["appName"],
["eventType"] = payload["eventType"],
["sentAtUtc"] = payload["sentAtUtc"],
["summary"] = payload["summary"],
["detail"] = payload["detail"],
["source"] = payload["source"]
};
if (payload.TryGetValue("liveRoom", out var liveRoomPayload) &&
liveRoomPayload is IReadOnlyDictionary<string, object?> liveRoom)
{
foreach (var pair in liveRoom)
{
variables[$"liveRoom.{pair.Key}"] = pair.Value;
}
}
if (payload.TryGetValue("recordTask", out var recordTaskPayload) &&
recordTaskPayload is IReadOnlyDictionary<string, object?> recordTask)
{
foreach (var pair in recordTask)
{
variables[$"recordTask.{pair.Key}"] = pair.Value;
}
}
if (report is not null)
{
variables["report.date"] = report.Date;
variables["report.summary.activeLiveRoomCount"] = report.Summary.ActiveLiveRoomCount;
variables["report.summary.sessionCount"] = report.Summary.SessionCount;
variables["report.summary.segmentCount"] = report.Summary.SegmentCount;
variables["report.summary.totalDurationSeconds"] = report.Summary.TotalDurationSeconds;
variables["report.summary.warningCount"] = report.Summary.WarningCount;
variables["report.summary.errorCount"] = report.Summary.ErrorCount;
variables["report.summary.totalDanmakuCount"] = report.Summary.TotalDanmakuCount;
}
return variables;
}
private static string Truncate(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim();
return trimmed.Length <= maxLength ? trimmed : $"{trimmed[..maxLength]}...";
}
}