// Copyright (C) 2021-present sam (starshines.gay) // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . using Catalogger.Backend.Api; using Catalogger.Backend.Api.Middleware; using Catalogger.Backend.Bot; using Catalogger.Backend.Bot.Commands; using Catalogger.Backend.Bot.Responders.Messages; using Catalogger.Backend.Cache; using Catalogger.Backend.Cache.InMemoryCache; using Catalogger.Backend.Cache.RedisCache; using Catalogger.Backend.Database; using Catalogger.Backend.Database.Redis; using Catalogger.Backend.Database.Repositories; using Catalogger.Backend.Services; using NodaTime; using Prometheus; using Remora.Discord.API; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.Commands.Services; using Remora.Discord.Gateway.Extensions; using Remora.Discord.Interactivity.Services; using Remora.Rest.Core; using Serilog; using Serilog.Events; using Serilog.Sinks.SystemConsole.Themes; namespace Catalogger.Backend.Extensions; public static class StartupExtensions { /// /// Adds Serilog to this service collection. This method also initializes Serilog, so it should be called as early as possible, before any log calls. /// public static WebApplicationBuilder AddSerilog( this WebApplicationBuilder builder, Config config ) { var logCfg = new LoggerConfiguration() .Enrich.FromLogContext() .MinimumLevel.Is(config.Logging.LogEventLevel) // ASP.NET's built in request logs are extremely verbose, so we use Serilog's instead. // Serilog doesn't disable the built-in logs, so we do it here. .MinimumLevel.Override("Microsoft", LogEventLevel.Information) .MinimumLevel.Override( "Microsoft.EntityFrameworkCore.Database.Command", config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal ) .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) // The default theme doesn't support light mode .WriteTo.Console(theme: AnsiConsoleTheme.Sixteen, applyThemeToRedirectedOutput: true); if (config.Logging.SeqLogUrl != null) { logCfg.WriteTo.Seq( config.Logging.SeqLogUrl, restrictedToMinimumLevel: LogEventLevel.Verbose ); } // AddSerilog doesn't seem to add an ILogger to the service collection, so add that manually. builder.Services.AddSerilog().AddSingleton(Log.Logger = logCfg.CreateLogger()); return builder; } public static Config AddConfiguration(this WebApplicationBuilder builder) { builder.Configuration.Sources.Clear(); builder.Configuration.AddConfiguration(); var config = builder.Configuration.Get() ?? new(); builder.Services.AddSingleton(config); return config; } public static IConfigurationBuilder AddConfiguration(this IConfigurationBuilder builder) { var file = Environment.GetEnvironmentVariable("CATALOGGER_CONFIG_FILE") ?? "config.ini"; return builder .SetBasePath(Directory.GetCurrentDirectory()) .AddIniFile(file, optional: false, reloadOnChange: false) .AddEnvironmentVariables(); } public static IServiceCollection AddCustomServices(this IServiceCollection services) => services .AddSingleton(SystemClock.Instance) .AddDatabasePool() .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddScoped() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(InMemoryDataService.Instance) .AddTransient() .AddSingleton() // Background services // GuildFetchService is added as a separate singleton as it's also injected into other services. .AddHostedService(serviceProvider => serviceProvider.GetRequiredService() ) .AddHostedService() .AddHostedService(); public static IHostBuilder AddShardedDiscordService( this IHostBuilder builder, Func tokenFactory ) => builder.ConfigureServices( (_, services) => services .AddDiscordGateway(tokenFactory) .AddSingleton() .AddHostedService() ); /// /// The dashboard API is only enabled when Redis is configured, as it heavily relies on it. /// This method only adds API-related services when Redis is found as otherwise we'll get missing dependency errors. /// The actual API definition /// public static IServiceCollection MaybeAddDashboardServices( this IServiceCollection services, Config config ) { if (config.Database.Redis == null) return services; return services .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddScoped(); } public static IServiceCollection MaybeAddRedisCaches( this IServiceCollection services, Config config ) { if (config.Database.Redis == null) { return services .AddSingleton() .AddSingleton() .AddSingleton(); } return services .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(); } public static async Task Initialize(this WebApplication app) { await BuildInfo.ReadBuildInfo(); await using var scope = app.Services.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService().ForContext(); logger.Information( "Starting Catalogger.NET {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash ); CataloggerMetrics.Startup = scope .ServiceProvider.GetRequiredService() .GetCurrentInstant(); DatabasePool.ConfigureDapper(); await using var migrator = scope.ServiceProvider.GetRequiredService(); await migrator.MigrateUp(); var config = scope.ServiceProvider.GetRequiredService(); var slashService = scope.ServiceProvider.GetRequiredService(); var timeoutService = scope.ServiceProvider.GetRequiredService(); if (config.Discord.TestMode) logger.Warning( "Catalogger is running in test mode. This means no logs will be sent and no commands will be responded to." ); if (config.Discord.ApplicationId == 0) { logger.Warning( "Application ID not set in config. Fetching and setting it now, but for future restarts, please add it to config.ini as Discord.ApplicationId." ); var restApi = scope.ServiceProvider.GetRequiredService(); var application = await restApi.GetCurrentApplicationAsync().GetOrThrow(); config.Discord.ApplicationId = application.ID.ToUlong(); logger.Information( "Current application ID is {ApplicationId}", config.Discord.ApplicationId ); } if (config.Discord.SyncCommands) { if (config.Discord.CommandsGuildId != null) { logger.Information( "Syncing application commands with guild {GuildId}", config.Discord.CommandsGuildId ); await slashService.UpdateSlashCommandsAsync( guildID: DiscordSnowflake.New(config.Discord.CommandsGuildId.Value) ); } else { logger.Information("Syncing application commands globally"); await slashService.UpdateSlashCommandsAsync(); } } else logger.Information( "Not syncing slash commands, Discord.SyncCommands is false or unset" ); // Initialize the timeout service by loading all the timeouts currently in the database. await timeoutService.InitializeAsync(); } public static void MaybeAddDashboard(this WebApplication app) { using var scope = app.Services.CreateScope(); var logger = scope.ServiceProvider.GetRequiredService().ForContext(); var config = scope.ServiceProvider.GetRequiredService(); if (config.Database.Redis == null) { logger.Warning( "Redis URL is not set. The dashboard relies on Redis, so it will not be usable." ); return; } app.UseSerilogRequestLogging(); app.UseRouting(); app.UseHttpMetrics(); app.UseSwagger(); app.UseSwaggerUI(); app.UseCors(); app.UseMiddleware(); app.UseMiddleware(); app.MapControllers(); } }