Merge pull request #9 from NoiTheCat/dev/interactionframework

Switch commands to interaction framework
This commit is contained in:
Noi 2022-06-20 17:21:59 -07:00 committed by GitHub
commit 3fb4647d29
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 591 additions and 483 deletions

26
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,26 @@
{
"version": "0.2.0",
"configurations": [
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/bin/Debug/net6.0/WorldTime.dll",
"args": [],
"cwd": "${workspaceFolder}",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
"console": "internalConsole",
"stopAtEntry": false
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}

41
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/WorldTime.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/WorldTime.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/WorldTime.csproj"
],
"problemMatcher": "$msCompile"
}
]
}

292
ApplicationCommands.cs Normal file
View 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((SocketGuildUser)Context.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((SocketGuildUser)Context.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
}

View file

@ -1,121 +0,0 @@
using NodaTime;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Text;
namespace WorldTime;
internal abstract class CommandsCommon {
protected readonly Database _database;
protected readonly WorldTime _instance;
private static readonly ReadOnlyDictionary<string, string> _tzNameMap;
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 ErrTargetUserNotFound = ":x: Unable to find the target user.";
protected const string ErrNoUserCache = ":warning: Please try the command again.";
protected const int MaxSingleLineLength = 750;
protected const int MaxSingleOutputLength = 900;
static CommandsCommon() {
Dictionary<string, string> tzNameMap = new(StringComparer.OrdinalIgnoreCase);
foreach (var name in DateTimeZoneProviders.Tzdb.Ids) tzNameMap.Add(name, name);
_tzNameMap = new(tzNameMap);
}
public CommandsCommon(Database database, WorldTime instance) {
_database = database;
_instance = instance;
}
/// <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>
protected 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>
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 given user can be considered a guild admin ('Manage Server' is set).
/// </summary>
protected static bool IsUserAdmin(SocketGuildUser user)
=> user.GuildPermissions.Administrator || user.GuildPermissions.ManageGuild;
// TODO port modrole feature from BB, implement in here
/// <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.
/// </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) {
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;
}
}
/// <summary>
/// An alternative to <see cref="SocketGuild.HasAllMembers"/>.
/// Returns true if *most* members have been downloaded.
/// Used as a workaround check due to Discord.Net occasionally unable to actually download all members.
/// </summary>
/// <remarks>Copied directly from BirthdayBot. Try to coordinate changes between projects...</remarks>
private 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;
}
}
}

View file

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

View file

