diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..cde0129 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,219 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = false +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_property = false + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_operators = never_if_unnecessary +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +# Expression-level preferences +dotnet_style_coalesce_expression = true +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true +dotnet_style_namespace_match_folder = true +dotnet_style_null_propagation = true +dotnet_style_object_initializer = true +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_compound_assignment = true +dotnet_style_prefer_conditional_expression_over_assignment = true +dotnet_style_prefer_conditional_expression_over_return = true +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true + +# Field preferences +dotnet_style_readonly_field = true + +# Parameter preferences +dotnet_code_quality_unused_parameters = all + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +# New line preferences +dotnet_style_allow_multiple_blank_lines_experimental = false +dotnet_style_allow_statement_immediately_after_block_experimental = true + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = false +csharp_style_var_for_built_in_types = false +csharp_style_var_when_type_is_apparent = false + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true +csharp_style_expression_bodied_constructors = true +csharp_style_expression_bodied_indexers = true +csharp_style_expression_bodied_lambdas = true +csharp_style_expression_bodied_local_functions = true +csharp_style_expression_bodied_methods = true +csharp_style_expression_bodied_operators = true +csharp_style_expression_bodied_properties = true + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_not_pattern = true +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_prefer_switch_expression = true + +# Null-checking preferences +csharp_style_conditional_delegate_call = true + +# Modifier preferences +csharp_prefer_static_local_function = true +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async + +# Code-block preferences +csharp_prefer_braces = when_multiline +csharp_prefer_simple_using_statement = true +csharp_style_namespace_declarations = file_scoped + +# Expression-level preferences +csharp_prefer_simple_default_expression = true +csharp_style_deconstructed_variable_declaration = true +csharp_style_implicit_object_creation_when_type_is_apparent = true +csharp_style_inlined_variable_declaration = true +csharp_style_pattern_local_over_anonymous_function = true +csharp_style_prefer_index_operator = true +csharp_style_prefer_null_check_over_type_check = true +csharp_style_prefer_range_operator = true +csharp_style_throw_expression = true +csharp_style_unused_value_assignment_preference = discard_variable +csharp_style_unused_value_expression_statement_preference = discard_variable + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace + +# New line preferences +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false +csharp_style_allow_embedded_statements_on_same_line_experimental = true + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = false +csharp_new_line_before_else = false +csharp_new_line_before_finally = false +csharp_new_line_before_members_in_anonymous_types = false +csharp_new_line_before_members_in_object_initializers = false +csharp_new_line_before_open_brace = none +csharp_new_line_between_query_expression_clauses = false + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = flush_left +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case diff --git a/.gitignore b/.gitignore index 56387c1..e04f816 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ -users.db -__pycache__ -settings.py \ No newline at end of file +[Bb]in/ +[Oo]bj/ +.vs/ +*.user \ No newline at end of file diff --git a/Commands.cs b/Commands.cs new file mode 100644 index 0000000..08718f3 --- /dev/null +++ b/Commands.cs @@ -0,0 +1,337 @@ +using NodaTime; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace WorldTime; + +internal class Commands { +#if DEBUG + public const string CommandPrefix = "tt."; +#else + public const string CommandPrefix = "tz."; +#endif + const string ErrInvalidZone = ":x: Not a valid zone name." + + " To find your time zone, refer to: ."; + const string ErrTargetUserNotFound = ":x: Unable to find the target user."; + const int MaxSingleLineLength = 750; + const int MaxSingleOutputLength = 900; + + delegate Task Command(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message); + + private readonly Dictionary _commands; + private readonly Database _database; + private readonly WorldTime _instance; + private static readonly ReadOnlyDictionary _tzNameMap; + private static readonly Regex _userExplicit; + private static readonly Regex _userMention; + + static Commands() { + Dictionary tzNameMap = new(StringComparer.OrdinalIgnoreCase); + foreach (var name in DateTimeZoneProviders.Tzdb.Ids) tzNameMap.Add(name, name); + _tzNameMap = new(tzNameMap); + _userExplicit = new Regex(@"(.+)#(\d{4})", RegexOptions.Compiled); + _userMention = new Regex(@"\!?(\d+)>", RegexOptions.Compiled); + } + + public Commands(WorldTime inst, Database db) { + _instance = inst; + _database = db; + + _commands = new(StringComparer.OrdinalIgnoreCase) { + { "help", CmdHelp }, + { "list", CmdList }, + { "set", CmdSet }, + { "remove", CmdRemove }, + { "setfor", CmdSetFor }, + { "removefor", CmdRemoveFor } + }; + + inst.DiscordClient.MessageReceived += CommandDispatch; + } + + private async Task CommandDispatch(SocketMessage message) { + if (message.Author.IsBot || message.Author.IsWebhook) return; + if (message.Type != MessageType.Default) return; + if (message.Channel is not SocketTextChannel channel) return; // not handling DMs + + var msgsplit = message.Content.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (msgsplit.Length == 0 || msgsplit[0].Length < 4) return; + if (msgsplit[0].StartsWith(CommandPrefix, StringComparison.OrdinalIgnoreCase)) { // TODO add support for multiple prefixes? + var cmdBase = msgsplit[0][3..]; + if (_commands.ContainsKey(cmdBase)) { + Program.Log("Command invoked", $"{channel.Guild.Name}/{message.Author} {message.Content}"); + try { + await _commands[cmdBase](channel, (SocketGuildUser)message.Author, message).ConfigureAwait(false); + } catch (Exception ex) { + Program.Log("Command invoked", ex.ToString()); + } + } + } + } + + private async Task CmdHelp(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message) { + var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version!.ToString(3); + var guildct = _instance.DiscordClient.Guilds.Count; + var uniquetz = await _database.GetDistinctZoneCountAsync(); + await channel.SendMessageAsync(embed: new EmbedBuilder() { + Color = new Color(0xe0f2f7), + 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: + $"`{CommandPrefix}help` - This message.\n" + + $"`{CommandPrefix}list` - Displays current times for all recently active known users.\n" + + $"`{CommandPrefix}list [user]` - Displays the current time for the given *user*.\n" + + $"`{CommandPrefix}set [zone]` - Registers or updates your *zone* with the bot.\n" + + $"`{CommandPrefix}remove` - Removes your name from this bot." + ).AddField(inline: false, name: "Admin commands", value: + $"`{CommandPrefix}setFor [user] [zone]` - Sets the time zone for another user.\n" + + $"`{CommandPrefix}removeFor [user]` - Removes another user's information." + ).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(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message) { + var wspl = message.Content.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (wspl.Length == 2) { + // Has parameter - do specific user lookup + var usersearch = ResolveUserParameter(channel.Guild, wspl[1]); + if (usersearch == null) { + await channel.SendMessageAsync(":x: Cannot find the specified user.").ConfigureAwait(false); + return; + } + + var result = await _database.GetUserZoneAsync(usersearch).ConfigureAwait(false); + if (result == null) { + bool isself = sender.Id == usersearch.Id; + if (isself) await channel.SendMessageAsync(":x: You do not have a time zone. Set it with `tz.set`.").ConfigureAwait(false); + else await channel.SendMessageAsync(":x: The given user does not have a time zone set.").ConfigureAwait(false); + return; + } + + var resulttext = TzPrint(result)[4..] + ": " + FormatName(usersearch); + await channel.SendMessageAsync(embed: new EmbedBuilder().WithDescription(resulttext).Build()).ConfigureAwait(false); + } else { + // Does not have parameter - build full list + var userlist = await _database.GetGuildZonesAsync(channel.Guild.Id).ConfigureAwait(false); + if (userlist.Count == 0) { + await channel.SendMessageAsync(":x: Nothing to show. " + + $"To register time zones with the bot, use the `{CommandPrefix}set` command.").ConfigureAwait(false); + return; + } + + // Order times by popularity to limit how many are shown, group by printed name + var sortedlist = new SortedDictionary>(); + foreach ((string area, List 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()); + sortedlist[areaprint].AddRange(users); + } + + // Build zone listings with users + var outputlines = new List(); + foreach ((string area, List users) in sortedlist) { + var buffer = new StringBuilder(); + buffer.Append(area[4..] + ": "); + bool empty = true; + foreach (var userid in users) { + var userinstance = channel.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(); + var resultout = new StringBuilder(); + foreach (var line in outputlines) { + if (resultout.Length + line.Length > MaxSingleOutputLength) { + await channel.SendMessageAsync( + embed: 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 channel.SendMessageAsync( + embed: new EmbedBuilder().WithDescription(resultout.ToString()).Build()).ConfigureAwait(false); + } + } + } + + private async Task CmdSet(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message) { + var wspl = message.Content.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (wspl.Length == 1) { + await channel.SendMessageAsync(":x: Zone parameter is required.").ConfigureAwait(false); + return; + } + var input = ParseTimeZone(wspl[1]); + if (input == null) { + await channel.SendMessageAsync(ErrInvalidZone).ConfigureAwait(false); + return; + } + await _database.UpdateUserAsync(sender, input).ConfigureAwait(false); + await channel.SendMessageAsync($":white_check_mark: Your time zone has been set to **{input}**.").ConfigureAwait(false); + } + + private async Task CmdSetFor(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message) { + if (!IsUserAdmin(sender)) return; + + // Parameters: command, target, zone + var wspl = message.Content.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + if (wspl.Length == 1) { + await channel.SendMessageAsync(":x: You must specify a user to set the time zone for.").ConfigureAwait(false); + return; + } + if (wspl.Length == 2) { + await channel.SendMessageAsync(":x: You must specify a time zone to apply to the user.").ConfigureAwait(false); + return; + } + var targetuser = ResolveUserParameter(channel.Guild, wspl[1]); + if (targetuser == null) { + await channel.SendMessageAsync(ErrTargetUserNotFound).ConfigureAwait(false); + return; + } + var newtz = ParseTimeZone(wspl[2]); + if (newtz == null) { + await channel.SendMessageAsync(ErrInvalidZone).ConfigureAwait(false); + return; + } + + await _database.UpdateUserAsync(targetuser, newtz).ConfigureAwait(false); + await channel.SendMessageAsync($":white_check_mark: Time zone for **{targetuser}** set to **{newtz}**.").ConfigureAwait(false); + } + + private async Task CmdRemove(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message) { + var success = await _database.DeleteUserAsync(sender).ConfigureAwait(false); + if (success) await channel.SendMessageAsync(":white_check_mark: Your zone has been removed.").ConfigureAwait(false); + else await channel.SendMessageAsync(":x: You don't have a time zone set."); + } + + private async Task CmdRemoveFor(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message) { + if (!IsUserAdmin(sender)) return; + + // Parameters: command, target + var wspl = message.Content.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (wspl.Length == 1) { + await channel.SendMessageAsync(":x: You must specify a user for whom to remove time zone data.").ConfigureAwait(false); + return; + } + var targetuser = ResolveUserParameter(channel.Guild, wspl[1]); + if (targetuser == null) { + await channel.SendMessageAsync(ErrTargetUserNotFound).ConfigureAwait(false); + return; + } + + await _database.DeleteUserAsync(targetuser).ConfigureAwait(false); + await channel.SendMessageAsync($":white_check_mark: Removed zone information for {targetuser}."); + } + + #region Helper methods + /// + /// 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. + /// + 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')'", DateTimeFormatInfo.InvariantInfo); + return $"{sortpfx}● `{fullstr}`"; + } + + /// + /// Checks given time zone input. Returns a valid string for use with NodaTime, or null. + /// + 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; + } + + /// + /// Formats a user's name to a consistent, readable format which makes use of their nickname. + /// + 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}"; + } + + /// + /// Checks if the given user can be considered a guild admin ('Manage Server' is set). + /// + private static bool IsUserAdmin(SocketGuildUser user) + => user.GuildPermissions.Administrator || user.GuildPermissions.ManageGuild; + // TODO port modrole feature from BB, implement in here + + /// + /// Given parameter input, attempts to find the corresponding SocketGuildUser. + /// + private static SocketGuildUser? ResolveUserParameter(SocketGuild guild, string input) { + // if (!guild.HasAllMembers) await guild.DownloadUsersAsync().ConfigureAwait(false); + // TODO selective user downloading - see here when implementing + + // Try interpreting as ID + var match = _userMention.Match(input); + string idcheckstr = match.Success ? match.Groups[1].Value : input; + if (ulong.TryParse(idcheckstr, out var value)) return guild.GetUser(value); + + // Prepare if input looks like Username#Discriminator + var @explicit = _userExplicit.Match(input); + + foreach (var user in guild.Users) { + // Explicit match search + if (@explicit.Success) { + var username = @explicit.Groups[1].Value; + var discriminator = @explicit.Groups[2].Value; + if (string.Equals(user.Username, username, StringComparison.OrdinalIgnoreCase) && user.Discriminator == discriminator) + return user; + } + + // Nickname search + if (user.Nickname != null && string.Equals(user.Nickname, input, StringComparison.OrdinalIgnoreCase)) return user; + + // Username search + if (string.Equals(user.Username, input, StringComparison.OrdinalIgnoreCase)) return user; + } + + return null; + } + #endregion +} diff --git a/Configuration.cs b/Configuration.cs new file mode 100644 index 0000000..571c1be --- /dev/null +++ b/Configuration.cs @@ -0,0 +1,87 @@ +using CommandLine; +using CommandLine.Text; +using Newtonsoft.Json.Linq; +using Npgsql; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace WorldTime; + +/// +/// Loads and holds configuration values. +/// +class Configuration { + const string KeySqlHost = "SqlHost"; + const string KeySqlUsername = "SqlUsername"; + const string KeySqlPassword = "SqlPassword"; + const string KeySqlDatabase = "SqlDatabase"; + + public string DbConnectionString { get; } + public string BotToken { get; } + public string? DBotsToken { get; } + + public int ShardTotal { get; } + + public Configuration(string[] args) { + var cmdline = CmdLineOpts.Parse(args); + + // Looks for configuration file + var confPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) + Path.DirectorySeparatorChar; + confPath += cmdline.Config!; + if (!File.Exists(confPath)) throw new Exception("Settings file not found in path: " + confPath); + + var jc = JObject.Parse(File.ReadAllText(confPath)); + + BotToken = ReadConfKey(jc, nameof(BotToken), true); + DBotsToken = ReadConfKey(jc, nameof(DBotsToken), false); + + ShardTotal = cmdline.ShardTotal ?? ReadConfKey(jc, nameof(ShardTotal), false) ?? 1; + if (ShardTotal < 1) throw new Exception($"'{nameof(ShardTotal)}' must be a positive integer."); + + var sqlhost = ReadConfKey(jc, KeySqlHost, false) ?? "localhost"; // Default to localhost + var sqluser = ReadConfKey(jc, KeySqlUsername, false); + var sqlpass = ReadConfKey(jc, KeySqlPassword, false); + if (string.IsNullOrWhiteSpace(sqluser) || string.IsNullOrWhiteSpace(sqlpass)) + throw new Exception("'SqlUsername', 'SqlPassword' must be specified."); + var csb = new NpgsqlConnectionStringBuilder() { + Host = sqlhost, + Username = sqluser, + Password = sqlpass + }; + var sqldb = ReadConfKey(jc, KeySqlDatabase, false); + if (sqldb != null) csb.Database = sqldb; // Optional database setting + DbConnectionString = csb.ToString(); + } + + private static T? ReadConfKey(JObject jc, string key, [DoesNotReturnIf(true)] bool failOnEmpty) { + if (jc.ContainsKey(key)) return jc[key]!.Value(); + if (failOnEmpty) throw new Exception($"'{key}' must be specified."); + return default; + } + + private class CmdLineOpts { + [Option('c', "config", Default = "settings.json", + HelpText = "Custom path to instance configuration, relative from executable directory.")] + public string? Config { get; set; } + + [Option("shardtotal", + HelpText = "Total number of shards online. MUST be the same for all instances.\n" + + "This value overrides the config file value.")] + public int? ShardTotal { get; set; } + + public static CmdLineOpts Parse(string[] args) { + // Do not automatically print help message + var clp = new Parser(c => c.HelpWriter = null); + + CmdLineOpts? result = null; + var r = clp.ParseArguments(args); + r.WithParsed(parsed => result = parsed); + r.WithNotParsed(err => { + var ht = HelpText.AutoBuild(r); + Console.WriteLine(ht.ToString()); + Environment.Exit((int)Program.ExitCodes.BadCommand); + }); + return result!; + } + } +} diff --git a/Database.cs b/Database.cs new file mode 100644 index 0000000..73db51c --- /dev/null +++ b/Database.cs @@ -0,0 +1,143 @@ +using Npgsql; +using NpgsqlTypes; + +namespace WorldTime; + +/// +/// Database abstractions +/// +internal class Database { + private const string UserDatabase = "userdata"; + + private readonly string _connectionString; + + public Database(string connectionString) { + _connectionString = connectionString; + DoInitialDatabaseSetupAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + } + + /// + /// Sets up and opens a database connection. + /// + private async Task OpenConnectionAsync() { + var db = new NpgsqlConnection(_connectionString); + await db.OpenAsync().ConfigureAwait(false); + return db; + } + + private async Task DoInitialDatabaseSetupAsync() { + using var db = await OpenConnectionAsync().ConfigureAwait(false); + using var c = db.CreateCommand(); + c.CommandText = $"create table if not exists {UserDatabase} (" + + $"guild_id BIGINT, " + + "user_id BIGINT, " + + "zone TEXT NOT NULL, " + + "last_active TIMESTAMPTZ NOT NULL DEFAULT now(), " + + "PRIMARY KEY (guild_id, user_id)" // index automatically created with this + + ")"; + await c.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + /// + /// Gets the number of unique time zones in the database. + /// + public async Task GetDistinctZoneCountAsync() { + using var db = await OpenConnectionAsync().ConfigureAwait(false); + using var c = db.CreateCommand(); + c.CommandText = $"SELECT COUNT(DISTINCT zone) FROM {UserDatabase}"; + return (int)((long?)await c.ExecuteScalarAsync() ?? -1); // ExecuteScalarAsync returns a long here + } + + /// + /// Updates the last activity field for the specified guild user, if existing in the database. + /// + public async Task UpdateLastActivityAsync(SocketGuildUser user) { + using var db = await OpenConnectionAsync().ConfigureAwait(false); + using var c = db.CreateCommand(); + c.CommandText = $"UPDATE {UserDatabase} SET last_active = now() " + + "WHERE guild_id = @Gid AND user_id = @Uid"; + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)user.Guild.Id; + c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)user.Id; + await c.PrepareAsync().ConfigureAwait(false); + await c.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + // TODO remove data from users with very distant last activity. how long ago? + + /// + /// Removes the specified user from the database. + /// + /// True if the removal was successful. False typically if the user did not exist. + public async Task DeleteUserAsync(SocketGuildUser user) { + using var db = await OpenConnectionAsync().ConfigureAwait(false); + using var c = db.CreateCommand(); + c.CommandText = $"DELETE FROM {UserDatabase} " + + "WHERE guild_id = @Gid AND user_id = @Uid"; + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)user.Guild.Id; + c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)user.Id; + await c.PrepareAsync().ConfigureAwait(false); + return await c.ExecuteNonQueryAsync().ConfigureAwait(false) > 0; + } + + /// + /// Inserts/updates the specified user in the database. + /// + public 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) " + + "VALUES (@Gid, @Uid, @Zone) " + + "ON CONFLICT (guild_id, user_id) DO " + + "UPDATE SET zone = EXCLUDED.zone"; + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)user.Guild.Id; + c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)user.Id; + c.Parameters.Add("@Zone", NpgsqlDbType.Text).Value = timezone; + await c.PrepareAsync().ConfigureAwait(false); + await c.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + /// + /// Retrieves the time zone name of a single user. + /// + public async Task GetUserZoneAsync(SocketGuildUser user) { + using var db = await OpenConnectionAsync().ConfigureAwait(false); + using var c = db.CreateCommand(); + c.CommandText = $"SELECT zone FROM {UserDatabase} " + + "WHERE guild_id = @Gid AND user_id = @Uid"; + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)user.Guild.Id; + c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)user.Id; + await c.PrepareAsync().ConfigureAwait(false); + return (string?)await c.ExecuteScalarAsync(); + } + + /// + /// Retrieves all known user time zones for the given guild. Filtered only by last-seen time. + /// Further filtering should be handled by the consumer. + /// + /// + /// An unsorted dictionary. Keys are time zones, values are user IDs representative of those zones. + /// + public async Task>> 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 +SELECT zone, user_id FROM {UserDatabase} +WHERE + guild_id = @Gid + AND last_active >= now() - INTERVAL '30 days' -- TODO make configurable? +ORDER BY RANDOM() -- Randomize results for display purposes"; + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId; + await c.PrepareAsync().ConfigureAwait(false); + var r = await c.ExecuteReaderAsync().ConfigureAwait(false); + + var resultSet = new Dictionary>(); + while (await r.ReadAsync().ConfigureAwait(false)) { + var tz = r.GetString(0); + var user = (ulong)r.GetInt64(1); + + if (!resultSet.ContainsKey(tz)) resultSet.Add(tz, new List()); + resultSet[tz].Add(user); + } + return resultSet; + } +} diff --git a/LICENSE.txt b/LICENSE.txt index 43f3c33..d23a43e 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018-2020 Noi +Copyright (c) 2018-2021 Noi 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: diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..9651452 --- /dev/null +++ b/Program.cs @@ -0,0 +1,75 @@ +namespace WorldTime; + +class Program { + private static WorldTime? _bot; + private static readonly DateTimeOffset _botStartTime = DateTimeOffset.UtcNow; + + /// + /// Returns the amount of time the program has been running in a human-readable format. + /// + public static string BotUptime => (DateTimeOffset.UtcNow - _botStartTime).ToString("d' days, 'hh':'mm':'ss"); + + static async Task Main(string[] args) { + Configuration? cfg = null; + try { + cfg = new Configuration(args); + } catch (Exception ex) { + Console.WriteLine(ex); + Environment.Exit((int)ExitCodes.ConfigError); + } + + Database? d = null; + try { + d = new(cfg.DbConnectionString); + } catch (Npgsql.NpgsqlException e) { + Console.WriteLine("Error when attempting to connect to database: " + e.Message); + Environment.Exit((int)ExitCodes.DatabaseError); + } + + Console.CancelKeyPress += OnCancelKeyPressed; + _bot = new WorldTime(cfg, d); + await _bot.StartAsync().ConfigureAwait(false); + + await Task.Delay(-1).ConfigureAwait(false); + } + + /// + /// Sends a formatted message to console. + /// + public static void Log(string source, string message) { + var ts = DateTime.UtcNow; + var ls = new string[] { "\r\n", "\n" }; + foreach (var item in message.Split(ls, StringSplitOptions.None)) + Console.WriteLine($"{ts:u} [{source}] {item}"); + } + + private static void OnCancelKeyPressed(object? sender, ConsoleCancelEventArgs e) { + e.Cancel = true; + Log("Shutdown", "Captured cancel key; sending shutdown."); + ProgramStop(); + } + + private static bool _stopping = false; + public static void ProgramStop() { + if (_stopping) return; + _stopping = true; + Log("Shutdown", "Commencing shutdown..."); + + var dispose = Task.Run(_bot!.Dispose); + if (!dispose.Wait(90000)) { + Log("Shutdown", "Normal shutdown has not concluded after 90 seconds. Will force quit."); + Environment.ExitCode &= (int)ExitCodes.ForcedExit; + } + Environment.Exit(Environment.ExitCode); + } + + [Flags] + public enum ExitCodes { + Normal = 0x0, + ForcedExit = 0x1, + ConfigError = 0x2, + DatabaseError = 0x4, + DeadShardThreshold = 0x8, + BadCommand = 0x10, + } +} diff --git a/README.md b/README.md index 820c8a8..a84e3e3 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,5 @@ A social time zone reference tool! Displays the current time for all your active members. * Info: https://discord.bots.gg/bots/447266583459528715 -* Invite: https://discordapp.com/oauth2/authorize?client_id=447266583459528715&scope=bot&permissions=0 -For more information, see the `DiscordBots.md` file. - -## Setup -1. Install the necessary dependencies: `pytz`, `psycopg2`, `discord.py` -2. Copy `settings_default.py` to `settings.py` and configure as needed. -3. Run `worldtime.py`. +For more information, see the `DiscordBots.md` file. \ No newline at end of file diff --git a/WorldTime.cs b/WorldTime.cs new file mode 100644 index 0000000..756ddb1 --- /dev/null +++ b/WorldTime.cs @@ -0,0 +1,171 @@ +global using Discord; +global using Discord.WebSocket; +using System.Text; + +namespace WorldTime; + +/// +/// Main class for the program. Configures the client on start and occasionally prints status information. +/// +internal class WorldTime : IDisposable { + /// + /// Number of seconds between each time the status task runs, in seconds. + /// +#if DEBUG + private const int StatusInterval = 20; +#else + private const int StatusInterval = 300; +#endif + + /// + /// Number of concurrent shard startups to happen on each check. + /// This value is also used in . + /// + public const int MaxConcurrentOperations = 5; + + private readonly Task _statusTask; + private readonly CancellationTokenSource _mainCancel; + private readonly Commands _commands; + + internal Configuration Config { get; } + internal DiscordShardedClient DiscordClient { get; } + internal Database Database { get; } + + 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() { + TotalShards = Config.ShardTotal, + LogLevel = LogSeverity.Info, + DefaultRetryMode = RetryMode.RetryRatelimit, + MessageCacheSize = 0, // disable message cache + AlwaysDownloadUsers = true, + GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages + }); + DiscordClient.Log += DiscordClient_Log; + DiscordClient.ShardReady += DiscordClient_ShardReady; + DiscordClient.MessageReceived += DiscordClient_MessageReceived; + _commands = new Commands(this, Database); + + // Start status reporting thread + _mainCancel = new CancellationTokenSource(); + _statusTask = Task.Factory.StartNew(StatusLoop, _mainCancel.Token, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + public async Task StartAsync() { + await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken).ConfigureAwait(false); + await DiscordClient.StartAsync().ConfigureAwait(false); + } + + public void Dispose() { + _mainCancel.Cancel(); + _statusTask.Wait(10000); + if (!_statusTask.IsCompleted) + Program.Log(nameof(WorldTime), "Warning: Main thread did not cleanly finish up in time. Continuing..."); + + _mainCancel.Cancel(); + _statusTask.Wait(5000); + _mainCancel.Dispose(); + + Program.Log(nameof(WorldTime), $"Uptime: {Program.BotUptime}"); + } + + private async Task StatusLoop() { + try { + await Task.Delay(30000, _mainCancel.Token).ConfigureAwait(false); // initial 30 second delay + while (!_mainCancel.IsCancellationRequested) { + Program.Log(nameof(WorldTime), $"Bot uptime: {Program.BotUptime}"); + + await PeriodicReport(DiscordClient.CurrentUser.Id, DiscordClient.Guilds.Count, _mainCancel.Token).ConfigureAwait(false); + await Task.Delay(StatusInterval * 1000, _mainCancel.Token).ConfigureAwait(false); + } + } catch (TaskCanceledException) { } + } + + private static readonly HttpClient _httpClient = new(); + /// + /// Called by the status loop. Reports guild count to the console and to external services. + /// + private async Task PeriodicReport(ulong botId, int guildCount, CancellationToken cancellationToken) { + var avg = (float)guildCount / Config.ShardTotal; + Program.Log("Report", $"Currently in {guildCount} guilds. Average shard load: {avg:0.0}."); + if (botId == 0) return; + + // Discord Bots + if (!string.IsNullOrEmpty(Config.DBotsToken)) { + try { + string dBotsApiUrl = $"https://discord.bots.gg/api/v1/bots/{ botId }/stats"; + var body = $"{{ \"guildCount\": {guildCount} }}"; + var uri = new Uri(string.Format(dBotsApiUrl)); + + var post = new HttpRequestMessage(HttpMethod.Post, uri); + post.Headers.Add("Authorization", Config.DBotsToken); + post.Content = new StringContent(body, + Encoding.UTF8, "application/json"); + + await _httpClient.SendAsync(post, cancellationToken); + Program.Log("Discord Bots", "Update successful."); + } catch (Exception ex) { + Program.Log("Discord Bots", "Exception encountered during update: " + ex.Message); + } + } + } + +#region Event handling + private Task DiscordClient_Log(LogMessage arg) { + // Suppress certain messages + if (arg.Message != null) { + //switch (arg.Message) + //{ + // case "Connecting": + // case "Connected": + // case "Ready": + // case "Failed to resume previous session": + // case "Resumed previous session": + // case "Disconnecting": + // case "Disconnected": + // case "WebSocket connection was closed": + // case "Server requested a reconnect": + // return Task.CompletedTask; + //} + if (arg.Message == "Heartbeat Errored") { + // Replace this with a custom message; do not show stack trace + Program.Log("Discord.Net", $"{arg.Severity}: {arg.Message} - {arg.Exception.Message}"); + return Task.CompletedTask; + } + + Program.Log("Discord.Net", $"{arg.Severity}: {arg.Message}"); + } + + if (arg.Exception != null) Program.Log("Discord.Net exception", arg.Exception.ToString()); + + return Task.CompletedTask; + } + + private Task DiscordClient_ShardReady(DiscordSocketClient arg) => arg.SetGameAsync(Commands.CommandPrefix + "help"); + + private async Task DiscordClient_MessageReceived(SocketMessage message) { + /* + * From https://support-dev.discord.com/hc/en-us/articles/4404772028055: + * "You will still receive the events and can call the same APIs, and you'll get other data about a message like + * author and timestamp. To put it simply, you'll be able to know all the information about when someone sends a + * message; you just won't know what they said." + * + * Assuming this stays true, it will be possible to maintain legacy behavior after this bot loses full access to incoming messages. + */ + + // POTENTIAL BUG: If user does a list command, the list may be processed before their own time's refreshed, and they may be skipped. + if (message.Author.IsWebhook) return; + if (message.Type != MessageType.Default) return; + if (message.Channel is not SocketTextChannel) return; + + await Database.UpdateLastActivityAsync((SocketGuildUser)message.Author).ConfigureAwait(false); + } + #endregion +} diff --git a/WorldTime.csproj b/WorldTime.csproj new file mode 100644 index 0000000..f096e1c --- /dev/null +++ b/WorldTime.csproj @@ -0,0 +1,20 @@ + + + + Exe + net6.0 + enable + enable + 2.0.0 + NoiTheCat + + + + + + + + + + + diff --git a/WorldTime.sln b/WorldTime.sln new file mode 100644 index 0000000..31753ec --- /dev/null +++ b/WorldTime.sln @@ -0,0 +1,25 @@ + +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 diff --git a/client.py b/client.py deleted file mode 100644 index 265458c..0000000 --- a/client.py +++ /dev/null @@ -1,70 +0,0 @@ -# WorldTime Discord client - -import discord -import asyncio -import aiohttp - -from common import logPrint -import settings -from userdb import UserDatabase -from commands import WtCommands - -class WorldTime(discord.AutoShardedClient): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.userdb = UserDatabase(settings.PgConnectionString) - self.commands = WtCommands(self.userdb, self) - self.bg_task = self.loop.create_task(self.periodic_report()) - - async def on_shard_connect(self, sid): - logPrint('Status', 'Shard {2} connected as {0} ({1})'.format(self.user.name, self.user.id, sid)) - await self.change_presence(activity=discord.Game("tz.help"), shard_id=sid) - - async def on_message(self, message): - # ignore bots (should therefore also ignore self) - if message.author.bot: return - - if isinstance(message.channel, discord.DMChannel): - await self.respond_dm(message) - return - - # Regular message - self.userdb.update_activity(message.guild.id, message.author.id) - cmdBase = message.content.split(' ', 1)[0].lower() - if cmdBase.startswith('tz.'): # wishlist: per-guild customizable prefix - cmdBase = cmdBase[3:] - await self.commands.dispatch(cmdBase, message) - - async def respond_dm(self, message): - logPrint('Incoming DM', '{0}: {1}'.format(message.author, message.content.replace('\n', '\\n'))) - await message.channel.send('''I don't work over DM. Refer to the `tz.help` command when in a server.''') - - # ---------------- - - async def periodic_report(self): - ''' - Provides a periodic update in console of how many guilds we're on. - Reports guild count to Discord Bots if the appropriate token has been defined. - ''' - try: - authtoken = settings.DBotsApiKey - except AttributeError: - authtoken = '' - - await self.wait_until_ready() - while not self.is_closed(): - guildcount = len(self.guilds) - async with aiohttp.ClientSession() as session: - if authtoken != '': - rurl = "https://discord.bots.gg/api/v1/bots/{}/stats".format(self.user.id) - rdata = { "guildCount": guildcount } - rhead = { "Content-Type": "application/json", "Authorization": authtoken } - try: - await session.post(rurl, json=rdata, headers=rhead) - logPrint("Report", "Reported count to Discord Bots.") - except aiohttp.ClientError as e: - logPrint("Report", "Discord Bots API report failed: {}".format(e)) - except Exception as e: - logPrint("Report", "Unknown error on Discord Bots API report.") - logPrint("Report", "Currently in {0} guild(s).".format(guildcount)) - await asyncio.sleep(21600) # Repeat once every six hours diff --git a/commands.py b/commands.py deleted file mode 100644 index c421e56..0000000 --- a/commands.py +++ /dev/null @@ -1,280 +0,0 @@ -# Command handlers -# Incoming commands are fully handled by functions defined here. - -from common import BotVersion, tzPrint -from textwrap import dedent -import discord -import pytz -from datetime import datetime -import re -import collections - -from userdb import UserDatabase -from common import tzlcmap, logPrint - -class WtCommands: - errStrInvalidZone = ':x: Not a valid zone name. To find your time zone, refer to: ' - - def __init__(self, userdb: UserDatabase, client: discord.Client): - self.userdb = userdb - self.dclient = client - self.commandlist = { - 'help' : self.cmd_help, - 'list' : self.cmd_list, - 'set' : self.cmd_set, - 'remove': self.cmd_remove, - 'setfor': self.cmd_setFor, - 'removefor': self.cmd_removeFor - } - - async def dispatch(self, cmdBase: str, message: discord.Message): - try: - command = self.commandlist[cmdBase] - except KeyError: - return - logPrint('Command invoked', '{0}/{1}: {2}'.format(message.guild, message.author, message.content)) - await command(message.guild, message.channel, message.author, message.content) - - # ------ - # Helper methods - - def _userFormat(self, member: discord.Member): - """ - Given a member, returns a formatted string showing their username and nickname - prepared for result output. - """ - username = self._userFormatEscapeFormattingCharacters(member.name) - if member.nick is not None: - nickname = self._userFormatEscapeFormattingCharacters(member.nick) - return "**{}** ({}#{})".format(nickname, username, member.discriminator) - else: - return "**{}#{}**".format(username, member.discriminator) - - def _userFormatEscapeFormattingCharacters(self, input: str): - result = '' - for char in input: - if char == '\\' or char == '_' or char == '~' or char == '*': - result += '\\' - result += char - return result - - def _isUserAdmin(self, member: discord.Member): - """ - Checks if the given user can be considered a guild admin ('Manage Server' is set). - """ - # Can fit in a BirthdayBot-like bot moderator role in here later, if desired. - p = member.guild_permissions - return p.administrator or p.manage_guild - - def _resolveUserParam(self, guild: discord.Guild, input: str): - """ - Given user input, attempts to find the corresponding Member. - Currently only accepts pings and explicit user IDs. - """ - UserMention = re.compile(r"<@\!?(\d+)>") - match = UserMention.match(input) - if match is not None: - idcheck = match.group(1) - else: - idcheck = input - try: - idcheck = int(idcheck) - except ValueError: - return None - return guild.get_member(idcheck) - - # ------ - # Individual command handlers - # All command functions are expected to have this signature: - # def cmd_NAME(self, guild: discord.Guild, channel: discord.TextChannel, author: discord.User, msgcontent: str) - - async def cmd_help(self, guild: discord.Guild, channel: discord.TextChannel, author: discord.User, msgcontent: str): - # Be a little fancy. - tzcount = self.userdb.get_unique_tz_count() - - em = discord.Embed( - color=14742263, - title='Help & About', - description=dedent(''' - World Time v{0} - Serving {1} communities across {2} time zones. - - 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. - '''.format(BotVersion, len(self.dclient.guilds), tzcount)) - ) - em.set_footer(text='World Time', icon_url=self.dclient.user.avatar_url) - em.add_field(name='Commands', value=dedent(''' - `tz.help` - This message. - `tz.list` - Displays current times for all recently active known users. - `tz.list [user]` - Displays the current time for the given *user*. - `tz.set [zone]` - Registers or updates your *zone* with the bot. - `tz.remove` - Removes your name from this bot. - ''')) - em.add_field(name='Admin commands', value=dedent(''' - `tz.setFor [user] [zone]` - Sets the time zone for another user. - `tz.removeFor [user]` - Removes another user's information. - '''), inline=False) - em.add_field(name='Zones', value=dedent(''' - 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/ - '''), inline=False) - await channel.send(embed=em) - - async def cmd_set(self, guild: discord.Guild, channel: discord.TextChannel, author: discord.User, msgcontent: str): - wspl = msgcontent.split(' ', 1) - if len(wspl) == 1: - # No parameter. But it's required - await channel.send(':x: Zone parameter is required.') - return - try: - userinput = wspl[1].lower() - if userinput == "asia/calcutta": userinput = "asia/kolkata" - zoneinput = tzlcmap[userinput] - except KeyError: - await channel.send(self.errStrInvalidZone) - return - self.userdb.update_user(guild.id, author.id, zoneinput) - await channel.send(f':white_check_mark: Your time zone has been set to **{zoneinput}**.') - - async def cmd_setFor(self, guild: discord.Guild, channel: discord.TextChannel, author: discord.User, msgcontent: str): - if not self._isUserAdmin(author): - # Silently ignore - return - - # parameters: command, target, zone - wspl = msgcontent.split(' ', 2) - - if len(wspl) == 1: - await channel.send(":x: You must specify a user to set the time zone for.") - return - if len(wspl) == 2: - await channel.send(":x: You must specify a time zone to apply to the user.") - return - - # Determine user from second parameter - targetuser = self._resolveUserParam(guild, wspl[1]) - if targetuser is None: - await channel.send(":x: Unable to find the target user.") - return - - # Check the third parameter - try: - userinput = wspl[2].lower() - if userinput == "asia/calcutta": userinput = "asia/kolkata" - zoneinput = tzlcmap[userinput] - except KeyError: - await channel.send(self.errStrInvalidZone) - return - - # Do the thing - self.userdb.update_user(guild.id, targetuser.id, zoneinput) - await channel.send(f':white_check_mark: Time zone for **{targetuser.name}#{targetuser.discriminator}** set to **{zoneinput}**.') - - async def cmd_list(self, guild: discord.Guild, channel: discord.TextChannel, author: discord.User, msgcontent: str): - wspl = msgcontent.split(' ', 1) - if len(wspl) == 1: - await self._list_noparam(guild, channel) - else: - await self._list_userparam(guild, channel, author, wspl[1]) - - async def cmd_remove(self, guild: discord.Guild, channel: discord.TextChannel, author: discord.User, msgcontent: str): - # To do: Check if there even is data to remove; react accordingly - self.userdb.delete_user(guild.id, author.id) - await channel.send(':white_check_mark: Your zone has been removed.') - - async def cmd_removeFor(self, guild: discord.Guild, channel: discord.TextChannel, author: discord.User, msgcontent: str): - if not self._isUserAdmin(author): - # Silently ignore - return - - # Parameters: command, target - wspl = msgcontent.split(' ', 1) - - if len(wspl) == 1: - await channel.send(":x: You must specify a user for whom to remove time zone data.") - return - targetuser = self._resolveUserParam(guild, wspl[1]) - if targetuser is None: - await channel.send(":x: Unable to find the target user.") - return - - self.userdb.delete_user(guild.id, targetuser.id) - await channel.send(':white_check_mark: Removed zone information for **' + targetuser.name + '#' + targetuser.discriminator + '**.') - - # ------ - # Supplemental command functions - - async def _list_noparam(self, guild: discord.Guild, channel: discord.TextChannel): - userlist = self.userdb.get_users(guild.id) - if len(userlist) == 0: - await channel.send(':x: No users with registered time zones have been active in the last 30 days.') - return - - orderedList = collections.OrderedDict(sorted(userlist.items())) - result = '' - - for k, v in orderedList.items(): - foundUsers = 0 - line = k[4:] + ": " - for id in v: - member = await self._resolve_member(guild, id) - if member is None: - continue - if foundUsers > 10: - line += "and others... " - foundUsers += 1 - line += self._userFormat(member) + ", " - if foundUsers > 0: result += line[:-2] + "\n" - - em = discord.Embed(description=result.strip()) - await channel.send(embed=em) - - async def _list_userparam(self, guild: discord.Guild, channel: discord.TextChannel, author: discord.User, param): - param = str(param) - usersearch = await self._resolve_member(guild, param) - if usersearch is None: - await channel.send(':x: Cannot find the specified user.') - return - - res = self.userdb.get_user(guild.id, usersearch.id) - if res is None: - ownsearch = author.id == param - if ownsearch: await channel.send(':x: You do not have a time zone. Set it with `tz.set`.') - else: await channel.send(':x: The given user does not have a time zone set.') - return - em = discord.Embed(description=tzPrint(res)[4:] + ": " + self._userFormat(usersearch)) - await channel.send(embed=em) - - async def _resolve_member(self, guild: discord.Guild, inputstr: str): - """ - Takes a string input and attempts to find the corresponding member. - """ - if not guild.chunked: await guild.chunk() - idsearch = None - try: - idsearch = int(inputstr) - except ValueError: - if inputstr.startswith('<@!') and inputstr.endswith('>'): - idsearch = int(inputstr[3:][:-1]) - elif inputstr.startswith('<@') and inputstr.endswith('>'): - idsearch = int(inputstr[2:][:-1]) - - if idsearch is not None: - return guild.get_member(idsearch) - - # get_member_named is case-sensitive. we do it ourselves. username only. - for member in guild.members: - # we'll use the discriminator and do a username lookup if it exists - if len(inputstr) > 5 and inputstr[-5] == '#': - discstr = inputstr[-4:] - userstr = inputstr[:-5] - if discstr.isdigit(): - if member.discriminator == discstr and userstr.lower() == member.name.lower(): - return member - #nickname search - if member.nick is not None: - if member.nick.lower() == inputstr.lower(): - return member - #username search - if member.name.lower() == inputstr.lower(): - return member - - return None \ No newline at end of file diff --git a/common.py b/common.py deleted file mode 100644 index 52d31ad..0000000 --- a/common.py +++ /dev/null @@ -1,26 +0,0 @@ -# Common items used throughout the project - -import pytz -from datetime import datetime - -# Bot's current version (as a string), for use in the help command -BotVersion = "1.3.4" - -# For case-insensitive time zone lookup, map lowercase tzdata entries with -# entires with proper case. pytz is case sensitive. -tzlcmap = {x.lower():x for x in pytz.common_timezones} - -def logPrint(label, line): - """ - Print with timestamp in a way that resembles some of my other projects - """ - resultstr = datetime.utcnow().strftime('%Y-%m-%d %H:%m:%S') + ' [' + label + '] ' + line - print(resultstr) - -def tzPrint(zone : str): - """ - Returns a string displaying the current time in the given time zone. - String begins with four numbers for sorting purposes and must be trimmed before output. - """ - now_time = datetime.now(pytz.timezone(zone)) - return "{:s}● {:s}".format(now_time.strftime("%m%d"), now_time.strftime("%d-%b %H:%M %Z (UTC%z)")) \ No newline at end of file diff --git a/dbtransfer.py b/dbtransfer.py deleted file mode 100644 index e302a52..0000000 --- a/dbtransfer.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 - -# Utility for carrying over data from an older SQLite -# database into a newer PostgreSQL one. - -# Edit this value: -PgConnectionString = '' - -import sqlite3 -import psycopg2 - -sldb = sqlite3.connect('users.db') -slcur = sldb.cursor() -pgdb = psycopg2.connect(PgConnectionString) -pgcur = pgdb.cursor() - -pgcur.execute(""" - CREATE TABLE IF NOT EXISTS userdata ( - guild_id BIGINT, - user_id BIGINT, - zone TEXT NOT NULL, - last_active TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (guild_id, user_id) - )""") -pgdb.commit() -pgcur.execute("TRUNCATE TABLE userdata") -pgdb.commit() - -slcur.execute(''' - SELECT guild, user, zone, lastactive - FROM users - ''') - -for row in slcur: - pgcur.execute(''' - INSERT INTO userdata (guild_id, user_id, zone, last_active) - VALUES (%s, %s, %s, to_timestamp(%s) at time zone 'utc') - ''', (int(row[0]), int(row[1]), row[2], int(row[3]))) - pgdb.commit() - print(row) \ No newline at end of file diff --git a/settings_default.py b/settings_default.py deleted file mode 100644 index 4f87c42..0000000 --- a/settings_default.py +++ /dev/null @@ -1,8 +0,0 @@ -# WorldTime instance settings - -# Bot token. Required to run. -BotToken = '' - -# PostgreSQL connection string. -# More information: https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING -PgConnectionString = '' \ No newline at end of file diff --git a/userdb.py b/userdb.py deleted file mode 100644 index 3c44e46..0000000 --- a/userdb.py +++ /dev/null @@ -1,119 +0,0 @@ -# User database abstractions - -import psycopg2 -from common import tzPrint - -class UserDatabase: - def __init__(self, connstr): - ''' - Sets up the PostgreSQL connection to be used by this instance. - ''' - self.db = psycopg2.connect(connstr) - cur = self.db.cursor() - cur.execute(""" - CREATE TABLE IF NOT EXISTS userdata ( - guild_id BIGINT, - user_id BIGINT, - zone TEXT NOT NULL, - last_active TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (guild_id, user_id) - )""") - self.db.commit() - cur.close() - - def update_activity(self, serverid : str, authorid : str): - ''' - If a user exists in the database, updates their last activity timestamp. - ''' - c = self.db.cursor() - c.execute(""" - UPDATE userdata SET last_active = now() - WHERE guild_id = %s AND user_id = %s - """, (serverid, authorid)) - self.db.commit() - c.close() - - def delete_user(self, serverid : str, authorid : str): - ''' - Deletes existing user from the database. - ''' - c = self.db.cursor() - c.execute(""" - DELETE FROM userdata - WHERE guild_id = %s AND user_id = %s - """, (serverid, authorid)) - self.db.commit() - c.close() - - def update_user(self, serverid : str, authorid : str, zone : str): - ''' - Insert or update user in the database. - Does not do any sanitizing of incoming values, as only a small set of - values are allowed anyway. This is enforced by the caller. - ''' - self.delete_user(serverid, authorid) - c = self.db.cursor() - c.execute(""" - INSERT INTO userdata (guild_id, user_id, zone) VALUES - (%s, %s, %s) - ON CONFLICT (guild_id, user_id) - DO UPDATE SET zone = EXCLUDED.zone - """, (serverid, authorid, zone)) - self.db.commit() - c.close() - - def get_user(self, serverid, userid): - ''' - Retrieves the time zone name of a single user. - ''' - c = self.db.cursor() - c.execute(""" - SELECT zone FROM userdata - WHERE guild_id = %s and user_id = %s - """, (serverid, userid)) - result = c.fetchone() - c.close() - if result is None: return None - return result[0] - - def get_users(self, serverid): - """ - Retrieves all user time zones for all recently active members. - Users not present are not filtered here. Must be handled by the caller. - Returns a dictionary of lists - Key is formatted zone, value is list of users represented. - """ - c = self.db.cursor() - c.execute(""" - SELECT zone, user_id - FROM userdata - WHERE - last_active >= now() - INTERVAL '30 DAYS' -- only users active in the last 30 days - AND guild_id = %(guild)s - AND zone in (SELECT zone from ( - SELECT zone, count(*) as ct - FROM userdata - WHERE - guild_id = %(guild)s - AND last_active >= now() - INTERVAL '30 DAYS' - GROUP BY zone - LIMIT 20 - ) as pop_zones) - ORDER BY RANDOM() -- Randomize display order (expected by consumer) - """, {'guild': serverid}) - result = {} - for row in c: - resultrow = tzPrint(row[0]) - result[resultrow] = result.get(resultrow, list()) - result[resultrow].append(row[1]) - c.close() - return result - - def get_unique_tz_count(self): - ''' - Gets the number of unique time zones in the database. - ''' - c = self.db.cursor() - c.execute('SELECT COUNT(DISTINCT zone) FROM userdata') - result = c.fetchall() - c.close() - return result[0][0] \ No newline at end of file diff --git a/worldtime.py b/worldtime.py deleted file mode 100644 index 563b582..0000000 --- a/worldtime.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 - -# World Time, a Discord bot. Displays user time zones. -# - https://github.com/NoiTheCat/WorldTime -# - https://discord.bots.gg/bots/447266583459528715 - -# Dependencies (install via pip or other means): -# pytz, psycopg2-binary, discord.py - -from discord import Intents -from client import WorldTime -import settings -import common - -if __name__ == '__main__': - common.logPrint("World Time", "World Time v" + common.BotVersion) - - try: - # Raising AttributeError here to cover either: variable doesn't exist, or variable is empty - if settings.BotToken == '': raise AttributeError() - except AttributeError: - print("Bot token not set. Will not continue.") - exit() - - # todo: sharding options handled here: pass shard_id and shard_count parameters - subscribedIntents = Intents.none() - subscribedIntents.guilds = True - subscribedIntents.members = True - subscribedIntents.guild_messages = True - client = WorldTime( - max_messages=None, - intents = subscribedIntents - ) - - client.run(settings.BotToken)