mirror of
https://github.com/NoiTheCat/WorldTime.git
synced 2024-11-25 01:44:12 +00:00
408 lines
No EOL
15 KiB
C#
408 lines
No EOL
15 KiB
C#
using Discord.Interactions;
|
|
using NodaTime;
|
|
using System.Collections.ObjectModel;
|
|
using System.Globalization;
|
|
using System.Text;
|
|
using WorldTime.Data;
|
|
|
|
namespace WorldTime.Commands;
|
|
public class CommandsBase : InteractionModuleBase<ShardedInteractionContext> {
|
|
protected const string ErrInvalidZone = ":x: Not a valid zone name."
|
|
+ " To find your time zone, refer to: <https://kevinnovak.github.io/Time-Zone-Picker/>.";
|
|
protected const string ErrNoUserCache = ":warning: Please try the command again.";
|
|
protected const string ErrNotAllowed = ":x: Only server moderators may use this command.";
|
|
|
|
private static readonly ReadOnlyDictionary<string, string> _tzNameMap;
|
|
|
|
static CommandsBase() {
|
|
Dictionary<string, string> tzNameMap = new(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var name in DateTimeZoneProviders.Tzdb.Ids) tzNameMap.Add(name, name);
|
|
_tzNameMap = new(tzNameMap);
|
|
}
|
|
|
|
public DiscordShardedClient ShardedClient { get; set; } = null!;
|
|
public BotDatabaseContext DbContext { get; set; } = null!;
|
|
|
|
/// <summary>
|
|
/// Returns a string displaying the current time in the given time zone.
|
|
/// The result begins with 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 (!_tzNameMap.TryGetValue(tzinput, out tzinput!)) return null;
|
|
|
|
// We directly convert between -some- aliases in an effort to clean up the list display,
|
|
// inadvertently displaying the same region in two separate areas and causing confusion
|
|
// or otherwise replacing much longer names with shorter aliases.
|
|
// Source: https://nodatime.org/TimeZones. Version referenced: 2022g.
|
|
string name;
|
|
switch (tzinput) {
|
|
case "Iceland":
|
|
name = "Atlantic/Reykjavik";
|
|
break;
|
|
case "Egypt":
|
|
name = "Africa/Cairo";
|
|
break;
|
|
case "Libya":
|
|
name = "Africa/Tripoli";
|
|
break;
|
|
case "America/Atka":
|
|
case "US/Aleutian":
|
|
name = "America/Adak";
|
|
break;
|
|
case "US/Alaska":
|
|
name = "America/Anchorage";
|
|
break;
|
|
case "America/Argentina/Buenos_Aires":
|
|
name = "America/Buenos_Aires";
|
|
break;
|
|
case "America/Argentina/Catamarca":
|
|
case "America/Argentina/ComodRivadavia":
|
|
name = "America/Catamarca";
|
|
break;
|
|
case "America/Argentina/Cordoba":
|
|
case "America/Rosario":
|
|
name = "America/Cordoba";
|
|
break;
|
|
case "America/Argentina/Jujuy":
|
|
name = "America/Jujuy";
|
|
break;
|
|
case "America/Argentina/Mendoza":
|
|
name = "America/Mendoza";
|
|
break;
|
|
case "US/Central":
|
|
name = "America/Chicago";
|
|
break;
|
|
case "America/Shiprock":
|
|
case "Navajo":
|
|
case "US/Mountain":
|
|
name = "America/Denver";
|
|
break;
|
|
case "US/Michigan":
|
|
name = "America/Detroit";
|
|
break;
|
|
case "Canada/Mountain":
|
|
name = "America/Edmonton";
|
|
break;
|
|
case "Canada/Atlantic":
|
|
name = "America/Halifax";
|
|
break;
|
|
case "Cuba":
|
|
name = "America/Havana";
|
|
break;
|
|
case "America/Fort_Wayne":
|
|
case "America/Indianapolis":
|
|
case "US/East-Indiana":
|
|
name = "America/Indiana/Indanapolis";
|
|
break;
|
|
case "America/Knox_IN":
|
|
case "US/Indiana-Starke":
|
|
name = "America/Indiana/Knox";
|
|
break;
|
|
case "America/Pangnirtung":
|
|
name = "America/Iqaluit";
|
|
break;
|
|
case "Jamaica":
|
|
name = "America/Jamaica";
|
|
break;
|
|
case "America/Kentucky/Louisville":
|
|
name = "America/Louisville";
|
|
break;
|
|
case "US/Pacific":
|
|
name = "America/Los_Angeles";
|
|
break;
|
|
case "Brazil/West":
|
|
name = "America/Manaus";
|
|
break;
|
|
case "Mexico/BajaSur":
|
|
name = "America/Mazatlan";
|
|
break;
|
|
case "Mexico/General":
|
|
name = "America/Mexico_City";
|
|
break;
|
|
case "US/Eastern":
|
|
name = "America/New_York";
|
|
break;
|
|
case "Brazil/DeNoronha":
|
|
name = "America/Noronha";
|
|
break;
|
|
case "America/Godthab":
|
|
name = "America/Nuuk";
|
|
break;
|
|
case "America/Creston":
|
|
case "US/Arizona":
|
|
name = "America/Phoenix";
|
|
break;
|
|
case "Canada/Saskatchewan":
|
|
name = "America/Regina";
|
|
break;
|
|
case "America/Porto_Acre":
|
|
case "Brazil/Acre":
|
|
name = "America/Rio_Branco";
|
|
break;
|
|
case "Chile/Continental":
|
|
name = "America/Santiago";
|
|
break;
|
|
case "Brazil/East":
|
|
name = "America/Sao_Paulo";
|
|
break;
|
|
case "Canada/Newfoundland":
|
|
name = "America/St_Johns";
|
|
break;
|
|
case "America/Ensenada":
|
|
case "America/Santa_Isabel":
|
|
case "Mexico/BajaNorte":
|
|
name = "America/Tijuana";
|
|
break;
|
|
case "America/Montreal":
|
|
case "America/Nipigon":
|
|
case "America/Thunder_Bay":
|
|
case "Canada/Eastern":
|
|
name = "America/Toronto";
|
|
break;
|
|
case "Canada/Pacific":
|
|
name = "America/Vancouver";
|
|
break;
|
|
case "Canada/Yukon":
|
|
name = "America/Whitehorse";
|
|
break;
|
|
case "America/Rainy_River":
|
|
case "Canada/Central":
|
|
name = "America/Winnipeg";
|
|
break;
|
|
case "Asia/Ashkhabad":
|
|
name = "Asia/Ashgabat";
|
|
break;
|
|
case "Asia/Dacca":
|
|
name = "Asia/Dhaka";
|
|
break;
|
|
case "Asia/Saigon":
|
|
name = "Asia/Ho_Chi_Minh";
|
|
break;
|
|
case "Hongkong":
|
|
name = "Asia/Hong_Kong";
|
|
break;
|
|
case "Asia/Tel_Aviv":
|
|
case "Israel":
|
|
name = "Asia/Jerusalem";
|
|
break;
|
|
case "Asia/Katmandu":
|
|
name = "Asia/Kathmandu";
|
|
break;
|
|
case "Asia/Calcutta":
|
|
name = "Asia/Kolkata";
|
|
break;
|
|
case "Asia/Macao":
|
|
name = "Asia/Macau";
|
|
break;
|
|
case "Europe/Nicosia":
|
|
name = "Asia/Nicosia";
|
|
break;
|
|
case "ROK":
|
|
name = "Asia/Seoul";
|
|
break;
|
|
case "Asia/Chongqing":
|
|
case "Asia/Chungking":
|
|
case "Asia/Harbin":
|
|
case "PRC":
|
|
name = "Asia/Shanghai";
|
|
break;
|
|
case "Singapore":
|
|
name = "Asia/Singapore";
|
|
break;
|
|
case "ROC":
|
|
name = "Asia/Taipei";
|
|
break;
|
|
case "Iran":
|
|
name = "Asia/Tehran";
|
|
break;
|
|
case "Asia/Thimbu":
|
|
name = "Asia/Thimphu";
|
|
break;
|
|
case "Tokyo":
|
|
name = "Asia/Tokyo";
|
|
break;
|
|
case "Asia/Ulan_Bator":
|
|
name = "Asia/Ulaanbaatar";
|
|
break;
|
|
case "Asia/Rangoon":
|
|
name = "Asia/Yangon";
|
|
break;
|
|
case "Atlantic/Faeroe":
|
|
name = "Atlantic/Faroe";
|
|
break;
|
|
case "Australia/South":
|
|
name = "Australia/Adelaide";
|
|
break;
|
|
case "Australia/Queensland":
|
|
name = "Australia/Brisbane";
|
|
break;
|
|
case "Australia/Yancowinna":
|
|
name = "Australia/Brorken_Hill";
|
|
break;
|
|
case "Australia/North":
|
|
name = "Australia/Darwin";
|
|
break;
|
|
case "Australia/Currie":
|
|
case "Australia/Tasmania":
|
|
name = "Australia/Hobart";
|
|
break;
|
|
case "Australia/LHI":
|
|
name = "Australia/Lord_Howe";
|
|
break;
|
|
case "Australia/Victoria":
|
|
name = "Australia/Melbourne";
|
|
break;
|
|
case "Australia/West":
|
|
name = "Australia/Perth";
|
|
break;
|
|
case "Australia/ACT":
|
|
case "Australia/Canberra":
|
|
case "Australia/NSW":
|
|
name = "Australia/Sydney";
|
|
break;
|
|
case "Etc/Greenwich":
|
|
case "Greenwich":
|
|
name = "Etc/GMT";
|
|
break;
|
|
case "Etc/UCT":
|
|
case "Etc/Universal":
|
|
case "Etc/Zulu":
|
|
case "UCT":
|
|
case "UTC":
|
|
case "Universal":
|
|
case "Zulu":
|
|
name = "Etc/UTC";
|
|
break;
|
|
case "Eire":
|
|
name = "Europe/Dublin";
|
|
break;
|
|
case "Asia/Istanbul":
|
|
case "Turkey":
|
|
name = "Europe/Istanbul";
|
|
break;
|
|
case "Europe/Kiev":
|
|
case "Europe/Uzhgorod":
|
|
case "Europe/Zaporozhye":
|
|
name = "Europe/Kyiv";
|
|
break;
|
|
case "Portugal":
|
|
name = "Europe/Lisbon";
|
|
break;
|
|
case "Europe/Belfast":
|
|
case "GB":
|
|
case "GB-Eire":
|
|
name = "Europe/London";
|
|
break;
|
|
case "W-SU":
|
|
name = "Europe/Moscow";
|
|
break;
|
|
case "Poland":
|
|
name = "Europe/Warsaw";
|
|
break;
|
|
case "Europe/Busingen":
|
|
name = "Europe/Zurich";
|
|
break;
|
|
case "NZ":
|
|
name = "Pacific/Auckland";
|
|
break;
|
|
case "NZ-CHAT":
|
|
name = "Pacific/Chatham";
|
|
break;
|
|
case "Chile/EasterIsland":
|
|
name = "Pacific/Easter";
|
|
break;
|
|
case "US/Hawaii":
|
|
name = "Pacific/Honolulu";
|
|
break;
|
|
case "Kwajalein":
|
|
name = "Pacific/Kwajalein";
|
|
break;
|
|
case "Pacific/Samoa":
|
|
case "US/Samoa":
|
|
name = "Pacific/Pago_Pago";
|
|
break;
|
|
default:
|
|
name = tzinput;
|
|
break;
|
|
}
|
|
if (!_tzNameMap.TryGetValue(name, out _)) {
|
|
// TODO proper logging (it's not exposed, so can't do it as-is)
|
|
Console.WriteLine($"!!! Our replacement for {tzinput} (to {name}) failed! Will use original input...");
|
|
return tzinput;
|
|
}
|
|
return name;
|
|
}
|
|
|
|
/// <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>
|
|
// TODO replace this with a precondition, or there's also a new permission scheme going around?
|
|
protected 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>
|
|
protected static async Task<bool> AreUsersDownloadedAsync(SocketGuild guild) {
|
|
static bool HasMostMembersDownloaded(SocketGuild guild) {
|
|
if (guild.HasAllMembers) return true;
|
|
if (guild.MemberCount > 30) {
|
|
// For guilds of size over 30, require 85% or more of the members to be known
|
|
// (26/30, 42/50, 255/300, etc)
|
|
return guild.DownloadedMemberCount >= (int)(guild.MemberCount * 0.85);
|
|
} else {
|
|
// For smaller guilds, fail if two or more members are missing
|
|
return guild.MemberCount - guild.DownloadedMemberCount <= 2;
|
|
}
|
|
}
|
|
if (HasMostMembersDownloaded(guild)) return true;
|
|
else {
|
|
// Event handler hangs if awaited normally or used with Task.Run
|
|
await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false);
|
|
return false;
|
|
}
|
|
}
|
|
} |