diff --git a/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJob.cs b/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJob.cs new file mode 100644 index 000000000..e45166d41 --- /dev/null +++ b/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJob.cs @@ -0,0 +1,47 @@ +using StackExchange.Redis; + +namespace SWLOR.BackgroundServices.BackgroundJobs +{ + public sealed class BackgroundJob + { + public RedisValue Id { get; } + public string Type { get; } + public string Payload { get; } + public int Attempt { get; } + public StreamEntry Entry { get; } + + private BackgroundJob(RedisValue id, string type, string payload, int attempt, StreamEntry entry) + { + Id = id; + Type = type; + Payload = payload; + Attempt = attempt; + Entry = entry; + } + + public static bool TryCreate(StreamEntry entry, out BackgroundJob? job, out string error) + { + var values = entry.Values.ToDictionary(x => x.Name.ToString(), x => x.Value.ToString()); + var type = values.GetValueOrDefault("type"); + var payload = values.GetValueOrDefault("payload"); + + if (string.IsNullOrWhiteSpace(type) || string.IsNullOrWhiteSpace(payload)) + { + job = null; + error = "Job is missing type or payload."; + return false; + } + + job = new BackgroundJob(entry.Id, type, payload, ParseAttempt(values.GetValueOrDefault("attempt")), entry); + error = string.Empty; + return true; + } + + private static int ParseAttempt(string? value) + { + return int.TryParse(value, out var attempt) + ? Math.Max(0, attempt) + : 0; + } + } +} diff --git a/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJobFailureHandler.cs b/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJobFailureHandler.cs new file mode 100644 index 000000000..3c2fca1c5 --- /dev/null +++ b/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJobFailureHandler.cs @@ -0,0 +1,109 @@ +using StackExchange.Redis; +using SWLOR.BackgroundServices.Configuration; +using SWLOR.BackgroundServices.Infrastructure; + +namespace SWLOR.BackgroundServices.BackgroundJobs +{ + public sealed class BackgroundJobFailureHandler + { + private const string RequeueAndAcknowledgeScript = @" +redis.call('XADD', KEYS[1], 'MAXLEN', '~', ARGV[1], '*', + 'type', ARGV[4], + 'payload', ARGV[5], + 'attempt', ARGV[6], + 'createdUtc', ARGV[7], + 'lastError', ARGV[8]) +return redis.call('XACK', KEYS[1], ARGV[2], ARGV[3]) +"; + + private const string MoveToDeadLetterAndAcknowledgeScript = @" +redis.call('XADD', KEYS[1], '*', + 'originalId', ARGV[2], + 'error', ARGV[3], + 'failedUtc', ARGV[4], + unpack(ARGV, 5)) +return redis.call('XACK', KEYS[2], ARGV[1], ARGV[2]) +"; + + private readonly BackgroundServiceSettings _settings; + private readonly IAppLogger _logger; + + public BackgroundJobFailureHandler(BackgroundServiceSettings settings, IAppLogger logger) + { + _settings = settings; + _logger = logger; + } + + public async Task HandleFailureAsync(IDatabase database, BackgroundJob job, Exception exception) + { + var nextAttempt = job.Attempt + 1; + if (nextAttempt >= _settings.MaxAttempts) + { + await MoveToDeadLetterAsync(database, job.Entry, exception.ToString()); + _logger.Error($"Background job {job.Id} failed permanently after {nextAttempt} attempts: {exception.Message}"); + return; + } + + await RequeueAndAcknowledgeAsync(database, job, nextAttempt, exception.ToString()); + _logger.Error($"Background job {job.Id} failed attempt {nextAttempt}; requeued. {exception.Message}"); + } + + public async Task MoveToDeadLetterAsync(IDatabase database, StreamEntry entry, string error) + { + var arguments = new List + { + BackgroundJobQueueNames.ConsumerGroup, + entry.Id, + Truncate(error), + DateTime.UtcNow.ToString("O") + }; + + foreach (var value in entry.Values) + { + arguments.Add(value.Name); + arguments.Add(value.Value); + } + + await database.ScriptEvaluateAsync( + MoveToDeadLetterAndAcknowledgeScript, + new RedisKey[] + { + BackgroundJobQueueNames.DeadLetterStreamName, + BackgroundJobQueueNames.StreamName + }, + arguments.ToArray()); + } + + private async Task RequeueAndAcknowledgeAsync( + IDatabase database, + BackgroundJob job, + int nextAttempt, + string error) + { + await database.ScriptEvaluateAsync( + RequeueAndAcknowledgeScript, + new RedisKey[] + { + BackgroundJobQueueNames.StreamName + }, + new RedisValue[] + { + BackgroundJobQueueNames.MaxStreamLength, + BackgroundJobQueueNames.ConsumerGroup, + job.Id, + job.Type, + job.Payload, + nextAttempt.ToString(), + DateTime.UtcNow.ToString("O"), + Truncate(error) + }); + } + + private string Truncate(string value) + { + return value.Length <= _settings.MaxLogContentLength + ? value + : value.Substring(0, _settings.MaxLogContentLength) + "..."; + } + } +} diff --git a/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJobProcessor.cs b/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJobProcessor.cs new file mode 100644 index 000000000..8e7a2b8bf --- /dev/null +++ b/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJobProcessor.cs @@ -0,0 +1,64 @@ +using StackExchange.Redis; +using SWLOR.BackgroundServices.Infrastructure; + +namespace SWLOR.BackgroundServices.BackgroundJobs +{ + public sealed class BackgroundJobProcessor + { + private readonly IReadOnlyDictionary _handlers; + private readonly BackgroundJobFailureHandler _failureHandler; + private readonly IAppLogger _logger; + + public BackgroundJobProcessor( + IReadOnlyDictionary handlers, + BackgroundJobFailureHandler failureHandler, + IAppLogger logger) + { + _handlers = handlers; + _failureHandler = failureHandler; + _logger = logger; + } + + public async Task ProcessAsync(IDatabase database, StreamEntry entry, CancellationToken cancellationToken) + { + if (!BackgroundJob.TryCreate(entry, out var job, out var error)) + { + await _failureHandler.MoveToDeadLetterAsync(database, entry, error); + return; + } + + var backgroundJob = job!; + if (!_handlers.TryGetValue(backgroundJob.Type, out var handler)) + { + await _failureHandler.MoveToDeadLetterAsync( + database, + backgroundJob.Entry, + $"Unsupported background job type '{backgroundJob.Type}'."); + _logger.Error($"Unsupported background job type '{backgroundJob.Type}' for job {backgroundJob.Id}; moved to dead-letter."); + return; + } + + try + { + await handler.HandleAsync(backgroundJob.Payload, cancellationToken); + // Redis Streams provide at-least-once delivery here: if the handler succeeds + // but XACK fails, this job can be delivered again. Handlers must be idempotent + // or use their own deduplication/idempotency keys. + await database.StreamAcknowledgeAsync( + BackgroundJobQueueNames.StreamName, + BackgroundJobQueueNames.ConsumerGroup, + backgroundJob.Id); + + _logger.Info($"Processed background job {backgroundJob.Id} ({backgroundJob.Type})."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + await _failureHandler.HandleFailureAsync(database, backgroundJob, ex); + } + } + } +} diff --git a/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJobQueueNames.cs b/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJobQueueNames.cs new file mode 100644 index 000000000..9e6cbb047 --- /dev/null +++ b/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJobQueueNames.cs @@ -0,0 +1,10 @@ +namespace SWLOR.BackgroundServices.BackgroundJobs +{ + public static class BackgroundJobQueueNames + { + public const string StreamName = "swlor:background-jobs"; + public const string DeadLetterStreamName = "swlor:background-jobs:dead"; + public const string ConsumerGroup = "swlor-background-services"; + public const int MaxStreamLength = 10000; + } +} diff --git a/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJobTypes.cs b/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJobTypes.cs new file mode 100644 index 000000000..33ea21515 --- /dev/null +++ b/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJobTypes.cs @@ -0,0 +1,8 @@ +namespace SWLOR.BackgroundServices.BackgroundJobs +{ + public static class BackgroundJobTypes + { + public const string GitHubIssue = "GitHubIssue"; + public const string DiscordWebhook = "DiscordWebhook"; + } +} diff --git a/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJobWorker.cs b/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJobWorker.cs new file mode 100644 index 000000000..308857873 --- /dev/null +++ b/SWLOR.BackgroundServices/BackgroundJobs/BackgroundJobWorker.cs @@ -0,0 +1,187 @@ +using StackExchange.Redis; +using SWLOR.BackgroundServices.Configuration; +using SWLOR.BackgroundServices.Infrastructure; + +namespace SWLOR.BackgroundServices.BackgroundJobs +{ + public sealed class BackgroundJobWorker + { + private readonly BackgroundServiceSettings _settings; + private readonly BackgroundJobProcessor _processor; + private readonly IAppLogger _logger; + + public BackgroundJobWorker( + BackgroundServiceSettings settings, + BackgroundJobProcessor processor, + IAppLogger logger) + { + _settings = settings; + _processor = processor; + _logger = logger; + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + _logger.Info($"Connecting to Redis at {RedactRedisConnection(_settings.RedisConnection)}..."); + var redisOptions = ConfigurationOptions.Parse(_settings.RedisConnection); + redisOptions.AbortOnConnectFail = false; + + using var redis = await ConnectToRedis(redisOptions, cancellationToken); + var database = redis.GetDatabase(); + await EnsureConsumerGroup(database, cancellationToken); + + _logger.Info($"Background service '{_settings.ConsumerName}' listening on Redis Stream '{BackgroundJobQueueNames.StreamName}'."); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + var entries = await ReadPendingOrNew(database); + + if (entries.Length == 0) + { + await Task.Delay(_settings.EmptyReadDelay, cancellationToken); + continue; + } + + foreach (var entry in entries) + { + await _processor.ProcessAsync(database, entry, cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.Error("Worker loop failed.", ex); + await Task.Delay(_settings.FailureDelay, cancellationToken); + } + } + } + + private async Task ConnectToRedis( + ConfigurationOptions redisOptions, + CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + var redis = await ConnectionMultiplexer.ConnectAsync(redisOptions); + _logger.Info("Redis connection established."); + return redis; + } + catch (RedisConnectionException) + { + _logger.Info($"Redis is not available yet. Retrying in {_settings.FailureDelay.TotalSeconds:0.#} seconds."); + await Task.Delay(_settings.FailureDelay, cancellationToken); + } + } + + throw new OperationCanceledException(cancellationToken); + } + + private async Task EnsureConsumerGroup(IDatabase database, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await database.StreamCreateConsumerGroupAsync( + BackgroundJobQueueNames.StreamName, + BackgroundJobQueueNames.ConsumerGroup, + "0-0", + true); + return; + } + catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase)) + { + // Group already exists. + return; + } + catch (RedisConnectionException) + { + _logger.Info($"Redis is still loading. Retrying background job initialization in {_settings.FailureDelay.TotalSeconds:0.#} seconds."); + await Task.Delay(_settings.FailureDelay, cancellationToken); + } + catch (RedisTimeoutException) + { + _logger.Info($"Redis is still loading. Retrying background job initialization in {_settings.FailureDelay.TotalSeconds:0.#} seconds."); + await Task.Delay(_settings.FailureDelay, cancellationToken); + } + } + + throw new OperationCanceledException(cancellationToken); + } + + private async Task ReadPendingOrNew(IDatabase database) + { + var pending = await database.StreamReadGroupAsync( + BackgroundJobQueueNames.StreamName, + BackgroundJobQueueNames.ConsumerGroup, + _settings.ConsumerName, + "0-0", + _settings.BatchSize); + + if (pending.Length > 0) + { + return pending; + } + + return await database.StreamReadGroupAsync( + BackgroundJobQueueNames.StreamName, + BackgroundJobQueueNames.ConsumerGroup, + _settings.ConsumerName, + ">", + _settings.BatchSize); + } + + private static string RedactRedisConnection(string connection) + { + if (string.IsNullOrWhiteSpace(connection)) + { + return string.Empty; + } + + if (Uri.TryCreate(connection, UriKind.Absolute, out var uri) && + !string.IsNullOrWhiteSpace(uri.UserInfo)) + { + var schemeSeparator = connection.IndexOf("://", StringComparison.Ordinal); + var userInfoSeparator = connection.IndexOf('@', schemeSeparator + 3); + if (schemeSeparator >= 0 && userInfoSeparator > schemeSeparator) + { + return connection.Substring(0, schemeSeparator + 3) + + "[REDACTED]" + + connection.Substring(userInfoSeparator); + } + } + + var parts = connection.Split(','); + for (var index = 0; index < parts.Length; index++) + { + var part = parts[index].Trim(); + var separator = part.IndexOf('='); + if (separator <= 0) + { + parts[index] = part; + continue; + } + + var name = part.Substring(0, separator).Trim(); + if (name.Equals("password", StringComparison.OrdinalIgnoreCase) || + name.Equals("pwd", StringComparison.OrdinalIgnoreCase)) + { + parts[index] = $"{name}=[REDACTED]"; + } + else + { + parts[index] = part; + } + } + + return string.Join(",", parts); + } + } +} diff --git a/SWLOR.BackgroundServices/BackgroundJobs/Handlers/DiscordWebhookJobHandler.cs b/SWLOR.BackgroundServices/BackgroundJobs/Handlers/DiscordWebhookJobHandler.cs new file mode 100644 index 000000000..5d6aae5f3 --- /dev/null +++ b/SWLOR.BackgroundServices/BackgroundJobs/Handlers/DiscordWebhookJobHandler.cs @@ -0,0 +1,145 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SWLOR.BackgroundServices.BackgroundJobs.Models; +using SWLOR.BackgroundServices.Infrastructure; + +namespace SWLOR.BackgroundServices.BackgroundJobs.Handlers +{ + public sealed class DiscordWebhookJobHandler : IBackgroundJobHandler + { + private readonly HttpClient _httpClient; + + public DiscordWebhookJobHandler(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task HandleAsync(string payload, CancellationToken cancellationToken) + { + var job = JsonConvert.DeserializeObject(payload) + ?? throw new InvalidOperationException("Unable to deserialize Discord webhook payload."); + var threadName = ResolveThreadName(job); + var includeThreadName = string.IsNullOrWhiteSpace(job.ThreadId) && !string.IsNullOrWhiteSpace(threadName); + + for (var attempt = 1; attempt <= 5; attempt++) + { + using var request = CreateRequest(job, includeThreadName ? threadName : string.Empty); + using var response = await _httpClient.SendAsync(request, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + if (response.IsSuccessStatusCode) + { + return; + } + + if (includeThreadName && IsThreadCreationUnsupported(responseBody)) + { + includeThreadName = false; + continue; + } + + if ((int)response.StatusCode == 429 && attempt < 5) + { + await Task.Delay(GetRetryAfter(responseBody), cancellationToken); + continue; + } + + throw new InvalidOperationException($"Discord webhook failed: {(int)response.StatusCode} {response.StatusCode}. {responseBody}"); + } + + throw new InvalidOperationException("Discord webhook failed after all retry attempts."); + } + + private static HttpRequestMessage CreateRequest(DiscordWebhookPayload job, string threadName) + { + return new HttpRequestMessage(HttpMethod.Post, BuildWebhookUrl(job)) + { + Content = JsonHttpContent.Create(new + { + thread_name = string.IsNullOrWhiteSpace(threadName) + ? null + : threadName, + embeds = new[] + { + new + { + author = new + { + name = job.AuthorName + }, + title = string.IsNullOrWhiteSpace(job.Title) + ? null + : job.Title, + description = job.Description, + color = job.Color + } + } + }) + }; + } + + private static string BuildWebhookUrl(DiscordWebhookPayload job) + { + if (string.IsNullOrWhiteSpace(job.ThreadId)) + { + return job.WebhookUrl; + } + + return AddQueryString(job.WebhookUrl, "thread_id", job.ThreadId); + } + + private static string AddQueryString(string url, string name, string value) + { + var separator = url.Contains('?') + ? "&" + : "?"; + + return $"{url}{separator}{name}={Uri.EscapeDataString(value)}"; + } + + private static string ResolveThreadName(DiscordWebhookPayload job) + { + if (!job.CreateThread) + { + return string.Empty; + } + + if (!string.IsNullOrWhiteSpace(job.ThreadName)) + { + return job.ThreadName; + } + + if (!string.IsNullOrWhiteSpace(job.Title)) + { + return job.Title; + } + + return string.Empty; + } + + private static bool IsThreadCreationUnsupported(string responseBody) + { + try + { + return JObject.Parse(responseBody)["code"]?.Value() == 220003; + } + catch (JsonException) + { + return false; + } + } + + private static TimeSpan GetRetryAfter(string responseBody) + { + try + { + var retryAfter = JObject.Parse(responseBody)["retry_after"]?.Value() ?? 1.0; + return TimeSpan.FromMilliseconds(retryAfter * 1000 + 100); + } + catch (JsonException) + { + return TimeSpan.FromSeconds(1); + } + } + } +} diff --git a/SWLOR.BackgroundServices/BackgroundJobs/Handlers/GitHubIssueJobHandler.cs b/SWLOR.BackgroundServices/BackgroundJobs/Handlers/GitHubIssueJobHandler.cs new file mode 100644 index 000000000..c00a92bd1 --- /dev/null +++ b/SWLOR.BackgroundServices/BackgroundJobs/Handlers/GitHubIssueJobHandler.cs @@ -0,0 +1,61 @@ +using System.Net.Http.Headers; +using Newtonsoft.Json; +using SWLOR.BackgroundServices.BackgroundJobs.Models; +using SWLOR.BackgroundServices.Configuration; +using SWLOR.BackgroundServices.Infrastructure; + +namespace SWLOR.BackgroundServices.BackgroundJobs.Handlers +{ + public sealed class GitHubIssueJobHandler : IBackgroundJobHandler + { + private const string CodexReviewPrompt = "@codex review this issue and provide more details around what you find"; + private readonly HttpClient _httpClient; + private readonly BackgroundServiceSettings _settings; + + public GitHubIssueJobHandler(HttpClient httpClient, BackgroundServiceSettings settings) + { + _httpClient = httpClient; + _settings = settings; + } + + public async Task HandleAsync(string payload, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_settings.GitHubToken)) + { + throw new InvalidOperationException("SWLOR_BUG_GITHUB_TOKEN is not configured."); + } + + var job = JsonConvert.DeserializeObject(payload) + ?? throw new InvalidOperationException("Unable to deserialize GitHub issue payload."); + + var endpoint = $"https://api.github.com/repos/{job.Repository}/issues"; + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = JsonHttpContent.Create(new + { + title = job.Title, + body = BuildBody(job.Body) + }) + }; + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _settings.GitHubToken); + request.Headers.UserAgent.ParseAdd("SWLOR-BackgroundServices"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + throw new InvalidOperationException($"GitHub issue creation failed: {(int)response.StatusCode} {response.StatusCode}. {responseBody}"); + } + } + + private string BuildBody(string body) + { + return _settings.CodexReviewEnabled + ? $"{CodexReviewPrompt}\n\n{body}" + : body; + } + } +} diff --git a/SWLOR.BackgroundServices/BackgroundJobs/IBackgroundJobHandler.cs b/SWLOR.BackgroundServices/BackgroundJobs/IBackgroundJobHandler.cs new file mode 100644 index 000000000..2ac97b0c6 --- /dev/null +++ b/SWLOR.BackgroundServices/BackgroundJobs/IBackgroundJobHandler.cs @@ -0,0 +1,7 @@ +namespace SWLOR.BackgroundServices.BackgroundJobs +{ + public interface IBackgroundJobHandler + { + Task HandleAsync(string payload, CancellationToken cancellationToken); + } +} diff --git a/SWLOR.BackgroundServices/BackgroundJobs/Models/DiscordWebhookPayload.cs b/SWLOR.BackgroundServices/BackgroundJobs/Models/DiscordWebhookPayload.cs new file mode 100644 index 000000000..d6e715f93 --- /dev/null +++ b/SWLOR.BackgroundServices/BackgroundJobs/Models/DiscordWebhookPayload.cs @@ -0,0 +1,31 @@ +using Newtonsoft.Json; + +namespace SWLOR.BackgroundServices.BackgroundJobs.Models +{ + public sealed class DiscordWebhookPayload + { + [JsonProperty("webhookUrl")] + public string WebhookUrl { get; set; } = string.Empty; + + [JsonProperty("authorName")] + public string AuthorName { get; set; } = string.Empty; + + [JsonProperty("title")] + public string Title { get; set; } = string.Empty; + + [JsonProperty("description")] + public string Description { get; set; } = string.Empty; + + [JsonProperty("color")] + public int Color { get; set; } + + [JsonProperty("threadId")] + public string ThreadId { get; set; } = string.Empty; + + [JsonProperty("createThread")] + public bool CreateThread { get; set; } + + [JsonProperty("threadName")] + public string ThreadName { get; set; } = string.Empty; + } +} diff --git a/SWLOR.BackgroundServices/BackgroundJobs/Models/GitHubIssuePayload.cs b/SWLOR.BackgroundServices/BackgroundJobs/Models/GitHubIssuePayload.cs new file mode 100644 index 000000000..6d2287164 --- /dev/null +++ b/SWLOR.BackgroundServices/BackgroundJobs/Models/GitHubIssuePayload.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace SWLOR.BackgroundServices.BackgroundJobs.Models +{ + public sealed class GitHubIssuePayload + { + [JsonProperty("repository")] + public string Repository { get; set; } = string.Empty; + + [JsonProperty("title")] + public string Title { get; set; } = string.Empty; + + [JsonProperty("body")] + public string Body { get; set; } = string.Empty; + } +} diff --git a/SWLOR.BackgroundServices/Configuration/BackgroundServiceSettings.cs b/SWLOR.BackgroundServices/Configuration/BackgroundServiceSettings.cs new file mode 100644 index 000000000..df773bf17 --- /dev/null +++ b/SWLOR.BackgroundServices/Configuration/BackgroundServiceSettings.cs @@ -0,0 +1,84 @@ +namespace SWLOR.BackgroundServices.Configuration +{ + public sealed class BackgroundServiceSettings + { + public string RedisConnection { get; } + public string ConsumerName { get; } + public string GitHubToken { get; } + public bool CodexReviewEnabled { get; } + public TimeSpan HttpTimeout { get; } + public TimeSpan EmptyReadDelay { get; } + public TimeSpan FailureDelay { get; } + public int BatchSize { get; } + public int MaxAttempts { get; } + public int MaxLogContentLength { get; } + + private BackgroundServiceSettings( + string redisConnection, + string consumerName, + string githubToken, + bool codexReviewEnabled, + TimeSpan httpTimeout, + TimeSpan emptyReadDelay, + TimeSpan failureDelay, + int batchSize, + int maxAttempts, + int maxLogContentLength) + { + RedisConnection = redisConnection; + ConsumerName = consumerName; + GitHubToken = githubToken; + CodexReviewEnabled = codexReviewEnabled; + HttpTimeout = httpTimeout; + EmptyReadDelay = emptyReadDelay; + FailureDelay = failureDelay; + BatchSize = batchSize; + MaxAttempts = maxAttempts; + MaxLogContentLength = maxLogContentLength; + } + + public static BackgroundServiceSettings FromEnvironment() + { + return new BackgroundServiceSettings( + GetString("NWNX_REDIS_HOST", "redis:6379"), + GetString("SWLOR_BACKGROUND_SERVICE_NAME", "worker-1"), + Environment.GetEnvironmentVariable("SWLOR_BUG_GITHUB_TOKEN") ?? string.Empty, + GetBool("SWLOR_BUG_CODEX_REVIEW_ENABLED", false), + TimeSpan.FromSeconds(GetInt("SWLOR_BACKGROUND_HTTP_TIMEOUT_SECONDS", 60)), + TimeSpan.FromSeconds(GetInt("SWLOR_BACKGROUND_EMPTY_READ_DELAY_SECONDS", 1)), + TimeSpan.FromSeconds(GetInt("SWLOR_BACKGROUND_FAILURE_DELAY_SECONDS", 5)), + GetInt("SWLOR_BACKGROUND_BATCH_SIZE", 10), + GetInt("SWLOR_BACKGROUND_MAX_ATTEMPTS", 5), + GetInt("SWLOR_BACKGROUND_MAX_LOG_CONTENT_LENGTH", 2000)); + } + + private static string GetString(string name, string defaultValue) + { + var value = Environment.GetEnvironmentVariable(name); + return string.IsNullOrWhiteSpace(value) + ? defaultValue + : value; + } + + private static int GetInt(string name, int defaultValue) + { + var value = Environment.GetEnvironmentVariable(name); + return int.TryParse(value, out var parsed) && parsed > 0 + ? parsed + : defaultValue; + } + + private static bool GetBool(string name, bool defaultValue) + { + var value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + return value.Equals("true", StringComparison.OrdinalIgnoreCase) || + value.Equals("1", StringComparison.OrdinalIgnoreCase) || + value.Equals("yes", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/SWLOR.BackgroundServices/Dockerfile b/SWLOR.BackgroundServices/Dockerfile new file mode 100644 index 000000000..2598a980c --- /dev/null +++ b/SWLOR.BackgroundServices/Dockerfile @@ -0,0 +1,17 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY SWLOR.BackgroundServices/SWLOR.BackgroundServices.csproj SWLOR.BackgroundServices/ +RUN dotnet restore SWLOR.BackgroundServices/SWLOR.BackgroundServices.csproj + +COPY SWLOR.BackgroundServices/ SWLOR.BackgroundServices/ +RUN dotnet publish SWLOR.BackgroundServices/SWLOR.BackgroundServices.csproj -c Release -o /app/publish --no-restore + +FROM mcr.microsoft.com/dotnet/runtime:8.0 +RUN groupadd --gid 10001 appgroup \ + && useradd --uid 10001 --gid appgroup --create-home --shell /usr/sbin/nologin appuser +WORKDIR /app +COPY --from=build /app/publish . +RUN chown -R appuser:appgroup /app +USER appuser +ENTRYPOINT ["dotnet", "SWLOR.BackgroundServices.dll"] diff --git a/SWLOR.BackgroundServices/Infrastructure/ConsoleAppLogger.cs b/SWLOR.BackgroundServices/Infrastructure/ConsoleAppLogger.cs new file mode 100644 index 000000000..39ceebb8e --- /dev/null +++ b/SWLOR.BackgroundServices/Infrastructure/ConsoleAppLogger.cs @@ -0,0 +1,22 @@ +using Serilog; + +namespace SWLOR.BackgroundServices.Infrastructure +{ + public sealed class ConsoleAppLogger : IAppLogger + { + public void Info(string message) + { + Log.Information("{Message}", message); + } + + public void Error(string message) + { + Log.Error("{Message}", message); + } + + public void Error(string message, Exception exception) + { + Log.Error(exception, "{Message}", message); + } + } +} diff --git a/SWLOR.BackgroundServices/Infrastructure/IAppLogger.cs b/SWLOR.BackgroundServices/Infrastructure/IAppLogger.cs new file mode 100644 index 000000000..a85d54178 --- /dev/null +++ b/SWLOR.BackgroundServices/Infrastructure/IAppLogger.cs @@ -0,0 +1,9 @@ +namespace SWLOR.BackgroundServices.Infrastructure +{ + public interface IAppLogger + { + void Info(string message); + void Error(string message); + void Error(string message, Exception exception); + } +} diff --git a/SWLOR.BackgroundServices/Infrastructure/JsonHttpContent.cs b/SWLOR.BackgroundServices/Infrastructure/JsonHttpContent.cs new file mode 100644 index 000000000..d1f54de64 --- /dev/null +++ b/SWLOR.BackgroundServices/Infrastructure/JsonHttpContent.cs @@ -0,0 +1,13 @@ +using System.Text; +using Newtonsoft.Json; + +namespace SWLOR.BackgroundServices.Infrastructure +{ + public static class JsonHttpContent + { + public static StringContent Create(object value) + { + return new StringContent(JsonConvert.SerializeObject(value), Encoding.UTF8, "application/json"); + } + } +} diff --git a/SWLOR.BackgroundServices/Program.cs b/SWLOR.BackgroundServices/Program.cs new file mode 100644 index 000000000..41be72825 --- /dev/null +++ b/SWLOR.BackgroundServices/Program.cs @@ -0,0 +1,44 @@ +using Serilog; +using SWLOR.BackgroundServices.Configuration; +using SWLOR.BackgroundServices.BackgroundJobs; +using SWLOR.BackgroundServices.BackgroundJobs.Handlers; +using SWLOR.BackgroundServices.Infrastructure; + +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateLogger(); + +var settings = BackgroundServiceSettings.FromEnvironment(); +var logger = new ConsoleAppLogger(); +var httpClient = new HttpClient +{ + Timeout = settings.HttpTimeout +}; + +var handlers = new Dictionary(StringComparer.OrdinalIgnoreCase) +{ + [BackgroundJobTypes.GitHubIssue] = new GitHubIssueJobHandler(httpClient, settings), + [BackgroundJobTypes.DiscordWebhook] = new DiscordWebhookJobHandler(httpClient) +}; + +var processor = new BackgroundJobProcessor(handlers, new BackgroundJobFailureHandler(settings, logger), logger); +var worker = new BackgroundJobWorker(settings, processor, logger); + +using var shutdown = new CancellationTokenSource(); + +Console.CancelKeyPress += (_, eventArgs) => +{ + eventArgs.Cancel = true; + shutdown.Cancel(); +}; + +AppDomain.CurrentDomain.ProcessExit += (_, _) => shutdown.Cancel(); + +try +{ + await worker.RunAsync(shutdown.Token); +} +finally +{ + Log.CloseAndFlush(); +} diff --git a/SWLOR.BackgroundServices/SWLOR.BackgroundServices.csproj b/SWLOR.BackgroundServices/SWLOR.BackgroundServices.csproj new file mode 100644 index 000000000..195c13412 --- /dev/null +++ b/SWLOR.BackgroundServices/SWLOR.BackgroundServices.csproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + diff --git a/SWLOR.Game.Server.sln b/SWLOR.Game.Server.sln index bf8b0f542..aa896d88e 100644 --- a/SWLOR.Game.Server.sln +++ b/SWLOR.Game.Server.sln @@ -15,36 +15,102 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SWLOR.Runner", "SWLOR.Runne EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SWLOR.NWN.API", "SWLOR.NWN.API\SWLOR.NWN.API.csproj", "{189C46D2-B341-4C01-B87D-CB2484F2110F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SWLOR.BackgroundServices", "SWLOR.BackgroundServices\SWLOR.BackgroundServices.csproj", "{11D53838-9CB2-46E9-B6FD-23F46FD753D9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {AB7417D9-A314-4ED6-8E7E-75B3F2A83DE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AB7417D9-A314-4ED6-8E7E-75B3F2A83DE1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB7417D9-A314-4ED6-8E7E-75B3F2A83DE1}.Debug|x64.ActiveCfg = Debug|Any CPU + {AB7417D9-A314-4ED6-8E7E-75B3F2A83DE1}.Debug|x64.Build.0 = Debug|Any CPU + {AB7417D9-A314-4ED6-8E7E-75B3F2A83DE1}.Debug|x86.ActiveCfg = Debug|Any CPU + {AB7417D9-A314-4ED6-8E7E-75B3F2A83DE1}.Debug|x86.Build.0 = Debug|Any CPU {AB7417D9-A314-4ED6-8E7E-75B3F2A83DE1}.Release|Any CPU.ActiveCfg = Release|Any CPU {AB7417D9-A314-4ED6-8E7E-75B3F2A83DE1}.Release|Any CPU.Build.0 = Release|Any CPU + {AB7417D9-A314-4ED6-8E7E-75B3F2A83DE1}.Release|x64.ActiveCfg = Release|Any CPU + {AB7417D9-A314-4ED6-8E7E-75B3F2A83DE1}.Release|x64.Build.0 = Release|Any CPU + {AB7417D9-A314-4ED6-8E7E-75B3F2A83DE1}.Release|x86.ActiveCfg = Release|Any CPU + {AB7417D9-A314-4ED6-8E7E-75B3F2A83DE1}.Release|x86.Build.0 = Release|Any CPU {4CF51B54-4FE9-4167-8BD6-742A067BBEF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4CF51B54-4FE9-4167-8BD6-742A067BBEF3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CF51B54-4FE9-4167-8BD6-742A067BBEF3}.Debug|x64.ActiveCfg = Debug|Any CPU + {4CF51B54-4FE9-4167-8BD6-742A067BBEF3}.Debug|x64.Build.0 = Debug|Any CPU + {4CF51B54-4FE9-4167-8BD6-742A067BBEF3}.Debug|x86.ActiveCfg = Debug|Any CPU + {4CF51B54-4FE9-4167-8BD6-742A067BBEF3}.Debug|x86.Build.0 = Debug|Any CPU {4CF51B54-4FE9-4167-8BD6-742A067BBEF3}.Release|Any CPU.ActiveCfg = Release|Any CPU {4CF51B54-4FE9-4167-8BD6-742A067BBEF3}.Release|Any CPU.Build.0 = Release|Any CPU + {4CF51B54-4FE9-4167-8BD6-742A067BBEF3}.Release|x64.ActiveCfg = Release|Any CPU + {4CF51B54-4FE9-4167-8BD6-742A067BBEF3}.Release|x64.Build.0 = Release|Any CPU + {4CF51B54-4FE9-4167-8BD6-742A067BBEF3}.Release|x86.ActiveCfg = Release|Any CPU + {4CF51B54-4FE9-4167-8BD6-742A067BBEF3}.Release|x86.Build.0 = Release|Any CPU {0AF50AD5-8CAC-4CFD-92C9-A2AAAAEE64BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0AF50AD5-8CAC-4CFD-92C9-A2AAAAEE64BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0AF50AD5-8CAC-4CFD-92C9-A2AAAAEE64BE}.Debug|x64.ActiveCfg = Debug|Any CPU + {0AF50AD5-8CAC-4CFD-92C9-A2AAAAEE64BE}.Debug|x64.Build.0 = Debug|Any CPU + {0AF50AD5-8CAC-4CFD-92C9-A2AAAAEE64BE}.Debug|x86.ActiveCfg = Debug|Any CPU + {0AF50AD5-8CAC-4CFD-92C9-A2AAAAEE64BE}.Debug|x86.Build.0 = Debug|Any CPU {0AF50AD5-8CAC-4CFD-92C9-A2AAAAEE64BE}.Release|Any CPU.ActiveCfg = Release|Any CPU {0AF50AD5-8CAC-4CFD-92C9-A2AAAAEE64BE}.Release|Any CPU.Build.0 = Release|Any CPU + {0AF50AD5-8CAC-4CFD-92C9-A2AAAAEE64BE}.Release|x64.ActiveCfg = Release|Any CPU + {0AF50AD5-8CAC-4CFD-92C9-A2AAAAEE64BE}.Release|x64.Build.0 = Release|Any CPU + {0AF50AD5-8CAC-4CFD-92C9-A2AAAAEE64BE}.Release|x86.ActiveCfg = Release|Any CPU + {0AF50AD5-8CAC-4CFD-92C9-A2AAAAEE64BE}.Release|x86.Build.0 = Release|Any CPU {34607A71-B934-46A9-B08B-C9E0F7458E91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {34607A71-B934-46A9-B08B-C9E0F7458E91}.Debug|Any CPU.Build.0 = Debug|Any CPU + {34607A71-B934-46A9-B08B-C9E0F7458E91}.Debug|x64.ActiveCfg = Debug|Any CPU + {34607A71-B934-46A9-B08B-C9E0F7458E91}.Debug|x64.Build.0 = Debug|Any CPU + {34607A71-B934-46A9-B08B-C9E0F7458E91}.Debug|x86.ActiveCfg = Debug|Any CPU + {34607A71-B934-46A9-B08B-C9E0F7458E91}.Debug|x86.Build.0 = Debug|Any CPU {34607A71-B934-46A9-B08B-C9E0F7458E91}.Release|Any CPU.ActiveCfg = Release|Any CPU {34607A71-B934-46A9-B08B-C9E0F7458E91}.Release|Any CPU.Build.0 = Release|Any CPU + {34607A71-B934-46A9-B08B-C9E0F7458E91}.Release|x64.ActiveCfg = Release|Any CPU + {34607A71-B934-46A9-B08B-C9E0F7458E91}.Release|x64.Build.0 = Release|Any CPU + {34607A71-B934-46A9-B08B-C9E0F7458E91}.Release|x86.ActiveCfg = Release|Any CPU + {34607A71-B934-46A9-B08B-C9E0F7458E91}.Release|x86.Build.0 = Release|Any CPU {CAD52F45-F4DC-4EE3-8008-068CC00E27B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CAD52F45-F4DC-4EE3-8008-068CC00E27B6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CAD52F45-F4DC-4EE3-8008-068CC00E27B6}.Debug|x64.ActiveCfg = Debug|Any CPU + {CAD52F45-F4DC-4EE3-8008-068CC00E27B6}.Debug|x64.Build.0 = Debug|Any CPU + {CAD52F45-F4DC-4EE3-8008-068CC00E27B6}.Debug|x86.ActiveCfg = Debug|Any CPU + {CAD52F45-F4DC-4EE3-8008-068CC00E27B6}.Debug|x86.Build.0 = Debug|Any CPU {CAD52F45-F4DC-4EE3-8008-068CC00E27B6}.Release|Any CPU.ActiveCfg = Release|Any CPU {CAD52F45-F4DC-4EE3-8008-068CC00E27B6}.Release|Any CPU.Build.0 = Release|Any CPU + {CAD52F45-F4DC-4EE3-8008-068CC00E27B6}.Release|x64.ActiveCfg = Release|Any CPU + {CAD52F45-F4DC-4EE3-8008-068CC00E27B6}.Release|x64.Build.0 = Release|Any CPU + {CAD52F45-F4DC-4EE3-8008-068CC00E27B6}.Release|x86.ActiveCfg = Release|Any CPU + {CAD52F45-F4DC-4EE3-8008-068CC00E27B6}.Release|x86.Build.0 = Release|Any CPU {189C46D2-B341-4C01-B87D-CB2484F2110F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {189C46D2-B341-4C01-B87D-CB2484F2110F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {189C46D2-B341-4C01-B87D-CB2484F2110F}.Debug|x64.ActiveCfg = Debug|Any CPU + {189C46D2-B341-4C01-B87D-CB2484F2110F}.Debug|x64.Build.0 = Debug|Any CPU + {189C46D2-B341-4C01-B87D-CB2484F2110F}.Debug|x86.ActiveCfg = Debug|Any CPU + {189C46D2-B341-4C01-B87D-CB2484F2110F}.Debug|x86.Build.0 = Debug|Any CPU {189C46D2-B341-4C01-B87D-CB2484F2110F}.Release|Any CPU.ActiveCfg = Release|Any CPU {189C46D2-B341-4C01-B87D-CB2484F2110F}.Release|Any CPU.Build.0 = Release|Any CPU + {189C46D2-B341-4C01-B87D-CB2484F2110F}.Release|x64.ActiveCfg = Release|Any CPU + {189C46D2-B341-4C01-B87D-CB2484F2110F}.Release|x64.Build.0 = Release|Any CPU + {189C46D2-B341-4C01-B87D-CB2484F2110F}.Release|x86.ActiveCfg = Release|Any CPU + {189C46D2-B341-4C01-B87D-CB2484F2110F}.Release|x86.Build.0 = Release|Any CPU + {11D53838-9CB2-46E9-B6FD-23F46FD753D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11D53838-9CB2-46E9-B6FD-23F46FD753D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11D53838-9CB2-46E9-B6FD-23F46FD753D9}.Debug|x64.ActiveCfg = Debug|Any CPU + {11D53838-9CB2-46E9-B6FD-23F46FD753D9}.Debug|x64.Build.0 = Debug|Any CPU + {11D53838-9CB2-46E9-B6FD-23F46FD753D9}.Debug|x86.ActiveCfg = Debug|Any CPU + {11D53838-9CB2-46E9-B6FD-23F46FD753D9}.Debug|x86.Build.0 = Debug|Any CPU + {11D53838-9CB2-46E9-B6FD-23F46FD753D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11D53838-9CB2-46E9-B6FD-23F46FD753D9}.Release|Any CPU.Build.0 = Release|Any CPU + {11D53838-9CB2-46E9-B6FD-23F46FD753D9}.Release|x64.ActiveCfg = Release|Any CPU + {11D53838-9CB2-46E9-B6FD-23F46FD753D9}.Release|x64.Build.0 = Release|Any CPU + {11D53838-9CB2-46E9-B6FD-23F46FD753D9}.Release|x86.ActiveCfg = Release|Any CPU + {11D53838-9CB2-46E9-B6FD-23F46FD753D9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/SWLOR.Game.Server/Docker/docker-compose.yml b/SWLOR.Game.Server/Docker/docker-compose.yml index 2be237254..c8792dae8 100644 --- a/SWLOR.Game.Server/Docker/docker-compose.yml +++ b/SWLOR.Game.Server/Docker/docker-compose.yml @@ -34,6 +34,14 @@ services: - ${PWD-.}/logs:/nwn/data/bin/linux-x86/logs.0 ports: - "5121:5121/udp" + + background-services: + hostname: background-services + image: zunath/swlor-background-worker:latest + env_file: ${PWD-.}/swlor.env + depends_on: + - redis + restart: unless-stopped influxdb: hostname: influxdb @@ -70,4 +78,4 @@ services: volumes: influxdb: - grafana: \ No newline at end of file + grafana: diff --git a/SWLOR.Game.Server/Docker/swlor.env b/SWLOR.Game.Server/Docker/swlor.env index ee7699d10..2756ecffa 100644 --- a/SWLOR.Game.Server/Docker/swlor.env +++ b/SWLOR.Game.Server/Docker/swlor.env @@ -88,8 +88,11 @@ NWNX_TWEAKS_HIDE_PLAYERS_ON_CHAR_LIST=1 # SWLOR Application Environment Variables SWLOR_APP_LOG_DIRECTORY=/nwn/home/app_logs/ SWLOR_SUPER_ADMIN_CD_KEY= -SWLOR_BUG_WEBHOOK_URL= +SWLOR_BUG_GITHUB_REPOSITORY= +SWLOR_BUG_GITHUB_TOKEN= +SWLOR_BUG_CODEX_REVIEW_ENABLED=false +SWLOR_BUG_DISCORD_WEBHOOK_URL= SWLOR_HOLONET_WEBHOOK_URL= SWLOR_DM_SHOUT_WEBHOOK_URL= # SWLOR_ENVIRONMENT expects either 'dev', 'test' or 'production' values -SWLOR_ENVIRONMENT=dev \ No newline at end of file +SWLOR_ENVIRONMENT=dev diff --git a/SWLOR.Game.Server/Feature/ChatCommandDefinition/DMChatCommand.cs b/SWLOR.Game.Server/Feature/ChatCommandDefinition/DMChatCommand.cs index 4031c35d6..7c05b3594 100644 --- a/SWLOR.Game.Server/Feature/ChatCommandDefinition/DMChatCommand.cs +++ b/SWLOR.Game.Server/Feature/ChatCommandDefinition/DMChatCommand.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using SWLOR.Game.Server.Core; using SWLOR.Game.Server.Entity; using SWLOR.Game.Server.Enumeration; @@ -8,11 +9,9 @@ using SWLOR.Game.Server.Service.GuiService; using SWLOR.Game.Server.Service.ChatCommandService; using SWLOR.Game.Server.Service.FactionService; +using SWLOR.Game.Server.Service.LogService; using Faction = SWLOR.Game.Server.Service.Faction; using ChatChannel = SWLOR.Game.Server.Core.NWNX.Enum.ChatChannel; -using System.Threading.Tasks; -using Discord; -using Discord.Webhook; using SWLOR.NWN.API.NWNX; using SWLOR.NWN.API.NWScript; using SWLOR.NWN.API.NWScript.Enum; @@ -945,7 +944,7 @@ private void Broadcast() return string.Empty; }) - .Action((user, target, location, args) => + .Action(async (user, target, location, args) => { var message = string.Join(" ", args); var url = Environment.GetEnvironmentVariable("SWLOR_DM_SHOUT_WEBHOOK_URL"); @@ -954,23 +953,23 @@ private void Broadcast() ChatPlugin.SendMessage(ChatChannel.DMShout, message, user, onlinePlayer); var authorName = $"{GetName(user)} ({GetPCPlayerName(user)}) [{GetPCPublicCDKey(user)}]"; - Task.Run(async () => + if (!string.IsNullOrWhiteSpace(url)) { - using (var client = new DiscordWebhookClient(url)) + try { - var embed = new EmbedBuilder + var enqueued = await BackgroundJob.EnqueueDiscordWebhook(url, authorName, message, 15105570); + if (!enqueued) { - Author = new EmbedAuthorBuilder - { - Name = authorName - }, - Description = message, - Color = Color.Orange - }; - - await client.SendMessageAsync(string.Empty, embeds: new[] { embed.Build() }); + Log.Write(LogGroup.Error, "Failed to queue DM shout Discord webhook."); + SendMessageToPC(user, ColorToken.Red("ERROR: Unable to queue DM shout Discord webhook. Please notify an admin.")); + } } - }); + catch (Exception ex) + { + Log.Write(LogGroup.Error, $"Failed to queue DM shout Discord webhook. {ex}"); + SendMessageToPC(user, ColorToken.Red("ERROR: Unable to queue DM shout Discord webhook. Please notify an admin.")); + } + } }); } diff --git a/SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/BugReportViewModel.cs b/SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/BugReportViewModel.cs index e088c4503..4824ac677 100644 --- a/SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/BugReportViewModel.cs +++ b/SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/BugReportViewModel.cs @@ -1,9 +1,6 @@ using System; -using System.Collections.Generic; using System.Globalization; using System.Threading.Tasks; -using Discord; -using Discord.Webhook; using SWLOR.Game.Server.Enumeration; using SWLOR.Game.Server.Service; using SWLOR.Game.Server.Service.GuiService; @@ -13,6 +10,7 @@ namespace SWLOR.Game.Server.Feature.GuiDefinition.ViewModel public class BugReportViewModel: GuiViewModelBase { public const int MaxBugReportLength = 1000; + private const int MaxDiscordThreadNameLength = 100; private static readonly ApplicationSettings _appSettings = ApplicationSettings.Get(); protected override void Initialize(GuiPayloadBase initialPayload) @@ -29,7 +27,7 @@ public string BugReportText } } - public Action OnClickSubmit() => () => + public Action OnClickSubmit() => async () => { if (string.IsNullOrWhiteSpace(BugReportText)) { @@ -46,11 +44,11 @@ public Action OnClickSubmit() => () => var area = GetArea(Player); var position = GetPosition(Player); - var url = Environment.GetEnvironmentVariable("SWLOR_BUG_WEBHOOK_URL"); + var discordWebhookUrl = Environment.GetEnvironmentVariable("SWLOR_BUG_DISCORD_WEBHOOK_URL"); - if (string.IsNullOrWhiteSpace(url)) + if (string.IsNullOrWhiteSpace(discordWebhookUrl)) { - SendMessageToPC(Player, ColorToken.Red("ERROR: Unable to send bug report because the server admin has not set the SWLOR_BUG_WEBHOOK_URL environment variable.")); + SendMessageToPC(Player, ColorToken.Red("ERROR: Unable to send bug report because the server admin has not set SWLOR_BUG_DISCORD_WEBHOOK_URL.")); return; } @@ -59,74 +57,23 @@ public Action OnClickSubmit() => () => var areaTag = GetTag(area); var areaResref = GetResRef(area); var positionGroup = $"({position.X}, {position.Y}, {position.Z})"; - var dateReported = DateTime.UtcNow.ToString("yyyy-MM-dd hh:mm:ss"); + var dateReported = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); var playerId = GetObjectUUID(Player); var nextReportAllowed = DateTime.UtcNow.AddMinutes(1); - var title = _appSettings.ServerEnvironment == ServerEnvironmentType.Test - ? "Bug Report [TEST SERVER]" - : "Bug Report"; - - Task.Run(async () => + if (!await SubmitBugReportToDiscord( + discordWebhookUrl, + message, + authorName, + areaName, + areaTag, + areaResref, + positionGroup, + dateReported, + playerId)) { - using (var client = new DiscordWebhookClient(url)) - { - var embed = new EmbedBuilder - { - Title = title, - Description = message, - Author = new EmbedAuthorBuilder - { - Name = authorName - }, - Color = Color.Red, - Fields = new List - { - new() - { - IsInline = true, - Name = "Area Name", - Value = areaName - }, - new() - { - IsInline = true, - Name = "Area Tag", - Value = areaTag - }, - new() - { - IsInline = true, - Name = "Area Resref", - Value = areaResref - }, - new() - { - IsInline = true, - Name = "Position", - Value = positionGroup - }, - new() - { - IsInline = true, - Name = "Date Reported", - Value = dateReported, - }, - new() - { - IsInline = true, - Name = "Player ID", - Value = playerId - }, - } - }; - - - await client.SendMessageAsync( - string.Empty, - embeds: new[] { embed.Build() }, - threadName: title); - } - }); + SendMessageToPC(Player, ColorToken.Red("ERROR: Unable to queue bug report. Please notify a DM.")); + return; + } SetLocalString(Player, "BUG_REPORT_LAST_SUBMISSION", nextReportAllowed.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); SendMessageToPC(Player, "Bug report submitted! Thank you for your report."); @@ -134,6 +81,56 @@ await client.SendMessageAsync( Gui.TogglePlayerWindow(Player, GuiWindowType.BugReport); }; + + private Task SubmitBugReportToDiscord( + string discordWebhookUrl, + string message, + string authorName, + string areaName, + string areaTag, + string areaResref, + string positionGroup, + string dateReported, + string playerId) + { + var titlePrefix = _appSettings.ServerEnvironment switch + { + ServerEnvironmentType.Test => "[TEST] ", + ServerEnvironmentType.Development => "[DEV] ", + _ => string.Empty + }; + var title = $"{titlePrefix}Bug Report"; + var environmentName = _appSettings.ServerEnvironment.ToString(); + var body = $"{message}\n\n---\n" + + $"**Server Environment**: {environmentName}\n" + + $"**Reporter**: {authorName}\n" + + $"**Area Name**: {areaName}\n" + + $"**Area Tag**: {areaTag}\n" + + $"**Area Resref**: {areaResref}\n" + + $"**Position**: {positionGroup}\n" + + $"**Date Reported (UTC)**: {dateReported}\n" + + $"**Player ID**: {playerId}"; + + return BackgroundJob.EnqueueDiscordWebhook( + discordWebhookUrl, + authorName, + body, + 15158332, + title, + createThread: true, + threadName: CreateDiscordThreadName(title)); + } + + private static string CreateDiscordThreadName(string title) + { + if (title.Length <= MaxDiscordThreadNameLength) + { + return title; + } + + return title.Substring(0, MaxDiscordThreadNameLength); + } + public Action OnClickCancel() => () => { Gui.TogglePlayerWindow(Player, GuiWindowType.BugReport); diff --git a/SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/HoloNetViewModel.cs b/SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/HoloNetViewModel.cs index a10eee6ca..6424c5de8 100644 --- a/SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/HoloNetViewModel.cs +++ b/SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/HoloNetViewModel.cs @@ -1,7 +1,5 @@ using System; using System.Threading.Tasks; -using Discord; -using Discord.Webhook; using SWLOR.Game.Server.Service; using SWLOR.Game.Server.Service.GuiService; @@ -39,7 +37,7 @@ public Action OnClickSubmit() => () => return; } - ShowModal("Are you sure you want to submit this broadcast?", () => + ShowModal("Are you sure you want to submit this broadcast?", async () => { var url = Environment.GetEnvironmentVariable("SWLOR_HOLONET_WEBHOOK_URL"); @@ -55,27 +53,14 @@ public Action OnClickSubmit() => () => return; } - AssignCommand(Player, () => TakeGoldFromCreature(BroadcastPrice, Player, true)); - var authorName = $"{GetName(Player)} ({GetPCPlayerName(Player)}) [{GetPCPublicCDKey(Player)}]"; - - Task.Run(async () => + if (!await BackgroundJob.EnqueueDiscordWebhook(url, authorName, message, 3447003)) { - using (var client = new DiscordWebhookClient(url)) - { - var embed = new EmbedBuilder - { - Description = message, - Author = new EmbedAuthorBuilder - { - Name = authorName - }, - Color = Color.Blue - }; + SendMessageToPC(Player, ColorToken.Red("ERROR: Unable to queue HoloNet broadcast. Please notify a DM.")); + return; + } - await client.SendMessageAsync(string.Empty, embeds: new[] { embed.Build() }); - } - }); + AssignCommand(Player, () => TakeGoldFromCreature(BroadcastPrice, Player, true)); SendMessageToPC(Player, "HoloNet message broadcasted!"); Gui.TogglePlayerWindow(Player, GuiWindowType.HoloNet); diff --git a/SWLOR.Game.Server/Readmes/Deployment.md b/SWLOR.Game.Server/Readmes/Deployment.md index 46602d0cc..4f3f572ac 100644 --- a/SWLOR.Game.Server/Readmes/Deployment.md +++ b/SWLOR.Game.Server/Readmes/Deployment.md @@ -40,7 +40,7 @@ Environment variables are configured in `Docker/swlor.env`. Key variables includ - `SWLOR_ENVIRONMENT`: Controls server environment (development, test, production) - `SWLOR_APP_LOG_DIRECTORY`: Log file location - `NWNX_REDIS_HOST`: Redis connection string -- Database and Discord integration settings +- Database and external integration settings (including GitHub bug reporting) ### 3. Application Settings diff --git a/SWLOR.Game.Server/SWLOR.Game.Server.csproj b/SWLOR.Game.Server/SWLOR.Game.Server.csproj index a530e282f..7fd82bab7 100644 --- a/SWLOR.Game.Server/SWLOR.Game.Server.csproj +++ b/SWLOR.Game.Server/SWLOR.Game.Server.csproj @@ -17,7 +17,6 @@ - diff --git a/SWLOR.Game.Server/Service/BackgroundJob.cs b/SWLOR.Game.Server/Service/BackgroundJob.cs new file mode 100644 index 000000000..34f78e83b --- /dev/null +++ b/SWLOR.Game.Server/Service/BackgroundJob.cs @@ -0,0 +1,104 @@ +using System; +using System.Threading.Tasks; +using Newtonsoft.Json; +using StackExchange.Redis; +using SWLOR.Game.Server.Service.BackgroundJobService; +using SWLOR.Game.Server.Service.LogService; + +namespace SWLOR.Game.Server.Service +{ + public static class BackgroundJob + { + public const string StreamName = "swlor:background-jobs"; + public const int MaxStreamLength = 10000; + + public static Task EnqueueGitHubIssue(string repository, string title, string body) + { + var payload = new GitHubIssuePayload + { + Repository = repository, + Title = title, + Body = body + }; + + return Enqueue(BackgroundJobType.GitHubIssue, payload); + } + + public static Task EnqueueDiscordWebhook( + string webhookUrl, + string authorName, + string description, + int color, + string title = "", + string threadId = "", + bool createThread = false, + string threadName = "") + { + var payload = new DiscordWebhookPayload + { + WebhookUrl = webhookUrl, + AuthorName = authorName, + Title = title, + Description = description, + Color = color, + ThreadId = threadId, + CreateThread = createThread, + ThreadName = threadName + }; + + return Enqueue(BackgroundJobType.DiscordWebhook, payload); + } + + private static async Task Enqueue(BackgroundJobType type, TPayload payload) + { + try + { + var context = BuildLogContext(payload); + var entries = new[] + { + new NameValueEntry("type", type.ToString()), + new NameValueEntry("payload", JsonConvert.SerializeObject(payload)), + new NameValueEntry("createdUtc", DateTime.UtcNow.ToString("O")) + }; + + return await EnqueueAsync(type, entries, context); + } + catch (Exception ex) + { + Log.Write(LogGroup.Error, $"Failed to enqueue background job. Type='{type}'. Context='{BuildLogContext(payload)}'. {ex}"); + return false; + } + } + + private static async Task EnqueueAsync(BackgroundJobType type, NameValueEntry[] entries, string context) + { + try + { + await DB.StreamAddAsync( + StreamName, + entries, + maxLength: MaxStreamLength, + useApproximateMaxLength: true); + return true; + } + catch (Exception ex) + { + Log.Write(LogGroup.Error, $"Failed to enqueue background job. Type='{type}'. Context='{context}'. {ex}"); + return false; + } + } + + private static string BuildLogContext(TPayload payload) + { + switch (payload) + { + case DiscordWebhookPayload discord: + return $"threadId='{discord.ThreadId}', createThread='{discord.CreateThread}'"; + case GitHubIssuePayload gitHub: + return $"repository='{gitHub.Repository}'"; + default: + return payload?.GetType().Name ?? "null"; + } + } + } +} diff --git a/SWLOR.Game.Server/Service/BackgroundJobService/BackgroundJobType.cs b/SWLOR.Game.Server/Service/BackgroundJobService/BackgroundJobType.cs new file mode 100644 index 000000000..e16fde26b --- /dev/null +++ b/SWLOR.Game.Server/Service/BackgroundJobService/BackgroundJobType.cs @@ -0,0 +1,8 @@ +namespace SWLOR.Game.Server.Service.BackgroundJobService +{ + public enum BackgroundJobType + { + GitHubIssue = 1, + DiscordWebhook = 2 + } +} diff --git a/SWLOR.Game.Server/Service/BackgroundJobService/DiscordWebhookPayload.cs b/SWLOR.Game.Server/Service/BackgroundJobService/DiscordWebhookPayload.cs new file mode 100644 index 000000000..d0e942825 --- /dev/null +++ b/SWLOR.Game.Server/Service/BackgroundJobService/DiscordWebhookPayload.cs @@ -0,0 +1,31 @@ +using Newtonsoft.Json; + +namespace SWLOR.Game.Server.Service.BackgroundJobService +{ + public class DiscordWebhookPayload + { + [JsonProperty("webhookUrl")] + public string WebhookUrl { get; set; } + + [JsonProperty("authorName")] + public string AuthorName { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("color")] + public int Color { get; set; } + + [JsonProperty("threadId")] + public string ThreadId { get; set; } + + [JsonProperty("createThread")] + public bool CreateThread { get; set; } + + [JsonProperty("threadName")] + public string ThreadName { get; set; } + } +} diff --git a/SWLOR.Game.Server/Service/BackgroundJobService/GitHubIssuePayload.cs b/SWLOR.Game.Server/Service/BackgroundJobService/GitHubIssuePayload.cs new file mode 100644 index 000000000..db5d6931b --- /dev/null +++ b/SWLOR.Game.Server/Service/BackgroundJobService/GitHubIssuePayload.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace SWLOR.Game.Server.Service.BackgroundJobService +{ + public class GitHubIssuePayload + { + [JsonProperty("repository")] + public string Repository { get; set; } = string.Empty; + + [JsonProperty("title")] + public string Title { get; set; } = string.Empty; + + [JsonProperty("body")] + public string Body { get; set; } = string.Empty; + } +} diff --git a/SWLOR.Game.Server/Service/DB.cs b/SWLOR.Game.Server/Service/DB.cs index b038ddde6..67efda081 100644 --- a/SWLOR.Game.Server/Service/DB.cs +++ b/SWLOR.Game.Server/Service/DB.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reflection; using System.Threading; +using System.Threading.Tasks; using Newtonsoft.Json; using NRediSearch; using NReJSON; @@ -77,6 +78,32 @@ public static void Load() ExecuteScript("db_loaded", OBJECT_SELF); } + public static RedisValue StreamAdd( + RedisKey key, + NameValueEntry[] streamPairs, + int? maxLength = null, + bool useApproximateMaxLength = false) + { + return _multiplexer.GetDatabase().StreamAdd( + key, + streamPairs, + maxLength: maxLength, + useApproximateMaxLength: useApproximateMaxLength); + } + + public static Task StreamAddAsync( + RedisKey key, + NameValueEntry[] streamPairs, + int? maxLength = null, + bool useApproximateMaxLength = false) + { + return _multiplexer.GetDatabase().StreamAddAsync( + key, + streamPairs, + maxLength: maxLength, + useApproximateMaxLength: useApproximateMaxLength); + } + /// /// Processes the Redis Search index with the latest changes. ///