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):
@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.
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.""")
@ -24,16 +24,18 @@ def setup(bot):
"guild_id": ctx.message.guild and ctx.message.guild.id,
"original_time_spec": time
}
tz = await util.get_user_timezone(ctx)
try:
now = datetime.now(tz=timezone.utc)
time = util.parse_time(time)
time = util.parse_time(time, tz)
except:
await ctx.send(embed=util.error_embed("Invalid time (wrong format/too large months or years)"))
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 (?, ?, ?, ?, ?)",
(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 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):
channel = bot.get_channel(info["channel_id"])
@ -81,10 +83,13 @@ def setup(bot):
rid, remind_timestamp, created_timestamp, reminder_text, _, extra = row
try:
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)
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:
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("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:
logging.info("Expiring reminder %d", 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 discord.ext.commands as commands
def setup(bot):
@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.
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
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.""")
async def userdata(ctx): pass
async def userdata(self, ctx): pass
async def get_userdata(db, user, guild, key):
return (await db.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)))
async def set_userdata(db, user, guild, key, value):
await db.execute("INSERT OR REPLACE INTO user_data VALUES (?, ?, ?, ?)", (user, guild, key, value))
await bot.database.commit()
async def get_userdata(self, 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 self.bot.database.execute_fetchone("SELECT * FROM user_data WHERE user_id = ? AND guild_id = '_global' AND key = ?", (user, key)))
async def set_userdata(self, user, guild, key, value):
await self.bot.database.execute("INSERT OR REPLACE INTO user_data VALUES (?, ?, ?, ?)", (user, guild, key, value))
await self.bot.database.commit()
@userdata.command(help="Get a userdata key. Checks guild first, then global.")
async def get(ctx, *, key):
row = await get_userdata(bot.database, ctx.author.id, ctx.guild.id, key)
async def get(self, ctx, *, key):
row = await self.get_userdata(ctx.author.id, ctx.guild.id, key)
if not row:
raise ValueError("No such key")
await ctx.send(row["value"])
@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."
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:
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 = []
for row in rows:
if show_values:
@ -33,35 +45,27 @@ def setup(bot):
else:
out.append(row["key"])
if len(out) == 0: return await ctx.send("No data")
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
await ctx.send(("\n" if show_values else ", ").join(out)[:2000]) # TODO: split better
@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)
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)")
@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)
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)")
@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."
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:
value = 0
guild = ctx.guild.id
@ -69,16 +73,19 @@ def setup(bot):
value = int(row["value"])
guild = row["guild_id"]
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}")
@userdata.command()
async def delete(ctx, *keys):
async def delete(self, ctx, *keys):
"Delete the specified keys (smallest scope first)."
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:
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 bot.database.commit()
await ctx.send(f"**{key}** deleted")
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 self.bot.database.commit()
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 time
import math
import pytz
import collections
config = {}
@ -101,22 +103,22 @@ def parse_short_timedelta(text):
return datetime.datetime.now(tz=datetime.timezone.utc) + relativedelta(**data)
cal = parsedatetime.Calendar()
def parse_humantime(text):
dt_tuple = cal.nlp(text)
if dt_tuple: return dt_tuple[0][0].replace(tzinfo=datetime.timezone.utc)
def parse_humantime(text, tz):
dt_tuple = cal.parseDT(text, tzinfo=tz)
if dt_tuple: return dt_tuple[0]
else: raise ValueError("parse failed")
def parse_time(text):
try: return datetime.datetime.strptime(text, "%d/%m/%Y").replace(tzinfo=datetime.timezone.utc)
def parse_time(text, tz):
try: return datetime.datetime.strptime(text, "%Y-%m-%d")
except: pass
try: return parse_short_timedelta(text)
except: pass
try: return parse_humantime(text)
try: return parse_humantime(text, tz)
except: pass
raise ValueError("time matches no available format")
def format_time(dt):
return dt.strftime("%H:%M:%S %d/%m/%Y")
return dt.strftime("%Y-%m-%d %H:%M:%S")
timeparts = (
("y", "years"),
@ -267,6 +269,32 @@ async def get_asset(bot: commands.Bot, identifier):
def hashbow(thing):
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 = (
"reminders",
"debug",