Rewrite to C#

This new code's structure and function is partially adapted from Birthday Bot and partially ported from the previous Python-based version of World Time.
This commit introduces very minor end-user changes and no new features.
This commit is contained in:
Noi 2021-11-07 21:46:26 -08:00
parent 32804aa9b2
commit 13e48c91b3
18 changed files with 1083 additions and 589 deletions

219
.editorconfig Normal file
View file

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

7
.gitignore vendored
View file

@ -1,3 +1,4 @@
users.db
__pycache__
settings.py
[Bb]in/
[Oo]bj/
.vs/
*.user

337
Commands.cs Normal file
View file

@ -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: <https://kevinnovak.github.io/Time-Zone-Picker/>.";
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<string, Command> _commands;
private readonly Database _database;
private readonly WorldTime _instance;
private static readonly ReadOnlyDictionary<string, string> _tzNameMap;
private static readonly Regex _userExplicit;
private static readonly Regex _userMention;
static Commands() {
Dictionary<string, string> 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<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 = 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
/// <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>
/// Given parameter input, attempts to find the corresponding SocketGuildUser.
/// </summary>
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
}

87
Configuration.cs Normal file
View file

@ -0,0 +1,87 @@
using CommandLine;
using CommandLine.Text;
using Newtonsoft.Json.Linq;
using Npgsql;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
namespace WorldTime;
/// <summary>
/// Loads and holds configuration values.
/// </summary>
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<string>(jc, nameof(BotToken), true);
DBotsToken = ReadConfKey<string>(jc, nameof(DBotsToken), false);
ShardTotal = cmdline.ShardTotal ?? ReadConfKey<int?>(jc, nameof(ShardTotal), false) ?? 1;
if (ShardTotal < 1) throw new Exception($"'{nameof(ShardTotal)}' must be a positive integer.");
var sqlhost = ReadConfKey<string>(jc, KeySqlHost, false) ?? "localhost"; // Default to localhost
var sqluser = ReadConfKey<string>(jc, KeySqlUsername, false);
var sqlpass = ReadConfKey<string>(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<string>(jc, KeySqlDatabase, false);
if (sqldb != null) csb.Database = sqldb; // Optional database setting
DbConnectionString = csb.ToString();
}
private static T? ReadConfKey<T>(JObject jc, string key, [DoesNotReturnIf(true)] bool failOnEmpty) {
if (jc.ContainsKey(key)) return jc[key]!.Value<T>();
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<CmdLineOpts>(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!;
}
}
}

143
Database.cs Normal file
View file

@ -0,0 +1,143 @@
using Npgsql;
using NpgsqlTypes;
namespace WorldTime;
/// <summary>
/// Database abstractions
/// </summary>
internal class Database {
private const string UserDatabase = "userdata";
private readonly string _connectionString;
public Database(string connectionString) {
_connectionString = connectionString;
DoInitialDatabaseSetupAsync().ConfigureAwait(false).GetAwaiter().GetResult();
}
/// <summary>
/// Sets up and opens a database connection.
/// </summary>
private async Task<NpgsqlConnection> 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);
}
/// <summary>
/// Gets the number of unique time zones in the database.
/// </summary>
public async Task<int> 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
}
/// <summary>
/// Updates the last activity field for the specified guild user, if existing in the database.
/// </summary>
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?
/// <summary>
/// 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) {
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;
}
/// <summary>
/// Inserts/updates the specified user in the database.
/// </summary>
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);
}
/// <summary>
/// Retrieves the time zone name of a single user.
/// </summary>
public async Task<string?> 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();
}
/// <summary>
/// Retrieves all known user time zones for the given guild. Filtered only by last-seen time.
/// Further filtering should be handled by the consumer.
/// </summary>
/// <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) {
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<string, List<ulong>>();
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<ulong>());
resultSet[tz].Add(user);
}
return resultSet;
}
}

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018-2020 Noi <noithecat AT protonmail.com>
Copyright (c) 2018-2021 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:

75
Program.cs Normal file
View file

@ -0,0 +1,75 @@
namespace WorldTime;
class Program {
private static WorldTime? _bot;
private static readonly DateTimeOffset _botStartTime = DateTimeOffset.UtcNow;
/// <summary>
/// Returns the amount of time the program has been running in a human-readable format.
/// </summary>
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);
}
/// <summary>
/// Sends a formatted message to console.
/// </summary>
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,
}
}

View file

@ -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`.

171
WorldTime.cs Normal file
View file

@ -0,0 +1,171 @@
global using Discord;
global using Discord.WebSocket;
using System.Text;
namespace WorldTime;
/// <summary>
/// Main class for the program. Configures the client on start and occasionally prints status information.
/// </summary>
internal class WorldTime : IDisposable {
/// <summary>
/// Number of seconds between each time the status task runs, in seconds.
/// </summary>
#if DEBUG
private const int StatusInterval = 20;
#else
private const int StatusInterval = 300;
#endif
/// <summary>
/// Number of concurrent shard startups to happen on each check.
/// This value is also used in <see cref="DataRetention"/>.
/// </summary>
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();
/// <summary>
/// Called by the status loop. Reports guild count to the console and to external services.
/// </summary>
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
}

20
WorldTime.csproj Normal file
View file

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>2.0.0</Version>
<Authors>NoiTheCat</Authors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.8.0" />
<PackageReference Include="Discord.Net" Version="3.0.0-dev-20210822.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="NodaTime" Version="3.0.9" />
<PackageReference Include="Npgsql" Version="6.0.0-rc.2" />
</ItemGroup>
</Project>

25
WorldTime.sln Normal file
View file

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

View file

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

View file

@ -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: <https://kevinnovak.github.io/Time-Zone-Picker/>'
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

View file

@ -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)"))

View file

@ -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)

View file

@ -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 = ''

119
userdb.py
View file

@ -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]

View file

@ -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)