diff --git a/DiscordBot/Extensions/UserExtensions.cs b/DiscordBot/Extensions/UserExtensions.cs index cbff8a42..1598f806 100644 --- a/DiscordBot/Extensions/UserExtensions.cs +++ b/DiscordBot/Extensions/UserExtensions.cs @@ -17,4 +17,21 @@ public static bool HasRoleGroup(this IUser user, ulong roleId) { return user is SocketGuildUser guildUser && guildUser.Roles.Any(x => x.Id == roleId); } + + // Returns the users DisplayName (nickname) if it exists, otherwise returns the username + public static string GetUserPreferredName(this IUser user) + { + var guildUser = user as SocketGuildUser; + return guildUser?.DisplayName ?? user.Username; + } + + public static string GetPreferredAndUsername(this IUser user) + { + var guildUser = user as SocketGuildUser; + if (guildUser == null) + return user.Username; + if (guildUser.DisplayName == user.Username) + return guildUser.DisplayName; + return $"{guildUser.DisplayName} (aka {user.Username})"; + } } \ No newline at end of file diff --git a/DiscordBot/Modules/ModerationModule.cs b/DiscordBot/Modules/ModerationModule.cs index 3b75e44c..198c364b 100644 --- a/DiscordBot/Modules/ModerationModule.cs +++ b/DiscordBot/Modules/ModerationModule.cs @@ -5,6 +5,8 @@ using DiscordBot.Settings; using Pathoschild.NaturalTimeParser.Parser; using DiscordBot.Attributes; +using DiscordBot.Utils; +using Org.BouncyCastle.Asn1.Cms; namespace DiscordBot.Modules; @@ -262,13 +264,8 @@ public async Task RulesCommand(IMessageChannel channel, int seconds = 60) { //Display rules of this channel for x seconds var rule = Rules.Channel.First(x => x.Id == 0); - IUserMessage m; - if (rule == null) - m = await ReplyAsync( - "There is no special rule for this channel.\nPlease follow global rules (you can get them by typing `!globalrules`)"); - else - m = await ReplyAsync( - $"{rule.Header}{(rule.Content.Length > 0 ? rule.Content : "There is no special rule for this channel.\nPlease follow global rules (you can get them by typing `!globalrules`)")}"); + var m = await ReplyAsync( + $"{rule.Header}{(rule.Content.Length > 0 ? rule.Content : "There is no special rule for this channel.\nPlease follow global rules (you can get them by typing `!globalrules`)")}"); var deleteAsync = Context.Message?.DeleteAsync(); if (deleteAsync != null) await deleteAsync; @@ -421,6 +418,7 @@ public async Task FullSync() } #region General Utility Commands + [Command("WelcomeMessageCount")] [Summary("Returns a count of pending welcome messages.")] [RequireModerator, HideFromHelp] @@ -440,6 +438,41 @@ public async Task WelcomeMessageCount() } await Context.Message.DeleteAsync(); } + + // Command to show the tags available for a specific channel, so the command needs to be run in a channel with tags or specific a channel id to check + [Command("ChannelTags")] + [Summary("Returns a list of tags for the current channel.")] + [RequireModerator, HideFromHelp] + public async Task ChannelTags(ulong channelId) + { + // Get the channel + var channel = await Context.Guild.GetChannelAsync(channelId); + + if (channel is not IForumChannel forumChannel) + { + await ReplyAsync($"<#{channelId}> is not a forum channel and has no tags.").DeleteAfterSeconds(seconds: 10); + return; + } + + var tags = forumChannel.Tags; + // If there are no tags, say so + if (tags.Count == 0) + { + await ReplyAsync($"<#{channelId}> has no tags.").DeleteAfterSeconds(seconds: 10); + return; + } + + // If there are tags, list them in an embed in format of (ID: `id` - Name: `name`) + var embed = new EmbedBuilder() + .WithTitle($"Tags for <#{channelId}>") + .WithDescription(string.Join("\n", tags.Select(tag => $"ID: `{tag.Id}` - Name: `{tag.Name}`")) + + $"\n\n{StringUtil.MessageSelfDestructIn(60)}") + .WithColor(Color.Blue) + .Build(); + + Context.Message.DeleteAsync(); + await ReplyAsync(embed: embed).DeleteAfterSeconds(seconds: 60); + } #endregion diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs index dece68ff..678d6227 100644 --- a/DiscordBot/Modules/UserModule.cs +++ b/DiscordBot/Modules/UserModule.cs @@ -131,7 +131,7 @@ public async Task QuoteMessage(ulong messageId, IMessageChannel channel = null) .WithFooter(footer => { footer - .WithText($"Quoted by {Context.User.Username}#{Context.User.Discriminator} • From channel {message.Channel.Name}") + .WithText($"Quoted by {Context.User.GetUserPreferredName()} • From channel {message.Channel.Name}") .WithIconUrl(Context.User.GetAvatarUrl()); }) .WithAuthor(author => diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs index 8f639ca0..35944b03 100644 --- a/DiscordBot/Modules/UserSlashModule.cs +++ b/DiscordBot/Modules/UserSlashModule.cs @@ -170,7 +170,7 @@ public async Task ModalResponse(ulong id, ReportMessageModal modal) .WithFooter(footer => { footer - .WithText($"Reported by {Context.User.Username}#{Context.User.Discriminator} • From channel {reportedMessage.Channel.Name}") + .WithText($"Reported by {Context.User.GetPreferredAndUsername()} • From channel {reportedMessage.Channel.Name}") .WithIconUrl(Context.User.GetAvatarUrl()); }) .WithAuthor(author => diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 71c407a4..7212effb 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -64,10 +64,11 @@ private async Task MainAsync() ?.GetTextChannel(_settings.BotAnnouncementChannel.Id) ?.SendMessageAsync("Bot Started."); - LoggingService.LogToConsole("Bot is connected.", LogSeverity.Info); + LoggingService.LogToConsole("Bot is connected.", ExtendedLogSeverity.Positive); _isInitialized = true; _unityHelpService = _services.GetRequiredService<UnityHelpService>(); + _services.GetRequiredService<RecruitService>(); } return Task.CompletedTask; }; @@ -91,6 +92,7 @@ private IServiceProvider ConfigureServices() => .AddSingleton<PublisherService>() .AddSingleton<FeedService>() .AddSingleton<UnityHelpService>() + .AddSingleton<RecruitService>() .AddSingleton<UpdateService>() .AddSingleton<CurrencyService>() .AddSingleton<ReactRoleService>() diff --git a/DiscordBot/Services/DatabaseService.cs b/DiscordBot/Services/DatabaseService.cs index 06c4137e..5c10c87d 100644 --- a/DiscordBot/Services/DatabaseService.cs +++ b/DiscordBot/Services/DatabaseService.cs @@ -9,6 +9,8 @@ namespace DiscordBot.Services; public class DatabaseService { + private const string ServiceName = "DatabaseService"; + private readonly ILoggingService _logging; private string ConnectionString { get; } @@ -39,20 +41,33 @@ public DatabaseService(ILoggingService logging, BotSettings settings) try { var userCount = await _connection.TestConnection(); - await _logging.LogAction($"DatabaseService: Connected to database successfully. {userCount} users in database."); + await _logging.LogAction($"{ServiceName}: Connected to database successfully. {userCount} users in database."); + LoggingService.LogToConsole($"{ServiceName}: Connected to database successfully. {userCount} users in database.", ExtendedLogSeverity.Positive); } - catch (Exception) + catch { LoggingService.LogToConsole( "DatabaseService: Table 'users' does not exist, attempting to generate table.", - LogSeverity.Warning); - c.ExecuteSql( - "CREATE TABLE `users` (`ID` int(11) UNSIGNED NOT NULL, `UserID` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, `Karma` int(11) UNSIGNED NOT NULL DEFAULT 0, `KarmaWeekly` int(11) UNSIGNED NOT NULL DEFAULT 0, `KarmaMonthly` int(11) UNSIGNED NOT NULL DEFAULT 0, `KarmaYearly` int(11) UNSIGNED NOT NULL DEFAULT 0, `KarmaGiven` int(11) UNSIGNED NOT NULL DEFAULT 0, `Exp` bigint(11) UNSIGNED NOT NULL DEFAULT 0, `Level` int(11) UNSIGNED NOT NULL DEFAULT 0) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); - c.ExecuteSql("ALTER TABLE `users` ADD PRIMARY KEY (`ID`,`UserID`), ADD UNIQUE KEY `UserID` (`UserID`)"); - c.ExecuteSql( - "ALTER TABLE `users` MODIFY `ID` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1"); + ExtendedLogSeverity.LowWarning); + try + { + c.ExecuteSql( + "CREATE TABLE `users` (`ID` int(11) UNSIGNED NOT NULL, `UserID` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, `Karma` int(11) UNSIGNED NOT NULL DEFAULT 0, `KarmaWeekly` int(11) UNSIGNED NOT NULL DEFAULT 0, `KarmaMonthly` int(11) UNSIGNED NOT NULL DEFAULT 0, `KarmaYearly` int(11) UNSIGNED NOT NULL DEFAULT 0, `KarmaGiven` int(11) UNSIGNED NOT NULL DEFAULT 0, `Exp` bigint(11) UNSIGNED NOT NULL DEFAULT 0, `Level` int(11) UNSIGNED NOT NULL DEFAULT 0) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + c.ExecuteSql( + "ALTER TABLE `users` ADD PRIMARY KEY (`ID`,`UserID`), ADD UNIQUE KEY `UserID` (`UserID`)"); + c.ExecuteSql( + "ALTER TABLE `users` MODIFY `ID` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1"); + } + catch (Exception e) + { + LoggingService.LogToConsole( + $"SQL Exception: Failed to generate table 'users'.\nMessage: {e}", + LogSeverity.Critical); + c.Close(); + return; + } LoggingService.LogToConsole("DatabaseService: Table 'users' generated without errors.", - LogSeverity.Info); + ExtendedLogSeverity.Positive); c.Close(); } @@ -134,7 +149,7 @@ public async Task AddNewUser(SocketGuildUser socketUser) await Query().InsertUser(user); await _logging.LogAction( - $"User {socketUser.Username}#{socketUser.DiscriminatorValue.ToString()} successfully added to the database.", + $"User {socketUser.GetPreferredAndUsername()} successfully added to the database.", true, false); } diff --git a/DiscordBot/Services/LoggingService.cs b/DiscordBot/Services/LoggingService.cs index c94be032..64d84681 100644 --- a/DiscordBot/Services/LoggingService.cs +++ b/DiscordBot/Services/LoggingService.cs @@ -5,6 +5,43 @@ namespace DiscordBot.Services.Logging; +#region Extended Log Severity + +// We use DNets built in severity levels, but we add a few more for internal logging. +public enum ExtendedLogSeverity +{ + Critical = LogSeverity.Critical, + Error = LogSeverity.Error, + Warning = LogSeverity.Warning, + Info = LogSeverity.Info, + Verbose = LogSeverity.Verbose, + Debug = LogSeverity.Debug, + // Extended levels + Positive = 10, // Positive(info) is green in the console + LowWarning = 11, // LowWarning(warning) is yellow in the console +} + +public static class ExtendedLogSeverityExtensions +{ + public static LogSeverity ToLogSeverity(this ExtendedLogSeverity severity) + { + return severity switch + { + ExtendedLogSeverity.Positive => LogSeverity.Info, + ExtendedLogSeverity.LowWarning => LogSeverity.Warning, + _ => (LogSeverity)severity + }; + } + + public static ExtendedLogSeverity ToExtended(this LogSeverity severity) + { + return (ExtendedLogSeverity)severity; + } + +} + +#endregion // Extended Log Severity + public class LoggingService : ILoggingService { private readonly DiscordSocketClient _client; @@ -45,7 +82,7 @@ public static string ConsistentDateTimeFormat() // Logs DiscordNet specific messages, this shouldn't be used for normal logging public static Task DiscordNetLogger(LogMessage message) { - LoggingService.LogToConsole($"{message.Source} | {message.Message}", message.Severity); + LoggingService.LogToConsole($"{message.Source} | {message.Message}", message.Severity.ToExtended()); return Task.CompletedTask; } #region Console Messages @@ -53,7 +90,8 @@ public static Task DiscordNetLogger(LogMessage message) public static void LogConsole(string message) { Console.WriteLine($"[{ConsistentDateTimeFormat()}] {message}"); } - public static void LogToConsole(string message, LogSeverity severity = LogSeverity.Info) + + public static void LogToConsole(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info) { ConsoleColor restoreColour = Console.ForegroundColor; SetConsoleColour(severity); @@ -62,39 +100,59 @@ public static void LogToConsole(string message, LogSeverity severity = LogSeveri Console.ForegroundColor = restoreColour; } + public static void LogToConsole(string message, LogSeverity severity) => LogToConsole(message, severity.ToExtended()); + public static void LogServiceDisabled(string service, string varName) + { + LogToConsole($"Service \"{service}\" is Disabled, {varName} is false in settings.json", ExtendedLogSeverity.Warning); + } + + public static void LogServiceEnabled(string service) + { + LogToConsole($"Service \"{service}\" is Enabled", ExtendedLogSeverity.Info); + } + /// <summary> /// Same behaviour as LogToConsole, however this method is not included in the release build. /// Good if you need more verbose but obvious logging, but don't want it included in release. /// </summary> [Conditional("DEBUG")] - public static void DebugLog(string message, LogSeverity severity = LogSeverity.Info) + public static void DebugLog(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info) { LogToConsole(message, severity); } - - private static void SetConsoleColour(LogSeverity severity) + [Conditional("DEBUG")] + public static void DebugLog(string message, LogSeverity severity) => DebugLog(message, severity.ToExtended()); + + private static void SetConsoleColour(ExtendedLogSeverity severity) { switch (severity) { - case LogSeverity.Critical: - case LogSeverity.Error: + case ExtendedLogSeverity.Critical: + case ExtendedLogSeverity.Error: Console.ForegroundColor = ConsoleColor.Red; break; - case LogSeverity.Warning: + case ExtendedLogSeverity.Warning: Console.ForegroundColor = ConsoleColor.Yellow; break; - case LogSeverity.Info: + case ExtendedLogSeverity.Info: Console.ForegroundColor = ConsoleColor.White; break; - case LogSeverity.Verbose: - case LogSeverity.Debug: + case ExtendedLogSeverity.Positive: + Console.ForegroundColor = ConsoleColor.Green; + break; + case ExtendedLogSeverity.LowWarning: + Console.ForegroundColor = ConsoleColor.DarkYellow; + break; + // case ExtendedLogSeverity.Verbose: + // case ExtendedLogSeverity.Debug: + default: Console.ForegroundColor = ConsoleColor.DarkGray; break; } } #endregion -} +} public interface ILoggingService { diff --git a/DiscordBot/Services/ModerationService.cs b/DiscordBot/Services/ModerationService.cs index ebbeb6ab..1e6e30c6 100644 --- a/DiscordBot/Services/ModerationService.cs +++ b/DiscordBot/Services/ModerationService.cs @@ -7,6 +7,10 @@ public class ModerationService { private readonly ILoggingService _loggingService; private readonly BotSettings _settings; + + private const int MaxMessageLength = 800; + private static readonly Color DeletedMessageColor = new (200, 128, 128); + private static readonly Color EditedMessageColor = new (255, 255, 128); public ModerationService(DiscordSocketClient client, BotSettings settings, ILoggingService loggingService) { @@ -14,6 +18,7 @@ public ModerationService(DiscordSocketClient client, BotSettings settings, ILogg _loggingService = loggingService; client.MessageDeleted += MessageDeleted; + client.MessageUpdated += MessageUpdated; } private async Task MessageDeleted(Cacheable<IMessage, ulong> message, Cacheable<IMessageChannel, ulong> channel) @@ -22,11 +27,12 @@ private async Task MessageDeleted(Cacheable<IMessage, ulong> message, Cacheable< return; var content = message.Value.Content; - if (content.Length > 800) - content = content.Substring(0, 800); + if (content.Length > MaxMessageLength) + content = content[..MaxMessageLength]; + var user = message.Value.Author; var builder = new EmbedBuilder() - .WithColor(new Color(200, 128, 128)) + .WithColor(DeletedMessageColor) .WithTimestamp(message.Value.Timestamp) .WithFooter(footer => { @@ -36,14 +42,64 @@ private async Task MessageDeleted(Cacheable<IMessage, ulong> message, Cacheable< .WithAuthor(author => { author - .WithName($"{message.Value.Author.Username}"); + .WithName($"{user.GetPreferredAndUsername()} deleted a message"); }) - .AddField("Deleted message", content); + .AddField($"Deleted Message {(content.Length != message.Value.Content.Length ? "(truncated)" : "")}", + content); var embed = builder.Build(); + // TimeStamp for the Footer + await _loggingService.LogAction( - $"User {message.Value.Author.Username}#{message.Value.Author.DiscriminatorValue} has " + + $"User {user.GetPreferredAndUsername()} has " + $"deleted the message\n{content}\n from channel #{(await channel.GetOrDownloadAsync()).Name}", true, false); await _loggingService.LogAction(" ", false, true, embed); } + + private async Task MessageUpdated(Cacheable<IMessage, ulong> before, SocketMessage after, ISocketMessageChannel channel) + { + if (after.Author.IsBot || channel.Id == _settings.BotAnnouncementChannel.Id) + return; + + bool isCached = true; + string content = ""; + var beforeMessage = await before.GetOrDownloadAsync(); + if (beforeMessage == null || beforeMessage.Content == after.Content) + isCached = false; + else + content = beforeMessage.Content; + + bool isTruncated = false; + if (content.Length > MaxMessageLength) + { + content = content[..MaxMessageLength]; + isTruncated = true; + } + + var user = after.Author; + var builder = new EmbedBuilder() + .WithColor(EditedMessageColor) + .WithTimestamp(after.Timestamp) + .WithFooter(footer => + { + footer + .WithText($"In channel {after.Channel.Name}"); + }) + .WithAuthor(author => + { + author + .WithName($"{user.GetPreferredAndUsername()} updated a message"); + }); + if (isCached) + builder.AddField($"Previous message content {(isTruncated ? "(truncated)" : "")}", content); + builder.WithDescription($"Message: [{after.Id}]({after.GetJumpUrl()})"); + var embed = builder.Build(); + + // TimeStamp for the Footer + + await _loggingService.LogAction( + $"User {user.GetPreferredAndUsername()} has " + + $"updated the message\n{content}\n in channel #{channel.Name}", true, false); + await _loggingService.LogAction(" ", false, true, embed); + } } \ No newline at end of file diff --git a/DiscordBot/Services/ReactRoleService.cs b/DiscordBot/Services/ReactRoleService.cs index 19aa34ab..5dbc422a 100644 --- a/DiscordBot/Services/ReactRoleService.cs +++ b/DiscordBot/Services/ReactRoleService.cs @@ -33,6 +33,10 @@ public ReactRoleService(DiscordSocketClient client, ILoggingService logging, Bot _settings = settings; _client = client; + + if (!_settings.ReactRoleServiceEnabled) + return; + _client.ReactionAdded += ReactionAdded; _client.ReactionRemoved += ReactionRemoved; diff --git a/DiscordBot/Services/Recruitment/RecruitService.cs b/DiscordBot/Services/Recruitment/RecruitService.cs new file mode 100644 index 00000000..5289d144 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitService.cs @@ -0,0 +1,365 @@ +using Discord.WebSocket; +using DiscordBot.Settings; +using DiscordBot.Utils; + +namespace DiscordBot.Services; + +public class RecruitService +{ + private const string ServiceName = "RecruitmentService"; + + private readonly DiscordSocketClient _client; + private readonly ILoggingService _logging; + private SocketRole ModeratorRole { get; set; } + + #region Extra Details + + private readonly ForumTag _tagIsHiring; + private readonly ForumTag _tagWantsWork; + private readonly ForumTag _tagUnpaidCollab; + private readonly ForumTag _tagPosFilled; + + private readonly IForumChannel _recruitChannel; + + #endregion // Extra Details + + #region Configuration + + private Color DeletedMessageColor => new (255, 50, 50); + private Color WarningMessageColor => new (255, 255, 100); + private Color EditedMessageColor => new (100, 255, 100); + + private const int TimeBeforeDeletingForumInSec = 30; + private const string _messageToBeDeleted = "Your thread will be deleted in %s because it did not follow the expected guidelines. Try again after the slow mode period has passed."; + + private const int MinimumLengthMessage = 120; + private const int ShortMessageNoticeDurationInSec = 30 * 4; + + private readonly int _editTimePermissionInMin; + private const string _messageToBeEdited = "This post will remain editable until %s, make any desired changes to your thread. After that the thread will be locked."; + + + private Embed _userHiringButNoPrice; + private Embed _userWantsWorkButNoPrice; + + private Embed _userDidntUseTags; + private Embed _userRevShareMentioned; + private Embed _userMoreThanOneTagUsed; + + Dictionary<ulong, bool> _botSanityCheck = new Dictionary<ulong, bool>(); + + #endregion // Configuration + + public RecruitService(DiscordSocketClient client, ILoggingService logging, BotSettings settings) + { + _client = client; + _logging = logging; + ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.ModeratorRoleId); + + if (!settings.RecruitmentServiceEnabled) + { + LoggingService.LogServiceDisabled(ServiceName, nameof(settings.RecruitmentServiceEnabled)); + return; + } + _editTimePermissionInMin = settings.EditPermissionAccessTimeMin; + + // Get target channel + _recruitChannel = _client.GetChannel(settings.RecruitmentChannel.Id) as IForumChannel; + if (_recruitChannel == null) + { + LoggingService.LogToConsole("[{ServiceName}] Recruitment channel not found.", LogSeverity.Error); + return; + } + + try + { + var lookingToHire = ulong.Parse(settings.TagLookingToHire); + var lookingForWork = ulong.Parse(settings.TagLookingForWork); + var unpaidCollab = ulong.Parse(settings.TagUnpaidCollab); + var positionFilled = ulong.Parse(settings.TagPositionFilled); + + var availableTags = _recruitChannel.Tags; + _tagIsHiring = availableTags.First(x => x.Id == lookingToHire); + _tagWantsWork = availableTags.First(x => x.Id == lookingForWork); + _tagUnpaidCollab = availableTags.First(x => x.Id == unpaidCollab); + _tagPosFilled = availableTags.First(x => x.Id == positionFilled); + + // If any tags are null we print a logging warning + if (_tagIsHiring == null) StartUpTagMissing(lookingToHire, nameof(settings.TagLookingToHire)); + if (_tagWantsWork == null) StartUpTagMissing(lookingForWork, nameof(settings.TagLookingForWork)); + if (_tagUnpaidCollab == null) StartUpTagMissing(unpaidCollab, nameof(settings.TagUnpaidCollab)); + if (_tagPosFilled == null) StartUpTagMissing(positionFilled, nameof(settings.TagPositionFilled)); + } + catch (Exception e) + { + LoggingService.LogToConsole($"[{ServiceName}] Error parsing recruitment tags: {e.Message}", LogSeverity.Error); + } + + // Subscribe to events + _client.ThreadCreated += GatewayOnThreadCreated; + + ConstructEmbeds(); + + LoggingService.LogServiceEnabled(ServiceName); + } + + #region Thread Creation + + private async Task GatewayOnThreadCreated(SocketThreadChannel thread) + { + if (!thread.IsThreadInChannel(_recruitChannel.Id)) + return; + if (thread.Owner.IsUserBotOrWebhook()) + return; + if (thread.Owner.HasRoleGroup(ModeratorRole)) + return; + + #region Sanity Check + // Oddly the thread is sometimes called twice, so we do a sanity check to make sure we don't process it twice. + // Probably a better way to do this, but this is pretty cheap operation. + if (_botSanityCheck.ContainsKey(thread.Id)) + { + _botSanityCheck.Remove(thread.Id); + return; + } + if (_botSanityCheck.Count > 10) + _botSanityCheck.Clear(); + _botSanityCheck.Add(thread.Id, true); + #endregion // Sanity Check + + LoggingService.DebugLog($"[{ServiceName}] New Thread Created: {thread.Id} - {thread.Name}", LogSeverity.Debug); + + var message = (await thread.GetMessagesAsync(1).FlattenAsync()).FirstOrDefault(); + if (message == null) + { + LoggingService.LogToConsole($"[{ServiceName}] Thread {thread.Id} has no messages.", LogSeverity.Error); + return; + } + + Task.Run(async () => + { + if (thread.AppliedTags.Count == 0) + { + await ThreadHandleNoTags(thread); + return; + } + + if (IsThreadUsingMoreThanOneTag(thread)) + { + await ThreadHandleMoreThanOneTag(thread); + return; + } + + bool isPaidWork = (thread.AppliedTags.Contains(_tagWantsWork.Id) || + thread.AppliedTags.Contains(_tagIsHiring.Id)); + + if (isPaidWork) + { + if (!message.Content.ContainsCurrencySymbol()) + { + await ThreadHandleExpectedCurrency(thread); + return; + } + await ThreadHandleRevShare(thread, message); + } + + // Any Notices that we can recommend the user for improvement + if (message.Content.Length < MinimumLengthMessage) + { + Task.Run(() => ThreadHandleShortMessage(thread, message)); + } + + await Task.Delay(millisecondsDelay: 200); + + // If they got this far, they have a valid thread. + await GrantEditPermissions(thread); + // The above method will await for 30~ minutes, so we need to check if the thread is still valid. + + // Confirm user hasn't deleted the thread + var channel = await _client.GetChannelAsync(thread.Id) as SocketThreadChannel; + if (channel == null) + return; + // Confirm the message still exists + var threadMessage = await (channel.GetMessageAsync(thread.Id)); + if (threadMessage == null) + return; + + // We do one last check to make sure the thread is still valid + if (isPaidWork && !threadMessage.Content.ContainsCurrencySymbol()) + { + await ThreadHandleExpectedCurrency(channel); + } + }); + } + + #endregion // Thread Creation + + #region Basic Handlers for posts + + private async Task ThreadHandleExpectedCurrency(SocketThreadChannel thread) + { + var embedToUse = thread.AppliedTags.Contains(_tagWantsWork.Id) + ? _userWantsWorkButNoPrice + : _userHiringButNoPrice; + await thread.SendMessageAsync(embed: embedToUse); + await DeleteThread(thread); + } + + private async Task ThreadHandleRevShare(SocketThreadChannel thread, IMessage message) + { + if (message.Content.ContainsRevShare()) + { + await thread.SendMessageAsync(embed: _userRevShareMentioned); + } + } + + private async Task ThreadHandleMoreThanOneTag(SocketThreadChannel thread) + { + await thread.SendMessageAsync(embed: _userMoreThanOneTagUsed); + await DeleteThread(thread); + } + + private async Task ThreadHandleNoTags(SocketThreadChannel thread) + { + await thread.SendMessageAsync(embed: _userDidntUseTags); + await DeleteThread(thread); + } + + private async Task ThreadHandleShortMessage(SocketThreadChannel thread, IMessage message) + { + if (message.Content.Length < MinimumLengthMessage) + { + var ourResponse = await thread.SendMessageAsync(embed: GetShortMessageEmbed()); + await ourResponse.DeleteAfterSeconds(ShortMessageNoticeDurationInSec); + } + } + + private async Task GrantEditPermissions(SocketThreadChannel thread) + { + var parentChannel = thread.ParentChannel; + var message = await thread.SendMessageAsync(embed: GetEditPermMessageEmbed()); + await parentChannel.AddPermissionOverwriteAsync(thread.Owner, new OverwritePermissions(sendMessages: PermValue.Allow)); + + // We give them a bit of time to edit their post, then remove the permission + await message.DeleteAfterSeconds((_editTimePermissionInMin * 60) + 5); + await parentChannel.RemovePermissionOverwriteAsync(thread.Owner); + } + + #endregion // Basic Handlers for posts + + #region Basic Logging Assisst + + private static void StartUpTagMissing(ulong tagId, string tagName) + { + LoggingService.LogToConsole($"[{ServiceName}] Tag {tagId} not found. '{tagName}'", LogSeverity.Error); + } + + #endregion // Basic Logging Assisst + + #region Embed Construction + + private void ConstructEmbeds() + { + _userHiringButNoPrice = new EmbedBuilder() + .WithTitle("No payment price detected") + .WithDescription( + $"You have used the `{_tagIsHiring.Name}` tag but have not specified a price of any kind.\n\nPost **must** include a currency symbol or word, e.g. $, dollars, USD, £, pounds, €, EUR, euro, euros, GBP.") + .WithColor(DeletedMessageColor) + .Build(); + + _userWantsWorkButNoPrice = new EmbedBuilder() + .WithTitle("No payment price detected") + .WithDescription( + $"You have used the `{_tagWantsWork.Name}` tag but have not specified a price of any kind.\n\nPost **must** include a currency symbol or word, e.g. $, dollars, USD, £, pounds, €, EUR, euro, euros, GBP.") + .WithColor(DeletedMessageColor) + .Build(); + + _userRevShareMentioned = new EmbedBuilder() + .WithTitle("Notice: Rev-Share mentioned") + .WithDescription( + $"Rev-share isn't considered a valid form of payment for `{_tagIsHiring.Name}` or `{_tagWantsWork.Name}` as it's not guaranteed. " + + $"Consider using the `{_tagUnpaidCollab.Name}` tag instead if you intend to use rev-share as a source of payment.") + .WithColor(WarningMessageColor) + .Build(); + + _userMoreThanOneTagUsed = new EmbedBuilder() + .WithTitle("Broken Guideline: Colliding tags used") + .WithDescription( + $"You may only use one of the following tags: `{_tagIsHiring.Name}`, `{_tagWantsWork.Name}` or `{_tagUnpaidCollab.Name}`\n\n" + + "Be sure to read the guidelines before posting.") + .WithColor(DeletedMessageColor) + .Build(); + + _userDidntUseTags = new EmbedBuilder() + .WithTitle("Broken Guideline: No tags used") + .WithDescription( + $"You must use one of the following tags: `{_tagIsHiring.Name}`, `{_tagWantsWork.Name}` or `{_tagUnpaidCollab.Name}`\n\n" + + "Be sure to read the guidelines before posting.") + .WithColor(DeletedMessageColor) + .Build(); + } + + private Embed GetDeletedMessageEmbed() + { + var message = _messageToBeDeleted.Replace("%s", GetDynamicTimeStampString(TimeBeforeDeletingForumInSec)); + return new EmbedBuilder() + .WithTitle("Post does not follow guidelines") + .WithDescription(message) + .WithColor(DeletedMessageColor) + .Build(); + } + + private Embed GetEditPermMessageEmbed() + { + var message = _messageToBeEdited.Replace("%s", GetDynamicTimeStampString(_editTimePermissionInMin * 60)); + return new EmbedBuilder() + .WithTitle("Edit permissions granted") + .WithDescription(message) + .WithColor(EditedMessageColor) + .Build(); + } + + private Embed GetShortMessageEmbed() + { + var timestamp = GetDynamicTimeStampString(ShortMessageNoticeDurationInSec); + return new EmbedBuilder() + .WithTitle("Notice: Post is short") + .WithDescription( + $"Your post should provide more information to convince others, we recommend at least {MinimumLengthMessage} characters, " + + "which is still very short.\n\nYou should consider editing your post to contain more info otherwise it may be deleted by staff.\n\n" + + $"*This is only a notice and will be removed {timestamp}, can be ignored.*") + .WithColor(WarningMessageColor) + .Build(); + } + + #endregion // Embed Construction + + #region Basic Utility + + private bool IsThreadUsingMoreThanOneTag(SocketThreadChannel thread) + { + int clashingTagCount = 0; + var tags = thread.AppliedTags; + + if (tags.Contains(_tagIsHiring.Id)) clashingTagCount++; + if (tags.Contains(_tagWantsWork.Id)) clashingTagCount++; + if (tags.Contains(_tagUnpaidCollab.Id)) clashingTagCount++; + + return clashingTagCount > 1; + } + + private async Task DeleteThread(SocketThreadChannel thread) + { + await thread.SendMessageAsync(embed: GetDeletedMessageEmbed()); + await thread.DeleteAfterSeconds(TimeBeforeDeletingForumInSec); + } + + private string GetDynamicTimeStampString(int addSeconds) + { + var timestamp = DateTimeOffset.Now.AddSeconds(addSeconds); + return $"<t:{timestamp.ToUnixTimeSeconds()}:R>"; + } + + #endregion // Basic Utility + +} \ No newline at end of file diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index 2e88e8fd..61faedb3 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -8,6 +8,8 @@ namespace DiscordBot.Services; public class UnityHelpService { + private const string ServiceName = "UnityHelpService"; + private readonly DiscordSocketClient _client; private readonly ILoggingService _logging; private SocketRole ModeratorRole { get; set; } @@ -83,11 +85,17 @@ public UnityHelpService(DiscordSocketClient client, BotSettings settings, ILoggi ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.ModeratorRoleId); + if (!settings.UnityHelpBabySitterEnabled) + { + LoggingService.LogServiceDisabled(ServiceName, nameof(settings.UnityHelpBabySitterEnabled)); + return; + } + // get the help channel settings.GenericHelpChannel _helpChannel = _client.GetChannel(settings.GenericHelpChannel.Id) as IForumChannel; if (_helpChannel == null) { - LoggingService.LogToConsole("[UnityHelpService] Help channel not found", LogSeverity.Error); + LoggingService.LogToConsole($"[{ServiceName}] Help channel not found", LogSeverity.Error); } var resolvedTag = _helpChannel!.Tags.FirstOrDefault(x => x.Name == ResolvedTag); _resolvedForumTag = resolvedTag; @@ -106,6 +114,8 @@ public UnityHelpService(DiscordSocketClient client, BotSettings settings, ILoggi _client.MessageUpdated += GatewayOnMessageUpdated; Task.Run(LoadActiveThreads); + + LoggingService.LogServiceEnabled(ServiceName); } private async Task LoadActiveThreads() @@ -207,7 +217,7 @@ private Task GatewayOnThreadCreated(SocketThreadChannel thread) if (_activeThreads.ContainsKey(thread.Id)) return Task.CompletedTask; - LoggingService.DebugLog($"[UnityHelpService] New Thread Created: {thread.Id} - {thread.Name}", LogSeverity.Debug); + LoggingService.DebugLog($"[{ServiceName}] New Thread Created: {thread.Id} - {thread.Name}", LogSeverity.Debug); Task.Run(() => OnThreadCreated(thread)); return Task.CompletedTask; @@ -281,7 +291,7 @@ private async Task GatewayOnThreadUpdated(Cacheable<SocketThreadChannel, ulong> return; } - LoggingService.DebugLog($"[UnityHelpService] Thread Updated: {after.Id} - {after.Name}", LogSeverity.Debug); + LoggingService.DebugLog($"[{ServiceName}] Thread Updated: {after.Id} - {after.Name}", LogSeverity.Debug); #pragma warning disable CS4014 Task.Run(() => OnThreadUpdated(beforeThread, afterThread)); @@ -302,7 +312,7 @@ private async Task GatewayOnThreadDeleted(Cacheable<SocketThreadChannel, ulong> if (!_activeThreads.ContainsKey(threadId.Id)) return; - LoggingService.DebugLog($"[UnityHelpService] Thread Deleted: {threadId.Id}", LogSeverity.Debug); + LoggingService.DebugLog($"[{ServiceName}] Thread Deleted: {threadId.Id}", LogSeverity.Debug); var thread = await threadId.GetOrDownloadAsync(); #pragma warning disable CS4014 @@ -378,7 +388,7 @@ private Task GatewayOnMessageReceived(SocketMessage message) if (!_activeThreads.TryGetValue(message.Channel.Id, out var thread)) return Task.CompletedTask; - LoggingService.DebugLog($"[UnityHelpService] Help Message Received: {message.Id} - {message.Content}", LogSeverity.Debug); + LoggingService.DebugLog($"[{ServiceName}] Help Message Received: {message.Id} - {message.Content}", LogSeverity.Debug); Task.Run(() => OnMessageReceived(message)); return Task.CompletedTask; } @@ -419,7 +429,7 @@ private async Task GatewayOnMessageUpdated(Cacheable<IMessage, ulong> before, So if (beforeMsg == null) return; - LoggingService.DebugLog($"[UnityHelpService] Help Message Updated: {after.Id} - {after.Content}", LogSeverity.Debug); + LoggingService.DebugLog($"[{ServiceName}] Help Message Updated: {after.Id} - {after.Content}", LogSeverity.Debug); #pragma warning disable CS4014 Task.Run(() => OnMessageUpdated(beforeMsg, after, channel as SocketThreadChannel)); #pragma warning restore CS4014 @@ -708,7 +718,7 @@ private Task<bool> IsTaskCancelled(ThreadContainer thread) return Task.FromResult(false); if (thread.CancellationToken.IsCancellationRequested) { - LoggingService.DebugLog($"[UnityHelpService] Task cancelled for Channel {thread.ThreadId}"); + LoggingService.DebugLog($"[{ServiceName}] Task cancelled for Channel {thread.ThreadId}"); return Task.FromResult(true); } return Task.FromResult(false); diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index 5d10afe2..bec0d61b 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -33,9 +33,9 @@ public class UserService private readonly BotSettings _settings; private readonly Dictionary<ulong, DateTime> _thanksCooldown; - private readonly Dictionary<ulong, DateTime> _everyoneScoldCooldown = new Dictionary<ulong, DateTime>(); + private readonly Dictionary<ulong, DateTime> _everyoneScoldCooldown = new(); - private readonly List<(ulong id, DateTime time)> _welcomeNoticeUsers = new List<(ulong id, DateTime time)>(); + private readonly List<(ulong id, DateTime time)> _welcomeNoticeUsers = new(); private readonly int _thanksCooldownTime; private readonly int _thanksMinJoinTime; @@ -165,13 +165,13 @@ private async Task UserLeft(SocketGuild guild, SocketUser user) var timeStayed = DateTime.Now - joinDate; await _loggingService.LogAction( $"User Left - After {(timeStayed.Days > 1 ? Math.Floor((double)timeStayed.Days) + " days" : " ")}" + - $" {Math.Floor((double)timeStayed.Hours).ToString(CultureInfo.InvariantCulture)} hours {user.Mention} - `{user.Username}#{user.DiscriminatorValue}` - ID : `{user.Id}`"); + $" {Math.Floor((double)timeStayed.Hours).ToString(CultureInfo.InvariantCulture)} hours {user.Mention} - `{guildUser.GetPreferredAndUsername()}` - ID : `{user.Id}`"); } // If bot is to slow to get user info, we just say they left at current time. else { await _loggingService.LogAction( - $"User `{user.Username}#{user.DiscriminatorValue}` - ID : `{user.Id}` - Left at {DateTime.Now}"); + $"User `{guildUser.GetPreferredAndUsername()}` - ID : `{user.Id}` - Left at {DateTime.Now}"); } } @@ -336,7 +336,7 @@ public async Task<string> GenerateProfileCard(IUser user) MaxXpShown = maxXpShown, Nickname = ((IGuildUser)user).Nickname, UserId = ulong.Parse(userData.UserID), - Username = user.Username, + Username = user.GetPreferredAndUsername(), XpHigh = xpHigh, XpLow = xpLow, XpPercentage = percentage, @@ -392,10 +392,8 @@ public async Task<string> GenerateProfileCard(IUser user) profileCardPath = $"{_settings.ServerRootPath}/images/profiles/{user.Username}-profile.png"; - using (var result = profileCard.Mosaic()) - { - result.Write(profileCardPath); - } + using var result = profileCard.Mosaic(); + result.Write(profileCardPath); } catch (Exception e) { @@ -412,14 +410,15 @@ public Embed WelcomeMessage(SocketGuildUser user) { string icon = user.GetAvatarUrl(); icon = string.IsNullOrEmpty(icon) ? "https://cdn.discordapp.com/embed/avatars/0.png" : icon; - + + string welcomeString = $"Welcome to Unity Developer Community {user.GetPreferredAndUsername()}!"; var builder = new EmbedBuilder() - .WithDescription($"Welcome to Unity Developer Community **{user.Username}#{user.Discriminator}**!") + .WithDescription(welcomeString) .WithColor(_welcomeColour) .WithAuthor(author => { author - .WithName(user.Username) + .WithName(user.GetUserPreferredName()) .WithIconUrl(icon); }); @@ -652,7 +651,7 @@ private async Task UserJoined(SocketGuildUser user) { await user.AddRoleAsync(socketTextChannel?.Guild.GetRole(_settings.MutedRoleId)); await _loggingService.LogAction( - $"Currently muted user rejoined - {user.Mention} - `{user.Username}#{user.DiscriminatorValue}` - ID : `{user.Id}`"); + $"Currently muted user rejoined - {user.Mention} - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}`"); if (socketTextChannel != null) await socketTextChannel.SendMessageAsync( $"{user.Mention} tried to rejoin the server to avoid their mute. Mute time increased by 72 hours."); @@ -662,7 +661,7 @@ await socketTextChannel.SendMessageAsync( } await _loggingService.LogAction( - $"User Joined - {user.Mention} - `{user.Username}#{user.DiscriminatorValue}` - ID : `{user.Id}`"); + $"User Joined - {user.Mention} - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}`"); // We check if they're already in the welcome list, if they are we don't add them again to avoid double posts if (_welcomeNoticeUsers.Count == 0 || !_welcomeNoticeUsers.Exists(u => u.id == user.Id)) @@ -674,6 +673,9 @@ await _loggingService.LogAction( // Welcomes users to the server after they've been connected for over x number of seconds. private async Task DelayedWelcomeService() { + ulong currentlyProcessedUserId = 0; + bool firstRun = true; + await Task.Delay(10000); try { while (true) @@ -683,18 +685,35 @@ private async Task DelayedWelcomeService() // We loop through our list, anyone that has been in the list for more than x seconds is welcomed. foreach (var userData in _welcomeNoticeUsers.Where(u => u.time < now)) { + currentlyProcessedUserId = userData.id; await ProcessWelcomeUser(userData.id, null); } - + + if (firstRun) + firstRun = false; await Task.Delay(10000); } } catch (Exception e) { // Catch and show exception - LoggingService.LogToConsole($"UserService Exception during welcome message.\n{e.Message}", + LoggingService.LogToConsole($"UserService Exception during welcome message `{currentlyProcessedUserId}`.\n{e.Message}", LogSeverity.Error); - await _loggingService.LogAction($"UserService Exception during welcome message.\n{e.Message}.", false, true); + await _loggingService.LogAction($"UserService Exception during welcome message `{currentlyProcessedUserId}`.\n{e.Message}.", true, true); + + // Remove the offending user from the dictionary and run the service again. + _welcomeNoticeUsers.RemoveAll(u => u.id == currentlyProcessedUserId); + if (_welcomeNoticeUsers.Count > 200) + { + _welcomeNoticeUsers.Clear(); + await _loggingService.LogAction("UserService: Welcome list cleared due to size (+200), this should not happen.", true, true); + } + + if (firstRun) + await _loggingService.LogAction("UserService: Welcome service failed on first run!? This should not happen.", true, true); + + // Run the service again. + Task.Run(DelayedWelcomeService); } } @@ -704,17 +723,19 @@ private async Task ProcessWelcomeUser(ulong userID, IUser user = null) { // Remove the user from the welcome list. _welcomeNoticeUsers.RemoveAll(u => u.id == userID); - + // If we didn't get the user passed in, we try grab it user ??= await _client.GetUserAsync(userID); // if they're null, they've likely left, so we just remove them from the list. - if (user != null) - { - var offTopic = await _client.GetChannelAsync(_settings.GeneralChannel.Id) as SocketTextChannel; - var em = WelcomeMessage(user as SocketGuildUser); - if (offTopic != null) - await offTopic.SendMessageAsync(string.Empty, false, em); - } + if (user == null) + return; + + var offTopic = await _client.GetChannelAsync(_settings.GeneralChannel.Id) as SocketTextChannel; + if (user is not SocketGuildUser guildUser) + return; + var em = WelcomeMessage(guildUser); + if (offTopic != null && em != null) + await offTopic.SendMessageAsync(string.Empty, false, em); } } @@ -767,8 +788,8 @@ private async Task UserUpdated(Cacheable<SocketGuildUser, ulong> oldUserCached, if (oldUser.Nickname != user.Nickname) { await _loggingService.LogAction( - $"User {oldUser.Nickname ?? oldUser.Username}#{oldUser.DiscriminatorValue} changed his " + - $"username to {user.Nickname ?? user.Username}#{user.DiscriminatorValue}"); + $"User {oldUser.GetUserPreferredName()} changed his " + + $"username to {user.GetUserPreferredName()}"); } } diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index e1550076..72e83f7b 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -27,6 +27,17 @@ public class BotSettings #endregion // Fun Commands + #region Service Enabling + // Used for enabling/disabling services in the bot + + public bool RecruitmentServiceEnabled { get; set; } = false; + + public bool UnityHelpBabySitterEnabled { get; set; } = false; + + public bool ReactRoleServiceEnabled { get; set; } = false; + + #endregion // Service Enabling + #endregion // Configuration #region Asset Publisher @@ -54,10 +65,9 @@ public class BotSettings public RulesChannel RulesChannel { get; set; } // Recruitment Channels - public WorkForHireChannel LookingToHire { get; set; } - public WorkForHireChannel LookingForWork { get; set; } - public CollaborationChannel CollaborationChannel { get; set; } + public RecruitmentChannel RecruitmentChannel { get; set; } + public ChannelInfo ReportedMessageChannel { get; set; } #region Complaint Channel @@ -89,6 +99,17 @@ public class BotSettings #endregion // User Roles + #region Recruitment Thread + + public string TagLookingToHire { get; set; } + public string TagLookingForWork { get; set; } + public string TagUnpaidCollab { get; set; } + public string TagPositionFilled { get; set; } + + public int EditPermissionAccessTimeMin { get; set; } = 3; + + #endregion // Recruitment Thread Tags + #region API Keys public string WeatherAPIKey { get; set; } @@ -193,11 +214,7 @@ public class UnityReleasesChannel : ChannelInfo { } -public class WorkForHireChannel : ChannelInfo -{ -} - -public class CollaborationChannel : ChannelInfo +public class RecruitmentChannel : ChannelInfo { } diff --git a/DiscordBot/Settings/Settings.example.json b/DiscordBot/Settings/Settings.example.json index d00e0d62..cb1b355f 100644 --- a/DiscordBot/Settings/Settings.example.json +++ b/DiscordBot/Settings/Settings.example.json @@ -56,18 +56,6 @@ "desc": "Unity News Channel", "id": "0" }, - "LookingToHire": { - "desc": "Looking To Hire Channel", - "id": "0" // Currently Unused 29/04/21 - }, - "LookingForWork": { - "desc": "Looking For Work Channel", - "id": "0" // Currently Unused 29/04/21 - }, - "CollaborationChannel": { - "desc": "Collaboration Channel", - "id": "0" // Currently Unused 29/04/21 - }, "ReportedMessageChannel": { "desc": "Reported Message Channel", "id": "0" @@ -91,5 +79,25 @@ "WeatherAPIKey": "", // Key for openweathermap.org "FlightAPIKey": "", "FlightAPISecret": "", - "AirLabAPIKey": "" + "AirLabAPIKey": "", + /* Recruitment Service */ + "RecruitmentServiceEnabled": false, + "RecruitmentChannel": { // Recruitment + "desc": "Channel for job postings", + "id": "0" + }, + "TagLookingToHire": "0", + "TagLookingForWork": "0", + "TagUnpaidCollab": "0", + "TagPositionFilled": "0", + "EditPermissionAccessTimeMin": 3, + /* Unity Help Service */ + "UnityHelpBabySitterEnabled": false, + "genericHelpChannel": { + // Unity-help + "desc": "Unity-Help Channel", + "id": "0" + }, + /* React Role Service */ + "ReactRoleServiceEnabled": false } \ No newline at end of file diff --git a/DiscordBot/Utils/StringUtil.cs b/DiscordBot/Utils/StringUtil.cs new file mode 100644 index 00000000..154041a1 --- /dev/null +++ b/DiscordBot/Utils/StringUtil.cs @@ -0,0 +1,27 @@ +using System.Text.RegularExpressions; + +namespace DiscordBot.Utils; + +public static class StringUtil +{ + private static readonly Regex CurrencyRegex = + new (@"(?:\$\s*\d+|\d+\s*\$|\d*\s*(?:USD|£|pounds|€|EUR|euro|euros|GBP))", RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(250)); + private static readonly Regex RevShareRegex = new (@"\b(?:rev-share|revshare|rev share)\b", RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(250)); + + // a string extension that checks if the contents of the string contains a limited selection of currency symbols/words + public static bool ContainsCurrencySymbol(this string str) + { + return !string.IsNullOrWhiteSpace(str) && CurrencyRegex.IsMatch(str); + } + + public static bool ContainsRevShare(this string str) + { + return !string.IsNullOrWhiteSpace(str) && RevShareRegex.IsMatch(str); + } + + public static string MessageSelfDestructIn(int secondsFromNow) + { + var time = DateTime.Now.ToUnixTimestamp() + secondsFromNow; + return $"Self-delete: **<t:{time}:R>**"; + } +} \ No newline at end of file diff --git a/README.md b/README.md index 2d91a0be..420e4c1e 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,29 @@ Join us on [Discord](https://discord.gg/bu3bbby) ! The code is provided as-is and there will be no guaranteed support to help make it run. +# Table Of Contents +<!-- Link to all the headers --> +- [Compiling](#compiling) + - [Dependencies](#dependencies) +- [Running](#running) + - [Docker](#docker) + - [Runtime Dependencies](#runtime-dependencies) +- [Notes](#notes) + - [Logging](#logging) + - [Discord.Net](#discordnet) +- [FAQ](#faq) + ## Compiling ### Dependencies To successfully compile you will need the following. - - [Discord.Net](https://www.nuget.org/packages/Discord.Net/) -- [Visual Studio](https://visualstudio.microsoft.com/vs/community/) +- [Visual Studio](https://visualstudio.microsoft.com/vs/community/) (or IDE of choice) - [.NET Core SDK](https://www.microsoft.com/net/download/core) +- [Docker](https://www.docker.com/get-started) (Optional, but recommended) + +>NOTE: It is highly recommended to install docker and docker-compose to run at least the database in a container for local development. ## Running @@ -28,9 +42,15 @@ _Several comments have been placed throughout the settings file to indicate what _If you plan on running the bot outside of the IDE, you will want to give [Discord Net Deployment](https://discord.foxbot.me/docs/guides/deployment/deployment.html) a read._ +### Docker +Running docker should be as simple as running `docker-compose up` in the root directory of the project. This will use the docker-compose.yml file to build the bot and database images and run them. You may need to change the DbConnectionString in the [settings.json](https://github.com/Unity-Developer-Community/UDC-Bot/tree/dev/DiscordBot/Settings) file to match the docker-compose.yml file configuration. + +> Note: Docker will also create an image of the bot, it may be much quicker to run the bot yourself from the IDE if you're making changes. +> You can also use `docker-compose up --build` to force a rebuild of the bot image, but I would advise to disable the bot in the docker-compose.yml file, and run the bot from the IDE. + ### Runtime Dependencies -You will need an accessible SQL database setup, the easiest way to do this is using XAMPP which can host the database and make it easier to setup. +If you choose to not use docker, you will need to host the database yourself and ensure the bot can connect to it. You will need an accessible SQL database setup, the easiest way to do this is using XAMPP which can host the database and make it easier to setup. - [XAMPP](https://www.apachefriends.org/download.html) @@ -45,12 +65,16 @@ On Linux you might need `sudo apt install ttf-mscorefonts-installer` for ImageMa ## Notes -I'll re-introduce some of this later. -~~When you hit run, you'll probably see some warnings and errors if you've sped through this without much thought.~~ -~~- **_Yellow_** : Warnings _(The bot will continue to run, but may disable some features)_~~ -~~- **_Red_** : Errors _(Usually a pending exception/crash is moments away)_~~ +### Logging + +The bot does attempt to log issues it runs into during startup, not everything is covered yet and a lot of it is stripped from the release build. +A basic Enum is used, Critical/Error uses Red, Warning uses Yellow, Info is White and Verbose/Debug is Gray. ([LoggingService](https://github.com/Unity-Developer-Community/UDC-Bot/blob/dev/DiscordBot/Services/LoggingService.cs#L71)) + +During startup, if you see any yellow/red, good chance something is wrong. + +### Discord.Net -I strongly suggest giving [Discord.Net API Documention](https://discord.foxbot.me/stable/api/index.html) a read when interacting with systems you haven't seen before. Discord Net uses Tasks, Asynchronous Patterns and heavy use of Polymorphism, some systems might not always be straight forward. +I strongly suggest giving [Discord.Net API Documention](https://discordnet.dev/guides/introduction/intro.html) a read when interacting with systems you haven't seen before. Discord Net uses Tasks, Asynchronous Patterns and heavy use of Polymorphism, some systems might not always be straight forward. ## FAQ