diff --git a/.gitignore b/.gitignore index 2dac34a..89296ed 100644 --- a/.gitignore +++ b/.gitignore @@ -445,6 +445,6 @@ $RECYCLE.BIN/ ## ## Local environment ## -kubeconfig.yaml .env -localconfig.* \ No newline at end of file +localconfig.* +local/ \ No newline at end of file diff --git a/README.md b/README.md index 92bbf82..092fac1 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ Each example will show how to host the bot application itself as well as integra | HostUri | The bot application host URI. Only used if `LargeFileDownloadHandler.BuiltIn` is used. | https://localhost:5000 | | EnableFileDownloadHandler | This is a global setting for whether to enable the file downloads feature. Configured servers must still opt-in by providing a `FilesPath` value. | true | | LargeFileDownloadHandler | Enable file downloads larger than 25MB. See [Handling Large File Downloads](#handlinglargefiledownloads). | Disabled | +| EnableGallery | Enable server gallery. | true | +| EnableGalleryUploads | Enable uploads to the gallery. | false | +| GalleryFileExtensions | Gallery file extensions. | [ png, jpg, jpeg, gif, webp ] | | ServersCacheExpiration | The server info cache expiration. | 5 minutes | | DownloadLinkExpiration | The lifetime of a large file download link. | 24 hours | | ServerStatusWaitTimeout | The timeout for waiting for server status after a start or stop interaction. | 10 minutes | diff --git a/src/ServerManagerDiscordBot/AppSettings.cs b/src/ServerManagerDiscordBot/AppSettings.cs index 85260ca..f82d59b 100644 --- a/src/ServerManagerDiscordBot/AppSettings.cs +++ b/src/ServerManagerDiscordBot/AppSettings.cs @@ -10,6 +10,12 @@ public class AppSettings public LargeFileDownloadHandlerType LargeFileDownloadHandler { get; set; } = LargeFileDownloadHandlerType.Disabled; + public bool EnableGallery { get; set; } = true; + + public bool EnableGalleryUploads { get; set; } = false; + + public string[] GalleryFileExtensions { get; set; } = [ "png", "jpg", "jpeg", "gif", "webp" ]; + public TimeSpan ServersCacheExpiration { get; set; } = TimeSpan.FromMinutes(5); public TimeSpan DownloadLinkExpiration { get; set; } = TimeSpan.FromDays(1); diff --git a/src/ServerManagerDiscordBot/BotService.cs b/src/ServerManagerDiscordBot/BotService.cs index d9669ef..a05fff8 100644 --- a/src/ServerManagerDiscordBot/BotService.cs +++ b/src/ServerManagerDiscordBot/BotService.cs @@ -1,4 +1,3 @@ -using System.Reflection; using Discord; using Discord.Interactions; using Discord.WebSocket; @@ -7,6 +6,7 @@ public class BotService( IOptions appSettings, DiscordSocketClient client, + CommandManager commandManager, InteractionService interactionService, ILogger logger, IServiceProvider serviceProvider) @@ -17,6 +17,8 @@ public class BotService( public ILogger Logger { get; } = logger; public IServiceProvider ServiceProvider { get; } = serviceProvider; public DiscordSocketClient Client { get; } = client; + public CommandManager CommandManager { get; } = commandManager; + public IReadOnlyCollection Commands { get; private set; } = []; public async Task StartAsync(CancellationToken cancellationToken) { @@ -30,8 +32,6 @@ public async Task StartAsync(CancellationToken cancellationToken) Client.SelectMenuExecuted += CommandHandler; Client.AutocompleteExecuted += CommandHandler; - await InteractionService.AddModulesAsync(Assembly.GetEntryAssembly(), ServiceProvider); - await Client.LoginAsync(TokenType.Bot, AppSettings.BotToken); await Client.StartAsync(); @@ -56,7 +56,7 @@ public void Dispose() private async Task Ready() { - await RegisterCommandsAsync(); + await CommandManager.RegisterCommandsAsync(); await Client.SetStatusAsync(UserStatus.Online); } @@ -67,21 +67,6 @@ private async Task CommandHandler(SocketInteraction interaction) await InteractionService.ExecuteCommandAsync(context, ServiceProvider); } - private async Task RegisterCommandsAsync() - { - if (!AppSettings.GuildIds.Any()) - { - await InteractionService.RegisterCommandsGloballyAsync(true); - } - else - { - foreach (var guildId in AppSettings.GuildIds) - { - await InteractionService.RegisterCommandsToGuildAsync(guildId, true); - } - } - } - private Task LogAsync(LogMessage message) { Logger.Log( diff --git a/src/ServerManagerDiscordBot/CommandManager.cs b/src/ServerManagerDiscordBot/CommandManager.cs new file mode 100644 index 0000000..b338d01 --- /dev/null +++ b/src/ServerManagerDiscordBot/CommandManager.cs @@ -0,0 +1,43 @@ +using System.Reflection; +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.Options; + +public class CommandManager( + IOptions appSettings, + InteractionService interactionService, + IServiceProvider serviceProvider) +{ + public AppSettings AppSettings { get; } = appSettings.Value; + public InteractionService InteractionService { get; } = interactionService; + public IServiceProvider ServiceProvider { get; } = serviceProvider; + + public IReadOnlyCollection Commands { get; private set; } = []; + + public IApplicationCommand GetCommand(string name) + { + var command = Commands.FirstOrDefault(c => c.Name == name); + if (command is null) + { + throw new ArgumentException($"Command '{name}' not found.", nameof(name)); + } + return command; + } + + public async Task RegisterCommandsAsync() + { + await InteractionService.AddModulesAsync(Assembly.GetEntryAssembly(), ServiceProvider); + + if (!AppSettings.GuildIds.Any()) + { + Commands = await InteractionService.RegisterCommandsGloballyAsync(true); + } + else + { + foreach (var guildId in AppSettings.GuildIds) + { + Commands = await InteractionService.RegisterCommandsToGuildAsync(guildId, true); + } + } + } +} \ No newline at end of file diff --git a/src/ServerManagerDiscordBot/Program.cs b/src/ServerManagerDiscordBot/Program.cs index 2caf1ec..b86e782 100644 --- a/src/ServerManagerDiscordBot/Program.cs +++ b/src/ServerManagerDiscordBot/Program.cs @@ -28,6 +28,7 @@ LogLevel = Discord.LogSeverity.Verbose }); }); +builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddSingleton(); diff --git a/src/ServerManagerDiscordBot/ServerInfo.cs b/src/ServerManagerDiscordBot/ServerInfo.cs index e1a338e..7eeae24 100644 --- a/src/ServerManagerDiscordBot/ServerInfo.cs +++ b/src/ServerManagerDiscordBot/ServerInfo.cs @@ -13,5 +13,7 @@ public class ServerInfo public string? FilesPath { get; set; } + public string? GalleryPath { get; set; } + public Dictionary Fields { get; set; } = new Dictionary(); } diff --git a/src/ServerManagerDiscordBot/ServerManager.cs b/src/ServerManagerDiscordBot/ServerManager.cs index 1aceca2..6b59cda 100644 --- a/src/ServerManagerDiscordBot/ServerManager.cs +++ b/src/ServerManagerDiscordBot/ServerManager.cs @@ -149,6 +149,51 @@ public async Task GetServerFileAsync(string name, string fileName, Can return new FileInfo(filePath); } + public async Task> GetServerGalleryFilesAsync(string name, CancellationToken cancellationToken = default) + { + var server = await GetServerInfoAsync(name, cancellationToken); + + if (string.IsNullOrWhiteSpace(server.GalleryPath)) + { + throw new InvalidOperationException($"The `{name}` server does not support this operation."); + } + + var directory = new DirectoryInfo(server.GalleryPath); + if (!directory.Exists) + { + throw new DirectoryNotFoundException($"The `{name}` server gallery directory `{server.GalleryPath}` does not exist."); + } + + var files = directory.EnumerateFiles() + .Where(file => AppSettings.GalleryFileExtensions.Contains(file.Extension.Substring(1), StringComparer.OrdinalIgnoreCase)); + + return files; + } + + public async Task UploadServerGalleryFileAsync(string name, string sourceUrl, string fileName, CancellationToken cancellationToken = default) + { + var server = await GetServerInfoAsync(name, cancellationToken); + + if (string.IsNullOrWhiteSpace(server.GalleryPath)) + { + throw new InvalidOperationException($"The `{name}` server does not support this operation."); + } + + if (!AppSettings.GalleryFileExtensions.Contains(Path.GetExtension(fileName).Substring(1), StringComparer.OrdinalIgnoreCase)) + { + throw new ArgumentException($"The gallery does not support the file type `{Path.GetExtension(fileName)}`."); + } + + fileName = $"{Path.GetFileNameWithoutExtension(fileName)}_{DateTime.UtcNow:yyyyMMddHHmmss}{Path.GetExtension(fileName)}"; + + var filePath = Path.Combine(server.GalleryPath, fileName); + + using var client = new HttpClient(); + using var stream = await client.GetStreamAsync(sourceUrl, cancellationToken); + using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None); + await stream.CopyToAsync(fileStream, cancellationToken); + } + public async Task> GetServerLogsAsync(string name, CancellationToken cancellationToken = default) { var server = await GetServerInfoAsync(name, cancellationToken); diff --git a/src/ServerManagerDiscordBot/ServersCommandModule.cs b/src/ServerManagerDiscordBot/ServersCommandModule.cs index 37f0228..16057b2 100644 --- a/src/ServerManagerDiscordBot/ServersCommandModule.cs +++ b/src/ServerManagerDiscordBot/ServersCommandModule.cs @@ -1,17 +1,20 @@ using System.Text; using Discord; using Discord.Interactions; +using Discord.Interactions.Builders; using Microsoft.Extensions.Options; using SmartFormat; public class ServersCommandModule( IOptions appSettings, + CommandManager commandManager, ServerManager serverManager, ILargeFileDownloadHandler largeFileDownloadHandler, ILogger logger) : InteractionModuleBase { public AppSettings AppSettings { get; } = appSettings.Value; + public CommandManager CommandManager { get; } = commandManager; public ServerManager ServerManager { get; } = serverManager; public ILargeFileDownloadHandler LargeFileDownloadHandler { get; } = largeFileDownloadHandler; public ILogger Logger { get; } = logger; @@ -113,6 +116,12 @@ private async Task Info(string name) .WithButton("Files", $"files|{name}", ButtonStyle.Secondary); includeContentActionsRow = true; } + if (AppSettings.EnableGallery && !string.IsNullOrWhiteSpace(info.GalleryPath)) + { + contentActionsRow + .WithButton("Gallery", $"gallery|{name}|1", ButtonStyle.Secondary); + includeContentActionsRow = true; + } if (includeContentActionsRow) { @@ -122,6 +131,24 @@ private async Task Info(string name) await FollowupAsync(embed: embed.Build(), components: component.Build(), ephemeral: true); } + [ComponentInteraction("status|*")] + public async Task Status(string name) + { + await DeferAsync(ephemeral: true); + + try + { + var status = await ServerManager.GetServerStatusAsync(name); + + await FollowupAsync($"The status of the `{name}` server is `{status}`.", ephemeral: true); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error in status interaction for server '{Name}'.", name); + await FollowupAsync($"Error: {ex.Message}", ephemeral: true); + } + } + [ComponentInteraction("start|*")] public async Task Start(string name) { @@ -139,6 +166,25 @@ public async Task Start(string name) } } + [ComponentInteraction("restart|*")] + public async Task Restart(string name) + { + await RespondAsync($"The `{name}` server is restarting...", ephemeral: true); + + try + { + await ServerManager.StopServerAsync(name, wait: true); + await ServerManager.StartServerAsync(name, wait: true); + + await FollowupAsync($"{Context.User.GlobalName} restarted the `{name}` server."); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error restarting server '{Name}'.", name); + await FollowupAsync($"Error: {ex.Message}", ephemeral: true); + } + } + [ComponentInteraction("stop|*")] public async Task Stop(string name) { @@ -156,21 +202,64 @@ public async Task Stop(string name) } } - [ComponentInteraction("restart|*")] - public async Task Restart(string name) + [ComponentInteraction("logs|*")] + public async Task Logs(string name) { - await RespondAsync($"The `{name}` server is restarting...", ephemeral: true); + await DeferAsync(ephemeral: true); + try + { + var logs = await ServerManager.GetServerLogsAsync(name); + + if (!logs.Any()) + { + await FollowupAsync($"No logs for the `{name}` server are available.", ephemeral: true); + return; + } + + var fileAttachments = logs.Select(log => new FileAttachment(log.Value, $"{log.Key}.log")).ToArray(); + await FollowupWithFilesAsync(fileAttachments, text: $"Logs for the `{name}` server:", ephemeral: true); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error in logs interaction for server '{Name}'.", name); + await FollowupAsync($"Error: {ex.Message}", ephemeral: true); + } + } + + [ComponentInteraction("readme|*")] + public async Task Readme(string name) + { + await DeferAsync(ephemeral: true); try { - await ServerManager.StopServerAsync(name, wait: true); - await ServerManager.StartServerAsync(name, wait: true); + var serverInfo = await ServerManager.GetServerInfoAsync(name); - await FollowupAsync($"{Context.User.GlobalName} restarted the `{name}` server."); + if (string.IsNullOrWhiteSpace(serverInfo.Readme)) + { + await FollowupAsync("No readme found.", ephemeral: true); + return; + } + + var readme = Smart.Format(serverInfo.Readme, serverInfo).Trim(); + var quotedReadme = "> " + readme.Replace("\n", "\n> "); + var response = $"Readme for the `{name}` server:"; + + if (response.Length + quotedReadme.Length < 2000) + { + await FollowupAsync($"{response}\n{quotedReadme}", ephemeral: true); + } + else + { + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(readme))) + { + await FollowupWithFileAsync(stream, "README.md", text: response, ephemeral: true); + } + } } catch (Exception ex) { - Logger.LogError(ex, "Error restarting server '{Name}'.", name); + Logger.LogError(ex, "Error in readme interaction for server '{Name}'.", name); await FollowupAsync($"Error: {ex.Message}", ephemeral: true); } } @@ -258,82 +347,104 @@ public async Task DownloadFile(string name, string fileName) } } - [ComponentInteraction("readme|*")] - public async Task Readme(string name) + [ComponentInteraction("gallery|*|*")] + public async Task Gallery(string name, int page) { + if (!AppSettings.EnableGallery) + { + await FollowupAsync("Gallery is disabled.", ephemeral: true); + return; + } + await DeferAsync(ephemeral: true); try { - var serverInfo = await ServerManager.GetServerInfoAsync(name); + var files = (await ServerManager.GetServerGalleryFilesAsync(name)).ToArray(); - if (string.IsNullOrWhiteSpace(serverInfo.Readme)) + if (files.Length == 0) { - await FollowupAsync("No readme found.", ephemeral: true); + await FollowupAsync("No gallery files found.", ephemeral: true); return; } - var readme = Smart.Format(serverInfo.Readme, serverInfo).Trim(); - var quotedReadme = "> " + readme.Replace("\n", "\n> "); - var response = $"Readme for the `{name}` server:"; + const int pageSize = 10; // Discord max image count + var hasPages = files.Length > pageSize; + var hasMore = files.Length > pageSize * page; + files = files.OrderByDescending(f => f.Name).Skip((page - 1) * pageSize).Take(pageSize).ToArray(); - if (response.Length + quotedReadme.Length < 2000) + var component = new ComponentBuilder(); + var actionsRow = new ActionRowBuilder(); + component.AddRow(actionsRow); + + if (hasMore) { - await FollowupAsync($"{response}\n{quotedReadme}", ephemeral: true); + actionsRow.WithButton("Load more", $"gallery|{name}|{page + 1}", ButtonStyle.Primary); } - else + + if (AppSettings.EnableGalleryUploads) { - using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(readme))) - { - await FollowupWithFileAsync(stream, "README.md", text: response, ephemeral: true); - } + actionsRow.WithButton("Upload", $"galleryupload|{name}", ButtonStyle.Secondary); } + + var imageAttachments = files.Select(f => new FileAttachment(f.OpenRead(), f.Name)).ToArray(); + + var pageText = hasPages ? $" (page {page})" : ""; + + await FollowupWithFilesAsync( + text: $"Gallery files for the `{name}` server{pageText}:", + attachments: imageAttachments, + components: component.Build(), + ephemeral: true); } catch (Exception ex) { - Logger.LogError(ex, "Error in readme interaction for server '{Name}'.", name); + Logger.LogError(ex, "Error in gallery interaction for server '{Name}'.", name); await FollowupAsync($"Error: {ex.Message}", ephemeral: true); } } - [ComponentInteraction("status|*")] - public async Task Status(string name) + [ComponentInteraction("galleryupload|*")] + public async Task GalleryUpload(string name) { - await DeferAsync(ephemeral: true); + if (!AppSettings.EnableGalleryUploads) + { + await FollowupAsync("Gallery uploads are disabled.", ephemeral: true); + return; + } + await DeferAsync(ephemeral: true); try { - var status = await ServerManager.GetServerStatusAsync(name); - - await FollowupAsync($"The status of the `{name}` server is `{status}`.", ephemeral: true); + var command = CommandManager.GetCommand("servers-gallup"); + await FollowupAsync($"Upload a file to the gallery using the command.", ephemeral: true); } catch (Exception ex) { - Logger.LogError(ex, "Error in status interaction for server '{Name}'.", name); + Logger.LogError(ex, "Error in gallery upload interaction for server '{Name}'.", name); await FollowupAsync($"Error: {ex.Message}", ephemeral: true); } } - [ComponentInteraction("logs|*")] - public async Task Logs(string name) + [SlashCommand("servers-gallup", "Upload a file to a server gallery.")] + public async Task GalleryUpload([Autocomplete(typeof(ServersAutocompleteHandler))] string name, IAttachment file) { - await DeferAsync(ephemeral: true); - try + if (!AppSettings.EnableGalleryUploads) { - var logs = await ServerManager.GetServerLogsAsync(name); + await FollowupAsync("Gallery uploads are disabled.", ephemeral: true); + return; + } - if (!logs.Any()) - { - await FollowupAsync($"No logs for the `{name}` server are available.", ephemeral: true); - return; - } + await DeferAsync(ephemeral: true); - var fileAttachments = logs.Select(log => new FileAttachment(log.Value, $"{log.Key}.log")).ToArray(); + try + { + await ServerManager.UploadServerGalleryFileAsync(name, file.Url, file.Filename); - await FollowupWithFilesAsync(fileAttachments, text: $"Logs for the `{name}` server:", ephemeral: true); + await FollowupAsync($"Uploaded file to the `{name}` server gallery."); } catch (Exception ex) { - Logger.LogError(ex, "Error in logs interaction for server '{Name}'.", name); + Logger.LogError(ex, "Error in gallery upload command for server '{Name}'.", name); await FollowupAsync($"Error: {ex.Message}", ephemeral: true); } }