From d9c05448a62f57628c848c9c96e20a7c6d7611c8 Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Fri, 23 Oct 2020 19:30:16 -0400 Subject: [PATCH 01/21] Removed error handling in unit conversion cog and moved it to the client's on_error handler. --- sandpiper/sandpiper.py | 11 +++++++++-- sandpiper/unit_conversion/cog.py | 13 +------------ 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/sandpiper/sandpiper.py b/sandpiper/sandpiper.py index 862c87a..ebc555b 100644 --- a/sandpiper/sandpiper.py +++ b/sandpiper/sandpiper.py @@ -1,4 +1,5 @@ import logging +import sys import discord import discord.ext.commands as commands @@ -64,7 +65,14 @@ async def on_ready(self): logger.info('Client started') async def on_error(self, event_method: str, *args, **kwargs): - if event_method == 'on_message': + exc_type, __, __ = sys.exc_info() + + if exc_type is discord.HTTPException: + logger.warning('HTTP exception', exc_info=True) + elif exc_type is discord.Forbidden: + logger.warning('Forbidden request', exc_info=True) + + elif event_method == 'on_message': msg: discord.Message = args[0] logger.error( f'Unhandled in on_message (content: {msg.content!r} ' @@ -76,4 +84,3 @@ async def on_error(self, event_method: str, *args, **kwargs): f"Unhandled in {event_method} (args: {args} kwargs: {kwargs})", exc_info=True ) - await super().on_error(event_method, *args, **kwargs) diff --git a/sandpiper/unit_conversion/cog.py b/sandpiper/unit_conversion/cog.py index b983249..b344a08 100644 --- a/sandpiper/unit_conversion/cog.py +++ b/sandpiper/unit_conversion/cog.py @@ -33,12 +33,6 @@ async def unit_conversions(self, msg: discord.Message): if not quantity_strs: return - perms = msg.channel.permissions_for(msg.guild.me) - if not perms.send_messages: - logger.info(f'Lacking send_messages permission ' - f'(guild: {msg.guild} channel: {msg.channel})') - return - # Create output strings for all quantities encountered in the message quantities = [q for qstr in quantity_strs if (q := imperial_metric(qstr)) is not None] @@ -47,9 +41,4 @@ async def unit_conversions(self, msg: discord.Message): conversion = '\n'.join([f'{q[0]:.2f~P} = {q[1]:.2f~P}' for q in quantities]) - try: - await msg.channel.send(conversion) - except discord.HTTPException as e: - logger.warning('Failed to send unit conversion: ', exc_info=e) - except discord.InvalidArgument as e: - logger.error('Failed to send unit conversion: ', exc_info=e) + await msg.channel.send(conversion) From 15c49000d1722e6a1a1b15b56ea41b5f7ccd06c8 Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Fri, 23 Oct 2020 22:44:58 -0400 Subject: [PATCH 02/21] Extracted imperial/metric conversion to its own method so that conversion strings can scanned for once, then passed to different converter methods. --- sandpiper/unit_conversion/cog.py | 41 ++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/sandpiper/unit_conversion/cog.py b/sandpiper/unit_conversion/cog.py index b344a08..8f11916 100644 --- a/sandpiper/unit_conversion/cog.py +++ b/sandpiper/unit_conversion/cog.py @@ -1,5 +1,6 @@ import logging import re +from typing import List import discord import discord.ext.commands as commands @@ -8,7 +9,7 @@ logger = logging.getLogger('sandpiper.unit_conversion') -quantity_pattern = re.compile(r'{(.+?)}') +conversion_pattern = re.compile(r'{(.+?)}') class UnitConversion(commands.Cog): @@ -17,28 +18,38 @@ def __init__(self, bot: commands.Bot): self.bot = bot @commands.Cog.listener(name='on_message') - async def unit_conversions(self, msg: discord.Message): + async def conversions(self, msg: discord.Message): """ - Scan a message for quantities like {5 km}, and reply with their - conversions to either imperial or metric. + Scan a message for conversion strings. - :param msg: discord message to scan for quantities - :return: ``False`` if no quantities were found + :param msg: Discord message to scan for conversions """ - if msg.author == self.bot.user: return - quantity_strs = quantity_pattern.findall(msg.content) - if not quantity_strs: + conversion_strs = conversion_pattern.findall(msg.content) + if not conversion_strs: + return + + if await self.imperial_metric_conversion(msg.channel, conversion_strs): return - # Create output strings for all quantities encountered in the message + async def imperial_metric_conversion( + self, channel: discord.TextChannel, + quantity_strs: List[str]) -> bool: + """ + Convert a list of quantity strings (like "5 km") between imperial and + metric and reply with the conversions. + + :param channel: Discord channel to send conversions message to + :param quantity_strs: a list of strings that may be valid quantities + """ + quantities = [q for qstr in quantity_strs if (q := imperial_metric(qstr)) is not None] if not quantities: - return - conversion = '\n'.join([f'{q[0]:.2f~P} = {q[1]:.2f~P}' - for q in quantities]) - - await msg.channel.send(conversion) + return False + conversion = '\n'.join(f'{q[0]:.2f~P} = {q[1]:.2f~P}' + for q in quantities) + await channel.send(conversion) + return True From d79e7d7868efa6449f7e3139b8829d1817737106 Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Fri, 23 Oct 2020 23:22:37 -0400 Subject: [PATCH 03/21] Changed name of imperial_metric_conversion to convert_imperial_metric. Rewrote this method to be more efficient with iteration and it now returns a list of strings that could not be converted. --- sandpiper/unit_conversion/cog.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/sandpiper/unit_conversion/cog.py b/sandpiper/unit_conversion/cog.py index 8f11916..a8a8b71 100644 --- a/sandpiper/unit_conversion/cog.py +++ b/sandpiper/unit_conversion/cog.py @@ -31,25 +31,30 @@ async def conversions(self, msg: discord.Message): if not conversion_strs: return - if await self.imperial_metric_conversion(msg.channel, conversion_strs): - return + await self.convert_imperial_metric(msg.channel, conversion_strs) - async def imperial_metric_conversion( + async def convert_imperial_metric( self, channel: discord.TextChannel, - quantity_strs: List[str]) -> bool: + quantity_strs: List[str]) -> List[str]: """ Convert a list of quantity strings (like "5 km") between imperial and metric and reply with the conversions. :param channel: Discord channel to send conversions message to :param quantity_strs: a list of strings that may be valid quantities + :returns: a list of strings that could not be converted """ - quantities = [q for qstr in quantity_strs - if (q := imperial_metric(qstr)) is not None] - if not quantities: - return False - conversion = '\n'.join(f'{q[0]:.2f~P} = {q[1]:.2f~P}' - for q in quantities) - await channel.send(conversion) - return True + conversions = [] + failed = [] + for qstr in quantity_strs: + q = imperial_metric(qstr) + if q is not None: + conversions.append(f'{q[0]:.2f~P} = {q[1]:.2f~P}') + else: + failed.append(qstr) + + if conversions: + await channel.send('\n'.join(conversions)) + + return failed From 178284aa58dd63a732e8612a3f67a3d6cf93cb64 Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 00:25:32 -0400 Subject: [PATCH 04/21] Added empty convert_time method. --- sandpiper/unit_conversion/cog.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/sandpiper/unit_conversion/cog.py b/sandpiper/unit_conversion/cog.py index a8a8b71..d8c59c2 100644 --- a/sandpiper/unit_conversion/cog.py +++ b/sandpiper/unit_conversion/cog.py @@ -31,8 +31,21 @@ async def conversions(self, msg: discord.Message): if not conversion_strs: return + conversion_strs = await self.convert_time(msg.channel, conversion_strs) await self.convert_imperial_metric(msg.channel, conversion_strs) + async def convert_time(self, channel: discord.TextChannel, + time_strs: List[str]) -> List[str]: + """ + Convert a list of time strings (like "5:45 PM") to different users' + timezones and reply with the conversions. + + :param channel: Discord channel to send conversions message to + :param time_strs: a list of strings that may be valid times + :returns: a list of strings that could not be converted + """ + pass + async def convert_imperial_metric( self, channel: discord.TextChannel, quantity_strs: List[str]) -> List[str]: From 706fd635d45ad667af49b2487442f42596255fa6 Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 02:08:51 -0400 Subject: [PATCH 05/21] Added TimezoneType to the common module for properly annotating pytz timezones. Database adapter timezone methods are now annotated with this type. --- sandpiper/common/time.py | 7 +++++++ sandpiper/user_info/database.py | 9 ++++----- sandpiper/user_info/database_sqlite.py | 6 +++--- 3 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 sandpiper/common/time.py diff --git a/sandpiper/common/time.py b/sandpiper/common/time.py new file mode 100644 index 0000000..a944be6 --- /dev/null +++ b/sandpiper/common/time.py @@ -0,0 +1,7 @@ +from typing import Union + +import pytz + +__all__ = ['TimezoneType'] + +TimezoneType = Union[pytz.tzinfo.StaticTzInfo, pytz.tzinfo.DstTzInfo] diff --git a/sandpiper/user_info/database.py b/sandpiper/user_info/database.py index 3096f36..d9d4de5 100644 --- a/sandpiper/user_info/database.py +++ b/sandpiper/user_info/database.py @@ -2,11 +2,10 @@ import datetime from typing import List, Optional, Tuple -import pytz - +from ..common.time import TimezoneType from .enums import PrivacyType -__all__ = ['DatabaseError', 'Database'] +__all__ = ['DatabaseError', 'Database', 'TimezoneType'] class DatabaseError(Exception): @@ -88,12 +87,12 @@ def set_privacy_birthday(self, user_id: int, new_privacy: PrivacyType): pass @abstractmethod - def get_timezone(self, user_id: int) -> Optional[pytz.tzinfo.BaseTzInfo]: + def get_timezone(self, user_id: int) -> Optional[TimezoneType]: pass @abstractmethod def set_timezone(self, user_id: int, - new_timezone: Optional[pytz.tzinfo.BaseTzInfo]): + new_timezone: Optional[TimezoneType]): pass @abstractmethod diff --git a/sandpiper/user_info/database_sqlite.py b/sandpiper/user_info/database_sqlite.py index b66ade8..62b8c09 100644 --- a/sandpiper/user_info/database_sqlite.py +++ b/sandpiper/user_info/database_sqlite.py @@ -6,7 +6,7 @@ import pytz -from .database import Database, DatabaseError +from .database import * from .enums import PrivacyType __all__ = ['DatabaseSQLite'] @@ -189,14 +189,14 @@ def set_privacy_birthday(self, user_id: int, new_privacy: PrivacyType): # Timezone - def get_timezone(self, user_id: int) -> Optional[pytz.tzinfo.BaseTzInfo]: + def get_timezone(self, user_id: int) -> Optional[TimezoneType]: timezone_name = self._do_execute_get('timezone', user_id) if timezone_name: return pytz.timezone(timezone_name) return None def set_timezone(self, user_id: int, - new_timezone: Optional[pytz.tzinfo.BaseTzInfo]): + new_timezone: Optional[TimezoneType]): if new_timezone: new_timezone = new_timezone.zone self._do_execute_set('timezone', user_id, new_timezone) From 248fa165ffd5bfe5ffdceca75deac3f384c5a3ba Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 02:11:47 -0400 Subject: [PATCH 06/21] Changed fuzzy_match_timezone's matching list from pytz.all_timezones to pytz.common_timezones since it doesn't contain historical or deprecated timezones. --- sandpiper/bios/misc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sandpiper/bios/misc.py b/sandpiper/bios/misc.py index d49903e..8514bc6 100644 --- a/sandpiper/bios/misc.py +++ b/sandpiper/bios/misc.py @@ -32,7 +32,7 @@ def fuzzy_match_timezone(tz_str: str, best_match_threshold=75, # partial_ratio finds substrings, which isn't really what users will be # searching by, and the _set_ratio methods are totally unusable. matches: List[Tuple[str, int]] = fuzzy_process.extractBests( - tz_str, pytz.all_timezones, scorer=fuzz.ratio, + tz_str, pytz.common_timezones, scorer=fuzz.ratio, score_cutoff=lower_score_cutoff, limit=limit) tz_matches = TimezoneMatches(matches) From 6ebc4808ab73bbdc95882a548e684463799ee284 Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 03:29:07 -0400 Subject: [PATCH 07/21] Moved some imports and constants to the database ABC module. --- sandpiper/user_info/database.py | 5 ++++- sandpiper/user_info/database_sqlite.py | 3 --- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sandpiper/user_info/database.py b/sandpiper/user_info/database.py index d9d4de5..3e617c7 100644 --- a/sandpiper/user_info/database.py +++ b/sandpiper/user_info/database.py @@ -5,7 +5,10 @@ from ..common.time import TimezoneType from .enums import PrivacyType -__all__ = ['DatabaseError', 'Database', 'TimezoneType'] +__all__ = ['DEFAULT_PRIVACY', 'DatabaseError', 'Database', 'TimezoneType', + 'PrivacyType'] + +DEFAULT_PRIVACY = PrivacyType.PRIVATE class DatabaseError(Exception): diff --git a/sandpiper/user_info/database_sqlite.py b/sandpiper/user_info/database_sqlite.py index 62b8c09..397e7f6 100644 --- a/sandpiper/user_info/database_sqlite.py +++ b/sandpiper/user_info/database_sqlite.py @@ -7,14 +7,11 @@ import pytz from .database import * -from .enums import PrivacyType __all__ = ['DatabaseSQLite'] logger = logging.getLogger('sandpiper.user_data.database_sqlite') -DEFAULT_PRIVACY = PrivacyType.PRIVATE - class DatabaseSQLite(Database): From 23a0232c1ef27df94c2fddd5f510e80b6c35182d Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 18:14:43 -0400 Subject: [PATCH 08/21] Database ABC module now exports everything in its namespace (makes it easier for subclassing). --- sandpiper/user_info/database.py | 3 --- sandpiper/user_info/database_sqlite.py | 3 +-- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/sandpiper/user_info/database.py b/sandpiper/user_info/database.py index 3e617c7..0f8f655 100644 --- a/sandpiper/user_info/database.py +++ b/sandpiper/user_info/database.py @@ -5,9 +5,6 @@ from ..common.time import TimezoneType from .enums import PrivacyType -__all__ = ['DEFAULT_PRIVACY', 'DatabaseError', 'Database', 'TimezoneType', - 'PrivacyType'] - DEFAULT_PRIVACY = PrivacyType.PRIVATE diff --git a/sandpiper/user_info/database_sqlite.py b/sandpiper/user_info/database_sqlite.py index 397e7f6..bfb6936 100644 --- a/sandpiper/user_info/database_sqlite.py +++ b/sandpiper/user_info/database_sqlite.py @@ -1,8 +1,7 @@ -import datetime import logging from pathlib import Path import sqlite3 -from typing import Any, List, NoReturn, Optional, Tuple, Union +from typing import Any, NoReturn, Union import pytz From 27d963b043a836f0f18d60168b87383011c5748e Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 18:15:28 -0400 Subject: [PATCH 09/21] Added get_all_timezones method. --- sandpiper/user_info/database.py | 4 ++++ sandpiper/user_info/database_sqlite.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/sandpiper/user_info/database.py b/sandpiper/user_info/database.py index 0f8f655..7d9b01c 100644 --- a/sandpiper/user_info/database.py +++ b/sandpiper/user_info/database.py @@ -34,6 +34,10 @@ def connected(self) -> bool: def find_users_by_preferred_name(self, name: str) -> List[Tuple[int, str]]: pass + @abstractmethod + def get_all_timezones(self) -> List[Tuple[int, TimezoneType]]: + pass + @abstractmethod def delete_user(self, user_id: int): pass diff --git a/sandpiper/user_info/database_sqlite.py b/sandpiper/user_info/database_sqlite.py index bfb6936..9baee87 100644 --- a/sandpiper/user_info/database_sqlite.py +++ b/sandpiper/user_info/database_sqlite.py @@ -84,6 +84,21 @@ def find_users_by_preferred_name(self, name: str) -> List[Tuple[int, str]]: except sqlite3.Error: logger.error('Failed to find users by name', exc_info=True) + def get_all_timezones(self) -> List[Tuple[int, TimezoneType]]: + logger.info(f'Getting all user timezones') + stmt = ''' + SELECT user_id, timezone FROM user_info + WHERE privacy_timezone = :privacy + ''' + args = {'privacy': PrivacyType.PUBLIC} + try: + with self._con: + result = self._con.execute(stmt, args).fetchall() + return [(user_id, pytz.timezone(tz_name)) + for user_id, tz_name in result] + except sqlite3.Error: + logger.error('Failed to get all user timezones', exc_info=True) + def delete_user(self, user_id: int): logger.info(f'Deleting user (user_id={user_id})') stmt = 'DELETE FROM user_info WHERE user_id = ?' From a2e082608e0d73cc973349684a263d4ad60971f0 Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 19:17:43 -0400 Subject: [PATCH 10/21] Changed pint version specifier to be >= instead of > --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e67db31..5d5a0da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,5 @@ bidict>=0.21 certifi; sys_platform == 'win32' discord.py>=1.5 fuzzywuzzy[speedup]>=0.18 -pint>0.16 +pint>=0.16 pytz>=2020.1 From 2156c258e672cc540a19b01dd37f3183f0bdd912 Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 19:17:59 -0400 Subject: [PATCH 11/21] Added tzlocal requirement. --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 5d5a0da..8dc5cb0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ discord.py>=1.5 fuzzywuzzy[speedup]>=0.18 pint>=0.16 pytz>=2020.1 +tzlocal>=2.1 From 04c0db3e0e3325120ac1be6c175341c26d067c0f Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 19:20:14 -0400 Subject: [PATCH 12/21] Added utc_now function. --- sandpiper/common/time.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/sandpiper/common/time.py b/sandpiper/common/time.py index a944be6..a27ae82 100644 --- a/sandpiper/common/time.py +++ b/sandpiper/common/time.py @@ -1,7 +1,16 @@ -from typing import Union +import datetime as dt +from typing import Union, cast import pytz -__all__ = ['TimezoneType'] +__all__ = ['TimezoneType', 'utc_now'] + +import tzlocal TimezoneType = Union[pytz.tzinfo.StaticTzInfo, pytz.tzinfo.DstTzInfo] + + +def utc_now() -> dt.datetime: + # Get the system-local timezone and use it to localize dt.datetime.now() + local_tz = cast(TimezoneType, tzlocal.get_localzone()) + return local_tz.localize(dt.datetime.now()) From eafeb939d55e85cf8412778fa5447cf4dfdaa63e Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 19:21:32 -0400 Subject: [PATCH 13/21] Added time_conversion module. --- sandpiper/unit_conversion/time_conversion.py | 141 +++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 sandpiper/unit_conversion/time_conversion.py diff --git a/sandpiper/unit_conversion/time_conversion.py b/sandpiper/unit_conversion/time_conversion.py new file mode 100644 index 0000000..32d21e0 --- /dev/null +++ b/sandpiper/unit_conversion/time_conversion.py @@ -0,0 +1,141 @@ +import datetime as dt +import logging +import re +from typing import List, Tuple + +import discord + +from ..common.time import * +from ..user_info import UserData + +__all__ = ['time_format', 'UserTimezoneUnset', 'convert_time_to_user_timezones'] + +logger = logging.getLogger('sandpiper.conversion.time_conversion') + +time_pattern = re.compile( + r'^(?P[0-2]?\d)' + r'(?::(?P\d{2}))?' + r'\s*' + r'(?:(?Pa|am)|(?Pp|pm))?$', + re.I +) + +try: + # Unix strip zero-padding + time_format = '%-I:%M %p (%H:%M)' + dt.datetime.now().strftime(time_format) +except ValueError: + try: + # Windows strip zero-padding + time_format = '%#I:%M %p (%H:%M)' + dt.datetime.now().strftime(time_format) + except ValueError: + # Fallback without stripping zero-padding + time_format = '%I:%M %p (%H:%M)' + + +class UserTimezoneUnset(Exception): + pass + + +class TimeParsingError(Exception): + pass + + +def parse_time(string: str, basis_tz: TimezoneType) -> dt.datetime: + """ + Parse a string as a time specifier of the general format "12:34 PM". + + :raises: TimeParsingError if parsing failed in an expected way + """ + + # Convert UTC now to the basis timezone + now_basis = utc_now().astimezone(basis_tz) + + match = time_pattern.match(string) + if not match: + raise TimeParsingError('No match') + + hour = int(match.group('hour')) + minute = int(match.group('minute') or 0) + + if (0 > hour > 23) or (0 > minute > 59): + raise TimeParsingError('Hour or minute is out of range') + + if match.group('period_pm'): + if hour < 12: + # This is PM and we use 24 hour times in datetime, so add 12 hours + hour += 12 + elif hour == 12: + # 12 PM is 12:00 + pass + else: + raise TimeParsingError('24 hour times do not use AM or PM') + elif match.group('period_am'): + if hour < 12: + # AM, so no change + pass + elif hour == 12: + # 12 AM is 00:00 + hour = 0 + else: + raise TimeParsingError('24 hour times do not use AM or PM') + + # Create the datetime we think the user is trying to specify by using + # their current local day and adding the hour and minute arguments. + # Return the localized datetime + basis_time = dt.datetime(now_basis.year, now_basis.month, now_basis.day, + hour, minute) + return basis_tz.localize(basis_time) + + +def convert_time_to_user_timezones( + user_data: UserData, user_id: int, guild: discord.Guild, + time_strs: List[str] +) -> Tuple[List[Tuple[str, List[dt.datetime]]], List[str]]: + """ + Convert times. + + :param user_data: the UserData cog for interacting with the database + :param user_id: the id of the user asking for a time conversion + :param guild: the guild the conversion is occurring in + :param time_strs: a list of strings that may be time specifiers + :returns: A tuple of (conversions, failed). + ``failed`` is a list of strings that could not be converted. + ``conversions`` is a list of tuples of (tz_name, converted_times). + ``tz_name`` is the name of the timezone the following times are in. + ``converted_times`` is a list of datetimes localized to every timezone + occupied by users in the guild. + """ + + db = user_data.get_database() + basis_tz = db.get_timezone(user_id) + if basis_tz is None: + raise UserTimezoneUnset() + user_timezones = [tz for user_id, tz in db.get_all_timezones() + if guild.get_member(user_id)] + + parsed_times = [] + failed = [] + for tstr in time_strs: + try: + parsed_times.append(parse_time(tstr, basis_tz)) + except TimeParsingError as e: + logger.debug(f"Failed to parse time string (string={tstr}, " + f"reason={e})") + failed.append(tstr) + except: + logger.warning(f"Unhandled exception while parsing time string " + f"(string={tstr})", exc_info=True) + + if not parsed_times: + return [], failed + + conversions = [] + for tz in user_timezones: + tz_name: str = tz.zone + times = [time.astimezone(tz) for time in parsed_times] + conversions.append((tz_name, times)) + conversions.sort(key=lambda conv: conv[1][0].utcoffset()) + + return conversions, failed From fd0301f9813b1402fa226b530f28c30d10763873 Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 19:22:53 -0400 Subject: [PATCH 14/21] Minorly changed how unit conversions are formatted. --- sandpiper/unit_conversion/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sandpiper/unit_conversion/cog.py b/sandpiper/unit_conversion/cog.py index d8c59c2..c4092c8 100644 --- a/sandpiper/unit_conversion/cog.py +++ b/sandpiper/unit_conversion/cog.py @@ -63,7 +63,7 @@ async def convert_imperial_metric( for qstr in quantity_strs: q = imperial_metric(qstr) if q is not None: - conversions.append(f'{q[0]:.2f~P} = {q[1]:.2f~P}') + conversions.append(f'`{q[0]:.2f~P}` = `{q[1]:.2f~P}`') else: failed.append(qstr) From 02e9a919cc84b93c3eb5f6b4d2eed90d6c80c927 Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 19:24:25 -0400 Subject: [PATCH 15/21] Completed convert_time method. --- sandpiper/unit_conversion/cog.py | 38 ++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/sandpiper/unit_conversion/cog.py b/sandpiper/unit_conversion/cog.py index c4092c8..390c3f3 100644 --- a/sandpiper/unit_conversion/cog.py +++ b/sandpiper/unit_conversion/cog.py @@ -5,6 +5,8 @@ import discord import discord.ext.commands as commands +from ..common.embeds import Embeds +from .time_conversion import * from .unit_conversion import * logger = logging.getLogger('sandpiper.unit_conversion') @@ -31,20 +33,48 @@ async def conversions(self, msg: discord.Message): if not conversion_strs: return - conversion_strs = await self.convert_time(msg.channel, conversion_strs) + conversion_strs = await self.convert_time(msg, conversion_strs) await self.convert_imperial_metric(msg.channel, conversion_strs) - async def convert_time(self, channel: discord.TextChannel, + async def convert_time(self, msg: discord.Message, time_strs: List[str]) -> List[str]: """ Convert a list of time strings (like "5:45 PM") to different users' timezones and reply with the conversions. - :param channel: Discord channel to send conversions message to + :param msg: Discord message that triggered the conversion :param time_strs: a list of strings that may be valid times :returns: a list of strings that could not be converted """ - pass + + user_data = self.bot.get_cog('UserData') + if user_data is None: + # User data cog couldn't be retrieved, so consider all conversions + # failed + return time_strs + + try: + localized_times, failed = convert_time_to_user_timezones( + user_data, msg.author.id, msg.guild, time_strs + ) + except UserTimezoneUnset: + cmd_prefix = self.bot.command_prefix(self.bot, msg)[-1] + await Embeds.error( + msg.channel, + f"You haven't set your timezone yet. Type " + f"`{cmd_prefix}help timezone set` for more info." + ) + return time_strs + + if localized_times: + output = [] + for tz_name, times in localized_times: + times = ' | '.join(f'`{time.strftime(time_format)}`' + for time in times) + output.append(f'**{tz_name}**: {times}') + await msg.channel.send('\n'.join(output)) + + return failed async def convert_imperial_metric( self, channel: discord.TextChannel, From b4dc57f6272324b2b44b8fc02f5a8da6fc0353bd Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 19:36:27 -0400 Subject: [PATCH 16/21] Moved time parsing/formatting related components from time_conversion to common/time. --- sandpiper/common/time.py | 79 +++++++++++++++++++- sandpiper/unit_conversion/cog.py | 1 + sandpiper/unit_conversion/time_conversion.py | 75 +------------------ 3 files changed, 78 insertions(+), 77 deletions(-) diff --git a/sandpiper/common/time.py b/sandpiper/common/time.py index a27ae82..1f1518a 100644 --- a/sandpiper/common/time.py +++ b/sandpiper/common/time.py @@ -1,16 +1,89 @@ import datetime as dt +import re from typing import Union, cast import pytz - -__all__ = ['TimezoneType', 'utc_now'] - import tzlocal +__all__ = ['TimezoneType', 'TimeParsingError', 'time_format', 'parse_time', + 'utc_now'] + TimezoneType = Union[pytz.tzinfo.StaticTzInfo, pytz.tzinfo.DstTzInfo] +time_pattern = re.compile( + r'^(?P[0-2]?\d)' + r'(?::(?P\d{2}))?' + r'\s*' + r'(?:(?Pa|am)|(?Pp|pm))?$', + re.I +) + +try: + # Unix strip zero-padding + time_format = '%-I:%M %p (%H:%M)' + dt.datetime.now().strftime(time_format) +except ValueError: + try: + # Windows strip zero-padding + time_format = '%#I:%M %p (%H:%M)' + dt.datetime.now().strftime(time_format) + except ValueError: + # Fallback without stripping zero-padding + time_format = '%I:%M %p (%H:%M)' + + +class TimeParsingError(Exception): + pass + def utc_now() -> dt.datetime: # Get the system-local timezone and use it to localize dt.datetime.now() local_tz = cast(TimezoneType, tzlocal.get_localzone()) return local_tz.localize(dt.datetime.now()) + + +def parse_time(string: str, basis_tz: TimezoneType) -> dt.datetime: + """ + Parse a string as a time specifier of the general format "12:34 PM". + + :raises: TimeParsingError if parsing failed in an expected way + """ + + # Convert UTC now to the basis timezone + now_basis = utc_now().astimezone(basis_tz) + + match = time_pattern.match(string) + if not match: + raise TimeParsingError('No match') + + hour = int(match.group('hour')) + minute = int(match.group('minute') or 0) + + if (0 > hour > 23) or (0 > minute > 59): + raise TimeParsingError('Hour or minute is out of range') + + if match.group('period_pm'): + if hour < 12: + # This is PM and we use 24 hour times in datetime, so add 12 hours + hour += 12 + elif hour == 12: + # 12 PM is 12:00 + pass + else: + raise TimeParsingError('24 hour times do not use AM or PM') + elif match.group('period_am'): + if hour < 12: + # AM, so no change + pass + elif hour == 12: + # 12 AM is 00:00 + hour = 0 + else: + raise TimeParsingError('24 hour times do not use AM or PM') + + # Create the datetime we think the user is trying to specify by using + # their current local day and adding the hour and minute arguments. + # Return the localized datetime + basis_time = dt.datetime(now_basis.year, now_basis.month, now_basis.day, + hour, minute) + return basis_tz.localize(basis_time) diff --git a/sandpiper/unit_conversion/cog.py b/sandpiper/unit_conversion/cog.py index 390c3f3..213f7cc 100644 --- a/sandpiper/unit_conversion/cog.py +++ b/sandpiper/unit_conversion/cog.py @@ -6,6 +6,7 @@ import discord.ext.commands as commands from ..common.embeds import Embeds +from ..common.time import time_format from .time_conversion import * from .unit_conversion import * diff --git a/sandpiper/unit_conversion/time_conversion.py b/sandpiper/unit_conversion/time_conversion.py index 32d21e0..71d1f7c 100644 --- a/sandpiper/unit_conversion/time_conversion.py +++ b/sandpiper/unit_conversion/time_conversion.py @@ -1,6 +1,5 @@ import datetime as dt import logging -import re from typing import List, Tuple import discord @@ -8,87 +7,15 @@ from ..common.time import * from ..user_info import UserData -__all__ = ['time_format', 'UserTimezoneUnset', 'convert_time_to_user_timezones'] +__all__ = ['UserTimezoneUnset', 'convert_time_to_user_timezones'] logger = logging.getLogger('sandpiper.conversion.time_conversion') -time_pattern = re.compile( - r'^(?P[0-2]?\d)' - r'(?::(?P\d{2}))?' - r'\s*' - r'(?:(?Pa|am)|(?Pp|pm))?$', - re.I -) - -try: - # Unix strip zero-padding - time_format = '%-I:%M %p (%H:%M)' - dt.datetime.now().strftime(time_format) -except ValueError: - try: - # Windows strip zero-padding - time_format = '%#I:%M %p (%H:%M)' - dt.datetime.now().strftime(time_format) - except ValueError: - # Fallback without stripping zero-padding - time_format = '%I:%M %p (%H:%M)' - class UserTimezoneUnset(Exception): pass -class TimeParsingError(Exception): - pass - - -def parse_time(string: str, basis_tz: TimezoneType) -> dt.datetime: - """ - Parse a string as a time specifier of the general format "12:34 PM". - - :raises: TimeParsingError if parsing failed in an expected way - """ - - # Convert UTC now to the basis timezone - now_basis = utc_now().astimezone(basis_tz) - - match = time_pattern.match(string) - if not match: - raise TimeParsingError('No match') - - hour = int(match.group('hour')) - minute = int(match.group('minute') or 0) - - if (0 > hour > 23) or (0 > minute > 59): - raise TimeParsingError('Hour or minute is out of range') - - if match.group('period_pm'): - if hour < 12: - # This is PM and we use 24 hour times in datetime, so add 12 hours - hour += 12 - elif hour == 12: - # 12 PM is 12:00 - pass - else: - raise TimeParsingError('24 hour times do not use AM or PM') - elif match.group('period_am'): - if hour < 12: - # AM, so no change - pass - elif hour == 12: - # 12 AM is 00:00 - hour = 0 - else: - raise TimeParsingError('24 hour times do not use AM or PM') - - # Create the datetime we think the user is trying to specify by using - # their current local day and adding the hour and minute arguments. - # Return the localized datetime - basis_time = dt.datetime(now_basis.year, now_basis.month, now_basis.day, - hour, minute) - return basis_tz.localize(basis_time) - - def convert_time_to_user_timezones( user_data: UserData, user_id: int, guild: discord.Guild, time_strs: List[str] From fe98e113c62d854163c4c505380a1453c4bb372c Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 19:47:48 -0400 Subject: [PATCH 17/21] Changed module name from unit_conversion to conversion. --- sandpiper/{unit_conversion => conversion}/__init__.py | 0 sandpiper/{unit_conversion => conversion}/cog.py | 0 sandpiper/{unit_conversion => conversion}/time_conversion.py | 0 sandpiper/{unit_conversion => conversion}/unit_conversion.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename sandpiper/{unit_conversion => conversion}/__init__.py (100%) rename sandpiper/{unit_conversion => conversion}/cog.py (100%) rename sandpiper/{unit_conversion => conversion}/time_conversion.py (100%) rename sandpiper/{unit_conversion => conversion}/unit_conversion.py (100%) diff --git a/sandpiper/unit_conversion/__init__.py b/sandpiper/conversion/__init__.py similarity index 100% rename from sandpiper/unit_conversion/__init__.py rename to sandpiper/conversion/__init__.py diff --git a/sandpiper/unit_conversion/cog.py b/sandpiper/conversion/cog.py similarity index 100% rename from sandpiper/unit_conversion/cog.py rename to sandpiper/conversion/cog.py diff --git a/sandpiper/unit_conversion/time_conversion.py b/sandpiper/conversion/time_conversion.py similarity index 100% rename from sandpiper/unit_conversion/time_conversion.py rename to sandpiper/conversion/time_conversion.py diff --git a/sandpiper/unit_conversion/unit_conversion.py b/sandpiper/conversion/unit_conversion.py similarity index 100% rename from sandpiper/unit_conversion/unit_conversion.py rename to sandpiper/conversion/unit_conversion.py From 693206ffc6e71bef13bb56836d1719ce697c9d63 Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 19:49:32 -0400 Subject: [PATCH 18/21] Changed logger name to reflect module name. --- sandpiper/conversion/unit_conversion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sandpiper/conversion/unit_conversion.py b/sandpiper/conversion/unit_conversion.py index 5c44159..7855dbc 100644 --- a/sandpiper/conversion/unit_conversion.py +++ b/sandpiper/conversion/unit_conversion.py @@ -9,7 +9,7 @@ __all__ = ['imperial_metric'] -logger = logging.getLogger('sandpiper.unit_conversion') +logger = logging.getLogger('sandpiper.conversion.unit_conversion') ureg = UnitRegistry( autoconvert_offset_to_baseunit=True, # For temperatures From aef1ff6c3fd6b14269ae7b9829f4aee499875104 Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 19:51:42 -0400 Subject: [PATCH 19/21] Changed cog name from UnitConversion to Conversion. --- sandpiper/conversion/__init__.py | 4 ++-- sandpiper/conversion/cog.py | 2 +- sandpiper/sandpiper.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sandpiper/conversion/__init__.py b/sandpiper/conversion/__init__.py index 9d5aa5e..abe73da 100644 --- a/sandpiper/conversion/__init__.py +++ b/sandpiper/conversion/__init__.py @@ -1,7 +1,7 @@ from discord.ext.commands import Bot -from .cog import UnitConversion +from .cog import Conversion def setup(bot: Bot): - bot.add_cog(UnitConversion(bot)) + bot.add_cog(Conversion(bot)) diff --git a/sandpiper/conversion/cog.py b/sandpiper/conversion/cog.py index 213f7cc..49dddcb 100644 --- a/sandpiper/conversion/cog.py +++ b/sandpiper/conversion/cog.py @@ -15,7 +15,7 @@ conversion_pattern = re.compile(r'{(.+?)}') -class UnitConversion(commands.Cog): +class Conversion(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot diff --git a/sandpiper/sandpiper.py b/sandpiper/sandpiper.py index ebc555b..a65511e 100644 --- a/sandpiper/sandpiper.py +++ b/sandpiper/sandpiper.py @@ -49,7 +49,7 @@ async def noprefix_notify(ctx: commands.Context, *, rest: str): f'Just type "{rest}".') self.load_extension('sandpiper.bios') - self.load_extension('sandpiper.unit_conversion') + self.load_extension('sandpiper.conversion') self.load_extension('sandpiper.user_info') async def on_connect(self): From 4831e358525301e165673d45078cd713d6d6193f Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 19:55:44 -0400 Subject: [PATCH 20/21] Reordered extension loading to reflect cog interdependencies. --- sandpiper/sandpiper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sandpiper/sandpiper.py b/sandpiper/sandpiper.py index a65511e..6aa1909 100644 --- a/sandpiper/sandpiper.py +++ b/sandpiper/sandpiper.py @@ -48,9 +48,9 @@ async def noprefix_notify(ctx: commands.Context, *, rest: str): f'You don\'t need to prefix commands here. ' f'Just type "{rest}".') + self.load_extension('sandpiper.user_info') self.load_extension('sandpiper.bios') self.load_extension('sandpiper.conversion') - self.load_extension('sandpiper.user_info') async def on_connect(self): logger.info('Client connected') From 3e7e81cbcff570adcc33deea9a90a7ae29be138f Mon Sep 17 00:00:00 2001 From: Hawkpath Date: Sat, 24 Oct 2020 23:24:12 -0400 Subject: [PATCH 21/21] Updated for new time conversion feature. --- README.md | 50 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 73a396e..eedf736 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ around the world. Her current features include: - Unit conversion between imperial and metric quantities +- Time conversion between the timezones of users in your server - Miniature user bios - Set your preferred name, pronouns, birthday, and timezone - Manage the privacy of each of these fields (private by default, but they @@ -22,8 +23,10 @@ Her current features include: - [Config](#config) - [Commands and features](#commands-and-features) - [Unit conversion](#unit-conversion) + - [Time conversion](#time-conversion) - [Bios](#bios) - [Planned features](#planned-features) +- [Inspiration](#inspiration) - [License](#license) ## Install @@ -68,7 +71,7 @@ See [config](#config) for more configuration options. #### Basic -In the top level directory, simply run sandpiper as a Python module. +In the top level directory, simply run Sandpiper as a Python module. ```shell script python -m sandpiper @@ -135,27 +138,26 @@ Key | Value ## Commands and features In servers, commands must be prefixed with the configured command prefix -(default="!piper "). When DMing Sandpiper, you do not need to prefix commands. +(default=`"!piper "`). When DMing Sandpiper, you do not need to prefix commands. ### Unit conversion Convert measurements written in regular messages! Just surround a measurement in {curly brackets} and Sandpiper will convert it for you. You can put -multiple measurements in a message (just be sure that each is put in its own -brackets). +multiple measurements in a message as long as each is put in its own brackets. -Here are some examples: +#### Examples > guys it's **{30f}** outside today, I'm so cold... > I've been working out a lot lately and I've already lost **{2 kg}**!! -> I think Jason is like **{6' 2"}** +> I think Jason is like **{6' 2"}** tall > Lou lives about **{15km}** from me and TJ's staying at a hotel **{1.5km}** > away, so he and I are gonna meet up and drive over to Lou. -Currently supported units: +#### Supported units: Metric | Imperial ------ | -------- @@ -165,6 +167,31 @@ Centimeter `cm` | Inch `in or "` Kilogram `kg` | Pound `lbs` Celsius `C or degC or °C` | Fahrenheit `F or degF or °F` +### Time conversion + +Just like [unit conversion](#unit-conversion), you can also convert times +between timezones! Surround a time in {curly brackets} and Sandpiper will +convert it to the different timezones of users in your server. + +Times can be formatted in 12- or 24-hour time and use colon separators (HH:MM). +12-hour times can optionally include AM or PM to specify what half of the day +you mean. If you don't specify, AM will be assumed. + +You can put multiple times in a message as long as each is put in its own brackets. + +To use this feature, you and your friends need to set your timezones with the +`timezone set` command (see the [bio commands section](#setting-your-info) +for more info). + +#### Examples + +> do you guys wanna play at {9pm}? + +> I wish I could, but I'm busy from {14} to {17:45} + +> yeah I've gotta wake up at {5} for work tomorrow, so it's an early bedtime +> for me + ### Bios Store some info about yourself to help your friends get to know you more easily! @@ -236,9 +263,16 @@ Command | Description | Example - [X] Pronouns - [X] Birthday - [X] Timezone -- [ ] Time conversion +- [X] Time conversion - [ ] Birthday notifications +## Inspiration + +These Discord bots inspired the development of Sandpiper: + +- [Friend-Time by Kevin Novak](https://github.com/KevinNovak/Friend-Time) - inspiration for time and unit conversion features +- [Birthday Bot by NoiTheCat](https://github.com/NoiTheCat/BirthdayBot) - inspiration for upcoming birthday feature + ## License [MIT © Hawkpath.](LICENSE)