timezone awareness

This commit is contained in:
osmarks 2021-07-28 18:13:32 +01:00
parent 3654aa30a0
commit 4700a2f389
3 changed files with 89 additions and 49 deletions

View File

@ -8,7 +8,7 @@ import metrics
def setup(bot): def setup(bot):
@bot.command(brief="Set a reminder to be reminded about later.", rest_is_raw=True, help="""Sets a reminder which you will (probably) be reminded about at/after the specified time. @bot.command(brief="Set a reminder to be reminded about later.", rest_is_raw=True, help="""Sets a reminder which you will (probably) be reminded about at/after the specified time.
All times are UTC. All times are UTC unless overridden.
Reminders are checked every minute, so while precise times are not guaranteed, reminders should under normal conditions be received within 2 minutes of what you specify. Reminders are checked every minute, so while precise times are not guaranteed, reminders should under normal conditions be received within 2 minutes of what you specify.
Note that due to technical limitations reminders beyond the year 10000 CE or in the past cannot currently be handled. Note that due to technical limitations reminders beyond the year 10000 CE or in the past cannot currently be handled.
Note that reminder delivery is not guaranteed, due to possible issues including but not limited to: data loss, me eventually not caring, the failure of Discord (in this case message delivery will still be attempted manually on a case-by-case basis), the collapse of human civilization, or other existential risks.""") Note that reminder delivery is not guaranteed, due to possible issues including but not limited to: data loss, me eventually not caring, the failure of Discord (in this case message delivery will still be attempted manually on a case-by-case basis), the collapse of human civilization, or other existential risks.""")
@ -24,16 +24,18 @@ def setup(bot):
"guild_id": ctx.message.guild and ctx.message.guild.id, "guild_id": ctx.message.guild and ctx.message.guild.id,
"original_time_spec": time "original_time_spec": time
} }
tz = await util.get_user_timezone(ctx)
try: try:
now = datetime.now(tz=timezone.utc) now = datetime.now(tz=timezone.utc)
time = util.parse_time(time) time = util.parse_time(time, tz)
except: except:
await ctx.send(embed=util.error_embed("Invalid time (wrong format/too large months or years)")) await ctx.send(embed=util.error_embed("Invalid time (wrong format/too large months or years)"))
return return
utc_time, local_time = util.in_timezone(time, tz)
await bot.database.execute("INSERT INTO reminders (remind_timestamp, created_timestamp, reminder, expired, extra) VALUES (?, ?, ?, ?, ?)", await bot.database.execute("INSERT INTO reminders (remind_timestamp, created_timestamp, reminder, expired, extra) VALUES (?, ?, ?, ?, ?)",
(time.timestamp(), now.timestamp(), reminder, 0, util.json_encode(extra_data))) (utc_time.timestamp(), now.timestamp(), reminder, 0, util.json_encode(extra_data)))
await bot.database.commit() await bot.database.commit()
await ctx.send(f"Reminder scheduled for {util.format_time(time)} ({util.format_timedelta(now, time)}).") await ctx.send(f"Reminder scheduled for {util.format_time(local_time)} ({util.format_timedelta(now, utc_time)}).")
async def send_to_channel(info, text): async def send_to_channel(info, text):
channel = bot.get_channel(info["channel_id"]) channel = bot.get_channel(info["channel_id"])
@ -81,10 +83,13 @@ def setup(bot):
rid, remind_timestamp, created_timestamp, reminder_text, _, extra = row rid, remind_timestamp, created_timestamp, reminder_text, _, extra = row
try: try:
remind_timestamp = datetime.utcfromtimestamp(remind_timestamp) remind_timestamp = datetime.utcfromtimestamp(remind_timestamp)
created_timestamp = datetime.utcfromtimestamp(created_timestamp) created_timestamp = datetime.utcfromtimestamp(created_timestamp).replace(tzinfo=timezone.utc)
extra = json.loads(extra) extra = json.loads(extra)
uid = extra["author_id"] uid = extra["author_id"]
text = f"<@{uid}> Reminder queued at {util.format_time(created_timestamp)}: {reminder_text}" tz = await util.get_user_timezone(util.AltCtx(util.IDWrapper(uid), util.IDWrapper(None), bot))
print(created_timestamp, tz, created_timestamp.astimezone(tz))
created_time = util.format_time(created_timestamp.astimezone(tz))
text = f"<@{uid}> Reminder queued at {created_time}: {reminder_text}"
for method_name, func in remind_send_methods: for method_name, func in remind_send_methods:
print("trying", method_name, rid) print("trying", method_name, rid)
@ -96,7 +101,7 @@ def setup(bot):
except Exception as e: logging.warning("Failed to send %d to %s", rid, method_name, exc_info=e) except Exception as e: logging.warning("Failed to send %d to %s", rid, method_name, exc_info=e)
except Exception as e: except Exception as e:
logging.warning("Could not send reminder %d", rid, exc_info=e) logging.warning("Could not send reminder %d", rid, exc_info=e)
to_expire.append((2, rid)) # 2 = errored #to_expire.append((2, rid)) # 2 = errored
for expiry_type, expiry_id in to_expire: for expiry_type, expiry_id in to_expire:
logging.info("Expiring reminder %d", expiry_id) logging.info("Expiring reminder %d", expiry_id)
await bot.database.execute("UPDATE reminders SET expired = ? WHERE id = ?", (expiry_type, expiry_id)) await bot.database.execute("UPDATE reminders SET expired = ? WHERE id = ?", (expiry_type, expiry_id))

