diff --git a/DiscordBot/Modules/TipModule.cs b/DiscordBot/Modules/TipModule.cs new file mode 100644 index 00000000..cc4fc083 --- /dev/null +++ b/DiscordBot/Modules/TipModule.cs @@ -0,0 +1,90 @@ +using System.IO; +using Discord.Commands; +using DiscordBot.Attributes; +using DiscordBot.Services; +using DiscordBot.Services.Tips; +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.")] + 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)); + EmbedBuilder 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(Path.Combine(Settings.TipImageDirectory, 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()); + } + } + + [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); + } + + #region CommandList + + [Summary("Does what you see now.")] + [Command("Ticket Help")] + public async Task TicketHelp() + { + 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..4b4fdb3a --- /dev/null +++ b/DiscordBot/Services/Tips/Components/Tip.cs @@ -0,0 +1,8 @@ +namespace DiscordBot.Services.Tips.Components; + +public class Tip +{ + 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..8c39e285 --- /dev/null +++ b/DiscordBot/Services/Tips/TipService.cs @@ -0,0 +1,143 @@ +using System.Collections.Concurrent; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text.RegularExpressions; +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 readonly BotSettings _settings; + private readonly ILoggingService _loggingService; + private readonly string _imageDirectory; + + private ConcurrentDictionary> _tips = new(); + private bool _isRunning = false; + + public TipService(BotSettings settings, ILoggingService loggingService) + { + _settings = settings; + _loggingService = loggingService; + + if (string.IsNullOrEmpty(_settings.TipImageDirectory)) + { + _loggingService.LogAction($"[{ServiceName}] TipImageDirectory not set, service will not run.", ExtendedLogSeverity.Warning); + _isRunning = false; + return; + } + + _imageDirectory = Path.Combine(Directory.GetCurrentDirectory(), _settings.TipImageDirectory); + + Initialize(); + } + + private void Initialize() + { + if (_isRunning) return; + + if (!Directory.Exists(_imageDirectory)) + { + Directory.CreateDirectory(_imageDirectory); + File.WriteAllText(Path.Combine(_imageDirectory, "tips.json"), "{}"); + } + 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 / 1024} MB, exceeding the limit of {_settings.TipMaxDirectoryFileSize / 1024 / 1024} MB, no additional content will be added during this session.", ExtendedLogSeverity.Warning); + } + else + { + _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024} MB, within the limit of {_settings.TipMaxDirectoryFileSize / 1024 / 1024} MB.", ExtendedLogSeverity.Info); + _loggingService.LogAction($"[{ServiceName}] Tip directory contains {new DirectoryInfo(_imageDirectory).EnumerateFiles("*.*", SearchOption.AllDirectories).Count()} files.", + ExtendedLogSeverity.Info); + } + + var jsonPath = Path.Combine(_imageDirectory, "tips.json"); + if (File.Exists(jsonPath)) + { + var json = File.ReadAllText(jsonPath); + _tips = JsonConvert.DeserializeObject>>(json); + } + } + + _isRunning = true; + } + + public async Task AddTip(IUserMessage message, string keywords, string content) + { + var keywordList = keywords.Split(',').Select(k => k.Trim()).ToList(); + var imagePaths = new List(); + + foreach (var attachment in message.Attachments) + { + if (!attachment.Filename.EndsWith(".png") && !attachment.Filename.EndsWith(".webp") && !attachment.Filename.EndsWith(".jpg")) continue; + var newFileName = Guid.NewGuid().ToString() + attachment.Filename.Substring(attachment.Filename.LastIndexOf('.')); + var filePath = Path.Combine(_imageDirectory, newFileName); + if (attachment.Size > _settings.TipMaxImageFileSize) + { + continue; + } + + 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); + } + + var tip = new Tip + { + Content = content, + Keywords = keywordList, + ImagePaths = imagePaths + }; + + foreach (var keyword in keywordList) + { + _tips.AddOrUpdate(keyword, new List { tip }, (key, list) => + { + list.Add(tip); + return list; + }); + } + + // In same folder, we save json files + var jsonPath = Path.Combine(_imageDirectory, "tips.json"); + await File.WriteAllTextAsync(jsonPath, JsonConvert.SerializeObject(_tips)); + + await _loggingService.LogAction($"[{ServiceName}] Added tip from {message.Author.Username} with keywords {string.Join(", ", keywordList)}.", 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 `{string.Join(", ", keywordList)}`.") + .WithColor(Color.Green); + + // TODO: (James) Attach the images if they exist? + + await textChannel.SendMessageAsync(embed: builder.Build()); + } + } + + public List GetTips(string keyword) + { + var regex = new Regex(keyword, RegexOptions.IgnoreCase); + return _tips.Where(kvp => kvp.Key.Split(',').Any(k => regex.IsMatch(k))) + .SelectMany(kvp => kvp.Value) + .Distinct() + .ToList(); + } +}