mirror of
https://github.com/osmarks/autobotrobot
synced 2025-01-31 09:34:44 +00:00
timezone awareness
This commit is contained in:
parent
3654aa30a0
commit
4700a2f389
@ -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))
|
||||||
|
@ -1,39 +1,5 @@
|
|||||||
import util
|
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.
|
|
||||||
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 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()
|
|
||||||
|
|
||||||
@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)
|
|
||||||
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):
|
|
||||||
"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))
|
|
||||||
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))
|
|
||||||
out = []
|
|
||||||
for row in rows:
|
|
||||||
if show_values:
|
|
||||||
out.append(f"**{row['key']}**: {row['value']}")
|
|
||||||
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):
|
def check_key(key):
|
||||||
if len(key) > 128: raise ValueError("Key too long")
|
if len(key) > 128: raise ValueError("Key too long")
|
||||||
@ -43,25 +9,63 @@ def setup(bot):
|
|||||||
if len(value) > 1024: raise ValueError("Value too long")
|
if len(value) > 1024: raise ValueError("Value too long")
|
||||||
return value
|
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(self, ctx): pass
|
||||||
|
|
||||||
|
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(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(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 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 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:
|
||||||
|
out.append(f"**{row['key']}**: {row['value']}")
|
||||||
|
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
|
||||||
|
|
||||||
@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))
|
42
src/util.py
42
src/util.py
@ -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",
|
||||||
|
Loading…
Reference in New Issue
Block a user