Merge pull request #20 from NoiTheCat/dev/database-revamp

Implement Entity Framework for all database usage
This commit is contained in:
Noi 2022-03-23 11:03:01 -07:00 committed by GitHub
commit 8fb7ee91a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 936 additions and 351 deletions

View file

@ -41,8 +41,13 @@ public class BirthdayModule : BotModuleBase {
}
}
var user = await ((SocketGuildUser)Context.User).GetConfigAsync().ConfigureAwait(false);
await user.UpdateAsync(inmonth, inday, inzone ?? user.TimeZone).ConfigureAwait(false);
using var db = new BotDatabaseContext();
var user = ((SocketGuildUser)Context.User).GetUserEntryOrNew(db);
if (user.IsNew) db.UserEntries.Add(user);
user.BirthMonth = inmonth;
user.BirthDay = inday;
user.TimeZone = inzone;
await db.SaveChangesAsync();
await RespondAsync($":white_check_mark: Your birthday has been set to **{FormatDate(inmonth, inday)}**" +
(inzone == null ? "" : $", with time zone {inzone}") + ".").ConfigureAwait(false);
@ -50,31 +55,35 @@ public class BirthdayModule : BotModuleBase {
[SlashCommand("timezone", HelpCmdSetZone)]
public async Task CmdSetZone([Summary(description: HelpOptZone)] string zone) {
var user = await ((SocketGuildUser)Context.User).GetConfigAsync().ConfigureAwait(false);
if (!user.IsKnown) {
using var db = new BotDatabaseContext();
var user = ((SocketGuildUser)Context.User).GetUserEntryOrNew(db);
if (user.IsNew) {
await RespondAsync(":x: You do not have a birthday set.", ephemeral: true).ConfigureAwait(false);
return;
}
string inzone;
string newzone;
try {
inzone = ParseTimeZone(zone);
newzone = ParseTimeZone(zone);
} catch (FormatException e) {
await RespondAsync(e.Message, ephemeral: true).ConfigureAwait(false);
return;
}
await user.UpdateAsync(user.BirthMonth, user.BirthDay, inzone).ConfigureAwait(false);
await RespondAsync($":white_check_mark: Your time zone has been set to **{inzone}**.").ConfigureAwait(false);
user.TimeZone = newzone;
await db.SaveChangesAsync();
await RespondAsync($":white_check_mark: Your time zone has been set to **{newzone}**.").ConfigureAwait(false);
}
}
[SlashCommand("remove", HelpCmdRemove)]
public async Task CmdRemove() {
var user = await ((SocketGuildUser)Context.User).GetConfigAsync().ConfigureAwait(false);
if (user.IsKnown) {
await user.DeleteAsync().ConfigureAwait(false);
await RespondAsync(":white_check_mark: Your birthday in this server has been removed.")
.ConfigureAwait(false);
using var db = new BotDatabaseContext();
var user = ((SocketGuildUser)Context.User).GetUserEntryOrNew(db);
if (!user.IsNew) {
db.UserEntries.Remove(user);
await db.SaveChangesAsync();
await RespondAsync(":white_check_mark: Your birthday in this server has been removed.");
} else {
await RespondAsync(":white_check_mark: Your birthday is not registered.")
.ConfigureAwait(false);
@ -83,12 +92,15 @@ public class BirthdayModule : BotModuleBase {
[SlashCommand("get", "Gets a user's birthday.")]
public async Task CmdGetBday([Summary(description: "Optional: The user's birthday to look up.")] SocketGuildUser? user = null) {
var self = user is null;
if (self) user = (SocketGuildUser)Context.User;
var targetdata = await user!.GetConfigAsync().ConfigureAwait(false);
using var db = new BotDatabaseContext();
if (!targetdata.IsKnown) {
if (self) await RespondAsync(":x: You do not have your birthday registered.", ephemeral: true).ConfigureAwait(false);
var isSelf = user is null;
if (isSelf) user = (SocketGuildUser)Context.User;
var targetdata = user!.GetUserEntryOrNew(db);
if (targetdata.IsNew) {
if (isSelf) await RespondAsync(":x: You do not have your birthday registered.", ephemeral: true).ConfigureAwait(false);
else await RespondAsync(":x: The given user does not have their birthday registered.", ephemeral: true).ConfigureAwait(false);
return;
}
@ -111,7 +123,7 @@ public class BirthdayModule : BotModuleBase {
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);
var query = await GetSortedUsersAsync(Context.Guild).ConfigureAwait(false);
var query = GetSortedUserList(Context.Guild);
// TODO pagination instead of this workaround
bool hasOutputOneLine = false;
@ -182,7 +194,7 @@ public class BirthdayModule : BotModuleBase {
return;
}
var bdlist = await GetSortedUsersAsync(Context.Guild).ConfigureAwait(false);
var bdlist = GetSortedUserList(Context.Guild);
var filename = "birthdaybot-" + Context.Guild.Id;
Stream fileoutput;
@ -201,27 +213,26 @@ public class BirthdayModule : BotModuleBase {
/// Fetches all guild birthdays and places them into an easily usable structure.
/// Users currently not in the guild are not included in the result.
/// </summary>
private static async Task<List<ListItem>> GetSortedUsersAsync(SocketGuild guild) {
using var db = await Database.OpenConnectionAsync();
using var c = db.CreateCommand();
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()) {
var id = (ulong)r.GetInt64(0);
var month = r.GetInt32(1);
var day = r.GetInt32(2);
private static List<ListItem> GetSortedUserList(SocketGuild guild) {
using var db = new BotDatabaseContext();
var query = from row in db.UserEntries
where row.GuildId == (long)guild.Id
orderby row.BirthMonth, row.BirthDay
select new {
UserId = (ulong)row.UserId,
Month = row.BirthMonth,
Day = row.BirthDay
};
var guildUser = guild.GetUser(id);
var result = new List<ListItem>();
foreach (var row in query) {
var guildUser = guild.GetUser(row.UserId);
if (guildUser == null) continue; // Skip user not in guild
result.Add(new ListItem() {
BirthMonth = month,
BirthDay = day,
DateIndex = DateIndex(month, day),
BirthMonth = row.Month,
BirthDay = row.Day,
DateIndex = DateIndex(row.Month, row.Day),
UserId = guildUser.Id,
DisplayName = Common.FormatName(guildUser, false)
});

View file

@ -24,8 +24,12 @@ public class BirthdayOverrideModule : BotModuleBase {
return;
}
var user = await target.GetConfigAsync().ConfigureAwait(false);
await user.UpdateAsync(inmonth, inday, user.TimeZone).ConfigureAwait(false);
using var db = new BotDatabaseContext();
var user = target.GetUserEntryOrNew(db);
if (user.IsNew) db.UserEntries.Add(user);
user.BirthMonth = inmonth;
user.BirthDay = inday;
await db.SaveChangesAsync();
await RespondAsync($":white_check_mark: {Common.FormatName(target, false)}'s birthday has been set to " +
$"**{FormatDate(inmonth, inday)}**.").ConfigureAwait(false);
@ -34,30 +38,35 @@ public class BirthdayOverrideModule : BotModuleBase {
[SlashCommand("set-timezone", HelpPfxModOnly + "Set a user's time zone on their behalf.")]
public async Task OvSetTimezone([Summary(description: HelpOptOvTarget)]SocketGuildUser target,
[Summary(description: HelpOptZone)]string zone) {
var user = await target.GetConfigAsync().ConfigureAwait(false);
if (!user.IsKnown) {
using var db = new BotDatabaseContext();
var user = target.GetUserEntryOrNew(db);
if (user.IsNew) {
await RespondAsync($":x: {Common.FormatName(target, false)} does not have a birthday set.")
.ConfigureAwait(false);
return;
}
string inzone;
string newzone;
try {
inzone = ParseTimeZone(zone);
newzone = ParseTimeZone(zone);
} catch (FormatException e) {
await RespondAsync(e.Message, ephemeral: true).ConfigureAwait(false);
return;
}
await user.UpdateAsync(user.BirthMonth, user.BirthDay, inzone).ConfigureAwait(false);
user.TimeZone = newzone;
await db.SaveChangesAsync();
await RespondAsync($":white_check_mark: {Common.FormatName(target, false)}'s time zone has been set to " +
$"**{inzone}**.").ConfigureAwait(false);
$"**{newzone}**.").ConfigureAwait(false);
}
[SlashCommand("remove-birthday", HelpPfxModOnly + "Remove a user's birthday information on their behalf.")]
public async Task OvRemove([Summary(description: HelpOptOvTarget)]SocketGuildUser target) {
var user = await target.GetConfigAsync().ConfigureAwait(false);
if (user.IsKnown) {
await user.DeleteAsync().ConfigureAwait(false);
using var db = new BotDatabaseContext();
var user = ((SocketGuildUser)Context.User).GetUserEntryOrNew(db);
if (!user.IsNew) {
db.UserEntries.Remove(user);
await db.SaveChangesAsync();
await RespondAsync($":white_check_mark: {Common.FormatName(target, false)}'s birthday in this server has been removed.")
.ConfigureAwait(false);
} else {

View file

@ -57,16 +57,15 @@ public class ConfigModule : BotModuleBase {
[SlashCommand("set-channel", HelpPfxModOnly + HelpSubCmdChannel + HelpPofxBlankUnset)]
public async Task CmdSetChannel([Summary(description: HelpOptRole)] SocketTextChannel? channel = null) {
var gconf = await Context.Guild.GetConfigAsync().ConfigureAwait(false);
gconf.AnnounceChannelId = channel?.Id;
await gconf.UpdateAsync().ConfigureAwait(false);
await DoDatabaseUpdate(Context, s => s.ChannelAnnounceId = (long?)channel?.Id);
await RespondAsync(":white_check_mark: The announcement channel has been " +
(channel == null ? "unset." : $"set to **{channel.Name}**."));
}
[SlashCommand("set-message", HelpPfxModOnly + HelpSubCmdMessage)]
public async Task CmdSetMessage() {
var gconf = await Context.Guild.GetConfigAsync().ConfigureAwait(false);
using var db = new BotDatabaseContext();
var settings = Context.Guild.GetConfigOrNew(db);
var txtSingle = new TextInputBuilder() {
Label = "Single - Message for one birthday",
@ -75,7 +74,7 @@ public class ConfigModule : BotModuleBase {
MaxLength = 1500,
Required = false,
Placeholder = BackgroundServices.BirthdayRoleUpdate.DefaultAnnounce,
Value = gconf.AnnounceMessages.Item1 ?? ""
Value = settings.AnnounceMessage ?? ""
};
var txtMulti = new TextInputBuilder() {
Label = "Multi - Message for multiple birthdays",
@ -84,7 +83,7 @@ public class ConfigModule : BotModuleBase {
MaxLength = 1500,
Required = false,
Placeholder = BackgroundServices.BirthdayRoleUpdate.DefaultAnnouncePl,
Value = gconf.AnnounceMessages.Item2 ?? ""
Value = settings.AnnounceMessagePl ?? ""
};
var form = new ModalBuilder()
@ -104,18 +103,18 @@ public class ConfigModule : BotModuleBase {
if (string.IsNullOrWhiteSpace(newSingle)) newSingle = null;
if (string.IsNullOrWhiteSpace(newMulti)) newMulti = null;
var gconf = await channel.Guild.GetConfigAsync().ConfigureAwait(false);
gconf.AnnounceMessages = (newSingle, newMulti);
await gconf.UpdateAsync().ConfigureAwait(false);
using var db = new BotDatabaseContext();
var settings = channel.Guild.GetConfigOrNew(db);
if (settings.IsNew) db.GuildConfigurations.Add(settings);
settings.AnnounceMessage = newSingle;
settings.AnnounceMessagePl = newMulti;
await db.SaveChangesAsync();
await modal.RespondAsync(":white_check_mark: Announcement messages have been updated.");
}
[SlashCommand("set-ping", HelpPfxModOnly + HelpSubCmdPing)]
public async Task CmdSetPing([Summary(description: "Set True to ping users, False to display them normally.")]bool option) {
var gconf = await Context.Guild.GetConfigAsync().ConfigureAwait(false);
gconf.AnnouncePing = option;
await gconf.UpdateAsync().ConfigureAwait(false);
await DoDatabaseUpdate(Context, s => s.AnnouncePing = option);
await RespondAsync($":white_check_mark: Announcement pings are now **{(option ? "on" : "off")}**.").ConfigureAwait(false);
}
}
@ -124,17 +123,13 @@ public class ConfigModule : BotModuleBase {
public class SubCmdsConfigRole : BotModuleBase {
[SlashCommand("set-birthday-role", HelpPfxModOnly + "Set the role given to users having a birthday.")]
public async Task CmdSetBRole([Summary(description: HelpOptRole)] SocketRole role) {
var gconf = await Context.Guild.GetConfigAsync().ConfigureAwait(false);
gconf.RoleId = role.Id;
await gconf.UpdateAsync().ConfigureAwait(false);
await DoDatabaseUpdate(Context, s => s.RoleId = (long)role.Id);
await RespondAsync($":white_check_mark: The birthday role has been set to **{role.Name}**.").ConfigureAwait(false);
}
[SlashCommand("set-moderator-role", HelpPfxModOnly + "Designate a role whose members can configure the bot." + HelpPofxBlankUnset)]
public async Task CmdSetModRole([Summary(description: HelpOptRole)]SocketRole? role = null) {
var gconf = await Context.Guild.GetConfigAsync().ConfigureAwait(false);
gconf.ModeratorRole = role?.Id;
await gconf.UpdateAsync().ConfigureAwait(false);
await DoDatabaseUpdate(Context, s => s.ModeratorRole = (long?)role?.Id);
await RespondAsync(":white_check_mark: The moderator role has been " +
(role == null ? "unset." : $"set to **{role.Name}**."));
}
@ -149,26 +144,36 @@ public class ConfigModule : BotModuleBase {
public Task CmdDelBlock([Summary(description: "The user to unblock.")] SocketGuildUser user) => UpdateBlockAsync(user, false);
private async Task UpdateBlockAsync(SocketGuildUser user, bool setting) {
var gconf = await Context.Guild.GetConfigAsync().ConfigureAwait(false);
bool already = setting == await gconf.IsUserBlockedAsync(user.Id).ConfigureAwait(false);
// setting: true to add (set), false to remove (unset)
using var db = new BotDatabaseContext();
var existing = db.BlocklistEntries
.Where(bl => bl.GuildId == (long)user.Guild.Id && bl.UserId == (long)user.Id).FirstOrDefault();
bool already = (existing != null) == setting;
if (already) {
await RespondAsync($":white_check_mark: User is already {(setting ? "" : "not ")}blocked.").ConfigureAwait(false);
} else {
if (setting) await gconf.BlockUserAsync(user.Id).ConfigureAwait(false);
else await gconf.UnblockUserAsync(user.Id).ConfigureAwait(false);
await RespondAsync($":white_check_mark: {Common.FormatName(user, false)} has been {(setting ? "" : "un")}blocked.");
return;
}
if (setting) db.BlocklistEntries.Add(new BlocklistEntry() { GuildId = (long)user.Guild.Id, UserId = (long)user.Id });
else db.Remove(existing!);
await db.SaveChangesAsync();
await RespondAsync($":white_check_mark: {Common.FormatName(user, false)} has been {(setting ? "" : "un")}blocked.");
}
[SlashCommand("set-moderated", HelpPfxModOnly + "Set moderated mode on the server.")]
public async Task CmdAddBlock([Summary(name: "enable", description: "The moderated mode setting.")] bool setting) {
var gconf = await Context.Guild.GetConfigAsync().ConfigureAwait(false);
bool already = setting == gconf.IsModerated;
public async Task CmdSetModerated([Summary(name: "enable", description: "The moderated mode setting.")] bool setting) {
bool current = false;
await DoDatabaseUpdate(Context, s => {
current = s.Moderated;
s.Moderated = setting;
});
bool already = setting == current;
if (already) {
await RespondAsync($":white_check_mark: Moderated mode is already **{(setting ? "en" : "dis")}abled**.").ConfigureAwait(false);
await RespondAsync($":white_check_mark: Moderated mode is already **{(setting ? "en" : "dis")}abled**.");
} else {
gconf.IsModerated = setting;
await gconf.UpdateAsync().ConfigureAwait(false);
await RespondAsync($":white_check_mark: Moderated mode is now **{(setting ? "en" : "dis")}abled**.").ConfigureAwait(false);
}
}
@ -177,15 +182,19 @@ public class ConfigModule : BotModuleBase {
[SlashCommand("check", HelpPfxModOnly + HelpCmdCheck)]
public async Task CmdCheck() {
static string DoTestFor(string label, Func<bool> test) => $"{label}: { (test() ? ":white_check_mark: Yes" : ":x: No") }";
var result = new StringBuilder();
SocketTextChannel channel = (SocketTextChannel)Context.Channel;
var guild = Context.Guild;
var conf = await guild.GetConfigAsync().ConfigureAwait(false);
var usercfgs = await guild.GetUserConfigurationsAsync().ConfigureAwait(false);
using var db = new BotDatabaseContext();
var guildconf = guild.GetConfigOrNew(db);
await db.Entry(guildconf).Collection(t => t.UserEntries).LoadAsync();
var result = new StringBuilder();
result.AppendLine($"Server ID: `{guild.Id}` | Bot shard ID: `{Shard.ShardId:00}`");
result.AppendLine($"Number of registered birthdays: `{ usercfgs.Count() }`");
result.AppendLine($"Server time zone: `{ (conf?.TimeZone ?? "Not set - using UTC") }`");
result.AppendLine($"Number of registered birthdays: `{ guildconf.UserEntries.Count() }`");
result.AppendLine($"Server time zone: `{ (guildconf.TimeZone ?? "Not set - using UTC") }`");
result.AppendLine();
bool hasMembers = Common.HasMostMembersDownloaded(guild);
@ -194,7 +203,7 @@ public class ConfigModule : BotModuleBase {
int bdayCount = -1;
result.Append(DoTestFor("Birthday processing", delegate {
if (!hasMembers) return false;
bdayCount = BackgroundServices.BirthdayRoleUpdate.GetGuildCurrentBirthdays(usercfgs, conf?.TimeZone).Count;
bdayCount = BackgroundServices.BirthdayRoleUpdate.GetGuildCurrentBirthdays(guildconf.UserEntries, guildconf.TimeZone).Count;
return true;
}));
if (hasMembers) result.AppendLine($" - `{bdayCount}` user(s) currently having a birthday.");
@ -202,13 +211,13 @@ public class ConfigModule : BotModuleBase {
result.AppendLine();
result.AppendLine(DoTestFor("Birthday role set with `bb.config role`", delegate {
if (conf == null) return false;
SocketRole? role = guild.GetRole(conf.RoleId ?? 0);
if (guildconf.IsNew) return false;
SocketRole? role = guild.GetRole((ulong)(guildconf.RoleId ?? 0));
return role != null;
}));
result.AppendLine(DoTestFor("Birthday role can be managed by bot", delegate {
if (conf == null) return false;
SocketRole? role = guild.GetRole(conf.RoleId ?? 0);
if (guildconf.IsNew) return false;
SocketRole? role = guild.GetRole((ulong)(guildconf.RoleId ?? 0));
if (role == null) return false;
return guild.CurrentUser.GuildPermissions.ManageRoles && role.Position < guild.CurrentUser.Hierarchy;
}));
@ -216,8 +225,8 @@ public class ConfigModule : BotModuleBase {
SocketTextChannel? announcech = null;
result.AppendLine(DoTestFor("(Optional) Announcement channel set with `bb.config channel`", delegate {
if (conf == null) return false;
announcech = guild.GetTextChannel(conf.AnnounceChannelId ?? 0);
if (guildconf.IsNew) return false;
announcech = guild.GetTextChannel((ulong)(guildconf.ChannelAnnounceId ?? 0));
return announcech != null;
}));
string disp = announcech == null ? "announcement channel" : $"<#{announcech.Id}>";
@ -239,14 +248,14 @@ public class ConfigModule : BotModuleBase {
result.AppendLine($"> {line}");
return result.ToString();
}
if (conf != null && (conf.AnnounceMessages.Item1 != null || conf.AnnounceMessages.Item2 != null)) {
if (!guildconf.IsNew && (guildconf.AnnounceMessage != null || guildconf.AnnounceMessagePl != null)) {
var em = new EmbedBuilder().WithAuthor(new EmbedAuthorBuilder() { Name = "Custom announce messages:" });
var dispAnnounces = new StringBuilder("Custom announcement message(s):\n");
if (conf.AnnounceMessages.Item1 != null) {
em = em.AddField("Single", prepareAnnouncePreview(conf.AnnounceMessages.Item1));
if (guildconf.AnnounceMessage != null) {
em = em.AddField("Single", prepareAnnouncePreview(guildconf.AnnounceMessage));
}
if (conf.AnnounceMessages.Item2 != null) {
em = em.AddField("Multi", prepareAnnouncePreview(conf.AnnounceMessages.Item2));
if (guildconf.AnnounceMessagePl != null) {
em = em.AddField("Multi", prepareAnnouncePreview(guildconf.AnnounceMessagePl));
}
await ReplyAsync(embed: em.Build()).ConfigureAwait(false);
}
@ -255,11 +264,9 @@ public class ConfigModule : BotModuleBase {
[SlashCommand("set-timezone", HelpPfxModOnly + "Configure the time zone to use by default in the server." + HelpPofxBlankUnset)]
public async Task CmdSetTimezone([Summary(description: HelpOptZone)] string? zone = null) {
const string Response = ":white_check_mark: The server's time zone has been ";
var gconf = await Context.Guild.GetConfigAsync().ConfigureAwait(false);
if (zone == null) {
gconf.TimeZone = null;
await gconf.UpdateAsync().ConfigureAwait(false);
await DoDatabaseUpdate(Context, s => s.TimeZone = null);
await RespondAsync(Response + "unset.").ConfigureAwait(false);
} else {
string parsedZone;
@ -270,9 +277,22 @@ public class ConfigModule : BotModuleBase {
return;
}
gconf.TimeZone = parsedZone;
await gconf.UpdateAsync().ConfigureAwait(false);
await RespondAsync(Response + $"set to **{zone}**.").ConfigureAwait(false);
await DoDatabaseUpdate(Context, s => s.TimeZone = parsedZone);
await RespondAsync(Response + $"set to **{parsedZone}**.").ConfigureAwait(false);
}
}
/// <summary>
/// Helper method for updating arbitrary <see cref="GuildConfig"/> values without all the boilerplate.
/// </summary>
/// <param name="valueUpdater">A delegate which modifies <see cref="GuildConfig"/> properties as needed.</param>
private static async Task DoDatabaseUpdate(SocketInteractionContext context, Action<GuildConfig> valueUpdater) {
using var db = new BotDatabaseContext();
var settings = context.Guild.GetConfigOrNew(db);
valueUpdater(settings);
if (settings.IsNew) db.GuildConfigurations.Add(settings);
await db.SaveChangesAsync();
}
}

View file

@ -13,26 +13,31 @@ class EnforceBlockingAttribute : PreconditionAttribute {
public const string ReplyModerated = ":x: This bot is in moderated mode, preventing you from using any bot commands in this server.";
public const string ReplyBlocked = ":x: You have been blocked from using bot commands in this server.";
public override async Task<PreconditionResult> CheckRequirementsAsync(
public override Task<PreconditionResult> CheckRequirementsAsync(
IInteractionContext context, ICommandInfo commandInfo, IServiceProvider services) {
// Not in guild context, unaffected by blocking
if (context.Guild is not SocketGuild guild) return PreconditionResult.FromSuccess();
if (context.Guild is not SocketGuild guild) return Task.FromResult(PreconditionResult.FromSuccess());
// Manage Guild permission overrides any blocks
var user = (SocketGuildUser)context.User;
if (user.GuildPermissions.ManageGuild) return PreconditionResult.FromSuccess();
if (user.GuildPermissions.ManageGuild) return Task.FromResult(PreconditionResult.FromSuccess());
var gconf = await guild.GetConfigAsync().ConfigureAwait(false);
using var db = new BotDatabaseContext();
var settings = (from row in db.GuildConfigurations
where row.GuildId == (long)guild.Id
select new { ModRole = (ulong?)row.ModeratorRole, ModMode = row.Moderated }).FirstOrDefault();
if (settings != null) {
// Bot moderators override all blocking measures in place
if (user.Roles.Any(r => r.Id == settings.ModRole)) return Task.FromResult(PreconditionResult.FromSuccess());
// Bot moderators override any blocks
if (gconf.ModeratorRole.HasValue && user.Roles.Any(r => r.Id == gconf.ModeratorRole.Value)) return PreconditionResult.FromSuccess();
// Check for moderated mode
if (settings.ModMode) return Task.FromResult(PreconditionResult.FromError(FailModerated));
// Moderated mode check
if (gconf.IsModerated) return PreconditionResult.FromError(FailModerated);
// Check if user exists in blocklist
if (db.BlocklistEntries.Where(row => row.GuildId == (long)guild.Id && row.UserId == (long)user.Id).Any())
return Task.FromResult(PreconditionResult.FromError(FailBlocked));
}
// Block list check
if (await gconf.IsUserInBlocklistAsync(user.Id)) return PreconditionResult.FromError(FailBlocked);
return PreconditionResult.FromSuccess();
return Task.FromResult(PreconditionResult.FromSuccess());
}
}

View file

@ -13,17 +13,21 @@ class RequireBotModeratorAttribute : PreconditionAttribute {
public override string ErrorMessage => Error;
public override async Task<PreconditionResult> CheckRequirementsAsync(
public override Task<PreconditionResult> CheckRequirementsAsync(
IInteractionContext context, ICommandInfo commandInfo, IServiceProvider services) {
// A bot moderator can only exist in a guild context, so we must do this check.
// This check causes this precondition to become a functional equivalent to RequireGuildContextAttribute...
if (context.User is not SocketGuildUser user) return PreconditionResult.FromError(RequireGuildContextAttribute.Error);
if (context.User is not SocketGuildUser user)
return Task.FromResult(PreconditionResult.FromError(RequireGuildContextAttribute.Error));
if (user.GuildPermissions.ManageGuild) return PreconditionResult.FromSuccess();
var gconf = await ((SocketGuild)context.Guild).GetConfigAsync().ConfigureAwait(false);
if (gconf.ModeratorRole.HasValue && user.Roles.Any(r => r.Id == gconf.ModeratorRole.Value))
return PreconditionResult.FromSuccess();
if (user.GuildPermissions.ManageGuild) return Task.FromResult(PreconditionResult.FromSuccess());
using var db = new BotDatabaseContext();
var checkRole = (ulong?)db.GuildConfigurations
.Where(g => g.GuildId == (long)((SocketGuild)context.Guild).Id)
.Select(g => g.RoleId).FirstOrDefault();
if (checkRole.HasValue && user.Roles.Any(r => r.Id == checkRole.Value))
return Task.FromResult(PreconditionResult.FromSuccess());
return PreconditionResult.FromError(Error);
return Task.FromResult(PreconditionResult.FromError(Error));
}
}

View file

@ -9,29 +9,25 @@ class AutoUserDownload : BackgroundService {
public AutoUserDownload(ShardInstance instance) : base(instance) { }
public override async Task OnTick(int tickCount, CancellationToken token) {
foreach (var guild in ShardInstance.DiscordClient.Guilds) {
// Has the potential to disconnect while in the middle of processing.
if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) return;
using var db = new BotDatabaseContext();
// Determine if there is action to be taken...
if (!guild.HasAllMembers && await GuildUserAnyAsync(guild.Id).ConfigureAwait(false)) {
await guild.DownloadUsersAsync().ConfigureAwait(false); // This is already on a separate thread; no need to Task.Run
await Task.Delay(200, CancellationToken.None).ConfigureAwait(false); // Must delay, or else it seems to hang...
}
// Take action if a guild's cache is incomplete...
var incompleteCaches = ShardInstance.DiscordClient.Guilds.Where(g => !g.HasAllMembers).Select(g => (long)g.Id).ToHashSet();
// ...and if the guild contains any user data
var mustFetch = db.UserEntries.Where(e => incompleteCaches.Contains(e.GuildId)).Select(e => e.GuildId).Distinct();
int processed = 0;
foreach (var item in mustFetch) {
// May cause a disconnect in certain situations. Cancel all further attempts until the next pass if it happens.
if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) break;
var guild = ShardInstance.DiscordClient.GetGuild((ulong)item);
if (guild == null) continue; // A guild disappeared...?
await guild.DownloadUsersAsync().ConfigureAwait(false); // We're already on a seperate thread, no need to use Task.Run
await Task.Delay(200, CancellationToken.None).ConfigureAwait(false); // Must delay, or else it seems to hang...
processed++;
}
}
/// <summary>
/// Determines if the user database contains any entries corresponding to this guild.
/// </summary>
/// <returns>True if any entries exist.</returns>
private static async Task<bool> GuildUserAnyAsync(ulong guildId) {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
c.CommandText = $"select true from {GuildUserConfiguration.BackingTable} where guild_id = @Gid limit 1";
c.Parameters.Add("@Gid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = (long)guildId;
await c.PrepareAsync(CancellationToken.None).ConfigureAwait(false);
using var r = await c.ExecuteReaderAsync(CancellationToken.None).ConfigureAwait(false);
return r.Read();
if (processed > 100) Log($"Explicit user list request processed for {processed} guild(s).");
}
}

View file

@ -1,7 +1,4 @@
using System.Threading;
using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices;
namespace BirthdayBot.BackgroundServices;
abstract class BackgroundService {
protected ShardInstance ShardInstance { get; }

View file

@ -15,71 +15,61 @@ class BirthdayRoleUpdate : BackgroundService {
/// Processes birthday updates for all available guilds synchronously.
/// </summary>
public override async Task OnTick(int tickCount, CancellationToken token) {
var exs = new List<Exception>();
foreach (var guild in ShardInstance.DiscordClient.Guilds) {
if (ShardInstance.DiscordClient.ConnectionState != Discord.ConnectionState.Connected) {
Log("Client is not connected. Stopping early.");
return;
}
// For database efficiency, fetch all database information at once before proceeding
using var db = new BotDatabaseContext();
var shardGuilds = ShardInstance.DiscordClient.Guilds.Select(g => (long)g.Id).ToHashSet();
var presentGuildSettings = db.GuildConfigurations.Where(s => shardGuilds.Contains(s.GuildId));
var guildChecks = presentGuildSettings.ToList().Select(s => new Tuple<ulong, GuildConfig>((ulong)s.GuildId, s));
var exceptions = new List<Exception>();
foreach (var (guildId, settings) in guildChecks) {
var guild = ShardInstance.DiscordClient.GetGuild(guildId);
if (guild == null) continue; // A guild disappeared...?
// Check task cancellation here. Processing during a single guild is never interrupted.
if (token.IsCancellationRequested) throw new TaskCanceledException();
if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) {
Log("Client is not connected. Stopping early.");
return;
}
try {
await ProcessGuildAsync(guild).ConfigureAwait(false);
// Verify that role settings and permissions are usable
SocketRole? role = guild.GetRole((ulong)(settings.RoleId ?? 0));
if (role == null
|| !guild.CurrentUser.GuildPermissions.ManageRoles
|| role.Position >= guild.CurrentUser.Hierarchy) continue;
// Load up user configs and begin processing birthdays
await db.Entry(settings).Collection(t => t.UserEntries).LoadAsync(CancellationToken.None);
var birthdays = GetGuildCurrentBirthdays(settings.UserEntries, settings.TimeZone);
// Add or remove roles as appropriate
var announcementList = await UpdateGuildBirthdayRoles(guild, role, birthdays);
// Process birthday announcement
if (announcementList.Any()) {
await AnnounceBirthdaysAsync(settings, guild, announcementList);
}
} catch (Exception ex) {
// Catch all exceptions per-guild but continue processing, throw at end.
exs.Add(ex);
exceptions.Add(ex);
}
}
if (exs.Count != 0) throw new AggregateException(exs);
}
/// <summary>
/// Main method where actual guild processing occurs.
/// </summary>
private static async Task ProcessGuildAsync(SocketGuild guild) {
// Load guild information - stop if local cache is unavailable.
if (!Common.HasMostMembersDownloaded(guild)) return;
var gc = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false);
if (gc == null) return;
// Check if role settings are correct before continuing with further processing
SocketRole? role = guild.GetRole(gc.RoleId ?? 0);
if (role == null || !guild.CurrentUser.GuildPermissions.ManageRoles || role.Position >= guild.CurrentUser.Hierarchy) return;
// Determine who's currently having a birthday
var users = await GuildUserConfiguration.LoadAllAsync(guild.Id).ConfigureAwait(false);
var tz = gc.TimeZone;
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.
IEnumerable<SocketGuildUser> announcementList;
// Update roles as appropriate
try {
var updateResult = await UpdateGuildBirthdayRoles(guild, role, birthdays).ConfigureAwait(false);
announcementList = updateResult.Item1;
} catch (Discord.Net.HttpException) {
return;
}
// Birthday announcement
var announce = gc.AnnounceMessages;
var announceping = gc.AnnouncePing;
SocketTextChannel? channel = null;
if (gc.AnnounceChannelId.HasValue) channel = guild.GetTextChannel(gc.AnnounceChannelId.Value);
if (announcementList.Any()) {
await AnnounceBirthdaysAsync(announce, announceping, channel, announcementList).ConfigureAwait(false);
}
if (exceptions.Count > 1) throw new AggregateException("Unhandled exceptions occurred when processing birthdays.", exceptions);
else if (exceptions.Count == 1) throw new Exception("An unhandled exception occurred when processing a birthday.", exceptions[0]);
}
/// <summary>
/// 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.
/// </summary>
[Obsolete(Database.ObsoleteReason)]
public static HashSet<ulong> GetGuildCurrentBirthdays(IEnumerable<GuildUserConfiguration> guildUsers, string? defaultTzStr) {
var tzdb = DateTimeZoneProviders.Tzdb;
DateTimeZone defaultTz = (defaultTzStr != null ? DateTimeZoneProviders.Tzdb.GetZoneOrNull(defaultTzStr) : null) ?? tzdb.GetZoneOrNull("UTC")!;
DateTimeZone defaultTz = (defaultTzStr != null ? DateTimeZoneProviders.Tzdb.GetZoneOrNull(defaultTzStr) : null)
?? tzdb.GetZoneOrNull("UTC")!;
var birthdayUsers = new HashSet<ulong>();
foreach (var item in guildUsers) {
@ -101,39 +91,64 @@ class BirthdayRoleUpdate : BackgroundService {
}
return birthdayUsers;
}
/// <summary>
/// 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.
/// </summary>
public static HashSet<ulong> GetGuildCurrentBirthdays(IEnumerable<UserEntry> guildUsers, string? ServerDefaultTzId) {
var birthdayUsers = new HashSet<ulong>();
foreach (var record in guildUsers) {
// Determine final time zone to use for calculation
DateTimeZone tz = DateTimeZoneProviders.Tzdb
.GetZoneOrNull(record.TimeZone ?? ServerDefaultTzId ?? "UTC")!;
var checkNow = SystemClock.Instance.GetCurrentInstant().InZone(tz);
// Special case: If user's birthday is 29-Feb and it's currently not a leap year, check against 1-Mar
if (!DateTime.IsLeapYear(checkNow.Year) && record.BirthMonth == 2 && record.BirthDay == 29) {
if (checkNow.Month == 3 && checkNow.Day == 1) birthdayUsers.Add((ulong)record.UserId);
} else if (record.BirthMonth == checkNow.Month && record.BirthDay== checkNow.Day) {
birthdayUsers.Add((ulong)record.UserId);
}
}
return birthdayUsers;
}
/// <summary>
/// Sets the birthday role to all applicable users. Unsets it from all others who may have it.
/// </summary>
/// <returns>
/// First item: List of users who had the birthday role applied, used to announce.
/// Second item: Counts of users who have had roles added/removed, used for operation reporting.
/// List of users who had the birthday role applied, used to announce.
/// </returns>
private static async Task<(IEnumerable<SocketGuildUser>, (int, int))> UpdateGuildBirthdayRoles(
SocketGuild g, SocketRole r, HashSet<ulong> names) {
// Check members currently with the role. Figure out which users to remove it from.
var roleRemoves = new List<SocketGuildUser>();
var roleKeeps = new HashSet<ulong>();
foreach (var member in r.Members) {
if (!names.Contains(member.Id)) roleRemoves.Add(member);
else roleKeeps.Add(member.Id);
}
private static async Task<IEnumerable<SocketGuildUser>> UpdateGuildBirthdayRoles(SocketGuild g, SocketRole r, HashSet<ulong> toApply) {
var additions = new List<SocketGuildUser>();
try {
var removals = new List<SocketGuildUser>(); // TODO check if roles can be removed in-place instead of building a list first
var no_ops = new HashSet<ulong>();
foreach (var user in roleRemoves) {
await user.RemoveRoleAsync(r).ConfigureAwait(false);
}
// Scan role for members no longer needing it
foreach (var user in r.Members) {
if (!toApply.Contains(user.Id)) removals.Add(user);
else no_ops.Add(user.Id);
}
foreach (var user in removals) {
// TODO this gets hit with rate limits sometimes. figure something out.
await user.RemoveRoleAsync(r);
}
// Apply role to members not already having it. Prepare announcement list.
var newBirthdays = new List<SocketGuildUser>();
foreach (var target in names) {
var member = g.GetUser(target);
if (member == null) continue;
if (roleKeeps.Contains(member.Id)) continue; // already has role - do nothing
await member.AddRoleAsync(r).ConfigureAwait(false);
newBirthdays.Add(member);
foreach (var target in toApply) {
if (no_ops.Contains(target)) continue;
var user = g.GetUser(target);
if (user == null) continue; // User existing in database but not in guild
await user.AddRoleAsync(r);
additions.Add(user);
}
} catch (Discord.Net.HttpException ex)
when (ex.DiscordCode is DiscordErrorCode.MissingPermissions or DiscordErrorCode.InsufficientPermissions) {
// Encountered access and/or permission issues despite earlier checks. Quit the loop here.
}
return (newBirthdays, (newBirthdays.Count, roleRemoves.Count));
return additions;
}
public const string DefaultAnnounce = "Please wish a happy birthday to %n!";
@ -142,21 +157,21 @@ class BirthdayRoleUpdate : BackgroundService {
/// <summary>
/// Attempts to send an announcement message.
/// </summary>
private static async Task AnnounceBirthdaysAsync(
(string?, string?) announce, bool announcePing, SocketTextChannel? c, IEnumerable<SocketGuildUser> names) {
internal static async Task AnnounceBirthdaysAsync(GuildConfig settings, SocketGuild g, IEnumerable<SocketGuildUser> names) {
var c = g.GetTextChannel((ulong)(settings.ChannelAnnounceId ?? 0));
if (c == null) return;
if (!c.Guild.CurrentUser.GetPermissions(c).SendMessages) return;
string announceMsg;
if (names.Count() == 1) announceMsg = announce.Item1 ?? announce.Item2 ?? DefaultAnnounce;
else announceMsg = announce.Item2 ?? announce.Item1 ?? DefaultAnnouncePl;
if (names.Count() == 1) announceMsg = settings.AnnounceMessage ?? settings.AnnounceMessagePl ?? DefaultAnnounce;
else announceMsg = settings.AnnounceMessagePl ?? settings.AnnounceMessage ?? DefaultAnnouncePl;
announceMsg = announceMsg.TrimEnd();
if (!announceMsg.Contains("%n")) announceMsg += " %n";
// Build sorted name list
var namestrings = new List<string>();
foreach (var item in names)
namestrings.Add(Common.FormatName(item, announcePing));
namestrings.Add(Common.FormatName(item, settings.AnnouncePing));
namestrings.Sort(StringComparer.OrdinalIgnoreCase);
var namedisplay = new StringBuilder();

View file

@ -1,5 +1,4 @@
using BirthdayBot.Data;
using NpgsqlTypes;
using System.Text;
namespace BirthdayBot.BackgroundServices;
@ -20,96 +19,49 @@ class DataRetention : BackgroundService {
// On each tick, run only a set group of guilds, each group still processed every ProcessInterval ticks.
if ((tickCount + ShardInstance.ShardId) % ProcessInterval != 0) return;
try {
// A semaphore is used to restrict this work being done concurrently on other shards
// to avoid putting pressure on the SQL connection pool. Clearing old database information
// ultimately is a low priority among other tasks.
await _updateLock.WaitAsync(token).ConfigureAwait(false);
} catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) {
// Caller does not expect the exception that SemaphoreSlim throws...
throw new TaskCanceledException();
using var db = new BotDatabaseContext();
var now = DateTimeOffset.UtcNow;
int updatedGuilds = 0, updatedUsers = 0;
foreach (var guild in ShardInstance.DiscordClient.Guilds) {
// Update guild, fetch users from database
var dbGuild = db.GuildConfigurations.Where(s => s.GuildId == (long)guild.Id).FirstOrDefault();
if (dbGuild == null) continue;
dbGuild.LastSeen = now;
updatedGuilds++;
// Update users
var localIds = guild.Users.Select(u => (long)u.Id);
var dbSavedIds = db.UserEntries.Where(e => e.GuildId == (long)guild.Id).Select(e => e.UserId);
var usersToUpdate = localIds.Intersect(dbSavedIds).ToHashSet();
foreach (var user in db.UserEntries.Where(e => e.GuildId == (long)guild.Id && usersToUpdate.Contains(e.UserId))) {
user.LastSeen = now;
updatedUsers++;
}
}
try {
// Build a list of all values across all guilds to update
var updateList = new Dictionary<ulong, List<ulong>>();
foreach (var g in ShardInstance.DiscordClient.Guilds) {
// Get list of IDs for all users who exist in the database and currently exist in the guild
var userList = GuildUserConfiguration.LoadAllAsync(g.Id);
var guildUserIds = from gu in g.Users select gu.Id;
var savedUserIds = from cu in await userList.ConfigureAwait(false) select cu.UserId;
var existingCachedIds = savedUserIds.Intersect(guildUserIds);
updateList[g.Id] = existingCachedIds.ToList();
// And let go of old data
var staleGuilds = db.GuildConfigurations.Where(s => now - TimeSpan.FromDays(StaleGuildThreshold) > s.LastSeen);
var staleUsers = db.UserEntries.Where(e => now - TimeSpan.FromDays(StaleUserThreashold) > e.LastSeen);
int staleGuildCount = staleGuilds.Count(), staleUserCount = staleUsers.Count();
db.GuildConfigurations.RemoveRange(staleGuilds);
db.UserEntries.RemoveRange(staleUsers);
await db.SaveChangesAsync(CancellationToken.None);
var resultText = new StringBuilder();
resultText.Append($"Updated {updatedGuilds} guilds, {updatedUsers} users.");
if (staleGuildCount != 0 || staleUserCount != 0) {
resultText.Append(" Discarded ");
if (staleGuildCount != 0) {
resultText.Append($"{staleGuildCount} guilds");
if (staleUserCount != 0) resultText.Append(", ");
}
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
// 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;
using (var tUpdate = db.BeginTransaction()) {
foreach (var item in updateList) {
var guild = item.Key;
var userlist = item.Value;
pUpdateG.Value = (long)guild;
updatedGuilds += await cUpdateGuild.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
pUpdateGU_g.Value = (long)guild;
foreach (var userid in userlist) {
pUpdateGU_u.Value = (long)userid;
updatedUsers += await cUpdateGuildUser.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
}
}
await tUpdate.CommitAsync(CancellationToken.None).ConfigureAwait(false);
if (staleUserCount != 0) {
resultText.Append($"{staleUserCount} users");
}
var resultText = new StringBuilder();
resultText.Append($"Updated {updatedGuilds} guilds, {updatedUsers} users.");
// Deletes both guild and user data if it hasn't been seen for over the threshold defined at the top of this file
// Expects referencing tables to have 'on delete cascade'
int staleGuilds, staleUsers;
using (var tRemove = db.BeginTransaction()) {
using (var c = db.CreateCommand()) {
c.CommandText = $"delete from {GuildConfiguration.BackingTable}" +
$" where (now() - interval '{StaleGuildThreshold} days') > last_seen";
staleGuilds = await c.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
}
using (var c = db.CreateCommand()) {
c.CommandText = $"delete from {GuildUserConfiguration.BackingTable}" +
$" where (now() - interval '{StaleUserThreashold} days') > last_seen";
staleUsers = await c.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
}
await tRemove.CommitAsync(CancellationToken.None).ConfigureAwait(false);
}
if (staleGuilds != 0 || staleUsers != 0) {
resultText.Append(" Discarded ");
if (staleGuilds != 0) {
resultText.Append($"{staleGuilds} guilds");
if (staleUsers != 0) resultText.Append(", ");
}
if (staleUsers != 0) {
resultText.Append($"{staleUsers} users");
}
resultText.Append('.');
}
Log(resultText.ToString());
} finally {
_updateLock.Release();
resultText.Append('.');
}
Log(resultText.ToString());
}
}

View file

@ -1,8 +1,4 @@
using System;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Text;
namespace BirthdayBot.BackgroundServices;
@ -11,7 +7,7 @@ namespace BirthdayBot.BackgroundServices;
/// </summary>
class ExternalStatisticsReporting : BackgroundService {
const int ProcessInterval = 1200 / ShardBackgroundWorker.Interval; // Process every ~20 minutes
const int ProcessOffset = 300 / ShardBackgroundWorker.Interval; // Begin processing 5 minutes after shard start
const int ProcessOffset = 300 / ShardBackgroundWorker.Interval; // Begin processing ~5 minutes after shard start
private static readonly HttpClient _httpClient = new();

View file

@ -64,7 +64,7 @@ class ShardBackgroundWorker : IDisposable {
_tickCount++;
await service.OnTick(_tickCount, _workerCanceller.Token).ConfigureAwait(false);
} catch (Exception ex) when (ex is not TaskCanceledException) {
Instance.Log(nameof(WorkerLoop), $"{CurrentExecutingService} encountered an exception:\n" + ex.ToString());
Instance.Log(CurrentExecutingService, ex.ToString());
}
}
CurrentExecutingService = null;

View file

@ -23,10 +23,17 @@
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.8.0" />
<PackageReference Include="Discord.Net" Version="3.4.1" />
<PackageReference Include="EFCore.NamingConventions" Version="6.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="NodaTime" Version="3.0.9" />
<PackageReference Include="Npgsql" Version="6.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
</ItemGroup>
</Project>

View file

@ -26,6 +26,8 @@ class Configuration {
public int ShardStart { get; }
public int ShardAmount { get; }
public int ShardTotal { get; }
public string DatabaseConnectionString { get; }
public Configuration(string[] args) {
var cmdline = CmdLineOpts.Parse(args);
@ -74,7 +76,7 @@ class Configuration {
};
var sqldb = ReadConfKey<string>(jc, KeySqlDatabase, false);
if (sqldb != null) csb.Database = sqldb; // Optional database setting
Database.DBConnectionString = csb.ToString();
DatabaseConnectionString = csb.ToString();
}
private static T? ReadConfKey<T>(JObject jc, string key, [DoesNotReturnIf(true)] bool failOnEmpty) {

18
Data/BlocklistEntry.cs Normal file
View file

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace BirthdayBot.Data;
[Table("banned_users")]
public class BlocklistEntry {
[Key]
[Column("guild_id")]
public long GuildId { get; set; }
[Key]
[Column("user_id")]
public long UserId { get; set; }
[ForeignKey(nameof(GuildConfig.GuildId))]
[InverseProperty(nameof(GuildConfig.BlockedUsers))]
public GuildConfig Guild { get; set; } = null!;
}

View file

@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore;
namespace BirthdayBot.Data;
public class BotDatabaseContext : DbContext {
private static string? _npgsqlConnectionString;
internal static string NpgsqlConnectionString {
#if DEBUG
get {
if (_npgsqlConnectionString != null) return _npgsqlConnectionString;
Program.Log(nameof(BotDatabaseContext), "Using hardcoded connection string!");
return _npgsqlConnectionString ?? "Host=localhost;Username=birthdaybot;Password=bb";
}
#else
get => _npgsqlConnectionString!;
#endif
set => _npgsqlConnectionString ??= value;
}
public DbSet<BlocklistEntry> BlocklistEntries { get; set; } = null!;
public DbSet<GuildConfig> GuildConfigurations { get; set; } = null!;
public DbSet<UserEntry> UserEntries { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.UseNpgsql(NpgsqlConnectionString)
#if DEBUG
.LogTo((string line) => Program.Log("EF", line), Microsoft.Extensions.Logging.LogLevel.Information)
#endif
.UseSnakeCaseNamingConvention();
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.Entity<BlocklistEntry>(entity => {
entity.HasKey(e => new { e.GuildId, e.UserId })
.HasName("banned_users_pkey");
entity.HasOne(d => d.Guild)
.WithMany(p => p.BlockedUsers)
.HasForeignKey(d => d.GuildId)
.HasConstraintName("banned_users_guild_id_fkey")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<GuildConfig>(entity => {
entity.HasKey(e => e.GuildId)
.HasName("settings_pkey");
entity.Property(e => e.GuildId).ValueGeneratedNever();
entity.Property(e => e.LastSeen).HasDefaultValueSql("now()");
});
modelBuilder.Entity<UserEntry>(entity => {
entity.HasKey(e => new { e.GuildId, e.UserId })
.HasName("user_birthdays_pkey");
entity.Property(e => e.LastSeen).HasDefaultValueSql("now()");
entity.HasOne(d => d.Guild)
.WithMany(p => p.UserEntries)
.HasForeignKey(d => d.GuildId)
.HasConstraintName("user_birthdays_guild_id_fkey")
.OnDelete(DeleteBehavior.Cascade);
});
}
}

View file

@ -1,10 +1,12 @@
using Npgsql;
using System.Threading.Tasks;
namespace BirthdayBot.Data;
[Obsolete(ObsoleteReason, error: false)]
internal static class Database {
public static string DBConnectionString { get; set; }
public const string ObsoleteReason = "Will be removed in favor of EF6 stuff when text commands are removed";
public static string DBConnectionString { get; set; } = null!;
/// <summary>
/// Sets up and opens a database connection.

View file

@ -2,20 +2,18 @@
internal static class Extensions {
/// <summary>
/// Retrieves the database-backed bot configuration for this guild.
/// Gets the corresponding <see cref="GuildConfig"/> for this guild, or a new one if one does not exist.
/// If it doesn't exist in the database, <see cref="GuildConfig.IsNew"/> returns true.
/// </summary>
internal static async Task<GuildConfiguration> GetConfigAsync(this SocketGuild guild)
=> await GuildConfiguration.LoadAsync(guild.Id, false);
public static GuildConfig GetConfigOrNew(this SocketGuild guild, BotDatabaseContext db)
=> db.GuildConfigurations.Where(g => g.GuildId == (long)guild.Id).FirstOrDefault()
?? new GuildConfig() { IsNew = true, GuildId = (long)guild.Id };
/// <summary>
/// Retrieves a collection of all existing user configurations for this guild.
/// Gets the corresponding <see cref="UserEntry"/> for this user in this guild, or a new one if one does not exist.
/// If it doesn't exist in the database, <see cref="UserEntry.IsNew"/> returns true.
/// </summary>
internal static async Task<IEnumerable<GuildUserConfiguration>> GetUserConfigurationsAsync(this SocketGuild guild)
=> await GuildUserConfiguration.LoadAllAsync(guild.Id);
/// <summary>
/// Retrieves the database-backed bot configuration (birthday info) for this guild user.
/// </summary>
internal static async Task<GuildUserConfiguration> GetConfigAsync(this SocketGuildUser user)
=> await GuildUserConfiguration.LoadAsync(user.Guild.Id, user.Id);
}
public static UserEntry GetUserEntryOrNew(this SocketGuildUser user, BotDatabaseContext db)
=> db.UserEntries.Where(u => u.GuildId == (long)user.Guild.Id && u.UserId == (long)user.Id).FirstOrDefault()
?? new UserEntry() { IsNew = true, GuildId = (long)user.Guild.Id, UserId = (long)user.Id };
}

45
Data/GuildConfig.cs Normal file
View file

@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace BirthdayBot.Data;
[Table("settings")]
public class GuildConfig {
public GuildConfig() {
BlockedUsers = new HashSet<BlocklistEntry>();
UserEntries = new HashSet<UserEntry>();
}
[Key]
[Column("guild_id")]
public long GuildId { get; set; }
[Column("role_id")]
public long? RoleId { get; set; }
[Column("channel_announce_id")]
public long? ChannelAnnounceId { get; set; }
[Column("time_zone")]
public string? TimeZone { get; set; }
[Column("moderated")]
public bool Moderated { get; set; }
[Column("moderator_role")]
public long? ModeratorRole { get; set; }
[Column("announce_message")]
public string? AnnounceMessage { get; set; }
[Column("announce_message_pl")]
public string? AnnounceMessagePl { get; set; }
[Column("announce_ping")]
public bool AnnouncePing { get; set; }
[Column("last_seen")]
public DateTimeOffset LastSeen { get; set; }
[InverseProperty(nameof(BlocklistEntry.Guild))]
public ICollection<BlocklistEntry> BlockedUsers { get; set; }
[InverseProperty(nameof(UserEntry.Guild))]
public ICollection<UserEntry> UserEntries { get; set; }
/// <summary>
/// Gets if this instance is new and does not (yet) exist in the database.
/// </summary>
[NotMapped]
public bool IsNew { get; set; }
}

View file

@ -8,6 +8,7 @@ namespace BirthdayBot.Data;
/// 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>
[Obsolete(Database.ObsoleteReason, error: false)]
class GuildConfiguration {
/// <summary>
/// Gets this configuration's corresponding guild ID.
@ -73,7 +74,6 @@ class GuildConfiguration {
/// <summary>
/// Checks if the specified user is blocked by current guild policy (block list or moderated mode).
/// </summary>
[Obsolete("Block lists should be reimplemented in a more resource-efficient manner later.", false)]
public async Task<bool> IsUserBlockedAsync(ulong userId) {
if (IsModerated) return true;
return await IsUserInBlocklistAsync(userId).ConfigureAwait(false);
@ -82,7 +82,6 @@ class GuildConfiguration {
/// <summary>
/// Checks if the given user exists in the block list.
/// </summary>
[Obsolete("Block lists should be reimplemented in a more resource-efficient manner later.", false)]
public async Task<bool> IsUserInBlocklistAsync(ulong userId) {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
@ -99,7 +98,6 @@ class GuildConfiguration {
/// <summary>
/// Adds the specified user to the block list corresponding to this guild.
/// </summary>
[Obsolete("Block lists will be reimplemented in a more practical manner later.", false)]
public async Task BlockUserAsync(ulong userId) {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
@ -117,7 +115,6 @@ class GuildConfiguration {
/// 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>
[Obsolete("Block lists will be reimplemented in a more practical manner later.", false)]
public async Task<bool> UnblockUserAsync(ulong userId) {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
@ -134,7 +131,6 @@ class GuildConfiguration {
/// 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>
[Obsolete("Usage should be phased out when text commands are removed. Use PreconditionAttribute from now on.", error: false)]
public bool IsBotModerator(SocketGuildUser user)
=> user.GuildPermissions.ManageGuild || (ModeratorRole.HasValue && user.Roles.Any(r => r.Id == ModeratorRole.Value));
@ -142,6 +138,7 @@ class GuildConfiguration {
public const string BackingTable = "settings";
public const string BackingTableBans = "banned_users";
[Obsolete("DELETE THIS", error: true)]
internal static async Task DatabaseSetupAsync(NpgsqlConnection db) {
using (var c = db.CreateCommand()) {
c.CommandText = $"create table if not exists {BackingTable} ("
@ -175,7 +172,6 @@ class GuildConfiguration {
/// If true, this method shall not create a new entry and will return null if the guild does
/// not exist in the database.
/// </param>
[Obsolete("Begin using extension method to retrieve necessary data instead.", false)]
public static async Task<GuildConfiguration?> LoadAsync(ulong guildId, bool nullIfUnknown) {
// TODO nullable static analysis problem: how to indicate non-null return when nullIfUnknown parameter is true?
using (var db = await Database.OpenConnectionAsync().ConfigureAwait(false)) {

View file

@ -7,6 +7,7 @@ namespace BirthdayBot.Data;
/// <summary>
/// Represents configuration for a guild user as may exist in the database.
/// </summary>
[Obsolete(Database.ObsoleteReason, error: false)]
class GuildUserConfiguration {
public ulong GuildId { get; }
public ulong UserId { get; }
@ -106,7 +107,6 @@ class GuildUserConfiguration {
/// <summary>
/// Attempts to retrieve a user's configuration. Returns a new, updateable instance if none is found.
/// </summary>
[Obsolete("Migrate to using extension methods to retrieve necessary data instead.", false)]
public static async Task<GuildUserConfiguration> LoadAsync(ulong guildId, ulong userId) {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
@ -123,7 +123,6 @@ class GuildUserConfiguration {
/// <summary>
/// Gets all known user configuration records associated with the specified guild.
/// </summary>
[Obsolete("Migrate to using extension methods to retrieve necessary data instead.", false)]
public static async Task<IEnumerable<GuildUserConfiguration>> LoadAllAsync(ulong guildId) {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();

View file

@ -0,0 +1,160 @@
// <auto-generated />
using System;
using BirthdayBot.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BirthdayBot.Data.Migrations
{
[DbContext(typeof(BotDatabaseContext))]
[Migration("20220319195852_InitialEFSetup")]
partial class InitialEFSetup
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("BirthdayBot.Data.BlocklistEntry", b =>
{
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("GuildId", "UserId")
.HasName("banned_users_pkey");
b.ToTable("banned_users", (string)null);
});
modelBuilder.Entity("BirthdayBot.Data.GuildConfig", b =>
{
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<string>("AnnounceMessage")
.HasColumnType("text")
.HasColumnName("announce_message");
b.Property<string>("AnnounceMessagePl")
.HasColumnType("text")
.HasColumnName("announce_message_pl");
b.Property<bool>("AnnouncePing")
.HasColumnType("boolean")
.HasColumnName("announce_ping");
b.Property<long?>("ChannelAnnounceId")
.HasColumnType("bigint")
.HasColumnName("channel_announce_id");
b.Property<DateTimeOffset>("LastSeen")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen")
.HasDefaultValueSql("now()");
b.Property<bool>("Moderated")
.HasColumnType("boolean")
.HasColumnName("moderated");
b.Property<long?>("ModeratorRole")
.HasColumnType("bigint")
.HasColumnName("moderator_role");
b.Property<long?>("RoleId")
.HasColumnType("bigint")
.HasColumnName("role_id");
b.Property<string>("TimeZone")
.HasColumnType("text")
.HasColumnName("time_zone");
b.HasKey("GuildId")
.HasName("settings_pkey");
b.ToTable("settings", (string)null);
});
modelBuilder.Entity("BirthdayBot.Data.UserEntry", b =>
{
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.Property<int>("BirthDay")
.HasColumnType("integer")
.HasColumnName("birth_day");
b.Property<int>("BirthMonth")
.HasColumnType("integer")
.HasColumnName("birth_month");
b.Property<DateTimeOffset>("LastSeen")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen")
.HasDefaultValueSql("now()");
b.Property<string>("TimeZone")
.HasColumnType("text")
.HasColumnName("time_zone");
b.HasKey("GuildId", "UserId")
.HasName("user_birthdays_pkey");
b.ToTable("user_birthdays", (string)null);
});
modelBuilder.Entity("BirthdayBot.Data.BlocklistEntry", b =>
{
b.HasOne("BirthdayBot.Data.GuildConfig", "Guild")
.WithMany("BlockedUsers")
.HasForeignKey("GuildId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("banned_users_guild_id_fkey");
b.Navigation("Guild");
});
modelBuilder.Entity("BirthdayBot.Data.UserEntry", b =>
{
b.HasOne("BirthdayBot.Data.GuildConfig", "Guild")
.WithMany("UserEntries")
.HasForeignKey("GuildId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("user_birthdays_guild_id_fkey");
b.Navigation("Guild");
});
modelBuilder.Entity("BirthdayBot.Data.GuildConfig", b =>
{
b.Navigation("BlockedUsers");
b.Navigation("UserEntries");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,89 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
// command used:
// dotnet ef migrations add InitialEFSetup --output-dir Data/Migrations
// (don't forget to replace with a proper migration name)
#nullable disable
namespace BirthdayBot.Data.Migrations
{
public partial class InitialEFSetup : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "settings",
columns: table => new
{
guild_id = table.Column<long>(type: "bigint", nullable: false),
role_id = table.Column<long>(type: "bigint", nullable: true),
channel_announce_id = table.Column<long>(type: "bigint", nullable: true),
time_zone = table.Column<string>(type: "text", nullable: true),
moderated = table.Column<bool>(type: "boolean", nullable: false),
moderator_role = table.Column<long>(type: "bigint", nullable: true),
announce_message = table.Column<string>(type: "text", nullable: true),
announce_message_pl = table.Column<string>(type: "text", nullable: true),
announce_ping = table.Column<bool>(type: "boolean", nullable: false),
last_seen = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("settings_pkey", x => x.guild_id);
});
migrationBuilder.CreateTable(
name: "banned_users",
columns: table => new
{
guild_id = table.Column<long>(type: "bigint", nullable: false),
user_id = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("banned_users_pkey", x => new { x.guild_id, x.user_id });
table.ForeignKey(
name: "banned_users_guild_id_fkey",
column: x => x.guild_id,
principalTable: "settings",
principalColumn: "guild_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_birthdays",
columns: table => new
{
guild_id = table.Column<long>(type: "bigint", nullable: false),
user_id = table.Column<long>(type: "bigint", nullable: false),
birth_month = table.Column<int>(type: "integer", nullable: false),
birth_day = table.Column<int>(type: "integer", nullable: false),
time_zone = table.Column<string>(type: "text", nullable: true),
last_seen = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("user_birthdays_pkey", x => new { x.guild_id, x.user_id });
table.ForeignKey(
name: "user_birthdays_guild_id_fkey",
column: x => x.guild_id,
principalTable: "settings",
principalColumn: "guild_id",
onDelete: ReferentialAction.Cascade);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "banned_users");
migrationBuilder.DropTable(
name: "user_birthdays");
migrationBuilder.DropTable(
name: "settings");
}
}
}

View file

@ -0,0 +1,158 @@
// <auto-generated />
using System;
using BirthdayBot.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BirthdayBot.Data.Migrations
{
[DbContext(typeof(BotDatabaseContext))]
partial class BotDatabaseContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("BirthdayBot.Data.BlocklistEntry", b =>
{
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("GuildId", "UserId")
.HasName("banned_users_pkey");
b.ToTable("banned_users", (string)null);
});
modelBuilder.Entity("BirthdayBot.Data.GuildConfig", b =>
{
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<string>("AnnounceMessage")
.HasColumnType("text")
.HasColumnName("announce_message");
b.Property<string>("AnnounceMessagePl")
.HasColumnType("text")
.HasColumnName("announce_message_pl");
b.Property<bool>("AnnouncePing")
.HasColumnType("boolean")
.HasColumnName("announce_ping");
b.Property<long?>("ChannelAnnounceId")
.HasColumnType("bigint")
.HasColumnName("channel_announce_id");
b.Property<DateTimeOffset>("LastSeen")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen")
.HasDefaultValueSql("now()");
b.Property<bool>("Moderated")
.HasColumnType("boolean")
.HasColumnName("moderated");
b.Property<long?>("ModeratorRole")
.HasColumnType("bigint")
.HasColumnName("moderator_role");
b.Property<long?>("RoleId")
.HasColumnType("bigint")
.HasColumnName("role_id");
b.Property<string>("TimeZone")
.HasColumnType("text")
.HasColumnName("time_zone");
b.HasKey("GuildId")
.HasName("settings_pkey");
b.ToTable("settings", (string)null);
});
modelBuilder.Entity("BirthdayBot.Data.UserEntry", b =>
{
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.Property<int>("BirthDay")
.HasColumnType("integer")
.HasColumnName("birth_day");
b.Property<int>("BirthMonth")
.HasColumnType("integer")
.HasColumnName("birth_month");
b.Property<DateTimeOffset>("LastSeen")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen")
.HasDefaultValueSql("now()");
b.Property<string>("TimeZone")
.HasColumnType("text")
.HasColumnName("time_zone");
b.HasKey("GuildId", "UserId")
.HasName("user_birthdays_pkey");
b.ToTable("user_birthdays", (string)null);
});
modelBuilder.Entity("BirthdayBot.Data.BlocklistEntry", b =>
{
b.HasOne("BirthdayBot.Data.GuildConfig", "Guild")
.WithMany("BlockedUsers")
.HasForeignKey("GuildId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("banned_users_guild_id_fkey");
b.Navigation("Guild");
});
modelBuilder.Entity("BirthdayBot.Data.UserEntry", b =>
{
b.HasOne("BirthdayBot.Data.GuildConfig", "Guild")
.WithMany("UserEntries")
.HasForeignKey("GuildId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("user_birthdays_guild_id_fkey");
b.Navigation("Guild");
});
modelBuilder.Entity("BirthdayBot.Data.GuildConfig", b =>
{
b.Navigation("BlockedUsers");
b.Navigation("UserEntries");
});
#pragma warning restore 612, 618
}
}
}

32
Data/UserEntry.cs Normal file
View file

@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace BirthdayBot.Data;
[Table("user_birthdays")]
public class UserEntry {
[Key]
[Column("guild_id")]
public long GuildId { get; set; }
[Key]
[Column("user_id")]
public long UserId { get; set; }
[Column("birth_month")]
public int BirthMonth { get; set; }
[Column("birth_day")]
public int BirthDay { get; set; }
[Column("time_zone")]
public string? TimeZone { get; set; }
[Column("last_seen")]
public DateTimeOffset LastSeen { get; set; }
[ForeignKey(nameof(GuildConfig.GuildId))]
[InverseProperty(nameof(GuildConfig.UserEntries))]
public GuildConfig Guild { get; set; } = null!;
/// <summary>
/// Gets if this instance is new and does not (yet) exist in the database.
/// </summary>
[NotMapped]
public bool IsNew { get; set; }
}

View file

@ -1,6 +1,4 @@
using BirthdayBot.Data;
using System;
using System.Threading.Tasks;
namespace BirthdayBot;
@ -21,14 +19,17 @@ class Program {
Console.WriteLine(ex);
Environment.Exit((int)ExitCodes.ConfigError);
}
BotDatabaseContext.NpgsqlConnectionString = cfg.DatabaseConnectionString;
Database.DBConnectionString = cfg.DatabaseConnectionString;
try {
await Database.DoInitialDatabaseSetupAsync();
} catch (Npgsql.NpgsqlException e) {
Console.WriteLine("Error when attempting to connect to database: " + e.Message);
Environment.Exit((int)ExitCodes.DatabaseError);
}
Console.CancelKeyPress += OnCancelKeyPressed;
_bot = new ShardManager(cfg);

View file

@ -127,6 +127,7 @@ public sealed class ShardInstance : IDisposable {
#endif
}
#pragma warning disable CS0618
/// <summary>
/// Determines if the incoming message is an incoming command, and dispatches to the appropriate handler if necessary.
/// </summary>
@ -166,6 +167,7 @@ public sealed class ShardInstance : IDisposable {
}
}
}
#pragma warning restore CS0618
// Slash command preparation and invocation
private async Task DiscordClient_InteractionCreated(SocketInteraction arg) {

View file

@ -1,4 +1,5 @@
using BirthdayBot.Data;
#pragma warning disable CS0618
using BirthdayBot.Data;
using NodaTime;
using System.Collections.ObjectModel;
using System.Text.RegularExpressions;

View file

@ -1,4 +1,5 @@
using BirthdayBot.Data;
#pragma warning disable CS0618
using BirthdayBot.Data;
using System.Text;
namespace BirthdayBot.TextCommands;

View file

@ -1,4 +1,5 @@
using BirthdayBot.Data;
#pragma warning disable CS0618
using BirthdayBot.Data;
using System.Text;
namespace BirthdayBot.TextCommands;

View file

@ -1,4 +1,5 @@
using BirthdayBot.Data;
#pragma warning disable CS0618
using BirthdayBot.Data;
using System.Text;
using System.Text.RegularExpressions;

View file

@ -1,4 +1,5 @@
using BirthdayBot.Data;
#pragma warning disable CS0618
using BirthdayBot.Data;
using System.Text.RegularExpressions;
namespace BirthdayBot.TextCommands;