diff --git a/DiscordBot/Database/MongoOptions.cs b/DiscordBot/Database/MongoOptions.cs index 421e7d3..f6e8a2d 100644 --- a/DiscordBot/Database/MongoOptions.cs +++ b/DiscordBot/Database/MongoOptions.cs @@ -12,5 +12,6 @@ public class MongoOptions public string SubWordsCollectionName { get; set; } = "SubWords"; public string VotesCollectionName { get; set; } = "Votes"; + public string UserSettingsCollectionName { get; set; } = "UserSettings"; } } diff --git a/DiscordBot/DiscordBot.csproj b/DiscordBot/DiscordBot.csproj index 238fc07..ebbbcfa 100644 --- a/DiscordBot/DiscordBot.csproj +++ b/DiscordBot/DiscordBot.csproj @@ -15,7 +15,7 @@ https://github.com/DevSubmarine/DiscordBot git bot; discord; devsub; devsubmarine - 1.0.1 + 1.1.0 @@ -75,6 +75,7 @@ + diff --git a/DiscordBot/Features/BlogsManagement/BlogManagementCommands.cs b/DiscordBot/Features/BlogsManagement/BlogManagementCommands.cs index c44d535..1dc85dd 100644 --- a/DiscordBot/Features/BlogsManagement/BlogManagementCommands.cs +++ b/DiscordBot/Features/BlogsManagement/BlogManagementCommands.cs @@ -1,6 +1,7 @@ using Discord; using Discord.Interactions; using Discord.Net; +using TehGM.Utilities.Randomization; namespace DevSubmarine.DiscordBot.BlogsManagement.Services { @@ -10,14 +11,16 @@ public class BlogManagementCommands : DevSubInteractionModule { private readonly IBlogChannelManager _manager; private readonly IBlogChannelNameConverter _nameConverter; + private readonly IRandomizer _randomizer; private readonly BlogsManagementOptions _options; private readonly ILogger _log; - public BlogManagementCommands(IBlogChannelManager manager, IBlogChannelNameConverter nameConverter, + public BlogManagementCommands(IBlogChannelManager manager, IBlogChannelNameConverter nameConverter, IRandomizer randomizer, IOptionsMonitor options, ILogger log) { this._manager = manager; this._nameConverter = nameConverter; + this._randomizer = randomizer; this._options = options.CurrentValue; this._log = log; } @@ -71,7 +74,7 @@ public async Task CmdClearAsync( if (CreatingForSelf() && memberAge < this._options.MinMemberAge) { string[] emojis = new string[] { ResponseEmoji.FeelsBeanMan, ResponseEmoji.FeelsDumbMan, ResponseEmoji.EyesBlurry, ResponseEmoji.BlobSweatAnimated }; - await RespondFailureAsync($"{ResponseEmoji.Failure} You need to be here for at least {this._options.MinMemberAge.ToDisplayString()} to create a blog channel.\nYou've been here for {memberAge.ToDisplayString()} so far. {emojis[new Random().Next(emojis.Length)]}"); + await RespondFailureAsync($"{ResponseEmoji.Failure} You need to be here for at least {this._options.MinMemberAge.ToDisplayString()} to create a blog channel.\nYou've been here for {memberAge.ToDisplayString()} so far. {this._randomizer.GetRandomValue(emojis)}"); return; } diff --git a/DiscordBot/Features/BlogsManagement/Services/BlogActivityListener.cs b/DiscordBot/Features/BlogsManagement/Services/BlogActivityListener.cs index 225a0f3..50dad53 100644 --- a/DiscordBot/Features/BlogsManagement/Services/BlogActivityListener.cs +++ b/DiscordBot/Features/BlogsManagement/Services/BlogActivityListener.cs @@ -50,11 +50,11 @@ private async Task OnClientMessageReceived(SocketMessage message) this._log.LogInformation("Message received from inactive blog channel {ChannelName} ({ChannelID})", channel.Name, channel.Id); CancellationToken cancellationToken = this._cts.Token; - SocketCategoryChannel category = channel.Guild.GetCategoryChannel(channel.CategoryId.Value); + SocketCategoryChannel categoryToSort = channel.Guild.GetCategoryChannel(this.Options.ActiveBlogsCategoryID); try { await this._activator.ActivateBlogChannel(channel, cancellationToken).ConfigureAwait(false); - await this._sorter.SortChannelsAsync(category, cancellationToken).ConfigureAwait(false); + await this._sorter.SortChannelsAsync(categoryToSort, cancellationToken).ConfigureAwait(false); } catch (HttpException ex) when (ex.IsMissingPermissions() && ex.LogAsError(this._log, "Failed moving {ChannelName} ({ChannelID}) due to missing permissions", channel.Name, channel.Id)) { } diff --git a/DiscordBot/Features/ColourRoles/ColourRolesCommands.cs b/DiscordBot/Features/ColourRoles/ColourRolesCommands.cs index 21962c0..43f1e4d 100644 --- a/DiscordBot/Features/ColourRoles/ColourRolesCommands.cs +++ b/DiscordBot/Features/ColourRoles/ColourRolesCommands.cs @@ -2,6 +2,7 @@ using Discord.Interactions; using Discord.Net; using Discord.WebSocket; +using TehGM.Utilities.Randomization; namespace DevSubmarine.DiscordBot.ColourRoles { @@ -9,24 +10,25 @@ namespace DevSubmarine.DiscordBot.ColourRoles [EnabledInDm(false)] public class ColourRolesCommands : DevSubInteractionModule { + private readonly IRandomizer _randomizer; private readonly IOptionsMonitor _options; private readonly ILogger _log; - public ColourRolesCommands(IOptionsMonitor options, ILogger log) + public ColourRolesCommands(IRandomizer randomizer, IOptionsMonitor options, ILogger log) { + this._randomizer = randomizer; this._options = options; this._log = log; } [SlashCommand("set", "Sets your colour role to the one you selected")] + [EnabledInDm(false)] public async Task CmdSetAsync( [Summary("Role", "Role to apply")] IRole role, [Summary("User", "Which user to apply the role to; can only be used by administrators")] IGuildUser user = null) { ColourRolesOptions options = this._options.CurrentValue; - await base.DeferAsync(options: base.GetRequestOptions()).ConfigureAwait(false); - if (!this.GetAvailableRoles().Any(r => r.Id == role.Id)) { await base.RespondAsync( @@ -36,6 +38,8 @@ await base.RespondAsync( return; } + await base.DeferAsync(options: base.GetRequestOptions()).ConfigureAwait(false); + // if changing role of the other user, it should be only possible for user with specific permissions (admins basically) IGuildUser callerUser = await base.Context.Guild.GetGuildUserAsync(base.Context.User.Id, base.Context.CancellationToken).ConfigureAwait(false); if (user != null) @@ -57,30 +61,12 @@ await base.RespondAsync( else user = callerUser; - - try - { - if (this._options.CurrentValue.RemoveOldRoles) - await this.RemoveColourRolesAsync(user, oldRole => oldRole.Id != role.Id).ConfigureAwait(false); - - // special case is when user already has requested role. Just skip doing any changes then to prevent exceptions, Discord vomiting or whatever else - if (!user.RoleIds.Contains(role.Id)) - { - this._log.LogDebug("Adding role {RoleName} ({RoleID}) to user {UserID}", role.Name, role.Id, user.Id); - await user.AddRoleAsync(role, base.GetRequestOptions()).ConfigureAwait(false); - } - } - catch (HttpException ex) when (ex.IsMissingPermissions()) - { - await base.ModifyOriginalResponseAsync(msg => msg.Content = $"Oops! {ResponseEmoji.Failure}\nI lack permissions to change your role! {ResponseEmoji.FeelsBeanMan}", - base.GetRequestOptions()).ConfigureAwait(false); - return; - } - + await this.SetUserRoleAsync(user, role).ConfigureAwait(false); await this.ConfirmRoleChangeAsync(role.Color, role.Mention).ConfigureAwait(false); } [SlashCommand("list", "Lists all colour roles you can pick")] + [EnabledInDm(false)] public Task CmdListAsync() { IEnumerable availableRoles = this.GetAvailableRoles(); @@ -102,7 +88,8 @@ public Task CmdListAsync() options: base.GetRequestOptions()); } - [SlashCommand("clear", "Clears your role colour")] + [SlashCommand("clear", "Clears your colour role")] + [EnabledInDm(false)] public async Task CmdClearAsync( [Summary("User", "Which user to apply the role to; can only be used by administrators")] IGuildUser user = null) { @@ -145,6 +132,53 @@ await base.ModifyOriginalResponseAsync(msg => msg.Content = $"Oops! {ResponseEmo await this.ConfirmRoleChangeAsync(Color.Default, "colour-naked").ConfigureAwait(false); } + [SlashCommand("random", "Changes your colour role to a random one")] + [EnabledInDm(false)] + public async Task CmdRandomAsync() + { + ColourRolesOptions options = this._options.CurrentValue; + await base.DeferAsync(options: base.GetRequestOptions()).ConfigureAwait(false); + + IGuildUser user = await base.Context.Guild.GetGuildUserAsync(base.Context.User.Id).ConfigureAwait(false); + + // to ensure role does change, exclude user's current role + // they might have multiple for some damn reason (wtf Nerdu?), but it's okay - we only care about the highest one as that influences colour + // note they might have none + ulong? excludedRoleID = user.GetHighestRole(r => r.Color != Color.Default && options.AllowedRoleIDs.Contains(r.Id))?.Id; + IEnumerable availableIDs = excludedRoleID == null ? options.AllowedRoleIDs : options.AllowedRoleIDs.Except(new[] { excludedRoleID.Value }); + + // keep in mind that available roles might contain roles from other guilds, so we have to verify them + availableIDs = availableIDs.Intersect(base.Context.Guild.Roles.Select(r => r.Id)); + + ulong selectedRoleID = this._randomizer.GetRandomValue(availableIDs); + IRole selectedRole = base.Context.Guild.GetRole(selectedRoleID); + + await this.SetUserRoleAsync(user, selectedRole).ConfigureAwait(false); + await this.ConfirmRoleChangeAsync(selectedRole.Color, selectedRole.Mention).ConfigureAwait(false); + } + + private async Task SetUserRoleAsync(IGuildUser user, IRole role) + { + try + { + if (this._options.CurrentValue.RemoveOldRoles) + await this.RemoveColourRolesAsync(user, oldRole => oldRole.Id != role.Id).ConfigureAwait(false); + + // special case is when user already has requested role. Just skip doing any changes then to prevent exceptions, Discord vomiting or whatever else + if (!user.RoleIds.Contains(role.Id)) + { + this._log.LogDebug("Adding role {RoleName} ({RoleID}) to user {UserID}", role.Name, role.Id, user.Id); + await user.AddRoleAsync(role, base.GetRequestOptions()).ConfigureAwait(false); + } + } + catch (HttpException ex) when (ex.IsMissingPermissions()) + { + await base.ModifyOriginalResponseAsync(msg => msg.Content = $"Oops! {ResponseEmoji.Failure}\nI lack permissions to change your role! {ResponseEmoji.FeelsBeanMan}", + base.GetRequestOptions()).ConfigureAwait(false); + return; + } + } + private IEnumerable GetAvailableRoles() { return base.Context.Guild.Roles.Where(role => diff --git a/DiscordBot/Features/RandomStatus/RandomStatusDependencyInjectionExtensions.cs b/DiscordBot/Features/RandomStatus/RandomStatusDependencyInjectionExtensions.cs new file mode 100644 index 0000000..8b1ca83 --- /dev/null +++ b/DiscordBot/Features/RandomStatus/RandomStatusDependencyInjectionExtensions.cs @@ -0,0 +1,22 @@ +using DevSubmarine.DiscordBot.RandomStatus; +using DevSubmarine.DiscordBot.RandomStatus.Services; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class RandomStatusDependencyInjectionExtensions + { + public static IServiceCollection AddRandomStatus(this IServiceCollection services, Action configureOptions = null) + { + if (services == null) + throw new ArgumentNullException(nameof(services)); + + if (configureOptions != null) + services.Configure(configureOptions); + + services.AddHostedService(); + + return services; + } + } +} diff --git a/DiscordBot/Features/RandomStatus/RandomStatusOptions.cs b/DiscordBot/Features/RandomStatus/RandomStatusOptions.cs new file mode 100644 index 0000000..7d147fc --- /dev/null +++ b/DiscordBot/Features/RandomStatus/RandomStatusOptions.cs @@ -0,0 +1,11 @@ +namespace DevSubmarine.DiscordBot.RandomStatus +{ + public class RandomStatusOptions + { + public Status[] Statuses { get; set; } = new Status[0]; + public TimeSpan ChangeRate { get; set; } = TimeSpan.FromMinutes(10); + public bool Enable { get; set; } = true; + + public bool IsEnabled => this.Enable && this.Statuses?.Any() == true; + } +} diff --git a/DiscordBot/Features/RandomStatus/RandomStatusService.cs b/DiscordBot/Features/RandomStatus/RandomStatusService.cs new file mode 100644 index 0000000..a951b26 --- /dev/null +++ b/DiscordBot/Features/RandomStatus/RandomStatusService.cs @@ -0,0 +1,103 @@ +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Hosting; +using TehGM.Utilities.Randomization; + +namespace DevSubmarine.DiscordBot.RandomStatus.Services +{ + /// Background service that periodically scans blog channels for last activity and activates or deactivates them. + internal class RandomStatusService : IHostedService, IDisposable + { + private readonly DiscordSocketClient _client; + private readonly IRandomizer _randomizer; + private readonly ILogger _log; + private readonly IOptionsMonitor _options; + private CancellationTokenSource _cts; + + private DateTime _lastChangeUtc; + + public RandomStatusService(DiscordSocketClient client, IRandomizer randomizer, + ILogger log, IOptionsMonitor options) + { + this._client = client; + this._randomizer = randomizer; + this._log = log; + this._options = options; + } + + private async Task AutoChangeLoopAsync(CancellationToken cancellationToken) + { + this._log.LogDebug("Starting status randomization loop. Change rate is {ChangeRate}", this._options.CurrentValue.ChangeRate); + if (this._options.CurrentValue.ChangeRate <= TimeSpan.FromSeconds(10)) + this._log.LogWarning("Change rate is less than 10 seconds!"); + + while (!cancellationToken.IsCancellationRequested && this._options.CurrentValue.IsEnabled) + { + RandomStatusOptions options = this._options.CurrentValue; + + while (this._client.ConnectionState != ConnectionState.Connected) + { + this._log.LogTrace("Client not connected, waiting"); + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false); + } + + DateTime nextChangeUtc = this._lastChangeUtc + this._options.CurrentValue.ChangeRate; + TimeSpan remainingWait = nextChangeUtc - DateTime.UtcNow; + if (remainingWait > TimeSpan.Zero) + await Task.Delay(remainingWait, cancellationToken).ConfigureAwait(false); + await this.RandomizeStatusAsync(cancellationToken).ConfigureAwait(false); + this._log.LogTrace("Next status change: {ChangeTime}", this._lastChangeUtc + options.ChangeRate); + await Task.Delay(options.ChangeRate, cancellationToken).ConfigureAwait(false); + } + } + + private async Task RandomizeStatusAsync(CancellationToken cancellationToken) + { + RandomStatusOptions options = this._options.CurrentValue; + + if (!options.IsEnabled) + return null; + + Status status = this._randomizer.GetRandomValue(options.Statuses); + try + { + if (this._client.CurrentUser == null || this._client.ConnectionState != ConnectionState.Connected) + return null; + if (status == null) + return null; + if (!string.IsNullOrWhiteSpace(status.Text)) + this._log.LogDebug("Changing status to `{NewStatus}`", status); + else + this._log.LogDebug("Clearing status"); + await this._client.SetGameAsync(status.Text, status.Link, status.ActivityType).ConfigureAwait(false); + return status; + } + catch (Exception ex) when (options.IsEnabled && ex.LogAsError(this._log, "Failed changing status to {Status}", status)) + { + return null; + } + finally + { + this._lastChangeUtc = DateTime.UtcNow; + } + } + + Task IHostedService.StartAsync(CancellationToken cancellationToken) + { + this._cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _ = this.AutoChangeLoopAsync(this._cts.Token); + return Task.CompletedTask; + } + + Task IHostedService.StopAsync(CancellationToken cancellationToken) + { + try { this._cts?.Cancel(); } catch { } + return Task.CompletedTask; + } + + public void Dispose() + { + try { this._cts?.Dispose(); } catch { } + } + } +} diff --git a/DiscordBot/Features/RandomStatus/Status.cs b/DiscordBot/Features/RandomStatus/Status.cs new file mode 100644 index 0000000..9f2a5b9 --- /dev/null +++ b/DiscordBot/Features/RandomStatus/Status.cs @@ -0,0 +1,28 @@ +using Discord; + +namespace DevSubmarine.DiscordBot.RandomStatus +{ + public class Status + { + public string Text { get; set; } = null; + public string Link { get; set; } = null; + public ActivityType ActivityType { get; set; } = ActivityType.Playing; + + public override string ToString() + { + switch (this.ActivityType) + { + case ActivityType.Playing: + return $"Playing {this.Text}"; + case ActivityType.Streaming: + return $"Streaming {this.Text}"; + case ActivityType.Watching: + return $"Watching {this.Text}"; + case ActivityType.Listening: + return $"Listening to {this.Text}"; + default: + throw new NotSupportedException($"Activity of type {this.ActivityType} is not supported"); + } + } + } +} diff --git a/DiscordBot/Features/UserSettingsCommands.cs b/DiscordBot/Features/UserSettingsCommands.cs new file mode 100644 index 0000000..dfad53c --- /dev/null +++ b/DiscordBot/Features/UserSettingsCommands.cs @@ -0,0 +1,34 @@ +using DevSubmarine.DiscordBot.Settings; +using Discord.Interactions; + +namespace DevSubmarine.DiscordBot.Features +{ + [Group("user-settings", "Allows you to edit your individual bot settings")] + [EnabledInDm(true)] + public class UserSettingsCommands : DevSubInteractionModule + { + private readonly IUserSettingsProvider _provider; + + public UserSettingsCommands(IUserSettingsProvider provider) + { + this._provider = provider; + } + + [SlashCommand("vote-ping", "Allows you to change if you'll be pinged on a vote against you")] + public async Task CmdVotePingAsync( + [Summary("Enabled", "Whether the ping on vote will be enabled")] bool enabled) + { + await base.DeferAsync(options: base.GetRequestOptions()).ConfigureAwait(false); + + await this._provider.UpdateUserSettingsAsync( + base.Context.User.Id, + settings => settings.PingOnVote = enabled, + base.Context.CancellationToken).ConfigureAwait(false); + + string confirmationText = enabled + ? $"You'll now be pinged whenever someone casts a vote against you. {ResponseEmoji.Success}" + : $"You'll no longer be notified when someone votes against you. {ResponseEmoji.Success}"; + await base.ModifyOriginalResponseAsync(msg => msg.Content = confirmationText, base.GetRequestOptions()).ConfigureAwait(false); + } + } +} diff --git a/DiscordBot/Features/Voting/VotingCommands.cs b/DiscordBot/Features/Voting/VotingCommands.cs index 173d364..29d875c 100644 --- a/DiscordBot/Features/Voting/VotingCommands.cs +++ b/DiscordBot/Features/Voting/VotingCommands.cs @@ -1,17 +1,21 @@ -using Discord; +using DevSubmarine.DiscordBot.Settings; +using Discord; using Discord.Interactions; using System.Text; namespace DevSubmarine.DiscordBot.Voting.Services { [Group("vote", "Vote to kick others... or something")] + [EnabledInDm(false)] public class VotingCommands : DevSubInteractionModule { private readonly IVotingService _voting; + private readonly IUserSettingsProvider _userSettings; - public VotingCommands(IVotingService voting) + public VotingCommands(IVotingService voting, IUserSettingsProvider userSettings) { this._voting = voting; + this._userSettings = userSettings; } [SlashCommand("kick", "Vote to kick someone")] @@ -25,14 +29,7 @@ public async Task CmdVoteKickAsync( if (result is CooldownVotingResult cooldown) await this.RespondCooldownAsync(cooldown.CooldownRemaining, user).ConfigureAwait(false); else - { - SuccessVotingResult voteResult = (SuccessVotingResult)result; - await base.RespondAsync( - text: $"{user.Mention} you've been voted to be kicked! {ResponseEmoji.KekYu}", - embed: this.BuildResultEmbed(voteResult, user), - options: base.GetRequestOptions(), - allowedMentions: this.GetMentionOptions()).ConfigureAwait(false); - } + await this.RespondConfirmAsync(result, $"{user.Mention} you've been voted to be kicked! {ResponseEmoji.KekYu}"); } [SlashCommand("ban", "Vote to ban someone")] @@ -46,14 +43,7 @@ public async Task CmdVoteBanAsync( if (result is CooldownVotingResult cooldown) await this.RespondCooldownAsync(cooldown.CooldownRemaining, user).ConfigureAwait(false); else - { - SuccessVotingResult voteResult = (SuccessVotingResult)result; - await base.RespondAsync( - text: $"{user.Mention} you've been voted to be banned! {ResponseEmoji.KekPoint}", - embed: this.BuildResultEmbed(voteResult, user), - options: base.GetRequestOptions(), - allowedMentions: this.GetMentionOptions()).ConfigureAwait(false); - } + await this.RespondConfirmAsync(result, $"{user.Mention} you've been voted to be banned! {ResponseEmoji.KekPoint}"); } [SlashCommand("mod", "Vote to mod someone")] @@ -75,14 +65,28 @@ await base.RespondAsync( if (result is CooldownVotingResult cooldown) await this.RespondCooldownAsync(cooldown.CooldownRemaining, user).ConfigureAwait(false); else - { - SuccessVotingResult voteResult = (SuccessVotingResult)result; - await base.RespondAsync( - text: $"{user.Mention} you've been voted to be modded! {ResponseEmoji.EyesBlurry}", - embed: this.BuildResultEmbed(voteResult, user), - options: base.GetRequestOptions(), - allowedMentions: this.GetMentionOptions()).ConfigureAwait(false); - } + await this.RespondConfirmAsync(result, $"{user.Mention} you've been voted to be modded! {ResponseEmoji.EyesBlurry}"); + } + + private async Task RespondConfirmAsync(IVotingResult result, string message) + { + SuccessVotingResult voteResult = (SuccessVotingResult)result; + const string settingsCmd = "`/user-settings vote-ping`"; + + IGuildUser user = await base.Context.Guild.GetGuildUserAsync(voteResult.CreatedVote.TargetID, base.Context.CancellationToken).ConfigureAwait(false); + UserSettings settings = await this._userSettings.GetUserSettingsAsync(user.Id, base.Context.CancellationToken).ConfigureAwait(false); + AllowedMentions mentions = settings.PingOnVote ? new AllowedMentions(AllowedMentionTypes.Users) : AllowedMentions.None; + + Embed embed = this.BuildResultEmbed(voteResult, user); + if (settings.PingOnVote) + message += $"\n*You can disable pings on vote by using {settingsCmd} command.*"; + + await base.RespondAsync( + text: message, + embed: embed, + options: base.GetRequestOptions(), + allowedMentions: mentions) + .ConfigureAwait(false); } private Task RespondCooldownAsync(TimeSpan cooldown, IGuildUser user) @@ -105,8 +109,11 @@ private Embed BuildResultEmbed(SuccessVotingResult vote, IGuildUser target) .Build(); } - private AllowedMentions GetMentionOptions() - => AllowedMentions.None; + private async Task GetMentionOptionsAsync(ulong userID) + { + UserSettings settings = await this._userSettings.GetUserSettingsAsync(userID, base.Context.CancellationToken).ConfigureAwait(false); + return settings.PingOnVote ? new AllowedMentions(AllowedMentionTypes.Users) : AllowedMentions.None; + } private string FormatOrdinal(ulong number) { @@ -137,6 +144,7 @@ public VotingStatisticsCommands(IVotesStore store, IVotingAlignmentCalculator al } [SlashCommand("check", "Check voting statistics for specific user")] + [EnabledInDm(false)] public async Task CmdCheckAsync( [Summary("User", "User to check statistics for")] IGuildUser user = null) { @@ -241,6 +249,7 @@ await base.ModifyOriginalResponseAsync(msg => } [SlashCommand("search", "Query for statistics using specified search criteria")] + [EnabledInDm(true)] public async Task CmdFindAsync( [Summary("Target", "User the vote was sent against")] IUser target = null, [Summary("Voter", "User that sent the vote")] IUser voter = null, diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index a95f5e5..08bf188 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -48,17 +48,21 @@ static async Task Main(string[] args) services.Configure(context.Configuration.GetSection("ColourRoles")); services.Configure(context.Configuration.GetSection("BlogsManagement")); services.Configure(context.Configuration.GetSection("Voting")); + services.Configure(context.Configuration.GetSection("RandomStatus")); // dependencies services.AddDiscordClient(); + services.AddRandomizer(); services.AddMongoDB(); services.AddPasteMyst(); services.AddCaching(); + services.AddUserSettings(); // features services.AddSubWords(); services.AddBlogsManagement(); services.AddVoting(); + services.AddRandomStatus(); }) .Build(); await host.RunAsync().ConfigureAwait(false); diff --git a/DiscordBot/Utilities/RandomizerDependencyInjectionExtensions.cs b/DiscordBot/Utilities/RandomizerDependencyInjectionExtensions.cs new file mode 100644 index 0000000..5279c0c --- /dev/null +++ b/DiscordBot/Utilities/RandomizerDependencyInjectionExtensions.cs @@ -0,0 +1,18 @@ +using TehGM.Utilities.Randomization; +using TehGM.Utilities.Randomization.Services; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection +{ + // src: https://tehgm.net/blog/tehgm-csharp-utilities-v0-1-0/#irandomizer-and-irandomizerprovider + public static class RandomizerDependencyInjectionExtensions + { + public static IServiceCollection AddRandomizer(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddTransient(provider => provider.GetRequiredService().GetSharedRandomizer()); + + return services; + } + } +} \ No newline at end of file diff --git a/DiscordBot/Utilities/Settings/IUserSettingsProvider.cs b/DiscordBot/Utilities/Settings/IUserSettingsProvider.cs new file mode 100644 index 0000000..243bb22 --- /dev/null +++ b/DiscordBot/Utilities/Settings/IUserSettingsProvider.cs @@ -0,0 +1,16 @@ +namespace DevSubmarine.DiscordBot.Settings +{ + /// A store-agnostic provider for instances. + public interface IUserSettingsProvider + { + /// Gets settings for specific user. + /// User to retrieve settings for. + /// Token to cancel the operation. + /// Retrieved user settings; new default settings created for the user if not found. + Task GetUserSettingsAsync(ulong userID, CancellationToken cancellationToken = default); + /// Adds or updates user settings. + /// Settings instance to save. + /// Token to cancel the operation. + Task UpdateUserSettingsAsync(UserSettings settings, CancellationToken cancellationToken = default); + } +} diff --git a/DiscordBot/Utilities/Settings/IUserSettingsStore.cs b/DiscordBot/Utilities/Settings/IUserSettingsStore.cs new file mode 100644 index 0000000..92e43a2 --- /dev/null +++ b/DiscordBot/Utilities/Settings/IUserSettingsStore.cs @@ -0,0 +1,16 @@ +namespace DevSubmarine.DiscordBot.Settings +{ + /// Store for entities. + public interface IUserSettingsStore + { + /// Retrieves user settings from the store. + /// User to retrieve settings for. + /// Token to cancel the operation. + /// Retrieved user settings; null if not found. + Task GetUserSettingsAsync(ulong userID, CancellationToken cancellationToken = default); + /// Adds or updates user settings in the store. + /// Settings instance to save in store. + /// Token to cancel the operation. + Task UpsertUserSettingsAsync(UserSettings settings, CancellationToken cancellationToken = default); + } +} diff --git a/DiscordBot/Utilities/Settings/MongoUserSettingsStore.cs b/DiscordBot/Utilities/Settings/MongoUserSettingsStore.cs new file mode 100644 index 0000000..3bc8d69 --- /dev/null +++ b/DiscordBot/Utilities/Settings/MongoUserSettingsStore.cs @@ -0,0 +1,42 @@ +using DevSubmarine.DiscordBot.Database; +using MongoDB.Driver; + +namespace DevSubmarine.DiscordBot.Settings.Services +{ + /// + internal class MongoUserSettingsStore : IUserSettingsStore + { + private readonly ILogger _log; + private readonly IMongoCollection _collection; + + public MongoUserSettingsStore(IMongoDatabaseClient client, IOptions databaseOptions, ILogger log) + { + this._log = log; + this._collection = client.GetCollection(databaseOptions.Value.UserSettingsCollectionName); + } + + /// + public Task GetUserSettingsAsync(ulong userID, CancellationToken cancellationToken = default) + { + if (userID <= 0) + throw new ArgumentException($"{userID} is not a valid Discord user ID.", nameof(userID)); + + this._log.LogTrace("Retrieving User Settings for user {UserID} from DB", userID); + return this._collection.Find(db => db.UserID == userID).FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task UpsertUserSettingsAsync(UserSettings settings, CancellationToken cancellationToken = default) + { + if (settings == null) + throw new ArgumentNullException(nameof(settings)); + if (settings.UserID <= 0) + throw new ArgumentException($"{settings.UserID} is not a valid Discord user ID.", nameof(settings.UserID)); + + this._log.LogTrace("Upserting User Settings for user {UserID} to DB", settings.UserID); + ReplaceOptions options = new ReplaceOptions(); + options.IsUpsert = true; + return this._collection.ReplaceOneAsync(db => db.UserID == settings.UserID, settings, options, cancellationToken); + } + } +} diff --git a/DiscordBot/Utilities/Settings/UserSettings.cs b/DiscordBot/Utilities/Settings/UserSettings.cs new file mode 100644 index 0000000..8849356 --- /dev/null +++ b/DiscordBot/Utilities/Settings/UserSettings.cs @@ -0,0 +1,28 @@ +using DevSubmarine.DiscordBot.Caching; +using MongoDB.Bson.Serialization.Attributes; + +namespace DevSubmarine.DiscordBot.Settings +{ + public class UserSettings : ICacheable + { + [BsonId] + public ulong UserID { get; } + + [BsonElement] + public bool PingOnVote { get; set; } = true; + + [BsonConstructor(nameof(UserID))] + public UserSettings(ulong userID) + { + if (userID <= 0) + throw new ArgumentException($"{userID} is not a valid Discord user ID.", nameof(userID)); + + this.UserID = userID; + } + + public CacheItemKey GetCacheKey() + => new CacheItemKey(this.GetType(), this.UserID); + public static CacheItemKey GetCacheKey(ulong userID) + => new CacheItemKey(typeof(UserSettings), userID); + } +} diff --git a/DiscordBot/Utilities/Settings/UserSettingsDependencyInjectionExtensions.cs b/DiscordBot/Utilities/Settings/UserSettingsDependencyInjectionExtensions.cs new file mode 100644 index 0000000..ad110cb --- /dev/null +++ b/DiscordBot/Utilities/Settings/UserSettingsDependencyInjectionExtensions.cs @@ -0,0 +1,21 @@ +using DevSubmarine.DiscordBot.Settings; +using DevSubmarine.DiscordBot.Settings.Services; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class UserSettingsDependencyInjectionExtensions + { + public static IServiceCollection AddUserSettings(this IServiceCollection services) + { + if (services == null) + throw new ArgumentNullException(nameof(services)); + + services.AddMongoDB(); + services.TryAddSingleton(); + services.TryAddTransient(); + + return services; + } + } +} diff --git a/DiscordBot/Utilities/Settings/UserSettingsProvider.cs b/DiscordBot/Utilities/Settings/UserSettingsProvider.cs new file mode 100644 index 0000000..c613e08 --- /dev/null +++ b/DiscordBot/Utilities/Settings/UserSettingsProvider.cs @@ -0,0 +1,46 @@ +using DevSubmarine.DiscordBot.Caching; + +namespace DevSubmarine.DiscordBot.Settings.Services +{ + /// + internal class UserSettingsProvider : IUserSettingsProvider + { + private readonly ILogger _log; + private readonly IUserSettingsStore _store; + private readonly ICacheProvider _cache; + + public UserSettingsProvider(IUserSettingsStore store, ICacheProvider cache, ILogger log) + { + this._store = store; + this._cache = cache; + this._log = log; + } + + /// + public async Task GetUserSettingsAsync(ulong userID, CancellationToken cancellationToken = default) + { + if (this._cache.TryGetItem(UserSettings.GetCacheKey(userID), out UserSettings result)) + { + this._log.LogTrace("User Settings for user {UserID} retrieved from cache", userID); + return result; + } + + result = await this._store.GetUserSettingsAsync(userID, cancellationToken).ConfigureAwait(false); + if (result == null) + { + this._log.LogDebug("Creating a new default User Settings for user {UserID}", userID); + result = new UserSettings(userID); + } + + this._cache.AddItem(result); + return result; + } + + /// + public async Task UpdateUserSettingsAsync(UserSettings settings, CancellationToken cancellationToken = default) + { + await this._store.UpsertUserSettingsAsync(settings, cancellationToken).ConfigureAwait(false); + this._cache.AddItem(settings); + } + } +} diff --git a/DiscordBot/Utilities/Settings/UserSettingsProviderExtensions.cs b/DiscordBot/Utilities/Settings/UserSettingsProviderExtensions.cs new file mode 100644 index 0000000..8ddf88e --- /dev/null +++ b/DiscordBot/Utilities/Settings/UserSettingsProviderExtensions.cs @@ -0,0 +1,15 @@ +namespace DevSubmarine.DiscordBot.Settings +{ + public static class UserSettingsProviderExtensions + { + public static async Task UpdateUserSettingsAsync(this IUserSettingsProvider provider, ulong userID, Action updates, CancellationToken cancellationToken = default) + { + if (updates == null) + return; + + UserSettings settings = await provider.GetUserSettingsAsync(userID, cancellationToken).ConfigureAwait(false); + updates.Invoke(settings); + await provider.UpdateUserSettingsAsync(settings, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/DiscordBot/appsettings.json b/DiscordBot/appsettings.json index f902ab7..ffafbd7 100644 --- a/DiscordBot/appsettings.json +++ b/DiscordBot/appsettings.json @@ -1,4 +1,5 @@ { + "CommandsGuildID": 441702024715960330, "CompileCommands": true, "SubWords": { @@ -30,7 +31,8 @@ 954296930161283072, 526068890929987664, 526069654209429515, - 895014820288536586 + 895014820288536586, + 855561295206023188 ] }, "BlogsManagement": { @@ -50,6 +52,40 @@ "shit" ] }, + "RandomStatus": { + "ChangeRate": "0.00:15:00", + "Enabled": true, + "Statuses": [ + { + "ActivityType": "Listening", + "Text": "our lord and saviour, Guray" + }, + { + "ActivityType": "Watching", + "Text": "over DevSub nerds" + }, + { + "ActivityType": "Watching", + "Text": "over DevSub members" + }, + { + "ActivityType": "Watching", + "Text": "over DevSub blogs" + }, + { + "ActivityType": "Streaming", + "Text": "WTF is up with Nerdu's roles?" + }, + { + "ActivityType": "Playing", + "Text": "in #bot-commands" + }, + { + "ActivityType": "Playing", + "Text": "/vote kick Harsh" + } + ] + }, "Serilog": { "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.Datadog.Logs", "Serilog.Sinks.File" ], diff --git a/README.md b/README.md index fc0742c..5ce9df3 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,11 @@ For this reason, it's okay to add roles from different Discord guilds if necessa Note that for roles that are testing only, I recommend adding them to [appsettings.Development.json](DiscordBot/appsettings.Development.json) instead, just so the main config file is not polluted with testing configuration. +#### Random Statuses +The bot will automatically change its status on login, and also on a timer. This can be configured in [appsettings.json](DiscordBot/appsettings.json), `RandomStatus` section. + +The statuses themselves are in `RandomStatus:Statuses` array. Each status is JSON object, and should have `Text` and `ActivityType` property. Currently supported Activity Types are "Playing", "Watching", "Listening" and "Streaming". Additionally you can add `Link` property. + #### In-Development Commands Development Environment Commands shouldn't be registered globally for numerous reasons. For this reason you should set your test server ID in [appsettings.Development.json](DiscordBot/appsettings.Development.json) using `CommandsGuildID` property. This config will be used only during debugging, and your in-dev commands will be registered in one server only. @@ -87,6 +92,9 @@ Of course you have to replace `` with your DataDog API key. Also double che ## Planned Features - Whatever we neeed +## Known issues +- Due to a Discord backend bug, global slash commands do not appear in devsub guild on PC clients. As a *potential* workaround, currently [appsettings.json](DiscordBot/appsettings.json) is set to register only in that guild. This might not work and is less than optimal, but as bot is used currently, this is acceptable and won't hurt. + ## Contributing Feel free to open a PR or submit an issue to contribute. diff --git a/Tools/DiscordBot.Tools.DatabaseBootstrapper/CollectionCreators/UserSettingsCollectionCreator.cs b/Tools/DiscordBot.Tools.DatabaseBootstrapper/CollectionCreators/UserSettingsCollectionCreator.cs new file mode 100644 index 0000000..bf7b89d --- /dev/null +++ b/Tools/DiscordBot.Tools.DatabaseBootstrapper/CollectionCreators/UserSettingsCollectionCreator.cs @@ -0,0 +1,20 @@ +using DevSubmarine.DiscordBot.Voting; + +namespace DevSubmarine.DiscordBot.Tools.DatabaseBootstrapper.CollectionCreators +{ + class UserSettingsCollectionCreator : CollectionCreatorBase + { + protected override string CollectionName { get; } + + public UserSettingsCollectionCreator(IMongoDatabaseClient client, ILogger log, IOptions options) + : base(client, log, options) + { + this.CollectionName = options.Value.UserSettingsCollectionName; + } + + public override async Task ProcessCollectionAsync(CancellationToken cancellationToken = default) + { + IMongoCollection collection = await base.GetOrCreateCollectionAsync(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/Tools/DiscordBot.Tools.DatabaseBootstrapper/Program.cs b/Tools/DiscordBot.Tools.DatabaseBootstrapper/Program.cs index 15c1d41..bddad5c 100644 --- a/Tools/DiscordBot.Tools.DatabaseBootstrapper/Program.cs +++ b/Tools/DiscordBot.Tools.DatabaseBootstrapper/Program.cs @@ -69,6 +69,7 @@ private static IServiceCollection ConfigureServices(IConfiguration configuration // COLLECTION CREATORS services.AddCollectionCreator(); services.AddCollectionCreator(); + services.AddCollectionCreator(); return services; }