diff --git a/DiscordBot/Modules/TipModule.cs b/DiscordBot/Modules/TipModule.cs new file mode 100644 index 00000000..0423737e --- /dev/null +++ b/DiscordBot/Modules/TipModule.cs @@ -0,0 +1,195 @@ +using System.IO; +using Discord.Commands; +using DiscordBot.Attributes; +using DiscordBot.Services; +using DiscordBot.Services.Tips; +using DiscordBot.Services.Tips.Components; +using DiscordBot.Settings; + +// ReSharper disable all UnusedMember.Local +namespace DiscordBot.Modules; + +public class TipModule : ModuleBase +{ + #region Dependency Injection + + public CommandHandlingService CommandHandlingService { get; set; } + public BotSettings Settings { get; set; } + public TipService TipService { get; set; } + + #endregion + + [Command("Tip")] + [Summary("Find and provide pre-authored tips (images or text) by their keywords.")] + /* for now */ [RequireModerator] /* maybe Helper too */ + public async Task Tip(string keywords) + { + var tips = TipService.GetTips(keywords); + if (tips.Count == 0) + { + await ReplyAsync("No tips for the keywords provided were found."); + return; + } + + var isAnyTextTips = tips.Any(tip => !string.IsNullOrEmpty(tip.Content)); + var builder = new EmbedBuilder(); + if (isAnyTextTips) + { + // Loop through tips in order, have dot point list of the .Content property in an embed + builder + .WithTitle("Tip List") + .WithDescription("Here are the tips for your keywords:"); + foreach (var tip in tips) + { + builder.AddField(tip.Keywords.Count == 1 ? tip.Keywords[0] : "Multiple Keywords", tip.Content); + } + } + + var attachments = tips + .Where(tip => tip.ImagePaths != null && tip.ImagePaths.Any()) + .SelectMany(tip => tip.ImagePaths) + .Select(imagePath => new FileAttachment(TipService.GetTipPath(imagePath))) + .ToList(); + + if (attachments.Count > 0) + { + if (isAnyTextTips) + { + await Context.Channel.SendFilesAsync(attachments, embed: builder.Build()); + } + else + { + await Context.Channel.SendFilesAsync(attachments); + } + } + else + { + await ReplyAsync(embed: builder.Build()); + } + + var ids = string.Join(" ", tips.Select(t => t.Id.ToString()).ToArray()); + await ReplyAsync($"-# Tip ID {ids}"); + await Context.Message.DeleteAsync(); + } + + [Command("AddTip")] + [Summary("Add a tip to the database.")] + [RequireModerator] + public async Task AddTip(string keywords, string content = "") + { + await TipService.AddTip(Context.Message, keywords, content); + } + + [Command("RemoveTip")] + [Summary("Remove a tip from the database.")] + [RequireModerator] + public async Task RemoveTip(ulong tipId) + { + Tip tip = TipService.GetTip(tipId); + if (tip == null) + { + await Context.Channel.SendMessageAsync("No such tip found to be removed."); + return; + } + + await TipService.RemoveTip(Context.Message, tip); + } + + [Command("ReplaceTip")] + [Summary("Replace image content of an existing tip in the database.")] + [RequireModerator] + public async Task ReplaceTip(ulong tipId, string content = "") + { + Tip tip = TipService.GetTip(tipId); + if (tip == null) + { + await Context.Channel.SendMessageAsync("No such tip found to be replaced."); + return; + } + + await TipService.ReplaceTip(Context.Message, tip, content); + } + + [Command("DumpTips")] + [Summary("For debugging, view the tip index.")] + [RequireModerator] + public async Task DumpTipDatabase() + { + string json = TipService.DumpTipDatabase(); + string prefix = "Tip database index as JSON:\n"; + int chunkSize = 1800; + int chunkTime = 2000; + while (!string.IsNullOrEmpty(json)) + { + string chunk = json; + if (json.Length > chunkSize) + { + chunk = json.Substring(0, chunkSize); + json = json.Substring(chunkSize); + } + else + { + json = string.Empty; + } + await Context.Channel.SendMessageAsync( + $"{prefix}```\n{chunk}\n```"); + prefix = string.Empty; + if (!string.IsNullOrEmpty(json)) + await Task.Delay(chunkTime); + } + } + + [Command("ListTips")] + [Summary("List available tips by their keywords.")] + [RequireModerator] + public async Task ListTips() + { + List tips = TipService.GetAllTips().OrderBy(t => t.Id).ToList(); + int chunkCount = 10; + int chunkTime = 2000; + bool first = true; + + while (tips.Count > 0) + { + var builder = new EmbedBuilder(); + if (first) + { + builder + .WithTitle("List of Tips") + .WithDescription("Tips available for the following keywords:"); + first = false; + } + + int chunk = 0; + while (tips.Count > 0 && chunk < chunkCount) + { + string keywords = string.Join("`, `", tips[0].Keywords.OrderBy(k => k)); + string images = String.Concat( + Enumerable.Repeat(" :frame_photo:", + tips[0].ImagePaths.Count).ToArray()); + builder.AddField($"ID: {tips[0].Id} {images}", $"`{keywords}`"); + tips.RemoveAt(0); + chunk++; + } + + await ReplyAsync(embed: builder.Build()); + if (tips.Count > 0) + await Task.Delay(chunkTime); + } + } + + #region CommandList + [Command("TipHelp")] + [Alias("TipsHelp")] + [Summary("Shows available tip database commands.")] + public async Task TipHelp() + { + // NOTE: skips the RequireModerator commands, so nearly an empty list + foreach (var message in CommandHandlingService.GetCommandListMessages("TipModule", true, true, false)) + { + await ReplyAsync(message); + } + } + #endregion + +} diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 08655ad2..11711cba 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -4,6 +4,7 @@ using Discord.WebSocket; using DiscordBot.Service; using DiscordBot.Services; +using DiscordBot.Services.Tips; using DiscordBot.Settings; using DiscordBot.Utils; using Microsoft.Extensions.DependencyInjection; @@ -104,6 +105,7 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .BuildServiceProvider(); @@ -114,4 +116,4 @@ private static void DeserializeSettings() _rules = SerializeUtil.DeserializeFile(@"Settings/Rules.json"); _userSettings = SerializeUtil.DeserializeFile(@"Settings/UserSettings.json"); } -} \ No newline at end of file +} diff --git a/DiscordBot/Services/Tips/Components/Tip.cs b/DiscordBot/Services/Tips/Components/Tip.cs new file mode 100644 index 00000000..f7962ad4 --- /dev/null +++ b/DiscordBot/Services/Tips/Components/Tip.cs @@ -0,0 +1,11 @@ +using Discord; + +namespace DiscordBot.Services.Tips.Components; + +public class Tip: IEntity +{ + public ulong Id { get; set; } + public string Content { get; set; } + public List Keywords { get; set; } + public List ImagePaths { get; set; } +} diff --git a/DiscordBot/Services/Tips/TipService.cs b/DiscordBot/Services/Tips/TipService.cs new file mode 100644 index 00000000..b94490c0 --- /dev/null +++ b/DiscordBot/Services/Tips/TipService.cs @@ -0,0 +1,339 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; +using System.IO; +using System.Net; +using System.Net.Http; +using Discord; +using Discord.WebSocket; +using DiscordBot.Services.Tips.Components; +using DiscordBot.Settings; +using Newtonsoft.Json; + +namespace DiscordBot.Services.Tips; + +public class TipService +{ + private const string ServiceName = "TipService"; + private const string DatabaseName = "tips.json"; + + private readonly BotSettings _settings; + private readonly ILoggingService _loggingService; + private readonly string _imageDirectory; + + private ConcurrentDictionary> _tips = new(); + private bool _isRunning = false; + private bool _readOnly = false; + + private Regex keywordPattern = null; + + public TipService(BotSettings settings, ILoggingService loggingService) + { + _settings = settings; + _loggingService = loggingService; + + if (string.IsNullOrEmpty(_settings.ServerRootPath)) + { + _loggingService.LogAction($"[{ServiceName}] ServerRootPath not set, service will not run.", ExtendedLogSeverity.Warning); + _isRunning = false; + return; + } + + if (string.IsNullOrEmpty(_settings.TipImageDirectory)) + { + _loggingService.LogAction($"[{ServiceName}] TipImageDirectory not set, service will not run.", ExtendedLogSeverity.Warning); + _isRunning = false; + return; + } + + _imageDirectory = Path.Combine(_settings.ServerRootPath, _settings.TipImageDirectory); + + Initialize(); + } + + private void Initialize() + { + if (_isRunning) return; + + _readOnly = false; + var jsonPath = GetTipPath(DatabaseName);; + if (!Directory.Exists(_imageDirectory)) + { + _loggingService.LogAction($"[{ServiceName}] Tip directory {_imageDirectory} did not exist.", ExtendedLogSeverity.Info); + Directory.CreateDirectory(_imageDirectory); + File.WriteAllText(jsonPath, "{}"); + } + else + { + var directorySize = new DirectoryInfo(_imageDirectory).EnumerateFiles("*.*", SearchOption.AllDirectories).Sum(file => file.Length); + if (directorySize > _settings.TipMaxDirectoryFileSize) + { + _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, exceeding the limit of {_settings.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB, no additional content will be added during this session.", ExtendedLogSeverity.Warning); + _readOnly = true; + } + else + { + _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, within the limit of {_settings.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB.", ExtendedLogSeverity.Info); + _loggingService.LogAction($"[{ServiceName}] Tip directory contains {new DirectoryInfo(_imageDirectory).EnumerateFiles("*.*", SearchOption.AllDirectories).Count()} files.", + ExtendedLogSeverity.Info); + } + + if (File.Exists(jsonPath)) + { + var json = File.ReadAllText(jsonPath); + _tips = JsonConvert.DeserializeObject>>(json); + _loggingService.LogAction( + $"[{ServiceName}] Tip index has {_tips.Count} keywords.", + ExtendedLogSeverity.Info); + //NOTE: elements of type Tip are not de-duplicated after loading + } + } + + _isRunning = true; + } + + private bool IsValidTipKeyword(string keyword) + { + // Start with ascii letter + // continue with ascii letters, digits, limited punctuation + // no whitespace, no commas + // + // valid examples: "dr.mendeleev" "f451" "wash_hands" "Poe's-Law" + // + if (keywordPattern == null) + keywordPattern = new Regex(@"^[a-z][a-z.0-9_'-]*$", RegexOptions.IgnoreCase); + + if (!keywordPattern.IsMatch(keyword)) + return false; + + return true; + } + + private bool IsValidTipAttachment(IAttachment attachment) + { + if (attachment.Size > _settings.TipMaxImageFileSize) + return false; + + // Discord-friendly attachment image file formats only + // + if (attachment.Filename.EndsWith(".png")) return true; + if (attachment.Filename.EndsWith(".webp")) return true; + if (attachment.Filename.EndsWith(".jpg")) return true; + + return false; + } + + public string GetTipPath(string filename) + { + return Path.Combine(_imageDirectory, filename); + } + + public async Task AddTip(IUserMessage message, string keywords, string content) + { + if (_readOnly) + { + await message.Channel.SendMessageAsync("Cannot add or modify tips in the database at this time."); + return; + } + + if (string.IsNullOrEmpty(keywords)) + { + await message.Channel.SendMessageAsync("No valid keywords given to store a new tip."); + return; + } + + var keywordList = keywords.Split(',') + .Select(k => k.Trim()) + .Where(IsValidTipKeyword) + .ToList(); + if (keywordList.Count == 0) + { + await message.Channel.SendMessageAsync("No valid keywords given to store a new tip."); + return; + } + + var imagePaths = new List(); + foreach (var attachment in message.Attachments) + { + if (!IsValidTipAttachment(attachment)) + continue; + + var newFileName = + Guid.NewGuid().ToString() + + attachment.Filename.Substring(attachment.Filename.LastIndexOf('.')); + var filePath = GetTipPath(newFileName); + + using var client = new HttpClient(); + await using var stream = await client.GetStreamAsync(attachment.Url); + await using var file = File.Create(filePath); + await stream.CopyToAsync(file); + + imagePaths.Add(newFileName); + } + + // Need content and/or a valid attachment + if (imagePaths.Count == 0 && string.IsNullOrEmpty(content)) + { + await message.Channel.SendMessageAsync("No valid content given to store a new tip."); + return; + } + + ulong id = message.Id; + var tip = new Tip + { + Id = id, + Content = content, + Keywords = keywordList, + ImagePaths = imagePaths + }; + + foreach (var keyword in keywordList) + { + _tips.AddOrUpdate(keyword, new List { tip }, (key, list) => + { + list.Add(tip); + return list; + }); + } + + await CommitTipDatabase(); + + string words = string.Join("`, `", keywordList); + await _loggingService.LogAction( + $"[{ServiceName}] Added tip from {message.Author.Username} with keywords `{words}`.", + ExtendedLogSeverity.Info); + + // Send a confirmation message + if (message.Channel is SocketTextChannel textChannel) + { + var builder = new EmbedBuilder() + .WithTitle("Tip Added") + .WithDescription($"Your tip has been added with the keywords `{words}` and ID {tip.Id}.") + .WithColor(Color.Green); + + // TODO: (James) Attach the images if they exist? + + await textChannel.SendMessageAsync(embed: builder.Build()); + } + } + + public async Task RemoveTip(IUserMessage message, Tip tip) + { + if (tip == null) + { + await message.Channel.SendMessageAsync("No such tip found to be removed."); + return; + } + + foreach (string keyword in tip.Keywords) + { + if (!_tips.ContainsKey(keyword)) + continue; + _tips[keyword].RemoveAll(t => t.Id == tip.Id); + if (_tips[keyword].Count == 0) + _tips.Remove(keyword, out _); + } + + foreach (string imagePath in tip.ImagePaths) + { + try + { + File.Delete(GetTipPath(imagePath)); + } + catch (Exception e) + { + await _loggingService.LogAction( + $"[{ServiceName}] Failed to remove tip image: {e}", ExtendedLogSeverity.Warning); + } + } + + await CommitTipDatabase(); + + string keywords = string.Join("`, `", tip.Keywords); + await message.Channel.SendMessageAsync($"Removed a tip with keywords `{keywords}`."); + } + + public async Task ReplaceTip(IUserMessage message, Tip tip, string content) + { + if (_readOnly) + { + await message.Channel.SendMessageAsync("Cannot add or modify tips in the database at this time."); + return; + } + + if (tip == null) + { + await message.Channel.SendMessageAsync("No such tip found to be replaced."); + return; + } + + RemoveTip(message, tip); + AddTip(message, string.Join(",", tip.Keywords), content); + // REVIEW: causes two CommitTipDatabase calls + } + + private async Task CommitTipDatabase() + { + // In same folder, we save json files + var jsonPath = GetTipPath(DatabaseName); + await File.WriteAllTextAsync(jsonPath, JsonConvert.SerializeObject(_tips)); + } + + public string DumpTipDatabase() + { + return JsonConvert.SerializeObject(_tips); + } + + public Tip GetTip(ulong Id) + { + foreach (var kvp in _tips) + foreach (var tip in kvp.Value) + if (tip.Id == Id) + return tip; + return null; + } + + public List GetAllTips() + { + var found = new List(); + + foreach (string keyword in _tips.Keys) + foreach (Tip tip in _tips[keyword]) + if (found.All(t => t.Id != tip.Id)) + found.Add(tip); + + return found; + } + + public List GetTips(string keywords) + { + var found = new List(); + if (string.IsNullOrEmpty(keywords)) + return found; + + // TODO: if keywords looks numeric, get one tip based on id + + var keywordList = keywords.Split(',') + .Select(k => k.Trim()) + .Where(IsValidTipKeyword) + .ToList(); + +#if true + // boolean AND search + foreach (string keyword in keywordList) + if (_tips.TryGetValue(keyword, out var tips)) + foreach (Tip tip in tips) + if (tip.Keywords.Intersect(keywordList).Count() == keywordList.Count) + if (found.All(t => t.Id != tip.Id)) + found.Add(tip); +#else + // boolean OR search + foreach (string keyword in keywordList) + if (_tips.TryGetValue(keyword, out var tips)) + foreach (Tip tip in tips) + if (found.All(t => t.Id != tip.Id)) + found.Add(tip); +#endif + + return found; + } +} diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index f8d0d30e..342fe0cb 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -115,6 +115,16 @@ public class BotSettings #endregion // Recruitment Thread Tags #region Unity Help Threads + + #region Tips + + public string TipImageDirectory { get; set; } + + public int TipMaxImageFileSize { get; set; } = 1024 * 1024 * 10; // 10MB + // Unlikely, but we prevent exploitation by limiting the max directory size to avoid VPS disk space issues + public int TipMaxDirectoryFileSize { get; set; } = 1024 * 1024 * 1024; // 1GB + + #endregion // Tips public string TagUnitHelpResolvedTag { get; set; } @@ -191,4 +201,4 @@ public string GenerateFirstMessage(IUser author) } } -#endregion \ No newline at end of file +#endregion