diff --git a/CHANGELOG.md b/CHANGELOG.md index e8648c6..d25fbdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added - Added Trustee field to qrz command for club callsigns. +- Added alias for `ae7q call` command (`ae7q c`). +- Added ae7q lookup by FRN and Licensee ID, and for trustee records (`ae7q frn, licensee, trustee`). ### Changed - Changelog command to accept a version as argument. - The qrz command can now link to a QRZ page instead of embedding the data with the `--link` flag. +- All currently-available pools can now be accessed by the `hamstudy` command. +- The `hamstudy` command now uses the syntax `?hamstudy `. +- Replaced `hamstudyanswer` command with answering by reaction. ### Fixed - Fixed ditto marks (") appearing in the ae7q call command. - Fixed issue where incorrect table was parsed in ae7q call command. +- Fixed warning emoji reaction on messages starting with "??". ## [2.1.0] - 2020-01-04 diff --git a/README.md b/README.md index e95facd..45736d1 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ $ run.sh ## Copyright -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This program is released under the terms of the GNU General Public License, version 2. See `COPYING` for full license text. diff --git a/common.py b/common.py index 839ce6b..cb069b6 100644 --- a/common.py +++ b/common.py @@ -1,23 +1,19 @@ """ Common tools for the bot. --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrm2 and is released under the terms of the GNU General Public License, version 2. ---- - -`colours`: Colours used by embeds. - -`cat`: Category names for the HelpCommand. """ import collections import json +import re import traceback -from pathlib import Path from datetime import datetime +from pathlib import Path from types import SimpleNamespace import discord @@ -26,7 +22,8 @@ import discord.ext.commands as commands import data.options as opt -__all__ = ["colours", "cat", "emojis", "embed_factory", "error_embed_factory", "add_react", "check_if_owner"] +__all__ = ["colours", "cat", "emojis", "paths", "ImageMetadata", "ImagesGroup", + "embed_factory", "error_embed_factory", "add_react", "check_if_owner"] # --- Common values --- @@ -48,7 +45,11 @@ emojis = SimpleNamespace(check_mark='✅', warning='⚠️', question='❓', no_entry='⛔', - bangbang='‼️') + bangbang='‼️', + a='🇦', + b='🇧', + c='🇨', + d='🇩') paths = SimpleNamespace(data=Path("./data/"), resources=Path("./resources/"), @@ -95,6 +96,29 @@ class ImagesGroup(collections.abc.Mapping): return str(self._images) +# --- Converters --- + +class GlobalChannelConverter(commands.IDConverter): + """Converter to get any bot-acessible channel by ID/mention (global), or name (in current guild only).""" + async def convert(self, ctx: commands.Context, argument: str): + bot = ctx.bot + guild = ctx.guild + match = self._get_id_match(argument) or re.match(r'<#([0-9]+)>$', argument) + result = None + if match is None: + # not a mention/ID + if guild: + result = discord.utils.get(guild.text_channels, name=argument) + else: + raise commands.BadArgument(f"""Channel named "{argument}" not found in this guild.""") + else: + channel_id = int(match.group(1)) + result = bot.get_channel(channel_id) + if not isinstance(result, (discord.TextChannel, discord.abc.PrivateChannel)): + raise commands.BadArgument(f"""Channel "{argument}" not found.""") + return result + + # --- Helper functions --- def embed_factory(ctx: commands.Context) -> discord.Embed: diff --git a/exts/ae7q.py b/exts/ae7q.py index 6671c7c..bfcbcb6 100644 --- a/exts/ae7q.py +++ b/exts/ae7q.py @@ -1,7 +1,7 @@ """ ae7q extension for qrm --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrm2 and is released under the terms of the GNU General Public License, version 2. @@ -10,13 +10,13 @@ Test callsigns: KN8U: active, restricted AB2EE: expired, restricted KE8FGB: assigned once, no restrictions -NA2AAA: unassigned, no records -KC4USA: reserved but has call history -WF4EMA: " +KV4AAA: unassigned, no records +KC4USA: reserved, no call history, *but* has application history """ import discord.ext.commands as commands +import aiohttp from bs4 import BeautifulSoup import common as cmn @@ -25,7 +25,7 @@ import common as cmn class AE7QCog(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.session = bot.qrm.session + self.session = aiohttp.ClientSession(connector=bot.qrm.connector) @commands.group(name="ae7q", aliases=["ae"], category=cmn.cat.lookup) async def _ae7q_lookup(self, ctx: commands.Context): @@ -33,110 +33,380 @@ class AE7QCog(commands.Cog): if ctx.invoked_subcommand is None: await ctx.send_help(ctx.command) - @_ae7q_lookup.command(name="call", category=cmn.cat.lookup) + @_ae7q_lookup.command(name="call", aliases=["c"], category=cmn.cat.lookup) async def _ae7q_call(self, ctx: commands.Context, callsign: str): - '''Look up the history for a callsign on [ae7q.com](http://ae7q.com/).''' - callsign = callsign.upper() - desc = '' - base_url = "http://ae7q.com/query/data/CallHistory.php?CALL=" - embed = cmn.embed_factory(ctx) + '''Look up the history of a callsign on [ae7q.com](http://ae7q.com/).''' + with ctx.typing(): + callsign = callsign.upper() + desc = '' + base_url = "http://ae7q.com/query/data/CallHistory.php?CALL=" + embed = cmn.embed_factory(ctx) - async with self.session.get(base_url + callsign) as resp: - if resp.status != 200: - embed.title = "Error in AE7Q call command" - embed.description = 'Could not load AE7Q' - embed.colour = cmn.colours.bad - await ctx.send(embed=embed) - return - page = await resp.text() + async with self.session.get(base_url + callsign) as resp: + if resp.status != 200: + embed.title = "Error in AE7Q call command" + embed.description = 'Could not load AE7Q' + embed.colour = cmn.colours.bad + await ctx.send(embed=embed) + return + page = await resp.text() - soup = BeautifulSoup(page, features="html.parser") - tables = soup.select("table.Database") + soup = BeautifulSoup(page, features="html.parser") + tables = [[row for row in table.find_all("tr")] for table in soup.select("table.Database")] - for table in tables: - rows = table.find_all("tr") - if len(rows) > 1 and len(rows[0]) > 1: - break - if desc == '': - for row in rows: + table = tables[0] + + # find the first table in the page, and use it to make a description + if len(table[0]) == 1: + for row in table: desc += " ".join(row.getText().split()) desc += '\n' desc = desc.replace(callsign, f'`{callsign}`') - rows = None + table = tables[1] - first_header = ''.join(rows[0].find_all("th")[0].strings) + table_headers = table[0].find_all("th") + first_header = ''.join(table_headers[0].strings) if len(table_headers) > 0 else None - if rows is None or first_header != 'Entity Name': + # catch if the wrong table was selected + if first_header is None or first_header != 'Entity Name': + embed.title = f"AE7Q History for {callsign}" + embed.colour = cmn.colours.bad + embed.url = base_url + callsign + embed.description = desc + embed.description += f'\nNo records found for `{callsign}`' + await ctx.send(embed=embed) + return + + table = await process_table(table[1:]) + + embed = cmn.embed_factory(ctx) embed.title = f"AE7Q History for {callsign}" - embed.colour = cmn.colours.bad - embed.url = f"{base_url}{callsign}" + embed.colour = cmn.colours.good + embed.url = base_url + callsign + + # add the first three rows of the table to the embed + for row in table[0:3]: + header = f'**{row[0]}** ({row[1]})' # **Name** (Applicant Type) + body = (f'Class: *{row[2]}*\n' + f'Region: *{row[3]}*\n' + f'Status: *{row[4]}*\n' + f'Granted: *{row[5]}*\n' + f'Effective: *{row[6]}*\n' + f'Cancelled: *{row[7]}*\n' + f'Expires: *{row[8]}*') + embed.add_field(name=header, value=body, inline=False) + + if len(table) > 3: + desc += f'\nRecords 1 to 3 of {len(table)}. See ae7q.com for more...' + embed.description = desc - embed.description += f'\nNo records found for `{callsign}`' + await ctx.send(embed=embed) - return - table_contents = [] # store your table here - for tr in rows: - if rows.index(tr) == 0: - # first_header = ''.join(tr.find_all("th")[0].strings) - # if first_header == 'Entity Name': - # print('yooooo') - continue - row_cells = [] - for td in tr.find_all('td'): - if td.getText().strip() != '': - row_cells.append(td.getText().strip()) - else: - row_cells.append('-') - if 'colspan' in td.attrs and int(td.attrs['colspan']) > 1: - for i in range(int(td.attrs['colspan']) - 1): - row_cells.append(row_cells[-1]) - for i, cell in enumerate(row_cells): - if cell == '"': - row_cells[i] = table_contents[-1][i] - if len(row_cells) > 1: - table_contents += [row_cells] + @_ae7q_lookup.command(name="trustee", aliases=["t"], category=cmn.cat.lookup) + async def _ae7q_trustee(self, ctx: commands.Context, callsign: str): + '''Look up the licenses for which a licensee is trustee on [ae7q.com](http://ae7q.com/).''' + with ctx.typing(): + callsign = callsign.upper() + desc = '' + base_url = "http://ae7q.com/query/data/CallHistory.php?CALL=" + embed = cmn.embed_factory(ctx) - embed = cmn.embed_factory(ctx) - embed.title = f"AE7Q Records for {callsign}" - embed.colour = cmn.colours.good - embed.url = f"{base_url}{callsign}" + async with self.session.get(base_url + callsign) as resp: + if resp.status != 200: + embed.title = "Error in AE7Q trustee command" + embed.description = 'Could not load AE7Q' + embed.colour = cmn.colours.bad + await ctx.send(embed=embed) + return + page = await resp.text() - for row in table_contents[0:3]: - header = f'**{row[0]}** ({row[1]})' - body = (f'Class: *{row[2]}*\n' - f'Region: *{row[3]}*\n' - f'Status: *{row[4]}*\n' - f'Granted: *{row[5]}*\n' - f'Effective: *{row[6]}*\n' - f'Cancelled: *{row[7]}*\n' - f'Expires: *{row[8]}*') - embed.add_field(name=header, value=body, inline=False) + soup = BeautifulSoup(page, features="html.parser") + tables = [[row for row in table.find_all("tr")] for table in soup.select("table.Database")] - embed.description = desc - if len(table_contents) > 3: - embed.description += f'\nRecords 1 to 3 of {len(table_contents)}. See ae7q.com for more...' + try: + table = tables[2] if len(tables[0][0]) == 1 else tables[1] + except IndexError: + embed.title = f"AE7Q Trustee History for {callsign}" + embed.colour = cmn.colours.bad + embed.url = base_url + callsign + embed.description = desc + embed.description += f'\nNo records found for `{callsign}`' + await ctx.send(embed=embed) + return - await ctx.send(embed=embed) + table_headers = table[0].find_all("th") + first_header = ''.join(table_headers[0].strings) if len(table_headers) > 0 else None - # TODO: write commands for other AE7Q response types? - # @_ae7q_lookup.command(name="trustee") - # async def _ae7q_trustee(self, ctx: commands.Context, callsign: str): - # pass + # catch if the wrong table was selected + if first_header is None or not first_header.startswith("With"): + embed.title = f"AE7Q Trustee History for {callsign}" + embed.colour = cmn.colours.bad + embed.url = base_url + callsign + embed.description = desc + embed.description += f'\nNo records found for `{callsign}`' + await ctx.send(embed=embed) + return - # @_ae7q_lookup.command(name="applications", aliases=['apps']) - # async def _ae7q_applications(self, ctx: commands.Context, callsign: str): - # pass + table = await process_table(table[2:]) - # @_ae7q_lookup.command(name="frn") - # async def _ae7q_frn(self, ctx: commands.Context, frn: str): - # base_url = "http://ae7q.com/query/data/FrnHistory.php?FRN=" - # pass + embed = cmn.embed_factory(ctx) + embed.title = f"AE7Q Trustee History for {callsign}" + embed.colour = cmn.colours.good + embed.url = base_url + callsign - # @_ae7q_lookup.command(name="licensee", aliases=["lic"]) - # async def _ae7q_licensee(self, ctx: commands.Context, frn: str): - # base_url = "http://ae7q.com/query/data/LicenseeIdHistory.php?ID=" - # pass + # add the first three rows of the table to the embed + for row in table[0:3]: + header = f'**{row[0]}** ({row[3]})' # **Name** (Applicant Type) + body = (f'Name: *{row[2]}*\n' + f'Region: *{row[1]}*\n' + f'Status: *{row[4]}*\n' + f'Granted: *{row[5]}*\n' + f'Effective: *{row[6]}*\n' + f'Cancelled: *{row[7]}*\n' + f'Expires: *{row[8]}*') + embed.add_field(name=header, value=body, inline=False) + + if len(table) > 3: + desc += f'\nRecords 1 to 3 of {len(table)}. See ae7q.com for more...' + + embed.description = desc + + await ctx.send(embed=embed) + + @_ae7q_lookup.command(name="applications", aliases=["a"], category=cmn.cat.lookup) + async def _ae7q_applications(self, ctx: commands.Context, callsign: str): + '''Look up the application history for a callsign on [ae7q.com](http://ae7q.com/).''' + """ + with ctx.typing(): + callsign = callsign.upper() + desc = '' + base_url = "http://ae7q.com/query/data/CallHistory.php?CALL=" + embed = cmn.embed_factory(ctx) + + async with self.session.get(base_url + callsign) as resp: + if resp.status != 200: + embed.title = "Error in AE7Q applications command" + embed.description = 'Could not load AE7Q' + embed.colour = cmn.colours.bad + await ctx.send(embed=embed) + return + page = await resp.text() + + soup = BeautifulSoup(page, features="html.parser") + tables = [[row for row in table.find_all("tr")] for table in soup.select("table.Database")] + + table = tables[0] + + # find the first table in the page, and use it to make a description + if len(table[0]) == 1: + for row in table: + desc += " ".join(row.getText().split()) + desc += '\n' + desc = desc.replace(callsign, f'`{callsign}`') + + # select the last table to get applications + table = tables[-1] + + table_headers = table[0].find_all("th") + first_header = ''.join(table_headers[0].strings) if len(table_headers) > 0 else None + + # catch if the wrong table was selected + if first_header is None or not first_header.startswith("Receipt"): + embed.title = f"AE7Q Application History for {callsign}" + embed.colour = cmn.colours.bad + embed.url = base_url + callsign + embed.description = desc + embed.description += f'\nNo records found for `{callsign}`' + await ctx.send(embed=embed) + return + + table = await process_table(table[1:]) + + embed = cmn.embed_factory(ctx) + embed.title = f"AE7Q Application History for {callsign}" + embed.colour = cmn.colours.good + embed.url = base_url + callsign + + # add the first three rows of the table to the embed + for row in table[0:3]: + header = f'**{row[1]}** ({row[3]})' # **Name** (Callsign) + body = (f'Received: *{row[0]}*\n' + f'Region: *{row[2]}*\n' + f'Purpose: *{row[5]}*\n' + f'Last Action: *{row[7]}*\n' + f'Application Status: *{row[8]}*\n') + embed.add_field(name=header, value=body, inline=False) + + if len(table) > 3: + desc += f'\nRecords 1 to 3 of {len(table)}. See ae7q.com for more...' + + embed.description = desc + + await ctx.send(embed=embed) + """ + pass + + @_ae7q_lookup.command(name="frn", aliases=["f"], category=cmn.cat.lookup) + async def _ae7q_frn(self, ctx: commands.Context, frn: str): + '''Look up the history of an FRN on [ae7q.com](http://ae7q.com/).''' + """ + NOTES: + - 2 tables: callsign history and application history + - If not found: no tables + """ + with ctx.typing(): + base_url = "http://ae7q.com/query/data/FrnHistory.php?FRN=" + embed = cmn.embed_factory(ctx) + + async with self.session.get(base_url + frn) as resp: + if resp.status != 200: + embed.title = "Error in AE7Q frn command" + embed.description = 'Could not load AE7Q' + embed.colour = cmn.colours.bad + await ctx.send(embed=embed) + return + page = await resp.text() + + soup = BeautifulSoup(page, features="html.parser") + tables = [[row for row in table.find_all("tr")] for table in soup.select("table.Database")] + + if not len(tables): + embed.title = f"AE7Q History for FRN {frn}" + embed.colour = cmn.colours.bad + embed.url = base_url + frn + embed.description = f'No records found for FRN `{frn}`' + await ctx.send(embed=embed) + return + + table = tables[0] + + table_headers = table[0].find_all("th") + first_header = ''.join(table_headers[0].strings) if len(table_headers) > 0 else None + + # catch if the wrong table was selected + if first_header is None or not first_header.startswith('With Licensee'): + embed.title = f"AE7Q History for FRN {frn}" + embed.colour = cmn.colours.bad + embed.url = base_url + frn + embed.description = f'No records found for FRN `{frn}`' + await ctx.send(embed=embed) + return + + table = await process_table(table[2:]) + + embed = cmn.embed_factory(ctx) + embed.title = f"AE7Q History for FRN {frn}" + embed.colour = cmn.colours.good + embed.url = base_url + frn + + # add the first three rows of the table to the embed + for row in table[0:3]: + header = f'**{row[0]}** ({row[3]})' # **Callsign** (Applicant Type) + body = (f'Name: *{row[2]}*\n' + f'Class: *{row[4]}*\n' + f'Region: *{row[1]}*\n' + f'Status: *{row[5]}*\n' + f'Granted: *{row[6]}*\n' + f'Effective: *{row[7]}*\n' + f'Cancelled: *{row[8]}*\n' + f'Expires: *{row[9]}*') + embed.add_field(name=header, value=body, inline=False) + + if len(table) > 3: + embed.description = f'Records 1 to 3 of {len(table)}. See ae7q.com for more...' + + await ctx.send(embed=embed) + + @_ae7q_lookup.command(name="licensee", aliases=["l"], category=cmn.cat.lookup) + async def _ae7q_licensee(self, ctx: commands.Context, licensee_id: str): + '''Look up the history of a licensee ID on [ae7q.com](http://ae7q.com/).''' + with ctx.typing(): + licensee_id = licensee_id.upper() + base_url = "http://ae7q.com/query/data/LicenseeIdHistory.php?ID=" + embed = cmn.embed_factory(ctx) + + async with self.session.get(base_url + licensee_id) as resp: + if resp.status != 200: + embed.title = "Error in AE7Q licensee command" + embed.description = 'Could not load AE7Q' + embed.colour = cmn.colours.bad + await ctx.send(embed=embed) + return + page = await resp.text() + + soup = BeautifulSoup(page, features="html.parser") + tables = [[row for row in table.find_all("tr")] for table in soup.select("table.Database")] + + if not len(tables): + embed.title = f"AE7Q History for Licensee {licensee_id}" + embed.colour = cmn.colours.bad + embed.url = base_url + licensee_id + embed.description = f'No records found for Licensee `{licensee_id}`' + await ctx.send(embed=embed) + return + + table = tables[0] + + table_headers = table[0].find_all("th") + first_header = ''.join(table_headers[0].strings) if len(table_headers) > 0 else None + + # catch if the wrong table was selected + if first_header is None or not first_header.startswith('With FCC'): + embed.title = f"AE7Q History for Licensee {licensee_id}" + embed.colour = cmn.colours.bad + embed.url = base_url + licensee_id + embed.description = f'No records found for Licensee `{licensee_id}`' + await ctx.send(embed=embed) + return + + table = await process_table(table[2:]) + + embed = cmn.embed_factory(ctx) + embed.title = f"AE7Q History for Licensee {licensee_id}" + embed.colour = cmn.colours.good + embed.url = base_url + licensee_id + + # add the first three rows of the table to the embed + for row in table[0:3]: + header = f'**{row[0]}** ({row[3]})' # **Callsign** (Applicant Type) + body = (f'Name: *{row[2]}*\n' + f'Class: *{row[4]}*\n' + f'Region: *{row[1]}*\n' + f'Status: *{row[5]}*\n' + f'Granted: *{row[6]}*\n' + f'Effective: *{row[7]}*\n' + f'Cancelled: *{row[8]}*\n' + f'Expires: *{row[9]}*') + embed.add_field(name=header, value=body, inline=False) + + if len(table) > 3: + embed.description = f'Records 1 to 3 of {len(table)}. See ae7q.com for more...' + + await ctx.send(embed=embed) + + +async def process_table(table: list): + """Processes tables (*not* including headers) and returns the processed table""" + table_contents = [] + for tr in table: + row = [] + for td in tr.find_all('td'): + cell_val = td.getText().strip() + row.append(cell_val if cell_val else '-') + + # take care of columns that span multiple rows by copying the contents rightward + if 'colspan' in td.attrs and int(td.attrs['colspan']) > 1: + for i in range(int(td.attrs['colspan']) - 1): + row.append(row[-1]) + + # get rid of ditto marks by copying the contents from the previous row + for i, cell in enumerate(row): + if cell == "\"": + row[i] = table_contents[-1][i] + # add row to table + table_contents += [row] + return table_contents def setup(bot: commands.Bot): diff --git a/exts/base.py b/exts/base.py index 163b52e..6f36582 100644 --- a/exts/base.py +++ b/exts/base.py @@ -1,23 +1,25 @@ """ Base extension for qrm --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrm2 and is released under the terms of the GNU General Public License, version 2. """ + +import random import re from collections import OrderedDict -import random +from typing import Union import discord import discord.ext.commands as commands import info +import common as cmn import data.options as opt -import common as cmn class QrmHelpCommand(commands.HelpCommand): @@ -172,15 +174,15 @@ class BaseCog(commands.Cog): "(https://github.com/classabbyamp/discord-qrm2/issues)!") await ctx.send(embed=embed) - @commands.command(name="bruce") - async def _b_issue(self, ctx: commands.Context): - """Shows how to create an issue for the bot.""" - await ctx.invoke(self._issue) - @commands.command(name="echo", aliases=["e"], category=cmn.cat.admin) @commands.check(cmn.check_if_owner) - async def _echo(self, ctx: commands.Context, channel: commands.TextChannelConverter, *, msg: str): - """Send a message in a channel as qrm. Only works within a server or DM to server, not between servers.""" + async def _echo(self, ctx: commands.Context, + channel: Union[cmn.GlobalChannelConverter, commands.UserConverter], *, msg: str): + """Send a message in a channel as qrm. Accepts channel/user IDs/mentions. + Channel names are current-guild only. + Does not work with the ID of the bot user.""" + if isinstance(channel, discord.ClientUser): + raise commands.BadArgument("Can't send to the bot user!") await channel.send(msg) diff --git a/exts/fun.py b/exts/fun.py index ca6ebb6..6fcbae5 100644 --- a/exts/fun.py +++ b/exts/fun.py @@ -1,7 +1,7 @@ """ Fun extension for qrm --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrm2 and is released under the terms of the GNU General Public License, version 2. diff --git a/exts/grid.py b/exts/grid.py index 1a9366f..e8774ec 100644 --- a/exts/grid.py +++ b/exts/grid.py @@ -1,7 +1,7 @@ """ Grid extension for qrm --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrm2 and is released under the terms of the GNU General Public License, version 2. diff --git a/exts/ham.py b/exts/ham.py index a040ceb..71766ba 100644 --- a/exts/ham.py +++ b/exts/ham.py @@ -1,7 +1,7 @@ """ Ham extension for qrm --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrm2 and is released under the terms of the GNU General Public License, version 2. diff --git a/exts/image.py b/exts/image.py index c4f92d9..b5448d0 100644 --- a/exts/image.py +++ b/exts/image.py @@ -1,7 +1,7 @@ """ Image extension for qrm --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrm2 and is released under the terms of the GNU General Public License, version 2. @@ -9,6 +9,8 @@ General Public License, version 2. import io +import aiohttp + import discord import discord.ext.commands as commands @@ -16,18 +18,20 @@ import common as cmn class ImageCog(commands.Cog): + gl_url = ('http://www.fourmilab.ch/cgi-bin/uncgi/Earth?img=NOAAtopo.evif' + '&imgsize=320&dynimg=y&opt=-p&lat=&lon=&alt=&tle=&date=0&utc=&jd=') + def __init__(self, bot: commands.Bot): self.bot = bot self.bandcharts = cmn.ImagesGroup(cmn.paths.bandcharts / "meta.json") self.maps = cmn.ImagesGroup(cmn.paths.maps / "meta.json") - self.session = bot.qrm.session + self.session = aiohttp.ClientSession(connector=bot.qrm.connector) @commands.command(name="bandplan", aliases=['plan', 'bands'], category=cmn.cat.ref) async def _bandplan(self, ctx: commands.Context, region: str = ''): '''Posts an image of Frequency Allocations.''' - arg = region.lower() - - with ctx.typing(): + async with ctx.typing(): + arg = region.lower() embed = cmn.embed_factory(ctx) if arg not in self.bandcharts: desc = 'Possible arguments are:\n' @@ -37,25 +41,24 @@ class ImageCog(commands.Cog): embed.description = desc embed.colour = cmn.colours.bad await ctx.send(embed=embed) - else: - metadata: cmn.ImageMetadata = self.bandcharts[arg] - img = discord.File(cmn.paths.bandcharts / metadata.filename, - filename=metadata.filename) - if metadata.description: - embed.description = metadata.description - if metadata.source: - embed.add_field(name="Source", value=metadata.source) - embed.title = metadata.long_name + (" " + metadata.emoji if metadata.emoji else "") - embed.colour = cmn.colours.good - embed.set_image(url='attachment://' + metadata.filename) - await ctx.send(embed=embed, file=img) + return + metadata: cmn.ImageMetadata = self.bandcharts[arg] + img = discord.File(cmn.paths.bandcharts / metadata.filename, + filename=metadata.filename) + if metadata.description: + embed.description = metadata.description + if metadata.source: + embed.add_field(name="Source", value=metadata.source) + embed.title = metadata.long_name + (" " + metadata.emoji if metadata.emoji else "") + embed.colour = cmn.colours.good + embed.set_image(url='attachment://' + metadata.filename) + await ctx.send(embed=embed, file=img) @commands.command(name="map", category=cmn.cat.maps) async def _map(self, ctx: commands.Context, map_id: str = ''): '''Posts an image of a ham-relevant map.''' - arg = map_id.lower() - - with ctx.typing(): + async with ctx.typing(): + arg = map_id.lower() embed = cmn.embed_factory(ctx) if arg not in self.maps: desc = 'Possible arguments are:\n' @@ -65,36 +68,35 @@ class ImageCog(commands.Cog): embed.description = desc embed.colour = cmn.colours.bad await ctx.send(embed=embed) - else: - metadata: cmn.ImageMetadata = self.maps[arg] - img = discord.File(cmn.paths.maps / metadata.filename, - filename=metadata.filename) - if metadata.description: - embed.description = metadata.description - if metadata.source: - embed.add_field(name="Source", value=metadata.source) - embed.title = metadata.long_name + (" " + metadata.emoji if metadata.emoji else "") - embed.colour = cmn.colours.good - embed.set_image(url='attachment://' + metadata.filename) - await ctx.send(embed=embed, file=img) + return + metadata: cmn.ImageMetadata = self.maps[arg] + img = discord.File(cmn.paths.maps / metadata.filename, + filename=metadata.filename) + if metadata.description: + embed.description = metadata.description + if metadata.source: + embed.add_field(name="Source", value=metadata.source) + embed.title = metadata.long_name + (" " + metadata.emoji if metadata.emoji else "") + embed.colour = cmn.colours.good + embed.set_image(url='attachment://' + metadata.filename) + await ctx.send(embed=embed, file=img) @commands.command(name="grayline", aliases=['greyline', 'grey', 'gray', 'gl'], category=cmn.cat.maps) async def _grayline(self, ctx: commands.Context): '''Posts a map of the current greyline, where HF propagation is the best.''' - gl_url = ('http://www.fourmilab.ch/cgi-bin/uncgi/Earth?img=NOAAtopo.evif' - '&imgsize=320&dynimg=y&opt=-p&lat=&lon=&alt=&tle=&date=0&utc=&jd=') - with ctx.typing(): + async with ctx.typing(): embed = cmn.embed_factory(ctx) embed.title = 'Current Greyline Conditions' embed.colour = cmn.colours.good - async with self.session.get(gl_url) as resp: + async with self.session.get(self.gl_url) as resp: if resp.status != 200: embed.description = 'Could not download file...' embed.colour = cmn.colours.bad - else: - data = io.BytesIO(await resp.read()) - embed.set_image(url=f'attachment://greyline.jpg') - await ctx.send(embed=embed, file=discord.File(data, 'greyline.jpg')) + await ctx.send(embed=embed) + return + data = io.BytesIO(await resp.read()) + embed.set_image(url=f'attachment://greyline.jpg') + await ctx.send(embed=embed, file=discord.File(data, 'greyline.jpg')) def setup(bot: commands.Bot): diff --git a/exts/lookup.py b/exts/lookup.py index b078bd9..ae22e7a 100644 --- a/exts/lookup.py +++ b/exts/lookup.py @@ -1,7 +1,7 @@ """ Lookup extension for qrm --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrm2 and is released under the terms of the GNU General Public License, version 2. diff --git a/exts/morse.py b/exts/morse.py index 434822b..0f61650 100644 --- a/exts/morse.py +++ b/exts/morse.py @@ -1,7 +1,7 @@ """ Morse Code extension for qrm --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrm2 and is released under the terms of the GNU General Public License, version 2. diff --git a/exts/qrz.py b/exts/qrz.py index 5cc94b8..0d8624a 100644 --- a/exts/qrz.py +++ b/exts/qrz.py @@ -1,7 +1,7 @@ """ QRZ extension for qrm --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrm2 and is released under the terms of the GNU General Public License, version 2. @@ -21,7 +21,7 @@ import data.keys as keys class QRZCog(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.session = bot.qrm.session + self.session = aiohttp.ClientSession(connector=bot.qrm.connector) self._qrz_session_init.start() @commands.command(name="call", aliases=["qrz"], category=cmn.cat.lookup) diff --git a/exts/study.py b/exts/study.py index faf40a0..b6e11c8 100644 --- a/exts/study.py +++ b/exts/study.py @@ -1,7 +1,7 @@ """ Study extension for qrm --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrm2 and is released under the terms of the GNU General Public License, version 2. @@ -9,55 +9,106 @@ General Public License, version 2. import random import json +from datetime import datetime +import asyncio + +import aiohttp import discord.ext.commands as commands import common as cmn +from resources import study class StudyCog(commands.Cog): + choices = {cmn.emojis.a: 'A', cmn.emojis.b: 'B', cmn.emojis.c: 'C', cmn.emojis.d: 'D'} + def __init__(self, bot: commands.Bot): self.bot = bot self.lastq = dict() self.source = 'Data courtesy of [HamStudy.org](https://hamstudy.org/)' - self.session = bot.qrm.session + self.session = aiohttp.ClientSession(connector=bot.qrm.connector) @commands.command(name="hamstudy", aliases=['rq', 'randomquestion', 'randomq'], category=cmn.cat.study) - async def _random_question(self, ctx: commands.Context, level: str = None): - '''Gets a random question from the Technician, General, and/or Extra question pools.''' - tech_pool = 'E2_2018' - gen_pool = 'E3_2019' - extra_pool = 'E4_2016' - - embed = cmn.embed_factory(ctx) + async def _random_question(self, ctx: commands.Context, country: str = '', level: str = ''): + '''Gets a random question from [HamStudy's](https://hamstudy.org) question pools.''' with ctx.typing(): - selected_pool = None - try: - level = level.lower() - except AttributeError: # no level given (it's None) - pass + embed = cmn.embed_factory(ctx) - if level in ['t', 'technician', 'tech']: - selected_pool = tech_pool + country = country.lower() + level = level.lower() - if level in ['g', 'gen', 'general']: - selected_pool = gen_pool + if country in study.pool_names.keys(): + if level in study.pool_names[country].keys(): + pool_name = study.pool_names[country][level] - if level in ['e', 'ae', 'extra']: - selected_pool = extra_pool + elif level in ("random", "r"): + # select a random level in that country + pool_name = random.choice(list(study.pool_names[country].values())) - if (level is None) or (level == 'all'): # no pool given or user wants all, so pick a random pool - selected_pool = random.choice([tech_pool, gen_pool, extra_pool]) - if (level is not None) and (selected_pool is None): # unrecognized pool given by user - embed.title = 'Error in HamStudy command' - embed.description = ('The question pool you gave was unrecognized. ' - 'There are many ways to call up certain question pools - try ?rq t, g, or e. ' - '\n\nNote that currently only the US question pools are available.') + else: + # show list of possible pools + embed.title = "Pool Not Found!" + embed.description = "Possible arguments are:" + embed.colour = cmn.colours.bad + for cty in study.pool_names: + levels = '`, `'.join(study.pool_names[cty].keys()) + embed.add_field(name=f"**Country: `{cty}` {study.pool_emojis[cty]}**", + value=f"Levels: `{levels}`", inline=False) + embed.add_field(name="**Random**", value="To select a random pool or country, use `random` or `r`") + await ctx.send(embed=embed) + return + + elif country in ("random", "r"): + # select a random country and level + country = random.choice(list(study.pool_names.keys())) + pool_name = random.choice(list(study.pool_names[country].values())) + + else: + # show list of possible pools + embed.title = "Pool Not Found!" + embed.description = "Possible arguments are:" embed.colour = cmn.colours.bad + for cty in study.pool_names: + levels = '`, `'.join(study.pool_names[cty].keys()) + embed.add_field(name=f"**Country: `{cty}` {study.pool_emojis[cty]}**", + value=f"Levels: `{levels}`", inline=False) + embed.add_field(name="**Random**", value="To select a random pool or country, use `random` or `r`") await ctx.send(embed=embed) return - async with self.session.get(f'https://hamstudy.org/pools/{selected_pool}') as resp: + pools = await self.hamstudy_get_pools() + + pool_matches = [p for p in pools.keys() if "_".join(p.split("_")[:-1]) == pool_name] + + if len(pool_matches) > 0: + if len(pool_matches) == 1: + pool = pool_matches[0] + else: + # look at valid_from and expires dates to find the correct one + for p in pool_matches: + valid_from = datetime.fromisoformat(pools[p]["valid_from"][:-1] + "+00:00") + expires = datetime.fromisoformat(pools[p]["expires"][:-1] + "+00:00") + + if valid_from < datetime.utcnow() < expires: + pool = p + break + else: + # show list of possible pools + embed.title = "Pool Not Found!" + embed.description = "Possible arguments are:" + embed.colour = cmn.colours.bad + for cty in study.pool_names: + levels = '`, `'.join(study.pool_names[cty].keys()) + embed.add_field(name=f"**Country: `{cty}` {study.pool_emojis[cty]}**", + value=f"Levels: `{levels}`", inline=False) + embed.add_field(name="**Random**", value="To select a random pool or country, use `random` or `r`") + await ctx.send(embed=embed) + return + + pool_meta = pools[pool] + + async with self.session.get(f'https://hamstudy.org/pools/{pool}') as resp: if resp.status != 200: embed.title = 'Error in HamStudy command' embed.description = 'Could not load questions' @@ -71,47 +122,66 @@ class StudyCog(commands.Cog): pool_questions = random.choice(pool_section)['questions'] question = random.choice(pool_questions) - embed.title = question['id'] + embed.title = f"{study.pool_emojis[country]} {pool_meta['class']} {question['id']}" embed.description = self.source - embed.colour = cmn.colours.good embed.add_field(name='Question:', value=question['text'], inline=False) - embed.add_field(name='Answers:', value='**A:** ' + question['answers']['A'] - + '\n**B:** ' + question['answers']['B'] - + '\n**C:** ' + question['answers']['C'] - + '\n**D:** ' + question['answers']['D'], inline=False) - embed.add_field(name='Answer:', value='Type _?rqa_ for answer', inline=False) + embed.add_field(name='Answers:', + value=(f"**{cmn.emojis.a}** {question['answers']['A']}" + f"\n**{cmn.emojis.b}** {question['answers']['B']}" + f"\n**{cmn.emojis.c}** {question['answers']['C']}" + f"\n**{cmn.emojis.d}** {question['answers']['D']}"), + inline=False) + embed.add_field(name='To Answer:', + value=('Answer with reactions below. If not answered within 10 minutes,' + ' the answer will be revealed.'), + inline=False) if 'image' in question: - image_url = f'https://hamstudy.org/_1330011/images/{selected_pool.split("_",1)[1]}/{question["image"]}' + image_url = f'https://hamstudy.org/_1330011/images/{pool.split("_",1)[1]}/{question["image"]}' embed.set_image(url=image_url) - self.lastq[ctx.message.channel.id] = (question['id'], question['answer']) - await ctx.send(embed=embed) - @commands.command(name="hamstudyanswer", aliases=['rqa', 'randomquestionanswer', 'randomqa', 'hamstudya'], - category=cmn.cat.study) - async def _q_answer(self, ctx: commands.Context, answer: str = None): - '''Returns the answer to question last asked (Optional argument: your answer).''' - with ctx.typing(): - correct_ans = self.lastq[ctx.message.channel.id][1] - q_num = self.lastq[ctx.message.channel.id][0] - embed = cmn.embed_factory(ctx) - if answer is not None: - answer = answer.upper() - if answer == correct_ans: - result = f'Correct! The answer to {q_num} was **{correct_ans}**.' - embed.title = f'{q_num} Answer' - embed.description = f'{self.source}\n\n{result}' - embed.colour = cmn.colours.good - else: - result = f'Incorrect. The answer to {q_num} was **{correct_ans}**, not **{answer}**.' - embed.title = f'{q_num} Answer' - embed.description = f'{self.source}\n\n{result}' - embed.colour = cmn.colours.bad + q_msg = await ctx.send(embed=embed) + + await cmn.add_react(q_msg, cmn.emojis.a) + await cmn.add_react(q_msg, cmn.emojis.b) + await cmn.add_react(q_msg, cmn.emojis.c) + await cmn.add_react(q_msg, cmn.emojis.d) + + def check(reaction, user): + return (user.id != self.bot.user.id + and reaction.message.id == q_msg.id + and str(reaction.emoji) in self.choices.keys()) + + try: + reaction, user = await self.bot.wait_for('reaction_add', timeout=600.0, check=check) + except asyncio.TimeoutError: + embed.remove_field(2) + embed.add_field(name="Answer:", value=f"Timed out! The correct answer was **{question['answer']}**.") + await q_msg.edit(embed=embed) + else: + if self.choices[str(reaction.emoji)] == question['answer']: + embed.remove_field(2) + embed.add_field(name="Answer:", value=f"Correct! The answer was **{question['answer']}**.") + embed.colour = cmn.colours.good + await q_msg.edit(embed=embed) else: - result = f'The correct answer to {q_num} was **{correct_ans}**.' - embed.title = f'{q_num} Answer' - embed.description = f'{self.source}\n\n{result}' - embed.colour = cmn.colours.neutral - await ctx.send(embed=embed) + embed.remove_field(2) + embed.add_field(name="Answer:", value=f"Incorrect! The correct answer was **{question['answer']}**.") + embed.colour = cmn.colours.bad + await q_msg.edit(embed=embed) + + async def hamstudy_get_pools(self): + async with self.session.get('https://hamstudy.org/pools/') as resp: + if resp.status != 200: + raise ConnectionError + else: + pools_dict = json.loads(await resp.read()) + + pools = dict() + for ls in pools_dict.values(): + for pool in ls: + pools[pool["id"]] = pool + + return pools def setup(bot: commands.Bot): diff --git a/exts/weather.py b/exts/weather.py index 978d057..e877a02 100644 --- a/exts/weather.py +++ b/exts/weather.py @@ -1,7 +1,7 @@ """ Weather extension for qrm --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrm2 and is released under the terms of the GNU General Public License, version 2. @@ -10,6 +10,8 @@ General Public License, version 2. import io import re +import aiohttp + import discord import discord.ext.commands as commands @@ -21,12 +23,12 @@ class WeatherCog(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.session = bot.qrm.session + self.session = aiohttp.ClientSession(connector=bot.qrm.connector) @commands.command(name="bandconditions", aliases=['cond', 'condx', 'conditions'], category=cmn.cat.weather) async def _band_conditions(self, ctx: commands.Context): '''Posts an image of HF Band Conditions.''' - with ctx.typing(): + async with ctx.typing(): embed = cmn.embed_factory(ctx) embed.title = 'Current Solar Conditions' embed.colour = cmn.colours.good @@ -34,10 +36,11 @@ class WeatherCog(commands.Cog): if resp.status != 200: embed.description = 'Could not download file...' embed.colour = cmn.colours.bad - else: - data = io.BytesIO(await resp.read()) - embed.set_image(url=f'attachment://condx.png') - await ctx.send(embed=embed, file=discord.File(data, 'condx.png')) + await ctx.send(embed=embed) + return + data = io.BytesIO(await resp.read()) + embed.set_image(url=f'attachment://condx.png') + await ctx.send(embed=embed, file=discord.File(data, 'condx.png')) @commands.group(name="weather", aliases=['wttr'], category=cmn.cat.weather) async def _weather_conditions(self, ctx: commands.Context): @@ -59,7 +62,7 @@ class WeatherCog(commands.Cog): async def _weather_conditions_forecast(self, ctx: commands.Context, *, location: str): '''Posts an image of Local Weather Conditions for the next three days from [wttr.in](http://wttr.in/). See help for weather command for possible location types. Add a `-c` or `-f` to use Celcius or Fahrenheit.''' - with ctx.typing(): + async with ctx.typing(): try: units_arg = re.search(self.wttr_units_regex, location).group(1) except AttributeError: @@ -83,16 +86,17 @@ See help for weather command for possible location types. Add a `-c` or `-f` to if resp.status != 200: embed.description = 'Could not download file...' embed.colour = cmn.colours.bad - else: - data = io.BytesIO(await resp.read()) - embed.set_image(url=f'attachment://wttr_forecast.png') - await ctx.send(embed=embed, file=discord.File(data, 'wttr_forecast.png')) + await ctx.send(embed=embed) + return + data = io.BytesIO(await resp.read()) + embed.set_image(url=f'attachment://wttr_forecast.png') + await ctx.send(embed=embed, file=discord.File(data, 'wttr_forecast.png')) @_weather_conditions.command(name='now', aliases=['n'], category=cmn.cat.weather) async def _weather_conditions_now(self, ctx: commands.Context, *, location: str): '''Posts an image of current Local Weather Conditions from [wttr.in](http://wttr.in/). See help for weather command for possible location types. Add a `-c` or `-f` to use Celcius or Fahrenheit.''' - with ctx.typing(): + async with ctx.typing(): try: units_arg = re.search(self.wttr_units_regex, location).group(1) except AttributeError: @@ -116,10 +120,11 @@ See help for weather command for possible location types. Add a `-c` or `-f` to if resp.status != 200: embed.description = 'Could not download file...' embed.colour = cmn.colours.bad - else: - data = io.BytesIO(await resp.read()) - embed.set_image(url=f'attachment://wttr_now.png') - await ctx.send(embed=embed, file=discord.File(data, 'wttr_now.png')) + await ctx.send(embed=embed) + return + data = io.BytesIO(await resp.read()) + embed.set_image(url=f'attachment://wttr_now.png') + await ctx.send(embed=embed, file=discord.File(data, 'wttr_now.png')) def setup(bot: commands.Bot): diff --git a/info.py b/info.py index c429a44..5a442b2 100644 --- a/info.py +++ b/info.py @@ -1,7 +1,7 @@ """ Static info about the bot. --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrm2 and is released under the terms of the GNU General Public License, version 2. diff --git a/main.py b/main.py index b68303e..0dfec4d 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,7 @@ """ qrm, a bot for Discord --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrm2 and is released under the terms of the GNU General Public License, version 2. @@ -11,17 +11,19 @@ General Public License, version 2. import sys import traceback +import asyncio from datetime import time, datetime import random from types import SimpleNamespace import pytz -import aiohttp import discord from discord.ext import commands, tasks +import utils.connector as conn import common as cmn + import info import data.options as opt import data.keys as keys @@ -38,13 +40,21 @@ debug_mode = opt.debug # Separate assignement in-case we define an override (te # --- Bot setup --- +# Loop/aiohttp stuff +loop = asyncio.get_event_loop() +connector = loop.run_until_complete(conn.new_connector()) + bot = commands.Bot(command_prefix=opt.prefix, description=info.description, - help_command=commands.MinimalHelpCommand()) + help_command=commands.MinimalHelpCommand(), + loop=loop, + connector=connector) +# Simple way to access bot-wide stuff in extensions. bot.qrm = SimpleNamespace() -bot.qrm.session = aiohttp.ClientSession(headers={'User-Agent': f'discord-qrm2/{info.release}'}) +# Let's store stuff here. +bot.qrm.connector = connector bot.qrm.debug_mode = debug_mode @@ -54,7 +64,6 @@ bot.qrm.debug_mode = debug_mode @commands.check(cmn.check_if_owner) async def _restart_bot(ctx: commands.Context): """Restarts the bot.""" - await bot.qrm.session.close() global exit_code await cmn.add_react(ctx.message, cmn.emojis.check_mark) print(f"[**] Restarting! Requested by {ctx.author}.") @@ -66,7 +75,6 @@ async def _restart_bot(ctx: commands.Context): @commands.check(cmn.check_if_owner) async def _shutdown_bot(ctx: commands.Context): """Shuts down the bot.""" - await bot.qrm.session.close() global exit_code await cmn.add_react(ctx.message, cmn.emojis.check_mark) print(f"[**] Shutting down! Requested by {ctx.author}.") @@ -74,7 +82,7 @@ async def _shutdown_bot(ctx: commands.Context): await bot.logout() -@bot.group(name="extctl", category=cmn.cat.admin) +@bot.group(name="extctl", aliases=["ex"], category=cmn.cat.admin) @commands.check(cmn.check_if_owner) async def _extctl(ctx: commands.Context): """Extension control commands. @@ -84,7 +92,7 @@ async def _extctl(ctx: commands.Context): await ctx.invoke(cmd) -@_extctl.command(name="list") +@_extctl.command(name="list", aliases=["ls"]) @commands.check(cmn.check_if_owner) async def _extctl_list(ctx: commands.Context): """Lists Extensions.""" @@ -94,7 +102,7 @@ async def _extctl_list(ctx: commands.Context): await ctx.send(embed=embed) -@_extctl.command(name="load") +@_extctl.command(name="load", aliases=["ld"]) @commands.check(cmn.check_if_owner) async def _extctl_load(ctx: commands.Context, extension: str): try: @@ -105,7 +113,7 @@ async def _extctl_load(ctx: commands.Context, extension: str): await ctx.send(embed=embed) -@_extctl.command(name="reload", aliases=["relaod"]) +@_extctl.command(name="reload", aliases=["rl", "r", "relaod"]) @commands.check(cmn.check_if_owner) async def _extctl_reload(ctx: commands.Context, extension: str): if ctx.invoked_with == "relaod": @@ -120,7 +128,7 @@ async def _extctl_reload(ctx: commands.Context, extension: str): await ctx.send(embed=embed) -@_extctl.command(name="unload") +@_extctl.command(name="unload", aliases=["ul"]) @commands.check(cmn.check_if_owner) async def _extctl_unload(ctx: commands.Context, extension: str): try: @@ -160,8 +168,11 @@ async def on_command_error(ctx: commands.Context, err: commands.CommandError): if isinstance(err, commands.UserInputError): await cmn.add_react(ctx.message, cmn.emojis.warning) await ctx.send_help(ctx.command) - elif isinstance(err, commands.CommandNotFound) and not ctx.invoked_with.startswith("?"): - await cmn.add_react(ctx.message, cmn.emojis.question) + elif isinstance(err, commands.CommandNotFound): + if ctx.invoked_with.startswith(("?", "!")): + return + else: + await cmn.add_react(ctx.message, cmn.emojis.question) elif isinstance(err, commands.CheckFailure): # Add handling of other subclasses of CheckFailure as needed. if isinstance(err, commands.NotOwner): @@ -250,6 +261,7 @@ except ConnectionResetError as ex: raise raise SystemExit("ConnectionResetError: {}".format(ex)) + # --- Exit --- # Codes for the wrapper shell script: # 0 - Clean exit, don't restart diff --git a/resources/callsign_info.py b/resources/callsign_info.py index c8f4945..ac671f4 100644 --- a/resources/callsign_info.py +++ b/resources/callsign_info.py @@ -1,7 +1,7 @@ """ Information about callsigns for the vanity prefixes command in hamcog. --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrmbot and is released under the terms of the GNU General Public License, version 2. diff --git a/resources/morse.py b/resources/morse.py index 97181d6..950306c 100644 --- a/resources/morse.py +++ b/resources/morse.py @@ -1,7 +1,7 @@ """ A listing of morse code symbols --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrmbot and is released under the terms of the GNU General Public License, version 2. diff --git a/resources/phonetics.py b/resources/phonetics.py index 7dca02b..45bf2c7 100644 --- a/resources/phonetics.py +++ b/resources/phonetics.py @@ -1,7 +1,7 @@ """ A listing of NATO Phonetics --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrmbot and is released under the terms of the GNU General Public License, version 2. diff --git a/resources/qcodes.py b/resources/qcodes.py index 562a6c1..394472c 100644 --- a/resources/qcodes.py +++ b/resources/qcodes.py @@ -1,7 +1,7 @@ """ A listing of Q Codes --- -Copyright (C) 2019 Abigail Gold, 0x5c +Copyright (C) 2019-2020 Abigail Gold, 0x5c This file is part of discord-qrmbot and is released under the terms of the GNU General Public License, version 2. diff --git a/resources/study.py b/resources/study.py new file mode 100644 index 0000000..b68f99f --- /dev/null +++ b/resources/study.py @@ -0,0 +1,49 @@ +""" +A listing of hamstudy command resources +--- +Copyright (C) 2019-2020 Abigail Gold, 0x5c + +This file is part of discord-qrmbot and is released under the terms of the GNU +General Public License, version 2. +""" + +pool_names = {'us': {'technician': 'E2', + 'tech': 'E2', + 't': 'E2', + 'general': 'E3', + 'gen': 'E3', + 'g': 'E3', + 'extra': 'E4', + 'e': 'E4'}, + 'ca': {'basic': 'CA_B', + 'b': 'CA_B', + 'advanced': 'CA_A', + 'adv': 'CA_A', + 'a': 'CA_A', + 'basic_fr': 'CA_FB', + 'b_fr': 'CA_FB', + 'base': 'CA_FB', + 'advanced_fr': 'CA_FS', + 'adv_fr': 'CA_FS', + 'a_fr': 'CA_FS', + 'supérieure': 'CA_FS', + 'superieure': 'CA_FS', + 's': 'CA_FS'}, + 'us_c': {'c1': 'C1', + 'comm1': 'C1', + 'c3': 'C3', + 'comm3': 'C3', + 'c6': 'C6', + 'comm6': 'C6', + 'c7': 'C7', + 'comm7': 'C7', + 'c7r': 'C7R', + 'comm7r': 'C7R', + 'c8': 'C8', + 'comm8': 'C8', + 'c9': 'C9', + 'comm9': 'C9'}} + +pool_emojis = {'us': '🇺🇸', + 'ca': '🇨🇦', + 'us_c': '🇺🇸 🏢'} diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..c2387da --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,3 @@ +""" +Various utilities for the bot. +""" diff --git a/utils/connector.py b/utils/connector.py new file mode 100644 index 0000000..7fce782 --- /dev/null +++ b/utils/connector.py @@ -0,0 +1,16 @@ +""" +Wrapper to handle aiohttp connector creation. +--- +Copyright (C) 2020 Abigail Gold, 0x5c + +This file is part of discord-qrm2 and is released under the terms of the GNU +General Public License, version 2. +""" + + +import aiohttp + + +async def new_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """*Yes, it's just a coro to instantiate a class.*""" + return aiohttp.TCPConnector(*args, **kwargs)