ProjectMakoto/ProjectMakoto/Bot.cs
Mira 1c3aa23a76
fix: Delete old deleted message logs
(they stay uploaded to discord)
2025-05-07 18:48:06 +02:00

595 lines
No EOL
24 KiB
C#

// 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<string, BasePlugin> Plugins => this._Plugins.AsReadOnly();
internal Dictionary<string, BasePlugin> _Plugins { get; set; } = [];
public IReadOnlyDictionary<string, List<MakotoModule>> PluginCommandModules => this._PluginCommandModules.AsReadOnly();
internal Dictionary<string, List<MakotoModule>> _PluginCommandModules { get; set; } = new();
public IReadOnlyList<MakotoModule> CommandModules => this._CommandModules.AsReadOnly();
internal List<MakotoModule> _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<string> ProfanityList { get; set; }
internal BumpReminderHandler BumpReminder { get; set; }
internal ExperienceHandler ExperienceHandler { get; set; }
public TaskWatcher Watcher { get; internal set; } = new();
internal DatabaseDictionary<string, PhishingUrlEntry> PhishingHosts { get; set; }
internal DatabaseDictionary<ulong, SubmittedUrlEntry> SubmittedHosts { get; set; }
#endregion Util
#region Bans
internal DatabaseList<ulong> objectedUsers { get; set; }
internal DatabaseDictionary<ulong, BanDetails> bannedUsers { get; set; }
internal DatabaseDictionary<ulong, BanDetails> bannedGuilds { get; set; }
internal DatabaseDictionary<ulong, BanDetails> globalBans { get; set; }
internal SelfFillingDatabaseDictionary<GlobalNote> globalNotes { get; set; }
#endregion Bans
public Status status = new();
public SelfFillingDatabaseDictionary<Entities.Guild> Guilds { get; internal set; } = null;
public SelfFillingDatabaseDictionary<Entities.User> 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<ExceptionDataEnricher>()
.Enrich.With<BadRequestExceptionEnricher>()
.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<DiscordApplicationCommand>()).ConfigureAwait(false);
//await this.DiscordClient.GetFirstShard().BulkOverwriteGlobalApplicationCommandsAsync(Array.Empty<DiscordApplicationCommand>()).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<int> GetPrefix(DiscordMessage message)
{
return Task<int>.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<Task>(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<Task>(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);
}