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;
}