diff --git a/src/pluralkit/bot/commands/__init__.py b/src/pluralkit/bot/commands/__init__.py index 09d2deab..e2870556 100644 --- a/src/pluralkit/bot/commands/__init__.py +++ b/src/pluralkit/bot/commands/__init__.py @@ -1,4 +1,5 @@ import asyncio +from datetime import datetime import discord import re @@ -37,14 +38,15 @@ class CommandError(Exception): class CommandContext: - def __init__(self, client: discord.Client, message: discord.Message, conn, args: str): + def __init__(self, client: discord.Client, message: discord.Message, conn, args: str, system: Optional[System]): self.client = client self.message = message self.conn = conn self.args = args + self._system = system async def get_system(self) -> Optional[System]: - return await db.get_system_by_account(self.conn, self.message.author.id) + return self._system async def ensure_system(self) -> System: system = await self.get_system() @@ -57,6 +59,11 @@ class CommandContext: def has_next(self) -> bool: return bool(self.args) + def format_time(self, dt: datetime): + if self._system: + return self._system.format_time(dt) + return dt.isoformat(sep=" ", timespec="seconds") + " UTC" + def pop_str(self, error: CommandError = None) -> Optional[str]: if not self.args: if error: @@ -211,7 +218,8 @@ async def command_dispatch(client: discord.Client, message: discord.Message, con client=client, message=message, conn=conn, - args=remaining_string + args=remaining_string, + system=await System.get_by_account(conn, message.author.id) ) await run_command(ctx, command_root) return True diff --git a/src/pluralkit/bot/commands/switch_commands.py b/src/pluralkit/bot/commands/switch_commands.py index 3896951d..0afad06a 100644 --- a/src/pluralkit/bot/commands/switch_commands.py +++ b/src/pluralkit/bot/commands/switch_commands.py @@ -124,14 +124,14 @@ async def switch_move(ctx: CommandContext): last_fronters = await last_switch.fetch_members(ctx.conn) members = ", ".join([member.name for member in last_fronters]) or "nobody" - last_absolute = last_switch.timestamp.isoformat(sep=" ", timespec="seconds") + last_absolute = ctx.format_time(last_switch.timestamp) last_relative = display_relative(last_switch.timestamp) - new_absolute = new_time.isoformat(sep=" ", timespec="seconds") + new_absolute = ctx.format_time(new_time) new_relative = display_relative(new_time) # Confirm with user switch_confirm_message = await ctx.reply( - "This will move the latest switch ({}) from {} UTC ({} ago) to {} UTC ({} ago). Is this OK?".format(members, + "This will move the latest switch ({}) from {} ({} ago) to {} ({} ago). Is this OK?".format(members, last_absolute, last_relative, new_absolute, diff --git a/src/pluralkit/bot/commands/system_commands.py b/src/pluralkit/bot/commands/system_commands.py index 2c56176f..8fe9f007 100644 --- a/src/pluralkit/bot/commands/system_commands.py +++ b/src/pluralkit/bot/commands/system_commands.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta import dateparser import humanize +import pytz import pluralkit.bot.embeds from pluralkit.bot.commands import * @@ -29,6 +30,8 @@ async def system_root(ctx: CommandContext): await system_fronthistory(ctx, await ctx.ensure_system()) elif ctx.match("frontpercent") or ctx.match("frontbreakdown") or ctx.match("frontpercentage"): await system_frontpercent(ctx, await ctx.ensure_system()) + elif ctx.match("timezone") or ctx.match("tz"): + await system_timezone(ctx) elif ctx.match("set"): await system_set(ctx) elif not ctx.has_next(): @@ -95,6 +98,16 @@ async def system_description(ctx: CommandContext): await ctx.reply_ok("System description {}.".format("updated" if new_description else "cleared")) +async def system_timezone(ctx: CommandContext): + system = await ctx.ensure_system() + new_tz = ctx.remaining() or None + + tz = await system.set_time_zone(ctx.conn, new_tz) + offset = tz.utcoffset(datetime.utcnow()) + offset_str = "UTC{:+02d}:{:02d}".format(int(offset.total_seconds() // 3600), int(offset.total_seconds() // 60 % 60)) + await ctx.reply_ok("System time zone set to {} ({}, {}).".format(tz.tzname(datetime.utcnow()), offset_str, tz.zone)) + + async def system_tag(ctx: CommandContext): system = await ctx.ensure_system() new_tag = ctx.remaining() or None @@ -196,14 +209,14 @@ async def system_fronthistory(ctx: CommandContext, system: System): name = ", ".join([member.name for member in members]) # Make proper date string - time_text = timestamp.isoformat(sep=" ", timespec="seconds") + time_text = ctx.format_time(timestamp) rel_text = display_relative(timestamp) delta_text = "" if i > 0: last_switch_time = front_history[i - 1][0] delta_text = ", for {}".format(display_relative(timestamp - last_switch_time)) - lines.append("**{}** ({} UTC, {} ago{})".format(name, time_text, rel_text, delta_text)) + lines.append("**{}** ({}, {} ago{})".format(name, time_text, rel_text, delta_text)) embed = embeds.status("\n".join(lines) or "(none)") embed.title = "Past switches" @@ -302,6 +315,6 @@ async def system_frontpercent(ctx: CommandContext, system: System): embed.add_field(name=member.name if member else "(no fronter)", value="{}% ({})".format(percent, humanize.naturaldelta(front_time))) - embed.set_footer(text="Since {} UTC ({} ago)".format(span_start.isoformat(sep=" ", timespec="seconds"), + embed.set_footer(text="Since {} ({} ago)".format(ctx.format_time(span_start), display_relative(span_start))) await ctx.reply(embed=embed) diff --git a/src/pluralkit/bot/embeds.py b/src/pluralkit/bot/embeds.py index 2f9efa05..eaba9ec4 100644 --- a/src/pluralkit/bot/embeds.py +++ b/src/pluralkit/bot/embeds.py @@ -172,7 +172,7 @@ async def member_card(conn, member: Member) -> discord.Embed: return card -async def front_status(switch: Switch, conn) -> discord.Embed: +async def front_status(ctx: "CommandContext", switch: Switch, conn) -> discord.Embed: if switch: embed = status("") fronter_names = [member.name for member in await switch.fetch_members(conn)] @@ -186,7 +186,7 @@ async def front_status(switch: Switch, conn) -> discord.Embed: if switch.timestamp: embed.add_field(name="Since", - value="{} UTC ({})".format(switch.timestamp.isoformat(sep=" ", timespec="seconds"), + value="{} ({})".format(ctx.format_time(switch.timestamp), display_relative(switch.timestamp))) else: embed = error("No switches logged.") diff --git a/src/pluralkit/db.py b/src/pluralkit/db.py index dd500e29..9990c345 100644 --- a/src/pluralkit/db.py +++ b/src/pluralkit/db.py @@ -334,7 +334,8 @@ async def create_tables(conn): tag text, avatar_url text, token text, - created timestamp not null default (current_timestamp at time zone 'utc') + created timestamp not null default (current_timestamp at time zone 'utc'), + ui_tz text not null default 'UTC' )""") await conn.execute("""create table if not exists members ( id serial primary key, diff --git a/src/pluralkit/errors.py b/src/pluralkit/errors.py index cb421e91..b65f0fe8 100644 --- a/src/pluralkit/errors.py +++ b/src/pluralkit/errors.py @@ -93,3 +93,8 @@ class MembersAlreadyFrontingError(PluralKitError): class DuplicateSwitchMembersError(PluralKitError): def __init__(self): super().__init__("Duplicate members in member list.") + + +class InvalidTimeZoneError(PluralKitError): + def __init__(self, tz_name: str): + super().__init__("Invalid time zone designation \"{}\".\n\nFor a list of valid time zone designations, see the `TZ database name` column here: .".format(tz_name)) diff --git a/src/pluralkit/system.py b/src/pluralkit/system.py index 7b03bcec..2e7953fc 100644 --- a/src/pluralkit/system.py +++ b/src/pluralkit/system.py @@ -5,13 +5,29 @@ from collections.__init__ import namedtuple from datetime import datetime from typing import Optional, List, Tuple +import pytz + from pluralkit import db, errors from pluralkit.member import Member from pluralkit.switch import Switch from pluralkit.utils import generate_hid, contains_custom_emoji, validate_avatar_url_or_raise -class System(namedtuple("System", ["id", "hid", "name", "description", "tag", "avatar_url", "token", "created"])): +def canonicalize_tz_name(name: str) -> Optional[str]: + # First, try a direct search + try: + pytz.timezone(name) + return name + except pytz.UnknownTimeZoneError: + pass + + # Then check last fragment of common time zone identifiers + name_map = {tz.split("/")[-1].replace("_", " "): tz for tz in pytz.common_timezones} + if name in name_map: + return name_map[name] + + +class System(namedtuple("System", ["id", "hid", "name", "description", "tag", "avatar_url", "token", "created", "ui_tz"])): id: int hid: str name: str @@ -20,6 +36,8 @@ class System(namedtuple("System", ["id", "hid", "name", "description", "tag", "a avatar_url: str token: str created: datetime + # pytz-compatible time zone name, usually Olson-style (eg. Europe/Amsterdam) + ui_tz: str @staticmethod async def get_by_id(conn, system_id: int) -> Optional["System"]: @@ -206,6 +224,36 @@ class System(namedtuple("System", ["id", "hid", "name", "description", "tag", "a inner_message = leading_mentions + inner_message return member, inner_message + def format_time(self, dt: datetime) -> str: + """ + Localizes the given `datetime` to a string based on the system's preferred time zone. + + Assumes `dt` is a naïve `datetime` instance set to UTC, which is consistent with the rest of PluralKit. + """ + tz = pytz.timezone(self.ui_tz) + + # Set to aware (UTC), convert to tz, set to naive (tz), then format and append name + return tz.normalize(pytz.utc.localize(dt)).replace(tzinfo=None).isoformat(sep=" ", timespec="seconds") + " " + tz.tzname(dt) + + async def set_time_zone(self, conn, tz_name: str) -> pytz.tzinfo: + """ + Sets the system time zone to the time zone represented by the given string. + + If `tz_name` is None or an empty string, will default to UTC. + If `tz_name` does not represent a valid time zone string, will raise InvalidTimeZoneError. + + :raises: InvalidTimeZoneError + :returns: The `pytz.tzinfo` instance of the newly set time zone. + """ + + canonical_name = canonicalize_tz_name(tz_name) + if not canonical_name: + raise errors.InvalidTimeZoneError(tz_name) + tz = pytz.timezone(canonical_name) + + await db.update_system_field(conn, self.id, "ui_tz", tz.zone) + return tz + def to_json(self): return { "id": self.hid, diff --git a/src/requirements.txt b/src/requirements.txt index 58cdf429..e9e6c148 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -6,3 +6,4 @@ https://github.com/Rapptz/discord.py/archive/860d6a9ace8248dfeec18b8b159e7b757d9 humanize uvloop; sys.platform != 'win32' and sys.platform != 'cygwin' and sys.platform != 'cli' ciso8601 +pytz \ No newline at end of file