mirror of
https://github.com/NoiTheCat/WorldTime.git
synced 2024-11-21 22:34:36 +00:00
Switch to Interaction Framework for slash commands
This commit is contained in:
parent
9f49e73540
commit
f8d5aef4aa
6 changed files with 408 additions and 318 deletions
292
ApplicationCommands.cs
Normal file
292
ApplicationCommands.cs
Normal file
|
@ -0,0 +1,292 @@
|
||||||
|
using Discord.Interactions;
|
||||||
|
using NodaTime;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
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 Database Database { 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;
|
||||||
|
var uniquetz = await Database.GetDistinctZoneCountAsync();
|
||||||
|
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) {
|
||||||
|
await CmdListUserAsync(user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userlist = await Database.GetGuildZonesAsync(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 CmdListUserAsync(SocketGuildUser parameter) {
|
||||||
|
// Not meant as a command handler - called by CmdList
|
||||||
|
var result = await Database.GetUserZoneAsync(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;
|
||||||
|
}
|
||||||
|
await Database.UpdateUserAsync((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(user)) {
|
||||||
|
await RespondAsync(ErrNotAllowed, ephemeral: true).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract parameters
|
||||||
|
var newtz = ParseTimeZone(zone);
|
||||||
|
if (newtz == null) {
|
||||||
|
await RespondAsync(ErrInvalidZone);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Database.UpdateUserAsync(user, newtz).ConfigureAwait(false);
|
||||||
|
await RespondAsync($":white_check_mark: Time zone for **{user}** set to **{newtz}**.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RequireGuildContext]
|
||||||
|
[SlashCommand("remove", HelpRemove)]
|
||||||
|
public async Task CmdRemove() {
|
||||||
|
var success = await Database.DeleteUserAsync((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(user)) {
|
||||||
|
await RespondAsync(ErrNotAllowed, ephemeral: true).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await Database.DeleteUserAsync(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
|
||||||
|
}
|
297
CommandsSlash.cs
297
CommandsSlash.cs
|
@ -1,297 +0,0 @@
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace WorldTime;
|
|
||||||
|
|
||||||
internal class CommandsSlash : CommandsCommon {
|
|
||||||
delegate Task CommandResponder(SocketSlashCommand arg);
|
|
||||||
|
|
||||||
const string ErrGuildOnly = ":x: This command can only be run within a server.";
|
|
||||||
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
|
|
||||||
|
|
||||||
public CommandsSlash(WorldTime inst, Database db) : base(db, inst) {
|
|
||||||
inst.DiscordClient.SlashCommandExecuted += DiscordClient_SlashCommandExecuted;
|
|
||||||
inst.DiscordClient.ShardReady += DiscordClient_ShardReady;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DiscordClient_ShardReady(DiscordSocketClient arg) {
|
|
||||||
#if !DEBUG
|
|
||||||
// Update our commands here, only when the first shard connects
|
|
||||||
if (arg.ShardId != 0) return;
|
|
||||||
#endif
|
|
||||||
var cmds = new ApplicationCommandProperties[] {
|
|
||||||
new SlashCommandBuilder()
|
|
||||||
.WithName("help").WithDescription(HelpHelp).Build(),
|
|
||||||
new SlashCommandBuilder()
|
|
||||||
.WithName("list")
|
|
||||||
.WithDescription(HelpList)
|
|
||||||
.AddOption("user", ApplicationCommandOptionType.User, "A specific user whose time to look up.", isRequired: false)
|
|
||||||
.Build(),
|
|
||||||
new SlashCommandBuilder()
|
|
||||||
.WithName("set")
|
|
||||||
.WithDescription(HelpSet)
|
|
||||||
.AddOption("zone", ApplicationCommandOptionType.String, "The new time zone to set.", isRequired: true)
|
|
||||||
.Build(),
|
|
||||||
new SlashCommandBuilder()
|
|
||||||
.WithName("set-for")
|
|
||||||
.WithDescription(HelpSetFor)
|
|
||||||
.AddOption("user", ApplicationCommandOptionType.User, "The user whose time zone to modify.", isRequired: true)
|
|
||||||
.AddOption("zone", ApplicationCommandOptionType.String, "The new time zone to set.", isRequired: true)
|
|
||||||
.Build(),
|
|
||||||
new SlashCommandBuilder()
|
|
||||||
.WithName("remove").WithDescription(HelpRemove).Build(),
|
|
||||||
new SlashCommandBuilder()
|
|
||||||
.WithName("remove-for")
|
|
||||||
.WithDescription(HelpRemoveFor)
|
|
||||||
.AddOption("user", ApplicationCommandOptionType.User, "The user whose time zone to remove.", isRequired: true)
|
|
||||||
.Build()
|
|
||||||
};
|
|
||||||
#if !DEBUG
|
|
||||||
// Remove any unneeded/unused commands
|
|
||||||
var existingcmdnames = cmds.Select(c => c.Name.Value).ToHashSet();
|
|
||||||
foreach (var gcmd in await arg.GetGlobalApplicationCommandsAsync()) {
|
|
||||||
if (!existingcmdnames.Contains(gcmd.Name)) {
|
|
||||||
Program.Log("Command registration", $"Found registered unused command /{gcmd.Name} - sending removal request");
|
|
||||||
await gcmd.DeleteAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// And update what we have
|
|
||||||
Program.Log("Command registration", $"Bulk updating {cmds.Length} global command(s)");
|
|
||||||
await arg.BulkOverwriteGlobalApplicationCommandsAsync(cmds).ConfigureAwait(false);
|
|
||||||
#else
|
|
||||||
// Debug: Register our commands locally instead, in each guild we're in
|
|
||||||
foreach (var g in arg.Guilds) {
|
|
||||||
await g.DeleteApplicationCommandsAsync().ConfigureAwait(false);
|
|
||||||
await g.BulkOverwriteApplicationCommandAsync(cmds).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var gcmd in await arg.GetGlobalApplicationCommandsAsync()) {
|
|
||||||
Program.Log("Command registration", $"Found global command /{gcmd.Name} and we're DEBUG - sending removal request");
|
|
||||||
await gcmd.DeleteAsync();
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DiscordClient_SlashCommandExecuted(SocketSlashCommand arg) {
|
|
||||||
SocketGuildChannel? rptChannel = arg.Channel as SocketGuildChannel;
|
|
||||||
var rptId = rptChannel?.Guild.Id ?? arg.User.Id;
|
|
||||||
Program.Log("Command executed", $"/{arg.CommandName} by {arg.User} in { (rptChannel != null ? "guild" : "DM. User") } ID {rptId}");
|
|
||||||
|
|
||||||
CommandResponder responder = arg.Data.Name switch {
|
|
||||||
"help" => CmdHelp,
|
|
||||||
"list" => CmdList,
|
|
||||||
"set" => CmdSet,
|
|
||||||
"set-for" => CmdSetFor,
|
|
||||||
"remove" => CmdRemove,
|
|
||||||
"remove-for" => CmdRemoveFor,
|
|
||||||
_ => UnknownCommandHandler
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await responder(arg).ConfigureAwait(false);
|
|
||||||
} catch (Exception e) {
|
|
||||||
Program.Log("Command exception", e.ToString());
|
|
||||||
// TODO respond with error message?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UnknownCommandHandler(SocketSlashCommand arg) {
|
|
||||||
Program.Log("Command invoked", $"/{arg.Data.Name} is an unknown command!");
|
|
||||||
await arg.RespondAsync("Oops, that command isn't supposed to be there... Please try something else.",
|
|
||||||
ephemeral: true).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CmdHelp(SocketSlashCommand arg) {
|
|
||||||
var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version!.ToString(3);
|
|
||||||
var guildct = _instance.DiscordClient.Guilds.Count;
|
|
||||||
var uniquetz = await _database.GetDistinctZoneCountAsync();
|
|
||||||
await arg.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 = _instance.DiscordClient.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());
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CmdList(SocketSlashCommand arg) {
|
|
||||||
if (arg.Channel is not SocketGuildChannel gc) {
|
|
||||||
await arg.RespondAsync(ErrGuildOnly).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (arg.Data.Options.FirstOrDefault()?.Value is SocketGuildUser parameter) {
|
|
||||||
await CmdListUser(arg, parameter);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var guild = gc.Guild;
|
|
||||||
if (!await AreUsersDownloadedAsync(guild).ConfigureAwait(false)) {
|
|
||||||
await arg.RespondAsync(ErrNoUserCache, ephemeral: true).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var userlist = await _database.GetGuildZonesAsync(guild.Id).ConfigureAwait(false);
|
|
||||||
if (userlist.Count == 0) {
|
|
||||||
await arg.RespondAsync(":x: Nothing to show. " +
|
|
||||||
$"To register your time zone with the bot, use the `/set` command.").ConfigureAwait(false);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = 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 arg.RespondAsync(embed: msg).ConfigureAwait(false);
|
|
||||||
hasOutputOneLine = true;
|
|
||||||
} else {
|
|
||||||
await arg.Channel.SendMessageAsync(embed: msg).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var resultout = new StringBuilder();
|
|
||||||
foreach (var line in outputlines) {
|
|
||||||
if (resultout.Length + line.Length > MaxSingleOutputLength) {
|
|
||||||
await doOutput(new EmbedBuilder().WithDescription(resultout.ToString()).Build()).ConfigureAwait(false);
|
|
||||||
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()).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CmdListUser(SocketSlashCommand arg, SocketGuildUser parameter) {
|
|
||||||
// Not meant as a command handler - called by CmdList
|
|
||||||
var result = await _database.GetUserZoneAsync(parameter).ConfigureAwait(false);
|
|
||||||
if (result == null) {
|
|
||||||
bool isself = arg.User.Id == parameter.Id;
|
|
||||||
if (isself) await arg.RespondAsync(":x: You do not have a time zone. Set it with `tz.set`.", ephemeral: true)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
else await arg.RespondAsync(":x: The given user does not have a time zone set.", ephemeral: true).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var resulttext = TzPrint(result)[4..] + ": " + FormatName(parameter);
|
|
||||||
await arg.RespondAsync(embed: new EmbedBuilder().WithDescription(resulttext).Build()).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CmdSet(SocketSlashCommand arg) {
|
|
||||||
if (arg.Channel is not SocketGuildChannel) {
|
|
||||||
await arg.RespondAsync(ErrGuildOnly).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var input = (string)arg.Data.Options.First().Value;
|
|
||||||
input = ParseTimeZone(input);
|
|
||||||
if (input == null) {
|
|
||||||
await arg.RespondAsync(ErrInvalidZone, ephemeral: true).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await _database.UpdateUserAsync((SocketGuildUser)arg.User, input).ConfigureAwait(false);
|
|
||||||
await arg.RespondAsync($":white_check_mark: Your time zone has been set to **{input}**.").ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CmdSetFor(SocketSlashCommand arg) {
|
|
||||||
if (arg.Channel is not SocketGuildChannel) {
|
|
||||||
await arg.RespondAsync(ErrGuildOnly).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!IsUserAdmin((SocketGuildUser)arg.User)) {
|
|
||||||
await arg.RespondAsync(ErrNotAllowed, ephemeral: true).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract parameters
|
|
||||||
var opts = arg.Data.Options.ToDictionary(o => o.Name, o => o);
|
|
||||||
var user = (SocketGuildUser)opts["user"].Value;
|
|
||||||
var zone = (string)opts["zone"].Value;
|
|
||||||
|
|
||||||
var newtz = ParseTimeZone(zone);
|
|
||||||
if (newtz == null) {
|
|
||||||
await arg.RespondAsync(ErrInvalidZone).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _database.UpdateUserAsync(user, newtz).ConfigureAwait(false);
|
|
||||||
await arg.RespondAsync($":white_check_mark: Time zone for **{user}** set to **{newtz}**.").ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CmdRemove(SocketSlashCommand arg) {
|
|
||||||
if (arg.Channel is not SocketGuildChannel) {
|
|
||||||
await arg.RespondAsync(ErrGuildOnly).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var success = await _database.DeleteUserAsync((SocketGuildUser)arg.User).ConfigureAwait(false);
|
|
||||||
if (success) await arg.RespondAsync(":white_check_mark: Your zone has been removed.").ConfigureAwait(false);
|
|
||||||
else await arg.RespondAsync(":x: You don't have a time zone set.").ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CmdRemoveFor(SocketSlashCommand arg) {
|
|
||||||
if (arg.Channel is not SocketGuildChannel) {
|
|
||||||
await arg.RespondAsync(ErrGuildOnly).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!IsUserAdmin((SocketGuildUser)arg.User)) {
|
|
||||||
await arg.RespondAsync(ErrNotAllowed, ephemeral: true).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
18
Database.cs
18
Database.cs
|
@ -6,13 +6,13 @@ namespace WorldTime;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Database abstractions
|
/// Database abstractions
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal class Database {
|
public class Database {
|
||||||
private const string UserDatabase = "userdata";
|
private const string UserDatabase = "userdata";
|
||||||
private const string CutoffInterval = "INTERVAL '30 days'"; // TODO make configurable?
|
private const string CutoffInterval = "INTERVAL '30 days'"; // TODO make configurable?
|
||||||
|
|
||||||
private readonly string _connectionString;
|
private readonly string _connectionString;
|
||||||
|
|
||||||
public Database(string connectionString) {
|
internal Database(string connectionString) {
|
||||||
_connectionString = connectionString;
|
_connectionString = connectionString;
|
||||||
DoInitialDatabaseSetupAsync().ConfigureAwait(false).GetAwaiter().GetResult();
|
DoInitialDatabaseSetupAsync().ConfigureAwait(false).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
@ -42,7 +42,7 @@ internal class Database {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if a given guild contains at least one user data entry with recent enough activity.
|
/// Checks if a given guild contains at least one user data entry with recent enough activity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<bool> HasAnyAsync(SocketGuild guild) {
|
internal async Task<bool> HasAnyAsync(SocketGuild guild) {
|
||||||
using var db = await OpenConnectionAsync().ConfigureAwait(false);
|
using var db = await OpenConnectionAsync().ConfigureAwait(false);
|
||||||
using var c = db.CreateCommand();
|
using var c = db.CreateCommand();
|
||||||
c.CommandText = $@"
|
c.CommandText = $@"
|
||||||
|
@ -61,7 +61,7 @@ LIMIT 1
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the number of unique time zones in the database.
|
/// Gets the number of unique time zones in the database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<int> GetDistinctZoneCountAsync() {
|
internal async Task<int> GetDistinctZoneCountAsync() {
|
||||||
using var db = await OpenConnectionAsync().ConfigureAwait(false);
|
using var db = await OpenConnectionAsync().ConfigureAwait(false);
|
||||||
using var c = db.CreateCommand();
|
using var c = db.CreateCommand();
|
||||||
c.CommandText = $"SELECT COUNT(DISTINCT zone) FROM {UserDatabase}";
|
c.CommandText = $"SELECT COUNT(DISTINCT zone) FROM {UserDatabase}";
|
||||||
|
@ -72,7 +72,7 @@ LIMIT 1
|
||||||
/// Updates the last activity field for the specified guild user, if existing in the database.
|
/// Updates the last activity field for the specified guild user, if existing in the database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>True if a value was updated, implying that the specified user exists in the database.</returns>
|
/// <returns>True if a value was updated, implying that the specified user exists in the database.</returns>
|
||||||
public async Task<bool> UpdateLastActivityAsync(SocketGuildUser user) {
|
internal async Task<bool> UpdateLastActivityAsync(SocketGuildUser user) {
|
||||||
using var db = await OpenConnectionAsync().ConfigureAwait(false);
|
using var db = await OpenConnectionAsync().ConfigureAwait(false);
|
||||||
using var c = db.CreateCommand();
|
using var c = db.CreateCommand();
|
||||||
c.CommandText = $"UPDATE {UserDatabase} SET last_active = now() " +
|
c.CommandText = $"UPDATE {UserDatabase} SET last_active = now() " +
|
||||||
|
@ -89,7 +89,7 @@ LIMIT 1
|
||||||
/// Removes the specified user from the database.
|
/// Removes the specified user from the database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>True if the removal was successful. False typically if the user did not exist.</returns>
|
/// <returns>True if the removal was successful. False typically if the user did not exist.</returns>
|
||||||
public async Task<bool> DeleteUserAsync(SocketGuildUser user) {
|
internal async Task<bool> DeleteUserAsync(SocketGuildUser user) {
|
||||||
using var db = await OpenConnectionAsync().ConfigureAwait(false);
|
using var db = await OpenConnectionAsync().ConfigureAwait(false);
|
||||||
using var c = db.CreateCommand();
|
using var c = db.CreateCommand();
|
||||||
c.CommandText = $"DELETE FROM {UserDatabase} " +
|
c.CommandText = $"DELETE FROM {UserDatabase} " +
|
||||||
|
@ -103,7 +103,7 @@ LIMIT 1
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Inserts/updates the specified user in the database.
|
/// Inserts/updates the specified user in the database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task UpdateUserAsync(SocketGuildUser user, string timezone) {
|
internal async Task UpdateUserAsync(SocketGuildUser user, string timezone) {
|
||||||
using var db = await OpenConnectionAsync().ConfigureAwait(false);
|
using var db = await OpenConnectionAsync().ConfigureAwait(false);
|
||||||
using var c = db.CreateCommand();
|
using var c = db.CreateCommand();
|
||||||
c.CommandText = $"INSERT INTO {UserDatabase} (guild_id, user_id, zone) " +
|
c.CommandText = $"INSERT INTO {UserDatabase} (guild_id, user_id, zone) " +
|
||||||
|
@ -120,7 +120,7 @@ LIMIT 1
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves the time zone name of a single user.
|
/// Retrieves the time zone name of a single user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<string?> GetUserZoneAsync(SocketGuildUser user) {
|
internal async Task<string?> GetUserZoneAsync(SocketGuildUser user) {
|
||||||
using var db = await OpenConnectionAsync().ConfigureAwait(false);
|
using var db = await OpenConnectionAsync().ConfigureAwait(false);
|
||||||
using var c = db.CreateCommand();
|
using var c = db.CreateCommand();
|
||||||
c.CommandText = $"SELECT zone FROM {UserDatabase} " +
|
c.CommandText = $"SELECT zone FROM {UserDatabase} " +
|
||||||
|
@ -138,7 +138,7 @@ LIMIT 1
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// An unsorted dictionary. Keys are time zones, values are user IDs representative of those zones.
|
/// An unsorted dictionary. Keys are time zones, values are user IDs representative of those zones.
|
||||||
/// </returns>
|
/// </returns>
|
||||||
public async Task<Dictionary<string, List<ulong>>> GetGuildZonesAsync(ulong guildId) {
|
internal async Task<Dictionary<string, List<ulong>>> GetGuildZonesAsync(ulong guildId) {
|
||||||
using var db = await OpenConnectionAsync().ConfigureAwait(false);
|
using var db = await OpenConnectionAsync().ConfigureAwait(false);
|
||||||
using var c = db.CreateCommand();
|
using var c = db.CreateCommand();
|
||||||
c.CommandText = $@" -- Simpler query than 1.x; most filtering is now done by caller
|
c.CommandText = $@" -- Simpler query than 1.x; most filtering is now done by caller
|
||||||
|
|
15
RequireGuildContextAttribute.cs
Normal file
15
RequireGuildContextAttribute.cs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
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) { }
|
||||||
|
}
|
97
WorldTime.cs
97
WorldTime.cs
|
@ -1,5 +1,8 @@
|
||||||
global using Discord;
|
global using Discord;
|
||||||
global using Discord.WebSocket;
|
global using Discord.WebSocket;
|
||||||
|
using Discord.Interactions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using System.Reflection;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace WorldTime;
|
namespace WorldTime;
|
||||||
|
@ -25,31 +28,38 @@ internal class WorldTime : IDisposable {
|
||||||
|
|
||||||
private readonly Task _statusTask;
|
private readonly Task _statusTask;
|
||||||
private readonly CancellationTokenSource _mainCancel;
|
private readonly CancellationTokenSource _mainCancel;
|
||||||
private readonly CommandsSlash _commands;
|
|
||||||
private readonly CommandsText _commandsTxt;
|
private readonly CommandsText _commandsTxt;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
|
||||||
internal Configuration Config { get; }
|
internal Configuration Config { get; }
|
||||||
internal DiscordShardedClient DiscordClient { get; }
|
internal DiscordShardedClient DiscordClient => _services.GetRequiredService<DiscordShardedClient>();
|
||||||
internal Database Database { get; }
|
internal Database Database => _services.GetRequiredService<Database>();
|
||||||
|
|
||||||
public WorldTime(Configuration cfg, Database d) {
|
public WorldTime(Configuration cfg, Database d) {
|
||||||
var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
|
var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
|
||||||
Program.Log(nameof(WorldTime), $"Version {ver!.ToString(3)} is starting...");
|
Program.Log(nameof(WorldTime), $"Version {ver!.ToString(3)} is starting...");
|
||||||
|
|
||||||
Config = cfg;
|
Config = cfg;
|
||||||
Database = d;
|
|
||||||
|
|
||||||
// Configure client
|
// Configure client, set up command handling
|
||||||
DiscordClient = new DiscordShardedClient(new DiscordSocketConfig() {
|
var clientConf = new DiscordSocketConfig() {
|
||||||
LogLevel = LogSeverity.Info,
|
LogLevel = LogSeverity.Info,
|
||||||
DefaultRetryMode = RetryMode.RetryRatelimit,
|
DefaultRetryMode = RetryMode.RetryRatelimit,
|
||||||
MessageCacheSize = 0, // disable message cache
|
MessageCacheSize = 0, // disable message cache
|
||||||
GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages
|
GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages
|
||||||
});
|
};
|
||||||
|
_services = new ServiceCollection()
|
||||||
|
.AddSingleton(new DiscordShardedClient(clientConf))
|
||||||
|
.AddSingleton(s => new InteractionService(s.GetRequiredService<DiscordShardedClient>()))
|
||||||
|
.AddSingleton(d)
|
||||||
|
.BuildServiceProvider();
|
||||||
DiscordClient.Log += DiscordClient_Log;
|
DiscordClient.Log += DiscordClient_Log;
|
||||||
DiscordClient.ShardReady += DiscordClient_ShardReady;
|
DiscordClient.ShardReady += DiscordClient_ShardReady;
|
||||||
DiscordClient.MessageReceived += DiscordClient_MessageReceived;
|
DiscordClient.MessageReceived += DiscordClient_MessageReceived;
|
||||||
_commands = new CommandsSlash(this, Database);
|
var iasrv = _services.GetRequiredService<InteractionService>();
|
||||||
|
DiscordClient.InteractionCreated += DiscordClient_InteractionCreated;
|
||||||
|
iasrv.SlashCommandExecuted += InteractionService_SlashCommandExecuted;
|
||||||
|
|
||||||
_commandsTxt = new CommandsText(this, Database);
|
_commandsTxt = new CommandsText(this, Database);
|
||||||
|
|
||||||
// Start status reporting thread
|
// Start status reporting thread
|
||||||
|
@ -59,6 +69,7 @@ internal class WorldTime : IDisposable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StartAsync() {
|
public async Task StartAsync() {
|
||||||
|
await _services.GetRequiredService<InteractionService>().AddModulesAsync(Assembly.GetExecutingAssembly(), _services);
|
||||||
await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken).ConfigureAwait(false);
|
await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken).ConfigureAwait(false);
|
||||||
await DiscordClient.StartAsync().ConfigureAwait(false);
|
await DiscordClient.StartAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
@ -148,7 +159,25 @@ internal class WorldTime : IDisposable {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task DiscordClient_ShardReady(DiscordSocketClient arg) => arg.SetGameAsync("/help");
|
private async Task DiscordClient_ShardReady(DiscordSocketClient arg) {
|
||||||
|
// TODO get rid of this eventually? or change it to something fun...
|
||||||
|
await arg.SetGameAsync("/help");
|
||||||
|
|
||||||
|
#if !DEBUG
|
||||||
|
// Update slash/interaction commands
|
||||||
|
if (arg.ShardId == 0) {
|
||||||
|
await _services.GetRequiredService<InteractionService>().RegisterCommandsGloballyAsync();
|
||||||
|
Program.Log("Command registration", "Updated global command registration.");
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
// Debug: Register our commands locally instead, in each guild we're in
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Non-specific handler for incoming events.
|
/// Non-specific handler for incoming events.
|
||||||
|
@ -178,5 +207,55 @@ internal class WorldTime : IDisposable {
|
||||||
await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false);
|
await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const string InternalError = ":x: An unknown error occurred. If it persists, please notify the bot owner.";
|
||||||
|
|
||||||
|
// Slash command preparation and invocation
|
||||||
|
private async Task DiscordClient_InteractionCreated(SocketInteraction arg) {
|
||||||
|
var context = new ShardedInteractionContext(DiscordClient, arg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _services.GetRequiredService<InteractionService>().ExecuteCommandAsync(context, _services);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Program.Log(nameof(DiscordClient_InteractionCreated), $"Unhandled exception. {ex}");
|
||||||
|
if (arg.Type == InteractionType.ApplicationCommand) {
|
||||||
|
if (arg.HasResponded) await arg.ModifyOriginalResponseAsync(prop => prop.Content = InternalError);
|
||||||
|
else await arg.RespondAsync(InternalError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slash command logging and failed execution handling
|
||||||
|
private static async Task InteractionService_SlashCommandExecuted(SlashCommandInfo info, IInteractionContext context, IResult result) {
|
||||||
|
string sender;
|
||||||
|
if (context.Guild != null) {
|
||||||
|
sender = $"{context.Guild}!{context.User}";
|
||||||
|
} else {
|
||||||
|
sender = $"{context.User} in non-guild context";
|
||||||
|
}
|
||||||
|
var logresult = $"{(result.IsSuccess ? "Success" : "Fail")}: `/{info}` by {sender}.";
|
||||||
|
|
||||||
|
if (result.Error != null) {
|
||||||
|
// Additional log information with error detail
|
||||||
|
logresult += " " + Enum.GetName(typeof(InteractionCommandError), result.Error) + ": " + result.ErrorReason;
|
||||||
|
|
||||||
|
// Specific responses to errors, if necessary
|
||||||
|
if (result.Error == InteractionCommandError.UnmetPrecondition) {
|
||||||
|
string errReply = result.ErrorReason switch {
|
||||||
|
RequireGuildContextAttribute.Error => RequireGuildContextAttribute.Reply,
|
||||||
|
_ => result.ErrorReason
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
}
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,10 +11,11 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CommandLineParser" Version="2.8.0" />
|
<PackageReference Include="CommandLineParser" Version="2.8.0" />
|
||||||
<PackageReference Include="Discord.Net" Version="3.4.1" />
|
<PackageReference Include="Discord.Net" Version="3.6.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||||
<PackageReference Include="NodaTime" Version="3.0.10" />
|
<PackageReference Include="NodaTime" Version="3.1.0" />
|
||||||
<PackageReference Include="Npgsql" Version="6.0.3" />
|
<PackageReference Include="Npgsql" Version="6.0.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
Loading…
Reference in a new issue