mirror of
https://github.com/NoiTheCat/WorldTime.git
synced 2024-11-21 22:34:36 +00:00
Merge pull request #18 from NoiTheCat/dev
Due to some poor time management, rough memory about what already got done, and all around general busy-ness, some changes within this branch had already been running live for at least a month without making it into the main branch. From current main to 2.2.0: * Console timestamps now show local time * Measures to prevent misconfigurations from messing with slash command registrations * Small but profound changes to how bot configuration is loaded, particularly with regard to SQL From 2.2.0 to 2.3.0: * Reorganize files, introduce "config" top level command group * Finally(!) add 12-hour formatting (closes #2) * Use the new Discord permissions model, removing our clumsy custom precondition
This commit is contained in:
commit
52cc4eadd4
15 changed files with 540 additions and 429 deletions
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
@ -11,7 +11,7 @@
|
||||||
"preLaunchTask": "build",
|
"preLaunchTask": "build",
|
||||||
// If you have changed target frameworks, make sure to update the program path.
|
// If you have changed target frameworks, make sure to update the program path.
|
||||||
"program": "${workspaceFolder}/bin/Debug/net6.0/WorldTime.dll",
|
"program": "${workspaceFolder}/bin/Debug/net6.0/WorldTime.dll",
|
||||||
"args": [],
|
"args": [ "-c", "${workspaceFolder}/bin/Debug/net6.0/settings.json" ],
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
|
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
|
||||||
"console": "internalConsole",
|
"console": "internalConsole",
|
||||||
|
|
|
@ -1,305 +0,0 @@
|
||||||
using Discord.Interactions;
|
|
||||||
using NodaTime;
|
|
||||||
using System.Collections.ObjectModel;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Text;
|
|
||||||
using WorldTime.Data;
|
|
||||||
|
|
||||||
namespace WorldTime;
|
|
||||||
public class ApplicationCommands : InteractionModuleBase<ShardedInteractionContext> {
|
|
||||||
const string ErrNotAllowed = ":x: Only server moderators may use this command.";
|
|
||||||
|
|
||||||
const string EmbedHelpField1 = $"`/help` - {HelpHelp}\n"
|
|
||||||
+ $"`/list` - {HelpList}\n"
|
|
||||||
+ $"`/set` - {HelpSet}\n"
|
|
||||||
+ $"`/remove` - {HelpRemove}";
|
|
||||||
const string EmbedHelpField2 = $"`/set-for` - {HelpSetFor}\n`/remove-for` - {HelpRemoveFor}";
|
|
||||||
|
|
||||||
#region Help strings
|
|
||||||
const string HelpHelp = "Displays a list of available bot commands.";
|
|
||||||
const string HelpList = "Shows the current time for all recently active known users.";
|
|
||||||
const string HelpSet = "Adds or updates your time zone to the bot.";
|
|
||||||
const string HelpSetFor = "Sets/updates time zone for a given user.";
|
|
||||||
const string HelpRemove = "Removes your time zone information from this bot.";
|
|
||||||
const string HelpRemoveFor = "Removes time zone for a given user.";
|
|
||||||
#endregion
|
|
||||||
private const string ErrInvalidZone = ":x: Not a valid zone name."
|
|
||||||
+ " To find your time zone, refer to: <https://kevinnovak.github.io/Time-Zone-Picker/>.";
|
|
||||||
private const string ErrNoUserCache = ":warning: Please try the command again.";
|
|
||||||
|
|
||||||
private static readonly ReadOnlyDictionary<string, string> _tzNameMap;
|
|
||||||
|
|
||||||
public DiscordShardedClient ShardedClient { get; set; } = null!;
|
|
||||||
public BotDatabaseContext DbContext { get; set; } = null!;
|
|
||||||
|
|
||||||
static ApplicationCommands() {
|
|
||||||
Dictionary<string, string> tzNameMap = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
foreach (var name in DateTimeZoneProviders.Tzdb.Ids) tzNameMap.Add(name, name);
|
|
||||||
_tzNameMap = new(tzNameMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
[SlashCommand("help", HelpHelp)]
|
|
||||||
public async Task CmdHelp() {
|
|
||||||
var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version!.ToString(3);
|
|
||||||
var guildct = ShardedClient.Guilds.Count;
|
|
||||||
using var db = DbContext;
|
|
||||||
var uniquetz = db.GetDistinctZoneCount();
|
|
||||||
await RespondAsync(embed: new EmbedBuilder() {
|
|
||||||
Title = "Help & About",
|
|
||||||
Description = $"World Time v{version} - Serving {guildct} communities across {uniquetz} time zones.\n\n"
|
|
||||||
+ "This bot is provided for free, without any paywalled 'premium' features. "
|
|
||||||
+ "If you've found this bot useful, please consider contributing via the "
|
|
||||||
+ "bot author's page on Ko-fi: https://ko-fi.com/noithecat.",
|
|
||||||
Footer = new EmbedFooterBuilder() {
|
|
||||||
IconUrl = Context.Client.CurrentUser.GetAvatarUrl(),
|
|
||||||
Text = "World Time"
|
|
||||||
}
|
|
||||||
}.AddField(inline: false, name: "Commands", value: EmbedHelpField1
|
|
||||||
).AddField(inline: false, name: "Admin commands", value: EmbedHelpField2
|
|
||||||
).AddField(inline: false, name: "Zones", value:
|
|
||||||
"This bot accepts zone names from the IANA Time Zone Database (a.k.a. Olson Database). " +
|
|
||||||
"A useful tool to determine yours can be found at: https://kevinnovak.github.io/Time-Zone-Picker/"
|
|
||||||
).Build());
|
|
||||||
}
|
|
||||||
|
|
||||||
[RequireGuildContext]
|
|
||||||
[SlashCommand("list", HelpList)]
|
|
||||||
public async Task CmdList([Summary(description: "A specific user whose time to look up.")]SocketGuildUser? user = null) {
|
|
||||||
if (!await AreUsersDownloadedAsync(Context.Guild)) {
|
|
||||||
await RespondAsync(ErrNoUserCache, ephemeral: true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user == null) {
|
|
||||||
// No parameter - full listing
|
|
||||||
await CmdListWithoutParamAsync();
|
|
||||||
} else {
|
|
||||||
// Has parameter - do single user listing
|
|
||||||
await CmdListWithUserParamAsync(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CmdListWithoutParamAsync() {
|
|
||||||
// Called by CmdList
|
|
||||||
using var db = DbContext;
|
|
||||||
var userlist = db.GetGuildZones(Context.Guild.Id);
|
|
||||||
if (userlist.Count == 0) {
|
|
||||||
await RespondAsync(":x: Nothing to show. Register your time zones with the bot using the `/set` command.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Order times by popularity to limit how many are shown, group by printed name
|
|
||||||
var sortedlist = new SortedDictionary<string, List<ulong>>();
|
|
||||||
foreach ((string area, List<ulong> users) in userlist.OrderByDescending(o => o.Value.Count).Take(20)) {
|
|
||||||
// Filter further to top 20 distinct timezones, even if they are not displayed in the final result
|
|
||||||
var areaprint = TzPrint(area);
|
|
||||||
if (!sortedlist.ContainsKey(areaprint)) sortedlist.Add(areaprint, new List<ulong>());
|
|
||||||
sortedlist[areaprint].AddRange(users);
|
|
||||||
}
|
|
||||||
|
|
||||||
const int MaxSingleLineLength = 750;
|
|
||||||
const int MaxSingleOutputLength = 900;
|
|
||||||
|
|
||||||
// Build zone listings with users
|
|
||||||
var outputlines = new List<string>();
|
|
||||||
foreach ((string area, List<ulong> users) in sortedlist) {
|
|
||||||
var buffer = new StringBuilder();
|
|
||||||
buffer.Append(area[4..] + ": ");
|
|
||||||
bool empty = true;
|
|
||||||
foreach (var userid in users) {
|
|
||||||
var userinstance = Context.Guild.GetUser(userid);
|
|
||||||
if (userinstance == null) continue;
|
|
||||||
if (empty) empty = !empty;
|
|
||||||
else buffer.Append(", ");
|
|
||||||
var useradd = FormatName(userinstance);
|
|
||||||
if (buffer.Length + useradd.Length > MaxSingleLineLength) {
|
|
||||||
buffer.Append("others...");
|
|
||||||
break;
|
|
||||||
} else buffer.Append(useradd);
|
|
||||||
}
|
|
||||||
if (!empty) outputlines.Add(buffer.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare for output - send buffers out if they become too large
|
|
||||||
outputlines.Sort();
|
|
||||||
bool hasOutputOneLine = false;
|
|
||||||
// First output is shown as an interaction response, followed then as regular channel messages
|
|
||||||
async Task doOutput(Embed msg) {
|
|
||||||
if (!hasOutputOneLine) {
|
|
||||||
await RespondAsync(embed: msg);
|
|
||||||
hasOutputOneLine = true;
|
|
||||||
} else {
|
|
||||||
await ReplyAsync(embed: msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var resultout = new StringBuilder();
|
|
||||||
foreach (var line in outputlines) {
|
|
||||||
if (resultout.Length + line.Length > MaxSingleOutputLength) {
|
|
||||||
await doOutput(new EmbedBuilder().WithDescription(resultout.ToString()).Build());
|
|
||||||
resultout.Clear();
|
|
||||||
}
|
|
||||||
if (resultout.Length > 0) resultout.AppendLine(); // avoids trailing newline by adding to the previous line
|
|
||||||
resultout.Append(line);
|
|
||||||
}
|
|
||||||
if (resultout.Length > 0) {
|
|
||||||
await doOutput(new EmbedBuilder().WithDescription(resultout.ToString()).Build());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CmdListWithUserParamAsync(SocketGuildUser parameter) {
|
|
||||||
// Called by CmdList
|
|
||||||
using var db = DbContext;
|
|
||||||
var result = db.GetUserZone(parameter);
|
|
||||||
if (result == null) {
|
|
||||||
bool isself = Context.User.Id == parameter.Id;
|
|
||||||
if (isself) await RespondAsync(":x: You do not have a time zone. Set it with `tz.set`.", ephemeral: true);
|
|
||||||
else await RespondAsync(":x: The given user does not have a time zone set.", ephemeral: true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var resulttext = TzPrint(result)[4..] + ": " + FormatName(parameter);
|
|
||||||
await RespondAsync(embed: new EmbedBuilder().WithDescription(resulttext).Build());
|
|
||||||
}
|
|
||||||
|
|
||||||
[SlashCommand("set", HelpSet)]
|
|
||||||
public async Task CmdSet([Summary(description: "The new time zone to set.")]string zone) {
|
|
||||||
var parsedzone = ParseTimeZone(zone);
|
|
||||||
if (parsedzone == null) {
|
|
||||||
await RespondAsync(ErrInvalidZone, ephemeral: true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
using var db = DbContext;
|
|
||||||
db.UpdateUser((SocketGuildUser)Context.User, parsedzone);
|
|
||||||
await RespondAsync($":white_check_mark: Your time zone has been set to **{parsedzone}**.");
|
|
||||||
}
|
|
||||||
|
|
||||||
[RequireGuildContext]
|
|
||||||
[SlashCommand("set-for", HelpSetFor)]
|
|
||||||
public async Task CmdSetFor([Summary(description: "The user whose time zone to modify.")] SocketGuildUser user,
|
|
||||||
[Summary(description: "The new time zone to set.")] string zone) {
|
|
||||||
if (!IsUserAdmin((SocketGuildUser)Context.User)) {
|
|
||||||
await RespondAsync(ErrNotAllowed, ephemeral: true).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract parameters
|
|
||||||
var newtz = ParseTimeZone(zone);
|
|
||||||
if (newtz == null) {
|
|
||||||
await RespondAsync(ErrInvalidZone);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
using var db = DbContext;
|
|
||||||
db.UpdateUser(user, newtz);
|
|
||||||
await RespondAsync($":white_check_mark: Time zone for **{user}** set to **{newtz}**.");
|
|
||||||
}
|
|
||||||
|
|
||||||
[RequireGuildContext]
|
|
||||||
[SlashCommand("remove", HelpRemove)]
|
|
||||||
public async Task CmdRemove() {
|
|
||||||
using var db = DbContext;
|
|
||||||
var success = db.DeleteUser((SocketGuildUser)Context.User);
|
|
||||||
if (success) await RespondAsync(":white_check_mark: Your zone has been removed.");
|
|
||||||
else await RespondAsync(":x: You don't have a time zone set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
[RequireGuildContext]
|
|
||||||
[SlashCommand("remove-for", HelpRemoveFor)]
|
|
||||||
public async Task CmdRemoveFor([Summary(description: "The user whose time zone to remove.")] SocketGuildUser user) {
|
|
||||||
if (!IsUserAdmin((SocketGuildUser)Context.User)) {
|
|
||||||
await RespondAsync(ErrNotAllowed, ephemeral: true).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
using var db = DbContext;
|
|
||||||
if (db.DeleteUser(user))
|
|
||||||
await RespondAsync($":white_check_mark: Removed zone information for {user}.");
|
|
||||||
else
|
|
||||||
await RespondAsync($":white_check_mark: No time zone is set for {user}.");
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Helper methods
|
|
||||||
/// <summary>
|
|
||||||
/// Returns a string displaying the current time in the given time zone.
|
|
||||||
/// The result begins with four numbers for sorting purposes. Must be trimmed before output.
|
|
||||||
/// </summary>
|
|
||||||
private static string TzPrint(string zone) {
|
|
||||||
var tzdb = DateTimeZoneProviders.Tzdb;
|
|
||||||
DateTimeZone tz = tzdb.GetZoneOrNull(zone)!;
|
|
||||||
if (tz == null) throw new Exception("Encountered unknown time zone: " + zone);
|
|
||||||
|
|
||||||
var now = SystemClock.Instance.GetCurrentInstant().InZone(tz);
|
|
||||||
var sortpfx = now.ToString("MMdd", DateTimeFormatInfo.InvariantInfo);
|
|
||||||
var fullstr = now.ToString("dd'-'MMM' 'HH':'mm' 'x' (UTC'o<g>')'", DateTimeFormatInfo.InvariantInfo);
|
|
||||||
return $"{sortpfx}● `{fullstr}`";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks given time zone input. Returns a valid string for use with NodaTime, or null.
|
|
||||||
/// </summary>
|
|
||||||
private static string? ParseTimeZone(string tzinput) {
|
|
||||||
if (tzinput.Equals("Asia/Calcutta", StringComparison.OrdinalIgnoreCase)) tzinput = "Asia/Kolkata";
|
|
||||||
if (_tzNameMap.TryGetValue(tzinput, out var name)) return name;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Formats a user's name to a consistent, readable format which makes use of their nickname.
|
|
||||||
/// </summary>
|
|
||||||
private static string FormatName(SocketGuildUser user) {
|
|
||||||
static string escapeFormattingCharacters(string input) {
|
|
||||||
var result = new StringBuilder();
|
|
||||||
foreach (var c in input) {
|
|
||||||
if (c is '\\' or '_' or '~' or '*' or '@') {
|
|
||||||
result.Append('\\');
|
|
||||||
}
|
|
||||||
result.Append(c);
|
|
||||||
}
|
|
||||||
return result.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
var username = escapeFormattingCharacters(user.Username);
|
|
||||||
if (user.Nickname != null) {
|
|
||||||
return $"**{escapeFormattingCharacters(user.Nickname)}** ({username}#{user.Discriminator})";
|
|
||||||
}
|
|
||||||
return $"**{username}**#{user.Discriminator}";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if the given user can be considered a guild admin ('Manage Server' is set).
|
|
||||||
/// </summary>
|
|
||||||
// TODO replace this with a precondition, or there's also a new permission scheme going around?
|
|
||||||
private static bool IsUserAdmin(SocketGuildUser user)
|
|
||||||
=> user.GuildPermissions.Administrator || user.GuildPermissions.ManageGuild;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if the member cache for the specified guild needs to be filled, and sends a request if needed.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Due to a quirk in Discord.Net, the user cache cannot be filled until the command handler is no longer executing
|
|
||||||
/// regardless of if the request runs on its own thread, thus requiring the user to run the command again.
|
|
||||||
/// </remarks>
|
|
||||||
/// <returns>
|
|
||||||
/// True if the guild's members are already downloaded. If false, the command handler must notify the user.
|
|
||||||
/// </returns>
|
|
||||||
private static async Task<bool> AreUsersDownloadedAsync(SocketGuild guild) {
|
|
||||||
static bool HasMostMembersDownloaded(SocketGuild guild) {
|
|
||||||
if (guild.HasAllMembers) return true;
|
|
||||||
if (guild.MemberCount > 30) {
|
|
||||||
// For guilds of size over 30, require 85% or more of the members to be known
|
|
||||||
// (26/30, 42/50, 255/300, etc)
|
|
||||||
return guild.DownloadedMemberCount >= (int)(guild.MemberCount * 0.85);
|
|
||||||
} else {
|
|
||||||
// For smaller guilds, fail if two or more members are missing
|
|
||||||
return guild.MemberCount - guild.DownloadedMemberCount <= 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (HasMostMembersDownloaded(guild)) return true;
|
|
||||||
else {
|
|
||||||
// Event handler hangs if awaited normally or used with Task.Run
|
|
||||||
await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
}
|
|
105
Commands/CommandsBase.cs
Normal file
105
Commands/CommandsBase.cs
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
using Discord.Interactions;
|
||||||
|
using NodaTime;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using WorldTime.Data;
|
||||||
|
|
||||||
|
namespace WorldTime.Commands;
|
||||||
|
public class CommandsBase : InteractionModuleBase<ShardedInteractionContext> {
|
||||||
|
protected const string ErrInvalidZone = ":x: Not a valid zone name."
|
||||||
|
+ " To find your time zone, refer to: <https://kevinnovak.github.io/Time-Zone-Picker/>.";
|
||||||
|
protected const string ErrNoUserCache = ":warning: Please try the command again.";
|
||||||
|
protected const string ErrNotAllowed = ":x: Only server moderators may use this command.";
|
||||||
|
|
||||||
|
private static readonly ReadOnlyDictionary<string, string> _tzNameMap;
|
||||||
|
|
||||||
|
static CommandsBase() {
|
||||||
|
Dictionary<string, string> tzNameMap = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var name in DateTimeZoneProviders.Tzdb.Ids) tzNameMap.Add(name, name);
|
||||||
|
_tzNameMap = new(tzNameMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DiscordShardedClient ShardedClient { get; set; } = null!;
|
||||||
|
public BotDatabaseContext DbContext { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a string displaying the current time in the given time zone.
|
||||||
|
/// The result begins with six numbers for sorting purposes. Must be trimmed before output.
|
||||||
|
/// </summary>
|
||||||
|
protected static string TzPrint(string zone, bool use12hr) {
|
||||||
|
var tzdb = DateTimeZoneProviders.Tzdb;
|
||||||
|
DateTimeZone tz = tzdb.GetZoneOrNull(zone)!;
|
||||||
|
if (tz == null) throw new Exception("Encountered unknown time zone: " + zone);
|
||||||
|
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant().InZone(tz);
|
||||||
|
var sortpfx = now.ToString("MMddHH", DateTimeFormatInfo.InvariantInfo);
|
||||||
|
string fullstr;
|
||||||
|
if (use12hr) {
|
||||||
|
var ap = now.ToString("tt", DateTimeFormatInfo.InvariantInfo).ToLowerInvariant();
|
||||||
|
fullstr = now.ToString($"MMM' 'dd', 'hh':'mm'{ap} 'x' (UTC'o<g>')'", DateTimeFormatInfo.InvariantInfo);
|
||||||
|
} else fullstr = now.ToString("dd'-'MMM', 'HH':'mm' 'x' (UTC'o<g>')'", DateTimeFormatInfo.InvariantInfo);
|
||||||
|
return $"{sortpfx}● `{fullstr}`";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks given time zone input. Returns a valid string for use with NodaTime, or null.
|
||||||
|
/// </summary>
|
||||||
|
protected static string? ParseTimeZone(string tzinput) {
|
||||||
|
if (tzinput.Equals("Asia/Calcutta", StringComparison.OrdinalIgnoreCase)) tzinput = "Asia/Kolkata";
|
||||||
|
if (_tzNameMap.TryGetValue(tzinput, out var name)) return name;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a user's name to a consistent, readable format which makes use of their nickname.
|
||||||
|
/// </summary>
|
||||||
|
protected static string FormatName(SocketGuildUser user) {
|
||||||
|
static string escapeFormattingCharacters(string input) {
|
||||||
|
var result = new StringBuilder();
|
||||||
|
foreach (var c in input) {
|
||||||
|
if (c is '\\' or '_' or '~' or '*' or '@') {
|
||||||
|
result.Append('\\');
|
||||||
|
}
|
||||||
|
result.Append(c);
|
||||||
|
}
|
||||||
|
return result.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
var username = escapeFormattingCharacters(user.Username);
|
||||||
|
if (user.Nickname != null) {
|
||||||
|
return $"**{escapeFormattingCharacters(user.Nickname)}** ({username}#{user.Discriminator})";
|
||||||
|
}
|
||||||
|
return $"**{username}**#{user.Discriminator}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if the member cache for the specified guild needs to be filled, and sends a request if needed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Due to a quirk in Discord.Net, the user cache cannot be filled until the command handler is no longer executing
|
||||||
|
/// regardless of if the request runs on its own thread, thus requiring the user to run the command again.
|
||||||
|
/// </remarks>
|
||||||
|
/// <returns>
|
||||||
|
/// True if the guild's members are already downloaded. If false, the command handler must notify the user.
|
||||||
|
/// </returns>
|
||||||
|
protected static async Task<bool> AreUsersDownloadedAsync(SocketGuild guild) {
|
||||||
|
static bool HasMostMembersDownloaded(SocketGuild guild) {
|
||||||
|
if (guild.HasAllMembers) return true;
|
||||||
|
if (guild.MemberCount > 30) {
|
||||||
|
// For guilds of size over 30, require 85% or more of the members to be known
|
||||||
|
// (26/30, 42/50, 255/300, etc)
|
||||||
|
return guild.DownloadedMemberCount >= (int)(guild.MemberCount * 0.85);
|
||||||
|
} else {
|
||||||
|
// For smaller guilds, fail if two or more members are missing
|
||||||
|
return guild.MemberCount - guild.DownloadedMemberCount <= 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (HasMostMembersDownloaded(guild)) return true;
|
||||||
|
else {
|
||||||
|
// Event handler hangs if awaited normally or used with Task.Run
|
||||||
|
await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
49
Commands/ConfigCommands.cs
Normal file
49
Commands/ConfigCommands.cs
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
using Discord.Interactions;
|
||||||
|
|
||||||
|
namespace WorldTime.Commands;
|
||||||
|
[Group("config", "Configuration commands for World Time.")]
|
||||||
|
[DefaultMemberPermissions(GuildPermission.ManageGuild)]
|
||||||
|
[EnabledInDm(false)]
|
||||||
|
public class ConfigCommands : CommandsBase {
|
||||||
|
internal const string HelpUse12 = "Sets whether to use the 12-hour (AM/PM) format in time zone listings.";
|
||||||
|
internal const string HelpSetFor = "Sets/updates time zone for a given user.";
|
||||||
|
internal const string HelpRemoveFor = "Removes time zone for a given user.";
|
||||||
|
|
||||||
|
[SlashCommand("use-12hour", HelpUse12)]
|
||||||
|
public async Task Cmd12Hour([Summary(description: "True to enable, False to disable.")] bool setting) {
|
||||||
|
using var db = DbContext;
|
||||||
|
var gs = db.GuildSettings.Where(r => r.GuildId == Context.Guild.Id).SingleOrDefault();
|
||||||
|
if (gs == null) {
|
||||||
|
gs = new() { GuildId = Context.Guild.Id };
|
||||||
|
db.Add(gs);
|
||||||
|
}
|
||||||
|
|
||||||
|
gs.Use12HourTime = setting;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await RespondAsync($":white_check_mark: Time listing set to **{(setting ? "AM/PM" : "24 hour")}** format.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("set-for", HelpSetFor)]
|
||||||
|
public async Task CmdSetFor([Summary(description: "The user whose time zone to modify.")] SocketGuildUser user,
|
||||||
|
[Summary(description: "The new time zone to set.")] string zone) {
|
||||||
|
// Extract parameters
|
||||||
|
var newtz = ParseTimeZone(zone);
|
||||||
|
if (newtz == null) {
|
||||||
|
await RespondAsync(ErrInvalidZone);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var db = DbContext;
|
||||||
|
db.UpdateUser(user, newtz);
|
||||||
|
await RespondAsync($":white_check_mark: Time zone for **{user}** set to **{newtz}**.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("remove-for", HelpRemoveFor)]
|
||||||
|
public async Task CmdRemoveFor([Summary(description: "The user whose time zone to remove.")] SocketGuildUser user) {
|
||||||
|
using var db = DbContext;
|
||||||
|
if (db.DeleteUser(user))
|
||||||
|
await RespondAsync($":white_check_mark: Removed zone information for {user}.");
|
||||||
|
else
|
||||||
|
await RespondAsync($":white_check_mark: No time zone is set for {user}.");
|
||||||
|
}
|
||||||
|
}
|
171
Commands/UserCommands.cs
Normal file
171
Commands/UserCommands.cs
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
using Discord.Interactions;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace WorldTime.Commands;
|
||||||
|
public class UserCommands : CommandsBase {
|
||||||
|
const string EmbedHelpField1 =
|
||||||
|
$"`/help` - {HelpHelp}\n"
|
||||||
|
+ $"`/list` - {HelpList}\n"
|
||||||
|
+ $"`/set` - {HelpSet}\n"
|
||||||
|
+ $"`/remove` - {HelpRemove}";
|
||||||
|
const string EmbedHelpField2 =
|
||||||
|
$"`/config use-12hour` - {ConfigCommands.HelpUse12}\n"
|
||||||
|
+ $"`/set-for` - {ConfigCommands.HelpSetFor}\n"
|
||||||
|
+ $"`/remove-for` - {ConfigCommands.HelpRemoveFor}";
|
||||||
|
|
||||||
|
#region Help strings
|
||||||
|
const string HelpHelp = "Displays a list of available bot commands.";
|
||||||
|
const string HelpList = "Shows the current time for all recently active known users.";
|
||||||
|
const string HelpSet = "Adds or updates your time zone to the bot.";
|
||||||
|
const string HelpRemove = "Removes your time zone information from this bot.";
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
[SlashCommand("help", HelpHelp)]
|
||||||
|
public async Task CmdHelp() {
|
||||||
|
var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version!.ToString(3);
|
||||||
|
var guildct = ShardedClient.Guilds.Count;
|
||||||
|
using var db = DbContext;
|
||||||
|
var uniquetz = db.GetDistinctZoneCount();
|
||||||
|
await RespondAsync(embed: new EmbedBuilder() {
|
||||||
|
Title = "Help & About",
|
||||||
|
Description = $"World Time v{version} - Serving {guildct} communities across {uniquetz} time zones.\n\n"
|
||||||
|
+ "This bot is provided for free, without any paywalled 'premium' features. "
|
||||||
|
+ "If you've found this bot useful, please consider contributing via the "
|
||||||
|
+ "bot author's page on Ko-fi: https://ko-fi.com/noithecat.",
|
||||||
|
Footer = new EmbedFooterBuilder() {
|
||||||
|
IconUrl = Context.Client.CurrentUser.GetAvatarUrl(),
|
||||||
|
Text = "World Time"
|
||||||
|
}
|
||||||
|
}.AddField(inline: false, name: "Commands", value: EmbedHelpField1
|
||||||
|
).AddField(inline: false, name: "Admin commands", value: EmbedHelpField2
|
||||||
|
).AddField(inline: false, name: "Zones", value:
|
||||||
|
"This bot accepts zone names from the IANA Time Zone Database (a.k.a. Olson Database). " +
|
||||||
|
"A useful tool to determine yours can be found at: https://kevinnovak.github.io/Time-Zone-Picker/"
|
||||||
|
).Build());
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("list", HelpList)]
|
||||||
|
[EnabledInDm(false)]
|
||||||
|
public async Task CmdList([Summary(description: "A specific user whose time to look up.")]SocketGuildUser? user = null) {
|
||||||
|
if (!await AreUsersDownloadedAsync(Context.Guild)) {
|
||||||
|
await RespondAsync(ErrNoUserCache, ephemeral: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
// No parameter - full listing
|
||||||
|
await CmdListWithoutParamAsync();
|
||||||
|
} else {
|
||||||
|
// Has parameter - do single user listing
|
||||||
|
await CmdListWithUserParamAsync(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CmdListWithoutParamAsync() {
|
||||||
|
// Called by CmdList
|
||||||
|
using var db = DbContext;
|
||||||
|
var userlist = db.GetGuildZones(Context.Guild.Id);
|
||||||
|
if (userlist.Count == 0) {
|
||||||
|
await RespondAsync(":x: Nothing to show. Register your time zones with the bot using the `/set` command.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order times by popularity to limit how many are shown, group by printed name
|
||||||
|
var sortedlist = new SortedDictionary<string, List<ulong>>();
|
||||||
|
var ampm = db.GuildSettings.Where(s => s.GuildId == Context.Guild.Id).SingleOrDefault()?.Use12HourTime ?? false;
|
||||||
|
foreach ((string area, List<ulong> users) in userlist.OrderByDescending(o => o.Value.Count).Take(20)) {
|
||||||
|
// Filter further to top 20 distinct timezones, even if they are not displayed in the final result
|
||||||
|
var areaprint = TzPrint(area, ampm);
|
||||||
|
if (!sortedlist.ContainsKey(areaprint)) sortedlist.Add(areaprint, new List<ulong>());
|
||||||
|
sortedlist[areaprint].AddRange(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int MaxSingleLineLength = 750;
|
||||||
|
const int MaxSingleOutputLength = 3000;
|
||||||
|
|
||||||
|
// Build zone listings with users
|
||||||
|
var outputlines = new List<string>();
|
||||||
|
foreach ((string area, List<ulong> users) in sortedlist) {
|
||||||
|
var buffer = new StringBuilder();
|
||||||
|
buffer.Append(area[6..] + ": ");
|
||||||
|
bool empty = true;
|
||||||
|
foreach (var userid in users) {
|
||||||
|
var userinstance = Context.Guild.GetUser(userid);
|
||||||
|
if (userinstance == null) continue;
|
||||||
|
if (empty) empty = !empty;
|
||||||
|
else buffer.Append(", ");
|
||||||
|
var useradd = FormatName(userinstance);
|
||||||
|
if (buffer.Length + useradd.Length > MaxSingleLineLength) {
|
||||||
|
buffer.Append("others...");
|
||||||
|
break;
|
||||||
|
} else buffer.Append(useradd);
|
||||||
|
}
|
||||||
|
if (!empty) outputlines.Add(buffer.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare for output - send buffers out if they become too large
|
||||||
|
outputlines.Sort();
|
||||||
|
bool hasOutputOneLine = false;
|
||||||
|
// First output is shown as an interaction response, followed then as regular channel messages
|
||||||
|
async Task doOutput(Embed msg) {
|
||||||
|
if (!hasOutputOneLine) {
|
||||||
|
await RespondAsync(embed: msg);
|
||||||
|
hasOutputOneLine = true;
|
||||||
|
} else {
|
||||||
|
await ReplyAsync(embed: msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resultout = new StringBuilder();
|
||||||
|
foreach (var line in outputlines) {
|
||||||
|
if (resultout.Length + line.Length > MaxSingleOutputLength) {
|
||||||
|
await doOutput(new EmbedBuilder().WithDescription(resultout.ToString()).Build());
|
||||||
|
resultout.Clear();
|
||||||
|
}
|
||||||
|
if (resultout.Length > 0) resultout.AppendLine(); // avoids trailing newline by adding to the previous line
|
||||||
|
resultout.Append(line);
|
||||||
|
}
|
||||||
|
if (resultout.Length > 0) {
|
||||||
|
await doOutput(new EmbedBuilder().WithDescription(resultout.ToString()).Build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CmdListWithUserParamAsync(SocketGuildUser parameter) {
|
||||||
|
// Called by CmdList
|
||||||
|
using var db = DbContext;
|
||||||
|
var result = db.GetUserZone(parameter);
|
||||||
|
if (result == null) {
|
||||||
|
bool isself = Context.User.Id == parameter.Id;
|
||||||
|
if (isself) await RespondAsync(":x: You do not have a time zone. Set it with `tz.set`.", ephemeral: true);
|
||||||
|
else await RespondAsync(":x: The given user does not have a time zone set.", ephemeral: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ampm = db.GuildSettings.Where(s => s.GuildId == Context.Guild.Id).SingleOrDefault()?.Use12HourTime ?? false;
|
||||||
|
var resulttext = TzPrint(result, ampm)[6..] + ": " + FormatName(parameter);
|
||||||
|
await RespondAsync(embed: new EmbedBuilder().WithDescription(resulttext).Build());
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("set", HelpSet)]
|
||||||
|
[EnabledInDm(false)]
|
||||||
|
public async Task CmdSet([Summary(description: "The new time zone to set.")]string zone) {
|
||||||
|
var parsedzone = ParseTimeZone(zone);
|
||||||
|
if (parsedzone == null) {
|
||||||
|
await RespondAsync(ErrInvalidZone, ephemeral: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
using var db = DbContext;
|
||||||
|
db.UpdateUser((SocketGuildUser)Context.User, parsedzone);
|
||||||
|
await RespondAsync($":white_check_mark: Your time zone has been set to **{parsedzone}**.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("remove", HelpRemove)]
|
||||||
|
[EnabledInDm(false)]
|
||||||
|
public async Task CmdRemove() {
|
||||||
|
using var db = DbContext;
|
||||||
|
var success = db.DeleteUser((SocketGuildUser)Context.User);
|
||||||
|
if (success) await RespondAsync(":white_check_mark: Your zone has been removed.");
|
||||||
|
else await RespondAsync(":x: You don't have a time zone set.");
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,56 +1,52 @@
|
||||||
using CommandLine;
|
using CommandLine;
|
||||||
using CommandLine.Text;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using Npgsql;
|
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
namespace WorldTime;
|
namespace WorldTime;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads and holds configuration values.
|
/// Loads and holds configuration values.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
class Configuration {
|
class Configuration {
|
||||||
const string KeySqlHost = "SqlHost";
|
|
||||||
const string KeySqlUsername = "SqlUsername";
|
|
||||||
const string KeySqlPassword = "SqlPassword";
|
|
||||||
const string KeySqlDatabase = "SqlDatabase";
|
|
||||||
|
|
||||||
public string DbConnectionString { get; }
|
|
||||||
public string BotToken { get; }
|
public string BotToken { get; }
|
||||||
public string? DBotsToken { get; }
|
public string? DBotsToken { get; }
|
||||||
|
|
||||||
public int ShardTotal { get; }
|
public int ShardTotal { get; }
|
||||||
|
|
||||||
public Configuration(string[] args) {
|
public string? SqlHost { get; }
|
||||||
var cmdline = CmdLineOpts.Parse(args);
|
public string? SqlDatabase { get; }
|
||||||
|
public string SqlUsername { get; }
|
||||||
|
public string SqlPassword { get; }
|
||||||
|
|
||||||
|
public Configuration() {
|
||||||
|
var args = CommandLineParameters.Parse(Environment.GetCommandLineArgs());
|
||||||
|
var path = args?.ConfigFile ?? Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location)
|
||||||
|
+ Path.DirectorySeparatorChar + "." + Path.DirectorySeparatorChar + "settings.json";
|
||||||
|
|
||||||
// Looks for configuration file
|
// Looks for configuration file
|
||||||
var confPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) + Path.DirectorySeparatorChar;
|
JObject jc;
|
||||||
confPath += cmdline.Config!;
|
try {
|
||||||
if (!File.Exists(confPath)) throw new Exception("Settings file not found in path: " + confPath);
|
var conftxt = File.ReadAllText(path);
|
||||||
|
jc = JObject.Parse(conftxt);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
string pfx;
|
||||||
|
if (ex is JsonException) pfx = "Unable to parse configuration: ";
|
||||||
|
else pfx = "Unable to access configuration: ";
|
||||||
|
|
||||||
var jc = JObject.Parse(File.ReadAllText(confPath));
|
throw new Exception(pfx + ex.Message, ex);
|
||||||
|
}
|
||||||
|
|
||||||
BotToken = ReadConfKey<string>(jc, nameof(BotToken), true);
|
BotToken = ReadConfKey<string>(jc, nameof(BotToken), true);
|
||||||
DBotsToken = ReadConfKey<string>(jc, nameof(DBotsToken), false);
|
DBotsToken = ReadConfKey<string>(jc, nameof(DBotsToken), false);
|
||||||
|
|
||||||
ShardTotal = cmdline.ShardTotal ?? ReadConfKey<int?>(jc, nameof(ShardTotal), false) ?? 1;
|
ShardTotal = args.ShardTotal ?? ReadConfKey<int?>(jc, nameof(ShardTotal), false) ?? 1;
|
||||||
if (ShardTotal < 1) throw new Exception($"'{nameof(ShardTotal)}' must be a positive integer.");
|
if (ShardTotal < 1) throw new Exception($"'{nameof(ShardTotal)}' must be a positive integer.");
|
||||||
|
|
||||||
var sqlhost = ReadConfKey<string>(jc, KeySqlHost, false) ?? "localhost"; // Default to localhost
|
SqlHost = ReadConfKey<string>(jc, nameof(SqlHost), false);
|
||||||
var sqluser = ReadConfKey<string>(jc, KeySqlUsername, false);
|
SqlDatabase = ReadConfKey<string?>(jc, nameof(SqlDatabase), false);
|
||||||
var sqlpass = ReadConfKey<string>(jc, KeySqlPassword, false);
|
SqlUsername = ReadConfKey<string>(jc, nameof(SqlUsername), true);
|
||||||
if (string.IsNullOrWhiteSpace(sqluser) || string.IsNullOrWhiteSpace(sqlpass))
|
SqlPassword = ReadConfKey<string>(jc, nameof(SqlPassword), true);
|
||||||
throw new Exception("'SqlUsername', 'SqlPassword' must be specified.");
|
|
||||||
var csb = new NpgsqlConnectionStringBuilder() {
|
|
||||||
Host = sqlhost,
|
|
||||||
Username = sqluser,
|
|
||||||
Password = sqlpass
|
|
||||||
};
|
|
||||||
var sqldb = ReadConfKey<string>(jc, KeySqlDatabase, false);
|
|
||||||
if (sqldb != null) csb.Database = sqldb; // Optional database setting
|
|
||||||
DbConnectionString = csb.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static T? ReadConfKey<T>(JObject jc, string key, [DoesNotReturnIf(true)] bool failOnEmpty) {
|
private static T? ReadConfKey<T>(JObject jc, string key, [DoesNotReturnIf(true)] bool failOnEmpty) {
|
||||||
|
@ -59,29 +55,24 @@ class Configuration {
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private class CmdLineOpts {
|
class CommandLineParameters {
|
||||||
[Option('c', "config", Default = "settings.json",
|
[Option('c', "config")]
|
||||||
HelpText = "Custom path to instance configuration, relative from executable directory.")]
|
public string? ConfigFile { get; set; }
|
||||||
public string? Config { get; set; }
|
|
||||||
|
|
||||||
[Option("shardtotal",
|
[Option("shardtotal")]
|
||||||
HelpText = "Total number of shards online. MUST be the same for all instances.\n"
|
|
||||||
+ "This value overrides the config file value.")]
|
|
||||||
public int? ShardTotal { get; set; }
|
public int? ShardTotal { get; set; }
|
||||||
|
|
||||||
public static CmdLineOpts Parse(string[] args) {
|
public static CommandLineParameters? Parse(string[] args) {
|
||||||
// Do not automatically print help message
|
CommandLineParameters? result = null;
|
||||||
var clp = new Parser(c => c.HelpWriter = null);
|
|
||||||
|
|
||||||
CmdLineOpts? result = null;
|
new Parser(settings => {
|
||||||
var r = clp.ParseArguments<CmdLineOpts>(args);
|
settings.IgnoreUnknownArguments = true;
|
||||||
r.WithParsed(parsed => result = parsed);
|
settings.AutoHelp = false;
|
||||||
r.WithNotParsed(err => {
|
settings.AutoVersion = false;
|
||||||
var ht = HelpText.AutoBuild(r);
|
}).ParseArguments<CommandLineParameters>(args)
|
||||||
Console.WriteLine(ht.ToString());
|
.WithParsed(p => result = p)
|
||||||
Environment.Exit((int)Program.ExitCodes.BadCommand);
|
.WithNotParsed(e => { /* ignore */ });
|
||||||
});
|
return result;
|
||||||
return result!;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,35 +1,32 @@
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
namespace WorldTime.Data;
|
namespace WorldTime.Data;
|
||||||
public class BotDatabaseContext : DbContext {
|
public class BotDatabaseContext : DbContext {
|
||||||
private static string? _npgsqlConnectionString;
|
private static readonly string _connectionString;
|
||||||
internal static string NpgsqlConnectionString {
|
|
||||||
#if DEBUG
|
static BotDatabaseContext() {
|
||||||
get {
|
// Get our own config loaded just for the SQL stuff
|
||||||
if (_npgsqlConnectionString != null) return _npgsqlConnectionString;
|
var conf = new Configuration();
|
||||||
Program.Log(nameof(BotDatabaseContext), "Using hardcoded connection string!");
|
_connectionString = new NpgsqlConnectionStringBuilder() {
|
||||||
return _npgsqlConnectionString ?? "Host=localhost;Username=worldtime;Password=wt";
|
Host = conf.SqlHost ?? "localhost", // default to localhost
|
||||||
}
|
Database = conf.SqlDatabase,
|
||||||
#else
|
Username = conf.SqlUsername,
|
||||||
get => _npgsqlConnectionString!;
|
Password = conf.SqlPassword
|
||||||
#endif
|
}.ToString();
|
||||||
set => _npgsqlConnectionString ??= value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public DbSet<UserEntry> UserEntries { get; set; } = null!;
|
public DbSet<UserEntry> UserEntries { get; set; } = null!;
|
||||||
|
public DbSet<GuildConfiguration> GuildSettings { get; set; } = null!;
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
=> optionsBuilder
|
=> optionsBuilder
|
||||||
.UseNpgsql(NpgsqlConnectionString)
|
.UseNpgsql(_connectionString)
|
||||||
#if DEBUG
|
|
||||||
.LogTo((string line) => Program.Log("EF", line), Microsoft.Extensions.Logging.LogLevel.Information)
|
|
||||||
#endif
|
|
||||||
.UseSnakeCaseNamingConvention();
|
.UseSnakeCaseNamingConvention();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
||||||
modelBuilder.Entity<UserEntry>(entity => {
|
modelBuilder.Entity<UserEntry>().HasKey(e => new { e.GuildId, e.UserId }).HasName("userdata_pkey");
|
||||||
entity.HasKey(e => new { e.GuildId, e.UserId }).HasName("userdata_pkey");
|
modelBuilder.Entity<GuildConfiguration>().Property(p => p.Use12HourTime).HasDefaultValue(false);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Helper methods / abstractions
|
#region Helper methods / abstractions
|
||||||
|
|
9
Data/GuildConfiguration.cs
Normal file
9
Data/GuildConfiguration.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace WorldTime.Data;
|
||||||
|
public class GuildConfiguration {
|
||||||
|
[Key]
|
||||||
|
public ulong GuildId { get; set; }
|
||||||
|
|
||||||
|
public bool Use12HourTime { get; set; }
|
||||||
|
}
|
69
Data/Migrations/20230115041447_Add12HrSetting.Designer.cs
generated
Normal file
69
Data/Migrations/20230115041447_Add12HrSetting.Designer.cs
generated
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
// <auto-generated />
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using WorldTime.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace WorldTime.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(BotDatabaseContext))]
|
||||||
|
[Migration("20230115041447_Add12HrSetting")]
|
||||||
|
partial class Add12HrSetting
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "7.0.1")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("WorldTime.Data.GuildConfiguration", b =>
|
||||||
|
{
|
||||||
|
b.Property<decimal>("GuildId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("numeric(20,0)")
|
||||||
|
.HasColumnName("guild_id");
|
||||||
|
|
||||||
|
b.Property<bool>("Use12HourTime")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("use12hour_time");
|
||||||
|
|
||||||
|
b.HasKey("GuildId")
|
||||||
|
.HasName("pk_guild_settings");
|
||||||
|
|
||||||
|
b.ToTable("guild_settings", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("WorldTime.Data.UserEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("GuildId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("guild_id");
|
||||||
|
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("user_id");
|
||||||
|
|
||||||
|
b.Property<string>("TimeZone")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("zone");
|
||||||
|
|
||||||
|
b.HasKey("GuildId", "UserId")
|
||||||
|
.HasName("userdata_pkey");
|
||||||
|
|
||||||
|
b.ToTable("userdata", (string)null);
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
33
Data/Migrations/20230115041447_Add12HrSetting.cs
Normal file
33
Data/Migrations/20230115041447_Add12HrSetting.cs
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace WorldTime.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Add12HrSetting : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "guild_settings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
guildid = table.Column<decimal>(name: "guild_id", type: "numeric(20,0)", nullable: false),
|
||||||
|
use12hourtime = table.Column<bool>(name: "use12hour_time", type: "boolean", nullable: false, defaultValue: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_guild_settings", x => x.guildid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "guild_settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,11 +16,30 @@ namespace WorldTime.Data.Migrations
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "6.0.6")
|
.HasAnnotation("ProductVersion", "7.0.1")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("WorldTime.Data.GuildConfiguration", b =>
|
||||||
|
{
|
||||||
|
b.Property<decimal>("GuildId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("numeric(20,0)")
|
||||||
|
.HasColumnName("guild_id");
|
||||||
|
|
||||||
|
b.Property<bool>("Use12HourTime")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("use12hour_time");
|
||||||
|
|
||||||
|
b.HasKey("GuildId")
|
||||||
|
.HasName("pk_guild_settings");
|
||||||
|
|
||||||
|
b.ToTable("guild_settings", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("WorldTime.Data.UserEntry", b =>
|
modelBuilder.Entity("WorldTime.Data.UserEntry", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("GuildId")
|
b.Property<long>("GuildId")
|
||||||
|
|
10
Program.cs
10
Program.cs
|
@ -9,17 +9,15 @@ class Program {
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string BotUptime => (DateTimeOffset.UtcNow - _botStartTime).ToString("d' days, 'hh':'mm':'ss");
|
public static string BotUptime => (DateTimeOffset.UtcNow - _botStartTime).ToString("d' days, 'hh':'mm':'ss");
|
||||||
|
|
||||||
static async Task Main(string[] args) {
|
static async Task Main() {
|
||||||
Configuration? cfg = null;
|
Configuration? cfg = null;
|
||||||
try {
|
try {
|
||||||
cfg = new Configuration(args);
|
cfg = new Configuration();
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Console.WriteLine(ex);
|
Console.WriteLine(ex);
|
||||||
Environment.Exit((int)ExitCodes.ConfigError);
|
Environment.Exit((int)ExitCodes.ConfigError);
|
||||||
}
|
}
|
||||||
|
|
||||||
Data.BotDatabaseContext.NpgsqlConnectionString = cfg.DbConnectionString;
|
|
||||||
|
|
||||||
Console.CancelKeyPress += OnCancelKeyPressed;
|
Console.CancelKeyPress += OnCancelKeyPressed;
|
||||||
_bot = new WorldTime(cfg);
|
_bot = new WorldTime(cfg);
|
||||||
await _bot.StartAsync().ConfigureAwait(false);
|
await _bot.StartAsync().ConfigureAwait(false);
|
||||||
|
@ -31,10 +29,10 @@ class Program {
|
||||||
/// Sends a formatted message to console.
|
/// Sends a formatted message to console.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void Log(string source, string message) {
|
public static void Log(string source, string message) {
|
||||||
var ts = DateTime.UtcNow;
|
var ts = DateTime.Now;
|
||||||
var ls = new string[] { "\r\n", "\n" };
|
var ls = new string[] { "\r\n", "\n" };
|
||||||
foreach (var item in message.Split(ls, StringSplitOptions.None))
|
foreach (var item in message.Split(ls, StringSplitOptions.None))
|
||||||
Console.WriteLine($"{ts:u} [{source}] {item}");
|
Console.WriteLine($"{ts:s} [{source}] {item}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void OnCancelKeyPressed(object? sender, ConsoleCancelEventArgs e) {
|
private static void OnCancelKeyPressed(object? sender, ConsoleCancelEventArgs e) {
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
using Discord.Interactions;
|
|
||||||
|
|
||||||
namespace WorldTime;
|
|
||||||
/// <summary>
|
|
||||||
/// Implements the included precondition from Discord.Net, requiring a guild context while using our custom error message.<br/><br/>
|
|
||||||
/// Combining this with <see cref="RequireBotModeratorAttribute"/> is redundant. If possible, only use the latter instead.
|
|
||||||
/// </summary>
|
|
||||||
class RequireGuildContextAttribute : RequireContextAttribute {
|
|
||||||
public const string Error = "Command not received within a guild context.";
|
|
||||||
public const string Reply = ":x: This command is only available within a server.";
|
|
||||||
|
|
||||||
public override string ErrorMessage => Error;
|
|
||||||
|
|
||||||
public RequireGuildContextAttribute() : base(ContextType.Guild) { }
|
|
||||||
}
|
|
38
WorldTime.cs
38
WorldTime.cs
|
@ -7,7 +7,6 @@ using System.Text;
|
||||||
using WorldTime.Data;
|
using WorldTime.Data;
|
||||||
|
|
||||||
namespace WorldTime;
|
namespace WorldTime;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Main class for the program. Configures the client on start and occasionally prints status information.
|
/// Main class for the program. Configures the client on start and occasionally prints status information.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -125,10 +124,7 @@ internal class WorldTime : IDisposable {
|
||||||
private Task DiscordClient_Log(LogMessage arg) {
|
private Task DiscordClient_Log(LogMessage arg) {
|
||||||
// Suppress certain messages
|
// Suppress certain messages
|
||||||
if (arg.Message != null) {
|
if (arg.Message != null) {
|
||||||
// These warnings appear often as of Discord.Net v3...
|
switch (arg.Message) { // Connection status messages replaced by ShardManager's output
|
||||||
if (arg.Message.StartsWith("Unknown Dispatch ") || arg.Message.StartsWith("Unknown Channel")) return Task.CompletedTask;
|
|
||||||
switch (arg.Message) // Connection status messages replaced by ShardManager's output
|
|
||||||
{
|
|
||||||
case "Connecting":
|
case "Connecting":
|
||||||
case "Connected":
|
case "Connected":
|
||||||
case "Ready":
|
case "Ready":
|
||||||
|
@ -139,7 +135,6 @@ internal class WorldTime : IDisposable {
|
||||||
case "Discord.WebSocket.GatewayReconnectException: Server requested a reconnect":
|
case "Discord.WebSocket.GatewayReconnectException: Server requested a reconnect":
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
Program.Log("Discord.Net", $"{arg.Severity}: {arg.Message}");
|
Program.Log("Discord.Net", $"{arg.Severity}: {arg.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -161,10 +156,14 @@ internal class WorldTime : IDisposable {
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
// Debug: Register our commands locally instead, in each guild we're in
|
// Debug: Register our commands locally instead, in each guild we're in
|
||||||
var iasrv = _services.GetRequiredService<InteractionService>();
|
if (arg.Guilds.Count > 5) {
|
||||||
foreach (var g in arg.Guilds) {
|
Program.Log("Command registration", "Are you debugging in production?! Skipping DEBUG command registration.");
|
||||||
await iasrv.RegisterCommandsToGuildAsync(g.Id, true).ConfigureAwait(false);
|
} else {
|
||||||
Program.Log("Command registration", $"Updated DEBUG command registration in guild {g.Id}.");
|
var iasrv = _services.GetRequiredService<InteractionService>();
|
||||||
|
foreach (var g in arg.Guilds) {
|
||||||
|
await iasrv.RegisterCommandsToGuildAsync(g.Id, true).ConfigureAwait(false);
|
||||||
|
Program.Log("Command registration", $"Updated DEBUG command registration in guild {g.Id}.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
@ -200,20 +199,11 @@ internal class WorldTime : IDisposable {
|
||||||
// Additional log information with error detail
|
// Additional log information with error detail
|
||||||
logresult += " " + Enum.GetName(typeof(InteractionCommandError), result.Error) + ": " + result.ErrorReason;
|
logresult += " " + Enum.GetName(typeof(InteractionCommandError), result.Error) + ": " + result.ErrorReason;
|
||||||
|
|
||||||
// Specific responses to errors, if necessary
|
// Generic error response
|
||||||
if (result.Error == InteractionCommandError.UnmetPrecondition) {
|
// TODO when implementing proper application error logging, see here
|
||||||
string errReply = result.ErrorReason switch {
|
var ia = context.Interaction;
|
||||||
RequireGuildContextAttribute.Error => RequireGuildContextAttribute.Reply,
|
if (ia.HasResponded) await ia.ModifyOriginalResponseAsync(p => p.Content = InternalError);
|
||||||
_ => result.ErrorReason
|
else await ia.RespondAsync(InternalError);
|
||||||
};
|
|
||||||
await context.Interaction.RespondAsync(errReply, ephemeral: true);
|
|
||||||
} else {
|
|
||||||
// Generic error response
|
|
||||||
// TODO when implementing proper application error logging, see here
|
|
||||||
var ia = context.Interaction;
|
|
||||||
if (ia.HasResponded) await ia.ModifyOriginalResponseAsync(p => p.Content = InternalError);
|
|
||||||
else await ia.RespondAsync(InternalError);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Program.Log("Command", logresult);
|
Program.Log("Command", logresult);
|
||||||
|
|
|
@ -5,24 +5,24 @@
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<Version>2.1.5</Version>
|
<Version>2.3.0</Version>
|
||||||
<Authors>NoiTheCat</Authors>
|
<Authors>NoiTheCat</Authors>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CommandLineParser" Version="2.9.1" />
|
<PackageReference Include="CommandLineParser" Version="2.9.1" />
|
||||||
<PackageReference Include="Discord.Net" Version="3.8.0" />
|
<PackageReference Include="Discord.Net" Version="3.9.0" />
|
||||||
<PackageReference Include="EFCore.NamingConventions" Version="6.0.0" />
|
<PackageReference Include="EFCore.NamingConventions" Version="7.0.2" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.2" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.8">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.2">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
|
||||||
<PackageReference Include="NodaTime" Version="3.1.2" />
|
<PackageReference Include="NodaTime" Version="3.1.6" />
|
||||||
<PackageReference Include="Npgsql" Version="6.0.6" />
|
<PackageReference Include="Npgsql" Version="7.0.1" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.6" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.1" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue