diff --git a/client.py b/client.py index 4a72997..fe27a99 100644 --- a/client.py +++ b/client.py @@ -4,172 +4,40 @@ import discord import asyncio import aiohttp -from datetime import datetime -import pytz - +from common import logPrint import settings from userdb import UserDatabase - -# For case-insensitive time zone lookup, map lowercase tzdata entries with -# entires with proper case. pytz is case sensitive. -tzlcmap = {x.lower():x for x in pytz.common_timezones} - -timefmt = "%H:%M %d-%b %Z%z" -def tzPrint(zone : str): - """ - Returns a string displaying the current time in the given time zone. - Resulting string should be placed in a code block. - """ - padding = '' - now_time = datetime.now(pytz.timezone(zone)) - if len(now_time.strftime("%Z")) != 4: padding = ' ' - return "{:s}{:s} | {:s}".format(now_time.strftime(timefmt), padding, zone) - -def tsPrint(label, line): - """ - Print with timestamp in a way that resembles some of my other projects - """ - resultstr = datetime.utcnow().strftime('%Y-%m-%d %H:%m:%S') + ' [' + label + '] ' + line - print(resultstr) +from commands import WtCommands class WorldTime(discord.Client): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.udb = UserDatabase('users.db') + self.userdb = UserDatabase('users.db') + self.commands = WtCommands(self.userdb, self) self.bg_task = self.loop.create_task(self.periodic_report()) async def on_ready(self): - tsPrint('Status', 'Connected as {0} ({1})'.format(self.user.name, self.user.id)) - - # Command processing ----------------- - async def cmd_help(self, message : discord.Message): - em = discord.Embed( - color=14742263, - title='Help & About', - description='This bot aims to answer the age-old question, "What time is it for everyone here?"') - em.set_footer(text='World Time', icon_url=self.user.avatar_url) - em.add_field( - name='Commands', value=''' -`tz.help` - This message. -`tz.list` - Displays current times for all recently active known users. -`tz.list [user]` - Displays the current time for the given *user*. -`tz.time` - Displays the current time in your time zone. -`tz.time [zone]` - Displays the current time in the given *zone*. -`tz.set [zone]` - Registers or updates your *zone* with the bot. -`tz.remove` - Removes your name from this bot. -''') - em.add_field( - name='Zones', value=''' -This bot uses zone names from the tz database. Most common zones are supported. For a list of entries, see: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. - ''') - await message.channel.send(embed=em) - - async def cmd_list(self, message): - wspl = message.content.split(' ', 1) - if len(wspl) == 1: - await self.cmd_list_noparam(message) - else: - await self.cmd_list_userparam(message, wspl[1]) - - async def cmd_list_noparam(self, message : discord.Message): - clist = self.udb.get_list(message.guild.id) - if len(clist) == 0: - await message.channel.send(':x: No users with known zones have been active in the last 72 hours.') - return - resultarr = [] - for i in clist: - resultarr.append(tzPrint(i)) - resultarr.sort() - resultstr = '```\n' - for i in resultarr: - resultstr += i + '\n' - resultstr += '```' - await message.channel.send(resultstr) - - async def cmd_list_userparam(self, message, param): - # wishlist: search based on username/nickname - param = str(param) - if param.startswith('<@!') and param.endswith('>'): - param = param[3:][:-1] - if param.startswith('<@') and param.endswith('>'): - param = param[2:][:-1] - if not param.isnumeric(): - # Didn't get an ID... - await message.channel.send(':x: You must specify a user by ID or `@` mention.') - return - res = self.udb.get_list(message.guild.id, param) - if len(res) == 0: - spaghetti = message.author.id == param - if spaghetti: await message.channel.send(':x: You do not have a time zone. Set it with `tz.set`.') - else: await message.channel.send(':x: The given user has not set a time zone. Ask to set it with `tz.set`.') - return - resultstr = '```\n' + tzPrint(res[0]) + '\n```' - await message.channel.send(resultstr) - - async def cmd_time(self, message): - wspl = message.content.split(' ', 1) - if len(wspl) == 1: - # No parameter. So, doing the same thing anyway... - await self.cmd_list_userparam(message, message.author.id) - else: - try: - zoneinput = tzlcmap[wspl[1].lower()] - except KeyError: - await message.channel.send(':x: Not a valid zone name.') - return - resultstr = '```\n' + tzPrint(zoneinput) + '\n```' - await message.channel.send(resultstr) - - async def cmd_set(self, message): - wspl = message.content.split(' ', 1) - if len(wspl) == 1: - # No parameter. But it's required - await message.channel.send(':x: Zone parameter is required.') - return - try: - zoneinput = tzlcmap[wspl[1].lower()] - except KeyError: - await message.channel.send(':x: Not a valid zone name.') - return - self.udb.update_user(message.guild.id, message.author.id, zoneinput) - await message.channel.send(':white_check_mark: Your zone has been set.') - - async def cmd_remove(self, message): - self.udb.delete_user(message.guild.id, message.author.id) - await message.channel.send(':white_check_mark: Your zone has been removed.') - - cmdlist = { - 'help' : cmd_help, - 'list' : cmd_list, - 'time' : cmd_time, - 'set' : cmd_set, - 'remove': cmd_remove - } - - async def command_dispatch(self, message): - '''Interprets incoming commands''' - cmdBase = message.content.split(' ', 1)[0].lower() - if cmdBase.startswith('tz.'): - cmdBase = cmdBase[3:] - else: - return - try: - await self.cmdlist[cmdBase](self, message) - tsPrint('Command invoked', '{0}/{1}: tz.{2}'.format(message.guild, message.author, cmdBase)) - except KeyError: - pass + logPrint('Status', 'Connected as {0} ({1})'.format(self.user.name, self.user.id)) async def on_message(self, message): # ignore bots (should therefore also ignore self) if message.author.bot: return - # act on DMs + if isinstance(message.channel, discord.DMChannel): - tsPrint('Incoming DM', '{0}: {1}'.format(message.author, message.content.replace('\n', '\\n'))) - await message.channel.send('''I don't work over DM. Only in servers.''') - # to do: small cache to not flood users who can't take a hint + await self.respond_dm(message) return - self.udb.update_activity(message.guild.id, message.author.id) - await self.command_dispatch(message) + + # Regular message + self.userdb.update_activity(message.guild.id, message.author.id) + cmdBase = message.content.split(' ', 1)[0].lower() + if cmdBase.startswith('tz.'): # wishlist: per-guild customizable prefix + cmdBase = cmdBase[3:] + await self.commands.dispatch(cmdBase, message) + + async def respond_dm(self, message): + logPrint('Incoming DM', '{0}: {1}'.format(message.author, message.content.replace('\n', '\\n'))) + await message.channel.send('''I don't work over DM. :frowning: Only in a server.''') + # to do: small cache to not flood users who can't take a hint # ---------------- @@ -186,7 +54,7 @@ This bot uses zone names from the tz database. Most common zones are supported. await self.wait_until_ready() while not self.is_closed(): guildcount = len(self.guilds) - tsPrint("Report", "Currently in {0} guild(s).".format(guildcount)) + logPrint("Report", "Currently in {0} guild(s).".format(guildcount)) async with aiohttp.ClientSession() as session: if authtoken != '': rurl = "https://bots.discord.pw/api/bots/{}/stats".format(self.user.id) @@ -194,7 +62,7 @@ This bot uses zone names from the tz database. Most common zones are supported. rhead = { "Content-Type": "application/json", "Authorization": authtoken } try: await session.post(rurl, json=rdata, headers=rhead) - tsPrint("Report", "Reported count to Discord Bots.") + logPrint("Report", "Reported count to Discord Bots.") except aiohttp.ClientError as e: - tsPrint("Report", "Discord Bots API report failed: {}".format(e)) + logPrint("Report", "Discord Bots API report failed: {}".format(e)) await asyncio.sleep(21600) # Repeat once every six hours \ No newline at end of file diff --git a/commands.py b/commands.py new file mode 100644 index 0000000..a622d6a --- /dev/null +++ b/commands.py @@ -0,0 +1,136 @@ +# Command handlers + +# Incoming messages that look like commands are passed into functions defined here. + +from textwrap import dedent +import discord + +from userdb import UserDatabase +from common import tzlcmap, tzPrint, logPrint + +# All command functions are expected to have this signature: +# def cmd_NAME(self, guild: discord.Guild, channel: discord.TextChannel, author: discord.User, msgcontent: str) + +class WtCommands: + def __init__(self, userdb: UserDatabase, client: discord.Client): + self.userdb = userdb + self.dclient = client + self.commandlist = { + 'help' : self.cmd_help, + 'list' : self.cmd_list, + 'time' : self.cmd_time, + 'set' : self.cmd_set, + 'remove': self.cmd_remove + } + + async def dispatch(self, cmdBase: str, message: discord.Message): + try: + command = self.commandlist[cmdBase] + except KeyError: + return + logPrint('Command invoked', '{0}/{1}: tz.{2}'.format(message.guild, message.author, cmdBase)) + await command(message.guild, message.channel, message.author, message.content) + + # ------ + # Helper functions + + # ------ + # Individual command handlers + + async def cmd_help(self, guild: discord.Guild, channel: discord.TextChannel, author: discord.User, msgcontent: str): + em = discord.Embed( + color=14742263, + title='Help & About', + description='This bot aims to answer the age-old question, "What time is it for everyone here?"') + em.set_footer(text='World Time', icon_url=self.dclient.user.avatar_url) + em.add_field(name='Commands', value=dedent(''' + `tz.help` - This message. + `tz.list` - Displays current times for all recently active known users. + `tz.list [user]` - Displays the current time for the given *user*. + `tz.time` - Displays the current time in your time zone. + `tz.time [zone]` - Displays the current time in the given *zone*. + `tz.set [zone]` - Registers or updates your *zone* with the bot. + `tz.remove` - Removes your name from this bot. + ''')) + em.add_field(name='Zones', value=dedent(''' + This bot uses zone names from the tz database. Most common zones are supported. For a list of entries, see: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. + ''')) + await channel.send(embed=em) + + async def cmd_time(self, guild: discord.Guild, channel: discord.TextChannel, author: discord.User, msgcontent: str): + wspl = msgcontent.split(' ', 1) + if len(wspl) == 1: + # No parameter. So, doing the same thing anyway... + await self._list_userparam(guild, channel, author, author.id) + else: + try: + zoneinput = tzlcmap[wspl[1].lower()] + except KeyError: + await channel.send(':x: Not a valid zone name.') + return + resultstr = '```\n' + tzPrint(zoneinput) + '\n```' + await channel.send(resultstr) + + async def cmd_set(self, guild: discord.Guild, channel: discord.TextChannel, author: discord.User, msgcontent: str): + wspl = msgcontent.split(' ', 1) + if len(wspl) == 1: + # No parameter. But it's required + await channel.send(':x: Zone parameter is required.') + return + try: + zoneinput = tzlcmap[wspl[1].lower()] + except KeyError: + await channel.send(':x: Not a valid zone name.') + return + self.userdb.update_user(guild.id, author.id, zoneinput) + await channel.send(':white_check_mark: Your zone has been set.') + + async def cmd_list(self, guild: discord.Guild, channel: discord.TextChannel, author: discord.User, msgcontent: str): + wspl = msgcontent.split(' ', 1) + if len(wspl) == 1: + await self._list_noparam(guild, channel) + else: + await self._list_userparam(guild, channel, author, wspl[1]) + + async def cmd_remove(self, guild: discord.Guild, channel: discord.TextChannel, author: discord.User, msgcontent: str): + # To do: Check if there even is data to remove; react accordingly + self.userdb.delete_user(guild.id, author.id) + await channel.send(':white_check_mark: Your zone has been removed.') + + # ------ + # Supplemental command functions + + async def _list_noparam(self, guild: discord.Guild, channel: discord.TextChannel): + clist = self.userdb.get_list(guild.id) + if len(clist) == 0: + await channel.send(':x: No users with known zones have been active in the last 72 hours.') + return + resultarr = [] + for i in clist: + resultarr.append(tzPrint(i)) + resultarr.sort() + resultstr = '```\n' + for i in resultarr: + resultstr += i + '\n' + resultstr += '```' + await channel.send(resultstr) + + async def _list_userparam(self, guild: discord.Guild, channel: discord.TextChannel, author: discord.User, param): + # wishlist: search based on username/nickname + param = str(param) + if param.startswith('<@!') and param.endswith('>'): + param = param[3:][:-1] + if param.startswith('<@') and param.endswith('>'): + param = param[2:][:-1] + if not param.isnumeric(): + # Didn't get an ID... + await channel.send(':x: You must specify a user by ID or `@` mention.') + return + res = self.userdb.get_list(guild.id, param) + if len(res) == 0: + spaghetti = author.id == param + if spaghetti: await channel.send(':x: You do not have a time zone. Set it with `tz.set`.') + else: await channel.send(':x: The given user has not set a time zone. Ask to set it with `tz.set`.') + return + resultstr = '```\n' + tzPrint(res[0]) + '\n```' + await channel.send(resultstr) \ No newline at end of file diff --git a/common.py b/common.py new file mode 100644 index 0000000..8b83c24 --- /dev/null +++ b/common.py @@ -0,0 +1,26 @@ +# Common items used throughout the project + +import pytz +from datetime import datetime + +# For case-insensitive time zone lookup, map lowercase tzdata entries with +# entires with proper case. pytz is case sensitive. +tzlcmap = {x.lower():x for x in pytz.common_timezones} + +timefmt = "%H:%M %d-%b %Z%z" +def tzPrint(zone : str): + """ + Returns a string displaying the current time in the given time zone. + Resulting string should be placed in a code block. + """ + padding = '' + now_time = datetime.now(pytz.timezone(zone)) + if len(now_time.strftime("%Z")) != 4: padding = ' ' + return "{:s}{:s} | {:s}".format(now_time.strftime(timefmt), padding, zone) + +def logPrint(label, line): + """ + Print with timestamp in a way that resembles some of my other projects + """ + resultstr = datetime.utcnow().strftime('%Y-%m-%d %H:%m:%S') + ' [' + label + '] ' + line + print(resultstr) \ No newline at end of file diff --git a/worldtime.py b/worldtime.py index 5eae861..c98e47b 100644 --- a/worldtime.py +++ b/worldtime.py @@ -4,10 +4,9 @@ # - https://github.com/Noikoio/WorldTime # - https://bots.discord.pw/bots/447266583459528715 -# Using discord.py rewrite. To install: -# pip install -U git+https://github.com/Rapptz/discord.py@rewrite - -# And yes, this code sucks. I don't know Python all too well. +# Dependencies (install via pip or other means): +# pytz, sqlite3, discord.py@rewrite +# How to install the latter: pip install -U git+https://github.com/Rapptz/discord.py@rewrite from discord import Game from client import WorldTime