@ -1,9 +1,12 @@
using System.Text;
using NodaTime;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace WorldTime;
internal class CommandsText : CommandsCommon {
internal class CommandsText {
#if DEBUG
public const string CommandPrefix = "tt.";
#else
@ -12,16 +15,32 @@ internal class CommandsText : CommandsCommon {
delegate Task Command(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message);
private readonly Dictionary<string, Command> _commands;
private readonly Database _database;
private readonly WorldTime _instance;
private static readonly Regex _userExplicit;
private static readonly Regex _userMention;
private static readonly ReadOnlyDictionary<string, string> _tzNameMap;
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 ErrTargetUserNotFound = ":x: Unable to find the target user.";
private const string ErrNoUserCache = ":warning: Please try the command again.";
private const int MaxSingleLineLength = 750;
private const int MaxSingleOutputLength = 900;
static CommandsText() {
_userExplicit = new Regex(@"(.+)#(\d{4})", RegexOptions.Compiled);
_userMention = new Regex(@"\!?(\d+)>", RegexOptions.Compiled);
Dictionary<string, string> tzNameMap = new(StringComparer.OrdinalIgnoreCase);
foreach (var name in DateTimeZoneProviders.Tzdb.Ids) tzNameMap.Add(name, name);
_tzNameMap = new(tzNameMap);
}
public CommandsText(WorldTime inst, Database db) : base(db, inst) {
public CommandsText(WorldTime inst, Database db) {
_instance = inst;
_database = db;
_commands = new(StringComparer.OrdinalIgnoreCase) {
{ "help", CmdHelp },
{ "list", CmdList },
@ -274,4 +293,96 @@ internal class CommandsText : CommandsCommon {
return null;
}
#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>
private static bool IsUserAdmin(SocketGuildUser user)
=> user.GuildPermissions.Administrator || user.GuildPermissions.ManageGuild;
// TODO port modrole feature from BB, implement in here
/// <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.
/// </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) {
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;
}
}
/// <summary>
/// An alternative to <see cref="SocketGuild.HasAllMembers"/>.
/// Returns true if *most* members have been downloaded.
/// Used as a workaround check due to Discord.Net occasionally unable to actually download all members.
/// </summary>
/// <remarks>Copied directly from BirthdayBot. Try to coordinate changes between projects...</remarks>
private 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;
}
}
#endregion
}

View file

@ -6,13 +6,13 @@ namespace WorldTime;
/// <summary>
/// Database abstractions
/// </summary>
internal class Database {
public class Database {
private const string UserDatabase = "userdata";
private const string CutoffInterval = "INTERVAL '30 days'"; // TODO make configurable?
private readonly string _connectionString;
public Database(string connectionString) {
internal Database(string connectionString) {
_connectionString = connectionString;
DoInitialDatabaseSetupAsync().ConfigureAwait(false).GetAwaiter().GetResult();
}
@ -42,7 +42,7 @@ internal class Database {
/// <summary>
/// Checks if a given guild contains at least one user data entry with recent enough activity.
/// </summary>
public async Task<bool> HasAnyAsync(SocketGuild guild) {
internal async Task<bool> HasAnyAsync(SocketGuild guild) {
using var db = await OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
c.CommandText = $@"
@ -61,7 +61,7 @@ LIMIT 1
/// <summary>
/// Gets the number of unique time zones in the database.
/// </summary>
public async Task<int> GetDistinctZoneCountAsync() {
internal async Task<int> GetDistinctZoneCountAsync() {
using var db = await OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
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.
/// </summary>
/// <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 c = db.CreateCommand();
c.CommandText = $"UPDATE {UserDatabase} SET last_active = now() " +
@ -89,7 +89,7 @@ LIMIT 1
/// Removes the specified user from the database.
/// </summary>
/// <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 c = db.CreateCommand();
c.CommandText = $"DELETE FROM {UserDatabase} " +
@ -103,7 +103,7 @@ LIMIT 1
/// <summary>
/// Inserts/updates the specified user in the database.
/// </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 c = db.CreateCommand();
c.CommandText = $"INSERT INTO {UserDatabase} (guild_id, user_id, zone) " +
@ -120,7 +120,7 @@ LIMIT 1
/// <summary>
/// Retrieves the time zone name of a single user.
/// </summary>
public async Task<string?> GetUserZoneAsync(SocketGuildUser user) {
internal async Task<string?> GetUserZoneAsync(SocketGuildUser user) {
using var db = await OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
c.CommandText = $"SELECT zone FROM {UserDatabase} " +
@ -138,7 +138,7 @@ LIMIT 1
/// <returns>
/// An unsorted dictionary. Keys are time zones, values are user IDs representative of those zones.
/// </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 c = db.CreateCommand();
c.CommandText = $@" -- Simpler query than 1.x; most filtering is now done by caller

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018-2021 Noi <noithecat AT protonmail.com>
Copyright (c) 2018-2022 Noi <noithecat AT protonmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>bin\Release\net6.0\publish\</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net6.0</TargetFramework>
<SelfContained>false</SelfContained>
</PropertyGroup>
</Project>

View 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) { }
}

View file

@ -1,5 +1,8 @@
global using Discord;
global using Discord.WebSocket;
using Discord.Interactions;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
using System.Text;
namespace WorldTime;
@ -25,31 +28,38 @@ internal class WorldTime : IDisposable {
private readonly Task _statusTask;
private readonly CancellationTokenSource _mainCancel;
private readonly CommandsSlash _commands;
private readonly CommandsText _commandsTxt;
private readonly IServiceProvider _services;
internal Configuration Config { get; }
internal DiscordShardedClient DiscordClient { get; }
internal Database Database { get; }
internal DiscordShardedClient DiscordClient => _services.GetRequiredService<DiscordShardedClient>();
internal Database Database => _services.GetRequiredService<Database>();
public WorldTime(Configuration cfg, Database d) {
var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
Program.Log(nameof(WorldTime), $"Version {ver!.ToString(3)} is starting...");
Config = cfg;
Database = d;
// Configure client
DiscordClient = new DiscordShardedClient(new DiscordSocketConfig() {
// Configure client, set up command handling
var clientConf = new DiscordSocketConfig() {
LogLevel = LogSeverity.Info,
DefaultRetryMode = RetryMode.RetryRatelimit,
MessageCacheSize = 0, // disable message cache
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.ShardReady += DiscordClient_ShardReady;
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);
// Start status reporting thread
@ -59,6 +69,7 @@ internal class WorldTime : IDisposable {
}
public async Task StartAsync() {
await _services.GetRequiredService<InteractionService>().AddModulesAsync(Assembly.GetExecutingAssembly(), _services);
await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken).ConfigureAwait(false);
await DiscordClient.StartAsync().ConfigureAwait(false);
}
@ -148,7 +159,25 @@ internal class WorldTime : IDisposable {
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>
/// Non-specific handler for incoming events.
@ -178,5 +207,55 @@ internal class WorldTime : IDisposable {
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
}

View file

@ -11,10 +11,11 @@
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.8.0" />
<PackageReference Include="Discord.Net" Version="3.4.1" />
<PackageReference Include="Discord.Net" Version="3.5.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="NodaTime" Version="3.0.10" />
<PackageReference Include="Npgsql" Version="6.0.3" />
<PackageReference Include="NodaTime" Version="3.1.0" />
<PackageReference Include="Npgsql" Version="6.0.4" />
</ItemGroup>
</Project>

View file

@ -1,25 +0,0 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31808.319
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorldTime", "WorldTime.csproj", "{537893B9-CD6C-4279-BE26-3A227354D80F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{537893B9-CD6C-4279-BE26-3A227354D80F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{537893B9-CD6C-4279-BE26-3A227354D80F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{537893B9-CD6C-4279-BE26-3A227354D80F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{537893B9-CD6C-4279-BE26-3A227354D80F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {750679F1-393A-4CBD-B856-A28BABC68ECF}
EndGlobalSection
EndGlobal