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*(?[a-zA-Z0-9_.]+)\\s*\\}\\}\"", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex TokenRegex = new( "\\{\\{\\s*(?[a-zA-Z0-9_.]+)\\s*\\}\\}", RegexOptions.Compiled | RegexOptions.CultureInvariant); private readonly ISystemSettingsService _systemSettingsService; private readonly ILogger _logger; public WebhookNotificationService( ISystemSettingsService systemSettingsService, ILogger 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 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 SendInternalAsync( SystemSettingsDto settings, Dictionary payload, IReadOnlyDictionary 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 payload, IReadOnlyDictionary 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 BuildDefaultPayload( string eventType, string summary, string? detail, string source, LiveRoom? liveRoom, RecordTask? recordTask, DailyReviewReportDto? report) { var payload = new Dictionary { ["appName"] = AppName, ["eventType"] = eventType, ["sentAtUtc"] = DateTimeOffset.UtcNow.ToString("O"), ["summary"] = summary, ["detail"] = detail, ["source"] = source, ["liveRoom"] = liveRoom is null ? null : new Dictionary { ["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 { ["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 { ["date"] = report.Date, ["summary"] = new Dictionary { ["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 BuildTemplateVariables( IReadOnlyDictionary payload, DailyReviewReportDto? report) { var variables = new Dictionary(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 liveRoom) { foreach (var pair in liveRoom) { variables[$"liveRoom.{pair.Key}"] = pair.Value; } } if (payload.TryGetValue("recordTask", out var recordTaskPayload) && recordTaskPayload is IReadOnlyDictionary 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]}..."; } }