Merge branch 'master' into help-checks

This commit is contained in:
Abigail Gold 2020-01-21 22:34:59 -05:00 committed by GitHub
commit 8686b0ef96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 712 additions and 253 deletions

View File

@ -7,12 +7,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- Added Trustee field to qrz command for club callsigns. - 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 ### Changed
- Changelog command to accept a version as argument. - 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. - 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 <country> <pool>`.
- Replaced `hamstudyanswer` command with answering by reaction.
### Fixed ### Fixed
- Fixed ditto marks (") appearing in the ae7q call command. - Fixed ditto marks (") appearing in the ae7q call command.
- Fixed issue where incorrect table was parsed in 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 ## [2.1.0] - 2020-01-04

View File

@ -28,7 +28,7 @@ $ run.sh
## Copyright ## 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, This program is released under the terms of the GNU General Public License,
version 2. See `COPYING` for full license text. version 2. See `COPYING` for full license text.

View File

@ -1,23 +1,19 @@
""" """
Common tools for the bot. 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 This file is part of discord-qrm2 and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.
---
`colours`: Colours used by embeds.
`cat`: Category names for the HelpCommand.
""" """
import collections import collections
import json import json
import re
import traceback import traceback
from pathlib import Path
from datetime import datetime from datetime import datetime
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import discord import discord
@ -26,7 +22,8 @@ import discord.ext.commands as commands
import data.options as opt 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 --- # --- Common values ---
@ -48,7 +45,11 @@ emojis = SimpleNamespace(check_mark='✅',
warning='⚠️', warning='⚠️',
question='', question='',
no_entry='', no_entry='',
bangbang='‼️') bangbang='‼️',
a='🇦',
b='🇧',
c='🇨',
d='🇩')
paths = SimpleNamespace(data=Path("./data/"), paths = SimpleNamespace(data=Path("./data/"),
resources=Path("./resources/"), resources=Path("./resources/"),
@ -95,6 +96,29 @@ class ImagesGroup(collections.abc.Mapping):
return str(self._images) 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 --- # --- Helper functions ---
def embed_factory(ctx: commands.Context) -> discord.Embed: def embed_factory(ctx: commands.Context) -> discord.Embed:

View File

@ -1,7 +1,7 @@
""" """
ae7q extension for qrm 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 This file is part of discord-qrm2 and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.
@ -10,13 +10,13 @@ Test callsigns:
KN8U: active, restricted KN8U: active, restricted
AB2EE: expired, restricted AB2EE: expired, restricted
KE8FGB: assigned once, no restrictions KE8FGB: assigned once, no restrictions
NA2AAA: unassigned, no records KV4AAA: unassigned, no records
KC4USA: reserved but has call history KC4USA: reserved, no call history, *but* has application history
WF4EMA: "
""" """
import discord.ext.commands as commands import discord.ext.commands as commands
import aiohttp
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import common as cmn import common as cmn
@ -25,7 +25,7 @@ import common as cmn
class AE7QCog(commands.Cog): class AE7QCog(commands.Cog):
def __init__(self, bot: commands.Bot): def __init__(self, bot: commands.Bot):
self.bot = 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) @commands.group(name="ae7q", aliases=["ae"], category=cmn.cat.lookup)
async def _ae7q_lookup(self, ctx: commands.Context): async def _ae7q_lookup(self, ctx: commands.Context):
@ -33,110 +33,380 @@ class AE7QCog(commands.Cog):
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:
await ctx.send_help(ctx.command) 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): async def _ae7q_call(self, ctx: commands.Context, callsign: str):
'''Look up the history for a callsign on [ae7q.com](http://ae7q.com/).''' '''Look up the history of a callsign on [ae7q.com](http://ae7q.com/).'''
callsign = callsign.upper() with ctx.typing():
desc = '' callsign = callsign.upper()
base_url = "http://ae7q.com/query/data/CallHistory.php?CALL=" desc = ''
embed = cmn.embed_factory(ctx) 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: async with self.session.get(base_url + callsign) as resp:
if resp.status != 200: if resp.status != 200:
embed.title = "Error in AE7Q call command" embed.title = "Error in AE7Q call command"
embed.description = 'Could not load AE7Q' embed.description = 'Could not load AE7Q'
embed.colour = cmn.colours.bad embed.colour = cmn.colours.bad
await ctx.send(embed=embed) await ctx.send(embed=embed)
return return
page = await resp.text() page = await resp.text()
soup = BeautifulSoup(page, features="html.parser") soup = BeautifulSoup(page, features="html.parser")
tables = soup.select("table.Database") tables = [[row for row in table.find_all("tr")] for table in soup.select("table.Database")]
for table in tables: table = tables[0]
rows = table.find_all("tr")
if len(rows) > 1 and len(rows[0]) > 1: # find the first table in the page, and use it to make a description
break if len(table[0]) == 1:
if desc == '': for row in table:
for row in rows:
desc += " ".join(row.getText().split()) desc += " ".join(row.getText().split())
desc += '\n' desc += '\n'
desc = desc.replace(callsign, f'`{callsign}`') 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.title = f"AE7Q History for {callsign}"
embed.colour = cmn.colours.bad embed.colour = cmn.colours.good
embed.url = f"{base_url}{callsign}" 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 = desc
embed.description += f'\nNo records found for `{callsign}`'
await ctx.send(embed=embed) await ctx.send(embed=embed)
return
table_contents = [] # store your table here @_ae7q_lookup.command(name="trustee", aliases=["t"], category=cmn.cat.lookup)
for tr in rows: async def _ae7q_trustee(self, ctx: commands.Context, callsign: str):
if rows.index(tr) == 0: '''Look up the licenses for which a licensee is trustee on [ae7q.com](http://ae7q.com/).'''
# first_header = ''.join(tr.find_all("th")[0].strings) with ctx.typing():
# if first_header == 'Entity Name': callsign = callsign.upper()
# print('yooooo') desc = ''
continue base_url = "http://ae7q.com/query/data/CallHistory.php?CALL="
row_cells = [] embed = cmn.embed_factory(ctx)
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]
embed = cmn.embed_factory(ctx) async with self.session.get(base_url + callsign) as resp:
embed.title = f"AE7Q Records for {callsign}" if resp.status != 200:
embed.colour = cmn.colours.good embed.title = "Error in AE7Q trustee command"
embed.url = f"{base_url}{callsign}" 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]: soup = BeautifulSoup(page, features="html.parser")
header = f'**{row[0]}** ({row[1]})' tables = [[row for row in table.find_all("tr")] for table in soup.select("table.Database")]
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)
embed.description = desc try:
if len(table_contents) > 3: table = tables[2] if len(tables[0][0]) == 1 else tables[1]
embed.description += f'\nRecords 1 to 3 of {len(table_contents)}. See ae7q.com for more...' 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? # catch if the wrong table was selected
# @_ae7q_lookup.command(name="trustee") if first_header is None or not first_header.startswith("With"):
# async def _ae7q_trustee(self, ctx: commands.Context, callsign: str): embed.title = f"AE7Q Trustee History for {callsign}"
# pass 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']) table = await process_table(table[2:])
# async def _ae7q_applications(self, ctx: commands.Context, callsign: str):
# pass
# @_ae7q_lookup.command(name="frn") embed = cmn.embed_factory(ctx)
# async def _ae7q_frn(self, ctx: commands.Context, frn: str): embed.title = f"AE7Q Trustee History for {callsign}"
# base_url = "http://ae7q.com/query/data/FrnHistory.php?FRN=" embed.colour = cmn.colours.good
# pass embed.url = base_url + callsign
# @_ae7q_lookup.command(name="licensee", aliases=["lic"]) # add the first three rows of the table to the embed
# async def _ae7q_licensee(self, ctx: commands.Context, frn: str): for row in table[0:3]:
# base_url = "http://ae7q.com/query/data/LicenseeIdHistory.php?ID=" header = f'**{row[0]}** ({row[3]})' # **Name** (Applicant Type)
# pass 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): def setup(bot: commands.Bot):

View File

@ -1,23 +1,25 @@
""" """
Base extension for qrm 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 This file is part of discord-qrm2 and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.
""" """
import random
import re import re
from collections import OrderedDict from collections import OrderedDict
import random from typing import Union
import discord import discord
import discord.ext.commands as commands import discord.ext.commands as commands
import info import info
import common as cmn
import data.options as opt import data.options as opt
import common as cmn
class QrmHelpCommand(commands.HelpCommand): class QrmHelpCommand(commands.HelpCommand):
@ -172,15 +174,15 @@ class BaseCog(commands.Cog):
"(https://github.com/classabbyamp/discord-qrm2/issues)!") "(https://github.com/classabbyamp/discord-qrm2/issues)!")
await ctx.send(embed=embed) 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.command(name="echo", aliases=["e"], category=cmn.cat.admin)
@commands.check(cmn.check_if_owner) @commands.check(cmn.check_if_owner)
async def _echo(self, ctx: commands.Context, channel: commands.TextChannelConverter, *, msg: str): async def _echo(self, ctx: commands.Context,
"""Send a message in a channel as qrm. Only works within a server or DM to server, not between servers.""" 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) await channel.send(msg)

View File

@ -1,7 +1,7 @@
""" """
Fun extension for qrm 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 This file is part of discord-qrm2 and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.

View File

@ -1,7 +1,7 @@
""" """
Grid extension for qrm 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 This file is part of discord-qrm2 and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.

View File

@ -1,7 +1,7 @@
""" """
Ham extension for qrm 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 This file is part of discord-qrm2 and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.

View File

@ -1,7 +1,7 @@
""" """
Image extension for qrm 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 This file is part of discord-qrm2 and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.
@ -9,6 +9,8 @@ General Public License, version 2.
import io import io
import aiohttp
import discord import discord
import discord.ext.commands as commands import discord.ext.commands as commands
@ -16,18 +18,20 @@ import common as cmn
class ImageCog(commands.Cog): 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): def __init__(self, bot: commands.Bot):
self.bot = bot self.bot = bot
self.bandcharts = cmn.ImagesGroup(cmn.paths.bandcharts / "meta.json") self.bandcharts = cmn.ImagesGroup(cmn.paths.bandcharts / "meta.json")
self.maps = cmn.ImagesGroup(cmn.paths.maps / "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) @commands.command(name="bandplan", aliases=['plan', 'bands'], category=cmn.cat.ref)
async def _bandplan(self, ctx: commands.Context, region: str = ''): async def _bandplan(self, ctx: commands.Context, region: str = ''):
'''Posts an image of Frequency Allocations.''' '''Posts an image of Frequency Allocations.'''
arg = region.lower() async with ctx.typing():
arg = region.lower()
with ctx.typing():
embed = cmn.embed_factory(ctx) embed = cmn.embed_factory(ctx)
if arg not in self.bandcharts: if arg not in self.bandcharts:
desc = 'Possible arguments are:\n' desc = 'Possible arguments are:\n'
@ -37,25 +41,24 @@ class ImageCog(commands.Cog):
embed.description = desc embed.description = desc
embed.colour = cmn.colours.bad embed.colour = cmn.colours.bad
await ctx.send(embed=embed) await ctx.send(embed=embed)
else: return
metadata: cmn.ImageMetadata = self.bandcharts[arg] metadata: cmn.ImageMetadata = self.bandcharts[arg]
img = discord.File(cmn.paths.bandcharts / metadata.filename, img = discord.File(cmn.paths.bandcharts / metadata.filename,
filename=metadata.filename) filename=metadata.filename)
if metadata.description: if metadata.description:
embed.description = metadata.description embed.description = metadata.description
if metadata.source: if metadata.source:
embed.add_field(name="Source", value=metadata.source) embed.add_field(name="Source", value=metadata.source)
embed.title = metadata.long_name + (" " + metadata.emoji if metadata.emoji else "") embed.title = metadata.long_name + (" " + metadata.emoji if metadata.emoji else "")
embed.colour = cmn.colours.good embed.colour = cmn.colours.good
embed.set_image(url='attachment://' + metadata.filename) embed.set_image(url='attachment://' + metadata.filename)
await ctx.send(embed=embed, file=img) await ctx.send(embed=embed, file=img)
@commands.command(name="map", category=cmn.cat.maps) @commands.command(name="map", category=cmn.cat.maps)
async def _map(self, ctx: commands.Context, map_id: str = ''): async def _map(self, ctx: commands.Context, map_id: str = ''):
'''Posts an image of a ham-relevant map.''' '''Posts an image of a ham-relevant map.'''
arg = map_id.lower() async with ctx.typing():
arg = map_id.lower()
with ctx.typing():
embed = cmn.embed_factory(ctx) embed = cmn.embed_factory(ctx)
if arg not in self.maps: if arg not in self.maps:
desc = 'Possible arguments are:\n' desc = 'Possible arguments are:\n'
@ -65,36 +68,35 @@ class ImageCog(commands.Cog):
embed.description = desc embed.description = desc
embed.colour = cmn.colours.bad embed.colour = cmn.colours.bad
await ctx.send(embed=embed) await ctx.send(embed=embed)
else: return
metadata: cmn.ImageMetadata = self.maps[arg] metadata: cmn.ImageMetadata = self.maps[arg]
img = discord.File(cmn.paths.maps / metadata.filename, img = discord.File(cmn.paths.maps / metadata.filename,
filename=metadata.filename) filename=metadata.filename)
if metadata.description: if metadata.description:
embed.description = metadata.description embed.description = metadata.description
if metadata.source: if metadata.source:
embed.add_field(name="Source", value=metadata.source) embed.add_field(name="Source", value=metadata.source)
embed.title = metadata.long_name + (" " + metadata.emoji if metadata.emoji else "") embed.title = metadata.long_name + (" " + metadata.emoji if metadata.emoji else "")
embed.colour = cmn.colours.good embed.colour = cmn.colours.good
embed.set_image(url='attachment://' + metadata.filename) embed.set_image(url='attachment://' + metadata.filename)
await ctx.send(embed=embed, file=img) await ctx.send(embed=embed, file=img)
@commands.command(name="grayline", aliases=['greyline', 'grey', 'gray', 'gl'], category=cmn.cat.maps) @commands.command(name="grayline", aliases=['greyline', 'grey', 'gray', 'gl'], category=cmn.cat.maps)
async def _grayline(self, ctx: commands.Context): async def _grayline(self, ctx: commands.Context):
'''Posts a map of the current greyline, where HF propagation is the best.''' '''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' async with ctx.typing():
'&imgsize=320&dynimg=y&opt=-p&lat=&lon=&alt=&tle=&date=0&utc=&jd=')
with ctx.typing():
embed = cmn.embed_factory(ctx) embed = cmn.embed_factory(ctx)
embed.title = 'Current Greyline Conditions' embed.title = 'Current Greyline Conditions'
embed.colour = cmn.colours.good 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: if resp.status != 200:
embed.description = 'Could not download file...' embed.description = 'Could not download file...'
embed.colour = cmn.colours.bad embed.colour = cmn.colours.bad
else: await ctx.send(embed=embed)
data = io.BytesIO(await resp.read()) return
embed.set_image(url=f'attachment://greyline.jpg') data = io.BytesIO(await resp.read())
await ctx.send(embed=embed, file=discord.File(data, 'greyline.jpg')) embed.set_image(url=f'attachment://greyline.jpg')
await ctx.send(embed=embed, file=discord.File(data, 'greyline.jpg'))
def setup(bot: commands.Bot): def setup(bot: commands.Bot):

View File

@ -1,7 +1,7 @@
""" """
Lookup extension for qrm 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 This file is part of discord-qrm2 and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.

View File

@ -1,7 +1,7 @@
""" """
Morse Code extension for qrm 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 This file is part of discord-qrm2 and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.

View File

@ -1,7 +1,7 @@
""" """
QRZ extension for qrm 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 This file is part of discord-qrm2 and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.
@ -21,7 +21,7 @@ import data.keys as keys
class QRZCog(commands.Cog): class QRZCog(commands.Cog):
def __init__(self, bot: commands.Bot): def __init__(self, bot: commands.Bot):
self.bot = bot self.bot = bot
self.session = bot.qrm.session self.session = aiohttp.ClientSession(connector=bot.qrm.connector)
self._qrz_session_init.start() self._qrz_session_init.start()
@commands.command(name="call", aliases=["qrz"], category=cmn.cat.lookup) @commands.command(name="call", aliases=["qrz"], category=cmn.cat.lookup)

View File

@ -1,7 +1,7 @@
""" """
Study extension for qrm 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 This file is part of discord-qrm2 and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.
@ -9,55 +9,106 @@ General Public License, version 2.
import random import random
import json import json
from datetime import datetime
import asyncio
import aiohttp
import discord.ext.commands as commands import discord.ext.commands as commands
import common as cmn import common as cmn
from resources import study
class StudyCog(commands.Cog): 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): def __init__(self, bot: commands.Bot):
self.bot = bot self.bot = bot
self.lastq = dict() self.lastq = dict()
self.source = 'Data courtesy of [HamStudy.org](https://hamstudy.org/)' 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) @commands.command(name="hamstudy", aliases=['rq', 'randomquestion', 'randomq'], category=cmn.cat.study)
async def _random_question(self, ctx: commands.Context, level: str = None): async def _random_question(self, ctx: commands.Context, country: str = '', level: str = ''):
'''Gets a random question from the Technician, General, and/or Extra question pools.''' '''Gets a random question from [HamStudy's](https://hamstudy.org) question pools.'''
tech_pool = 'E2_2018'
gen_pool = 'E3_2019'
extra_pool = 'E4_2016'
embed = cmn.embed_factory(ctx)
with ctx.typing(): with ctx.typing():
selected_pool = None embed = cmn.embed_factory(ctx)
try:
level = level.lower()
except AttributeError: # no level given (it's None)
pass
if level in ['t', 'technician', 'tech']: country = country.lower()
selected_pool = tech_pool level = level.lower()
if level in ['g', 'gen', 'general']: if country in study.pool_names.keys():
selected_pool = gen_pool if level in study.pool_names[country].keys():
pool_name = study.pool_names[country][level]
if level in ['e', 'ae', 'extra']: elif level in ("random", "r"):
selected_pool = extra_pool # 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 else:
selected_pool = random.choice([tech_pool, gen_pool, extra_pool]) # show list of possible pools
if (level is not None) and (selected_pool is None): # unrecognized pool given by user embed.title = "Pool Not Found!"
embed.title = 'Error in HamStudy command' embed.description = "Possible arguments are:"
embed.description = ('The question pool you gave was unrecognized. ' embed.colour = cmn.colours.bad
'There are many ways to call up certain question pools - try ?rq t, g, or e. ' for cty in study.pool_names:
'\n\nNote that currently only the US question pools are available.') 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 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) await ctx.send(embed=embed)
return 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: if resp.status != 200:
embed.title = 'Error in HamStudy command' embed.title = 'Error in HamStudy command'
embed.description = 'Could not load questions' embed.description = 'Could not load questions'
@ -71,47 +122,66 @@ class StudyCog(commands.Cog):
pool_questions = random.choice(pool_section)['questions'] pool_questions = random.choice(pool_section)['questions']
question = random.choice(pool_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.description = self.source
embed.colour = cmn.colours.good
embed.add_field(name='Question:', value=question['text'], inline=False) embed.add_field(name='Question:', value=question['text'], inline=False)
embed.add_field(name='Answers:', value='**A:** ' + question['answers']['A'] embed.add_field(name='Answers:',
+ '\n**B:** ' + question['answers']['B'] value=(f"**{cmn.emojis.a}** {question['answers']['A']}"
+ '\n**C:** ' + question['answers']['C'] f"\n**{cmn.emojis.b}** {question['answers']['B']}"
+ '\n**D:** ' + question['answers']['D'], inline=False) f"\n**{cmn.emojis.c}** {question['answers']['C']}"
embed.add_field(name='Answer:', value='Type _?rqa_ for answer', inline=False) 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: 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) 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'], q_msg = await ctx.send(embed=embed)
category=cmn.cat.study)
async def _q_answer(self, ctx: commands.Context, answer: str = None): await cmn.add_react(q_msg, cmn.emojis.a)
'''Returns the answer to question last asked (Optional argument: your answer).''' await cmn.add_react(q_msg, cmn.emojis.b)
with ctx.typing(): await cmn.add_react(q_msg, cmn.emojis.c)
correct_ans = self.lastq[ctx.message.channel.id][1] await cmn.add_react(q_msg, cmn.emojis.d)
q_num = self.lastq[ctx.message.channel.id][0]
embed = cmn.embed_factory(ctx) def check(reaction, user):
if answer is not None: return (user.id != self.bot.user.id
answer = answer.upper() and reaction.message.id == q_msg.id
if answer == correct_ans: and str(reaction.emoji) in self.choices.keys())
result = f'Correct! The answer to {q_num} was **{correct_ans}**.'
embed.title = f'{q_num} Answer' try:
embed.description = f'{self.source}\n\n{result}' reaction, user = await self.bot.wait_for('reaction_add', timeout=600.0, check=check)
embed.colour = cmn.colours.good except asyncio.TimeoutError:
else: embed.remove_field(2)
result = f'Incorrect. The answer to {q_num} was **{correct_ans}**, not **{answer}**.' embed.add_field(name="Answer:", value=f"Timed out! The correct answer was **{question['answer']}**.")
embed.title = f'{q_num} Answer' await q_msg.edit(embed=embed)
embed.description = f'{self.source}\n\n{result}' else:
embed.colour = cmn.colours.bad 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: else:
result = f'The correct answer to {q_num} was **{correct_ans}**.' embed.remove_field(2)
embed.title = f'{q_num} Answer' embed.add_field(name="Answer:", value=f"Incorrect! The correct answer was **{question['answer']}**.")
embed.description = f'{self.source}\n\n{result}' embed.colour = cmn.colours.bad
embed.colour = cmn.colours.neutral await q_msg.edit(embed=embed)
await ctx.send(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): def setup(bot: commands.Bot):

View File

@ -1,7 +1,7 @@
""" """
Weather extension for qrm 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 This file is part of discord-qrm2 and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.
@ -10,6 +10,8 @@ General Public License, version 2.
import io import io
import re import re
import aiohttp
import discord import discord
import discord.ext.commands as commands import discord.ext.commands as commands
@ -21,12 +23,12 @@ class WeatherCog(commands.Cog):
def __init__(self, bot: commands.Bot): def __init__(self, bot: commands.Bot):
self.bot = 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) @commands.command(name="bandconditions", aliases=['cond', 'condx', 'conditions'], category=cmn.cat.weather)
async def _band_conditions(self, ctx: commands.Context): async def _band_conditions(self, ctx: commands.Context):
'''Posts an image of HF Band Conditions.''' '''Posts an image of HF Band Conditions.'''
with ctx.typing(): async with ctx.typing():
embed = cmn.embed_factory(ctx) embed = cmn.embed_factory(ctx)
embed.title = 'Current Solar Conditions' embed.title = 'Current Solar Conditions'
embed.colour = cmn.colours.good embed.colour = cmn.colours.good
@ -34,10 +36,11 @@ class WeatherCog(commands.Cog):
if resp.status != 200: if resp.status != 200:
embed.description = 'Could not download file...' embed.description = 'Could not download file...'
embed.colour = cmn.colours.bad embed.colour = cmn.colours.bad
else: await ctx.send(embed=embed)
data = io.BytesIO(await resp.read()) return
embed.set_image(url=f'attachment://condx.png') data = io.BytesIO(await resp.read())
await ctx.send(embed=embed, file=discord.File(data, 'condx.png')) 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) @commands.group(name="weather", aliases=['wttr'], category=cmn.cat.weather)
async def _weather_conditions(self, ctx: commands.Context): 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): 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/). '''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.''' 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: try:
units_arg = re.search(self.wttr_units_regex, location).group(1) units_arg = re.search(self.wttr_units_regex, location).group(1)
except AttributeError: 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: if resp.status != 200:
embed.description = 'Could not download file...' embed.description = 'Could not download file...'
embed.colour = cmn.colours.bad embed.colour = cmn.colours.bad
else: await ctx.send(embed=embed)
data = io.BytesIO(await resp.read()) return
embed.set_image(url=f'attachment://wttr_forecast.png') data = io.BytesIO(await resp.read())
await ctx.send(embed=embed, file=discord.File(data, 'wttr_forecast.png')) 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) @_weather_conditions.command(name='now', aliases=['n'], category=cmn.cat.weather)
async def _weather_conditions_now(self, ctx: commands.Context, *, location: str): 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/). '''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.''' 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: try:
units_arg = re.search(self.wttr_units_regex, location).group(1) units_arg = re.search(self.wttr_units_regex, location).group(1)
except AttributeError: 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: if resp.status != 200:
embed.description = 'Could not download file...' embed.description = 'Could not download file...'
embed.colour = cmn.colours.bad embed.colour = cmn.colours.bad
else: await ctx.send(embed=embed)
data = io.BytesIO(await resp.read()) return
embed.set_image(url=f'attachment://wttr_now.png') data = io.BytesIO(await resp.read())
await ctx.send(embed=embed, file=discord.File(data, 'wttr_now.png')) 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): def setup(bot: commands.Bot):

View File

@ -1,7 +1,7 @@
""" """
Static info about the bot. 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 This file is part of discord-qrm2 and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.

38
main.py
View File

@ -2,7 +2,7 @@
""" """
qrm, a bot for Discord 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 This file is part of discord-qrm2 and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.
@ -11,17 +11,19 @@ General Public License, version 2.
import sys import sys
import traceback import traceback
import asyncio
from datetime import time, datetime from datetime import time, datetime
import random import random
from types import SimpleNamespace from types import SimpleNamespace
import pytz import pytz
import aiohttp
import discord import discord
from discord.ext import commands, tasks from discord.ext import commands, tasks
import utils.connector as conn
import common as cmn import common as cmn
import info import info
import data.options as opt import data.options as opt
import data.keys as keys import data.keys as keys
@ -38,13 +40,21 @@ debug_mode = opt.debug # Separate assignement in-case we define an override (te
# --- Bot setup --- # --- 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, bot = commands.Bot(command_prefix=opt.prefix,
description=info.description, 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 = 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 bot.qrm.debug_mode = debug_mode
@ -54,7 +64,6 @@ bot.qrm.debug_mode = debug_mode
@commands.check(cmn.check_if_owner) @commands.check(cmn.check_if_owner)
async def _restart_bot(ctx: commands.Context): async def _restart_bot(ctx: commands.Context):
"""Restarts the bot.""" """Restarts the bot."""
await bot.qrm.session.close()
global exit_code global exit_code
await cmn.add_react(ctx.message, cmn.emojis.check_mark) await cmn.add_react(ctx.message, cmn.emojis.check_mark)
print(f"[**] Restarting! Requested by {ctx.author}.") print(f"[**] Restarting! Requested by {ctx.author}.")
@ -66,7 +75,6 @@ async def _restart_bot(ctx: commands.Context):
@commands.check(cmn.check_if_owner) @commands.check(cmn.check_if_owner)
async def _shutdown_bot(ctx: commands.Context): async def _shutdown_bot(ctx: commands.Context):
"""Shuts down the bot.""" """Shuts down the bot."""
await bot.qrm.session.close()
global exit_code global exit_code
await cmn.add_react(ctx.message, cmn.emojis.check_mark) await cmn.add_react(ctx.message, cmn.emojis.check_mark)
print(f"[**] Shutting down! Requested by {ctx.author}.") print(f"[**] Shutting down! Requested by {ctx.author}.")
@ -74,7 +82,7 @@ async def _shutdown_bot(ctx: commands.Context):
await bot.logout() 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) @commands.check(cmn.check_if_owner)
async def _extctl(ctx: commands.Context): async def _extctl(ctx: commands.Context):
"""Extension control commands. """Extension control commands.
@ -84,7 +92,7 @@ async def _extctl(ctx: commands.Context):
await ctx.invoke(cmd) await ctx.invoke(cmd)
@_extctl.command(name="list", category=cmn.cat.admin) @_extctl.command(name="list", aliases=["ls"], category=cmn.cat.admin)
@commands.check(cmn.check_if_owner) @commands.check(cmn.check_if_owner)
async def _extctl_list(ctx: commands.Context): async def _extctl_list(ctx: commands.Context):
"""Lists Extensions.""" """Lists Extensions."""
@ -94,7 +102,7 @@ async def _extctl_list(ctx: commands.Context):
await ctx.send(embed=embed) await ctx.send(embed=embed)
@_extctl.command(name="load", category=cmn.cat.admin) @_extctl.command(name="load", aliases=["ld"], category=cmn.cat.admin)
@commands.check(cmn.check_if_owner) @commands.check(cmn.check_if_owner)
async def _extctl_load(ctx: commands.Context, extension: str): async def _extctl_load(ctx: commands.Context, extension: str):
try: try:
@ -105,7 +113,7 @@ async def _extctl_load(ctx: commands.Context, extension: str):
await ctx.send(embed=embed) await ctx.send(embed=embed)
@_extctl.command(name="reload", aliases=["relaod"], category=cmn.cat.admin) @_extctl.command(name="reload", aliases=["rl", "r", "relaod"], category=cmn.cat.admin)
@commands.check(cmn.check_if_owner) @commands.check(cmn.check_if_owner)
async def _extctl_reload(ctx: commands.Context, extension: str): async def _extctl_reload(ctx: commands.Context, extension: str):
if ctx.invoked_with == "relaod": if ctx.invoked_with == "relaod":
@ -120,7 +128,7 @@ async def _extctl_reload(ctx: commands.Context, extension: str):
await ctx.send(embed=embed) await ctx.send(embed=embed)
@_extctl.command(name="unload", category=cmn.cat.admin) @_extctl.command(name="unload", aliases=["ul"], category=cmn.cat.admin)
@commands.check(cmn.check_if_owner) @commands.check(cmn.check_if_owner)
async def _extctl_unload(ctx: commands.Context, extension: str): async def _extctl_unload(ctx: commands.Context, extension: str):
try: try:
@ -160,8 +168,11 @@ async def on_command_error(ctx: commands.Context, err: commands.CommandError):
if isinstance(err, commands.UserInputError): if isinstance(err, commands.UserInputError):
await cmn.add_react(ctx.message, cmn.emojis.warning) await cmn.add_react(ctx.message, cmn.emojis.warning)
await ctx.send_help(ctx.command) await ctx.send_help(ctx.command)
elif isinstance(err, commands.CommandNotFound) and not ctx.invoked_with.startswith("?"): elif isinstance(err, commands.CommandNotFound):
await cmn.add_react(ctx.message, cmn.emojis.question) if ctx.invoked_with.startswith(("?", "!")):
return
else:
await cmn.add_react(ctx.message, cmn.emojis.question)
elif isinstance(err, commands.CheckFailure): elif isinstance(err, commands.CheckFailure):
# Add handling of other subclasses of CheckFailure as needed. # Add handling of other subclasses of CheckFailure as needed.
if isinstance(err, commands.NotOwner): if isinstance(err, commands.NotOwner):
@ -250,6 +261,7 @@ except ConnectionResetError as ex:
raise raise
raise SystemExit("ConnectionResetError: {}".format(ex)) raise SystemExit("ConnectionResetError: {}".format(ex))
# --- Exit --- # --- Exit ---
# Codes for the wrapper shell script: # Codes for the wrapper shell script:
# 0 - Clean exit, don't restart # 0 - Clean exit, don't restart

View File

@ -1,7 +1,7 @@
""" """
Information about callsigns for the vanity prefixes command in hamcog. 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 This file is part of discord-qrmbot and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.

View File

@ -1,7 +1,7 @@
""" """
A listing of morse code symbols 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 This file is part of discord-qrmbot and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.

View File

@ -1,7 +1,7 @@
""" """
A listing of NATO Phonetics 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 This file is part of discord-qrmbot and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.

View File

@ -1,7 +1,7 @@
""" """
A listing of Q Codes 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 This file is part of discord-qrmbot and is released under the terms of the GNU
General Public License, version 2. General Public License, version 2.

49
resources/study.py Normal file
View File

@ -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': '🇺🇸 🏢'}

3
utils/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""
Various utilities for the bot.
"""

16
utils/connector.py Normal file
View File

@ -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)