mirror of
				https://github.com/osmarks/autobotrobot
				synced 2025-10-31 16:02:59 +00:00 
			
		
		
		
	timezone awareness
This commit is contained in:
		| @@ -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,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)) | ||||||
							
								
								
									
										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", | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user