450 lines
15 KiB
C#
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]}...";
|
|
}
|
|
}
|