View File

@ -1,31 +1,43 @@
import util import util
import discord.ext.commands as commands
def setup(bot): def check_key(key):
@bot.group(name="userdata", aliases=["data"], help="""Store per-user data AND retrieve it later! Note that, due to the nature of storing things, it is necessary to set userdata before getting it. if len(key) > 128: raise ValueError("Key too long")
def preprocess_value(value):
value = value.strip()
if len(value) > 1024: raise ValueError("Value too long")
return value
class Userdata(commands.Cog):
def __init__(self, bot):
self.bot = bot
@commands.group(name="userdata", aliases=["data"], help="""Store per-user data AND retrieve it later! Note that, due to the nature of storing things, it is necessary to set userdata before getting it.
Data can either be localized to a guild (guild scope) or shared between guilds (global scope), but is always tied to a user.""") Data can either be localized to a guild (guild scope) or shared between guilds (global scope), but is always tied to a user.""")
async def userdata(ctx): pass async def userdata(self, ctx): pass
async def get_userdata(db, user, guild, key): async def get_userdata(self, user, guild, key):
return (await db.execute_fetchone("SELECT * FROM user_data WHERE user_id = ? AND guild_id = ? AND key = ?", (user, guild, key)) return (await self.bot.database.execute_fetchone("SELECT * FROM user_data WHERE user_id = ? AND guild_id = ? AND key = ?", (user, guild, key))
or await db.execute_fetchone("SELECT * FROM user_data WHERE user_id = ? AND guild_id = '_global' AND key = ?", (user, key))) or await self.bot.database.execute_fetchone("SELECT * FROM user_data WHERE user_id = ? AND guild_id = '_global' AND key = ?", (user, key)))
async def set_userdata(db, user, guild, key, value): async def set_userdata(self, user, guild, key, value):
await db.execute("INSERT OR REPLACE INTO user_data VALUES (?, ?, ?, ?)", (user, guild, key, value)) await self.bot.database.execute("INSERT OR REPLACE INTO user_data VALUES (?, ?, ?, ?)", (user, guild, key, value))
await bot.database.commit() await self.bot.database.commit()
@userdata.command(help="Get a userdata key. Checks guild first, then global.") @userdata.command(help="Get a userdata key. Checks guild first, then global.")
async def get(ctx, *, key): async def get(self, ctx, *, key):
row = await get_userdata(bot.database, ctx.author.id, ctx.guild.id, key) row = await self.get_userdata(ctx.author.id, ctx.guild.id, key)
if not row: if not row:
raise ValueError("No such key") raise ValueError("No such key")
await ctx.send(row["value"]) await ctx.send(row["value"])
@userdata.command(name="list", brief="List userdata keys in a given scope matching a query.") @userdata.command(name="list", brief="List userdata keys in a given scope matching a query.")
async def list_cmd(ctx, query="%", scope="guild", show_values: bool = False): async def list_cmd(self, ctx, query="%", scope="guild", show_values: bool = False):
"List userdata keys in a given scope (guild/global) matching your query (LIKE syntax). Can also show the associated values." "List userdata keys in a given scope (guild/global) matching your query (LIKE syntax). Can also show the associated values."
if scope == "global": if scope == "global":
rows = await bot.database.execute_fetchall("SELECT * FROM user_data WHERE user_id = ? AND guild_id = '_global' AND key LIKE ?", (ctx.author.id, query)) rows = await self.bot.database.execute_fetchall("SELECT * FROM user_data WHERE user_id = ? AND guild_id = '_global' AND key LIKE ?", (ctx.author.id, query))
else: else:
rows = await bot.database.execute_fetchall("SELECT * FROM user_data WHERE user_id = ? AND guild_id = ? AND key LIKE ?", (ctx.author.id, ctx.guild.id, query)) rows = await self.bot.database.execute_fetchall("SELECT * FROM user_data WHERE user_id = ? AND guild_id = ? AND key LIKE ?", (ctx.author.id, ctx.guild.id, query))
out = [] out = []
for row in rows: for row in rows:
if show_values: if show_values:
@ -33,35 +45,27 @@ def setup(bot):
else: else:
out.append(row["key"]) out.append(row["key"])
if len(out) == 0: return await ctx.send("No data") if len(out) == 0: return await ctx.send("No data")
await ctx.send(("\n" if show_values else " ").join(out)[:2000]) # TODO: split better await ctx.send(("\n" if show_values else ", ").join(out)[:2000]) # TODO: split better
def check_key(key):
if len(key) > 128: raise ValueError("Key too long")
def preprocess_value(value):
value = value.strip()
if len(value) > 1024: raise ValueError("Value too long")
return value
@userdata.command(name="set", help="Set a userdata key in the guild scope.") @userdata.command(name="set", help="Set a userdata key in the guild scope.")
async def set_cmd(ctx, key, *, value): async def set_cmd(self, ctx, key, *, value):
check_key(key) check_key(key)
value = preprocess_value(value) value = preprocess_value(value)
await set_userdata(bot.database, ctx.author.id, ctx.guild.id, key, value) await self.set_userdata(ctx.author.id, ctx.guild.id, key, value)
await ctx.send(f"**{key}** set (scope guild)") await ctx.send(f"**{key}** set (scope guild)")
@userdata.command(help="Set a userdata key in the global scope.") @userdata.command(help="Set a userdata key in the global scope.")
async def set_global(ctx, key, *, value): async def set_global(self, ctx, key, *, value):
check_key(key) check_key(key)
value = preprocess_value(value) value = preprocess_value(value)
await set_userdata(bot.database, ctx.author.id, "_global", key, value) await self.set_userdata(ctx.author.id, "_global", key, value)
await ctx.send(f"**{key}** set (scope global)") await ctx.send(f"**{key}** set (scope global)")
@userdata.command() @userdata.command()
async def inc(ctx, key, by: int = 1): async def inc(self, ctx, key, by: int = 1):
"Increase the integer value of a userdata key." "Increase the integer value of a userdata key."
check_key(key) check_key(key)
row = await get_userdata(bot.database, ctx.author.id, ctx.guild.id, key) row = await self.get_userdata(ctx.author.id, ctx.guild.id, key)
if not row: if not row:
value = 0 value = 0
guild = ctx.guild.id guild = ctx.guild.id
@ -69,16 +73,19 @@ def setup(bot):
value = int(row["value"]) value = int(row["value"])
guild = row["guild_id"] guild = row["guild_id"]
new_value = value + by new_value = value + by
await set_userdata(bot.database, ctx.author.id, guild, key, preprocess_value(str(new_value))) await self.set_userdata(ctx.author.id, guild, key, preprocess_value(str(new_value)))
await ctx.send(f"**{key}** set to {new_value}") await ctx.send(f"**{key}** set to {new_value}")
@userdata.command() @userdata.command()
async def delete(ctx, *keys): async def delete(self, ctx, *keys):
"Delete the specified keys (smallest scope first)." "Delete the specified keys (smallest scope first)."
for key in keys: for key in keys:
row = await get_userdata(bot.database, ctx.author.id, ctx.guild.id, key) row = await self.get_userdata(ctx.author.id, ctx.guild.id, key)
if not row: if not row:
return await ctx.send(embed=util.error_embed(f"No such key {key}")) return await ctx.send(embed=util.error_embed(f"No such key {key}"))
await bot.database.execute("DELETE FROM user_data WHERE user_id = ? AND guild_id = ? AND key = ?", (ctx.author.id, row["guild_id"], key)) await self.bot.database.execute("DELETE FROM user_data WHERE user_id = ? AND guild_id = ? AND key = ?", (ctx.author.id, row["guild_id"], key))
await bot.database.commit() await self.bot.database.commit()
await ctx.send(f"**{key}** deleted") await ctx.send(f"**{key}** deleted")
def setup(bot):
bot.add_cog(Userdata(bot))

