Merge branch 'noguildcache'

This commit is contained in:
Noi 2020-08-05 19:49:57 -07:00
commit c77163cab3
19 changed files with 678 additions and 886 deletions

View file

@ -12,14 +12,15 @@ namespace BirthdayBot
class BackgroundServiceRunner class BackgroundServiceRunner
{ {
#if !DEBUG #if !DEBUG
// Amount of idle time between each round of task execution, in seconds.
const int Interval = 8 * 60;
// Amount of time between start and first round of processing, in seconds. // Amount of time between start and first round of processing, in seconds.
const int StartDelay = 60; const int StartDelay = 3 * 60; // 3 minutes
// Amount of idle time between each round of task execution, in seconds.
const int Interval = 5 * 60; // 5 minutes
#else #else
const int Interval = 10; // Short intervals for testing
const int StartDelay = 15; const int StartDelay = 20;
const int Interval = 20;
#endif #endif
const string LogName = nameof(BackgroundServiceRunner); const string LogName = nameof(BackgroundServiceRunner);

View file

@ -23,9 +23,11 @@ namespace BirthdayBot.BackgroundServices
public override async Task OnTick() public override async Task OnTick()
{ {
var tasks = new List<Task>(); var tasks = new List<Task>();
foreach (var guild in BotInstance.DiscordClient.Guilds)
// Work on each shard concurrently; guilds within each shard synchronously
foreach (var shard in BotInstance.DiscordClient.Shards)
{ {
tasks.Add(ProcessGuildAsync(guild)); tasks.Add(ProcessShardAsync(shard));
} }
var alltasks = Task.WhenAll(tasks); var alltasks = Task.WhenAll(tasks);
@ -40,6 +42,7 @@ namespace BirthdayBot.BackgroundServices
{ {
Log($"{exs.InnerExceptions.Count} exception(s) during bulk processing!"); Log($"{exs.InnerExceptions.Count} exception(s) during bulk processing!");
// TODO needs major improvements. output to file? // TODO needs major improvements. output to file?
foreach (var iex in exs.InnerExceptions) Log(iex.Message);
} }
else else
{ {
@ -59,6 +62,39 @@ namespace BirthdayBot.BackgroundServices
/// <returns>Diagnostic data in string form.</returns> /// <returns>Diagnostic data in string form.</returns>
public async Task<string> SingleProcessGuildAsync(SocketGuild guild) => (await ProcessGuildAsync(guild)).Export(); public async Task<string> SingleProcessGuildAsync(SocketGuild guild) => (await ProcessGuildAsync(guild)).Export();
/// <summary>
/// Called by <see cref="OnTick"/>, processes all guilds within a shard synchronously.
/// </summary>
private async Task ProcessShardAsync(DiscordSocketClient shard)
{
if (shard.ConnectionState != Discord.ConnectionState.Connected)
{
Log($"Shard {shard.ShardId} (with {shard.Guilds.Count} guilds) processing stopped - shard disconnected.");
return;
}
var exs = new List<Exception>();
foreach (var guild in shard.Guilds)
{
try
{
// Check if shard remains available
if (shard.ConnectionState != Discord.ConnectionState.Connected)
{
Log($"Shard {shard.ShardId} (with {shard.Guilds.Count} guilds) processing interrupted.");
return;
}
await ProcessGuildAsync(guild);
}
catch (Exception ex)
{
// Catch all exceptions per-guild but continue processing, throw at end
exs.Add(ex);
}
}
Log($"Shard {shard.ShardId} (with {shard.Guilds.Count} guilds) processing completed.");
if (exs.Count != 0) throw new AggregateException(exs);
}
/// <summary> /// <summary>
/// Main method where actual guild processing occurs. /// Main method where actual guild processing occurs.
/// </summary> /// </summary>
@ -66,23 +102,20 @@ namespace BirthdayBot.BackgroundServices
{ {
var diag = new PGDiagnostic(); var diag = new PGDiagnostic();
// Skip processing of guild if local info has not yet been loaded // Load guild information - stop if there is none (bot never previously used in guild)
if (!BotInstance.GuildCache.TryGetValue(guild.Id, out var gs)) var gc = await GuildConfiguration.LoadAsync(guild.Id, true);
{ if (gc == null) return diag;
diag.FetchCachedGuild = "Server information not yet loaded by the bot. Try again later.";
return diag;
}
diag.FetchCachedGuild = null;
// Check if role settings are correct before continuing with further processing // Check if role settings are correct before continuing with further processing
SocketRole role = null; SocketRole role = null;
if (gs.RoleId.HasValue) role = guild.GetRole(gs.RoleId.Value); if (gc.RoleId.HasValue) role = guild.GetRole(gc.RoleId.Value);
diag.RoleCheck = CheckCorrectRoleSettings(guild, role); diag.RoleCheck = CheckCorrectRoleSettings(guild, role);
if (diag.RoleCheck != null) return diag; if (diag.RoleCheck != null) return diag;
// Determine who's currently having a birthday // Determine who's currently having a birthday
var users = gs.Users; //await guild.DownloadUsersAsync();
var tz = gs.TimeZone; var users = await GuildUserConfiguration.LoadAllAsync(guild.Id);
var tz = gc.TimeZone;
var birthdays = GetGuildCurrentBirthdays(users, tz); var birthdays = GetGuildCurrentBirthdays(users, tz);
// Note: Don't quit here if zero people are having birthdays. Roles may still need to be removed by BirthdayApply. // Note: Don't quit here if zero people are having birthdays. Roles may still need to be removed by BirthdayApply.
diag.CurrentBirthdays = birthdays.Count.ToString(); diag.CurrentBirthdays = birthdays.Count.ToString();
@ -103,10 +136,10 @@ namespace BirthdayBot.BackgroundServices
diag.RoleApply = null; diag.RoleApply = null;
// Birthday announcement // Birthday announcement
var announce = gs.AnnounceMessages; var announce = gc.AnnounceMessages;
var announceping = gs.AnnouncePing; var announceping = gc.AnnouncePing;
SocketTextChannel channel = null; SocketTextChannel channel = null;
if (gs.AnnounceChannelId.HasValue) channel = guild.GetTextChannel(gs.AnnounceChannelId.Value); if (gc.AnnounceChannelId.HasValue) channel = guild.GetTextChannel(gc.AnnounceChannelId.Value);
if (announcementList.Count() != 0) if (announcementList.Count() != 0)
{ {
var announceResult = await AnnounceBirthdaysAsync(announce, announceping, channel, announcementList); var announceResult = await AnnounceBirthdaysAsync(announce, announceping, channel, announcementList);
@ -145,7 +178,7 @@ namespace BirthdayBot.BackgroundServices
/// Gets all known users from the given guild and returns a list including only those who are /// Gets all known users from the given guild and returns a list including only those who are
/// currently experiencing a birthday in the respective time zone. /// currently experiencing a birthday in the respective time zone.
/// </summary> /// </summary>
private HashSet<ulong> GetGuildCurrentBirthdays(IEnumerable<GuildUserSettings> guildUsers, string defaultTzStr) private HashSet<ulong> GetGuildCurrentBirthdays(IEnumerable<GuildUserConfiguration> guildUsers, string defaultTzStr)
{ {
var birthdayUsers = new HashSet<ulong>(); var birthdayUsers = new HashSet<ulong>();
@ -271,7 +304,6 @@ namespace BirthdayBot.BackgroundServices
{ {
const string DefaultValue = "--"; const string DefaultValue = "--";
public string FetchCachedGuild = DefaultValue;
public string RoleCheck = DefaultValue; public string RoleCheck = DefaultValue;
public string CurrentBirthdays = DefaultValue; public string CurrentBirthdays = DefaultValue;
public string RoleApply = DefaultValue; public string RoleApply = DefaultValue;
@ -282,7 +314,6 @@ namespace BirthdayBot.BackgroundServices
{ {
var result = new StringBuilder(); var result = new StringBuilder();
result.AppendLine("Test result:"); result.AppendLine("Test result:");
result.AppendLine("Fetch guild information: " + (FetchCachedGuild ?? ":white_check_mark:"));
result.AppendLine("Check role permissions: " + (RoleCheck ?? ":white_check_mark:")); result.AppendLine("Check role permissions: " + (RoleCheck ?? ":white_check_mark:"));
result.AppendLine("Number of known users currently with a birthday: " + CurrentBirthdays); result.AppendLine("Number of known users currently with a birthday: " + CurrentBirthdays);
result.AppendLine("Role application process: " + (RoleApply ?? ":white_check_mark:")); result.AppendLine("Role application process: " + (RoleApply ?? ":white_check_mark:"));

View file

@ -14,8 +14,7 @@ namespace BirthdayBot.BackgroundServices
public async override Task OnTick() public async override Task OnTick()
{ {
var count = BotInstance.DiscordClient.Guilds.Count; var count = BotInstance.DiscordClient.Guilds.Count;
var cacheCount = BotInstance.GuildCache.Count; Log($"Currently in {count} guilds.");
Log($"Currently in {count} guilds. Cached guild settings: {cacheCount}.");
await SendExternalStatistics(count); await SendExternalStatistics(count);
} }

View file

@ -14,18 +14,6 @@ namespace BirthdayBot.BackgroundServices
{ {
var uptime = DateTimeOffset.UtcNow - Program.BotStartTime; var uptime = DateTimeOffset.UtcNow - Program.BotStartTime;
Log($"Bot uptime: {Common.BotUptime}"); Log($"Bot uptime: {Common.BotUptime}");
// Disconnection warn
foreach (var shard in BotInstance.DiscordClient.Shards)
{
if (shard.ConnectionState == Discord.ConnectionState.Disconnected)
{
Log($"Shard {shard.ShardId} is disconnected! Restart the app if this persists.");
// The library alone cannot be restarted as it is in an unknown state. It was not designed to be restarted.
// TODO This is the part where we'd signal something to restart us if we were fancy.
}
}
return Task.CompletedTask; return Task.CompletedTask;
} }
} }

View file

@ -17,75 +17,68 @@ namespace BirthdayBot.BackgroundServices
{ {
// Build a list of all values to update // Build a list of all values to update
var updateList = new Dictionary<ulong, List<ulong>>(); var updateList = new Dictionary<ulong, List<ulong>>();
foreach (var gi in BotInstance.GuildCache) foreach (var g in BotInstance.DiscordClient.Guilds)
{ {
var guild = BotInstance.DiscordClient.GetGuild(gi.Key); // Get list of IDs for all users who exist in the database and currently exist in the guild
if (guild == null) continue; // Have cache without being in guild. Unlikely, but... var savedUserIds = from cu in await GuildUserConfiguration.LoadAllAsync(g.Id) select cu.UserId;
var guildUserIds = from gu in g.Users select gu.Id;
// Get IDs of cached users which are currently in the guild var existingCachedIds = savedUserIds.Intersect(guildUserIds);
var cachedUserIds = from cu in gi.Value.Users select cu.UserId; updateList[g.Id] = new List<ulong>(existingCachedIds);
var guildUserIds = from gu in guild.Users select gu.Id;
var existingCachedIds = cachedUserIds.Intersect(guildUserIds);
updateList[gi.Key] = new List<ulong>(existingCachedIds);
} }
using (var db = await BotInstance.Config.DatabaseSettings.OpenConnectionAsync()) using var db = await Database.OpenConnectionAsync();
// Statement for updating last_seen in guilds
var cUpdateGuild = db.CreateCommand();
cUpdateGuild.CommandText = $"update {GuildConfiguration.BackingTable} set last_seen = now() "
+ "where guild_id = @Gid";
var pUpdateG = cUpdateGuild.Parameters.Add("@Gid", NpgsqlDbType.Bigint);
cUpdateGuild.Prepare();
// Statement for updating last_seen in guild users
var cUpdateGuildUser = db.CreateCommand();
cUpdateGuildUser.CommandText = $"update {GuildUserConfiguration.BackingTable} set last_seen = now() "
+ "where guild_id = @Gid and user_id = @Uid";
var pUpdateGU_g = cUpdateGuildUser.Parameters.Add("@Gid", NpgsqlDbType.Bigint);
var pUpdateGU_u = cUpdateGuildUser.Parameters.Add("@Uid", NpgsqlDbType.Bigint);
cUpdateGuildUser.Prepare();
// Do actual updates
int updatedGuilds = 0;
int updatedUsers = 0;
foreach (var item in updateList)
{ {
// Prepare to update a lot of last-seen values var guild = item.Key;
var cUpdateGuild = db.CreateCommand(); var userlist = item.Value;
cUpdateGuild.CommandText = $"update {GuildStateInformation.BackingTable} set last_seen = now() "
+ "where guild_id = @Gid";
var pUpdateG = cUpdateGuild.Parameters.Add("@Gid", NpgsqlDbType.Bigint);
cUpdateGuild.Prepare();
var cUpdateGuildUser = db.CreateCommand(); pUpdateG.Value = (long)guild;
cUpdateGuildUser.CommandText = $"update {GuildUserSettings.BackingTable} set last_seen = now() " updatedGuilds += await cUpdateGuild.ExecuteNonQueryAsync();
+ "where guild_id = @Gid and user_id = @Uid";
var pUpdateGU_g = cUpdateGuildUser.Parameters.Add("@Gid", NpgsqlDbType.Bigint);
var pUpdateGU_u = cUpdateGuildUser.Parameters.Add("@Uid", NpgsqlDbType.Bigint);
cUpdateGuildUser.Prepare();
int updatedGuilds = 0; pUpdateGU_g.Value = (long)guild;
int updatedUsers = 0; foreach (var userid in userlist)
// Do actual updates
foreach (var item in updateList)
{ {
var guild = item.Key; pUpdateGU_u.Value = (long)userid;
var userlist = item.Value; updatedUsers += await cUpdateGuildUser.ExecuteNonQueryAsync();
pUpdateG.Value = (long)guild;
updatedGuilds += cUpdateGuild.ExecuteNonQuery();
pUpdateGU_g.Value = (long)guild;
foreach (var userid in userlist)
{
pUpdateGU_u.Value = (long)userid;
updatedUsers += cUpdateGuildUser.ExecuteNonQuery();
}
}
Log($"Updated last-seen records: {updatedGuilds} guilds, {updatedUsers} users");
// Delete all old values - expects referencing tables to have 'on delete cascade'
using (var t = db.BeginTransaction())
{
int staleGuilds, staleUsers;
using (var c = db.CreateCommand())
{
// Delete data for guilds not seen in 4 weeks
c.CommandText = $"delete from {GuildStateInformation.BackingTable} where (now() - interval '28 days') > last_seen";
staleGuilds = c.ExecuteNonQuery();
}
using (var c = db.CreateCommand())
{
// Delete data for users not seen in 8 weeks
c.CommandText = $"delete from {GuildUserSettings.BackingTable} where (now() - interval '56 days') > last_seen";
staleUsers = c.ExecuteNonQuery();
}
Log($"Will remove {staleGuilds} guilds, {staleUsers} users.");
t.Commit();
} }
} }
// Delete all old values - expects referencing tables to have 'on delete cascade'
using var t = db.BeginTransaction();
int staleGuilds, staleUsers;
using (var c = db.CreateCommand())
{
// Delete data for guilds not seen in 4 weeks
c.CommandText = $"delete from {GuildConfiguration.BackingTable} where (now() - interval '28 days') > last_seen";
staleGuilds = c.ExecuteNonQuery();
}
using (var c = db.CreateCommand())
{
// Delete data for users not seen in 8 weeks
c.CommandText = $"delete from {GuildUserConfiguration.BackingTable} where (now() - interval '56 days') > last_seen";
staleUsers = c.ExecuteNonQuery();
}
Log($"Will remove {staleGuilds} guilds, {staleUsers} users.");
t.Commit();
} }
} }
} }

View file

@ -5,7 +5,6 @@ using Discord.Net;
using Discord.Webhook; using Discord.Webhook;
using Discord.WebSocket; using Discord.WebSocket;
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using static BirthdayBot.UserInterface.CommandsCommon; using static BirthdayBot.UserInterface.CommandsCommon;
@ -20,20 +19,20 @@ namespace BirthdayBot
private readonly HelpInfoCommands _cmdsHelp; private readonly HelpInfoCommands _cmdsHelp;
private readonly ManagerCommands _cmdsMods; private readonly ManagerCommands _cmdsMods;
private BackgroundServiceRunner _worker; private readonly BackgroundServiceRunner _worker;
internal Configuration Config { get; } internal Configuration Config { get; }
internal DiscordShardedClient DiscordClient { get; } internal DiscordShardedClient DiscordClient { get; }
// TODO consider removal of the guild cache
internal ConcurrentDictionary<ulong, GuildStateInformation> GuildCache { get; }
internal DiscordWebhookClient LogWebhook { get; } internal DiscordWebhookClient LogWebhook { get; }
/// <summary>
/// Prepares the bot connection and all its event handlers
/// </summary>
public BirthdayBot(Configuration conf, DiscordShardedClient dc) public BirthdayBot(Configuration conf, DiscordShardedClient dc)
{ {
Config = conf; Config = conf;
DiscordClient = dc; DiscordClient = dc;
LogWebhook = new DiscordWebhookClient(conf.LogWebhook); LogWebhook = new DiscordWebhookClient(conf.LogWebhook);
GuildCache = new ConcurrentDictionary<ulong, GuildStateInformation>();
_worker = new BackgroundServiceRunner(this); _worker = new BackgroundServiceRunner(this);
@ -49,15 +48,17 @@ namespace BirthdayBot
foreach (var item in _cmdsMods.Commands) _dispatchCommands.Add(item.Item1, item.Item2); foreach (var item in _cmdsMods.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
// Register event handlers // Register event handlers
DiscordClient.JoinedGuild += LoadGuild;
DiscordClient.GuildAvailable += LoadGuild;
DiscordClient.LeftGuild += DiscardGuild;
DiscordClient.ShardConnected += SetStatus; DiscordClient.ShardConnected += SetStatus;
DiscordClient.MessageReceived += Dispatch; DiscordClient.MessageReceived += Dispatch;
} }
/// <summary>
/// Does some more basic initialization and then connects to Discord
/// </summary>
public async Task Start() public async Task Start()
{ {
await Database.DoInitialDatabaseSetupAsync();
await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken); await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken);
await DiscordClient.StartAsync(); await DiscordClient.StartAsync();
@ -76,21 +77,6 @@ namespace BirthdayBot
DiscordClient.Dispose(); DiscordClient.Dispose();
} }
private async Task LoadGuild(SocketGuild g)
{
if (!GuildCache.ContainsKey(g.Id))
{
var gi = await GuildStateInformation.LoadSettingsAsync(Config.DatabaseSettings, g.Id);
GuildCache.TryAdd(g.Id, gi);
}
}
private Task DiscardGuild(SocketGuild g)
{
GuildCache.TryRemove(g.Id, out _);
return Task.CompletedTask;
}
private async Task SetStatus(DiscordSocketClient shard) => await shard.SetGameAsync(CommandPrefix + "help"); private async Task SetStatus(DiscordSocketClient shard) => await shard.SetGameAsync(CommandPrefix + "help");
public async Task PushErrorLog(string source, string message) public async Task PushErrorLog(string source, string message)
@ -114,9 +100,10 @@ namespace BirthdayBot
private async Task Dispatch(SocketMessage msg) private async Task Dispatch(SocketMessage msg)
{ {
if (msg.Channel is IDMChannel) return; if (!(msg.Channel is SocketTextChannel channel)) return;
if (msg.Author.IsBot) return; if (msg.Author.IsBot || msg.Author.IsWebhook) return;
// TODO determine message type (pin, join, etc) if (((IMessage)msg).Type != MessageType.Default) return;
var author = (SocketGuildUser)msg.Author;
// Limit 3: // Limit 3:
// For all cases: base command, 2 parameters. // For all cases: base command, 2 parameters.
@ -124,27 +111,23 @@ namespace BirthdayBot
var csplit = msg.Content.Split(" ", 3, StringSplitOptions.RemoveEmptyEntries); var csplit = msg.Content.Split(" ", 3, StringSplitOptions.RemoveEmptyEntries);
if (csplit.Length > 0 && csplit[0].StartsWith(CommandPrefix, StringComparison.OrdinalIgnoreCase)) if (csplit.Length > 0 && csplit[0].StartsWith(CommandPrefix, StringComparison.OrdinalIgnoreCase))
{ {
var channel = (SocketTextChannel)msg.Channel;
var author = (SocketGuildUser)msg.Author;
// Determine if it's something we're listening for. // Determine if it's something we're listening for.
// Doing this first before the block check because a block check triggers a database query. if (!_dispatchCommands.TryGetValue(csplit[0].Substring(CommandPrefix.Length), out CommandHandler command)) return;
CommandHandler command = null;
if (!_dispatchCommands.TryGetValue(csplit[0].Substring(CommandPrefix.Length), out command)) return; // Load guild information here
var gconf = await GuildConfiguration.LoadAsync(channel.Guild.Id, false);
// Ban check // Ban check
var gi = GuildCache[channel.Guild.Id]; if (!gconf.IsBotModerator(author)) // skip check if user is a moderator
// Skip ban check if user is a manager
if (!gi.IsUserModerator(author))
{ {
if (gi.IsUserBlockedAsync(author.Id).GetAwaiter().GetResult()) return; if (await gconf.IsUserBlockedAsync(author.Id)) return; // silently ignore
} }
// Execute the command // Execute the command
try try
{ {
Program.Log("Command", $"{channel.Guild.Name}/{author.Username}#{author.Discriminator}: {msg.Content}"); Program.Log("Command", $"{channel.Guild.Name}/{author.Username}#{author.Discriminator}: {msg.Content}");
await command(csplit, channel, author); await command(csplit, gconf, channel, author);
} }
catch (Exception ex) catch (Exception ex)
{ {

View file

@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework> <TargetFramework>netcoreapp3.1</TargetFramework>
<Version>2.2.1</Version> <Version>2.3.1</Version>
<PackageId>BirthdayBot</PackageId> <PackageId>BirthdayBot</PackageId>
<Authors>NoiTheCat</Authors> <Authors>NoiTheCat</Authors>
<Product>BirthdayBot</Product> <Product>BirthdayBot</Product>
@ -22,7 +22,7 @@
<PackageReference Include="Discord.Net" Version="2.2.0" /> <PackageReference Include="Discord.Net" Version="2.2.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="NodaTime" Version="3.0.0" /> <PackageReference Include="NodaTime" Version="3.0.0" />
<PackageReference Include="Npgsql" Version="4.1.3.1" /> <PackageReference Include="Npgsql" Version="4.1.4" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -14,7 +14,6 @@ namespace BirthdayBot
public string BotToken { get; } public string BotToken { get; }
public string LogWebhook { get; } public string LogWebhook { get; }
public string DBotsToken { get; } public string DBotsToken { get; }
public Database DatabaseSettings { get; }
public int ShardCount { get; } public int ShardCount { get; }
public Configuration() public Configuration()
@ -52,7 +51,7 @@ namespace BirthdayBot
var sqlcs = jc["SqlConnectionString"]?.Value<string>(); var sqlcs = jc["SqlConnectionString"]?.Value<string>();
if (string.IsNullOrWhiteSpace(sqlcs)) if (string.IsNullOrWhiteSpace(sqlcs))
throw new Exception("'SqlConnectionString' must be specified."); throw new Exception("'SqlConnectionString' must be specified.");
DatabaseSettings = new Database(sqlcs); Database.DBConnectionString = sqlcs;
int? sc = jc["ShardCount"]?.Value<int>(); int? sc = jc["ShardCount"]?.Value<int>();
if (!sc.HasValue) ShardCount = 1; if (!sc.HasValue) ShardCount = 1;

View file

@ -1,42 +1,36 @@
using Npgsql; using Npgsql;
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace BirthdayBot.Data namespace BirthdayBot.Data
{ {
/// <summary> /// <summary>
/// Some database abstractions. /// Database access and some abstractions.
/// </summary> /// </summary>
class Database internal static class Database
{ {
/* private static string _connString;
* Database storage in this project, explained: public static string DBConnectionString
* Each guild gets a row in the settings table. This table is referred to when doing most things.
* Within each guild, each known user gets a row in the users table with specific information specified.
* Users can override certain settings in global, such as time zone.
*/
private string DBConnectionString { get; }
public Database(string connString)
{ {
DBConnectionString = connString; get => _connString;
set => _connString = "Minimum Pool Size=5;Maximum Pool Size=50;Connection Idle Lifetime=30;" + value;
// Database initialization happens here as well.
SetupTables();
} }
public async Task<NpgsqlConnection> OpenConnectionAsync() public static async Task<NpgsqlConnection> OpenConnectionAsync()
{ {
if (DBConnectionString == null) throw new Exception("Database connection string not set");
var db = new NpgsqlConnection(DBConnectionString); var db = new NpgsqlConnection(DBConnectionString);
await db.OpenAsync(); await db.OpenAsync();
return db; return db;
} }
private void SetupTables() public static async Task DoInitialDatabaseSetupAsync()
{ {
using var db = OpenConnectionAsync().GetAwaiter().GetResult(); using var db = await OpenConnectionAsync();
GuildStateInformation.SetUpDatabaseTable(db); // Note: Call this first. (Foreign reference constraints.)
GuildUserSettings.SetUpDatabaseTable(db); // Refer to the methods being called for information on how the database is set up.
await GuildConfiguration.DatabaseSetupAsync(db); // Note: Call this first. (Foreign reference constraints.)
await GuildUserConfiguration.DatabaseSetupAsync(db);
} }
} }
} }

263
Data/GuildConfiguration.cs Normal file
View file

@ -0,0 +1,263 @@
using Discord.WebSocket;
using Npgsql;
using NpgsqlTypes;
using System;
using System.Data.Common;
using System.Linq;
using System.Threading.Tasks;
namespace BirthdayBot.Data
{
/// <summary>
/// Represents guild-specific configuration as exists in the database.
/// Updating any property requires a call to <see cref="UpdateAsync"/> for changes to take effect.
/// </summary>
class GuildConfiguration
{
/// <summary>
/// Gets this configuration's corresponding guild ID.
/// </summary>
public ulong GuildId { get; }
/// <summary>
/// Gets or sets the guild's designated usable role ID.
/// Updating this value requires a call to <see cref="UpdateAsync"/>.
/// </summary>
public ulong? RoleId { get; set; }
/// <summary>
/// Gets or sets the announcement channel ID.
/// Updating this value requires a call to <see cref="UpdateAsync"/>.
/// </summary>
public ulong? AnnounceChannelId { get; set; }
/// <summary>
/// Gets or sets the guild's default time zone ztring.
/// Updating this value requires a call to <see cref="UpdateAsync"/>.
/// </summary>
public string TimeZone { get; set; }
/// <summary>
/// Gets or sets the guild's moderated mode setting.
/// Updating this value requires a call to <see cref="UpdateAsync"/>.
/// </summary>
public bool IsModerated { get; set; }
/// <summary>
/// Gets or sets the guild's corresponding bot moderator role ID.
/// Updating this value requires a call to <see cref="UpdateAsync"/>.
/// </summary>
public ulong? ModeratorRole { get; set; }
/// <summary>
/// Gets or sets the guild-specific birthday announcement message.
/// Updating this value requires a call to <see cref="UpdateAsync"/>.
/// </summary>
public (string, string) AnnounceMessages { get; set; }
/// <summary>
/// Gets or sets the announcement ping setting.
/// Updating this value requires a call to <see cref="UpdateAsync"/>.
/// </summary>
public bool AnnouncePing { get; set; }
// Called by Load. Double-check ordinals when changes are made.
private GuildConfiguration(DbDataReader reader)
{
GuildId = (ulong)reader.GetInt64(0);
if (!reader.IsDBNull(1)) RoleId = (ulong)reader.GetInt64(1);
if (!reader.IsDBNull(2)) AnnounceChannelId = (ulong)reader.GetInt64(2);
TimeZone = reader.IsDBNull(3) ? null : reader.GetString(3);
IsModerated = reader.GetBoolean(4);
if (!reader.IsDBNull(5)) ModeratorRole = (ulong)reader.GetInt64(5);
string announceMsg = reader.IsDBNull(6) ? null : reader.GetString(6);
string announceMsgPl = reader.IsDBNull(7) ? null : reader.GetString(7);
AnnounceMessages = (announceMsg, announceMsgPl);
AnnouncePing = reader.GetBoolean(8);
}
/// <summary>
/// Checks if the given user exists in the block list.
/// If the server is in moderated mode, this always returns true.
/// </summary>
public async Task<bool> IsUserBlockedAsync(ulong userId)
{
if (IsModerated) return true;
using var db = await Database.OpenConnectionAsync();
using var c = db.CreateCommand();
c.CommandText = $"select * from {BackingTableBans} "
+ "where guild_id = @Gid and user_id = @Uid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId;
c.Prepare();
using var r = await c.ExecuteReaderAsync();
if (await r.ReadAsync()) return true;
return false;
}
/// <summary>
/// Adds the specified user to the block list corresponding to this guild.
/// </summary>
public async Task BlockUserAsync(ulong userId)
{
using var db = await Database.OpenConnectionAsync();
using var c = db.CreateCommand();
c.CommandText = $"insert into {BackingTableBans} (guild_id, user_id) "
+ "values (@Gid, @Uid) "
+ "on conflict (guild_id, user_id) do nothing";
// There is no validation on whether the requested user is even in the guild. will this be a problem?
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
/// <summary>
/// Removes the specified user from the block list corresponding to this guild.
/// </summary>
/// <returns>True if a user has been removed, false if the requested user was not in this list.</returns>
public async Task<bool> UnblockUserAsync(ulong userId)
{
using var db = await Database.OpenConnectionAsync();
using var c = db.CreateCommand();
c.CommandText = $"delete from {BackingTableBans} where "
+ "guild_id = @Gid and user_id = @Uid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId;
c.Prepare();
var result = await c.ExecuteNonQueryAsync();
return result != 0;
}
/// <summary>
/// Checks if the given user can be considered a bot moderator.
/// Checks for either the Manage Guild permission or if the user is within a predetermined role.
/// </summary>
public bool IsBotModerator(SocketGuildUser user)
=> user.GuildPermissions.ManageGuild || (ModeratorRole.HasValue && user.Roles.Any(r => r.Id == ModeratorRole.Value));
#region Database
public const string BackingTable = "settings";
public const string BackingTableBans = "banned_users";
internal static async Task DatabaseSetupAsync(NpgsqlConnection db)
{
using (var c = db.CreateCommand())
{
c.CommandText = $"create table if not exists {BackingTable} ("
+ "guild_id bigint primary key, "
+ "role_id bigint null, "
+ "channel_announce_id bigint null, "
+ "time_zone text null, "
+ "moderated boolean not null default FALSE, "
+ "moderator_role bigint null, "
+ "announce_message text null, "
+ "announce_message_pl text null, "
+ "announce_ping boolean not null default FALSE, "
+ "last_seen timestamptz not null default NOW()"
+ ")";
await c.ExecuteNonQueryAsync();
}
using (var c = db.CreateCommand())
{
c.CommandText = $"create table if not exists {BackingTableBans} ("
+ $"guild_id bigint not null references {BackingTable} ON DELETE CASCADE, "
+ "user_id bigint not null, "
+ "PRIMARY KEY (guild_id, user_id)"
+ ")";
await c.ExecuteNonQueryAsync();
}
}
/// <summary>
/// Fetches guild settings from the database. If no corresponding entry exists, it will be created.
/// </summary>
/// <param name="nullIfUnknown">
/// If true, this method shall not create a new entry and will return null if the guild does
/// not exist in the database.
/// </param>
public static async Task<GuildConfiguration> LoadAsync(ulong guildId, bool nullIfUnknown)
{
using (var db = await Database.OpenConnectionAsync())
{
using (var c = db.CreateCommand())
{
// Take note of ordinals for the constructor
c.CommandText = "select guild_id, role_id, channel_announce_id, time_zone, "
+ " moderated, moderator_role, announce_message, announce_message_pl, announce_ping "
+ $"from {BackingTable} where guild_id = @Gid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId;
c.Prepare();
using var r = await c.ExecuteReaderAsync();
if (await r.ReadAsync()) return new GuildConfiguration(r);
}
if (nullIfUnknown) return null;
// If we got here, no row exists. Create it with default values.
using (var c = db.CreateCommand())
{
c.CommandText = $"insert into {BackingTable} (guild_id) values (@Gid)";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
}
// With a new row created, try this again
return await LoadAsync(guildId, nullIfUnknown);
}
/// <summary>
/// Updates values on the backing database with values from this object instance.
/// </summary>
public async Task UpdateAsync()
{
using var db = await Database.OpenConnectionAsync();
using var c = db.CreateCommand();
c.CommandText = $"update {BackingTable} set "
+ "role_id = @RoleId, "
+ "channel_announce_id = @ChannelId, "
+ "time_zone = @TimeZone, "
+ "moderated = @Moderated, "
+ "moderator_role = @ModRole, "
+ "announce_message = @AnnounceMsg, "
+ "announce_message_pl = @AnnounceMsgPl, "
+ "announce_ping = @AnnouncePing "
+ "where guild_id = @Gid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
NpgsqlParameter p;
p = c.Parameters.Add("@RoleId", NpgsqlDbType.Bigint);
if (RoleId.HasValue) p.Value = (long)RoleId.Value;
else p.Value = DBNull.Value;
p = c.Parameters.Add("@ChannelId", NpgsqlDbType.Bigint);
if (AnnounceChannelId.HasValue) p.Value = (long)AnnounceChannelId.Value;
else p.Value = DBNull.Value;
p = c.Parameters.Add("@TimeZone", NpgsqlDbType.Text);
if (TimeZone != null) p.Value = TimeZone;
else p.Value = DBNull.Value;
c.Parameters.Add("@Moderated", NpgsqlDbType.Boolean).Value = IsModerated;
p = c.Parameters.Add("@ModRole", NpgsqlDbType.Bigint);
if (ModeratorRole.HasValue) p.Value = (long)ModeratorRole.Value;
else p.Value = DBNull.Value;
p = c.Parameters.Add("@AnnounceMsg", NpgsqlDbType.Text);
if (AnnounceMessages.Item1 != null) p.Value = AnnounceMessages.Item1;
else p.Value = DBNull.Value;
p = c.Parameters.Add("@AnnounceMsgPl", NpgsqlDbType.Text);
if (AnnounceMessages.Item2 != null) p.Value = AnnounceMessages.Item2;
else p.Value = DBNull.Value;
c.Parameters.Add("@AnnouncePing", NpgsqlDbType.Boolean).Value = AnnouncePing;
c.Prepare();
c.ExecuteNonQuery();
}
#endregion
}
}

View file

@ -1,429 +0,0 @@
using Discord.WebSocket;
using Npgsql;
using NpgsqlTypes;
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Linq;
using System.Threading.Tasks;
namespace BirthdayBot.Data
{
/// <summary>
/// Holds various pieces of state information for a guild the bot is operating in.
/// Includes, among other things, a copy of the guild's settings and a list of all known users with birthdays.
/// </summary>
class GuildStateInformation
{
private readonly Database _db;
private ulong? _bdayRole;
private ulong? _announceCh;
private ulong? _modRole;
private string _tz;
private bool _moderated;
private string _announceMsg;
private string _announceMsgPl;
private bool _announcePing;
private readonly Dictionary<ulong, GuildUserSettings> _userCache;
public ulong GuildId { get; }
/// <summary>
/// Gets a list of cached registered user information.
/// </summary>
public IEnumerable<GuildUserSettings> Users {
get {
var items = new List<GuildUserSettings>();
lock (this)
{
foreach (var item in _userCache.Values) items.Add(item);
}
return items;
}
}
/// <summary>
/// Gets the guild's designated Role ID.
/// </summary>
public ulong? RoleId { get { lock (this) { return _bdayRole; } } }
/// <summary>
/// Gets the designated announcement Channel ID.
/// </summary>
public ulong? AnnounceChannelId { get { lock (this) { return _announceCh; } } }
/// <summary>
/// Gets the guild's default time zone.
/// </summary>
public string TimeZone { get { lock (this) { return _tz; } } }
/// <summary>
/// Gets whether the guild is in moderated mode.
/// </summary>
public bool IsModerated { get { lock (this) { return _moderated; } } }
/// <summary>
/// Gets the designated moderator role ID.
/// </summary>
public ulong? ModeratorRole { get { lock (this) { return _modRole; } } }
/// <summary>
/// Gets the guild-specific birthday announcement message.
/// </summary>
public (string, string) AnnounceMessages { get { lock (this) { return (_announceMsg, _announceMsgPl); } } }
/// <summary>
/// Gets whether to ping users in the announcement message instead of displaying their names.
/// </summary>
public bool AnnouncePing { get { lock (this) { return _announcePing; } } }
// Called by LoadSettingsAsync. Double-check ordinals when changes are made.
private GuildStateInformation(DbDataReader reader, Database dbconfig)
{
_db = dbconfig;
GuildId = (ulong)reader.GetInt64(0);
if (!reader.IsDBNull(1))
{
_bdayRole = (ulong)reader.GetInt64(1);
}
if (!reader.IsDBNull(2)) _announceCh = (ulong)reader.GetInt64(2);
_tz = reader.IsDBNull(3) ? null : reader.GetString(3);
_moderated = reader.GetBoolean(4);
if (!reader.IsDBNull(5)) _modRole = (ulong)reader.GetInt64(5);
_announceMsg = reader.IsDBNull(6) ? null : reader.GetString(6);
_announceMsgPl = reader.IsDBNull(7) ? null : reader.GetString(7);
_announcePing = reader.GetBoolean(8);
// Get user information loaded up.
var userresult = GuildUserSettings.GetGuildUsersAsync(dbconfig, GuildId);
_userCache = new Dictionary<ulong, GuildUserSettings>();
foreach (var item in userresult)
{
_userCache.Add(item.UserId, item);
}
}
/// <summary>
/// Gets user information from th is guild. If the user doesn't exist in the backing database,
/// a new instance is created which is capable of adding the user to the database.
/// </summary>
/// <remarks>
/// For users with the Known property set to false, be sure to call
/// <see cref="GuildUserSettings.DeleteAsync(Database)"/> if the resulting object is otherwise unused.
/// </remarks>
public GuildUserSettings GetUser(ulong userId)
{
lock (this)
{
if (_userCache.ContainsKey(userId))
{
return _userCache[userId];
}
// No result. Create a blank entry and add it to the list,
// in case it gets updated and then referenced later.
var blank = new GuildUserSettings(GuildId, userId);
_userCache.Add(userId, blank);
return blank;
}
}
/// <summary>
/// Deletes the user from the backing database. Drops the locally cached entry.
/// </summary>
public async Task DeleteUserAsync(ulong userId)
{
GuildUserSettings user = null;
lock (this)
{
if (!_userCache.TryGetValue(userId, out user))
{
return;
}
_userCache.Remove(userId);
}
await user.DeleteAsync(_db);
}
/// <summary>
/// Checks if the given user is blocked from issuing commands.
/// If the server is in moderated mode, this always returns true.
/// Does not check if the user is a manager.
/// </summary>
public async Task<bool> IsUserBlockedAsync(ulong userId)
{
if (IsModerated) return true;
// Block list is not cached, thus doing a database lookup
// TODO cache block list?
using (var db = await _db.OpenConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"select * from {BackingTableBans} "
+ "where guild_id = @Gid and user_id = @Uid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId;
c.Prepare();
using (var r = await c.ExecuteReaderAsync())
{
if (await r.ReadAsync()) return true;
return false;
}
}
}
}
/// <summary>
/// Checks if the given user is a moderator either by having the Manage Server permission or
/// being in the designated moderator role.
/// </summary>
public bool IsUserModerator(SocketGuildUser user)
{
if (user.GuildPermissions.ManageGuild) return true;
lock (this)
{
if (ModeratorRole.HasValue)
{
if (user.Roles.Where(r => r.Id == ModeratorRole.Value).Count() > 0) return true;
}
}
return false;
}
/// <summary>
/// Adds the specified user to the block list, preventing them from issuing commands.
/// </summary>
public async Task BlockUserAsync(ulong userId)
{
// TODO cache block list?
using (var db = await _db.OpenConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"insert into {BackingTableBans} (guild_id, user_id) "
+ "values (@Gid, @Uid) "
+ "on conflict (guild_id, user_id) do nothing";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
}
}
public async Task UnbanUserAsync(ulong userId)
{
// TODO cache block list?
using (var db = await _db.OpenConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"delete from {BackingTableBans} where "
+ "guild_id = @Gid and user_id = @Uid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
}
}
public void UpdateRole(ulong roleId)
{
lock (this)
{
_bdayRole = roleId;
UpdateDatabase();
}
}
public void UpdateAnnounceChannel(ulong? channelId)
{
lock (this)
{
_announceCh = channelId;
UpdateDatabase();
}
}
public void UpdateTimeZone(string tzString)
{
lock (this)
{
_tz = tzString;
UpdateDatabase();
}
}
public void UpdateModeratedMode(bool isModerated)
{
lock (this)
{
_moderated = isModerated;
UpdateDatabase();
}
}
public void UpdateModeratorRole(ulong? roleId)
{
lock (this)
{
_modRole = roleId;
UpdateDatabase();
}
}
public void UpdateAnnounceMessage(string message, bool plural)
{
lock (this)
{
if (plural) _announceMsgPl = message;
else _announceMsg = message;
UpdateDatabase();
}
}
public void UpdateAnnouncePing(bool value)
{
lock (this)
{
_announcePing = value;
UpdateDatabase();
}
}
#region Database
public const string BackingTable = "settings";
public const string BackingTableBans = "banned_users";
internal static void SetUpDatabaseTable(NpgsqlConnection db)
{
using (var c = db.CreateCommand())
{
c.CommandText = $"create table if not exists {BackingTable} ("
+ "guild_id bigint primary key, "
+ "role_id bigint null, "
+ "channel_announce_id bigint null, "
+ "time_zone text null, "
+ "moderated boolean not null default FALSE, "
+ "moderator_role bigint null, "
+ "announce_message text null, "
+ "announce_message_pl text null, "
+ "announce_ping boolean not null default FALSE, "
+ "last_seen timestamptz not null default NOW()"
+ ")";
c.ExecuteNonQuery();
}
using (var c = db.CreateCommand())
{
c.CommandText = $"create table if not exists {BackingTableBans} ("
+ $"guild_id bigint not null references {BackingTable} ON DELETE CASCADE, "
+ "user_id bigint not null, "
+ "PRIMARY KEY (guild_id, user_id)"
+ ")";
c.ExecuteNonQuery();
}
}
/// <summary>
/// Retrieves an object instance representative of guild settings for the specified guild.
/// If settings for the given guild do not yet exist, a new value is created.
/// </summary>
internal async static Task<GuildStateInformation> LoadSettingsAsync(Database dbsettings, ulong guild)
{
using (var db = await dbsettings.OpenConnectionAsync())
{
using (var c = db.CreateCommand())
{
// Take note of ordinals for use in the constructor
c.CommandText = "select guild_id, role_id, channel_announce_id, time_zone, "
+ " moderated, moderator_role, announce_message, announce_message_pl, announce_ping "
+ $"from {BackingTable} where guild_id = @Gid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guild;
c.Prepare();
using (var r = await c.ExecuteReaderAsync())
{
if (await r.ReadAsync())
{
return new GuildStateInformation(r, dbsettings);
}
}
}
// If we got here, no row exists. Create it.
using (var c = db.CreateCommand())
{
c.CommandText = $"insert into {BackingTable} (guild_id) values (@Gid)";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guild;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
}
// New row created. Try this again.
return await LoadSettingsAsync(dbsettings, guild);
}
/// <summary>
/// Updates the backing database with values from this instance
/// This is a non-asynchronous operation. That may be bad.
/// </summary>
private void UpdateDatabase()
{
using (var db = _db.OpenConnectionAsync().GetAwaiter().GetResult())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"update {BackingTable} set "
+ "role_id = @RoleId, "
+ "channel_announce_id = @ChannelId, "
+ "time_zone = @TimeZone, "
+ "moderated = @Moderated, "
+ "moderator_role = @ModRole, "
+ "announce_message = @AnnounceMsg, "
+ "announce_message_pl = @AnnounceMsgPl, "
+ "announce_ping = @AnnouncePing "
+ "where guild_id = @Gid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
NpgsqlParameter p;
p = c.Parameters.Add("@RoleId", NpgsqlDbType.Bigint);
if (RoleId.HasValue) p.Value = (long)RoleId.Value;
else p.Value = DBNull.Value;
p = c.Parameters.Add("@ChannelId", NpgsqlDbType.Bigint);
if (_announceCh.HasValue) p.Value = (long)_announceCh.Value;
else p.Value = DBNull.Value;
p = c.Parameters.Add("@TimeZone", NpgsqlDbType.Text);
if (_tz != null) p.Value = _tz;
else p.Value = DBNull.Value;
c.Parameters.Add("@Moderated", NpgsqlDbType.Boolean).Value = _moderated;
p = c.Parameters.Add("@ModRole", NpgsqlDbType.Bigint);
if (ModeratorRole.HasValue) p.Value = (long)ModeratorRole.Value;
else p.Value = DBNull.Value;
p = c.Parameters.Add("@AnnounceMsg", NpgsqlDbType.Text);
if (_announceMsg != null) p.Value = _announceMsg;
else p.Value = DBNull.Value;
p = c.Parameters.Add("@AnnounceMsgPl", NpgsqlDbType.Text);
if (_announceMsgPl != null) p.Value = _announceMsgPl;
else p.Value = DBNull.Value;
c.Parameters.Add("@AnnouncePing", NpgsqlDbType.Boolean).Value = _announcePing;
c.Prepare();
c.ExecuteNonQuery();
}
}
}
#endregion
}
}

View file

@ -0,0 +1,151 @@
using Npgsql;
using NpgsqlTypes;
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Threading.Tasks;
namespace BirthdayBot.Data
{
/// <summary>
/// Represents configuration for a guild user.
/// </summary>
class GuildUserConfiguration
{
public ulong GuildId { get; }
public ulong UserId { get; }
/// <summary>
/// Month of birth as a numeric value. Range 1-12.
/// </summary>
public int BirthMonth { get; private set; }
/// <summary>
/// Day of birth as a numeric value. Ranges between 1-31 or lower based on month value.
/// </summary>
public int BirthDay { get; private set; }
public string TimeZone { get; private set; }
public bool IsKnown { get { return BirthMonth != 0 && BirthDay != 0; } }
/// <summary>
/// Creates a new, data-less instance without a corresponding database entry.
/// Calling <see cref="UpdateAsync(int, int, int)"/> will create a real database enty
/// </summary>
private GuildUserConfiguration(ulong guildId, ulong userId)
{
GuildId = guildId;
UserId = userId;
}
// Called by GetGuildUsersAsync. Double-check ordinals when changes are made.
private GuildUserConfiguration(DbDataReader reader)
{
GuildId = (ulong)reader.GetInt64(0);
UserId = (ulong)reader.GetInt64(1);
BirthMonth = reader.GetInt32(2);
BirthDay = reader.GetInt32(3);
if (!reader.IsDBNull(4)) TimeZone = reader.GetString(4);
}
/// <summary>
/// Updates user with given information.
/// </summary>
public async Task UpdateAsync(int month, int day, string newtz)
{
using (var db = await Database.OpenConnectionAsync())
{
using var c = db.CreateCommand();
c.CommandText = $"insert into {BackingTable} "
+ "(guild_id, user_id, birth_month, birth_day, time_zone) values "
+ "(@Gid, @Uid, @Month, @Day, @Tz) "
+ "on conflict (guild_id, user_id) do update "
+ "set birth_month = EXCLUDED.birth_month, birth_day = EXCLUDED.birth_day, time_zone = EXCLUDED.time_zone";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)UserId;
c.Parameters.Add("@Month", NpgsqlDbType.Numeric).Value = month;
c.Parameters.Add("@Day", NpgsqlDbType.Numeric).Value = day;
var tzp = c.Parameters.Add("@Tz", NpgsqlDbType.Text);
if (newtz != null) tzp.Value = newtz;
else tzp.Value = DBNull.Value;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
// Database update succeeded; update instance values
BirthMonth = month;
BirthDay = day;
TimeZone = newtz;
}
/// <summary>
/// Deletes information of this user from the backing database.
/// The corresponding object reference should ideally be discarded after calling this.
/// </summary>
public async Task DeleteAsync()
{
using var db = await Database.OpenConnectionAsync();
using var c = db.CreateCommand();
c.CommandText = $"delete from {BackingTable} "
+ "where guild_id = @Gid and user_id = @Uid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)UserId;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
#region Database
public const string BackingTable = "user_birthdays";
// Take note of ordinals for use in the constructor
private const string SelectFields = "guild_id, user_id, birth_month, birth_day, time_zone";
internal static async Task DatabaseSetupAsync(NpgsqlConnection db)
{
using var c = db.CreateCommand();
c.CommandText = $"create table if not exists {BackingTable} ("
+ $"guild_id bigint not null references {GuildConfiguration.BackingTable} ON DELETE CASCADE, "
+ "user_id bigint not null, "
+ "birth_month integer not null, "
+ "birth_day integer not null, "
+ "time_zone text null, "
+ "last_seen timestamptz not null default NOW(), "
+ "PRIMARY KEY (guild_id, user_id)" // index automatically created with this
+ ")";
await c.ExecuteNonQueryAsync();
}
/// <summary>
/// Attempts to retrieve a user's configuration. Returns a new, updateable instance if none is found.
/// </summary>
public static async Task<GuildUserConfiguration> LoadAsync(ulong guildId, ulong userId)
{
using var db = await Database.OpenConnectionAsync();
using var c = db.CreateCommand();
c.CommandText = $"select {SelectFields} from {BackingTable} where guild_id = @Gid and user_id = @Uid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId;
c.Prepare();
using var r = c.ExecuteReader();
if (await r.ReadAsync()) return new GuildUserConfiguration(r);
else return new GuildUserConfiguration(guildId, userId);
}
/// <summary>
/// Gets all known user configuration records associated with the specified guild.
/// </summary>
public static async Task<IEnumerable<GuildUserConfiguration>> LoadAllAsync(ulong guildId)
{
using var db = await Database.OpenConnectionAsync();
using var c = db.CreateCommand();
c.CommandText = $"select {SelectFields} from {BackingTable} where guild_id = @Gid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId;
c.Prepare();
using var r = await c.ExecuteReaderAsync();
var result = new List<GuildUserConfiguration>();
while (await r.ReadAsync()) result.Add(new GuildUserConfiguration(r));
return result;
}
#endregion
}
}

View file

@ -1,169 +0,0 @@
using Npgsql;
using NpgsqlTypes;
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Threading.Tasks;
namespace BirthdayBot.Data
{
/// <summary>
/// Representation of a user's birthday settings within a guild.
/// Instances are held and managed by <see cref="="GuildStateInformation"/>.
/// </summary>
class GuildUserSettings
{
private int _month;
private int _day;
private string _tz;
public ulong GuildId { get; }
public ulong UserId { get; }
/// <summary>
/// Month of birth as a numeric value. Range 1-12.
/// </summary>
public int BirthMonth { get { return _month; } }
/// <summary>
/// Day of birth as a numeric value. Ranges between 1-31 or lower based on month value.
/// </summary>
public int BirthDay { get { return _day; } }
public string TimeZone { get { return _tz; } }
public bool IsKnown { get { return _month != 0 && _day != 0; } }
/// <summary>
/// Creates a data-less instance without any useful information.
/// Calling <see cref="UpdateAsync(int, int, int)"/> will create a real database enty
/// </summary>
public GuildUserSettings(ulong guildId, ulong userId)
{
GuildId = guildId;
UserId = userId;
}
// Called by GetGuildUsersAsync. Double-check ordinals when changes are made.
private GuildUserSettings(DbDataReader reader)
{
GuildId = (ulong)reader.GetInt64(0);
UserId = (ulong)reader.GetInt64(1);
_month = reader.GetInt32(2);
_day = reader.GetInt32(3);
if (!reader.IsDBNull(4)) _tz = reader.GetString(4);
}
/// <summary>
/// Updates user with given information.
/// NOTE: If there exists a tz value and the update contains none, the old tz value is retained.
/// </summary>
public async Task UpdateAsync(int month, int day, string newtz, Database dbconfig)
{
// TODO note from rewrite: huh? why are we doing this here?
var inserttz = newtz ?? TimeZone;
using (var db = await dbconfig.OpenConnectionAsync())
{
// Will do a delete/insert instead of insert...on conflict update. Because lazy.
using (var t = db.BeginTransaction())
{
await DoDeleteAsync(db);
using (var c = db.CreateCommand())
{
c.CommandText = $"insert into {BackingTable} "
+ "(guild_id, user_id, birth_month, birth_day, time_zone) values "
+ "(@Gid, @Uid, @Month, @Day, @Tz)";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)UserId;
c.Parameters.Add("@Month", NpgsqlDbType.Numeric).Value = month;
c.Parameters.Add("@Day", NpgsqlDbType.Numeric).Value = day;
var p = c.Parameters.Add("@Tz", NpgsqlDbType.Text);
if (inserttz != null) p.Value = inserttz;
else p.Value = DBNull.Value;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
await t.CommitAsync();
}
}
// We didn't crash! Get the new values stored locally.
_month = month;
_day = day;
_tz = inserttz;
}
/// <summary>
/// Deletes information of this user from the backing database.
/// The corresponding object reference should ideally be discarded after calling this.
/// </summary>
public async Task DeleteAsync(Database dbconfig)
{
using (var db = await dbconfig.OpenConnectionAsync())
{
await DoDeleteAsync(db);
}
}
// Shared between UpdateAsync and DeleteAsync
private async Task DoDeleteAsync(NpgsqlConnection dbconn)
{
using (var c = dbconn.CreateCommand())
{
c.CommandText = $"delete from {BackingTable} "
+ "where guild_id = @Gid and user_id = @Uid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)UserId;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
}
#region Database
public const string BackingTable = "user_birthdays";
internal static void SetUpDatabaseTable(NpgsqlConnection db)
{
using (var c = db.CreateCommand())
{
c.CommandText = $"create table if not exists {BackingTable} ("
+ $"guild_id bigint not null references {GuildStateInformation.BackingTable} ON DELETE CASCADE, "
+ "user_id bigint not null, "
+ "birth_month integer not null, "
+ "birth_day integer not null, "
+ "time_zone text null, "
+ "last_seen timestamptz not null default NOW(), "
+ "PRIMARY KEY (guild_id, user_id)"
+ ")";
c.ExecuteNonQuery();
}
}
/// <summary>
/// Gets all known birthday records from the specified guild. No further filtering is done here.
/// </summary>
internal static IEnumerable<GuildUserSettings> GetGuildUsersAsync(Database dbsettings, ulong guildId)
{
using (var db = dbsettings.OpenConnectionAsync().GetAwaiter().GetResult())
{
using (var c = db.CreateCommand())
{
// Take note of ordinals for use in the constructor
c.CommandText = "select guild_id, user_id, birth_month, birth_day, time_zone "
+ $"from {BackingTable} where guild_id = @Gid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId;
c.Prepare();
using (var r = c.ExecuteReader())
{
var result = new List<GuildUserSettings>();
while (r.Read())
{
result.Add(new GuildUserSettings(r));
}
return result;
}
}
}
}
#endregion
}
}

View file

@ -22,7 +22,7 @@ namespace BirthdayBot
var dc = new DiscordSocketConfig() var dc = new DiscordSocketConfig()
{ {
AlwaysDownloadUsers = true, AlwaysDownloadUsers = true,
DefaultRetryMode = Discord.RetryMode.RetryRatelimit, DefaultRetryMode = RetryMode.RetryRatelimit,
MessageCacheSize = 0, MessageCacheSize = 0,
TotalShards = cfg.ShardCount, TotalShards = cfg.ShardCount,
ExclusiveBulkDelete = true ExclusiveBulkDelete = true

View file

@ -1,4 +1,5 @@
using Discord.WebSocket; using BirthdayBot.Data;
using Discord.WebSocket;
using NodaTime; using NodaTime;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
@ -22,7 +23,7 @@ namespace BirthdayBot.UserInterface
public const string NoParameterError = ":x: This command does not accept any parameters."; public const string NoParameterError = ":x: This command does not accept any parameters.";
public const string InternalError = ":x: An internal bot error occurred. The bot maintainer has been notified of the issue."; public const string InternalError = ":x: An internal bot error occurred. The bot maintainer has been notified of the issue.";
public delegate Task CommandHandler(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser); public delegate Task CommandHandler(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser);
protected static Dictionary<string, string> TzNameMap { protected static Dictionary<string, string> TzNameMap {
get { get {

View file

@ -1,4 +1,5 @@
using Discord; using BirthdayBot.Data;
using Discord;
using Discord.WebSocket; using Discord.WebSocket;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text; using System.Text;
@ -89,13 +90,13 @@ namespace BirthdayBot.UserInterface
return (helpRegular.Build(), helpConfig.Build()); return (helpRegular.Build(), helpConfig.Build());
} }
private async Task CmdHelp(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) private async Task CmdHelp(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
=> await reqChannel.SendMessageAsync(embed: _helpEmbed); => await reqChannel.SendMessageAsync(embed: _helpEmbed);
private async Task CmdHelpConfig(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) private async Task CmdHelpConfig(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
=> await reqChannel.SendMessageAsync(embed: _helpConfigEmbed); => await reqChannel.SendMessageAsync(embed: _helpConfigEmbed);
private async Task CmdHelpTzdata(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) private async Task CmdHelpTzdata(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
{ {
const string tzhelp = "You may specify a time zone in order to have your birthday recognized with respect to your local time. " const string tzhelp = "You may specify a time zone in order to have your birthday recognized with respect to your local time. "
+ "This bot only accepts zone names from the IANA Time Zone Database (a.k.a. Olson Database).\n\n" + "This bot only accepts zone names from the IANA Time Zone Database (a.k.a. Olson Database).\n\n"
@ -110,7 +111,7 @@ namespace BirthdayBot.UserInterface
await reqChannel.SendMessageAsync(embed: embed.Build()); await reqChannel.SendMessageAsync(embed: embed.Build());
} }
private async Task CmdHelpMessage(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) private async Task CmdHelpMessage(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
{ {
const string msghelp = "The `message` and `messagepl` subcommands allow for editing the message sent into the announcement " const string msghelp = "The `message` and `messagepl` subcommands allow for editing the message sent into the announcement "
+ "channel (defined with `{0}config channel`). This feature is separated across two commands:\n" + "channel (defined with `{0}config channel`). This feature is separated across two commands:\n"
@ -136,7 +137,7 @@ namespace BirthdayBot.UserInterface
await reqChannel.SendMessageAsync(embed: embed.Build()); await reqChannel.SendMessageAsync(embed: embed.Build());
} }
private async Task CmdInfo(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) private async Task CmdInfo(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
{ {
var strStats = new StringBuilder(); var strStats = new StringBuilder();
var asmnm = System.Reflection.Assembly.GetExecutingAssembly().GetName(); var asmnm = System.Reflection.Assembly.GetExecutingAssembly().GetName();

View file

@ -35,7 +35,7 @@ namespace BirthdayBot.UserInterface
new CommandDocumentation(new string[] { "when" }, "Displays the given user's birthday information.", null); new CommandDocumentation(new string[] { "when" }, "Displays the given user's birthday information.", null);
#endregion #endregion
private async Task CmdWhen(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) private async Task CmdWhen(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
{ {
// Requires a parameter // Requires a parameter
if (param.Length == 1) if (param.Length == 1)
@ -53,8 +53,7 @@ namespace BirthdayBot.UserInterface
SocketGuildUser searchTarget = null; SocketGuildUser searchTarget = null;
ulong searchId = 0; if (!TryGetUserId(search, out ulong searchId)) // ID lookup
if (!TryGetUserId(search, out searchId)) // ID lookup
{ {
// name lookup without discriminator // name lookup without discriminator
foreach (var searchuser in reqChannel.Guild.Users) foreach (var searchuser in reqChannel.Guild.Users)
@ -76,9 +75,8 @@ namespace BirthdayBot.UserInterface
return; return;
} }
var users = Instance.GuildCache[reqChannel.Guild.Id].Users; var searchTargetData = await GuildUserConfiguration.LoadAsync(reqChannel.Guild.Id, searchId);
var searchTargetData = users.FirstOrDefault(u => u.UserId == searchTarget.Id); if (!searchTargetData.IsKnown)
if (searchTargetData == null)
{ {
await reqChannel.SendMessageAsync("I do not have birthday information for that user."); await reqChannel.SendMessageAsync("I do not have birthday information for that user.");
return; return;
@ -93,10 +91,10 @@ namespace BirthdayBot.UserInterface
} }
// Creates a file with all birthdays. // Creates a file with all birthdays.
private async Task CmdList(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) private async Task CmdList(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
{ {
// For now, we're restricting this command to moderators only. This may turn into an option later. // For now, we're restricting this command to moderators only. This may turn into an option later.
if (!Instance.GuildCache[reqChannel.Guild.Id].IsUserModerator(reqUser)) if (!gconf.IsBotModerator(reqUser))
{ {
// Do not add detailed usage information to this error message. // Do not add detailed usage information to this error message.
await reqChannel.SendMessageAsync(":x: Only bot moderators may use this command."); await reqChannel.SendMessageAsync(":x: Only bot moderators may use this command.");
@ -120,7 +118,7 @@ namespace BirthdayBot.UserInterface
return; return;
} }
var bdlist = await LoadList(reqChannel.Guild, false); var bdlist = await GetSortedUsersAsync(reqChannel.Guild);
var filepath = Path.GetTempPath() + "birthdaybot-" + reqChannel.Guild.Id; var filepath = Path.GetTempPath() + "birthdaybot-" + reqChannel.Guild.Id;
string fileoutput; string fileoutput;
@ -158,13 +156,13 @@ namespace BirthdayBot.UserInterface
// "Recent and upcoming birthdays" // "Recent and upcoming birthdays"
// The 'recent' bit removes time zone ambiguity and spares us from extra time zone processing here // The 'recent' bit removes time zone ambiguity and spares us from extra time zone processing here
private async Task CmdUpcoming(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) private async Task CmdUpcoming(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
{ {
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var search = DateIndex(now.Month, now.Day) - 8; // begin search 8 days prior to current date UTC var search = DateIndex(now.Month, now.Day) - 8; // begin search 8 days prior to current date UTC
if (search <= 0) search = 366 - Math.Abs(search); if (search <= 0) search = 366 - Math.Abs(search);
var query = await LoadList(reqChannel.Guild, true); var query = await GetSortedUsersAsync(reqChannel.Guild);
var output = new StringBuilder(); var output = new StringBuilder();
var resultCount = 0; var resultCount = 0;
@ -219,43 +217,35 @@ namespace BirthdayBot.UserInterface
/// Fetches all guild birthdays and places them into an easily usable structure. /// Fetches all guild birthdays and places them into an easily usable structure.
/// Users currently not in the guild are not included in the result. /// Users currently not in the guild are not included in the result.
/// </summary> /// </summary>
private async Task<List<ListItem>> LoadList(SocketGuild guild, bool escapeFormat) private async Task<List<ListItem>> GetSortedUsersAsync(SocketGuild guild)
{ {
var ping = Instance.GuildCache[guild.Id].AnnouncePing; using var db = await Database.OpenConnectionAsync();
using var c = db.CreateCommand();
using (var db = await BotConfig.DatabaseSettings.OpenConnectionAsync()) c.CommandText = "select user_id, birth_month, birth_day from " + GuildUserConfiguration.BackingTable
+ " where guild_id = @Gid order by birth_month, birth_day";
c.Parameters.Add("@Gid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = (long)guild.Id;
c.Prepare();
using var r = await c.ExecuteReaderAsync();
var result = new List<ListItem>();
while (await r.ReadAsync())
{ {
using (var c = db.CreateCommand()) var id = (ulong)r.GetInt64(0);
var month = r.GetInt32(1);
var day = r.GetInt32(2);
var guildUser = guild.GetUser(id);
if (guildUser == null) continue; // Skip user not in guild
result.Add(new ListItem()
{ {
c.CommandText = "select user_id, birth_month, birth_day from " + GuildUserSettings.BackingTable BirthMonth = month,
+ " where guild_id = @Gid order by birth_month, birth_day"; BirthDay = day,
c.Parameters.Add("@Gid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = (long)guild.Id; DateIndex = DateIndex(month, day),
c.Prepare(); UserId = guildUser.Id,
using (var r = await c.ExecuteReaderAsync()) DisplayName = Common.FormatName(guildUser, false)
{ });
var result = new List<ListItem>();
while (await r.ReadAsync())
{
var id = (ulong)r.GetInt64(0);
var month = r.GetInt32(1);
var day = r.GetInt32(2);
var guildUser = guild.GetUser(id);
if (guildUser == null) continue; // Skip users not in guild
result.Add(new ListItem()
{
BirthMonth = month,
BirthDay = day,
DateIndex = DateIndex(month, day),
UserId = guildUser.Id,
DisplayName = Common.FormatName(guildUser, false)
});
}
return result;
}
}
} }
return result;
} }
private string ListExportNormal(SocketGuildChannel channel, IEnumerable<ListItem> list) private string ListExportNormal(SocketGuildChannel channel, IEnumerable<ListItem> list)
@ -295,7 +285,7 @@ namespace BirthdayBot.UserInterface
result.Append(','); result.Append(',');
if (user.Nickname != null) result.Append(user.Nickname); if (user.Nickname != null) result.Append(user.Nickname);
result.Append(','); result.Append(',');
result.Append($"{Common.MonthNames[item.BirthMonth]}-{item.BirthDay.ToString("00")}"); result.Append($"{Common.MonthNames[item.BirthMonth]}-{item.BirthDay:00}");
result.Append(','); result.Append(',');
result.Append(item.BirthMonth); result.Append(item.BirthMonth);
result.Append(','); result.Append(',');

View file

@ -1,4 +1,5 @@
using Discord.WebSocket; using BirthdayBot.Data;
using Discord.WebSocket;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@ -11,7 +12,7 @@ namespace BirthdayBot.UserInterface
{ {
private static readonly string ConfErrorPostfix = private static readonly string ConfErrorPostfix =
$" Refer to the `{CommandPrefix}help-config` command for information on this command's usage."; $" Refer to the `{CommandPrefix}help-config` command for information on this command's usage.";
private delegate Task ConfigSubcommand(string[] param, SocketTextChannel reqChannel); private delegate Task ConfigSubcommand(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel);
private readonly Dictionary<string, ConfigSubcommand> _subcommands; private readonly Dictionary<string, ConfigSubcommand> _subcommands;
private readonly Dictionary<string, CommandHandler> _usercommands; private readonly Dictionary<string, CommandHandler> _usercommands;
@ -57,11 +58,10 @@ namespace BirthdayBot.UserInterface
"Perform certain commands on behalf of another user.", null); "Perform certain commands on behalf of another user.", null);
#endregion #endregion
private async Task CmdConfigDispatch(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) private async Task CmdConfigDispatch(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
{ {
// Ignore those without the proper permissions. // Ignore those without the proper permissions.
// Requires either the manage guild permission or to be in the moderators role if (!gconf.IsBotModerator(reqUser))
if (!Instance.GuildCache[reqUser.Guild.Id].IsUserModerator(reqUser))
{ {
await reqChannel.SendMessageAsync(":x: This command may only be used by bot moderators."); await reqChannel.SendMessageAsync(":x: This command may only be used by bot moderators.");
return; return;
@ -73,7 +73,7 @@ namespace BirthdayBot.UserInterface
return; return;
} }
// Special case: Restrict 'modrole' to only guild managers // Special case: Restrict 'modrole' to only guild managers, not mods
if (string.Equals(param[1], "modrole", StringComparison.OrdinalIgnoreCase) && !reqUser.GuildPermissions.ManageGuild) if (string.Equals(param[1], "modrole", StringComparison.OrdinalIgnoreCase) && !reqUser.GuildPermissions.ManageGuild)
{ {
await reqChannel.SendMessageAsync(":x: This command may only be used by those with the `Manage Server` permission."); await reqChannel.SendMessageAsync(":x: This command may only be used by those with the `Manage Server` permission.");
@ -86,13 +86,13 @@ namespace BirthdayBot.UserInterface
if (_subcommands.TryGetValue(confparam[0], out ConfigSubcommand h)) if (_subcommands.TryGetValue(confparam[0], out ConfigSubcommand h))
{ {
await h(confparam, reqChannel); await h(confparam, gconf, reqChannel);
} }
} }
#region Configuration sub-commands #region Configuration sub-commands
// Birthday role set // Birthday role set
private async Task ScmdRole(string[] param, SocketTextChannel reqChannel) private async Task ScmdRole(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel)
{ {
if (param.Length != 2) if (param.Length != 2)
{ {
@ -112,13 +112,14 @@ namespace BirthdayBot.UserInterface
} }
else else
{ {
Instance.GuildCache[guild.Id].UpdateRole(role.Id); gconf.RoleId = role.Id;
await gconf.UpdateAsync();
await reqChannel.SendMessageAsync($":white_check_mark: The birthday role has been set as **{role.Name}**."); await reqChannel.SendMessageAsync($":white_check_mark: The birthday role has been set as **{role.Name}**.");
} }
} }
// Ping setting // Ping setting
private async Task ScmdPing(string[] param, SocketTextChannel reqChannel) private async Task ScmdPing(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel)
{ {
const string InputErr = ":x: You must specify either `off` or `on` in this setting."; const string InputErr = ":x: You must specify either `off` or `on` in this setting.";
if (param.Length != 2) if (param.Length != 2)
@ -146,26 +147,25 @@ namespace BirthdayBot.UserInterface
return; return;
} }
Instance.GuildCache[reqChannel.Guild.Id].UpdateAnnouncePing(setting); gconf.AnnouncePing = setting;
await gconf.UpdateAsync();
await reqChannel.SendMessageAsync(result); await reqChannel.SendMessageAsync(result);
} }
// Announcement channel set // Announcement channel set
private async Task ScmdChannel(string[] param, SocketTextChannel reqChannel) private async Task ScmdChannel(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel)
{ {
if (param.Length == 1) if (param.Length == 1) // No extra parameter. Unset announcement channel.
{ {
// No extra parameter. Unset announcement channel.
var gi = Instance.GuildCache[reqChannel.Guild.Id];
// Extra detail: Show a unique message if a channel hadn't been set prior. // Extra detail: Show a unique message if a channel hadn't been set prior.
if (!gi.AnnounceChannelId.HasValue) if (!gconf.AnnounceChannelId.HasValue)
{ {
await reqChannel.SendMessageAsync(":x: There is no announcement channel set. Nothing to unset."); await reqChannel.SendMessageAsync(":x: There is no announcement channel set. Nothing to unset.");
return; return;
} }
gi.UpdateAnnounceChannel(null); gconf.AnnounceChannelId = null;
await gconf.UpdateAsync();
await reqChannel.SendMessageAsync(":white_check_mark: The announcement channel has been unset."); await reqChannel.SendMessageAsync(":white_check_mark: The announcement channel has been unset.");
} }
else else
@ -204,7 +204,8 @@ namespace BirthdayBot.UserInterface
} }
// Update the value // Update the value
Instance.GuildCache[reqChannel.Guild.Id].UpdateAnnounceChannel(chId); gconf.AnnounceChannelId = chId;
await gconf.UpdateAsync();
// Report the success // Report the success
await reqChannel.SendMessageAsync($":white_check_mark: The announcement channel is now set to <#{chId}>."); await reqChannel.SendMessageAsync($":white_check_mark: The announcement channel is now set to <#{chId}>.");
@ -212,7 +213,7 @@ namespace BirthdayBot.UserInterface
} }
// Moderator role set // Moderator role set
private async Task ScmdModRole(string[] param, SocketTextChannel reqChannel) private async Task ScmdModRole(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel)
{ {
if (param.Length != 2) if (param.Length != 2)
{ {
@ -228,27 +229,26 @@ namespace BirthdayBot.UserInterface
} }
else else
{ {
Instance.GuildCache[guild.Id].UpdateModeratorRole(role.Id); gconf.ModeratorRole = role.Id;
await gconf.UpdateAsync();
await reqChannel.SendMessageAsync($":white_check_mark: The moderator role is now **{role.Name}**."); await reqChannel.SendMessageAsync($":white_check_mark: The moderator role is now **{role.Name}**.");
} }
} }
// Guild default time zone set/unset // Guild default time zone set/unset
private async Task ScmdZone(string[] param, SocketTextChannel reqChannel) private async Task ScmdZone(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel)
{ {
if (param.Length == 1) if (param.Length == 1) // No extra parameter. Unset guild default time zone.
{ {
// No extra parameter. Unset guild default time zone.
var gi = Instance.GuildCache[reqChannel.Guild.Id];
// Extra detail: Show a unique message if there is no set zone. // Extra detail: Show a unique message if there is no set zone.
if (!gi.AnnounceChannelId.HasValue) if (!gconf.AnnounceChannelId.HasValue)
{ {
await reqChannel.SendMessageAsync(":x: A default zone is not set. Nothing to unset."); await reqChannel.SendMessageAsync(":x: A default zone is not set. Nothing to unset.");
return; return;
} }
gi.UpdateTimeZone(null); gconf.TimeZone = null;
await gconf.UpdateAsync();
await reqChannel.SendMessageAsync(":white_check_mark: The default time zone preference has been removed."); await reqChannel.SendMessageAsync(":white_check_mark: The default time zone preference has been removed.");
} }
else else
@ -266,7 +266,8 @@ namespace BirthdayBot.UserInterface
} }
// Update value // Update value
Instance.GuildCache[reqChannel.Guild.Id].UpdateTimeZone(zone); gconf.TimeZone = zone;
await gconf.UpdateAsync();
// Report the success // Report the success
await reqChannel.SendMessageAsync($":white_check_mark: The server's time zone has been set to **{zone}**."); await reqChannel.SendMessageAsync($":white_check_mark: The server's time zone has been set to **{zone}**.");
@ -274,7 +275,7 @@ namespace BirthdayBot.UserInterface
} }
// Block/unblock individual non-manager users from using commands. // Block/unblock individual non-manager users from using commands.
private async Task ScmdBlock(string[] param, SocketTextChannel reqChannel) private async Task ScmdBlock(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel)
{ {
if (param.Length != 2) if (param.Length != 2)
{ {
@ -284,43 +285,41 @@ namespace BirthdayBot.UserInterface
bool doBan = param[0].ToLower() == "block"; // true = block, false = unblock bool doBan = param[0].ToLower() == "block"; // true = block, false = unblock
ulong inputId; if (!TryGetUserId(param[1], out ulong inputId))
if (!TryGetUserId(param[1], out inputId))
{ {
await reqChannel.SendMessageAsync(BadUserError); await reqChannel.SendMessageAsync(BadUserError);
return; return;
} }
var gi = Instance.GuildCache[reqChannel.Guild.Id]; var isBanned = await gconf.IsUserBlockedAsync(inputId);
var isBanned = await gi.IsUserBlockedAsync(inputId);
if (doBan) if (doBan)
{ {
if (!isBanned) if (!isBanned)
{ {
await gi.BlockUserAsync(inputId); await gconf.BlockUserAsync(inputId);
await reqChannel.SendMessageAsync(":white_check_mark: User has been blocked."); await reqChannel.SendMessageAsync(":white_check_mark: User has been blocked.");
} }
else else
{ {
// TODO bug: this is incorrectly always displayed when in moderated mode
await reqChannel.SendMessageAsync(":white_check_mark: User is already blocked."); await reqChannel.SendMessageAsync(":white_check_mark: User is already blocked.");
} }
} }
else else
{ {
if (isBanned) if (await gconf.UnblockUserAsync(inputId))
{ {
await gi.UnbanUserAsync(inputId);
await reqChannel.SendMessageAsync(":white_check_mark: User is now unblocked."); await reqChannel.SendMessageAsync(":white_check_mark: User is now unblocked.");
} }
else else
{ {
await reqChannel.SendMessageAsync(":white_check_mark: The specified user has not been blocked."); await reqChannel.SendMessageAsync(":white_check_mark: The specified user is not blocked.");
} }
} }
} }
// "moderated on/off" - Sets/unsets moderated mode. // "moderated on/off" - Sets/unsets moderated mode.
private async Task ScmdModerated(string[] param, SocketTextChannel reqChannel) private async Task ScmdModerated(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel)
{ {
if (param.Length != 2) if (param.Length != 2)
{ {
@ -334,26 +333,24 @@ namespace BirthdayBot.UserInterface
else if (parameter == "off") modSet = false; else if (parameter == "off") modSet = false;
else else
{ {
await reqChannel.SendMessageAsync(":x: Expected `on` or `off` as a parameter." + ConfErrorPostfix); await reqChannel.SendMessageAsync(":x: Expecting `on` or `off` as a parameter." + ConfErrorPostfix);
return; return;
} }
var gi = Instance.GuildCache[reqChannel.Guild.Id]; if (gconf.IsModerated == modSet)
var currentSet = gi.IsModerated;
gi.UpdateModeratedMode(modSet);
if (currentSet == modSet)
{ {
await reqChannel.SendMessageAsync($":white_check_mark: Moderated mode is already {parameter}."); await reqChannel.SendMessageAsync($":white_check_mark: Moderated mode is already {parameter}.");
} }
else else
{ {
gconf.IsModerated = modSet;
await gconf.UpdateAsync();
await reqChannel.SendMessageAsync($":white_check_mark: Moderated mode has been turned {parameter}."); await reqChannel.SendMessageAsync($":white_check_mark: Moderated mode has been turned {parameter}.");
} }
} }
// Sets/unsets custom announcement message. // Sets/unsets custom announcement message.
private async Task ScmdAnnounceMsg(string[] param, SocketTextChannel reqChannel) private async Task ScmdAnnounceMsg(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel)
{ {
var plural = param[0].ToLower().EndsWith("pl"); var plural = param[0].ToLower().EndsWith("pl");
@ -370,17 +367,21 @@ namespace BirthdayBot.UserInterface
clear = true; clear = true;
} }
Instance.GuildCache[reqChannel.Guild.Id].UpdateAnnounceMessage(newmsg, plural); (string, string) update;
const string report = ":white_check_mark: The {0} birthday announcement message has been {1}."; if (!plural) update = (newmsg, gconf.AnnounceMessages.Item2);
await reqChannel.SendMessageAsync(string.Format(report, plural ? "plural" : "singular", clear ? "reset" : "updated")); else update = (gconf.AnnounceMessages.Item1, newmsg);
gconf.AnnounceMessages = update;
await gconf.UpdateAsync();
await reqChannel.SendMessageAsync(string.Format(":white_check_mark: The {0} birthday announcement message has been {1}.",
plural ? "plural" : "singular", clear ? "reset" : "updated"));
} }
#endregion #endregion
// Execute command as another user // Execute command as another user
private async Task CmdOverride(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) private async Task CmdOverride(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
{ {
// Moderators only. As with config, silently drop if this check fails. // Moderators only. As with config, silently drop if this check fails.
if (!Instance.GuildCache[reqUser.Guild.Id].IsUserModerator(reqUser)) return; if (!gconf.IsBotModerator(reqUser)) return;
if (param.Length != 3) if (param.Length != 3)
{ {
@ -389,8 +390,7 @@ namespace BirthdayBot.UserInterface
} }
// Second parameter: determine the user to act as // Second parameter: determine the user to act as
ulong user = 0; if (!TryGetUserId(param[1], out ulong user))
if (!TryGetUserId(param[1], out user))
{ {
await reqChannel.SendMessageAsync(BadUserError, embed: DocOverride.UsageEmbed); await reqChannel.SendMessageAsync(BadUserError, embed: DocOverride.UsageEmbed);
return; return;
@ -416,8 +416,7 @@ namespace BirthdayBot.UserInterface
// Add command prefix to input, just in case. // Add command prefix to input, just in case.
overparam[0] = CommandPrefix + overparam[0].ToLower(); overparam[0] = CommandPrefix + overparam[0].ToLower();
} }
CommandHandler action = null; if (!_usercommands.TryGetValue(cmdsearch, out CommandHandler action))
if (!_usercommands.TryGetValue(cmdsearch, out action))
{ {
await reqChannel.SendMessageAsync($":x: `{cmdsearch}` is not an overridable command.", embed: DocOverride.UsageEmbed); await reqChannel.SendMessageAsync($":x: `{cmdsearch}` is not an overridable command.", embed: DocOverride.UsageEmbed);
return; return;
@ -425,15 +424,14 @@ namespace BirthdayBot.UserInterface
// Preparations complete. Run the command. // Preparations complete. Run the command.
await reqChannel.SendMessageAsync($"Executing `{cmdsearch.ToLower()}` on behalf of {overuser.Nickname ?? overuser.Username}:"); await reqChannel.SendMessageAsync($"Executing `{cmdsearch.ToLower()}` on behalf of {overuser.Nickname ?? overuser.Username}:");
await action.Invoke(overparam, reqChannel, overuser); await action.Invoke(overparam, gconf, reqChannel, overuser);
} }
// Publicly available command that immediately processes the current guild, // Publicly available command that immediately processes the current guild,
private async Task CmdTest(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) private async Task CmdTest(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
{ {
// Moderators only. As with config, silently drop if this check fails. // Moderators only. As with config, silently drop if this check fails.
if (!Instance.GuildCache[reqUser.Guild.Id].IsUserModerator(reqUser)) return; if (!gconf.IsBotModerator(reqUser)) return;
// TODO fix this or incorporate into final output - checking existence in guild cache is a step in the process
if (param.Length != 1) if (param.Length != 1)
{ {
@ -472,8 +470,7 @@ namespace BirthdayBot.UserInterface
if (rmatch.Success) input = rmatch.Groups["snowflake"].Value; if (rmatch.Success) input = rmatch.Groups["snowflake"].Value;
// Attempt to get role by ID, or null // Attempt to get role by ID, or null
ulong rid; if (ulong.TryParse(input, out ulong rid))
if (ulong.TryParse(input, out rid))
{ {
return guild.GetRole(rid); return guild.GetRole(rid);
} }

View file

@ -1,7 +1,7 @@
using Discord.WebSocket; using BirthdayBot.Data;
using Discord.WebSocket;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -111,7 +111,7 @@ namespace BirthdayBot.UserInterface
new CommandDocumentation(new string[] { "remove" }, "Removes your birthday information from this bot.", null); new CommandDocumentation(new string[] { "remove" }, "Removes your birthday information from this bot.", null);
#endregion #endregion
private async Task CmdSet(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) private async Task CmdSet(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
{ {
// Requires one parameter. Optionally two. // Requires one parameter. Optionally two.
if (param.Length < 2 || param.Length > 3) if (param.Length < 2 || param.Length > 3)
@ -140,9 +140,9 @@ namespace BirthdayBot.UserInterface
bool known; // Extra detail: Bot's response changes if the user was previously unknown. bool known; // Extra detail: Bot's response changes if the user was previously unknown.
try try
{ {
var user = Instance.GuildCache[reqChannel.Guild.Id].GetUser(reqUser.Id); var user = await GuildUserConfiguration.LoadAsync(gconf.GuildId, reqUser.Id);
known = user.IsKnown; known = user.IsKnown;
await user.UpdateAsync(bmonth, bday, btz, BotConfig.DatabaseSettings); await user.UpdateAsync(bmonth, bday, btz);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -161,7 +161,7 @@ namespace BirthdayBot.UserInterface
} }
} }
private async Task CmdZone(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) private async Task CmdZone(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
{ {
if (param.Length != 2) if (param.Length != 2)
{ {
@ -169,7 +169,7 @@ namespace BirthdayBot.UserInterface
return; return;
} }
var user = Instance.GuildCache[reqChannel.Guild.Id].GetUser(reqUser.Id); var user = await GuildUserConfiguration.LoadAsync(gconf.GuildId, reqUser.Id);
if (!user.IsKnown) if (!user.IsKnown)
{ {
await reqChannel.SendMessageAsync(":x: You may only update your time zone when you have a birthday registered." await reqChannel.SendMessageAsync(":x: You may only update your time zone when you have a birthday registered."
@ -187,12 +187,12 @@ namespace BirthdayBot.UserInterface
reqChannel.SendMessageAsync(ex.Message, embed: DocZone.UsageEmbed).Wait(); reqChannel.SendMessageAsync(ex.Message, embed: DocZone.UsageEmbed).Wait();
return; return;
} }
await user.UpdateAsync(user.BirthMonth, user.BirthDay, btz, BotConfig.DatabaseSettings); await user.UpdateAsync(user.BirthMonth, user.BirthDay, btz);
await reqChannel.SendMessageAsync($":white_check_mark: Your time zone has been updated to **{btz}**."); await reqChannel.SendMessageAsync($":white_check_mark: Your time zone has been updated to **{btz}**.");
} }
private async Task CmdRemove(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) private async Task CmdRemove(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
{ {
// Parameter count check // Parameter count check
if (param.Length != 1) if (param.Length != 1)
@ -203,10 +203,9 @@ namespace BirthdayBot.UserInterface
// Extra detail: Send a notification if the user isn't actually known by the bot. // Extra detail: Send a notification if the user isn't actually known by the bot.
bool known; bool known;
var g = Instance.GuildCache[reqChannel.Guild.Id]; var u = await GuildUserConfiguration.LoadAsync(gconf.GuildId, reqUser.Id);
known = g.GetUser(reqUser.Id).IsKnown; known = u.IsKnown;
// Delete database and cache entry await u.DeleteAsync();
await g.DeleteUserAsync(reqUser.Id);
if (!known) if (!known)
{ {
await reqChannel.SendMessageAsync(":white_check_mark: This bot already does not contain your information."); await reqChannel.SendMessageAsync(":white_check_mark: This bot already does not contain your information.");