// Project Makoto // Copyright (C) 2024 Fortunevale // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU 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 using Octokit; using GenHTTP.Engine; using GenHTTP.Modules.IO; using GenHTTP.Modules.Practices; using GenHTTP.Api.Infrastructure; using GenHTTP.Modules.StaticWebsites; using Serilog; using Microsoft.Extensions.Logging; using Serilog.Events; using Serilog.Core; using ProjectMakoto.Entities.LoggingEnrichers; using Status = ProjectMakoto.Entities.Status; using Microsoft.CodeAnalysis; using GenHTTP.Engine.Internal; namespace ProjectMakoto; public sealed class Bot { #region Clients internal DatabaseClient DatabaseClient { get; set; } public DiscordShardedClient DiscordClient { get; internal set; } public ThreadJoinClient ThreadJoinClient { get; internal set; } public AbuseIpDbClient AbuseIpDbClient { get; internal set; } public MonitorClient MonitorClient { get; internal set; } public ChartGeneration ChartsClient { get; set; } public TokenInvalidatorRepository TokenInvalidator { get; internal set; } public OfficialPluginRepository OfficialPlugins { get; internal set; } internal GitHubClient GithubClient { get; set; } internal IServerHost WebServer { get; set; } #endregion Clients #region Plugins public IReadOnlyDictionary Plugins => this._Plugins.AsReadOnly(); internal Dictionary _Plugins { get; set; } = []; public IReadOnlyDictionary> PluginCommandModules => this._PluginCommandModules.AsReadOnly(); internal Dictionary> _PluginCommandModules { get; set; } = new(); public IReadOnlyList CommandModules => this._CommandModules.AsReadOnly(); internal List _CommandModules { get; set; } = new(); #endregion #region Util public Translations LoadedTranslations { get; set; } public CountryCodes CountryCodes { get; internal set; } public LanguageCodes LanguageCodes { get; internal set; } internal IReadOnlyList ProfanityList { get; set; } internal BumpReminderHandler BumpReminder { get; set; } internal ExperienceHandler ExperienceHandler { get; set; } public TaskWatcher Watcher { get; internal set; } = new(); internal DatabaseDictionary PhishingHosts { get; set; } internal DatabaseDictionary SubmittedHosts { get; set; } #endregion Util #region Bans internal DatabaseList objectedUsers { get; set; } internal DatabaseDictionary bannedUsers { get; set; } internal DatabaseDictionary bannedGuilds { get; set; } internal DatabaseDictionary globalBans { get; set; } internal SelfFillingDatabaseDictionary globalNotes { get; set; } #endregion Bans public Status status = new(); public SelfFillingDatabaseDictionary Guilds { get; internal set; } = null; public SelfFillingDatabaseDictionary Users { get; internal set; } = null; internal string RawFetchedPrivacyPolicy = ""; internal string Prefix { get; private set; } = ";;"; internal ILoggerFactory msLoggerFactory; internal Microsoft.Extensions.Logging.ILogger msLogger; internal LoggingLevelSwitch loggingLevel; internal async Task Init(string[] args) { var sink = new LogsSink(this); loggingLevel = new LoggingLevelSwitch(); var loggingTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{Level:u3}] {Message:lj}{NewLine}{Exception}{ExceptionData:j}{BadRequestException:j}"; Log.Logger = new LoggerConfiguration() .MinimumLevel.ControlledBy(this.loggingLevel) .WriteTo.Console(outputTemplate: loggingTemplate) .WriteTo.File($"logs/{DateTime.UtcNow.Ticks}.log", LogEventLevel.Debug, outputTemplate: loggingTemplate, retainedFileTimeLimit: TimeSpan.FromDays(7)) .WriteTo.Sink(sink) .Enrich.With() .Enrich.With() .CreateLogger(); this.msLoggerFactory = new LoggerFactory().AddSerilog(); this.msLogger = msLoggerFactory.CreateLogger("ms"); ScheduledTaskExtensions.TaskStarted += this.TaskStarted; UniversalExtensions.AttachLogger(msLogger); RenderAsciiArt(); if (args.Contains("--verbose")) this.loggingLevel.MinimumLevel = LogEventLevel.Verbose; else if (args.Contains("--debug")) this.loggingLevel.MinimumLevel = LogEventLevel.Debug; Log.Debug("Environment Details\n\n" + "Dotnet Version: {Version}\n" + "OS & Version: {OSVersion}\n\n" + "OS 64x: {Is64BitOperatingSystem}\n" + "Process 64x: {Is64BitProcess}\n\n" + "MachineName: {MachineName}\n" + "UserName: {UserName}\n" + "UserDomain: {UserDomainName}\n\n" + "Current Directory: {CurrentDirectory}\n" + "Commandline: {Commandline}\n", Environment.Version, Environment.OSVersion, Environment.Is64BitOperatingSystem, Environment.Is64BitProcess, Environment.MachineName, Environment.UserName, Environment.UserDomainName, Environment.CurrentDirectory, Regex.Replace(Environment.CommandLine, @"(--token \S*)", "")); if (args.Contains("--build-manifests")) { await ManifestBuilder.BuildPluginManifests(this, args); return; } this.status.RunningVersion = (File.Exists("LatestGitPush.cfg") ? await File.ReadAllLinesAsync("LatestGitPush.cfg") : new string[] { "Development-Build" })[0].Trim(); Log.Information("Starting up Makoto {RunningVersion}..\n", this.status.RunningVersion); var loadDatabase = Task.Run(async () => { try { await Util.Initializers.ConfigLoader.Load(this); this.ThreadJoinClient = new ThreadJoinClient(); this.MonitorClient = new MonitorClient(this); this.AbuseIpDbClient = new AbuseIpDbClient(this); this.TokenInvalidator = new TokenInvalidatorRepository(this); this.OfficialPlugins = new OfficialPluginRepository(this); this.ChartsClient = new ChartGeneration(this); this.GithubClient = new GitHubClient(new ProductHeaderValue("ProjectMakoto", this.status.RunningVersion)) { Credentials = new Credentials(this.status.LoadedConfig.Secrets.Github.Token) }; await Task.WhenAll(Util.Initializers.ListLoader.Load(this), Util.Initializers.TranslationLoader.Load(this), Util.Initializers.DependencyLoader.Load(this), Task.Run(() => { UniversalExtensions.LoadAllReferencedAssemblies(AppDomain.CurrentDomain); })); Util.Initializers.CommandCompiler.AssemblyReferences = AppDomain.CurrentDomain.GetAssemblies() .Where(x => !x.IsDynamic && !x.Location.IsNullOrWhiteSpace()) .Select(x => MetadataReference.CreateFromFile(x.Location)) .ToList(); await Util.Initializers.PluginLoader.LoadPlugins(this); _ = await DatabaseClient.InitializeDatabase(this); _ = BasePlugin.RaiseDatabaseInitialized(this); _ = Directory.CreateDirectory("WebServer"); this.WebServer = await Host.Create() .Port(this.status.LoadedConfig.WebServer.Port) .Console() .Defaults( compression: true, secureUpgrade: false, strictTransport: false, clientCaching: true, rangeSupport: false, preventSniffing: false) .Handler(StaticWebsite.From(ResourceTree.FromDirectory("WebServer"))) .StartAsync(); this.objectedUsers = new(this.DatabaseClient, "objected_users", "id", false); this.PhishingHosts = new(this.DatabaseClient, "scam_urls", "url", this.DatabaseClient.mainDatabaseConnection, (id) => { return new PhishingUrlEntry(this, id); }); this.SubmittedHosts = new(this.DatabaseClient, "active_url_submissions", "messageid", this.DatabaseClient.mainDatabaseConnection, (id) => { return new SubmittedUrlEntry(this, id); }); this.Users = new(this.DatabaseClient, "users", "userid", this.DatabaseClient.mainDatabaseConnection, (id) => { return new Entities.User(this, id); }); this.Guilds = new(this.DatabaseClient, "guilds", "serverid", this.DatabaseClient.mainDatabaseConnection, (id) => { return new Entities.Guild(this, id); }); this.globalNotes = new(this.DatabaseClient, "globalnotes", "id", this.DatabaseClient.mainDatabaseConnection, (id) => { return new Entities.GlobalNote(this, id); }); this.bannedUsers = new(this.DatabaseClient, "banned_users", "id", this.DatabaseClient.mainDatabaseConnection, (id) => { return new BanDetails(this, "banned_users", id); }); this.bannedGuilds = new(this.DatabaseClient, "banned_guilds", "id", this.DatabaseClient.mainDatabaseConnection, (id) => { return new BanDetails(this, "banned_guilds", id); }); this.globalBans = new(this.DatabaseClient, "globalbans", "id", this.DatabaseClient.mainDatabaseConnection, (id) => { return new BanDetails(this, "globalbans", id); }); this.BumpReminder = new(this); } catch (Exception ex) { Log.Fatal(ex, "An exception occurred while initializing data"); await Task.Delay(5000); Environment.Exit((int)ExitCodes.FailedDatabaseLogin); } _ = new PhishingUrlHandler(this).UpdatePhishingUrlDatabase(); }).Add(this).IsVital(); await loadDatabase.Task.WaitAsync(TimeSpan.FromSeconds(600)); var logInToDiscord = Task.Run(async () => { _ = Task.Delay(60000).ContinueWith(t => { if (!this.status.DiscordInitialized) { Log.Error("An exception occurred while trying to log into discord: {0}", "The log in took longer than 60 seconds"); Environment.FailFast("An exception occurred while trying to log into discord: The log in took longer than 60 seconds"); return; } }); await Util.Initializers.DisCatSharpExtensionsLoader.Load(this); Log.Information("Connecting and authenticating with Discord.."); await this.DiscordClient.StartAsync(); await Task.Delay(2000); Log.Information("Connected and authenticated with Discord as {User}.", this.DiscordClient.CurrentUser.GetUsernameWithIdentifier()); this.status.DiscordInitialized = true; await BasePlugin.RaiseConnected(this); await Util.Initializers.PostLoginTaskLoader.Load(this); foreach (var plugin in this.Plugins) _ = plugin.Value.PostLoginInternalInit().Add(this); //foreach (var guild in this.DiscordClient.GetGuilds().Values) // await this.DiscordClient.GetShard(guild.Id).BulkOverwriteGuildApplicationCommandsAsync(guild.Id, Array.Empty()).ConfigureAwait(false); //await this.DiscordClient.GetFirstShard().BulkOverwriteGlobalApplicationCommandsAsync(Array.Empty()).ConfigureAwait(false); _ = Task.Run(async () => { if (this.status.LoadedConfig.DontModify.LastStartedVersion == this.status.RunningVersion) return; this.status.LoadedConfig.DontModify.LastStartedVersion = this.status.RunningVersion; this.status.LoadedConfig.Save(); var channel = await this.DiscordClient.GetFirstShard().GetChannelAsync(this.status.LoadedConfig.Channels.GithubLog); _ = await channel.SendMessageAsync(new DiscordEmbedBuilder { Color = EmbedColors.Success, Title = $"Successfully updated to `{this.status.RunningVersion}`." }); }); _ = Task.Run(async () => { try { this.status.TeamOwner = this.DiscordClient.CurrentApplication.Team.Owner.Id; Log.Information("Set {TeamOwner} as owner of the bot", this.status.TeamOwner); this.status._TeamMembers.AddRange(this.DiscordClient.CurrentApplication.Team.Members.Select(x => x.User.Id)); Log.Information("Added {Count} users to administrator list", this.status.TeamMembers.Count); } catch (Exception ex) { Log.Error(ex, "An exception occurred trying to add team members to administrator list. Is the current bot registered in a team?"); } try { if (this.DiscordClient.CurrentApplication.PrivacyPolicyUrl.IsNullOrWhiteSpace()) throw new Exception("No privacy policy was defined."); this.RawFetchedPrivacyPolicy = await new HttpClient().GetStringAsync(this.DiscordClient.CurrentApplication.PrivacyPolicyUrl); } catch (Exception ex) { Log.Error(ex, "An exception occurred while trying to fetch the privacy policy"); } }); _ = this.CleanupOldPrunedMessages().Add(this); _ = this.ProcessDeletionRequests().Add(this); }).Add(this).IsVital(); while (!loadDatabase.Task.IsCompleted || !logInToDiscord.Task.IsCompleted) await Task.Delay(100); if (!loadDatabase.Task.IsCompletedSuccessfully) { Log.Fatal("An uncaught exception occurred while initializing the database.", loadDatabase.Task.Exception); await Task.Delay(1000); Environment.Exit((int)ExitCodes.FailedDatabaseLoad); } if (!logInToDiscord.Task.IsCompletedSuccessfully) { Log.Fatal("An uncaught exception occurred while initializing the discord client.", logInToDiscord.Task.Exception); await Task.Delay(1000); Environment.Exit((int)ExitCodes.FailedDiscordLogin); } AppDomain.CurrentDomain.ProcessExit += delegate { this.ExitApplication(true).Wait(); }; Console.CancelKeyPress += delegate { Log.Information("Exiting, please wait.."); this.ExitApplication().Wait(); }; _ = Task.Run(async () => { while (true) { if (File.Exists("updated")) { File.Delete("updated"); await this.ExitApplication(); return; } await Task.Delay(1000); } }).Add(this).IsVital(); await Task.Delay(-1); } private static void RenderAsciiArt() { try { var ASCII = File.ReadAllText("Assets/ASCII.txt"); Console.WriteLine(); foreach (var b in ASCII) { switch (b) { case 'g': Console.ForegroundColor = ConsoleColor.DarkGray; break; case 'b': Console.ForegroundColor = ConsoleColor.Blue; break; case 'r': Console.ForegroundColor = ConsoleColor.White; break; case 'p': Console.ForegroundColor = ConsoleColor.DarkBlue; break; default: Console.Write(b); break; } } Console.WriteLine("\n\n"); } catch (Exception ex) { Log.Error(ex, "Failed to render ASCII art"); } Console.ResetColor(); } internal Task GetPrefix(DiscordMessage message) { return Task.Run(() => { //if (IsDev) // if (!_status.TeamMembers.Any(x => x == message.Author.Id)) // return -1; var currentPrefix = this.Guilds.TryGetValue(message.GuildId ?? 0, out var guild) ? guild.PrefixSettings.Prefix : this.Prefix; var CommandStart = -1; if (!(guild?.PrefixSettings.PrefixDisabled ?? false)) CommandStart = CommandsNextUtilities.GetStringPrefixLength(message, currentPrefix); if (CommandStart == -1) CommandStart = CommandsNextUtilities.GetMentionPrefixLength(message, this.DiscordClient.CurrentUser); return CommandStart; }); } bool ExitCalled = false; internal async Task ExitApplication(bool Immediate = false) { _ = Task.Delay(Immediate ? TimeSpan.FromSeconds(10) : TimeSpan.FromMinutes(5)).ContinueWith(async x => { if (x.IsCompletedSuccessfully) { Environment.Exit((int)ExitCodes.ExitTasksTimeout); // Fail-Safe in case the shutdown tasks lock up await Task.Delay(5000); Environment.FailFast(null); } }); if (this.DatabaseClient.Disposed || this.ExitCalled) // When the Database Client has been disposed, the Exit Call has already been made. return; this.ExitCalled = true; Log.Information("Preparing to shut down Makoto.."); _ = await this.WebServer.StopAsync(); foreach (var b in this.Plugins) { try { Log.Information("Shutting down '{0}'..", b.Value.Name); await b.Value.Shutdown(); } catch (Exception ex) { Log.Error(ex, "Failed to shutdown", b.Value.Name); } } if (this.status.DiscordInitialized && !Immediate) { try { Stopwatch sw = new(); sw.Start(); if (!this.status.DiscordCommandsRegistered) Log.Warning("Startup is incomplete. Waiting for Startup to finish to shutdown.."); while (!this.status.DiscordCommandsRegistered && sw.ElapsedMilliseconds < TimeSpan.FromMinutes(5).TotalMilliseconds) await Task.Delay(500); await Util.Initializers.SyncTasks.ExecuteSyncTasks(this, this.DiscordClient); } catch (Exception ex) { Log.Error(ex, "Failed to run sync tasks"); } try { Log.Information("Closing Discord Client.."); await this.DiscordClient.UpdateStatusAsync(userStatus: UserStatus.Offline); await this.DiscordClient.StopAsync(); Log.Debug("Closed Discord Client."); } catch (Exception ex) { Log.Error(ex, "Failed to close Discord Client gracefully."); } } await Task.Delay(500); Log.Information("Goodbye!"); await Task.Delay(500); Environment.Exit(0); await Task.Delay(10000); Environment.FailFast("Failed to exit"); } private async Task CleanupOldPrunedMessages() { _ = new Func(async () => { _ = this.CleanupOldPrunedMessages().Add(this); }).CreateScheduledTask(DateTime.UtcNow.AddHours(24)); foreach (var guildDirectory in Directory.GetDirectories("WebServer").Select(x => new DirectoryInfo(x))) { if (guildDirectory.GetDirectories().Any(x => x.Name.Equals("DeletedMessages", StringComparison.CurrentCultureIgnoreCase))) { var deletedMessagesDirectory = guildDirectory.GetDirectories().First(x => x.Name.Equals("DeletedMessages", StringComparison.CurrentCultureIgnoreCase)); foreach (var deletedMessageFile in deletedMessagesDirectory.GetFiles()) { if (deletedMessageFile.LastWriteTimeUtc.GetTimespanSince() > TimeSpan.FromDays(30)) { Log.Information("Deleting '{File}'..", deletedMessageFile.FullName); deletedMessageFile.Delete(); } } if (deletedMessagesDirectory.GetFiles().Length == 0) { Log.Information("Deleting '{File}'..", deletedMessagesDirectory.FullName); deletedMessagesDirectory.Delete(); } } if (guildDirectory.GetFiles().Length == 0 && guildDirectory.GetDirectories().Length == 0) { Log.Information("Deleting '{File}'..", guildDirectory.FullName); guildDirectory.Delete(); } } } private async Task ProcessDeletionRequests() { _ = new Func(async () => { _ = this.ProcessDeletionRequests().Add(this); }).CreateScheduledTask(DateTime.UtcNow.AddHours(24)); lock (this.Users) { foreach (var b in this.Users) { if ((b.Value?.Data?.DeletionRequested ?? false) && b.Value?.Data?.DeletionRequestDate.GetTimespanUntil() < TimeSpan.Zero) { Log.Information("Deleting profile of '{Key}'", b.Key); _ = this.Users.Remove(b.Key); this.objectedUsers.Add(b.Key); foreach (var c in this.DiscordClient.GetGuilds().Where(x => x.Value.OwnerId == b.Key)) { try { Log.Information("Leaving guild '{guild}'..", c.Key); _ = c.Value.LeaveAsync().Add(this); } catch { } } } } } } internal Task GuildDownloadCompleted(DiscordClient sender, GuildDownloadCompletedEventArgs e) => Util.Initializers.SyncTasks.GuildDownloadCompleted(this, sender, e); internal void TaskStarted(object? sender, Xorog.UniversalExtensions.EventArgs.ScheduledTaskStartedEventArgs e) => this.Watcher.TaskStarted(this, sender, e); }