View File

@ -13,6 +13,8 @@ from discord.ext import commands
import hashlib import hashlib
import time import time
import math import math
import pytz
import collections
config = {} config = {}
@ -101,22 +103,22 @@ def parse_short_timedelta(text):
return datetime.datetime.now(tz=datetime.timezone.utc) + relativedelta(**data) return datetime.datetime.now(tz=datetime.timezone.utc) + relativedelta(**data)
cal = parsedatetime.Calendar() cal = parsedatetime.Calendar()
def parse_humantime(text): def parse_humantime(text, tz):
dt_tuple = cal.nlp(text) dt_tuple = cal.parseDT(text, tzinfo=tz)
if dt_tuple: return dt_tuple[0][0].replace(tzinfo=datetime.timezone.utc) if dt_tuple: return dt_tuple[0]
else: raise ValueError("parse failed") else: raise ValueError("parse failed")
def parse_time(text): def parse_time(text, tz):
try: return datetime.datetime.strptime(text, "%d/%m/%Y").replace(tzinfo=datetime.timezone.utc) try: return datetime.datetime.strptime(text, "%Y-%m-%d")
except: pass except: pass
try: return parse_short_timedelta(text) try: return parse_short_timedelta(text)
except: pass except: pass
try: return parse_humantime(text) try: return parse_humantime(text, tz)
except: pass except: pass
raise ValueError("time matches no available format") raise ValueError("time matches no available format")
def format_time(dt): def format_time(dt):
return dt.strftime("%H:%M:%S %d/%m/%Y") return dt.strftime("%Y-%m-%d %H:%M:%S")
timeparts = ( timeparts = (
("y", "years"), ("y", "years"),
@ -267,6 +269,32 @@ async def get_asset(bot: commands.Bot, identifier):
def hashbow(thing): def hashbow(thing):
return int.from_bytes(hashlib.blake2b(thing.encode("utf-8")).digest()[:3], "little") return int.from_bytes(hashlib.blake2b(thing.encode("utf-8")).digest()[:3], "little")
IDWrapper = collections.namedtuple("IDWrapper", ["id"])
AltCtx = collections.namedtuple("AltCtx", ["author", "guild", "bot"])
async def user_config_lookup(ctx, cfg):
userdata = ctx.bot.get_cog("Userdata")
if userdata is None: return
row = await userdata.get_userdata(ctx.author.id, ctx.guild.id, cfg)
if row is None: return
return row["value"]
async def get_user_timezone(ctx):
tzname = await user_config_lookup(ctx, "tz")
if tzname:
try:
return pytz.timezone(tzname)
except pytz.UnknownTimeZoneError:
raise commands.UserInputError(f"Invalid time zone {tzname}")
else:
return pytz.utc
def in_timezone(dt, tz):
# we already have an aware datetime, so return that and localized version
if dt.tzinfo is not None: return dt, dt.astimezone(tz)
else:
aware = tz.localize(dt)
return aware, aware.astimezone(pytz.utc)
extensions = ( extensions = (
"reminders", "reminders",
"debug", "debug",