Skip to content

Commit

Permalink
Merge pull request #9 from Hawkpath/time-conversion
Browse files Browse the repository at this point in the history
Time conversion
  • Loading branch information
Phanabani authored Oct 25, 2020
2 parents c344d39 + 3e7e81c commit 8f59d68
Show file tree
Hide file tree
Showing 13 changed files with 352 additions and 90 deletions.
50 changes: 42 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
------ | --------
Expand All @@ -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!
Expand Down Expand Up @@ -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)
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion sandpiper/bios/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
89 changes: 89 additions & 0 deletions sandpiper/common/time.py
Original file line number Diff line number Diff line change
@@ -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<hour>[0-2]?\d)'
r'(?::(?P<minute>\d{2}))?'
r'\s*'
r'(?:(?P<period_am>a|am)|(?P<period_pm>p|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)
7 changes: 7 additions & 0 deletions sandpiper/conversion/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from discord.ext.commands import Bot

from .cog import Conversion


def setup(bot: Bot):
bot.add_cog(Conversion(bot))
104 changes: 104 additions & 0 deletions sandpiper/conversion/cog.py
Original file line number Diff line number Diff line change
@@ -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
68 changes: 68 additions & 0 deletions sandpiper/conversion/time_conversion.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 8f59d68

Please sign in to comment.