From cd5b338582a6f0cb4107b8bffe0aa24c2303604c Mon Sep 17 00:00:00 2001 From: Isaac Beh Date: Tue, 26 Sep 2023 20:11:24 +1000 Subject: [PATCH 1/7] Added Whatweekisit Flavour Text (#163) * Added random whatweekisit flavour text * Styled to make black happy * Removed some repitition --- uqcsbot/whatweekisit.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/uqcsbot/whatweekisit.py b/uqcsbot/whatweekisit.py index 6b996ba..f655b3e 100644 --- a/uqcsbot/whatweekisit.py +++ b/uqcsbot/whatweekisit.py @@ -5,6 +5,7 @@ from zoneinfo import ZoneInfo from datetime import datetime, timedelta from math import ceil +from random import choice from bs4 import BeautifulSoup from discord.ext import commands @@ -158,11 +159,29 @@ async def whatweekisit(self, interaction: discord.Interaction, date: Optional[st else: semester_name, week_name, weekday = semester_tuple - message = ( - "The week we're in is:" - if date == None - else f"The week of {date} is in:" - ) + if date != None: + message = f"The week of {date} is in:" + else: + message = choice( + [ + "The week we're in is:", + "The current week is:", + "Currently, the week is:", + "Hey, look at the time:", + f"Can you believe that it's already {week_name}:", + "Time flies when you're having fun:", + "Maybe time's just a construct of human perception:", + "Time waits for noone:", + "This week is:", + "It is currently:", + "The week is", + "The week we're currently in is:", + f"Right now we are in:", + "Good heavens, would you look at the time:", + "What's the time, mister wolf? It's:", + ] + ) + message += f"\n> {weekday}, {week_name} of {semester_name}" await interaction.edit_original_response(content=message) From c61622528754fd383e618716f3a5247111fb5555 Mon Sep 17 00:00:00 2001 From: James Dearlove <39483549+JamesDearlove@users.noreply.github.com> Date: Tue, 17 Oct 2023 21:03:00 +1000 Subject: [PATCH 2/7] Added logging for holidays cog (#169) * Added logging for holidays cog * Extra log was not necessary * Caught out by the style guide once more --- uqcsbot/holidays.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/uqcsbot/holidays.py b/uqcsbot/holidays.py index d4eaea0..e8a2f95 100644 --- a/uqcsbot/holidays.py +++ b/uqcsbot/holidays.py @@ -113,8 +113,11 @@ async def holiday(self): Posts a random celebratory day on #general from https://www.timeanddate.com/holidays/fun/ """ + logging.info("Running daily holiday task") + holiday = get_holiday() if holiday is None: + logging.info("No holiday was found for today") return general_channel = discord.utils.get( From 05c1e052d0ed9ed0cfed2e522b255407bfc4c01d Mon Sep 17 00:00:00 2001 From: bradleysigma <42644678+bradleysigma@users.noreply.github.com> Date: Sat, 28 Oct 2023 09:28:08 +1000 Subject: [PATCH 3/7] Remove Ununimplemented (#168) * Remove Ununimplemented * Delete cookbook.py --------- Co-authored-by: Andrew Brown <92134285+andrewj-brown@users.noreply.github.com> --- unimplemented/calendar.py | 74 --------------- unimplemented/channel_log.py | 13 --- unimplemented/coin.py | 20 ---- unimplemented/cookbook.py | 10 -- unimplemented/dominos.py | 116 ----------------------- unimplemented/emoji_log.py | 37 -------- unimplemented/emojify.py | 173 ----------------------------------- unimplemented/history.py | 56 ------------ unimplemented/id.py | 9 -- unimplemented/link.py | 163 --------------------------------- unimplemented/pastexams.py | 67 -------------- unimplemented/wavie.py | 22 ----- unimplemented/whoami.py | 18 ---- unimplemented/xkcd.py | 125 ------------------------- 14 files changed, 903 deletions(-) delete mode 100644 unimplemented/calendar.py delete mode 100644 unimplemented/channel_log.py delete mode 100644 unimplemented/coin.py delete mode 100644 unimplemented/cookbook.py delete mode 100644 unimplemented/dominos.py delete mode 100644 unimplemented/emoji_log.py delete mode 100644 unimplemented/emojify.py delete mode 100644 unimplemented/history.py delete mode 100644 unimplemented/id.py delete mode 100644 unimplemented/link.py delete mode 100644 unimplemented/pastexams.py delete mode 100644 unimplemented/wavie.py delete mode 100644 unimplemented/whoami.py delete mode 100644 unimplemented/xkcd.py diff --git a/unimplemented/calendar.py b/unimplemented/calendar.py deleted file mode 100644 index a14806b..0000000 --- a/unimplemented/calendar.py +++ /dev/null @@ -1,74 +0,0 @@ -from datetime import datetime -from icalendar import Calendar, Event -from uuid import uuid4 as uuid -from uqcsbot import bot, Command -from uqcsbot.utils.command_utils import loading_status, success_status -from uqcsbot.utils.uq_course_utils import (get_course_assessment, - get_parsed_assessment_due_date, - HttpException, - CourseNotFoundException, - ProfileNotFoundException, - DateSyntaxException) - -# Maximum number of courses supported by !calendar to reduce call abuse. -COURSE_LIMIT = 6 - - -def get_calendar(assessment): - """ - Returns a compiled calendar containing the given assessment. - """ - calendar = Calendar() - for assessment_item in assessment: - course, task, due_date, weight = assessment_item - event = Event() - event['uid'] = str(uuid()) - event['summary'] = f'{course} ({weight}): {task}' - try: - start_datetime, end_datetime = get_parsed_assessment_due_date(assessment_item) - except DateSyntaxException as e: - bot.logger.error(e.message) - # If we can't parse a date, set its due date to today - # and let the user know through its summary. - # TODO(mitch): Keep track of these instances to attempt to accurately - # parse them in future. Will require manual detection + parsing. - start_datetime = end_datetime = datetime.today() - event['summary'] = ("WARNING: DATE PARSING FAILED\n" - "Please manually set date for event!\n" - "The provided due date from UQ was" - + f" '{due_date}\'. {event['summary']}") - event.add('dtstart', start_datetime) - event.add('dtend', end_datetime) - calendar.add_component(event) - return calendar.to_ical() - - -@bot.on_command('calendar') -@success_status -@loading_status -def handle_calendar(command: Command): - """ - `!calendar [COURSE CODE 2] ...` - Returns a compiled - calendar containing all the assessment for a given list of course codes. - """ - channel = bot.channels.get(command.channel_id) - course_names = command.arg.split() if command.has_arg() else [channel.name] - - if len(course_names) > COURSE_LIMIT: - bot.post_message(channel, f'Cannot process more than {COURSE_LIMIT} courses.') - return - - try: - assessment = get_course_assessment(course_names) - 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 - - user_direct_channel = bot.channels.get(command.user_id) - bot.api.files.upload(title='Importable calendar containing your assessment!', - channels=user_direct_channel.id, filetype='text/calendar', - filename='assessment.ics', file=get_calendar(assessment)) diff --git a/unimplemented/channel_log.py b/unimplemented/channel_log.py deleted file mode 100644 index d1c5c17..0000000 --- a/unimplemented/channel_log.py +++ /dev/null @@ -1,13 +0,0 @@ -from uqcsbot import bot - - -@bot.on("channel_created") -def channel_log(evt: dict): - """ - Notes when channels are created in #uqcs-meta - - @no_help - """ - bot.post_message(bot.channels.get("uqcs-meta"), - 'New Channel Created: ' - + f'<#{evt.get("channel").get("id")}|{evt.get("channel").get("name")}>') diff --git a/unimplemented/coin.py b/unimplemented/coin.py deleted file mode 100644 index ca5d2ae..0000000 --- a/unimplemented/coin.py +++ /dev/null @@ -1,20 +0,0 @@ -from random import choice -from uqcsbot import bot, Command - - -@bot.on_command("coin") -def handle_coin(command: Command): - """ - `!coin [number]` - Flips 1 or more coins. - """ - if command.has_arg() and command.arg.isnumeric(): - flips = min(max(int(command.arg), 1), 500) - else: - flips = 1 - - response = [] - emoji = (':heads:', ':tails:') - for i in range(flips): - response.append(choice(emoji)) - - bot.post_message(command.channel_id, "".join(response)) diff --git a/unimplemented/cookbook.py b/unimplemented/cookbook.py deleted file mode 100644 index 34d5948..0000000 --- a/unimplemented/cookbook.py +++ /dev/null @@ -1,10 +0,0 @@ -from uqcsbot import bot, Command - - -@bot.on_command("cookbook") -def handle_cookbook(command: Command): - """ - `!cookbook` - Returns the URL of the UQCS student-compiled cookbook (pdf). - """ - bot.post_message(command.channel_id, "It's A Cookbook!\n" - "https://github.com/UQComputingSociety/cookbook") diff --git a/unimplemented/dominos.py b/unimplemented/dominos.py deleted file mode 100644 index a75318b..0000000 --- a/unimplemented/dominos.py +++ /dev/null @@ -1,116 +0,0 @@ -import argparse -from uqcsbot import bot, Command -from bs4 import BeautifulSoup -from datetime import datetime -from requests.exceptions import RequestException -from typing import List -from uqcsbot.utils.command_utils import loading_status, UsageSyntaxException -import requests - -MAX_COUPONS = 10 # Prevents abuse -COUPONESE_DOMINOS_URL = 'https://www.couponese.com/store/dominos.com.au/' - - -class Coupon: - def __init__(self, code: str, expiry_date: str, description: str) -> None: - self.code = code - self.expiry_date = expiry_date - self.description = description - - def is_valid(self) -> bool: - try: - expiry_date = datetime.strptime(self.expiry_date, '%Y-%m-%d') - now = datetime.now() - return all([expiry_date.year >= now.year, expiry_date.month >= now.month, - expiry_date.day >= now.day]) - except ValueError: - return True - - def keyword_matches(self, keyword: str) -> bool: - return keyword.lower() in self.description.lower() - - -@bot.on_command("dominos") -@loading_status -def handle_dominos(command: Command): - """ - `!dominos [--num] N [--expiry] ` - Returns a list of dominos coupons (default: 5 | max: 10) - """ - command_args = command.arg.split() if command.has_arg() else [] - - parser = argparse.ArgumentParser() - - def usage_error(*args, **kwargs): - raise UsageSyntaxException() - parser.error = usage_error # type: ignore - parser.add_argument('-n', '--num', default=5, type=int) - parser.add_argument('-e', '--expiry', action='store_true') - parser.add_argument('keywords', nargs='*') - - args = parser.parse_args(command_args) - coupons_amount = min(args.num, MAX_COUPONS) - coupons = get_coupons(coupons_amount, args.expiry, args.keywords) - - message = "" - for coupon in coupons: - message += f"Code: *{coupon.code}* - {coupon.description}\n" - bot.post_message(command.channel_id, message) - - -def filter_coupons(coupons: List[Coupon], keywords: List[str]) -> List[Coupon]: - """ - Filters coupons iff a keyword is found in the description. - """ - return [coupon for coupon in coupons if - any(coupon.keyword_matches(keyword) for keyword in keywords)] - - -def get_coupons(n: int, ignore_expiry: bool, keywords: List[str]) -> List[Coupon]: - """ - Returns a list of n Coupons - """ - - coupon_page = get_coupon_page() - if coupon_page is None: - return None - - coupons = get_coupons_from_page(coupon_page) - - if not ignore_expiry: - coupons = [coupon for coupon in coupons if coupon.is_valid()] - - if keywords: - coupons = filter_coupons(coupons, keywords) - return coupons[:n] - - -def get_coupons_from_page(coupon_page: bytes) -> List[Coupon]: - """ - Strips results from html page and returns a list of Coupon(s) - """ - soup = BeautifulSoup(coupon_page, 'html.parser') - soup_coupons = soup.find_all(class_="ov-coupon") - - coupons = [] - - for soup_coupon in soup_coupons: - expiry_date_str = soup_coupon.find(class_='ov-expiry').get_text(strip=True) - description = soup_coupon.find(class_='ov-desc').get_text(strip=True) - code = soup_coupon.find(class_='ov-code').get_text(strip=True) - coupon = Coupon(code, expiry_date_str, description) - coupons.append(coupon) - - return coupons - - -def get_coupon_page() -> bytes: - """ - Gets the coupon page HTML - """ - try: - response = requests.get(COUPONESE_DOMINOS_URL) - return response.content - except RequestException as e: - bot.logger.error(e.response.content) - return None diff --git a/unimplemented/emoji_log.py b/unimplemented/emoji_log.py deleted file mode 100644 index aedb8d1..0000000 --- a/unimplemented/emoji_log.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -Logs emoji addition/removal to emoji-request for audit purposes -""" -from uqcsbot import bot - - -@bot.on("emoji_changed") -def emoji_log(evt: dict): - """ - Notes when emojis are added or deleted. - - @no_help - """ - emoji_request = bot.channels.get("emoji-request") - subtype = evt.get("subtype") - - if subtype == 'add': - name = evt["name"] - value = evt["value"] - - if value.startswith('alias:'): - _, alias = value.split('alias:') - - bot.post_message(emoji_request, - f'Emoji alias added: `:{name}:` :arrow_right: `:{alias}:` (:{name}:)') - - else: - message = bot.post_message(emoji_request, f'Emoji added: :{name}: (`:{name}:`)') - bot.api.reactions.add(channel=message["channel"], - timestamp=message["ts"], name=name) - - elif subtype == 'remove': - names = evt.get("names") - removed = ', '.join(f'`:{name}:`' for name in names) - plural = 's' if len(names) > 1 else '' - - bot.post_message(emoji_request, f'Emoji{plural} removed: {removed}') diff --git a/unimplemented/emojify.py b/unimplemented/emojify.py deleted file mode 100644 index af131ec..0000000 --- a/unimplemented/emojify.py +++ /dev/null @@ -1,173 +0,0 @@ -from uqcsbot import bot, Command -from uqcsbot.utils.command_utils import loading_status -from typing import Dict, List - -from collections import defaultdict -from random import shuffle, choice - - -@bot.on_command("emojify") -@loading_status -def handle_emojify(command: Command): - ''' - `!emojify text` - converts text to emoji. - ''' - master: Dict[str, List[str]] = defaultdict(lambda: [":grey_question:"]) - - # letters - master['A'] = [":adobe:", ":airbnb:", ":amazon:", ":anarchism:", - ":arch:", ":atlassian:", ":office_access:", ":capital_a_agile:", - choice([":card-ace-clubs:", ":card-ace-diamonds:", - ":card-ace-hearts:", ":card-ace-spades:"])] - master['B'] = [":bhinking:", ":bitcoin:", ":blutes:"] - master['C'] = [":c:", ":clang:", ":cplusplus:", ":copyright:", ":clipchamp:", - ":clipchamp_old:"] - master['D'] = [":d:", ":disney:", ":deloitte:"] - master['E'] = [":ecorp:", ":emacs:", ":erlang:", ":ie10:", ":thonk_slow:", ":edge:", - ":expedia_group:"] - master['F'] = [":f:", ":facebook:", ":flutter:", ":figma:"] - master['G'] = [":g+:", ":google:", ":nintendo_gamecube:", ":gatsbyjs:", ":gmod:"] - master['H'] = [":hackerrank:", ":homejoy:"] - master['I'] = [":information_source:", ":indoorooshs:"] - master['J'] = [choice([":card-jack-clubs:", ":card-jack-diamonds:", - ":card-jack-hearts:", ":card-jack-spades:"])] - master['K'] = [":kickstarter:", ":kotlin:", - choice([":card-king-clubs:", ":card-king-diamonds:", - ":card-king-hearts:", ":card-king-spades:"])] - master['L'] = [":l:", ":lime:", ":l_plate:", ":ti_nekro:"] - master['M'] = [":gmail:", ":maccas:", ":mcgrathnicol:", ":melange_mining:", ":mtg:", ":mxnet:", - ":jmod:"] - master['N'] = [":nano:", ":neovim:", ":netscape_navigator:", ":notion:", - ":nginx:", ":nintendo_64:", ":office_onenote:", ":netflix-n:"] - master['O'] = [":office_outlook:", ":oracle:", ":o_:", ":tetris_o:", ":ubuntu:", - choice([":portal_blue:", ":portal_orange:"])] - master['P'] = [":auspost:", ":office_powerpoint:", ":office_publisher:", - ":pinterest:", ":paypal:", ":producthunt:", ":uqpain:"] - master['Q'] = [":quora:", ":quantium:", choice([":card-queen-clubs:", ":card-queen-diamonds:", - ":card-queen-hearts:", ":card-queen-spades:"])] - master['R'] = [":r-project:", ":rust:", ":redroom:", ":registered:"] - master['S'] = [":s:", ":skedulo:", ":stanford:", ":stripe_s:", ":sublime:", ":tetris_s:"] - master['T'] = [":tanda:", choice([":telstra:", ":telstra-pink:"]), - ":tesla:", ":tetris_t:", ":torchwood:", ":tumblr:", ":nyt:"] - master['U'] = [":uber:", ":uqu:", ":the_horns:", ":proctoru:", ":ubiquiti:"] - master['V'] = [":vim:", ":vue:", ":vuetify:", ":v:"] - master['W'] = [":office_word:", ":washio:", ":wesfarmers:", ":westpac:", - ":weyland_consortium:", ":wikipedia_w:", ":woolworths:"] - master['X'] = [":atlassian_old:", ":aginicx:", ":sonarr:", ":x-files:", ":xbox:", - ":x:", ":flag-scotland:", ":office_excel:"] - master['Y'] = [":hackernews:"] - master['Z'] = [":tetris_z:"] - - # numbers - master['0'] = [":chrome:", ":suncorp:", ":disney_zero:", ":firefox:", - ":mars:", ":0_:", choice([":dvd:", ":cd:"])] - master['1'] = [":techone:", ":testtube:", ":thonk_ping:", ":first_place_medal:", - ":critical_fail:", ":slack_unread_1:"] - master['2'] = [":second_place_medal:", choice([":card-2-clubs:", ":card-2-diamonds:", - ":card-2-hearts:", ":card-2-spades:"])] - master['3'] = [":css:", ":third_place_medal:", choice([":card-3-clubs:", ":card-3-diamonds:", - ":card-3-hearts:", ":card-3-spades:"])] - master['4'] = [choice([":card-4-clubs:", ":card-4-diamonds:", - ":card-4-hearts:"]), ":card-4-spades:"] - master['5'] = [":html:", choice([":card-5-clubs:", ":card-5-diamonds:", - ":card-5-hearts:", ":card-5-spades:"])] - master['6'] = [choice([":card-6-clubs:", ":card-6-diamonds:", - ":card-6-hearts:", ":card-6-spades:"])] - master['7'] = [choice([":card-7-clubs:", ":card-7-diamonds:", - ":card-7-hearts:", ":card-7-spades:"])] - master['8'] = [":8ball:", choice([":card-8-clubs:", ":card-8-diamonds:", - ":card-8-hearts:", ":card-8-spades:"])] - master['9'] = [choice([":card-9-clubs:", ":card-9-diamonds:", - ":card-9-hearts:", ":card-9-spades:"])] - - # whitespace - master[' '] = [":whitespace:"] - master['\n'] = ["\n"] - - # other ascii characters (sorted by ascii value) - master['!'] = [":exclamation:"] - master['"'] = [choice([":ldquo:", ":rdquo:"]), ":pig_nose:"] - master['\''] = [":apostrophe:"] - master['#'] = [":slack_old:", ":csharp:"] - master['$'] = [":thonk_money:", ":moneybag:"] - # '&' converts to '&' - master['&'] = [":ampersand:", ":dnd:"] - master['('] = [":lparen:"] - master[')'] = [":rparen:"] - master['*'] = [":day:", ":nab:", ":youtried:", ":msn_star:", ":rune_prayer:", ":wolfram:", - ":shuriken:", ":mtg_s:", ":aoc:", ":jetstar:"] - master['+'] = [":tf2_medic:", ":flag-ch:", ":flag-england:"] - master['-'] = [":no_entry:"] - master['.'] = [":full_stop_big:"] - master[','] = [":comma:"] - master['/'] = [":slash:"] - master[';'] = [":semi-colon:"] - # '>' converts to '>' - master['>'] = [":accenture:", ":implying:", ":plex:", ":powershell:"] - master['?'] = [":question:"] - master['@'] = [":whip:"] - master['^'] = [":this:", ":typographical_carrot:", ":arrow_up:", - ":this_but_it's_an_actual_caret:"] - master['~'] = [":wavy_dash:"] - - # slack/uqcsbot convert the following to other symbols - - # greek letters - # 'Α' converts to 'A' - master['Α'] = [":alpha:"] - # 'Β' converts to 'B' - master['Β'] = [":beta:"] - # 'Δ' converts to 'D' - master['Δ'] = [":optiver:"] - # 'Λ' converts to 'L' - master['Λ'] = [":halflife:", ":haskell:", ":lambda:", ":racket:", - choice([":uqcs:", ":scrollinguqcs:", ":scrollinguqcs_alt:", ":uqcs_mono:"])] - # 'Π' converts to 'P' - master['Π'] = [":pi:"] - # 'Φ' converts to 'PH' - master['Φ'] = [":phyrexia_blue:"] - # 'Σ' converts to 'S' - master['Σ'] = [":polymathian:", ":sigma:"] - - # other symbols (sorted by unicode value) - # '…' converts to '...' - master['…'] = [":lastpass:"] - # '€' converts to 'EUR' - master['€'] = [":martian_euro:"] - # '√' converts to '[?]' - master['√'] = [":sqrt:"] - # '∞' converts to '[?]' - master['∞'] = [":arduino:", ":visualstudio:", ":infinitely:"] - # '∴' converts to '[?]' - master['∴'] = [":julia:"] - - master['人'] = [":人:"] - - master[chr(127)] = [":delet_this:"] - - text = "" - if command.has_arg(): - text = command.arg.upper() - # revert HTML conversions - text = text.replace(">", ">") - text = text.replace("<", "<") - text = text.replace("&", "&") - - lexicon = {} - for character in set(text+'…'): - full, part = divmod((text+'…').count(character), len(master[character])) - shuffle(master[character]) - lexicon[character] = full * master[character] + master[character][:part] - shuffle(lexicon[character]) - - ellipsis = lexicon['…'].pop() - - response = "" - for character in text: - emoji = lexicon[character].pop() - if len(response + emoji + ellipsis) > 4000: - response += ellipsis - break - response += emoji - - bot.post_message(command.channel_id, response) diff --git a/unimplemented/history.py b/unimplemented/history.py deleted file mode 100644 index 2dd6212..0000000 --- a/unimplemented/history.py +++ /dev/null @@ -1,56 +0,0 @@ -from uqcsbot import bot -from datetime import datetime -from pytz import timezone, utc -from random import choice - - -class Pin: - """ - Class for pins, with channel, age in years, user and pin text - """ - def __init__(self, channel: str, years: int, user: str, text: str): - self.channel = channel - self.years = years - self.user = user - self.text = text - - def message(self) -> str: - return (f"On this day, {self.years} years ago, <@{self.user}> said" - f"\n>>>{self.text}") - - def origin(self): - return bot.channels.get(self.channel) - - -@bot.on_schedule('cron', hour=12, minute=0, timezone='Australia/Brisbane') -def daily_history() -> None: - """ - Selets a random pin that was posted on this date some years ago, - and reposts it in the same channel - """ - anniversary = [] - today = datetime.now(utc).astimezone(timezone('Australia/Brisbane')).date() - - # for every channel - for channel in bot.api.conversations.list(types="public_channel")['channels']: - # skip archived channels - if channel['is_archived']: - continue - - for pin in bot.api.pins.list(channel=channel['id'])['items']: - # messily get the date the pin was originally posted - pin_date = (datetime.fromtimestamp(int(float(pin['message']['ts'])), tz=utc) - .astimezone(timezone('Australia/Brisbane')).date()) - # if same date as today - if pin_date.month == today.month and pin_date.day == today.day: - # add pin to possibilities - anniversary.append(Pin(channel=channel['name'], years=today.year-pin_date.year, - user=pin['message']['user'], text=pin['message']['text'])) - - # if no pins were posted on this date, do nothing - if not anniversary: - return - - # randomly select a pin, and post it in the original channel - selected = choice(anniversary) - bot.post_message(selected.origin(), selected.message()) diff --git a/unimplemented/id.py b/unimplemented/id.py deleted file mode 100644 index 8d7ff00..0000000 --- a/unimplemented/id.py +++ /dev/null @@ -1,9 +0,0 @@ -from uqcsbot import bot, Command - - -@bot.on_command("id") -def handle_id(command: Command): - """ - `!id` - Returns the calling user's Slack ID. - """ - bot.post_message(command.channel_id, f'You are Number `{command.user_id}`') diff --git a/unimplemented/link.py b/unimplemented/link.py deleted file mode 100644 index 83674dc..0000000 --- a/unimplemented/link.py +++ /dev/null @@ -1,163 +0,0 @@ -from argparse import ArgumentParser -from enum import Enum -from typing import Optional, Tuple - -from slackblocks import Attachment, Color, SectionBlock -from sqlalchemy.exc import NoResultFound - -from uqcsbot import bot, Command -from uqcsbot.models import Link -from uqcsbot.utils.command_utils import loading_status - - -class LinkScope(Enum): - """ - Possible requested scopes for setting or retrieving a link. - """ - CHANNEL = "channel" - GLOBAL = "global" - - -class SetResult(Enum): - """ - Possible outcomes of the set link operation. - """ - NEEDS_OVERRIDE = "Link already exists, use `-f` to override" - OVERRIDE_SUCCESS = "Successfully overrode link" - NEW_LINK_SUCCESS = "Successfully added link" - - -def set_link_value(key: str, value: str, channel: str, - override: bool, link_scope: Optional[LinkScope] = None) -> Tuple[SetResult, str]: - """ - Sets a corresponding value for a particular key. Keys are set to global by default but this can - be overridden by passing the channel flag. Existing links can only be overridden if the - override flag is passed. - :param key: the lookup key for users to search the value by - :param value: the value to associate with the key - :param channel: the name of the channel the set operation was initiated in - :param link_scope: defines the scope to set the link in, defaults to global if not provided - :param override: required to be True if an association already exists and needs to be updated - :return: a SetResult status and the value associated with the given key/channel combination - """ - link_channel = channel if link_scope == LinkScope.CHANNEL else None - session = bot.create_db_session() - - try: - exists = session.query(Link).filter(Link.key == key, - Link.channel == link_channel).one() - if exists and not override: - return SetResult.NEEDS_OVERRIDE, exists.value - session.delete(exists) - result = SetResult.OVERRIDE_SUCCESS - except NoResultFound: - result = SetResult.NEW_LINK_SUCCESS - session.add(Link(key=key, channel=link_channel, value=value)) - session.commit() - session.close() - return result, value - - -def get_link_value(key: str, - channel: str, - link_scope: Optional[LinkScope] = None) -> Tuple[Optional[str], Optional[str]]: - """ - Gets the value associated with a given key (and optionally channel). If a channel association - exists, this is returned, otherwise a global association is returned. If no association exists - then None is returned. The default behaviour can be overridden by passing the global flag to - force retrieval of a global association when a channel association exists. - :param key: the key to look up - :param channel: the name of the channel the lookup request was made from - :param link_scope: the requested scope to retrieve the link from (if supplied) - :return: the associated value if an association exists, else None, and the source - (global/channel) if any else None - """ - session = bot.create_db_session() - channel_match = session.query(Link).filter(Link.key == key, - Link.channel == channel).one_or_none() - global_match = session.query(Link).filter(Link.key == key, - Link.channel == None).one_or_none() # noqa: E711 - session.close() - - if link_scope == LinkScope.GLOBAL: - return (global_match.value, "global") if global_match else (None, None) - - if link_scope == LinkScope.CHANNEL: - return (channel_match.value, "channel") if channel_match else (None, None) - - if channel_match: - return channel_match.value, "channel" - - if global_match: - return global_match.value, "global" - - return None, None - - -@bot.on_command('link') -@loading_status -def handle_link(command: Command) -> None: - """ - `!link [-c | -g] [-f] key [value [value ...]]` - Set and retrieve information in a key value - store. Links can be set to be channel specific or global. Links are set as global by default, - and channel specific links are retrieved by default unless overridden with the respective flag. - """ - parser = ArgumentParser("!link", add_help=False) - parser.add_argument("key", type=str, help="Lookup key") - parser.add_argument("value", type=str, help="Value to associate with key", nargs="*") - flag_group = parser.add_mutually_exclusive_group() - flag_group.add_argument("-c", "--channel", action="store_true", dest="channel_flag", - help="Ensure a channel link is retrieved, or none is") - flag_group.add_argument("-g", "--global", action="store_true", dest="global_flag", - help="Ignore channel link and force retrieval of global") - parser.add_argument("-f", "--force-override", action="store_true", dest="override", - help="Must be passed if overriding a link") - - try: - args = parser.parse_args(command.arg.split() if command.has_arg() else []) - except SystemExit: - # Incorrect Usage - return bot.post_message(command.channel_id, "", - attachments=[Attachment(SectionBlock(str(parser.format_help())), - color=Color.YELLOW)._resolve()]) - - channel = bot.channels.get(command.channel_id) - if not channel: - return bot.post_message(command.channel_id, "", attachments=[ - Attachment(SectionBlock("Cannot find channel name, please try again."), - color=Color.YELLOW)._resolve() - ]) - - channel_name = channel.name - - link_scope = LinkScope.CHANNEL if args.channel_flag else \ - LinkScope.GLOBAL if args.global_flag else None - - # Retrieve a link - if not args.value: - link_value, source = get_link_value(key=args.key, - channel=channel_name, - link_scope=link_scope) - channel_text = f" in channel `{channel_name}`" if args.channel_flag else "" - if link_value: - source_text = source if source == 'global' else channel_name - response = f"{args.key} ({source_text}): {link_value}" - else: - response = f"No link found for key: `{args.key}`" + channel_text - color = Color.GREEN if link_value else Color.RED - return bot.post_message(command.channel_id, "", attachments=[ - Attachment(SectionBlock(response), color=color)._resolve() - ]) - - # Set a link - if args.key and args.value: - result, current_value = set_link_value(key=args.key, - channel=channel_name, - value=" ".join(args.value), - override=args.override, - link_scope=link_scope) - color = Color.YELLOW if result == SetResult.NEEDS_OVERRIDE else Color.GREEN - scope = channel_name if args.channel_flag else 'global' - response = f"{args.key} ({scope}): {current_value}" - attachment = Attachment(SectionBlock(response), color=color)._resolve() - bot.post_message(command.channel_id, f"{result.value}:", attachments=[attachment]) diff --git a/unimplemented/pastexams.py b/unimplemented/pastexams.py deleted file mode 100644 index 21cfa75..0000000 --- a/unimplemented/pastexams.py +++ /dev/null @@ -1,67 +0,0 @@ -from uqcsbot import bot, Command -from bs4 import BeautifulSoup -from typing import Iterable, Tuple -import requests -from uqcsbot.utils.command_utils import loading_status - - -@bot.on_command('pastexams') -@loading_status -def handle_pastexams(command: Command): - """ - `!pastexams [COURSE CODE]` - Retrieves past exams for a 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_code = command.arg if command.has_arg() else channel.name - bot.post_message(channel, get_past_exams(course_code)) - - -def get_exam_data(soup: BeautifulSoup) -> Iterable[Tuple[str, str]]: - """ - Takes the soup object of the page and generates each result in the format: - ('year Sem X:', link) - """ - - # The exams are stored in a table with the structure: - # Row 1: A bunch of informational text - # Row 2: Semester information - # Row 3: Links to Exams - # Rows two and three are what we care about. Of those the first column is just a row title so - # we ignore that as well - - exam_table_rows = soup.find('table', class_='maintable').contents - semesters = exam_table_rows[1].find_all('td')[1:] # All columns in row 2 excluding the first - # Gets the content from each td. Text is separated by a
thus result is in the format - # (year,
, 'Sem.x' - semesters = [semester.contents for semester in semesters] - - # Same thing but for links - links = exam_table_rows[2].find_all('td')[1:] - links = [link.find('a')['href'] for link in links] - - for (year, _, semester_id), link in zip(semesters, links): - semester_str = semester_id.replace('.', ' ') - yield f'{year} {semester_str}', link - - -def get_past_exams(course_code: str) -> str: - """ - Gets the past exams for the course with the specified course code. - Returns intuitive error messages if this fails. - """ - url = 'https://www.library.uq.edu.au/exams/papers.php?' - http_response = requests.get(url, params={'stub': course_code}) - - if http_response.status_code != requests.codes.ok: - return "There was a problem getting a response" - - # Check if the course code exists - soup = BeautifulSoup(http_response.content, 'html.parser') - no_course = soup.find('div', class_='page').find('div').contents[0] - if "Sorry. We have not found any past exams for this course" in no_course: - return f"The course code {course_code} did not return any results" - - return '>>>' + '\n'.join((f'*{semester}*: <{link}|PDF>' - for semester, link in get_exam_data(soup))) diff --git a/unimplemented/wavie.py b/unimplemented/wavie.py deleted file mode 100644 index be81b47..0000000 --- a/unimplemented/wavie.py +++ /dev/null @@ -1,22 +0,0 @@ -from uqcsbot import bot -import logging - - -logger = logging.getLogger(__name__) - - -@bot.on('message') -def wave(evt): - """ - :wave: reacts to "person joined/left this channel" - - @no_help - """ - if evt.get('subtype') not in ['channel_join', 'channel_leave']: - return - chan = bot.channels.get(evt['channel']) - if chan is not None and chan.name == 'announcements': - return - result = bot.api.reactions.add(name='wave', channel=chan.id, timestamp=evt['ts']) - if not result.get('ok'): - logger.error(f"Error adding reaction: {result}") diff --git a/unimplemented/whoami.py b/unimplemented/whoami.py deleted file mode 100644 index 0d1983b..0000000 --- a/unimplemented/whoami.py +++ /dev/null @@ -1,18 +0,0 @@ -from uqcsbot import bot, Command -from uqcsbot.utils.command_utils import success_status - - -@bot.on_command("whoami") -@success_status -def handle_whoami(command: Command): - """ - `!whoami` - Returns the Slack information for the calling user. - """ - response = bot.api.users.info(user=command.user_id) - if not response['ok']: - message = 'An error occurred, please try again.' - else: - user_info = response['user'] - message = f'Your vital statistics: \n```{user_info}```' - user_direct_channel = bot.channels.get(command.user_id) - bot.post_message(user_direct_channel, message) diff --git a/unimplemented/xkcd.py b/unimplemented/xkcd.py deleted file mode 100644 index eaf491a..0000000 --- a/unimplemented/xkcd.py +++ /dev/null @@ -1,125 +0,0 @@ -import datetime -import requests -import feedparser -import re -from urllib.parse import quote -from uqcsbot import bot, Command -from uqcsbot.utils.command_utils import loading_status - - -# HTTP Endpoints -XKCD_BASE_URL = "https://xkcd.com/" -XKCD_RSS_URL = "https://xkcd.com/rss.xml" -RELEVANT_XKCD_URL = 'https://relevantxkcd.appspot.com/process' - - -def get_by_id(comic_number: int) -> str: - """ - Gets an xkcd comic based on its unique ID/sequence number. - :param comic_number: the ID number of the xkcd comic to retrieve. - :return: a response containing either a comic URL or an error message. - """ - if comic_number <= 0: - return "Invalid xkcd ID, it must be a positive integer." - url = f"{XKCD_BASE_URL}{str(comic_number)}" - response = requests.get(url) - if response.status_code != 200: - return "Could not retrieve an xkcd with that ID (are there even that many?)" - return url - - -def get_by_search_phrase(search_phrase: str) -> str: - """ - Uses the site relevantxkcd.appspot.com to identify the - most appropriate xkcd comic based on the phrase provided. - :param search_phrase: the phrase to find an xkcd comic related to. - :return: the URL of the most relevant comic for that search phrase. - """ - params = {"action": "xkcd", "query": quote(search_phrase)} - response = requests.get(RELEVANT_XKCD_URL, params=params) - # Response consists of a newline delimited list, with two irrelevant first parameters - relevant_comics = response.content.decode().split("\n")[2:] - # Each line consists of "comic_id image_url" - best_response = relevant_comics[0].split(" ") - comic_number = int(best_response[0]) - return get_by_id(comic_number) - - -def get_latest() -> str: - """ - Gets the most recently published xkcd comic by examining the RSS feed. - :return: the URL to the latest xkcd comic. - """ - rss = feedparser.parse(XKCD_RSS_URL) - entries = rss['entries'] - if len(entries) > 0: - i = 0 - latest = entries[i]['guid'] - if not re.match(r"https://xkcd\.com/\d+/", latest): - i += 1 - latest = entries[i]['guid'] - else: - latest = 'https://xkcd.com/2200/' - return latest - - -def is_id(argument: str) -> bool: - """ - Determines whether the given argument is a valid id (i.e. an integer). - :param argument: the string argument to evaluate - :return: true if the argument can be evaluated as an interger, false otherwise - """ - try: - int(argument) - except ValueError: - return False - else: - return True - - -@bot.on_command('xkcd') -@loading_status -def handle_xkcd(command: Command) -> None: - """ - `!xkcd [COMIC_ID|SEARCH_PHRASE]` - Returns the xkcd comic associated - with the given COMIC_ID (an integer) or matching the SEARCH_PHRASE. - Providing no arguments will return the most recent comic. - """ - if command.has_arg(): - argument = command.arg - if is_id(argument): - comic_number = int(argument) - response = get_by_id(comic_number) - else: - response = get_by_search_phrase(command.arg) - else: - response = get_latest() - - bot.post_message(command.channel_id, response, unfurl_links=True, unfurl_media=True) - - -@bot.on_schedule('cron', hour=14, minute=1, day_of_week='mon,wed,fri', - timezone='Australia/Brisbane') -def new_xkcd() -> None: - """ - Posts new xkcd comic when they are released every Monday, - Wednesday & Friday at 4AM UTC or 2PM Brisbane time. - - @no_help - """ - link = get_latest() - - day = datetime.datetime.today().weekday() - if (day == 0): # Monday - message = "It's Monday, 4 days till Friday; here's the" - elif (day == 2): # Wednesday - message = "Half way through the week, time for the" - elif (day == 4): # Friday - message = (":musical_note: It's Friday, Friday\nGotta get down on Friday\n" - "Everybody's lookin' forward to the") - else: - message = "@pah It is day " + str(day) + ", please fix me... Here's the" - message += " latest xkcd comic " - - general = bot.channels.get("general") - bot.post_message(general.id, message + link, unfurl_links=True, unfurl_media=True) From eaadaf8c00184759fc2621f9df1e83503a8a89e5 Mon Sep 17 00:00:00 2001 From: Isaac Beh Date: Sat, 28 Oct 2023 09:30:18 +1000 Subject: [PATCH 4/7] Whatsdue: Added Sorting (#142) * Whatsdue: Added sorting by type. * Whatsdue: Added course ECP links as an option * Whatsdue: Fixed grammar for ECP link(s) * Whatsdue: Added weeks_to_show to reduce output * Removed surplus old TODOs and added typing to get_weight_as_int() * Changed get_weight_as_int() to return 0 if no weight can be parsed * Revert "Changed get_weight_as_int() to return 0 if no weight can be parsed" This reverts commit 709071dd3e20271bdd48bdaf036a865d3e4aa8cd. --------- Co-authored-by: Andrew Brown <92134285+andrewj-brown@users.noreply.github.com> --- uqcsbot/utils/uq_course_utils.py | 132 +++++++++++++++++++------------ uqcsbot/whatsdue.py | 85 ++++++++++++++------ 2 files changed, 143 insertions(+), 74 deletions(-) diff --git a/uqcsbot/utils/uq_course_utils.py b/uqcsbot/utils/uq_course_utils.py index a4c2d9d..9343222 100644 --- a/uqcsbot/utils/uq_course_utils.py +++ b/uqcsbot/utils/uq_course_utils.py @@ -3,9 +3,10 @@ from datetime import datetime from dateutil import parser from bs4 import BeautifulSoup, element -from functools import partial from typing import List, Dict, Optional, Literal, Tuple +from dataclasses import dataclass import json +import re BASE_COURSE_URL = "https://my.uq.edu.au/programs-courses/course.html?course_code=" BASE_ASSESSMENT_URL = ( @@ -105,6 +106,69 @@ def _estimate_current_semester() -> SemesterType: return "Summer" +@dataclass +class AssessmentItem: + course_name: str + task: str + due_date: str + weight: str + + def get_parsed_due_date(self): + """ + Returns the parsed due date for the given assessment item as a datetime + object. If the date cannot be parsed, a DateSyntaxException is raised. + """ + if self.due_date == "Examination Period": + return get_current_exam_period() + parser_info = parser.parserinfo(dayfirst=True) + try: + # If a date range is detected, attempt to split into start and end + # dates. Else, attempt to just parse the whole thing. + if " - " in self.due_date: + start_date, end_date = self.due_date.split(" - ", 1) + start_datetime = parser.parse(start_date, parser_info) + end_datetime = parser.parse(end_date, parser_info) + return start_datetime, end_datetime + due_datetime = parser.parse(self.due_date, parser_info) + return due_datetime, due_datetime + except Exception: + raise DateSyntaxException(self.due_date, self.course_name) + + def is_after(self, cutoff: datetime): + """ + Returns whether the assessment occurs after the given cutoff. + """ + try: + start_datetime, end_datetime = self.get_parsed_due_date() + except DateSyntaxException: + # If we can't parse a date, we're better off keeping it just in case. + return True + return end_datetime >= cutoff if end_datetime else start_datetime >= cutoff + + def is_before(self, cutoff: datetime): + """ + Returns whether the assessment occurs before the given cutoff. + """ + try: + start_datetime, _ = self.get_parsed_due_date() + except DateSyntaxException: + # TODO bot.logger.error(e.message) + # If we can't parse a date, we're better off keeping it just in case. + # TODO(mitch): Keep track of these instances to attempt to accurately + # parse them in future. Will require manual detection + parsing. + return True + return start_datetime <= cutoff + + def get_weight_as_int(self) -> Optional[int]: + """ + Trys to get the weight percentage of an assessment as a percentage. Will return None + if a percentage can not be obtained. + """ + if match := re.match(r"\d+", self.weight): + return int(match.group(0)) + return None + + class DateSyntaxException(Exception): """ Raised when an unparsable date syntax is encountered. @@ -234,14 +298,14 @@ def get_course_profile_url( return url -def get_course_profile_id(course_name: str, offering: Optional[Offering]): +def get_course_profile_id(course_name: str, offering: Optional[Offering] = None) -> int: """ Returns the ID to the latest course profile for the given course. """ profile_url = get_course_profile_url(course_name, offering=offering) # The profile url looks like this # https://course-profiles.uq.edu.au/student_section_loader/section_1/100728 - return profile_url[profile_url.rindex("/") + 1 :] + return int(profile_url[profile_url.rindex("/") + 1 :]) def get_current_exam_period(): @@ -270,44 +334,6 @@ def get_current_exam_period(): return start_datetime, end_datetime -def get_parsed_assessment_due_date(assessment_item: Tuple[str, str, str, str]): - """ - Returns the parsed due date for the given assessment item as a datetime - object. If the date cannot be parsed, a DateSyntaxException is raised. - """ - course_name, _, due_date, _ = assessment_item - if due_date == "Examination Period": - return get_current_exam_period() - parser_info = parser.parserinfo(dayfirst=True) - try: - # If a date range is detected, attempt to split into start and end - # dates. Else, attempt to just parse the whole thing. - if " - " in due_date: - start_date, end_date = due_date.split(" - ", 1) - start_datetime = parser.parse(start_date, parser_info) - end_datetime = parser.parse(end_date, parser_info) - return start_datetime, end_datetime - due_datetime = parser.parse(due_date, parser_info) - return due_datetime, due_datetime - except Exception: - raise DateSyntaxException(due_date, course_name) - - -def is_assessment_after_cutoff(assessment: Tuple[str, str, str, str], cutoff: datetime): - """ - Returns whether the assessment occurs after the given cutoff. - """ - try: - start_datetime, end_datetime = get_parsed_assessment_due_date(assessment) - except DateSyntaxException: - # TODO bot.logger.error(e.message) - # If we can't parse a date, we're better off keeping it just in case. - # TODO(mitch): Keep track of these instances to attempt to accurately - # parse them in future. Will require manual detection + parsing. - return True - return end_datetime >= cutoff if end_datetime else start_datetime >= cutoff - - def get_course_assessment_page( course_names: List[str], offering: Optional[Offering] ) -> str: @@ -316,17 +342,18 @@ def get_course_assessment_page( url to the assessment table for the provided courses """ profile_ids = map( - lambda course: get_course_profile_id(course, offering=offering), course_names + lambda course: str(get_course_profile_id(course, offering=offering)), + course_names, ) return BASE_ASSESSMENT_URL + ",".join(profile_ids) def get_course_assessment( course_names: List[str], - cutoff: Optional[datetime] = None, + cutoff: Tuple[Optional[datetime], Optional[datetime]] = (None, None), assessment_url: Optional[str] = None, offering: Optional[Offering] = None, -) -> List[Tuple[str, str, str, str]]: +) -> List[AssessmentItem]: """ Returns all the course assessment for the given courses that occur after the given cutoff. @@ -346,9 +373,12 @@ def get_course_assessment( assessment = assessment_table.findAll("tr")[1:] parsed_assessment = map(get_parsed_assessment_item, assessment) # If no cutoff is specified, set cutoff to UNIX epoch (i.e. filter nothing). - cutoff = cutoff or datetime.min - assessment_filter = partial(is_assessment_after_cutoff, cutoff=cutoff) - filtered_assessment = filter(assessment_filter, parsed_assessment) + cutoff_min = cutoff[0] or datetime.min + cutoff_max = cutoff[1] or datetime.max + filtered_assessment = filter( + lambda item: item.is_after(cutoff_min) and item.is_before(cutoff_max), + parsed_assessment, + ) return list(filtered_assessment) @@ -360,8 +390,8 @@ def get_element_inner_html(dom_element: element.Tag): def get_parsed_assessment_item( - assessment_item: element.Tag, -) -> Tuple[str, str, str, str]: + assessment_item_tag: element.Tag, +) -> AssessmentItem: """ Returns the parsed assessment details for the given assessment item table row element. @@ -371,7 +401,7 @@ def get_parsed_assessment_item( This is likely insufficient to handle every course's structure, and thus is subject to change. """ - course_name, task, due_date, weight = assessment_item.findAll("div") + course_name, task, due_date, weight = assessment_item_tag.findAll("div") # Handles courses of the form 'CSSE1001 - Sem 1 2018 - St Lucia - Internal'. # Thus, this bit of code will extract the course. course_name = course_name.text.strip().split(" - ")[0] @@ -384,7 +414,7 @@ def get_parsed_assessment_item( # Handles weights of the form '30%
Alternative to oral presentation'. # Thus, this bit of code will keep only the weight portion of the field. weight = get_element_inner_html(weight).strip().split("
")[0] - return (course_name, task, due_date, weight) + return AssessmentItem(course_name, task, due_date, weight) class Exam: diff --git a/uqcsbot/whatsdue.py b/uqcsbot/whatsdue.py index 9dd61c6..7b3a51c 100644 --- a/uqcsbot/whatsdue.py +++ b/uqcsbot/whatsdue.py @@ -1,6 +1,6 @@ -from datetime import datetime +from datetime import datetime, timedelta import logging -from typing import Optional +from typing import Optional, Callable, Literal, Dict import discord from discord import app_commands @@ -9,14 +9,39 @@ from uqcsbot.yelling import yelling_exemptor from uqcsbot.utils.uq_course_utils import ( + DateSyntaxException, Offering, CourseNotFoundException, HttpException, ProfileNotFoundException, + AssessmentItem, get_course_assessment, get_course_assessment_page, + get_course_profile_id, ) +AssessmentSortType = Literal["Date", "Course Name", "Weight"] +ECP_ASSESSMENT_URL = ( + "https://course-profiles.uq.edu.au/student_section_loader/section_5/" +) + + +def sort_by_date(item: AssessmentItem): + """Provides a key to sort assessment dates by. If the date cannot be parsed, will put it with items occuring during exam block.""" + try: + return item.get_parsed_due_date()[0] + except DateSyntaxException: + return datetime.max + + +SORT_METHODS: Dict[ + AssessmentSortType, Callable[[AssessmentItem], int | str | datetime] +] = { + "Date": sort_by_date, + "Course Name": (lambda item: item.course_name), + "Weight": (lambda item: item.get_weight_as_int() or 0), +} + class WhatsDue(commands.Cog): def __init__(self, bot: commands.Bot): @@ -26,15 +51,14 @@ def __init__(self, bot: commands.Bot): @app_commands.describe( fulloutput="Display the full list of assessment. Defaults to False, which only " + "shows assessment due from today onwards.", + weeks_to_show="Only show assessment due within this number of weeks. If 0 (default), show all assessment.", semester="The semester to get assessment for. Defaults to what UQCSbot believes is the current semester.", campus="The campus the course is held at. Defaults to St Lucia. Note that many external courses are 'hosted' at St Lucia.", mode="The mode of the course. Defaults to Internal.", - course1="Course code", - course2="Course code", - course3="Course code", - course4="Course code", - course5="Course code", - course6="Course code", + courses="Course codes seperated by spaces", + sort_order="The order to sort courses by. Defualts to Date.", + reverse_sort="Whether to reverse the sort order. Defaults to false.", + show_ecp_links="Show the first ECP link for each course page. Defaults to false.", ) @yelling_exemptor( input_args=["course1", "course2", "course3", "course4", "course5", "course6"] @@ -42,16 +66,15 @@ def __init__(self, bot: commands.Bot): async def whatsdue( self, interaction: discord.Interaction, - course1: str, - course2: Optional[str], - course3: Optional[str], - course4: Optional[str], - course5: Optional[str], - course6: Optional[str], + courses: str, fulloutput: bool = False, + weeks_to_show: int = 0, semester: Optional[Offering.SemesterType] = None, campus: Offering.CampusType = "St Lucia", mode: Offering.ModeType = "Internal", + sort_order: AssessmentSortType = "Date", + reverse_sort: bool = False, + show_ecp_links: bool = False, ): """ Returns all the assessment for a given list of course codes that are scheduled to occur. @@ -60,15 +83,19 @@ async def whatsdue( await interaction.response.defer(thinking=True) - possible_courses = [course1, course2, course3, course4, course5, course6] - course_names = [c.upper() for c in possible_courses if c != None] + course_names = [c.upper() for c in courses.split()] offering = Offering(semester=semester, campus=campus, mode=mode) # If full output is not specified, set the cutoff to today's date. - cutoff = None if fulloutput else datetime.today() + cutoff = ( + None if fulloutput else datetime.today(), + datetime.today() + timedelta(weeks=weeks_to_show) + if weeks_to_show > 0 + else None, + ) try: - asses_page = get_course_assessment_page(course_names, offering) - assessment = get_course_assessment(course_names, cutoff, asses_page) + assessment_page = get_course_assessment_page(course_names, offering) + assessment = get_course_assessment(course_names, cutoff, assessment_page) except HttpException as e: logging.error(e.message) await interaction.edit_original_response( @@ -81,15 +108,15 @@ async def whatsdue( embed = discord.Embed( title=f"What's Due: {', '.join(course_names)}", - url=asses_page, + url=assessment_page, description="*WARNING: Assessment information may vary/change/be entirely different! Use at your own discretion. Check your ECP for a true list of assessment.*", ) if assessment: + assessment.sort(key=SORT_METHODS[sort_order], reverse=reverse_sort) for assessment_item in assessment: - course, task, due, weight = assessment_item embed.add_field( - name=course, - value=f"`{weight}` {task} **({due})**", + name=assessment_item.course_name, + value=f"`{assessment_item.weight}` {assessment_item.task} **({assessment_item.due_date})**", inline=False, ) elif fulloutput: @@ -103,6 +130,18 @@ async def whatsdue( value=f"Nothing seems to be due soon", ) + if show_ecp_links: + ecp_links = [ + f"[{course_name}]({ECP_ASSESSMENT_URL + str(get_course_profile_id(course_name))})" + for course_name in course_names + ] + embed.add_field( + name=f"Potential ECP {'Link' if len(course_names) == 1 else 'Links'}", + value=" ".join(ecp_links) + + "\nNote that these may not be the correct ECPs. Check the year and offering type.", + inline=False, + ) + if not fulloutput: embed.set_footer( text="Note: This may not be the full assessment list. Set fulloutput to True to see a potentially more complete list, or check your ECP for a true list of assessment." From 86b238b697c7ed19bf7288f8ad80900ddf4caa30 Mon Sep 17 00:00:00 2001 From: "Eli (they/them)" Date: Thu, 2 Nov 2023 16:50:06 +1000 Subject: [PATCH 5/7] Course ECP Command (#171) * Spoilering description text of xkcd Made xkcd_desc an f string, added spoiler `||` on either side so when it's posted to discord it spoilers the text. * Course ECP Command Attempting to create a command that takes in a course code, selected year, semester, mode, and campus and then provides the link to the course profile. Currently it defaults to the current semester and I'm yet to work out how to make it not default. * Remove unused imports * Added year parameter to get_course_profile_url * Course ECP now considers the year, semester, campus and mode input Need to format the embedded and allow it to take in multiple courses * Lists multiple course ecps It assume when requesting you want the same semester, year, mode and campus for all courses. * Adding suggestions - Fixed typo - Clarified error message - Changes wording of the else statement that in theory should never happen because of the exceptions. * Made `estimate_current_semester()` in `Offering` a public method. Unsure if this will cause issues, doesn't look like it and improve any future use of checking what the estimated current semester is. * Black formatting --------- Co-authored-by: Isaac Beh --- uqcsbot/__main__.py | 1 + uqcsbot/course_ecp.py | 110 +++++++++++++++++++++++++++++++ uqcsbot/utils/uq_course_utils.py | 32 +++++---- 3 files changed, 126 insertions(+), 17 deletions(-) create mode 100644 uqcsbot/course_ecp.py diff --git a/uqcsbot/__main__.py b/uqcsbot/__main__.py index 93e7591..ee84876 100644 --- a/uqcsbot/__main__.py +++ b/uqcsbot/__main__.py @@ -43,6 +43,7 @@ async def main(): "basic", "cat", "cowsay", + "course_ecp", "dominos_coupons", "error_handler", "events", diff --git a/uqcsbot/course_ecp.py b/uqcsbot/course_ecp.py new file mode 100644 index 0000000..e00643b --- /dev/null +++ b/uqcsbot/course_ecp.py @@ -0,0 +1,110 @@ +from typing import Optional +import logging +from datetime import datetime +import discord +from discord import app_commands +from discord.ext import commands + +from uqcsbot.utils.uq_course_utils import ( + Offering, + HttpException, + CourseNotFoundException, + ProfileNotFoundException, + get_course_profile_url, +) +from uqcsbot.yelling import yelling_exemptor + + +class CourseECP(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command() + @app_commands.describe( + course1="The course to find an ECP for.", + course2="The second course to find an ECP for.", + course3="The third course to find an ECP for.", + course4="The fourth course to find an ECP for.", + year="The year to find the course ECP for. Defaults to what UQCSbot believes is the current year.", + semester="The semester to find the course ECP for. Defaults to what UQCSbot believes is the current semester.", + campus="The campus the course is held at. Defaults to St Lucia. Defaults to St Lucia. Note that many external courses are 'hosted' at St Lucia.", + mode="The mode of the course. Defaults to Internal.", + ) + @yelling_exemptor(input_args=["course1, course2, course3, course4"]) + async def courseecp( + self, + interaction: discord.Interaction, + course1: str, + course2: Optional[str], + course3: Optional[str], + course4: Optional[str], + year: Optional[int] = None, + semester: Optional[Offering.SemesterType] = None, + campus: Offering.CampusType = "St Lucia", + mode: Offering.ModeType = "Internal", + ): + """ + Returns the URL of the ECPs for course codes given. Assumes the same semester and year for the course codes given. + + """ + await interaction.response.defer(thinking=True) + + possible_courses = [course1, course2, course3, course4] + course_names = [c.upper() for c in possible_courses if c != None] + course_name_urls: dict[str, str] = {} + offering = Offering(semester=semester, campus=campus, mode=mode) + + try: + for course in course_names: + course_name_urls.update( + {course: get_course_profile_url(course, offering, year)} + ) + except HttpException as exception: + logging.warning( + f"Received a HTTP response code {exception.status_code} when trying find the course url using get_course_profile_url in course_ecp.py . Error information: {exception.message}" + ) + await interaction.edit_original_response( + content=f"Could not contact UQ, please try again." + ) + return + except (CourseNotFoundException, ProfileNotFoundException) as exception: + await interaction.edit_original_response(content=exception.message) + return + + # If year is none assign it the current year + if not year: + year = datetime.today().year + + # If semester is none assign it the current estimated semester + if not semester: + semester = Offering.estimate_current_semester() + + # Create the embedded message with the course names and details + embed = discord.Embed( + title=f"Course ECP: {', '.join(course_names)}", + description=f"For Semester {semester} {year}, {mode}, {campus}", + ) + + # Add the ECP urls to the embedded message + if course_name_urls: + for course in course_name_urls: + embed.add_field( + name=f"", + value=f"[{course}]({course_name_urls.get(course)}) ", + inline=False, + ) + else: + await interaction.edit_original_response( + content=f"No ECP could be found for the courses: {course_names}. The ECP(s) might not be available." + ) + return + + embed.set_footer( + text="The course ECP might be out of date, be sure to check the course on BlackBoard." + ) + await interaction.edit_original_response(embed=embed) + return + + +async def setup(bot: commands.Bot): + await bot.add_cog(CourseECP(bot)) diff --git a/uqcsbot/utils/uq_course_utils.py b/uqcsbot/utils/uq_course_utils.py index 9343222..f879d79 100644 --- a/uqcsbot/utils/uq_course_utils.py +++ b/uqcsbot/utils/uq_course_utils.py @@ -14,8 +14,10 @@ "student_section_report.php?report=assessment&profileIds=" ) BASE_CALENDAR_URL = "http://www.uq.edu.au/events/calendar_view.php?category_id=16&year=" -OFFERING_PARAMETER = "offer" BASE_PAST_EXAMS_URL = "https://api.library.uq.edu.au/v1/exams/search/" +# Parameters for the course page +OFFERING_PARAMETER = "offer" +YEAR_PARAMETER = "year" class Offering: @@ -59,7 +61,7 @@ def __init__( if semester is not None: self.semester = semester else: - self.semester = self._estimate_current_semester() + self.semester = self.estimate_current_semester() self.semester self.campus = campus self.mode = mode @@ -93,7 +95,7 @@ def get_offering_code(self) -> str: return offering_code_text.encode("utf-8").hex() @staticmethod - def _estimate_current_semester() -> SemesterType: + def estimate_current_semester() -> SemesterType: """ Returns an estimate of the current semester (represented by an integer) based on the current month. 3 represents summer semester. """ @@ -256,23 +258,19 @@ def get_uq_request( def get_course_profile_url( - course_name: str, offering: Optional[Offering] = None + course_name: str, + offering: Optional[Offering] = None, + year: Optional[int] = None, ) -> str: """ - Returns the URL to the course profile for the given course for a given offering. - If no offering is give, will return the first course profile on the course page. + Returns the URL to the course profile (ECP) for the given course for a given offering. + If no offering or year are given, the first course profile on the course page will be returned. """ - if offering is None: - course_url = BASE_COURSE_URL + course_name - else: - course_url = ( - BASE_COURSE_URL - + course_name - + "&" - + OFFERING_PARAMETER - + "=" - + offering.get_offering_code() - ) + course_url = BASE_COURSE_URL + course_name + if offering: + course_url += "&" + OFFERING_PARAMETER + "=" + offering.get_offering_code() + if year: + course_url += "&" + YEAR_PARAMETER + "=" + str(year) http_response = get_uq_request(course_url) if http_response.status_code != requests.codes.ok: From c3042687c534e7266961779e37899803b3929f99 Mon Sep 17 00:00:00 2001 From: Andrew Brown <92134285+andrewj-brown@users.noreply.github.com> Date: Wed, 8 Nov 2023 16:27:54 +1000 Subject: [PATCH 6/7] bruh (#176) --- poetry.lock | 6 +++--- uqcsbot/manage_cogs.py | 7 ++++--- uqcsbot/starboard.py | 7 +++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/poetry.lock b/poetry.lock index 212ff96..ed25228 100644 --- a/poetry.lock +++ b/poetry.lock @@ -851,13 +851,13 @@ files = [ [[package]] name = "pyright" -version = "1.1.316" +version = "1.1.334" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.316-py3-none-any.whl", hash = "sha256:7259d73287c882f933d8cd88c238ef02336e172171ae95117a963a962a1fed4a"}, - {file = "pyright-1.1.316.tar.gz", hash = "sha256:bac1baf8567b90f2082ec95b61fc1cb50a68917119212c5608a72210870c6a9a"}, + {file = "pyright-1.1.334-py3-none-any.whl", hash = "sha256:dcb13e8358e021189672c4d6ebcad192ab061e4c7225036973ec493183c6da68"}, + {file = "pyright-1.1.334.tar.gz", hash = "sha256:3adaf10f1f4209575dc022f9c897f7ef024639b7ea5b3cbe49302147e6949cd4"}, ] [package.dependencies] diff --git a/uqcsbot/manage_cogs.py b/uqcsbot/manage_cogs.py index 116742a..12bcef4 100644 --- a/uqcsbot/manage_cogs.py +++ b/uqcsbot/manage_cogs.py @@ -5,6 +5,7 @@ from discord.ext import commands from uqcsbot.yelling import yelling_exemptor +from uqcsbot.bot import UQCSBot class ManageCogs(commands.Cog): @@ -12,11 +13,11 @@ class ManageCogs(commands.Cog): Note that most of these commands can make the bot load files to execute. Care should be made to ensure only entrusted users have access. """ - def __init__(self, bot: commands.Bot): + def __init__(self, bot: UQCSBot): self.bot = bot @app_commands.command(name="managecogs") - @app_commands.default_permissions(manage_guild=True) + @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( cog='The cog (i.e. python file) to try to unload. Use python package notation, so no suffix of ".py" and "." between folders: e.g. "manage_cogs".', ) @@ -49,5 +50,5 @@ async def manage_cogs( await self.bot.tree.sync() -async def setup(bot: commands.Bot): +async def setup(bot: UQCSBot): await bot.add_cog(ManageCogs(bot)) diff --git a/uqcsbot/starboard.py b/uqcsbot/starboard.py index ed727d1..7614bd7 100644 --- a/uqcsbot/starboard.py +++ b/uqcsbot/starboard.py @@ -62,7 +62,7 @@ async def on_ready(self): ) @app_commands.command() - @app_commands.default_permissions(manage_guild=True) + @app_commands.checks.has_permissions(manage_guild=True) async def cleanup_starboard(self, interaction: discord.Interaction): """Cleans up the last 100 messages from the starboard. Removes any uqcsbot message that doesn't have a corresponding message id in the db, regardless of recv. @@ -70,7 +70,6 @@ async def cleanup_starboard(self, interaction: discord.Interaction): manage_guild perms: for committee and infra use. """ - if interaction.channel == self.starboard_channel: # because if you do it from in the starboard, it deletes its own interaction response # and i cba making it not do that, so i'll just forbid doing it in starboard. @@ -146,7 +145,7 @@ async def _blacklist_log( ) ) - @app_commands.default_permissions(manage_messages=True) + @app_commands.checks.has_permissions(manage_messages=True) async def context_blacklist_sb_message( self, interaction: discord.Interaction, message: discord.Message ): @@ -194,7 +193,7 @@ async def context_blacklist_sb_message( f"Blacklisted message {message.id}.", ephemeral=True ) - @app_commands.default_permissions(manage_messages=True) + @app_commands.checks.has_permissions(manage_messages=True) async def context_unblacklist_sb_message( self, interaction: discord.Interaction, message: discord.Message ): From 5f630cd13294cfcb74f760791f2ecf8a1e88cdb4 Mon Sep 17 00:00:00 2001 From: Andrew Brown <92134285+andrewj-brown@users.noreply.github.com> Date: Wed, 8 Nov 2023 18:32:53 +1000 Subject: [PATCH 7/7] fix yelling types and remove arbitrary web request (#177) * fix yelling types and remove arbitrary web request * style. also take 2 * of all the days for my typechecker to stop fucking working * i'm capitulating * fixed capitulation --- uqcsbot/yelling.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/uqcsbot/yelling.py b/uqcsbot/yelling.py index abbe005..5a6ee04 100644 --- a/uqcsbot/yelling.py +++ b/uqcsbot/yelling.py @@ -3,8 +3,6 @@ from discord.ext import commands from random import choice, random import re -from urllib.request import urlopen -from urllib.error import URLError from uqcsbot.bot import UQCSBot from uqcsbot.cog import UQCSBotCog @@ -48,7 +46,8 @@ async def wrapper( if not Yelling.contains_lowercase(text): await func(cogself, *args, **kwargs) return - await interaction.response.send_message( + + await interaction.response.send_message( # type: ignore str(discord.utils.get(bot.emojis, name="disapproval") or "") ) if isinstance(interaction.user, discord.Member): @@ -167,12 +166,6 @@ def clean_text(self, message: str) -> str: # slightly more permissive version of discord's url regex, matches absolutely anything between http(s):// and whitespace for url in re.findall(r"https?:\/\/[^\s]+", text, flags=re.UNICODE): - try: - resp = urlopen(url) - except (ValueError, URLError): - continue - if 400 <= resp.code <= 499: - continue text = text.replace(url, url.upper()) text = text.replace(">", ">").replace("<", "<").replace("&", "&")