diff --git a/README.md b/README.md index deddf63..9aa49f7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ UQCSbot is our friendly chat bot used on the [UQCS Discord Server](https://discord.uqcs.org). For the UQCSbot used in our Slack team, see [UQCSbot-Slack](https://github.com/uqcomputing/uqcsbot-slack). +For a step-by-step guide to contributing, see the [How To Contribute Code page](https://github.com/UQComputingSociety/uqcsbot-discord/wiki/How-To-Contribute-Code) on the [wiki](https://github.com/UQComputingSociety/uqcsbot-discord/wiki). Brief build and code formatting instructions can also be found below. + ## Setup & Running Locally UQCSbot uses [Poetry](https://python-poetry.org/) for dependency management. Once you have Poetry setup on your machine, you can setup and install dependencies by running: @@ -10,57 +12,34 @@ UQCSbot uses [Poetry](https://python-poetry.org/) for dependency management. Onc poetry install ``` -### Environment Variables - -You'll need to define environment variables to be able to start the bot. The `.env.example` file contains a basis for what you can use as a `.env` file. You'll need to create an `.env` file with the required environment variables populated: +You'll need to define environment variables to be able to start the bot. The `.env.example` file contains a basis for what you can use as a `.env` file (see [Tokens and Environment Variables](https://github.com/UQComputingSociety/uqcsbot-discord/wiki/Tokens-and-Environment-Variables) for more information). The following environment variables are required for proper functionality: * `DISCORD_BOT_TOKEN` for the Discord provided bot token. * `POSTGRES_URI_BOT` for the PostgreSQL connection string. -It is recommended that you acquire your own Discord bot token for testing, details can be found in the [Discord Developer Docs](https://discord.com/developers/docs/getting-started#creating-an-app). Make sure you also enable the Server Members Intent and Message Content Intent in your bot settings. Requests can be made to committee for bot testing tokens, but will only be approved on a case by case basis. - -More information for currently implemented environment variables can be found on [this wiki page](https://github.com/UQComputingSociety/uqcsbot-discord/wiki/Tokens-and-Environment-Variables). - -### Running the Bot - Once you have a .env file, you can run the following command to start the bot: ```bash poetry run botdev ``` -To shutdown the bot, hit Ctrl+C - -
-Alternative Instructions for Docker - -UQCSbot is deployed using [Docker](https://docker.com). If you're familiar with it or want to fully simulate the production environment, you can follow these instructions instead. - -If you're going to use Docker as your dev environment, make sure you have: -* [Docker](https://docs.docker.com/engine/install/) -* [Docker Compose](https://docs.docker.com/compose/install/) - -To build and start Docker, you can run: (Note that depending on how Docker is configured, you may need to prepend `sudo`) -``` -docker-compose up -d --build -``` +To shutdown the bot, use `CTRL+C`. -To shut down the Docker environment, run: -``` -docker-compose down -``` -
+For more detailed build instructions (including how to build with Docker), see [How To Build & Run UQCSbot](https://github.com/UQComputingSociety/uqcsbot-discord/wiki/How-To-Build-&-Run-UQCSbot). ## Testing Tests are stored in the `tests` folder and the tests for each file are prefixed with `test_`. Each test should `import pytest` and import the relevant functions from the given part of `uqcsbot`. Tests should mainly focus on cog-specific behaviours and should avoid interacting with discord (say, to detect if a message was sent; see issue [#2](https://github.com/UQComputingSociety/uqcsbot-discord/issues/2#issuecomment-1498967689)). To run all tests: -``` + +```bash poetry run pytest ``` + To run a particular test, say `test_whatweekisit.py`, run: -``` + +```bash poetry run pytest tests\test_whatweekisit.py ``` @@ -94,9 +73,9 @@ poetry run pyright uqcsbot/file.py ## Development Resources -If this is your first time working on an open source project, we're here to walk you through every step of the way. +If this is your first time working on an open source project, we're here to walk you through every step of the way. The [How To Contribute Code page](https://github.com/UQComputingSociety/uqcsbot-discord/wiki/How-To-Contribute-Code) should guide you through everything, from running the bot on your machine, to helping you use Git to contribute your changes. -If you're completely new to Git, check out [Atlassian's Git Tutorial site](https://www.atlassian.com/git). +Additionally, if you're completely new to Git, check out [Atlassian's Git Tutorial site](https://www.atlassian.com/git). If you're unsure what to work on, check out the [issues labelled good first issue](https://github.com/UQComputingSociety/uqcsbot-discord/labels/good%20first%20issue). diff --git a/unimplemented/acronym.py b/unimplemented/acronym.py deleted file mode 100644 index 0383f88..0000000 --- a/unimplemented/acronym.py +++ /dev/null @@ -1,54 +0,0 @@ -from uqcsbot import bot, Command -from requests import get -from urllib.parse import quote -from bs4 import BeautifulSoup -from typing import List, Tuple -from functools import partial -import asyncio -from uqcsbot.utils.command_utils import UsageSyntaxException - -ACRONYM_LIMIT = 5 -BASE_URL = "http://acronyms.thefreedictionary.com" - - -async def get_acronyms(loop, word: str) -> Tuple[str, List[str]]: - http_response = await loop.run_in_executor(None, partial(get, f"{BASE_URL}/{quote(word)}")) - html = BeautifulSoup(http_response.content, 'html.parser') - acronym_tds = html.find_all("td", class_="acr") - return word, [td.find_next_sibling("td").text for td in acronym_tds] - - -@bot.on_command("acro") -def handle_acronym(command: Command): - """ - `!acro ` - Finds an acronym for the given text. - """ - if not command.has_arg(): - raise UsageSyntaxException() - - words = command.arg.split(" ") - - # Requested by @wbo, do not remove unless you get his express permission - if len(words) == 1: - word = words[0] - if word.lower() in [":horse:", "horse"]: - bot.post_message(command.channel_id, ">:taco:") - return - elif word.lower() in [":rachel:", "rachel"]: - bot.post_message(command.channel_id, ">:older_woman:") - return - - loop = bot.get_event_loop() - acronym_futures = [get_acronyms(loop, word) for word in words[:ACRONYM_LIMIT]] - response = "" - for word, acronyms in loop.run_until_complete(asyncio.gather(*acronym_futures)): - if acronyms: - acronym = acronyms[0] - response += f">{word.upper()}: {acronym}\r\n" - else: - response += f"{word.upper()}: No acronyms found!\r\n" - - if len(words) > ACRONYM_LIMIT: - response += f">I am limited to {ACRONYM_LIMIT} acronyms at once" - - bot.post_message(command.channel_id, response) diff --git a/unimplemented/ascii.py b/unimplemented/ascii.py deleted file mode 100644 index b294f13..0000000 --- a/unimplemented/ascii.py +++ /dev/null @@ -1,115 +0,0 @@ -from uqcsbot import bot, Command -from requests import get -from requests.exceptions import RequestException -from uqcsbot.utils.command_utils import loading_status -import random - -NO_QUERY_MESSAGE = "Can't ASCIIfy nothing... try `!asciify `" -BOTH_OPTIONS_MESSAGE = "Font can only be random OR specified" -ERROR_MESSAGE = "Trouble with HTTP Request, can't ASCIIfy :(" -NO_FONT_MESSAGE = "Cannot find the specified font in the fontslist." -ASCII_URL = "http://artii.herokuapp.com/make?text=" -FONT_URL = "http://artii.herokuapp.com/fonts_list" - - -@bot.on_command("asciify") -@loading_status -def handle_asciify(command: Command): - """ - `!asciify [--fontslist] [--randomfont | --] ` - Returns ASCIIfyed text. - `--fontslist` also returns a URL to available fonts, - `--randomfont` returns, well... a random font. - A custom font from the fonts list can also be specified. - """ - # Makes sure the query is not empty - if not command.has_arg(): - bot.post_message(command.channel_id, NO_QUERY_MESSAGE) - return - command_args = command.arg.split() - random_font = False - custom_font = False - return_fonts = False - # check for font list option - if '--fontslist' in command_args: - return_fonts = True - command_args.remove('--fontslist') - # check for random font option - if '--randomfont' in command_args: - random_font = True - command_args.remove('--randomfont') - # check for custom font option - fontslist = get_fontslist() - if not fontslist: - bot.post_message(command.channel_id, ERROR_MESSAGE) - return - for i in command_args: - if '--' in i: - if i.strip('--') in fontslist: - custom_font = True - selected_font = i.strip('--') - command_args.remove(i) - break - else: - bot.post_message(command.channel_id, NO_FONT_MESSAGE) - return - # check for invalid options - if random_font and custom_font: - bot.post_message(command.channel_id, BOTH_OPTIONS_MESSAGE) - return - if not command_args: - text = None - else: - text = ' '.join(command_args) - # asciification - if text is None: - bot.post_message(command.channel_id, NO_QUERY_MESSAGE) - ascii_text = None - else: - if random_font: - font = get_random_font() - elif custom_font: - font = selected_font - else: - font = None - ascii_text = asciify(text, font) - if ascii_text is None: - bot.post_message(command.channel_id, ERROR_MESSAGE) - return - # message posts - if return_fonts: - bot.post_message(command.channel_id, FONT_URL) - if ascii_text: - bot.post_message(command.channel_id, ascii_text) - else: - return - return - - -def asciify(text: str, font: str) -> str: - try: - if font is not None: - url = ASCII_URL + text + '&font=' + font - else: - url = ASCII_URL + text - resp = get(url) - ascii_text = f"```\n{resp.text}\n```" - return ascii_text - except RequestException: - return None - - -def get_random_font() -> str: - fontslist = get_fontslist() - if fontslist: - return random.choice(tuple(fontslist)) - else: - return None - - -def get_fontslist() -> set: - try: - resp = get('http://artii.herokuapp.com/fonts_list') - fontslist = set(resp.text.split()) - return fontslist - except RequestException: - return None diff --git a/unimplemented/attic.py b/unimplemented/attic.py deleted file mode 100644 index 1ec705e..0000000 --- a/unimplemented/attic.py +++ /dev/null @@ -1,115 +0,0 @@ -from uqcsbot import bot, Command -from uqcsbot.utils.command_utils import loading_status -from typing import List -import os -import requests - - -BASE_FOLDER_URL = 'https://drive.google.com/drive/folders/' -BASE_FILE_URL = 'https://drive.google.com/file/d/' -BASE_API_URL = 'https://www.googleapis.com/drive/v3/' -BASE_ATTIC_FOLDER = '0B6_D4T6LJ-uwZmFhMzIyNGYtNTM2OS00ZDJlLTg0NmYtY2IyNzA1MDZlNDIx' -API_KEY = os.environ.get('GOOGLE_API_KEY') -ROOM_FILE_LIMIT = 15 # Number of files allowed to be posted in room, else sent via direct message. - - -def format_files(files: List[dict]) -> List[str]: - """ - Takes the list of file dictionaries found and formats - them into Slack-appropriate message strings. - - :param files: a list of dictionaries where each dictionary - represents a file in the Google Drive folder. - :param course: a dictionary representing the parent Google Drive folder for the queried course. - :return: a list of strings to be sent as messages. - """ - sorted_files = sorted(files, key=lambda f: f['name']) # Sort alphabetically by filename. - return [f'>*{file["name"]}:* <{BASE_FILE_URL}{file["id"]}|Link>' for file in sorted_files] - - -def get_all_files(folder: dict) -> List[dict]: - """ - Takes a parent folder and recursively produces a single-level list - of all files contained in that folder and any subdirectories. - - :param folder: a dictionary representing a Google Drive folder. - :return: a single-dimensional list of file dictionaries. - """ - files = [] - for file in get_folder_contents(folder): - if file['mimeType'] == 'application/vnd.google-apps.folder': - files.extend(get_all_files(file)) - else: - files.append(file) - return files - - -def get_folder_contents(folder: dict) -> List[dict]: - """ - Gets all files and folders inside the given Google Drive folder dictionary. - Note: does not return the contents of subdirectories. - - :param folder: a dictionary representing a Google Drive folder to get the contents of. - :return: a list of Google drive folder/file dictionaries - representing all contents of the folder. - """ - folder_url = f"{BASE_API_URL}files?q='{folder['id']}' in parents&key={API_KEY}" - http_response = requests.get(folder_url) - if http_response.status_code == 200: - return http_response.json()['files'] - else: - # I figure it's better to ignore a failed folder fetch than provide no response/error out. - return [] - - -@bot.on_command('attic') -@loading_status -def handle_attic(command: Command) -> None: - """ - `!attic [COURSE CODE]` - Returns a list of links to all documents in the course folder for UQ - Attic, the unofficial exam solution and study material repository. Defaults to searching for - the name of the current channel unless explicitly provided a course code (e.g. CSSE1001). - """ - channel = bot.channels.get(command.channel_id) - course_code = command.arg if command.arg is not None else channel.name - course_code = course_code.upper() - - # Make request for UQAttic root directory contents. - root_directory_request_url = (f"{BASE_API_URL}files?q='{BASE_ATTIC_FOLDER}'" - + " in parents and mimeType = 'application/vnd.google-apps" - + f".folder'&pageSize=1000&key={API_KEY}") - root_directory = requests.get(root_directory_request_url) - if not root_directory.status_code == 200: - bot.post_message(channel, 'There was an error getting the root UQAttic directory.') - return - root_directory_data = root_directory.json() - - # Check course folder exists by checking for the course code in the 'name' of each file/folder. - course = next(( - item - for item in root_directory_data['files'] - if item['name'] == course_code - ), None) - if course is None: - bot.post_message(channel, f'No course folder found for {course_code}.') - return - - # Get all files in directory and subdirectories. - files = get_all_files(course) - - # Determine whether to send to user or channel (based on number of responses). - if len(files) > ROOM_FILE_LIMIT: - bot.post_message(channel, 'Too many files to list here, sent the list directly to ' - f'<@{command.user_id}>.') - response_channel = command.user_id - else: - response_channel = channel - - # Send response message with formatted list of files. - if len(files) > 0: - response_message = ('All of the UQAttic files found for the course' - + f' <{BASE_FOLDER_URL}{course["id"]} | {course["name"]}>' - + ' are listed below:\n' + '\n'.join(format_files(files))) - else: - response_message = f'There were no files found in the {course_code} course folder.' - bot.post_message(response_channel, response_message) diff --git a/unimplemented/cards.py b/unimplemented/cards.py deleted file mode 100644 index 353aba7..0000000 --- a/unimplemented/cards.py +++ /dev/null @@ -1,54 +0,0 @@ -from random import shuffle -from uqcsbot import bot, Command - - -def emojify(value: int): - if value == -1: - return ":card-joker:" - suit = ["hearts", "spades", "diamonds", "clubs"][value//13] - rank = ["ace", "king", "queen", "jack", "10", "9", "8", "7", - "6", "5", "4", "3", "2"][value % 13] - return ":card-{}-{}:".format(rank, suit) - - -@bot.on_command("cards") -def handle_cards(command: Command): - """ - `!cards [number] [joker]` - Deals one or more cards - """ - - # easter egg - prepare four 500 hands, and the kitty - if command.arg == "500": - deck = (list(range(0, 0+10)) + list(range(13, 13+11)) + - list(range(26, 26+10)) + list(range(39, 39+11)) + [-1]) - shuffle(deck) - hands = [deck[0:10], deck[10:20], deck[20:30], deck[30:40], deck[40:]] - for i in range(5): - h = [emojify(j) for j in sorted(hands[i])] - response = [":regional-indicator-n: ", ":regional-indicator-e: ", - ":regional-indicator-s: ", ":regional-indicator-w: ", - ":cat: "][i] + "".join(h) - bot.post_message(command.channel_id, response) - return - - deck = list(range(52)) - - # add joker - if command.has_arg() and command.arg.split(" ")[-1][0].lower() == "j": - deck.append(-1) - - # set number to deal - if command.has_arg() and command.arg.split(" ")[0].isnumeric(): - cards = min(max(int(command.arg.split(" ")[0]), 1), len(deck)) - else: - cards = 1 - - shuffle(deck) - deck = deck[:cards] - deck.sort() - - response = "" - for i in deck: - response += emojify(i) - - bot.post_message(command.channel_id, response) diff --git a/unimplemented/crates.py b/unimplemented/crates.py deleted file mode 100644 index 8a5c2f7..0000000 --- a/unimplemented/crates.py +++ /dev/null @@ -1,628 +0,0 @@ -import argparse -import json -from abc import ABC, abstractmethod -from enum import Enum -from typing import NamedTuple, Union, Optional, List, Dict, Tuple - -import requests - -from uqcsbot import bot, Command -from uqcsbot.api import Channel -from uqcsbot.utils.command_utils import loading_status, UsageSyntaxException - -BASE_URL = "https://crates.io/api/v1" -MAX_LIMIT = 15 # The maximum number of search results from one call to the command - -# NamedTuple for the case that the argparse finds a -h flag -HelpCommand = NamedTuple('HelpCommand', [('help_string', str)]) - -# NamedTuple for the default command that finds a crate by exact name match -ExactCrate = NamedTuple('ExactCrate', [('name', str)]) - -# NamedTuple for the search sub-command that deals with searching for specific crates -CrateSearch = NamedTuple('CrateSearch', [('name', str), ('limit', int), ('category', str), - ('user', str), ('sort', str), ('search', str)]) - -# NamedTuple for the default categories sub-command that deals with searching for categories -CategorySearch = NamedTuple('CategorySearch', [('name', str), ('sort', str)]) - -# NamedTuple for the user sub-command that deals with searching for specific users -UserSearch = NamedTuple('UserSearch', [('username', str)]) - -# Named tuple for a crate that was found in a search -CrateResult = NamedTuple('CrateResult', [('name', str), ('downloads', int), - ('homepage', str), ('description', str)]) - -# Named tuple for a category that was found in a search -CategoryResult = NamedTuple('CategoryResult', [('name', str), ('description', str), - ('crates', int)]) - -# Named tuple for a user that was found in a search -UserResult = NamedTuple('UserResult', [('id', int), ('username', str), ('name', str), - ('avatar', str), ('url', str)]) - - -class SlackBlock(ABC): - """ - Abstract class for a formatted slack block - (follows the conventions from https://api.slack.com/reference/messaging/blocks) - """ - - @abstractmethod - def get_formatted_block(self) -> Dict[str, str]: - """ - Gets the dictionary which maps the required properties for the given block - """ - pass - - -class ImageBlock(SlackBlock): - """ - SlackBlock that represents an image block - (contains an image url and alternate text for that image) - """ - - def __init__(self, url: str, alt_text: str): - self.url = url - self.alt_text = alt_text - - def get_formatted_block(self): - return {'type': 'image', 'image_url': self.url, 'alt_text': self.alt_text} - - -class TextBlock(SlackBlock): - """ - SlackBlock that represents a text block - (contains an image url and alternate text for that image) - """ - - def __init__(self, text: str, markdown: bool = True): - self.text = text - self.markdown = markdown - - def get_formatted_block(self): - return {'type': 'mrkdwn' if self.markdown else 'plain_text', - 'text': self.text} - - -class SubCommand(Enum): - """ - Distinguishes the type of sub command that was invoked - """ - EXACT = 1, - SEARCH = 2, - CATEGORIES = 3, - USERS = 4 - - -@bot.on_command('crates') -@loading_status -def handle_crates(command: Command): - """ - `!crates [-h] [[name] | {search,categories,users}]` - - Get information about crates from crates.io - """ - args = parse_arguments(command.arg if command.has_arg() else '') - - # Executes the function that was stored by the arg - # parser depending on which sub-command was used - args.execute_action(command.channel_id, args) # type: ignore - - -def parse_arguments(arg_str: str) -> argparse.Namespace: - """ - Parses the arguments passed to the command - :param arg_str: The argument string (not including "!crates") - """ - parser = argparse.ArgumentParser(prog="!crates", add_help=False) - subparsers = parser.add_subparsers() - - # Change the parsers default on error behaviour - def usage_error(*args, **kwargs): - raise UsageSyntaxException() - - parser.error = usage_error # type: ignore - - # Converts "Date and time" into "date-and-time" which is the format used for category ids - def category_formatter(cat: str): - return cat.lower().strip().replace(' ', '-') - - def search_limit(val: str): - return max(1, min(int(val), MAX_LIMIT)) # limits the val such that 0 < val <= MaxLimit - - # For "!crates {args}" - main_parser = subparsers.add_parser('main', add_help=False) - main_parser.add_argument('name', nargs='?', default='', type=str.lower, - help="The name of the crate to get information about") - main_parser.add_argument('-h', '--help', action='store_true', help='Prints this help message') - main_parser.set_defaults(execute_action=handle_exact_crate_route, route=SubCommand.EXACT) - - # For !crates search {args} - search_parser = subparsers.add_parser('search', add_help=False, - help='Sub-command to search for a crate') - search_parser.add_argument('search', nargs='?', default='', type=str.lower, - help='The string to use for the search') - search_parser.add_argument('-h', '--help', action='store_true', help='Prints this help message') - search_parser.add_argument('-l', '--limit', default=5, type=search_limit, - help='When not searching for a specific crate how' - + ' many results should be shown?' - + ' (max: ' + str(MAX_LIMIT) + ', default: %(default)s)') - search_parser.add_argument('-c', '--category', default='', type=category_formatter, - help="Limit results to crates in this category") - search_parser.add_argument('-u', '--user', default='', type=str, - help='Limit results by crate author') - search_parser.add_argument('-o', '--sort', choices=['alpha', 'downloads'], - default='downloads', type=str.lower, - help='Sort the results by alphabetical order or by number' - + ' of downloads (default: %(default)s)') - search_parser.set_defaults(execute_action=handle_search_crates_route, route=SubCommand.SEARCH) - - # For "!crates categories {args}" - category_parser = subparsers.add_parser('categories', add_help=False, - help='Sub-command to get information about' - + ' categories instead of crates') - category_parser.add_argument('-h', '--help', action='store_true', - help='Prints this help message') - category_parser.add_argument('name', nargs='?', default='', type=category_formatter, - help='Optional. Specify a specific category to get more' - + ' information about it') - category_parser.add_argument('-s', '--sort', choices=['alpha', 'crates'], - default='alpha', type=str.lower, - help='Sort the result by alphabetical order or' - + ' by number of crates in the category') - category_parser.set_defaults(execute_action=handle_categories_route, - route=SubCommand.CATEGORIES) - - # For "!crates users {args}" - users_parser = subparsers.add_parser('user', add_help=False, - help='Sub-command to get information about a username') - users_parser.add_argument('username', help='The users username') - users_parser.add_argument('-h', '--help', action='store_true', help='Prints this help message') - users_parser.set_defaults(execute_action=handle_users_route, route=SubCommand.USERS) - - # We need to check if the first argument is "categories" or "search" - # otherwise we add "main" to get around an issue were argparse will - # complain that the name isn't one of the subparser names - split_args = arg_str.split() - if not split_args or (split_args[0] != "categories" and split_args[0] != "search" - and split_args[0] != 'user'): - split_args.insert(0, "main") - - args = parser.parse_args(split_args) - - # If the arguments show that help was requested then - # change the execute_action and add the correct help string - if args.help: - args.execute_action = handle_help_route - if args.route == SubCommand.EXACT: - # Because we had to break parser up into main this - # help message needs to be manually typed to be useful - args.help_string = """ -*Usage: !crates [[name] | {search,categories,users}]* - -*Sub-Commands*: - {search,categories,users} - search Search for a crate with conditions - categories Get information about categories instead of crates - users Get information about a user from their username - -*Default Usage*: - usage: !crates [-h] [name] - - *Positional Arguments*: - name The exact name of the crate to get information about (use search for non-exact name) - - *Optional Arguments*: - -h, --help Prints this help message - """ - elif args.route == SubCommand.SEARCH: - args.help_string = search_parser.format_help() - elif args.route == SubCommand.CATEGORIES: - args.help_string = category_parser.format_help() - else: - args.help_string = users_parser.format_help() - - return args - - -def handle_help_route(channel: Channel, args: HelpCommand): - """ - This is called whenever the -h argument is invoked regardless of sub-command. - """ - bot.post_message(channel, args.help_string) - - -def get_user_id(username: str) -> int: - """ - Tries to get the users numerical id from their username. (Ex: BurntSushi -> 189). -1 on failure. - """ - url = f'{BASE_URL}/users/{username}' - response = requests.get(url) - - # If there was a problem getting a response return -1 - if response.status_code != requests.codes.ok: - return -1 - - user_data = json.loads(response.content) - - # If an error occurred then return with -1 - if 'errors' in user_data: - return -1 - - # Try to grab their id and return -1 if something goes wrong - # (which it shouldn't at this point but the API is badly documented so I added this for safety) - return int(user_data.get('user', {}).get('id', -1)) - - -def convert_crate_result(crate: Dict[str, Union[str, int]]) -> Optional[CrateResult]: - """ - Tries to convert a dictionary response from the api into a CrateResult. - Returns None on error. - """ - try: - # Sometimes the homepage is null so we try to grab - # something else if possible otherwise default to crates.io - homepage = crate['homepage'] - homepage = crate['repository'] if homepage is None else homepage - homepage = crate['documentation'] if homepage is None else homepage - homepage = "https://crates.io" if homepage is None else homepage - - return CrateResult(crate['name'], crate['downloads'], # type: ignore - homepage, crate['description']) # type: ignore - except KeyError: - return None - - -def get_crate_name_result(channel: Channel, name: str) -> Optional[CrateResult]: - """ - Get the result of searching for a specific crate by name - :param channel: The channel to post any error messages in - :param name: The name of the crate to search for - :return: The api response as a dictionary or None on error - """ - url = f'{BASE_URL}/crates/{name}' - - response = requests.get(url) - - # If there was a problem getting a response post a message to let the user know - if response.status_code != requests.codes.ok: - bot.post_message(channel, 'There was a problem getting a response.') - return None - - raw_crate_result = json.loads(response.content).get('crate', None) - - if raw_crate_result is None: - bot.post_message(channel, "There was an issue getting the crate information") - return None - - if 'errors' in raw_crate_result: - bot.post_message(channel, f"The requested crate {name} could not be found") - return None - - # Convert the raw crate result to a CrateResult - crate = convert_crate_result(raw_crate_result) - if crate is None: - bot.post_message(channel, "There was a problem getting information about the crate") - return None - - return crate - - -def create_slack_section_block(text: TextBlock, accessory: Optional[SlackBlock] = None) -> dict: - """ - Creates a "section block" as described in the slack documentation here: - https://api.slack.com/reference/messaging/blocks#section - """ - section_block = { - 'type': 'section', - 'text': text.get_formatted_block() - } - - if accessory is not None: - section_block['accessory'] = accessory.get_formatted_block() - - return section_block - - -def create_slack_context_block(elements: List[SlackBlock]) -> dict: - """ - Creates a "context block" as described in the slack documentation here: - https://api.slack.com/reference/messaging/blocks#context - """ - return { - 'type': 'context', - 'elements': [element.get_formatted_block() for element in elements], - } - - -def create_slack_divider_block() -> Dict[str, str]: - """ - Returns a "divider block" as described in the slack documentation here: - https://api.slack.com/reference/messaging/blocks#divider - """ - return { - 'type': 'divider' - } - - -def get_crate_blocks(crate: CrateResult) -> List[dict]: - """ - Converts a crate into its block based message format for posting to slack - """ - return [ - create_slack_section_block(TextBlock(f'*<{crate.homepage}|{crate.name}>*\n' - f'{crate.description}')), - create_slack_context_block([TextBlock(f'Downloads: {crate.downloads}', markdown=False)]), - create_slack_divider_block() - ] - - -def handle_exact_crate_route(channel: Channel, args: ExactCrate): - """ - Handles what happens when a single crate is being searched for by exact name - """ - crate = get_crate_name_result(channel, args.name) - if crate is None: - return - - bot.post_message(channel, '', blocks=get_crate_blocks(crate)) - - -def get_crates_search_results(channel: Channel, - search: str, - params: dict, - page: int = 1, ) -> Optional[Tuple[List[CrateResult], int]]: - """ - Gets a list of crates and the total number of results - from the api based on input parameters and the page number - :param channel: The channel to post any error messages to - :param search: The string to search for - :param params: The parameters dictionary that gets passed to requests.get - :param page: The page of the results to get from - :return: (list of crates, total number of search results) or None if an error occurred - """ - params['page'] = page - - if search: - params['letter'] = search - - url = BASE_URL + '/crates' - response = requests.get(url, params) - - # If there was a problem getting a response post a message to let the user know - if response.status_code != requests.codes.ok: - bot.post_message(channel, 'There was a problem getting a response.') - return None - - crates_results = json.loads(response.content) - raw_crates = crates_results.get('crates', []) - total = crates_results.get('meta', {}).get('total', 0) - - # Convert all of the crates to CrateResult - crates = [] - for raw_crate in raw_crates: - crate = convert_crate_result(raw_crate) - if crate is None: - bot.post_message(channel, "There was a problem getting information about a crate") - return None - - crates.append(crate) - - return crates, total - - -def handle_search_crates_route(channel: Channel, args: CrateSearch): - """ - Handles what happens when a crates are being searched for through multiple criteria - """ - # Generate the parameters to search with - params = {'sort': args.sort} - - if args.category: - params['category'] = args.category - - # If the user parameter is already a number use it as an id otherwise try to get the id - if args.user.isdigit(): - params['user_id'] = args.user - elif args.user: - user_id = get_user_id(args.user) - if user_id == -1: - bot.post_message(channel, f'The username {args.user} could not be resolved') - return - - params['user_id'] = str(user_id) - - search_result = get_crates_search_results(channel, args.search, params) - if search_result is None: - return - - crates, total = search_result - - # No crates at all were found - if not crates: - bot.post_message(channel, "No crates were found") - return - - # The beginning of the formatted response - blocks = [ - create_slack_section_block(TextBlock(f'*Showing {min(args.limit, total)}' - + f' of {total} results*')), - create_slack_divider_block() - ] - - # Iterate over all of the crates or until limit is reached. Whichever comes first. - page = 1 - remaining = args.limit - while remaining > 0: - amt = range(0, min(len(crates), remaining)) - for index in amt: - crate = crates[index] - blocks.extend(get_crate_blocks(crate)) - remaining -= 1 - - page += 1 - search_result = get_crates_search_results(channel, args.search, params, page) - if search_result is None or not search_result[0]: - break - - crates, _ = search_result - - bot.post_message(channel, '', blocks=blocks) - - -def get_category_page(channel: Channel, sort: str, page: int) -> Tuple[Optional[List[str]], int]: - """ - Returns all the names of all categories from a page of the response - :param channel: The channel to post any errors to - :param sort: The order to sort by. One of "crates" or "alpha" - :param page: The page number to get the categories from - :return: A tuple containing a list of category names (or None on error) - and the total number of categories - """ - # Get the categories - url = BASE_URL + '/categories' - response = requests.get(url, {'sort': sort, 'page': page}) # type: ignore - - if response.status_code != requests.codes.ok: - bot.post_message(channel, 'There was a problem getting the list of categories') - return None, 0 - - # Convert the json response - response_data = json.loads(response.content) - - raw_categories = response_data.get('categories') - total = response_data.get('meta', {}).get('total', 0) - - # Get the category names - categories = [cat.get('name') if 'name' in cat else cat.get('id', '') for cat in raw_categories] - - return categories, total - - -def display_all_categories(channel: Channel, args: CategorySearch): - """ - Displays just the names of all the categories in one big list - """ - categories, total = get_category_page(channel, args.sort, 1) - if categories is None: - return # Error occurred - - # Get all of the categories by incrementing page number - page = 2 - while len(categories) < total: - next_cats, _ = get_category_page(channel, args.sort, page) - if next_cats is None or not next_cats: - break - - categories.extend(next_cats) - page += 1 - - # Begin formatting the message - category_string = '\n'.join(categories) - blocks = [ - create_slack_section_block(TextBlock(f'*Displaying {total} categories:*')), - create_slack_section_block(TextBlock(f'```{category_string}```')), - ] - - bot.post_message(channel, '', blocks=blocks) - - -def display_specific_category(channel: Channel, args: CategorySearch): - """ - Displays a single category in more detail - For example !crates categories algorithms would return a description of the algorithms - category and the number of crates that falls into the algorithms category. A search for - a crate with "!crates search" can be filtered based on these categories using the -c flag. - """ - # Get the categories - url = BASE_URL + f'/categories/{args.name}' - response = requests.get(url) - - if response.status_code != requests.codes.ok: - bot.post_message(channel, f'There was a problem getting the category "{args.name}"') - return - - # Convert the json response - response_data = json.loads(response.content) - if 'errors' in response_data: - bot.post_message(channel, f'The category "{args.name}" does not exist') - return - - raw_category = response_data.get('category') - - name = raw_category.get('name') - name = raw_category.get('id') if name is None else name - desc = raw_category.get('description', 'No description provided') - - category = CategoryResult(name, desc, raw_category['crates_cnt']) - - # Format the message - blocks = [ - create_slack_section_block(TextBlock(f'*{category.name}:*')), - create_slack_section_block(TextBlock(category.description)), - create_slack_context_block([TextBlock(f'Crate Count: {category.crates}', markdown=False)]) - ] - - bot.post_message(channel, '', blocks=blocks) - - -def handle_categories_route(channel: Channel, args: CategorySearch): - """ - Handles the categories sub-command by determining whether - or not to display all categories or just on - """ - if args.name: - display_specific_category(channel, args) - else: - display_all_categories(channel, args) - - -def get_user(channel: Channel, username: str) -> Optional[UserResult]: - """ - Gets a UserResult by querying the crates.io api for the given username. - None on error. - """ - url = f'{BASE_URL}/users/{username}' - response = requests.get(url) - - if response.status_code != requests.codes.ok: - bot.post_message(channel, 'There was a problem getting the user') - return None - - raw_user = json.loads(response.content).get('user') - - if raw_user is None or 'errors' in raw_user: - bot.post_message(channel, f'User "{username}" not found') - return None - - user_id = raw_user.get('id', -1) - login = raw_user.get('login', username) - name = raw_user.get('name', username) - avatar = raw_user.get('avatar', 'https://imgur.com/gwtcGmr') # Blank avatar as a default - url = raw_user.get('url', '') - - return UserResult(user_id, login, name, avatar, url) - - -def handle_users_route(channel: Channel, args: UserSearch): - """ - Displays information about a user from their username - """ - user = get_user(channel, args.username) - - # Error occurred - if user is None: - return - - # Begin formatting the message - text = f'*{user.username}:*\n\t*ID*: {user.id}\n\t*Name:* {user.name}\n\t' - if user.url: - text += f'*Homepage:* {user.url}' - - blocks = [ - create_slack_section_block(TextBlock(text), - accessory=ImageBlock(user.avatar, 'User Avatar')), - create_slack_divider_block() - ] - - bot.post_message(channel, '', blocks=blocks) diff --git a/unimplemented/crisis.py b/unimplemented/crisis.py deleted file mode 100644 index 52ef039..0000000 --- a/unimplemented/crisis.py +++ /dev/null @@ -1,20 +0,0 @@ -from uqcsbot import bot, Command - -RESPONSE = ("*Mental health/crisis resources*\n" - "24/7 UQ Counselling and Crisis Line: 1300 851 998\n" - "Campus Security (emergency): 07 3365 3333\n" - "Campus Security (non-emergency): 07 3365 1234\n" - "Counselling Services: https://www.uq.edu.au/student-services/counselling-services\n" - "UQ Psychology Clinic: https://clinic.psychology.uq.edu.au/therapies-and-services\n" - "UQ resources: https://about.uq.edu.au/campaigns-and-initiatives/mental-health") - - -@bot.on_command("crisis") -@bot.on_command("mentalhealth") -@bot.on_command("emergency") -def handle_crisis(command: Command): - """ - `!crisis`, `!mentalhealth` or `!emergency` - Get a list of emergency resources. - """ - - bot.post_message(command.channel_id, RESPONSE) diff --git a/unimplemented/define.py b/unimplemented/define.py deleted file mode 100644 index 83c4e91..0000000 --- a/unimplemented/define.py +++ /dev/null @@ -1,39 +0,0 @@ -from uqcsbot import bot, Command -import requests -import json -from uqcsbot.utils.command_utils import UsageSyntaxException - -API_URL = "http://api.pearson.com/v2/dictionaries/laad3/entries?limit=1" - - -@bot.on_command("define") -def define(command: Command): - """ - `!define ` - Gets the dictionary definition of TEXT - """ - query = command.arg - # Fun Fact: Empty searches return the definition of adagio - # (a piece of music to be played or sung slowly) - if not command.has_arg(): - raise UsageSyntaxException() - - http_response = requests.get(API_URL, params={'headword': query}) - - # Check if the response is OK - if http_response.status_code != requests.codes.ok: - bot.post_message(command.channel_id, "Problem fetching definition") - return - - json_data = json.loads(http_response.content) - results = json_data.get('results', []) - if len(results) == 0: - message = "No Results" - else: - # This gets the first definition of the first result. - senses = results[0].get('senses', [{}])[0] - # Sometimes there are "subsenses" for whatever reason and sometimes there aren't. - # No explanation provided. - # This gets the first subsense if there are, otherwise, just uses senses. - message = senses.get('subsenses', [senses])[0].get('definition', "Definition not available") - - bot.post_message(command.channel_id, f">>>{message}") diff --git a/unimplemented/dice.py b/unimplemented/dice.py deleted file mode 100644 index a53a41e..0000000 --- a/unimplemented/dice.py +++ /dev/null @@ -1,21 +0,0 @@ -from random import choice -from uqcsbot import bot, Command - - -@bot.on_command("dice") -def handle_dice(command: Command): - """ - `!dice [number]` - Rolls 1 or more six sided dice (d6). - """ - if command.has_arg() and command.arg.isnumeric(): - rolls = min(max(int(command.arg), 1), 360) - else: - rolls = 1 - - response = [] - emoji = (':dice-one:', ':dice-two:', ':dice-three:', - ':dice-four:', ':dice-five:', ':dice-six:') - for i in range(rolls): - response.append(choice(emoji)) - - bot.post_message(command.channel_id, "".join(response)) diff --git a/unimplemented/dog.py b/unimplemented/dog.py deleted file mode 100644 index 0371c12..0000000 --- a/unimplemented/dog.py +++ /dev/null @@ -1,32 +0,0 @@ -from uqcsbot import bot, Command - - -@bot.on_command("dog") -def handle_dog(command: Command): - """ - `!dog` - Like !cat, but for dog people. - """ - dog = "\n".join(("```", - " _ ", - " ,:'/ _..._ ", - " // ( `""-.._.'", - " \\| / O\\___", - " | O 4", - " | /", - " \\_ .--' ", - " (_'---'`) ", - " / `'---`() ", - " ,' | ", - " , .'` | ", - " )\\ _.-' ; ", - " / | .'` _ / ", - " /` / .' '. , | ", - "/ / / \\ ; | | ", - "| \\ | | .| | | ", - " \\ `\"| /.-' | | | ", - " '-..-\\ _.;.._ | |.;-. ", - " \\ <`.._ )) | .;-. )) ", - " (__. ` ))-' \\_ ))' ", - " `'--\"` jgs `\"\"\"` ```")) - - bot.post_message(command.channel_id, dog) diff --git a/unimplemented/ecp.py b/unimplemented/ecp.py deleted file mode 100644 index 340abc7..0000000 --- a/unimplemented/ecp.py +++ /dev/null @@ -1,25 +0,0 @@ -from uqcsbot import bot, Command -from uqcsbot.utils.command_utils import loading_status -from uqcsbot.utils.uq_course_utils import (get_course_profile_url, HttpException, - CourseNotFoundException, ProfileNotFoundException) - - -@bot.on_command('ecp') -@loading_status -def handle_ecp(command: Command): - """ - `!ecp [COURSE CODE]` - Returns the link to the latest ECP for the given course code. - If unspecified, will attempt to find the ECP for the channel the command was called from. - """ - channel = bot.channels.get(command.channel_id) - course_name = channel.name.upper() if not command.has_arg() else command.arg - try: - profile_url = get_course_profile_url(course_name) - except HttpException as e: - bot.logger.error(e.message) - bot.post_message(channel, f'An error occurred, please try again.') - return - except (CourseNotFoundException, ProfileNotFoundException) as e: - bot.post_message(channel, e.message) - return - bot.post_message(channel, f'*{course_name}*: <{profile_url}|ECP>') diff --git a/unimplemented/help.py b/unimplemented/help.py deleted file mode 100644 index 8861a1a..0000000 --- a/unimplemented/help.py +++ /dev/null @@ -1,24 +0,0 @@ -from uqcsbot import bot, Command -from uqcsbot.utils.command_utils import success_status, get_helper_docs - - -@bot.on_command('help') -@success_status -def handle_help(command: Command): - """ - `!help [COMMAND]` - Display the helper docstring for the given command. - If unspecified, will return the helper docstrings for all commands. - """ - - # get helper docs - helper_docs = get_helper_docs(command.arg) - if len(helper_docs) == 0: - message = 'Could not find any helper docstrings.' - else: - message = '>>>' + '\n'.join(helper_docs) - - # post helper docs - if command.arg: - command.reply_with(bot, message) - else: - bot.post_message(command.user_id, message, as_user=True) diff --git a/unimplemented/leet.py b/unimplemented/leet.py deleted file mode 100644 index 4715007..0000000 --- a/unimplemented/leet.py +++ /dev/null @@ -1,145 +0,0 @@ -from uqcsbot import bot, Command -from http import HTTPStatus -from uqcsbot.utils.command_utils import loading_status -import json -import requests -import random -from slackblocks import Attachment, SectionBlock -from typing import List, Tuple, Dict - -LC_DIFFICULTY_MAP = ["easy", "medium", "hard"] # leetcode difficulty is 1,2,3, need to map -HR_DS_API_LINK = ("https://www.hackerrank.com/rest/contests/master/tracks/" + - "data-structures/challenges?limit=200") -HR_ALG_API_LINK = ("https://www.hackerrank.com/rest/contests/master/tracks/" + - "algorithms/challenges?limit=200") -LC_API_LINK = 'https://leetcode.com/api/problems/all/' - - -COLORS = {"easy": "#5db85b", - "medium": "#f1ad4e", - "hard": "#d9534f"} - - -@bot.on_command('leet') -@loading_status -def handle_leet(command: Command) -> None: - """ - `!leet [`easy` | `medium` | `hard`] - Retrieves a set of questions from online coding - websites, and posts in channel with a random question from this set. If a difficulty - is provided as an argument, the random question will be restricted to this level of - challenge. Else, a random difficulty is generated to choose. - """ - was_random = True # Used for output later - - if command.has_arg(): - if (command.arg not in {"easy", "medium", "hard"}): - bot.post_message(command.channel_id, "Usage: !leet [`easy` | `medium` | `hard`]") - return - else: - difficulty = command.arg.lower() - was_random = False - else: - difficulty = random.choice(LC_DIFFICULTY_MAP) # No difficulty specified, randomly generate - - # List to store questions collected - questions: List[Tuple[str, str]] = [] - - # Go fetch questions from APIs - collect_questions(questions, difficulty) - selected_question = select_question(questions) # Get a random question - - # If we didn't find any questions for this difficulty, try again, probably timeout on all 3 - if (selected_question is None): - bot.post_message(command.channel_id, - "Hmm, the internet pipes are blocked. Try that one again.") - return - - # Leetcode difficulty colors - color = COLORS[difficulty] - - if (was_random): - title_text = f"Random {difficulty} question generated!" - else: - # Style this a bit nicer - difficulty = difficulty.title() - title_text = f"{difficulty} question generated!" - - difficulty = difficulty.title() # If we haven't already (i.e. random question) - - msg_text = f"Here's a new question for you! <{selected_question[1]}|{selected_question[0]}>" - - bot.post_message(command.channel_id, text=title_text, - attachments=[Attachment(SectionBlock(msg_text), color=color)._resolve()]) - - -def select_question(questions: list) -> Tuple[str, str]: - """ - Small helper method that selects a question from a list randomly - """ - if (len(questions) == 0): - return None - return random.choice(questions) - - -def collect_questions(questions: List[Tuple[str, str]], difficulty: str): - """ - Helper method to send GET requests to various Leetcode and HackerRank APIs. - Populates provided dict (urls) with any successfully retrieved data, - in the form of (Question_Title, Question_Link) tuple pairs. - """ - options = [("Hackerrank data structure", HR_DS_API_LINK), - ("Hackerrank Algorithms structure", HR_ALG_API_LINK), - ("Leetcode", LC_API_LINK), - ] - - results = [] - - # Get all the questions off the internet: hr data struct, hr algo, all leetcode - for name, url in options: - try: - results.append((name, requests.get(url, timeout=3))) - except (requests.exceptions.ReadTimeout, requests.exceptions.ConnectTimeout) as error: - print(name + " API timed out!" + "\n" + str(error)) - results.append((name, None)) - - json_blobs: Dict[str, List[Dict]] = {} - - for name, response in results: - if (response is None or response.status_code != HTTPStatus.OK): - if (name != "Leetcode"): - json_blobs["parsed_hr_all"] = json_blobs.get("parsed_hr_all", []) + [] - else: - json_blobs["parsed_lc_all"] = [] - else: - if (name != "Leetcode"): - parsed_hr_data = json.loads(response.text) - json_blobs["parsed_hr_all"] = (json_blobs.get("parsed_hr_all", []) + - parsed_hr_data["models"]) - else: - parsed_lc_data = json.loads(response.text) - json_blobs["parsed_lc_all"] = parsed_lc_data["stat_status_pairs"] - - # Build HackerRank questions tuples from data - for question in json_blobs["parsed_hr_all"]: - # Construct a tuple pair of title for formatting and link to question - question_data = (question["name"], "http://hackerrank.com/challenges/" + question["slug"] - + "/problem") - question_difficulty = question["difficulty_name"].lower() - - # HackerRank annoyingly has 5 difficulty levels, anything above hard is hard - if (question_difficulty == "advanced" or question_difficulty == "expert"): - question_difficulty = "hard" - - if (question_difficulty == difficulty): - questions.append(question_data) - - # Build leetcode question tuples from data, but only the free ones - for question in json_blobs["parsed_lc_all"]: - if (question["paid_only"] is False): - question_data = (question["stat"]["question__title"], "https://leetcode.com/problems/" - + question["stat"]["question__title_slug"] + "/") - - question_difficulty = LC_DIFFICULTY_MAP[question["difficulty"]["level"] - 1] - - if (question_difficulty == difficulty): - questions.append(question_data) diff --git a/unimplemented/meme.py b/unimplemented/meme.py deleted file mode 100644 index bcd0f0b..0000000 --- a/unimplemented/meme.py +++ /dev/null @@ -1,196 +0,0 @@ -from uqcsbot import bot, Command -import re -from uqcsbot.utils.command_utils import loading_status, success_status, UsageSyntaxException -from urllib.parse import quote - -API_URL = "https://memegen.link/" -# Many different characters need to be replaced in order to work in url format -# See the API_URL for details -REPLACEMENTS = str.maketrans({'_': '__', ' ': '_', '-': '--', '?': '~q', - '%': '~p', '#': '~h', '/': '~s'}) -MEME_NAMES = { - "aag": "Ancient Aliens Guy", - "ackbar": "It's A Trap!", - "afraid": "Afraid to Ask Andy", - "ants": "Do You Want Ants?", - "away": "Life... Finds a Way", - "awesome": "Socially Awesome Penguin", - "awesome-awkward": "Socially Awesome Awkward Penguin", - "awkward": "Socially Awkward Penguin", - "awkward-awesome": "Socially Awkward Awesome Penguin", - "bad": "You Should Feel Bad", - "badchoice": "Milk Was a Bad Choice", - "bd": "Butthurt Dweller", - "bender": "I'm Going to Build My Own Theme Park", - "biw": "Baby Insanity Wolf", - "blb": "Bad Luck Brian", - "boat": "I Should Buy a Boat Cat", - "both": "Why Not Both?", - "bs": "This is Bull, Shark", - "buzz": "X, X Everywhere", - "captain": "I am the Captain Now", - "cb": "Confession Bear", - "cbg": "Comic Book Guy", - "center": "What is this, a Center for Ants?!", - "ch": "Captain Hindsight", - "chosen": "You Were the Chosen One!", - "crazypills": "I Feel Like I'm Taking Crazy Pills", - "cryingfloor": "Crying on Floor", - "disastergirl": "Disaster Girl", - "dodgson": "See? Nobody Cares", - "doge": "Doge", - "drake": "Drakeposting", - "dsm": "Dating Site Murderer", - "dwight": "Schrute Facts", - "elf": "You Sit on a Throne of Lies", - "ermg": "Ermahgerd", - "fa": "Forever Alone", - "facepalm": "Facepalm", - "fbf": "Foul Bachelor Frog", - "fetch": "Stop Trying to Make Fetch Happen", - "fine": "This is Fine", - "firsttry": "First Try!", - "fmr": "Fuck Me, Right?", - "fry": "Futurama Fry", - "fwp": "First World Problems", - "gandalf": "Confused Gandalf", - "ggg": "Good Guy Greg", - "grumpycat": "Grumpy Cat", - "hagrid": "I Should Not Have Said That", - "happening": "It's Happening", - "hipster": "Hipster Barista", - "icanhas": "I Can Has Cheezburger?", - "imsorry": "Oh, I'm Sorry, I Thought This Was America", - "inigo": "Inigo Montoya", - "interesting": "The Most Interesting Man in the World", - "ive": "Jony Ive Redesigns Things", - "iw": "Insanity Wolf", - "jetpack": "Nothing To Do Here", - "joker": "It's Simple, Kill the Batman", - "jw": "Probably Not a Good Idea", - "keanu": "Conspiracy Keanu", - "kermit": "But That's None of My Business", - "live": "Do It Live!", - "ll": "Laughing Lizard", - "mb": "Member Berries", - "mmm": "Minor Mistake Marvin", - "money": "Shut Up and Take My Money!", - "mordor": "One Does Not Simply Walk into Mordor", - "morpheus": "Matrix Morpheus", - "mw": "I Guarantee It", - "nice": "So I Got That Goin' For Me, Which is Nice", - "noidea": "I Have No Idea What I'm Doing", - "oag": "Overly Attached Girlfriend", - "officespace": "That Would Be Great", - "older": "An Older Code Sir, But It Checks Out", - "oprah": "Oprah You Get a Car", - "patrick": "Push it somewhere else Patrick", - "philosoraptor": "Philosoraptor", - "puffin": "Unpopular opinion puffin", - "red": "Oh, Is That What We're Going to Do Today?", - "regret": "I Immediately Regret This Decision!", - "remembers": "Pepperidge Farm Remembers", - "rollsafe": "Roll Safe", - "sad-biden": "Sad Joe Biden", - "sad-boehner": "Sad John Boehner", - "sad-bush": "Sad George Bush", - "sad-clinton": "Sad Bill Clinton", - "sad-obama": "Sad Barack Obama", - "sadfrog": "Sad Frog / Feels Bad Man", - "saltbae": "Salt Bae", - "sarcasticbear": "Sarcastic Bear", - "sb": "Scumbag Brain", - "scc": "Sudden Clarity Clarence", - "sf": "Sealed Fate", - "sk": "Skeptical Third World Kid", - "ski": "Super Cool Ski Instructor", - "snek": "Skeptical Snake", - "soa": "Seal of Approval", - "sohappy": "I Would Be So Happy", - "sohot": "So Hot Right Now", - "sparta": "This is Sparta!", - "spongebob": "Mocking Spongebob", - "ss": "Scumbag Steve", - "stew": "Baby, You've Got a Stew Going", - "success": "Success Kid", - "tenguy": "10 Guy", - "toohigh": "The Rent Is Too Damn High", - "tried": "At Least You Tried", - "ugandanknuck": "Ugandan Knuckles", - "whatyear": "What Year Is It?", - "winter": "Winter is coming", - "wonka": "Condescending Wonka", - "xy": "X all the Y", - "yallgot": "Y'all Got Any More of Them", - "yodawg": "Xzibit Yo Dawg", - "yuno": "Y U NO Guy", -} - -# TODO: Would be really simple to add custom UQCS memes - - -@bot.on_command("meme") -@loading_status -def handle_meme(command: Command): - """ - `!meme "" "")>` - Generates a meme of the given format with the provided top and - bottom text. For a full list of formats, try `!meme names`. - """ - channel = command.channel_id - - if not command.has_arg(): - raise UsageSyntaxException() - - name = command.arg.split()[0].lower() - if name == "names": - send_meme_names(command) - return - elif name not in MEME_NAMES.keys(): - bot.post_message(channel, "The meme name is invalid. " - "Try `!meme names` to get a list of all valid names") - return - - args = get_meme_arguments(command.arg) - if len(args) != 2: - raise UsageSyntaxException() - - # Make an attachment linking to image - top, bottom = args - image_url = API_URL + f"{quote(name)}/{quote(top)}/{quote(bottom)}.jpg" - attachments = [{"text": "", "image_url": image_url}] - bot.post_message(channel, "", attachments=attachments) - - -@success_status -def send_meme_names(command: Command): - """ - Sends the full list of meme names to the users channel to avoid channel spam - """ - user_channel = bot.channels.get(command.user_id) - names_text = "\n".join((f"{full_name}: {name}" for (name, full_name) in MEME_NAMES.items())) - attachments = [{'text': names_text, 'title': "Meme Names:"}] - bot.post_message(user_channel, "", attachments=attachments) - - -def get_meme_arguments(input_args: str): - """ - Gets the top and bottom text and returns them in a - url friendly form that conforms with the api standards - """ - # This gets the text between the quotation marks (and ignores \") - args = re.findall(r'"(.*?(? Tuple[int, str]: - """ - Returns a parking HTML document from the UQ P&F website - """ - page = requests.get("https://pg.pf.uq.edu.au/") - return (page.status_code, page.text) - - -@bot.on_command("parking") -@loading_status -def handle_parking(command: Command) -> None: - """ - `!parking [all]` - Displays how many car parks are available at UQ St. Lucia - By default, only dispalys casual parking availability - """ - - if command.has_arg() and command.arg.lower() == "all": - permit = True - else: - permit = False - - # read parking data - code, data = get_pf_parking_data() - if code != 200: - bot.post_message(command.channel_id, "Could Not Retrieve Parking Data") - return - - response = ["*Available Parks at UQ St. Lucia*"] - names = {"P1": "P1 - Warehouse (14P Daily)", "P2": "P2 - Space Bank (14P Daily)", - "P3": "P3 - Multi-Level West (Staff)", "P4": "P4 - Multi-Level East (Staff)", - "P6": "P6 - Hartley Teakle (14P Hourly)", "P7": "P7 - DustBowl (14P Daily)", - "P7 UC": "P7 - Keith Street (14P Daily Capped)", - "P8 L1": "P8 - Athletics Basement (14P Daily)", - "P8 L2": "P8 - Athletics Roof (14P Daily)", "P9": "P9 - Boatshed (14P Daily)", - "P10": "P10 - UQ Centre & Playing Fields (14P Daily/14P Daily Capped)", - "P11 L1": "P11 - Conifer Knoll Lower (Staff)", - "P11 L2": "P11 - Conifer Knoll Upper (Staff)", - "P11 L3": "P11 - Conifer Knoll Roof (14P Daily Restricted)"} - - def category(fill): - if fill.upper() == "FULL": - return "No" - if fill.upper() == "NEARLY FULL": - return "Few" - return fill - - # find parks - table = Soup(data, "html.parser").find("table", attrs={"id": "parkingAvailability"}) - rows = table.find_all("tr")[1:] - # split and join for single space whitespace - areas = [[" ".join(i.get_text().split()) for i in j.find_all("td")] for j in rows] - - for area in areas: - if area[2]: - response.append(f"{category(area[2])} Carparks Available in {names[area[0]]}") - elif permit and area[1]: - response.append(f"{category(area[1])} Carparks Available in {names[area[0]]}") - bot.post_message(command.channel_id, "\n".join(response)) diff --git a/unimplemented/pokemash.py b/unimplemented/pokemash.py deleted file mode 100644 index caaf6cd..0000000 --- a/unimplemented/pokemash.py +++ /dev/null @@ -1,503 +0,0 @@ -from uqcsbot import bot, Command -from re import match -from typing import Optional - -POKEDEX = {"bulbasaur": 1, - "ivysaur": 2, - "venusaur": 3, - "charmander": 4, - "charmeleon": 5, - "charizard": 6, - "squirtle": 7, - "wartortle": 8, - "blastoise": 9, - "caterpie": 10, - "metapod": 11, - "butterfree": 12, - "weedle": 13, - "kakuna": 14, - "beedrill": 15, - "pidgey": 16, - "pidgeotto": 17, - "pidgeot": 18, - "rattata": 19, - "raticate": 20, - "spearow": 21, - "fearow": 22, - "ekans": 23, - "arbok": 24, - "pikachu": 25, - "raichu": 26, - "sandshrew": 27, - "sandslash": 28, - "nidoran(f)": 29, - "nidorina": 30, - "nidoqueen": 31, - "nidoran(m)": 32, - "nidorino": 33, - "nidoking": 34, - "clefairy": 35, - "clefable": 36, - "vulpix": 37, - "ninetales": 38, - "jigglypuff": 39, - "wigglytuff": 40, - "zubat": 41, - "golbat": 42, - "oddish": 43, - "gloom": 44, - "vileplume": 45, - "paras": 46, - "parasect": 47, - "venonat": 48, - "venomoth": 49, - "diglett": 50, - "dugtrio": 51, - "meowth": 52, - "persian": 53, - "psyduck": 54, - "golduck": 55, - "mankey": 56, - "primeape": 57, - "growlithe": 58, - "arcanine": 59, - "poliwag": 60, - "poliwhirl": 61, - "poliwrath": 62, - "abra": 63, - "kadabra": 64, - "alakazam": 65, - "machop": 66, - "machoke": 67, - "machamp": 68, - "bellsprout": 69, - "weepinbell": 70, - "victreebel": 71, - "tentacool": 72, - "tentacruel": 73, - "geodude": 74, - "graveler": 75, - "golem": 76, - "ponyta": 77, - "rapidash": 78, - "slowpoke": 79, - "slowbro": 80, - "magnemite": 81, - "magneton": 82, - "farfetchd": 83, - "doduo": 84, - "dodrio": 85, - "seel": 86, - "dewgong": 87, - "grimer": 88, - "muk": 89, - "shellder": 90, - "cloyster": 91, - "gastly": 92, - "haunter": 93, - "gengar": 94, - "onix": 95, - "drowzee": 96, - "hypno": 97, - "krabby": 98, - "kingler": 99, - "voltorb": 100, - "electrode": 101, - "exeggcute": 102, - "exeggutor": 103, - "cubone": 104, - "marowak": 105, - "hitmonlee": 106, - "hitmonchan": 107, - "lickitung": 108, - "koffing": 109, - "weezing": 110, - "rhyhorn": 111, - "rhydon": 112, - "chansey": 113, - "tangela": 114, - "kangaskhan": 115, - "horsea": 116, - "seadra": 117, - "goldeen": 118, - "seaking": 119, - "staryu": 120, - "starmie": 121, - "mr. mime": 122, - "scyther": 123, - "jynx": 124, - "electabuzz": 125, - "magmar": 126, - "pinsir": 127, - "tauros": 128, - "magikarp": 129, - "gyarados": 130, - "lapras": 131, - "ditto": 132, - "eevee": 133, - "vaporeon": 134, - "jolteon": 135, - "flareon": 136, - "porygon": 137, - "omanyte": 138, - "omastar": 139, - "kabuto": 140, - "kabutops": 141, - "aerodactyl": 142, - "snorlax": 143, - "articuno": 144, - "zapdos": 145, - "moltres": 146, - "dratini": 147, - "dragonair": 148, - "dragonite": 149, - "mewtwo": 150, - "mew": 151} - -PREFIX = {1: "Bulb", - 2: "Ivy", - 3: "Venu", - 4: "Char", - 5: "Char", - 6: "Char", - 7: "Squirt", - 8: "War", - 9: "Blast", - 10: "Cater", - 11: "Meta", - 12: "Butter", - 13: "Wee", - 14: "Kak", - 15: "Bee", - 16: "Pid", - 17: "Pidg", - 18: "Pidg", - 19: "Rat", - 20: "Rat", - 21: "Spear", - 22: "Fear", - 23: "Ek", - 24: "Arb", - 25: "Pika", - 26: "Rai", - 27: "Sand", - 28: "Sand", - 29: "Nido", - 30: "Nido", - 31: "Nido", - 32: "Nido", - 33: "Nido", - 34: "Nido", - 35: "Clef", - 36: "Clef", - 37: "Vul", - 38: "Nine", - 39: "Jiggly", - 40: "Wiggly", - 41: "Zu", - 42: "Gol", - 43: "Odd", - 44: "Gloo", - 45: "Vile", - 46: "Pa", - 47: "Para", - 48: "Veno", - 49: "Veno", - 50: "Dig", - 51: "Dug", - 52: "Meow", - 53: "Per", - 54: "Psy", - 55: "Gol", - 56: "Man", - 57: "Prime", - 58: "Grow", - 59: "Arca", - 60: "Poli", - 61: "Poli", - 62: "Poli", - 63: "Ab", - 64: "Kada", - 65: "Ala", - 66: "Ma", - 67: "Ma", - 68: "Ma", - 69: "Bell", - 70: "Weepin", - 71: "Victree", - 72: "Tenta", - 73: "Tenta", - 74: "Geo", - 75: "Grav", - 76: "Gol", - 77: "Pony", - 78: "Rapi", - 79: "Slow", - 80: "Slow", - 81: "Magne", - 82: "Magne", - 83: "Far", - 84: "Do", - 85: "Do", - 86: "See", - 87: "Dew", - 88: "Gri", - 89: "Mu", - 90: "Shell", - 91: "Cloy", - 92: "Gas", - 93: "Haunt", - 94: "Gen", - 95: "On", - 96: "Drow", - 97: "Hyp", - 98: "Krab", - 99: "King", - 100: "Volt", - 101: "Electr", - 102: "Exegg", - 103: "Exegg", - 104: "Cu", - 105: "Maro", - 106: "Hitmon", - 107: "Hitmon", - 108: "Licki", - 109: "Koff", - 110: "Wee", - 111: "Rhy", - 112: "Rhy", - 113: "Chan", - 114: "Tang", - 115: "Kangas", - 116: "Hors", - 117: "Sea", - 118: "Gold", - 119: "Sea", - 120: "Star", - 121: "Star", - 122: "Mr.", - 123: "Scy", - 124: "Jyn", - 125: "Electa", - 126: "Mag", - 127: "Pin", - 128: "Tau", - 129: "Magi", - 130: "Gyara", - 131: "Lap", - 132: "Dit", - 133: "Ee", - 134: "Vapor", - 135: "Jolt", - 136: "Flare", - 137: "Pory", - 138: "Oma", - 139: "Oma", - 140: "Kabu", - 141: "Kabu", - 142: "Aero", - 143: "Snor", - 144: "Artic", - 145: "Zap", - 146: "Molt", - 147: "Dra", - 148: "Dragon", - 149: "Dragon", - 150: "Mew", - 151: "Mew"} - -SUFFIX = {1: "basaur", - 2: "ysaur", - 3: "usaur", - 4: "mander", - 5: "meleon", - 6: "izard", - 7: "tle", - 8: "tortle", - 9: "toise", - 10: "pie", - 11: "pod", - 12: "free", - 13: "dle", - 14: "una", - 15: "drill", - 16: "gey", - 17: "eotto", - 18: "eot", - 19: "tata", - 20: "icate", - 21: "row", - 22: "row", - 23: "kans", - 24: "bok", - 25: "chu", - 26: "chu", - 27: "shrew", - 28: "slash", - 29: "oran", - 30: "rina", - 31: "queen", - 32: "ran", - 33: "rino", - 34: "king", - 35: "fairy", - 36: "fable", - 37: "pix", - 38: "tales", - 39: "puff", - 40: "tuff", - 41: "bat", - 42: "bat", - 43: "ish", - 44: "oom", - 45: "plume", - 46: "ras", - 47: "sect", - 48: "nat", - 49: "moth", - 50: "lett", - 51: "trio", - 52: "th", - 53: "sian", - 54: "duck", - 55: "duck", - 56: "key", - 57: "ape", - 58: "lithe", - 59: "nine", - 60: "wag", - 61: "whirl", - 62: "wrath", - 63: "ra", - 64: "bra", - 65: "kazam", - 66: "chop", - 67: "choke", - 68: "champ", - 69: "sprout", - 70: "bell", - 71: "bell", - 72: "cool", - 73: "cruel", - 74: "dude", - 75: "eler", - 76: "em", - 77: "ta", - 78: "dash", - 79: "poke", - 80: "bro", - 81: "mite", - 82: "ton", - 83: "fetchd", - 84: "duo", - 85: "drio", - 86: "eel", - 87: "gong", - 88: "mer", - 89: "uk", - 90: "der", - 91: "ster", - 92: "tly", - 93: "ter", - 94: "gar", - 95: "ix", - 96: "zee", - 97: "no", - 98: "by", - 99: "ler", - 100: "orb", - 101: "ode", - 102: "cute", - 103: "utor", - 104: "bone", - 105: "wak", - 106: "lee", - 107: "chan", - 108: "tung", - 109: "fing", - 110: "zing", - 111: "horn", - 112: "don", - 113: "sey", - 114: "gela", - 115: "khan", - 116: "sea", - 117: "dra", - 118: "deen", - 119: "king", - 120: "yu", - 121: "mie", - 122: "mime", - 123: "ther", - 124: "nx", - 125: "buzz", - 126: "mar", - 127: "sir", - 128: "ros", - 129: "karp", - 130: "dos", - 131: "ras", - 132: "to", - 133: "vee", - 134: "eon", - 135: "eon", - 136: "eon", - 137: "gon", - 138: "nyte", - 139: "star", - 140: "to", - 141: "tops", - 142: "dactyl", - 143: "lax", - 144: "cuno", - 145: "dos", - 146: "tres", - 147: "tini", - 148: "nair", - 149: "nite", - 150: "two", - 151: "ew"} - - -def lookup(command: Command, arg: str) -> Optional[int]: - """ - converts a string representing a pokemon's name or number to an integer - """ - try: - num = int(arg) - except ValueError: - if arg not in POKEDEX: - bot.post_message(command.channel_id, f"Could Not Find Pokemon: {arg}") - return None - num = POKEDEX[arg] - if num <= 0 or num > 151: - bot.post_message(command.channel_id, f"Out of Range: {arg}") - return None - return num - - -@bot.on_command('pokemash') -def handle_pokemash(command: Command): - """ - `!pokemash pokemon pokemon` - Returns the pokemash of the two Pokemon. - Can use Pokemon names or Pokedex numbers (first gen only) - """ - cmd = command.arg.lower() - # checks for exactly two pokemon - # mr. mime is the only pokemon with a space in it's name - if not cmd or (cmd.count(" ") - cmd.count("mr. mime")) != 1: - bot.post_message(command.channel_id, "Incorrect Number of Pokemon") - return - - # two pokemon split - arg_left, arg_right = match(r"(mr\. mime|\S+) (mr\. mime|\S+)", cmd).group(1, 2) - - num_left = lookup(command, arg_left) - num_right = lookup(command, arg_right) - if num_left is None or num_right is None: - return - - bot.post_message(command.channel_id, - f"_{PREFIX[num_left]+SUFFIX[num_right]}_\n" - f"https://images.alexonsager.net/pokemon/fused/" - + f"{num_right}/{num_right}.{num_left}.png") diff --git a/unimplemented/radar.py b/unimplemented/radar.py deleted file mode 100644 index f5e44ca..0000000 --- a/unimplemented/radar.py +++ /dev/null @@ -1,14 +0,0 @@ -from uqcsbot import bot, Command -from time import time - - -@bot.on_command("radar") -def handle_radar(command: Command): - """ - `!radar` - Returns the latest BOM radar image for Brisbane. - """ - time_in_s = int(time()) - radar_url = f'https://bom.lambda.tools/?location=brisbane×tamp={time_in_s}' - attachment = {"image_url": radar_url, "text": None, - "fallback": "Screenshot from the Bureau of Meterology's Brisbane radar."} - bot.post_message(command.channel_id, radar_url, attachments=[attachment]) diff --git a/unimplemented/spider.py b/unimplemented/spider.py deleted file mode 100644 index 10e40e4..0000000 --- a/unimplemented/spider.py +++ /dev/null @@ -1,9 +0,0 @@ -from uqcsbot import bot, Command - - -@bot.on_command("spider") -def handle_spider(command: Command): - """ - `!spider` - Displays the spider shrug. - """ - bot.post_message(command.channel_id, "//\\; ;/\\\\") diff --git a/unimplemented/techcrunch.py b/unimplemented/techcrunch.py deleted file mode 100644 index fbee306..0000000 --- a/unimplemented/techcrunch.py +++ /dev/null @@ -1,48 +0,0 @@ -from uqcsbot import bot, Command -from uqcsbot.utils.command_utils import loading_status -from typing import Tuple, Dict, List - -import feedparser - -ARTICLES_TO_POST = 5 -RSS_URL = "http://feeds.feedburner.com/TechCrunch/" -TECHCRUNCH_URL = "https://techcrunch.com" - -def get_tech_crunch_data() -> List[Dict[str, str]]: - """ - Returns data from TechCrunch RSS feed - """ - data = feedparser.parse(RSS_URL) - if data.status != 200: - return None - return data.entries - -def get_data_from_article(news: List[Dict[str, str]], index: int) -> Tuple[str, str]: - """ - Returns the title of the article and the link - - Tuple returned: (title, url) - """ - return (news[index]['title'], news[index]['link']) - -@bot.on_command("techcrunch") -@loading_status -def handle_news(command: Command) -> None: - """ - Prints the 5 top-most articles in the Latest News Section of TechCrunch - using RSS feed - """ - message = f"*Latest News from <{TECHCRUNCH_URL}|_TechCrunch_> :techcrunch:*\n" - news = get_tech_crunch_data() - if news is None: - bot.post_message(command.channel_id, "There was an error accessing " - "TechCrunch RSS feed") - return - for i in range(ARTICLES_TO_POST): - title, url = get_data_from_article(news, i) - # Formats message a clickable headline which links to the article - # These articles are also now bullet pointed - message += f"• <{url}|{title}>\n" - # Additional parameters ensure that the links don't show as big articles - # underneath the input - bot.post_message(command.channel_id, message, unfurl_links=False, unfurl_media=False) diff --git a/unimplemented/trivia.py b/unimplemented/trivia.py deleted file mode 100644 index f7fda40..0000000 --- a/unimplemented/trivia.py +++ /dev/null @@ -1,376 +0,0 @@ -import argparse -import base64 -import json -import random -from datetime import datetime, timezone, timedelta -from functools import partial -from typing import List, Dict, Union, NamedTuple, Optional, Callable, Set - -import requests - -from uqcsbot import bot, Command -from uqcsbot.api import Channel -from uqcsbot.utils.command_utils import loading_status, UsageSyntaxException - -API_URL = "https://opentdb.com/api.php" -CATEGORIES_URL = "https://opentdb.com/api_category.php" - -# NamedTuple for use with the data returned from the api -QuestionData = NamedTuple('QuestionData', - [('type', str), ('question', str), ('correct_answer', str), - ('answers', List[str]), ('is_boolean', bool)]) - -# Contains information about a reaction and the list of users who used said reaction -ReactionUsers = NamedTuple('ReactionUsers', [('name', str), ('users', Set[str])]) - -# Customisation options -# The interval between reactions being made for the possible answers (prevents order changing) -REACT_INTERVAL = 1 -MIN_SECONDS = 5 -MAX_SECONDS = 300 - -# The channels where multiple trivia questions can be asked (prevent spam) -VALID_SEQUETIAL_CHANNELS = ['trivia', 'bot-testing'] -MAX_SEQUENTIAL_QUESTIONS = 30 - -BOOLEAN_REACTS = ['this', 'not-this'] # Format of [ , ] -# Colours should match CHOICE_COLORS -MULTIPLE_CHOICE_REACTS = ['green_heart', 'yellow_heart', 'heart', 'blue_heart'] -CHOICE_COLORS = ['#6C9935', '#F3C200', '#B6281E', '#3176EF'] - -# What arguments to use for the cron job version -CRON_CHANNEL = 'trivia' -# (One day - 15 seconds) Overrides any -s argument below and ignores MAX_SECONDS rule -CRON_SECONDS = 86385 -CRON_ARGUMENTS = '' - - -@bot.on_command('trivia') -@loading_status -def handle_trivia(command: Command): - """ - `!trivia [-d ] [-c ] - [-t ] [-s ] [-n ] [--cats]` - - Asks a new trivia question - """ - args = parse_arguments(command.channel_id, command.arg if command.has_arg() else '') - - # End early if the help option was used - if args.help: - return - - # Send the possible categories - if args.cats: - bot.post_message(command.channel_id, get_categories()) - return - - # Check if the channel is valid for sequential questions - current_channel = bot.channels.get(command.channel_id) - if all([args.count > 1, not current_channel.is_im, - current_channel.name not in VALID_SEQUETIAL_CHANNELS]): - # If no valid channels are specified - if len(VALID_SEQUETIAL_CHANNELS) == 0: - bot.post_message(command.channel_id, - 'This command can only be used in private messages with the bot') - return - - first_valid = bot.channels.get(VALID_SEQUETIAL_CHANNELS[0]) - channel_message = '' - if first_valid: - channel_message = f'Try <#{first_valid.id}|{VALID_SEQUETIAL_CHANNELS[0]}>.' - bot.post_message(command.channel_id, f'You cannot use the sequential questions ' - + f'feature in this channel. {channel_message}') - return - - handle_question(command.channel_id, args) - - -def parse_arguments(channel: Channel, arg_string: str) -> argparse.Namespace: - """ - Parses the arguments for the command - :param command: The command which the handle_trivia function receives - :return: An argpase Namespace object with the parsed arguments - """ - parser = argparse.ArgumentParser(prog='!trivia', add_help=False) - - def usage_error(*args, **kwargs): - raise UsageSyntaxException() - - parser.error = usage_error # type: ignore - parser.add_argument('-d', '--difficulty', choices=['easy', 'medium', 'hard'], - default='random', type=str.lower, - help='The difficulty of the question. (default: %(default)s)') - parser.add_argument('-c', '--category', default=-1, type=int, - help='Specifies a category (default: any)') - parser.add_argument('-t', '--type', choices=['boolean', 'multiple'], - default="random", type=str.lower, - help='The type of question. (default: %(default)s)') - parser.add_argument('-s', '--seconds', default=30, type=int, - help='Number of seconds before posting answer (default: %(default)s)') - parser.add_argument('-n', '--count', default=1, type=int, help=f"Do 'n' trivia questions in " - f"quick succession (max : {MAX_SEQUENTIAL_QUESTIONS})") - parser.add_argument('--cats', action='store_true', - help='Sends a list of valid categories to the user') - parser.add_argument('-h', '--help', action='store_true', help='Prints this help message') - - args = parser.parse_args(arg_string.split()) - - # If the help option was used print the help message to - # the channel (needs access to the parser to do this) - if args.help: - bot.post_message(channel, parser.format_help()) - - # Constrain the number of seconds to a reasonable frame - args.seconds = max(MIN_SECONDS, args.seconds) - args.seconds = min(args.seconds, MAX_SECONDS) - - # Constrain the number of sequential questions - args.count = max(args.count, 1) - args.count = min(args.count, MAX_SEQUENTIAL_QUESTIONS) - - # Add an original count to keep track - args.original_count = args.count - - return args - - -def get_categories() -> str: - """ - Gets the message to send if the user wants a list of the available categories. - """ - http_response = requests.get(CATEGORIES_URL) - if http_response.status_code != requests.codes.ok: - return "There was a problem getting the response" - - categories = json.loads(http_response.content)['trivia_categories'] - - # Construct pretty results to print in a code block to avoid a large spammy message - pretty_results = '```Use the id to specify a specific category \n\nID Name\n' - - for category in categories: - pretty_results += f'{category["id"]:<4d}{category["name"]}\n' - - pretty_results += '```' - - return pretty_results - - -def handle_question(channel: Channel, args: argparse.Namespace): - """ - Handles getting a question and posting it to the channel as well as scheduling the answer. - Returns the reaction string for the correct answer. - """ - question_data = get_question_data(channel, args) - - if question_data is None: - return - - question_number = args.original_count - args.count + 1 - prefix = f'Q{question_number}:' if args.original_count > 1 else '' - post_question(channel, question_data, prefix) - - # Get the answer message - if question_data.is_boolean: - if question_data.correct_answer == 'True': - answer_text = f':{BOOLEAN_REACTS[0]}:' - else: - answer_text = f':{BOOLEAN_REACTS[1]}:' - else: - answer_text = question_data.correct_answer - - answer_message = f'The answer to the question *{question_data.question}* is: *{answer_text}*' - - # Schedule the answer to be posted after the specified number of seconds has passed - post_answer = partial(bot.post_message, channel, answer_message) - schedule_action(post_answer, args.seconds) - - # If more questions are to be asked schedule the question for 5 seconds after the current answer - if args.count > 1: - args.count -= 1 - schedule_action(partial(handle_question, channel, args), args.seconds + 5) - - -def get_question_data(channel: Channel, args: argparse.Namespace) -> Optional[QuestionData]: - """ - Attempts to get a question from the api using the specified arguments. - Returns the dictionary object for the question on success - and None on failure (after posting an error message). - """ - # Base64 to help with encoding the message for slack - params: Dict[str, Union[int, str]] = {'amount': 1, 'encode': 'base64'} - - # Add in any explicitly specified arguments - if args.category != -1: - params['category'] = args.category - - if args.difficulty != 'random': - params['difficulty'] = args.difficulty - - if args.type != 'random': - params['type'] = args.type - - # Get the response and check that it is valid - http_response = requests.get(API_URL, params=params) - if http_response.status_code != requests.codes.ok: - bot.post_message(channel, "There was a problem getting the response") - return None - - # Check the response codes and post a useful message in the case of an error - response_content = json.loads(http_response.content) - if response_content['response_code'] == 2: - bot.post_message(channel, "Invalid category id. " - + "Try !trivia --cats for a list of valid categories.") - return None - elif response_content['response_code'] != 0: - bot.post_message(channel, "No results were returned") - return None - - question_data = response_content['results'][0] - - # Get the type of question and make the NamedTuple container for the data - is_boolean = len(question_data['incorrect_answers']) == 1 - answers = [question_data['correct_answer']] + question_data['incorrect_answers'] - - # Delete the ones we don't need - del question_data['category'] - del question_data['difficulty'] - del question_data['incorrect_answers'] - - # Decode the ones we want. The base 64 decoding ensures - # that the formatting works properly with slack. - question_data['question'] = decode_b64(question_data['question']) - question_data['correct_answer'] = decode_b64(question_data['correct_answer']) - answers = [decode_b64(ans) for ans in answers] - - question_data = QuestionData(is_boolean=is_boolean, answers=answers, **question_data) - - # Shuffle the answers - random.shuffle(question_data.answers) - - return question_data - - -def post_question(channel: Channel, question_data: QuestionData, prefix: str = '') -> float: - """ - Posts the question from the given QuestionData along with - the possible answers list if applicable. - Also creates the answer reacts. - Returns the timestamp of the posted message. - """ - # Post the question and get the timestamp for the reactions (asterisks bold it) - message_ts = bot.post_message(channel, f'*{prefix} {question_data.question}*')['ts'] - - # Print the questions (if multiple choice) and add the answer reactions - reactions = BOOLEAN_REACTS if question_data.is_boolean else MULTIPLE_CHOICE_REACTS - - if not question_data.is_boolean: - message_ts = post_possible_answers(channel, question_data.answers) - - add_reactions_interval(reactions, channel, message_ts, REACT_INTERVAL) - - return message_ts - - -def add_reactions_interval(reactions: List[str], channel: Channel, - msg_timestamp: str, interval: float = 1): - """ - Adds the given reactions with "interval" seconds between in order - to prevent them from changing order in slack (as slack uses the - timestamp of when the reaction was added to determine the order). - :param reactions: The reactions to add - :param channel: The channel containing the desired message to react to - :param msg_timestamp: The timestamp of the required message - :param interval: The interval between posting each reaction (defaults to 1 second) - """ - # If the react interval is 0 don't do any of the scheduling stuff - if REACT_INTERVAL == 0: - for reaction in reactions: - bot.api.reactions.add(name=reaction, channel=channel, timestamp=msg_timestamp) - - return - - # Do the first one immediately - bot.api.reactions.add(name=reactions[0], channel=channel, timestamp=msg_timestamp) - - # I am not 100% sure why this is needed. Doing it with a normal partial or - # lambda will try to post the same reacts - def add_reaction(reaction: str): - bot.api.reactions.add(name=reaction, channel=channel, timestamp=msg_timestamp) - - for index, reaction in enumerate(reactions[1:]): - delay = (index + 1) * interval - schedule_action(partial(add_reaction, reaction), delay) - - -def decode_b64(encoded: str) -> str: - """ - Takes a base64 encoded string. Returns the decoded version to utf-8. - """ - return base64.b64decode(encoded).decode('utf-8') - - -def get_correct_reaction(question_data: QuestionData): - """ - Returns the reaction that matches with the correct answer - """ - if question_data.is_boolean: - if question_data.correct_answer == 'True': - correct_reaction = BOOLEAN_REACTS[0] - else: - correct_reaction = BOOLEAN_REACTS[1] - else: - correct_reaction = MULTIPLE_CHOICE_REACTS[ - question_data.answers.index(question_data.correct_answer)] - - return correct_reaction - - -def post_possible_answers(channel: Channel, answers: List[str]) -> float: - """ - Posts the possible answers for a multiple choice question in a nice way. - Returns the timestamp of the message to allow reacting to it. - """ - attachments = [] - for col, answer in zip(CHOICE_COLORS, answers): - ans_att = {'text': answer, 'color': col} - attachments.append(ans_att) - - return bot.post_message(channel, '', attachments=attachments)['ts'] - - -def schedule_action(action: Callable, secs: Union[int, float]): - """ - Schedules the supplied action to be called once in the given number of seconds. - """ - run_date = datetime.now(timezone(timedelta(hours=10))) + timedelta(seconds=secs) - bot._scheduler.add_job(action, 'date', run_date=run_date) - - -@bot.on_schedule('cron', hour=12, timezone='Australia/Brisbane') -def daily_trivia(): - """ - Adds a job that displays a random question to the specified channel at lunch time - """ - channel = bot.channels.get(CRON_CHANNEL).id - - # Get arguments and update the seconds - args = parse_arguments(channel, CRON_ARGUMENTS) - args.seconds = CRON_SECONDS - - # Get and post the actual question - handle_question(channel, args) - - # Format a nice message to tell when the answer will be - hours = CRON_SECONDS // 3600 - minutes = (CRON_SECONDS - (hours * 3600)) // 60 - if minutes > 55: - hours += 1 - minutes = 0 - - time_until_answer = 'Answer in ' - if hours > 0: - time_until_answer += f'{hours} hours' - if minutes > 0: - time_until_answer += f' and {minutes} minutes' if hours > 0 else f'{minutes} minutes' - - bot.post_message(channel, time_until_answer) diff --git a/unimplemented/umart.py b/unimplemented/umart.py deleted file mode 100644 index 0daaa2f..0000000 --- a/unimplemented/umart.py +++ /dev/null @@ -1,79 +0,0 @@ -from uqcsbot import bot, Command -from requests import get -from requests.exceptions import RequestException -from bs4 import BeautifulSoup -from uqcsbot.utils.command_utils import loading_status - -NO_QUERY_MESSAGE = "You can't look for nothing. `!umart `" -NO_RESULTS_MESSAGE = "I can't find nothing baus! Try `!umart `" -ERROR_MESSAGE = "I tried to get the things but alas I could not. Error with HTTP Request." - -UMART_SEARCH_URL = "https://www.umart.com.au/umart1/pro/products_list_searchnew_min.phtml" -UMART_PRODUCT_URL = "https://www.umart.com.au/umart1/pro/" - - -@bot.on_command("umart") -@loading_status -def handle_umart(command: Command): - """ - `!umart ` - Returns 5 top results for products from umart matching the search query. - """ - # Makes sure the query is not empty - if not command.has_arg(): - bot.post_message(command.channel_id, NO_QUERY_MESSAGE) - return - search_query = command.arg.strip() - # Detects if user is being smart - if "SOMETHING NOT AS SPECIFIC" in search_query: - bot.post_message(command.channel_id, "Not literally...") - return - search_results = get_umart_results(search_query) - if search_results is None: - bot.post_message(command.channel_id, ERROR_MESSAGE) - return - if len(search_results) == 0: - bot.post_message(command.channel_id, NO_RESULTS_MESSAGE) - return - message = "```" - for result in search_results: - message += f"Name: <{UMART_PRODUCT_URL}{result['link']}|{result['name']}>\n" - message += f"Price: {result['price']}\n" - message += "```" - bot.post_message(command.channel_id, message) - - -def get_umart_results(search_query): - """ - Gets the top results based on the search_query. - Returns up to 5 results. - """ - search_page = get_search_page(search_query) - if search_page is None: - return None - return get_results_from_page(search_page) - - -def get_results_from_page(search_page): - """ - Strips results from html page - """ - html = BeautifulSoup(search_page, "html.parser") - search_items = [] - for li in html.select("li"): - name = li.select("a.proname")[0].get_text() - price = li.select("dl:nth-of-type(2) > dd > span")[0].get_text() - link = li.select("a.proname")[0]["href"] - search_items.append({"name": name, "price": price, "link": link}) - return search_items - - -def get_search_page(search_query): - """ - Gets the search page HTML - """ - try: - resp = get(UMART_SEARCH_URL + "?search=" + search_query + "&bid=2") - return resp.content - except RequestException as e: - bot.logger.error(f"A request error {e.resp.status} occurred:\n{e.content}") - return None diff --git a/unimplemented/uqfinal.py b/unimplemented/uqfinal.py deleted file mode 100644 index 6a10c06..0000000 --- a/unimplemented/uqfinal.py +++ /dev/null @@ -1,144 +0,0 @@ -from math import ceil -from uqcsbot import bot, Command -from requests import get, RequestException, Response -from typing import List -from uqcsbot.utils.command_utils import loading_status - -UQFINAL_API = "https://api.uqfinal.com" -GRADES = [(0.5, 'four'), (0.65, 'five'), (0.75, 'six'), (0.85, 'seven')] - - -@bot.on_command("uqfinal") -@loading_status -def handle_uqfinal(command: Command): - """ - `!uqfinal ` - Check UQFinal for course CODE - with the first assessments pieces as as percentages - """ - # Makes sure the query is not empty - if not command.has_arg(): - bot.post_message(command.channel_id, "Please choose a course") - return - - args = command.arg.split() - - course = args[0] # Always exists - arg_scores = args[1:] - scores: List[float] = [] - - # get UQ Final data - semester = get_uqfinal_semesters() - if semester is None: - bot.post_message(command.channel_id, "Failed to retrieve semester data from UQfinal") - return - - course_info = get_uqfinal_course(semester, course) - if course_info is None: - bot.post_message(command.channel_id, f"Failed to retrieve course information for {course}") - return - assessments = course_info["assessment"] - - # if no results submitted - if not arg_scores: - message = [f"{course.upper()} has the following assessments:"] - for i, assess in enumerate(assessments): - message.append(f"{i+1}: {assess['taskName']} ({assess['weight']}%)") - message.append("_Powered by http://uqfinal.com_") - bot.post_message(command.channel_id, "\n".join(message)) - return - - # convert arugments to decimals - for arg_score in arg_scores: - try: - score = float(arg_score.rstrip("%")) - except ValueError: - bot.post_message(command.channel_id, - f"\"{arg_score}\" could not be converted to a number.") - return - score_deci = score / (100 if score > 1 else 1) - if score_deci < 0 or score_deci > 1: - bot.post_message(command.channel_id, - "Assessments scores should be between 0% and 100%.") - return - scores.append(score_deci) - - # if too many results - if len(scores) >= len(assessments): - bot.post_message(command.channel_id, - f"Too many retults provided.\n" - f"This course has {len(assessments)} assessments.") - return - - # calculate achived marks - total_deci = 0.0 - results = [] - for i, score_deci in enumerate(scores): - total_deci += score_deci * float(assessments[i]["weight"]) / 100 - results.append(f"Inputted score of {round(score_deci * 100)}% for" - + f" {assessments[i]['taskName']} (weighted {assessments[i]['weight']}%)") - bot.post_message(command.channel_id, "\n".join(results)) - - # calculate remaining marks - remain_deci = 0.0 - for i in range(len(scores), len(assessments)): - remain_deci += float(assessments[i]["weight"]) / 100 - - # calculate marks needed to achieve grades - message = [] - for cutoff_deci, grade in GRADES: - needed_perc = ceil(100 * (cutoff_deci - total_deci) / remain_deci) - if needed_perc > 100: - break - if needed_perc <= 0: - message.append(f"You have achieved a {grade} :toot:.") - elif len(scores) == len(assessments) - 1: - message.append(f"You need to score at least {needed_perc}%" - + f" on the {assessments[-1]['taskName']} to achieve a {grade}.") - else: - message.append(f"You need to score at least a weighted average of {needed_perc}%" - + f" on the remaining {len(assessments) - len(scores)}" - + f" assessments to achieve a {grade}.") - - # if getting a four impossible - if not message: - message.append("I am a servant of the Secret Fire, wielder of the flame of Anor." - + " The dark fire will not avail you, flame of Udûn." - + " Go back to the Shadow! *You cannot pass.*") - message.append("_Disclaimer: this does not take hurdles into account._") - message.append("_Powered by http://uqfinal.com_") - bot.post_message(command.channel_id, "\n".join(message)) - - -def get_uqfinal_semesters(): - """ - Get the current semester data from uqfinal - Return None on failure - """ - try: - # Assume current semester - semester_response: Response = get(UQFINAL_API + "/semesters") - if semester_response.status_code != 200: - bot.logger.error(f"UQFinal returned {semester_response.status_code}" - + f" when getting the current semester") - return None - return semester_response.json()["data"]["semesters"].pop() - except RequestException as e: - bot.logger.error(f"A request error {e.response.status} occurred:\n{e.response.content}") - return None - - -def get_uqfinal_course(semester, course: str): - """ - Get the current course data from uqfinal - Return None on failure - """ - try: - course_response = get("/".join([UQFINAL_API, "course", str(semester["uqId"]), course])) - if course_response.status_code != 200: - bot.logger.error(f"UQFinal returned {course_response.status_code}" - + f" when getting the course {course}") - return None - return course_response.json()["data"] - except RequestException as e: - bot.logger.error(f"A request error {e.response.status} occurred:\n{e.response.content}") - return None diff --git a/unimplemented/urban.py b/unimplemented/urban.py deleted file mode 100644 index 32e9bcd..0000000 --- a/unimplemented/urban.py +++ /dev/null @@ -1,62 +0,0 @@ -import re -import requests -from uqcsbot import bot, Command -from uqcsbot.utils.command_utils import loading_status, UsageSyntaxException - -URBAN_API_ENDPOINT = 'http://api.urbandictionary.com/v0/define' -URBAN_USER_ENDPOINT = 'https://www.urbandictionary.com/define.php' - - -@bot.on_command('urban') -@loading_status -def handle_urban(command: Command) -> None: - """ - `!urban ` - Looks a phrase up on Urban Dictionary. - """ - # Check for search phase - if not command.has_arg(): - raise UsageSyntaxException() - - search_term = command.arg - - # Attempt to get definitions from the Urban Dictionary API. - http_response = requests.get(URBAN_API_ENDPOINT, params={'term': search_term}) - if http_response.status_code != 200: - bot.post_message(command.channel_id, - 'There was an error accessing the Urban Dictionary API.') - return - results = http_response.json() - - # Filter for exact matches - filtered_definitions = filter(lambda def_: def_['word'].casefold() == search_term.casefold(), - results['list']) - - # Sort definitions by their number of thumbs ups. - sorted_definitions = sorted(filtered_definitions, key=lambda def_: def_['thumbs_up'], - reverse=True) - - # If search phrase is not found, notify user. - if len(sorted_definitions) == 0: - bot.post_message(command.channel_id, f'> No results found for {search_term}. ¯\\_(ツ)_/¯') - return - - best_definition = sorted_definitions[0] - # Remove Urban Dictionary [links] - best_definition_text = re.sub(r'[\[\]]', '', best_definition["definition"]) - - # Remove Urban Dictionary [links] - example_text = re.sub(r'[\[\]]', '', best_definition.get('example', '')) - # Break example into individual lines and wrap each in it's own block quote. - example_lines = example_text.split('\r\n') - formatted_example = '\n'.join(f'> {line}' for line in example_lines) - - # Format message and send response to user in channel query was sent from. - message = (f'*{search_term.title()}*\n' - f'{best_definition_text.capitalize()}\n' - f'{formatted_example}') - # Only link back to Urban Dictionary if there are more definitions. - if len(sorted_definitions) > 1: - endpoint_url = http_response.url.replace(URBAN_API_ENDPOINT, URBAN_USER_ENDPOINT) - message += f'\n_ more definitions at {endpoint_url} _' - - bot.post_message(command.channel_id, message) diff --git a/unimplemented/weather.py b/unimplemented/weather.py deleted file mode 100644 index 2442528..0000000 --- a/unimplemented/weather.py +++ /dev/null @@ -1,219 +0,0 @@ -from uqcsbot import bot, Command -from uqcsbot.utils.command_utils import loading_status -from urllib.request import urlopen -import xml.etree.ElementTree as ET -from datetime import datetime as DT -from typing import Union, Tuple - - -def get_xml(state: str) -> Union[None, ET.Element]: - """ - Get BOM data as an XML for a given state - """ - source = {"NSW": "IDN11060", "ACT": "IDN11060", "NT": "IDD10207", "QLD": "IDQ11295", - "SA": "IDS10044", "TAS": "IDT16710", "VIC": "IDV10753", "WA": "IDW14199"} - try: - data = urlopen(f"ftp://ftp.bom.gov.au/anon/gen/fwo/{source[state]}.xml") - root = ET.fromstring(data.read()) - except Exception: - return None - return root - - -def process_arguments(arguments: str) -> Tuple[str, str, int]: - """ - Process the arguments given to !weather, dividing them into state, location and future - Uses default of QLD, Brisbane and 0 if not given - """ - args = arguments.split(" ") if arguments else [] - if args and args[-1].lstrip('-+').isnumeric(): - future = int(args.pop()) - else: - future = 0 - - # get location - if args: - if args[0].upper() in ["NSW", "ACT", "NT", "QLD", "SA", "TAS", "VIC", "WA"]: - state = args.pop(0).upper() - else: - state = "QLD" - location = " ".join(args) - else: - state = "QLD" - location = "Brisbane" - - return state, location, future - - -def find_location(root: ET.Element, location: str, future: int) \ - -> Tuple[Union[None, ET.Element], Union[None, str]]: - """ - Returns the XML for a given the location and how far into the future - """ - node = root.find(f".//area[@description='{location}']") - if node is None: - return None, "Location Not Found" - if node.get("type") != "location": - return None, "Location Given Is Region" - node = node.find(f".//forecast-period[@index='{future}']") - if node is None: - return None, "No Forecast Available For That Day" - return node, None - - -def response_header(node: ET.Element, location: str) -> str: - """ - Returns the response header, in the form "{Location}'s Weather Forecast For {Day}" - """ - forecast_date = DT.strptime("".join(node.get('start-time-local') - .rsplit(":", 1)), "%Y-%m-%dT%H:%M:%S%z").date() - today_date = DT.now().date() - date_delta = (forecast_date - today_date).days - if date_delta == 0: - date_name = "Today" - elif date_delta == 1: - date_name = "Tomorrow" - elif date_delta == -1: - # can happen during the witching hours - date_name = "Yesterday" - else: - date_name = forecast_date.strftime("%A") - return f"*{date_name}'s Weather Forecast For {location}*" - - -def response_overall(node: ET.Element) -> str: - """ - Returns the overall forecast - """ - icon_code = node.find(".//element[@type='forecast_icon_code']") - if icon_code is not None: - icon = ["", "sunny", "clear", "partly-cloudy", "cloudy", "", "haze", "", "light-rain", - "wind", "fog", "showers", "rain", "dust", "frost", "snow", "storm", - "light-showers", "heavy-showers", "tropicalcyclone"][int(icon_code.text)] - icon = f":bom_{icon}:" if icon else "" - descrip = node.find(".//text[@type='precis']") - if descrip is not None: - return f"{icon} {descrip.text} {icon}" - return "" - - -def response_temperature(node: ET.Element) -> str: - """ - Returns the temperature forecast - """ - temp_min = node.find(".//element[@type='air_temperature_minimum']") - temp_max = node.find(".//element[@type='air_temperature_maximum']") - if temp_min is not None and temp_max is not None: - return f"Temperature: {temp_min.text}ºC - {temp_max.text}ºC" - elif temp_min is not None: - return f"Minimum Temperature: {temp_min.text}ºC" - elif temp_max is not None: - return f"Maximum Temperature: {temp_max.text}ºC" - return "" - - -def response_precipitation(node: ET.Element) -> str: - """ - Returns the precipitation forecast - """ - rain_range = node.find(".//element[@type='precipitation_range']") - precip_prob = node.find(".//text[@type='probability_of_precipitation']") - if rain_range is not None and precip_prob is not None: - return f"{precip_prob.text} Chance of Precipitation; {rain_range.text}" - elif precip_prob is not None: - return f"{precip_prob.text} Chance of Precipitation" - return "" - - -def response_brisbane_detailed() -> Tuple[str, str, str]: - """ - Returns a detailed forecast for Brisbane - """ - try: - data = urlopen("ftp://ftp.bom.gov.au/anon/gen/fwo/IDQ10605.xml") - root = ET.fromstring(data.read()) - except Exception: - return "", "", "" - node = root.find(".//area[@description='Brisbane']") - if node is None: - return "", "", "" - node = node.find(".//forecast-period[@index='0']") - if node is None: - return "", "", "" - - forecast_node = node.find(".//text[@type='forecast']") - forecast = "" if forecast_node is None else forecast_node.text - - fire_danger_node = node.find(".//text[@type='fire_danger']") - if fire_danger_node is None or fire_danger_node.text == "Low-Moderate": - fire_danger = "" - else: - fire_danger = f"There Is A {fire_danger_node.text} Fire Danger Today" - - uv_alert_node = node.find(".//text[@type='uv_alert']") - uv_alert = "" if uv_alert_node is None else uv_alert_node.text - - return (forecast, fire_danger, uv_alert) - - -@bot.on_command('weather') -@loading_status -def handle_weather(command: Command) -> None: - """ - `!weather [[state] location] [day]` - Returns the weather forecast for a location - `day` is how many days into the future the forecast is for (0 is today and default) - `location` defaults to Brisbane, and `state` defaults to QLD - """ - - (state, location, future) = process_arguments(command.arg) - - root = get_xml(state) - if root is None: - failure_respone = bot.post_message(command.channel_id, "Could Not Retrieve BOM Data") - bot.api.reactions.add(channel=failure_respone["channel"], - timestamp=failure_respone["ts"], name="disapproval") - return - - node, find_response = find_location(root, location, future) - if node is None: - bot.post_message(command.channel_id, find_response) - return - - # get responses - response = [] - response.append(response_header(node, location)) - response.append(response_overall(node)) - response.append(response_temperature(node)) - response.append(response_precipitation(node)) - # post - bot.post_message(command.channel_id, "\n".join([r for r in response if r])) - - -@bot.on_schedule('cron', hour=6, minute=0, timezone='Australia/Brisbane') -def daily_weather() -> None: - """ - Posts today's Brisbane weather at 6:00am every day - """ - - (state, location, future) = ("QLD", "Brisbane", 0) - - root = get_xml(state) - if root is None: - return - - node, find_response = find_location(root, location, future) - if node is None: - return - - # get responses - response = [] - brisbane_detailed, brisbane_fire, brisbane_uv = response_brisbane_detailed() - response.append(response_header(node, location)) - response.append(response_overall(node)) - response.append(brisbane_detailed) - response.append(response_temperature(node)) - response.append(brisbane_fire) - response.append(brisbane_uv) - # post - general = bot.channels.get("general") - bot.post_message(general.id, "\n".join([r for r in response if r])) diff --git a/unimplemented/welcome.py b/unimplemented/welcome.py deleted file mode 100644 index f8cfdd1..0000000 --- a/unimplemented/welcome.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -Welcomes new users to UQCS Slack and check for member milestones -""" -from uqcsbot import bot -import time - -MEMBER_MILESTONE = 50 # Number of members between posting a celebration -MESSAGE_PAUSE = 2.5 # Number of seconds between sending bot messages -SLACK_DOWNLOAD_GUIDE = "https://slack.com/intl/en-au/help/categories/360000049043-Getting-" \ - "Started#download-the-slack-app" -UQCSBOT_REPO = "https://github.com/UQComputingSociety/uqcsbot" -SLACK_PROFILE_GUIDE = "https://slack.com/intl/en-au/help/articles/204092246-Edit-your-profile" - -WELCOME_MESSAGES = [ # Welcome messages sent to new members - "Hey there! Welcome to the UQCS Slack!", - - "This is the first time I've seen you, so you're probably new here.", - - f"I'm UQCSbot, your friendly (<{UQCSBOT_REPO}|open source>) robot helper!", - - "You can type `help` here, or `!help` anywhere else to find out what I can do!", - - "We've got a bunch of generic channels (e.g. <#C0D0BEYPM|banter>," - " <#C0DKX7NGP|games>, <#CB2K0Q09K|adulting>) along with many subject-specific" - " ones (e.g <#C0MAN4BRS|csse1001>, <#C0Q2KTCK1|math1051>, <#C0DKSDGLE|csse2310>).", - - "To find and join a channel, tap on the channels header in the sidebar.", - - "The UQCS Slack is a friendly community and we have a code of conduct in place to ensure our " - "members' well-being and safety. You can view a copy of this code of conduct here:" - "\n>https://github.com/UQComputingSociety/code-of-conduct", - - "UQCS elects a leadership committee every year who also serve as our friendly Slack admins. " - "This year's committee consists of:\n" - ">James (<@U9D6J8HB8>), Sanni (<@UM55HGLUT>), Kenton (<@U9LMBPJG5>), " - "Darren (<@U4B6LPU2J>), Madhav (<@UFB9R5QFM>), Matthew (<@U8JN3NR1C>), " - "Olivia (<@UA25BSPGT>), Paul (<@UTYTKAB89>), Sylvia (<@U01BXR5TX9T>), " - "Tom (<@UAGPENV96>), and Utkarsh (<@U010W5VDR36>).\n" - "If you have any questions or need to get in touch, please reach out to them.", - - "We also hold heaps of events events during semester. For a list of upcoming events check " - "out the <#C0D0G52PP|events> channel and use the command `!events`.", - - "Don't forget to let people know who you are! Choose a profile pic, set a " - "status and tell us what you're studying. These small touches help the UQCS " - "community understand who you are and, in turn, help you to more quickly " - "become an integral part of the society. If you need any help, check out " - f"this handy <{SLACK_PROFILE_GUIDE}|guide> or ask one admins for help! " - "Once that's all done, why not introduce yourself in <#C2R8W0YPJ|general>.", - - "Be sure to download the Slack and " - f"<{SLACK_DOWNLOAD_GUIDE}|mobile> apps as well, so you'll be able to catch any important " - "announcements, and again, welcome to the UQ Computing Society :)" -] - - -@bot.on("member_joined_channel") -def welcome(evt: dict): - """ - Welcomes new users to UQCS Slack and checks for member milestones. - - @no_help - """ - chan = bot.channels.get(evt.get('channel')) - if chan is None or chan.name != "announcements": - return - - announcements = chan - general = bot.channels.get("general") - user = bot.users.get(evt.get("user")) - - if user is None or user.is_bot: - return - - # Welcome user in general. - bot.post_message(general, f"Welcome to UQCS, <@{user.user_id}>! :tada:") - - # Calculate number of members, ignoring deleted users and bots. - num_members = 0 - for member_id in announcements.members: - member = bot.users.get(member_id) - if any([member is None, member.deleted, member.is_bot]): - continue - num_members += 1 - - # Alert general of any member milestone. - if num_members % MEMBER_MILESTONE == 0: - bot.post_message(general, f":tada: {num_members} members! :tada:") - - # Send new user their welcome messages. - for message in WELCOME_MESSAGES: - time.sleep(MESSAGE_PAUSE) - bot.post_message(user.user_id, message) diff --git a/unimplemented/wiki.py b/unimplemented/wiki.py deleted file mode 100644 index 194fd39..0000000 --- a/unimplemented/wiki.py +++ /dev/null @@ -1,45 +0,0 @@ -from uqcsbot import bot, Command -import requests -import json -from uqcsbot.utils.command_utils import UsageSyntaxException - - -@bot.on_command("wiki") -def handle_wiki(command: Command): - """ - `!wiki ` - Returns a snippet of text from a relevent wikipedia entry. - """ - if not command.has_arg(): - raise UsageSyntaxException() - - search_query = command.arg - api_url = f"https://en.wikipedia.org/w/api.php?action=opensearch&format=json&limit=2" - - http_response = requests.get(api_url, params={'search': search_query}) - if http_response.status_code != requests.codes.ok: - bot.post_message(command.channel_id, "Problem fetching data") - return - - _, title_list, snippet_list, url_list = json.loads(http_response.content) - - # If the results are empty let them know. Any list being empty signifies this. - if len(title_list) == 0: - bot.post_message(command.channel_id, "No Results Found.") - return - - title, snippet, url = title_list[0], snippet_list[0], url_list[0] - - # Sometimes the first element is an empty string which is weird so we handle that rare case here - if len(title) * len(snippet) * len(url) == 0: - bot.post_message(command.channel_id, "Sorry, there was something funny about the result") - return - - # Detect if there are multiple references to the query - # if so, use the first reference (i.e. the second item in the lists). - multiple_reference_instances = ("may refer to:", "may have several meanings:") - if any(instance in snippet for instance in multiple_reference_instances): - title, snippet, url = title_list[1], snippet_list[1], url_list[1] - - # The first url and title matches the first snippet containing any content - message = f'{title}: {snippet}\nLink: {url}' - bot.post_message(command.channel_id, message) diff --git a/unimplemented/wolfram.py b/unimplemented/wolfram.py deleted file mode 100644 index c9b6bbc..0000000 --- a/unimplemented/wolfram.py +++ /dev/null @@ -1,251 +0,0 @@ -from uqcsbot import bot, Command -from typing import Iterable, Tuple, Optional -import requests -import json -import os -from uqcsbot.utils.command_utils import loading_status, UsageSyntaxException - -WOLFRAM_APP_ID = os.environ.get('WOLFRAM_APP_ID') - - -def get_subpods(pods: list) -> Iterable[Tuple[str, dict]]: - """ - Yields subpods in the order they should be displayed. A subpod is essentially an element - of a wolfram response. For example one pod might be "Visual Representation" and the - subpod is a graph of your input. Every pod has at least one subpod (usually only one). - - Yield: (pod_or_subpod_title, subpod) - """ - for pod in pods: - for subpod in pod["subpods"]: - # Use the pods title if the subpod doesn't have its own title (general case) - title = pod['title'] if len(subpod['title']) == 0 else subpod['title'] - yield (title, subpod) - - -@bot.on_command('wolfram') -@loading_status -def handle_wolfram(command: Command): - """ - `!wolfram [--full] ` - Returns the wolfram response for the - given query. If `--full` is specified, will return the full reponse. - """ - if not command.has_arg(): - raise UsageSyntaxException() - - # Determines whether to use the full version or the short version. The full - # version is used if the --full. argument is supplied before or after the - # search query. See wolfram_full and wolfram_normal for the differences. - cmd = command.arg.strip() - # Doing it specific to the start and end just in case someone - # has --full inside their query for whatever reason. - if cmd.startswith('--full'): - cmd = cmd[len('--full'):] # removes the --full - wolfram_full(cmd, command.channel_id) - elif cmd.endswith('--full'): - cmd = cmd[:-len('--full')] # removes the --full - wolfram_full(cmd, command.channel_id) - else: - wolfram_normal(cmd, command.channel_id) - - -def wolfram_full(search_query: str, channel): - """ - This posts the full results from wolfram query. Images and all - - Example usage: - !wolfram --full y = 2x + c - """ - api_url = "http://api.wolframalpha.com/v2/query?&output=json" - http_response = requests.get(api_url, params={'input': search_query, 'appid': WOLFRAM_APP_ID}) - - # Check if the response is ok - if http_response.status_code != requests.codes.ok: - bot.post_message(channel, "There was a problem getting the response") - return - - # Get the result of the query and determine if wolfram succeeded in evaluating it - result = json.loads(http_response.content)['queryresult'] - if not result['success'] or result["error"]: - bot.post_message(channel, "Please rephrase your query. Wolfram could not compute.") - return - - # A pod is the name wolfram gives to the different "units" that make up its result. - # For example a pod may be a "Visual Representation" of the input. - # Essentially they are logical components. Each pod has one or more subpods that compose it. - message = "" - for title, subpod in get_subpods(result['pods']): - plaintext = subpod["plaintext"] - - # Prefer a plain text representation to the image - if plaintext != "" and plaintext != "* * * * * *": - message += f'{title}: {plaintext}\n' - else: - image_url = subpod['img']['src'] - image_title = subpod['img']['title'] - if len(image_title) > 0: - message += f'{image_title}:\n{image_url}\n' - else: - message += f'{image_url}\n' - bot.post_message(channel, message) - - -def get_short_answer(search_query: str): - """ - This uses wolfram's short answers api to just return a simple short plaintext response. - - This is used if the conversation api fails to get a result (for instance !wolfram - pineapple is not a great conversation starter but may be interesting). - """ - api_url = "http://api.wolframalpha.com/v2/result?" - http_response = requests.get(api_url, params={'input': search_query, 'appid': WOLFRAM_APP_ID}) - - # Check if the response is ok. A status code of 501 signifies that no result could be found. - if http_response.status_code == 501: - return "No short answer available. Try !wolfram --full" - elif http_response.status_code != requests.codes.ok: - return "There was a problem getting the response" - - return http_response.content - - -def wolfram_normal(search_query: str, channel): - """ - This uses wolfram's conversation api to return a short response - that can be replied to in a thread. If the response cannot be - replied to a general short answer response is displayed instead. - - Example Usage: - !wolfram Solve Newton's Second Law for mass - !wolfram What is the distance from Earth to Mars? - - and then start a thread to continue the conversation - """ - result, conversation_id, reply_host, s_output = conversation_request(search_query) - - if conversation_id is None: - if result == "No result is available": - # If no conversational result is available just return a normal short answer - short_response = get_short_answer(search_query) - bot.post_message(channel, short_response) - return - else: - bot.post_message(channel, result) - return - - # TODO(mubiquity): Is there a better option than storing the id in the fallback? - # Here we store the conversation ID in the fallback so we can get it back later. - # We also store an identifier string to check against later and the reply_host and s_output - # string. Attachments is a slack thing that allows the formatting or more complex messages. - # In this case we add a footer and use the fallback to cheekily store information for later. - attachments = [{'fallback': f'WolframCanReply {reply_host} {s_output} {conversation_id}', - 'footer': 'Further questions may be asked', - 'text': result}] - - bot.post_message(channel, "", attachments=attachments) - - -def extract_reply(wolfram_response: dict) -> Tuple[str, str, str, str]: - """ - Takes the response from the conversations API and returns it as a tuple containing - the reply, conversation id, reply host and s parameters. In that order. - """ - - return (wolfram_response['result'], # This is the answer to our question - wolfram_response['conversationID'], # Used to continue the conversation - wolfram_response['host'], # This is the hostname to ask the next question to - wolfram_response.get('s')) # s is only sometimes returned, but is vital - - -def conversation_request( - search_query: str, - host_name: Optional[str] = None, - conversation_id: Optional[str] = None, - s_output: Optional[str] = None -): - """ - Makes a request for either the first stage of the conversation (don't supply a - conversation_id and s_output) or for a continued stage of the conversation (do supply them). - It will return four values. In the case of an error it will return an error string that - can be posted to the user and 3 Nones or it will return the result of the question, - the new conversation_id, the new host name and the new s_output. In that order. - """ - # The format of the api urls is slightly different if a conversation is being continued - # (has a conversation_id). Any of the following would suffice but may as well be thorough - if any([host_name is None, conversation_id is None, s_output is None]): - api_url = "http://api.wolframalpha.com/v1/conversation.jsp?" - params = {'appid': WOLFRAM_APP_ID, 'i': search_query} - else: - # Slack annoyingly formats the reply_host link so we have to extract what we want: - # The format is - host_name = host_name[1:-1].split('|')[0] - api_url = f'{host_name}/api/v1/conversation.jsp?' - params = {'appid': WOLFRAM_APP_ID, 'i': search_query, - 'conversationid': conversation_id, 's': s_output} - - http_response = requests.get(api_url, params=params) - - if http_response.status_code != requests.codes.ok: - return "There was a problem getting the response", None, None, None - - # Convert to json and check for an error - wolfram_answer = json.loads(http_response.content) - if 'error' in wolfram_answer: - return wolfram_answer['error'], None, None, None - - return extract_reply(wolfram_answer) - - -@bot.on('message') -def handle_reply(evt: dict): - """ - Handles a message event. Whenever a message is a reply to one of !wolframs conversational - results this handles getting the next response and updating the old stored information. - """ - # If the message isn't from a thread or is from a bot ignore it (avoid those infinite loops) - if 'thread_ts' not in evt or evt.get('subtype') == 'bot_message': - return - - channel = evt['channel'] - thread_ts = evt['thread_ts'] # This refers to time the original message - thread_parent = bot.api.conversations.history(channel=channel, limit=1, - inclusive=True, latest=thread_ts) - - if not thread_parent['ok']: - # The most likely reason for this error is auth issues or possibly rate limiting - bot.logger.error(f'Error with wolfram script thread history: {thread_parent}') - return - - # Limit=1 was used so the first (and only) message is what we want - parent_message = thread_parent['messages'][0] - # If the threads parent wasn't by a bot ignore it - if parent_message.get('subtype') != 'bot_message': - return - - # Finally, we have to check that this is a Wolfram replyable message - # It is rare we would reach this point and not pass as who - # replies to a bot in a thread for another reason? - parent_attachment = parent_message['attachments'][0] # Only one attachment to get - parent_fallback = parent_attachment['fallback'] - if 'WolframCanReply' not in parent_fallback: - return - - # Now we can grab the conversation_id from the message - # and get the new question (s only sometimes appears). - # Recall the format of the fallback "identifier hostname s_output conversationID" - _, reply_host, s_output, conversation_id = parent_fallback.split(' ') - new_question = evt['text'] # This is the value of the message that triggered the response - s_output = '' if s_output is None else s_output - - # Ask Wolfram for the new answer grab the new stuff and post the reply. - reply, conversation_id, reply_host, s_output = conversation_request(new_question, reply_host, - conversation_id, s_output) - - bot.post_message(channel, reply, thread_ts=thread_ts) - - # If getting a the conversation request results in an error then conversation_id will be None - if conversation_id is not None: - # Update the old fallback to reflect the new state of the conversation - parent_attachment['fallback'] = f'WolframCanReply {reply_host} {s_output} {conversation_id}' - - bot.api.chat.update(channel=channel, attachments=[parent_attachment], ts=thread_ts) diff --git a/unimplemented/yt.py b/unimplemented/yt.py deleted file mode 100644 index a29920b..0000000 --- a/unimplemented/yt.py +++ /dev/null @@ -1,63 +0,0 @@ -import os -from uqcsbot import bot, Command -from uqcsbot.utils.command_utils import UsageSyntaxException -from googleapiclient.discovery import build -from googleapiclient.errors import HttpError - -YOUTUBE_API_KEY = os.environ.get('YOUTUBE_API_KEY') -YOUTUBE_API_SERVICE_NAME = 'youtube' -YOUTUBE_API_VERSION = 'v3' -YOUTUBE_VIDEO_URL = 'https://www.youtube.com/watch?v=' -NO_QUERY_MESSAGE = "You can't look for nothing. !yt " - - -@bot.on_command('yt') -def handle_yt(command: Command): - """ - `!yt ` - Returns the top video search result based on the query string. - """ - # Makes sure the query is not empty. - if not command.has_arg(): - raise UsageSyntaxException() - - search_query = command.arg.strip() - try: - videoID = get_top_video_result(search_query, command.channel_id) - except HttpError as e: - # Googleapiclient should handle http errors - bot.logger.error( - f'An HTTP error {e.resp.status} occurred:\n{e.content}') - # Force return to ensure no message is sent. - return - - if videoID: - bot.post_message(command.channel_id, f'{YOUTUBE_VIDEO_URL}{videoID}') - else: - bot.post_message(command.channel_id, "Your query returned no results.") - - -def get_top_video_result(search_query: str, channel): - """ - The normal method for using !yt searches based on query - and returns the first video result. "I'm feeling lucky" - """ - search_response = execute_search(search_query, 'id', 'video', 1) - search_result = search_response.get('items') - if search_result is None: - return None - return search_result[0]['id']['videoId'] - - -def execute_search(search_query: str, search_part: str, search_type: str, max_results: int): - """ - Executes the search via the google api client based on the parameters given. - """ - youtube = build(YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION, - developerKey=YOUTUBE_API_KEY, cache_discovery=False) - - search_response = youtube.search().list(q=search_query, - part=search_part, - maxResults=max_results, - type=search_type).execute() - - return search_response