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) diff --git a/requirements.txt b/requirements.txt index e67db31..8dc5cb0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,6 @@ bidict>=0.21 certifi; sys_platform == 'win32' discord.py>=1.5 fuzzywuzzy[speedup]>=0.18 -pint>0.16 +pint>=0.16 pytz>=2020.1 +tzlocal>=2.1 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) diff --git a/sandpiper/common/time.py b/sandpiper/common/time.py new file mode 100644 index 0000000..1f1518a --- /dev/null +++ b/sandpiper/common/time.py @@ -0,0 +1,89 @@ +import datetime as dt +import re +from typing import Union, cast + +import pytz +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/conversion/__init__.py b/sandpiper/conversion/__init__.py new file mode 100644 index 0000000..abe73da --- /dev/null +++ b/sandpiper/conversion/__init__.py @@ -0,0 +1,7 @@ +from discord.ext.commands import Bot + +from .cog import Conversion + + +def setup(bot: Bot): + bot.add_cog(Conversion(bot)) diff --git a/sandpiper/conversion/cog.py b/sandpiper/conversion/cog.py new file mode 100644 index 0000000..49dddcb --- /dev/null +++ b/sandpiper/conversion/cog.py @@ -0,0 +1,104 @@ +import logging +import re +from typing import List + +import discord +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 * + +logger = logging.getLogger('sandpiper.unit_conversion') + +conversion_pattern = re.compile(r'{(.+?)}') + + +class Conversion(commands.Cog): + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.Cog.listener(name='on_message') + async def conversions(self, msg: discord.Message): + """ + Scan a message for conversion strings. + + :param msg: Discord message to scan for conversions + """ + if msg.author == self.bot.user: + return + + conversion_strs = conversion_pattern.findall(msg.content) + if not conversion_strs: + return + + conversion_strs = await self.convert_time(msg, conversion_strs) + await self.convert_imperial_metric(msg.channel, conversion_strs) + + 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 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 + """ + + 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, + 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 + """ + + 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 diff --git a/sandpiper/conversion/time_conversion.py b/sandpiper/conversion/time_conversion.py new file mode 100644 index 0000000..71d1f7c --- /dev/null +++ b/sandpiper/conversion/time_conversion.py @@ -0,0 +1,68 @@ +import datetime as dt +import logging +from typing import List, Tuple + +import discord + +from ..common.time import * +from ..user_info import UserData + +__all__ = ['UserTimezoneUnset', 'convert_time_to_user_timezones'] + +logger = logging.getLogger('sandpiper.conversion.time_conversion') + + +class UserTimezoneUnset(Exception): + pass + + +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 diff --git a/sandpiper/unit_conversion/unit_conversion.py b/sandpiper/conversion/unit_conversion.py similarity index 97% rename from sandpiper/unit_conversion/unit_conversion.py rename to sandpiper/conversion/unit_conversion.py index 5c44159..7855dbc 100644 --- a/sandpiper/unit_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 diff --git a/sandpiper/sandpiper.py b/sandpiper/sandpiper.py index 862c87a..6aa1909 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 @@ -47,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.bios') - self.load_extension('sandpiper.unit_conversion') self.load_extension('sandpiper.user_info') + self.load_extension('sandpiper.bios') + self.load_extension('sandpiper.conversion') async def on_connect(self): logger.info('Client connected') @@ -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/__init__.py b/sandpiper/unit_conversion/__init__.py deleted file mode 100644 index 9d5aa5e..0000000 --- a/sandpiper/unit_conversion/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from discord.ext.commands import Bot - -from .cog import UnitConversion - - -def setup(bot: Bot): - bot.add_cog(UnitConversion(bot)) diff --git a/sandpiper/unit_conversion/cog.py b/sandpiper/unit_conversion/cog.py deleted file mode 100644 index b983249..0000000 --- a/sandpiper/unit_conversion/cog.py +++ /dev/null @@ -1,55 +0,0 @@ -import logging -import re - -import discord -import discord.ext.commands as commands - -from .unit_conversion import * - -logger = logging.getLogger('sandpiper.unit_conversion') - -quantity_pattern = re.compile(r'{(.+?)}') - - -class UnitConversion(commands.Cog): - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.Cog.listener(name='on_message') - async def unit_conversions(self, msg: discord.Message): - """ - Scan a message for quantities like {5 km}, and reply with their - conversions to either imperial or metric. - - :param msg: discord message to scan for quantities - :return: ``False`` if no quantities were found - """ - - if msg.author == self.bot.user: - return - - quantity_strs = quantity_pattern.findall(msg.content) - 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] - if not quantities: - return - 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) diff --git a/sandpiper/user_info/database.py b/sandpiper/user_info/database.py index 3096f36..7d9b01c 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'] +DEFAULT_PRIVACY = PrivacyType.PRIVATE class DatabaseError(Exception): @@ -35,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 @@ -88,12 +91,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..9baee87 100644 --- a/sandpiper/user_info/database_sqlite.py +++ b/sandpiper/user_info/database_sqlite.py @@ -1,20 +1,16 @@ -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 .database import Database, DatabaseError -from .enums import PrivacyType +from .database import * __all__ = ['DatabaseSQLite'] logger = logging.getLogger('sandpiper.user_data.database_sqlite') -DEFAULT_PRIVACY = PrivacyType.PRIVATE - class DatabaseSQLite(Database): @@ -88,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 = ?' @@ -189,14 +200,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)