├── cogs ├── __init__.py ├── serverstats.py ├── meta.py ├── games.py ├── piazza.py ├── tree.py ├── music.py ├── commands.py └── canvas.py ├── util ├── __init__.py ├── json.py ├── create_file.py ├── custom_role_converter.py ├── custom_member_converter.py ├── badargs.py ├── discord_handler.py ├── canvas_api_extension.py ├── piazza_handler.py └── canvas_handler.py ├── .gitignore ├── requirements.txt ├── .github └── ISSUE_TEMPLATE │ ├── pull_request_template.md │ └── bug_report.md ├── LICENSE ├── README.md └── cs221bot.py /cogs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | __pycache__/ 3 | .* -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.11.1 2 | binarytree==6.5.1 3 | cairosvg==2.5.2 4 | canvasapi==2.2.0 5 | chess==1.9.0 6 | discord.py==1.7.3 7 | piazza-api==0.12.0 8 | PyNaCl==1.5.0 9 | python-dateutil==2.8.2 10 | python-dotenv==0.20.0 11 | youtube-dl==2021.12.17 12 | -------------------------------------------------------------------------------- /util/json.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | def read_json(jsonfile: str) -> dict: 5 | with open(jsonfile, "r") as f: 6 | return json.load(f) 7 | 8 | 9 | def write_json(data: dict, jsonfile: str) -> None: 10 | with open(jsonfile, "w") as f: 11 | json.dump(data, f, indent=4) 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | 3 | _Describe the problem or feature in addition to a link to the issues._ 4 | 5 | ## Changes 6 | 7 | _What did you change/add in this PR, and briefly decsribe why?_ 8 | 9 | ## Steps to Test: 10 | 11 | _List any steps to test your changes here_ 12 | 13 | ## Screenshots (If applicable) 14 | -------------------------------------------------------------------------------- /util/create_file.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | 4 | 5 | def create_file_if_not_exists(file_path: str) -> None: 6 | """ 7 | Creates file with given path (as str) if the file does not already exist. 8 | All required directories are created, too. 9 | """ 10 | 11 | pathlib.Path(os.path.dirname(file_path)).mkdir(parents=True, exist_ok=True) 12 | 13 | with open(file_path, "a"): 14 | pass 15 | -------------------------------------------------------------------------------- /util/custom_role_converter.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | 5 | class CustomRoleConverter(commands.IDConverter): 6 | async def convert(self, ctx: commands.Context, argument: str) -> discord.Role: 7 | for role in ctx.guild.roles: 8 | if argument.lower() in role.name.lower(): 9 | return role 10 | else: 11 | raise commands.RoleNotFound(argument) 12 | -------------------------------------------------------------------------------- /util/custom_member_converter.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | 5 | class CustomMemberConverter(commands.IDConverter): 6 | async def convert(self, ctx: commands.Context, argument: str) -> discord.Member: 7 | try: 8 | return await commands.MemberConverter().convert(ctx, argument) 9 | except commands.BadArgument: 10 | for member in ctx.bot.get_guild(ctx.bot.guild_id).members: 11 | if argument.lower() in member.name.lower() or argument.lower() in member.display_name.lower(): 12 | return member 13 | else: 14 | raise commands.MemberNotFound(argument) 15 | -------------------------------------------------------------------------------- /util/badargs.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | 3 | 4 | class BadArgs(commands.CommandError): 5 | """ 6 | Exception raised if the arguments to a command are in correct. 7 | 8 | Attributes: 9 | command -- The command that was run 10 | show_help -- Whether help should be shown 11 | msg -- Message to show (or none for no message) 12 | """ 13 | 14 | def __init__(self, msg: str, show_help: bool = False): 15 | self.help = show_help 16 | self.msg = msg 17 | 18 | async def print(self, ctx: commands.Context): 19 | if self.msg: 20 | await ctx.send(self.msg, delete_after=5) 21 | 22 | if self.help: 23 | await ctx.send(ctx.command.help) 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report about: Create a report to help us improve our project title: '' 3 | labels: '' 4 | assignees: '' 5 | 6 | --- 7 | 8 | --- 9 | name: "\U0001F41B Bug Report" 10 | about: "If something isn't working as expected \U0001F914." 11 | title: '' 12 | labels: 'i: bug, i: needs triage' assignees: '' 13 | 14 | --- 15 | 16 | ## Bug Report 17 | 18 | **Current Behavior** 19 | A clear and concise description of the behavior. 20 | 21 | **Input Code** 22 | 23 | - REPL or Repo link if applicable: 24 | 25 | ```js 26 | var your 27 | => 28 | (code) => here; 29 | ``` 30 | 31 | **Expected behavior/code** 32 | A clear and concise description of what you expected to happen (or code). 33 | 34 | **Environment** 35 | 36 | - OS: [e.g. OSX 10.13.4, Windows 10] 37 | - Any additional info about your environment.... 38 | 39 | **Possible Solution** 40 | 41 | 42 | **Additional context/Screenshots** 43 | Add any other context about the problem here. If applicable, add screenshots to help explain. 44 | -------------------------------------------------------------------------------- /util/discord_handler.py: -------------------------------------------------------------------------------- 1 | from util.canvas_handler import CanvasHandler 2 | from util.piazza_handler import PiazzaHandler 3 | 4 | 5 | class DiscordHandler: 6 | """ 7 | Class for Discord bot to maintain list of active handlers 8 | 9 | Attributes 10 | ---------- 11 | canvas_handlers : `List[CanvasHandlers]` 12 | List for CanvasHandler for guilds 13 | piazza_handler : `PiazzaHandler` 14 | PiazzaHandler for guild. 15 | """ 16 | 17 | def __init__(self): 18 | self._canvas_handlers = [] # [c_handler1, ... ] 19 | self._piazza_handler = None 20 | 21 | @property 22 | def canvas_handlers(self) -> list[CanvasHandler]: 23 | return self._canvas_handlers 24 | 25 | @canvas_handlers.setter 26 | def canvas_handlers(self, handlers: list[CanvasHandler]): 27 | self._canvas_handlers = handlers 28 | 29 | @property 30 | def piazza_handler(self) -> PiazzaHandler: 31 | return self._piazza_handler 32 | 33 | @piazza_handler.setter 34 | def piazza_handler(self, piazza: PiazzaHandler) -> None: 35 | self._piazza_handler = piazza 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CS221Bot 2 | 3 | CS221Bot is a [Discord](https://discord.com/) [bot](https://discord.com/developers/docs/intro) using [Discord.py](https://discordpy.readthedocs.io/en/latest/) for managing the official [UBC CS221](https://www.ubc.ca/) class server. 4 | 5 | ## Installation 6 | 7 | Clone the repo to your computer, and ensure your Discord Bot [Token](https://discord.com/developers/docs/intro) is set as a environmental variable named `CS221BOT_KEY`. 8 | 9 | Additionally, you will need a Piazza email + password and a Canvas API key set as environment variables to use their respective commands. 10 | 11 | ## Dependencies 12 | 13 | The bot requires the following pip packages: 14 | 15 | - `beautifulsoup4` 16 | - `binarytree` 17 | - `cairosvg` 18 | - `canvasapi` 19 | - `chess` 20 | - `discord.py` 21 | - `piazza-api` 22 | - `PyNaCl` 23 | - `python-dateutil` 24 | - `python-dotenv` 25 | - `youtube-dl` 26 | 27 | You can install all of them using `pip install -r requirements.txt`. 28 | 29 | The bot also requires GraphViz, which can be installed with `conda install -c anaconda graphviz` 30 | 31 | ## Usage 32 | 33 | Start the bot by using `python3 cs221bot.py`. View the list of commands by typing `!help` in a server where the bot is in. 34 | 35 | The bot's Canvas module-tracking functionality only notifies you of new *published* modules by default. If you want the bot to notify you when it sees a new *unpublished* module, run the bot with the 36 | `--cnu` flag, i.e. run `python3 cs221bot.py --cnu`. You need to have access to unpublished modules, though. 37 | -------------------------------------------------------------------------------- /util/canvas_api_extension.py: -------------------------------------------------------------------------------- 1 | from canvasapi.course import Course 2 | from canvasapi.requester import Requester 3 | from canvasapi.util import combine_kwargs, get_institution_url 4 | 5 | 6 | def get_course_stream(course_id: int, base_url: str, access_token: str, **kwargs: dict) -> dict: 7 | """ 8 | Parameters 9 | ---------- 10 | course_id : `int` 11 | Course id 12 | 13 | base_url : `str` 14 | Base URL of the Canvas instance's API 15 | 16 | access_token : `str` 17 | API key to authenticate requests with 18 | 19 | Returns 20 | ------- 21 | `dict` 22 | JSON response for course activity stream 23 | """ 24 | 25 | access_token = access_token.strip() 26 | base_url = get_institution_url(base_url) 27 | requester = Requester(base_url, access_token) 28 | response = requester.request( 29 | "GET", 30 | f"courses/{course_id}/activity_stream", 31 | _kwargs=combine_kwargs(**kwargs) 32 | ) 33 | return response.json() 34 | 35 | 36 | def get_course_url(course_id: str, base_url) -> str: 37 | """ 38 | Parameters 39 | ---------- 40 | course_id : `str` 41 | Course id 42 | 43 | base_url : `str` 44 | Base URL of the Canvas instance's API 45 | 46 | Returns 47 | ------- 48 | `str` 49 | URL of course page 50 | """ 51 | 52 | base_url = get_institution_url(base_url) 53 | return f"{base_url}/courses/{course_id}" 54 | 55 | 56 | def get_staff_ids(course: Course) -> list[int]: 57 | """ 58 | Parameters 59 | ---------- 60 | course : `Course` 61 | The course to get staff IDs for 62 | 63 | Returns 64 | ------- 65 | `List[int]` 66 | A list of the IDs of all professors and TAs in the given course. 67 | """ 68 | 69 | staff = course.get_users(enrollment_type=["teacher", "ta"]) 70 | staff_ids = list(map(lambda user: user.id, staff)) 71 | 72 | return staff_ids 73 | -------------------------------------------------------------------------------- /cogs/serverstats.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | from discord.ext import commands 4 | 5 | SERVER_LIST = ("valdes", "remote", "anvil", "gambier", "pender", "thetis") 6 | 7 | 8 | class ServerStats(commands.Cog): 9 | def __init__(self, bot: commands.Bot): 10 | self.bot = bot 11 | 12 | @commands.command(name="checkservers") 13 | @commands.cooldown(1, 600) 14 | async def check_servers(self, ctx: commands.Context, *args: str): 15 | """ 16 | `!checkservers` __`Check if the remote CS servers are online`__ 17 | 18 | **Usage** `!checkservers [server names]` 19 | 20 | **Valid server names** 21 | thetis, remote, valdes, anvil, gambier, pender 22 | 23 | **Examples:** 24 | `!checkservers` checks all server statuses 25 | `!checkservers thetis` checks status of thetis server 26 | `!checkservers thetis gambier` checks status of thetis and gambier servers 27 | """ 28 | 29 | msgs = [] 30 | 31 | if not args: 32 | for server_name in SERVER_LIST: 33 | ip = f"{server_name}.students.cs.ubc.ca" 34 | status = await can_connect_ssh(ip) 35 | msgs.append(f"{'✅' if status else '❌'} {server_name} is {'online' if status else 'offline'}") 36 | else: 37 | for server_name in set(map(lambda arg: arg.lower(), args)): 38 | ip = f"{server_name}.students.cs.ubc.ca" 39 | status = await can_connect_ssh(ip) 40 | 41 | if server_name in SERVER_LIST: 42 | msgs.append(f"{'✅' if status else '❌'} {server_name} is {'online' if status else 'offline'}") 43 | else: 44 | msgs.append(f"{server_name} is not a valid server name.") 45 | 46 | await ctx.send("\n".join(msgs)) 47 | 48 | 49 | async def can_connect_ssh(server_ip: str) -> bool: 50 | """ 51 | Check if we can establish an SSH connection to the server with the given IP. 52 | 53 | Parameters 54 | ---------- 55 | server_ip: `str` 56 | The IP of the server 57 | 58 | Returns 59 | ------- 60 | `bool` 61 | True if the given IP is valid and if an SSH connection can be established; False otherwise 62 | """ 63 | 64 | try: 65 | # Command from https://stackoverflow.com/a/47166507 66 | output = subprocess.run(["ssh", "-o", "BatchMode=yes", server_ip, "2>&1"], capture_output=True, timeout=3) 67 | return output.returncode == 0 68 | except subprocess.TimeoutExpired: 69 | return False 70 | 71 | 72 | def setup(bot: commands.Bot) -> None: 73 | bot.add_cog(ServerStats(bot)) 74 | -------------------------------------------------------------------------------- /cogs/meta.py: -------------------------------------------------------------------------------- 1 | import random 2 | from datetime import datetime 3 | 4 | import discord 5 | from discord.ext import commands 6 | from discord.ext.commands import ExtensionError 7 | 8 | from util.badargs import BadArgs 9 | 10 | 11 | class Meta(commands.Cog): 12 | def __init__(self, bot: commands.Bot): 13 | self.bot = bot 14 | 15 | @commands.command(hidden=True) 16 | @commands.is_owner() 17 | async def die(self, ctx: commands.Context): 18 | await self.bot.logout() 19 | 20 | @commands.command() 21 | @commands.cooldown(1, 5, commands.BucketType.user) 22 | async def help(self, ctx: commands.Context, *arg: str): 23 | """ 24 | `!help` __`Returns list of commands or usage of command`__ 25 | 26 | **Usage:** !help [optional cmd] 27 | 28 | **Examples:** 29 | `!help` [embed] 30 | """ 31 | 32 | if not arg: 33 | embed = discord.Embed(title="CS221 Bot", description="Commands:", colour=random.randint(0, 0xFFFFFF), timestamp=datetime.utcnow()) 34 | embed.add_field(name=f"❗ Current Prefix: `{self.bot.command_prefix}`", value="\u200b", inline=False) 35 | 36 | for k, v in sorted(self.bot.cogs.items(), key=lambda kv: kv[0]): 37 | embed.add_field(name=k, value=" ".join(f"`{i}`" for i in v.get_commands() if not i.hidden), inline=False) 38 | 39 | embed.set_thumbnail(url=self.bot.user.avatar_url) 40 | embed.set_footer(text=f"Requested by {ctx.author.display_name}", icon_url=str(ctx.author.avatar_url)) 41 | await ctx.send(embed=embed) 42 | else: 43 | help_command = arg[0] 44 | 45 | comm = self.bot.get_command(help_command) 46 | 47 | if not comm or not comm.help or comm.hidden: 48 | raise BadArgs("That command doesn't exist.") 49 | 50 | await ctx.send(comm.help) 51 | 52 | @commands.command(hidden=True) 53 | @commands.is_owner() 54 | @commands.cooldown(1, 5, commands.BucketType.user) 55 | async def reload(self, ctx: commands.Context, *modules: str): 56 | if not isinstance(ctx.channel, discord.DMChannel): 57 | await ctx.message.delete() 58 | 59 | if not modules: 60 | modules = list(i.lower() for i in self.bot.cogs) 61 | 62 | for extension in modules: 63 | reload_msg = await ctx.send(f"Reloading the {extension} module") 64 | 65 | try: 66 | self.bot.reload_extension(f"cogs.{extension}") 67 | except ExtensionError as exc: 68 | return await ctx.send(str(exc)) 69 | 70 | await reload_msg.edit(content=f"{extension} module reloaded.") 71 | 72 | await ctx.send("Done") 73 | 74 | 75 | def setup(bot: commands.Bot) -> None: 76 | bot.add_cog(Meta(bot)) 77 | -------------------------------------------------------------------------------- /cogs/games.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | from datetime import datetime 4 | from io import BytesIO 5 | 6 | import chess 7 | import discord 8 | from cairosvg import svg2png 9 | from chess import pgn, svg 10 | from discord.ext import commands 11 | from discord.ext.commands import BadArgument, MemberConverter 12 | 13 | 14 | class Games(commands.Cog): 15 | def __init__(self, bot: commands.Bot): 16 | self.bot = bot 17 | 18 | @commands.command() 19 | async def chess(self, ctx: commands.Context, *args: str): 20 | """ 21 | `!chess` __`Play chess`__ 22 | 23 | **Usage:** !chess 24 | 25 | **Examples:** 26 | `!chess abc#1234` starts a game of chess with abc#1234 27 | 28 | **Note:** 29 | Moves are in standard algebraic notation (e4, Nxf7, etc). 30 | """ 31 | 32 | try: 33 | user = await MemberConverter().convert(ctx, " ".join(args)) 34 | except BadArgument: 35 | return await ctx.send("That user doesn't exist.", delete_after=5) 36 | 37 | board = chess.Board() 38 | game = pgn.Game() 39 | node = None 40 | players = [ctx.author, user] 41 | random.shuffle(players) 42 | turn = chess.WHITE 43 | game.headers["Date"] = datetime.today().strftime("%Y.%m.%d") 44 | game.headers["White"] = players[chess.WHITE].display_name 45 | game.headers["Black"] = players[chess.BLACK].display_name 46 | 47 | def render_board(board: chess.Board) -> BytesIO: 48 | boardimg = chess.svg.board(board=board, lastmove=board.peek() if board.move_stack else None, check=board.king(turn) if board.is_check() or board.is_checkmate() else None, flipped=board.turn == chess.BLACK) 49 | res = BytesIO() 50 | svg2png(bytestring=boardimg, write_to=res) 51 | res.seek(0) 52 | return res 53 | 54 | while True: 55 | res = render_board(board) 56 | 57 | if board.outcome() is not None: 58 | game.headers["Result"] = board.outcome().result() 59 | 60 | if board.is_checkmate(): 61 | return await ctx.send(f"Checkmate!\n\n{players[not turn].mention} wins.\n{str(game)}", file=discord.File(res, "file.png")) 62 | else: 63 | return await ctx.send(f"Draw!\n{str(game)}", file=discord.File(res, "file.png")) 64 | 65 | game_msg = await ctx.send(f"{players[turn].mention}, its your turn." + ("\n\nCheck!" if board.is_check() else ""), file=discord.File(res, "file.png")) 66 | 67 | try: 68 | msg = await self.bot.wait_for("message", timeout=600, check=lambda msg: msg.channel == ctx.channel and msg.author.id == players[turn].id) 69 | except asyncio.TimeoutError: 70 | return await ctx.send("Timed out.", delete_after=5) 71 | 72 | await msg.delete() 73 | await game_msg.delete() 74 | 75 | if msg.content == "exit" or msg.content == "resign": 76 | res = render_board(board) 77 | game.headers["Result"] = "0-1" if turn else "1-0" 78 | await game_msg.delete() 79 | return await ctx.send(f"{players[turn].mention} resigned. {players[not turn].mention} wins.\n{str(game)}", file=discord.File(res, "file.png")) 80 | 81 | try: 82 | move = board.push_san(msg.content) 83 | except ValueError: 84 | continue 85 | 86 | if not node: 87 | node = game.add_variation(move) 88 | else: 89 | node = node.add_variation(move) 90 | 91 | turn = not turn 92 | 93 | 94 | def setup(bot: commands.Bot) -> None: 95 | bot.add_cog(Games(bot)) 96 | -------------------------------------------------------------------------------- /cs221bot.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | import os 4 | import random 5 | import traceback 6 | from io import BytesIO 7 | from os.path import isfile, join 8 | 9 | import discord 10 | from discord.ext import commands 11 | from dotenv import load_dotenv 12 | 13 | from util.badargs import BadArgs 14 | 15 | CANVAS_COLOR = 0xe13f2b 16 | CANVAS_THUMBNAIL_URL = "https://lh3.googleusercontent.com/2_M-EEPXb2xTMQSTZpSUefHR3TjgOCsawM3pjVG47jI-BrHoXGhKBpdEHeLElT95060B=s180" 17 | POLL_FILE = "data/poll.json" 18 | GUILD_ID = 974449980947464214 19 | 20 | load_dotenv() 21 | CS221BOT_KEY = os.getenv("CS221BOT_KEY") 22 | 23 | bot = commands.Bot(command_prefix="!", help_command=None, intents=discord.Intents.all()) 24 | 25 | parser = argparse.ArgumentParser(description="Run CS221Bot") 26 | parser.add_argument("--cnu", dest="notify_unpublished", action="store_true", 27 | help="Allow the bot to send notifications about unpublished Canvas modules (if you have access) as well as published ones.") 28 | args = parser.parse_args() 29 | 30 | 31 | async def status_task() -> None: 32 | await bot.wait_until_ready() 33 | 34 | while not bot.is_closed(): 35 | online_members = {member for guild in bot.guilds for member in guild.members if not member.bot and member.status != discord.Status.offline} 36 | 37 | play = ["with the \"help\" command", " ", "with your mind", "ƃuᴉʎɐlԀ", "...something?", 38 | "a game? Or am I?", "¯\_(ツ)_/¯", f"with {len(online_members)} people", "with image manipulation"] 39 | listen = ["smart music", "... wait I can't hear anything", 40 | "rush 🅱", "C++ short course"] 41 | watch = ["TV", "YouTube vids", "over you", 42 | "how to make a bot", "C++ tutorials", "I, Robot"] 43 | 44 | rng = random.randrange(0, 3) 45 | 46 | if rng == 0: 47 | await bot.change_presence(activity=discord.Activity(type=discord.ActivityType.playing, name=random.choice(play))) 48 | elif rng == 1: 49 | await bot.change_presence(activity=discord.Activity(type=discord.ActivityType.listening, name=random.choice(listen))) 50 | else: 51 | await bot.change_presence(activity=discord.Activity(type=discord.ActivityType.watching, name=random.choice(watch))) 52 | 53 | await asyncio.sleep(30) 54 | 55 | 56 | def startup() -> None: 57 | bot.get_cog("Canvas").canvas_init() 58 | bot.get_cog("Piazza").piazza_start() 59 | 60 | 61 | @bot.event 62 | async def on_ready() -> None: 63 | startup() 64 | print("Logged in successfully") 65 | bot.loop.create_task(status_task()) 66 | bot.loop.create_task(bot.get_cog("Piazza").send_pupdate()) 67 | bot.loop.create_task(bot.get_cog("Canvas").stream_tracking()) 68 | bot.loop.create_task(bot.get_cog("Canvas").assignment_reminder()) 69 | bot.loop.create_task(bot.get_cog("Canvas").update_modules()) 70 | 71 | 72 | @bot.event 73 | async def on_message_edit(before: discord.Message, after: discord.Message) -> None: 74 | await bot.process_commands(after) 75 | 76 | 77 | @bot.event 78 | async def on_message(message: discord.Message) -> None: 79 | if isinstance(message.channel, discord.abc.PrivateChannel): 80 | return 81 | 82 | if not message.author.bot and message.guild.id == GUILD_ID: 83 | # debugging 84 | # with open("messages.txt", "a") as f: 85 | # print(f"{message.guild.name}: {message.channel.name}: {message.author.name}: \"{message.content}\" @ {str(datetime.datetime.now())} \r\n", file = f) 86 | # print(message.content) 87 | 88 | await bot.process_commands(message) 89 | 90 | 91 | if __name__ == "__main__": 92 | # True if the bot should send notifications about new *unpublished* modules on Canvas; False otherwise. 93 | # This only matters if the host of the bot has access to unpublished modules. If the host does 94 | # not have access, then the bot won't know about any unpublished modules and won't send any info 95 | # about them anyway. 96 | bot.notify_unpublished = args.notify_unpublished 97 | bot.guild_id = GUILD_ID 98 | 99 | if bot.notify_unpublished: 100 | print("Warning: bot will send notifications about unpublished modules (if you have access).") 101 | 102 | for extension in filter(lambda f: isfile(join("cogs", f)) and f != "__init__.py", os.listdir("cogs")): 103 | bot.load_extension(f"cogs.{extension[:-3]}") 104 | print(f"{extension} module loaded") 105 | 106 | 107 | @bot.event 108 | async def on_command_error(ctx: commands.Context, error: commands.CommandError): 109 | if isinstance(error, commands.CommandNotFound) or isinstance(error, discord.HTTPException): 110 | pass 111 | elif isinstance(error, BadArgs): 112 | await error.print(ctx) 113 | elif isinstance(error, commands.CommandOnCooldown): 114 | await ctx.send(str(error), delete_after=error.retry_after) 115 | elif isinstance(error, commands.MissingPermissions) or isinstance(error, commands.BotMissingPermissions) or isinstance(error, commands.BadArgument): 116 | await ctx.send(str(error), delete_after=5) 117 | else: 118 | etype = type(error) 119 | trace = error.__traceback__ 120 | 121 | await ctx.send(file=discord.File(BytesIO(bytes("".join(traceback.format_exception(etype, error, trace)), "utf-8")), filename="error.txt")) 122 | 123 | 124 | bot.run(CS221BOT_KEY) 125 | -------------------------------------------------------------------------------- /cogs/piazza.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from datetime import datetime, timedelta, timezone 4 | from os.path import isfile 5 | 6 | import discord 7 | from discord.ext import commands 8 | from dotenv import load_dotenv 9 | 10 | from util.badargs import BadArgs 11 | from util.create_file import create_file_if_not_exists 12 | from util.json import read_json, write_json 13 | from util.piazza_handler import InvalidPostID, PiazzaHandler 14 | 15 | PIAZZA_THUMBNAIL_URL = "https://store-images.s-microsoft.com/image/apps.25584.554ac7a6-231b-46e2-9960-a059f3147dbe.727eba5c-763a-473f-981d-ffba9c91adab.4e76ea6a-bd74-487f-bf57-3612e43ca795.png" 16 | PIAZZA_FILE = "data/piazza.json" 17 | 18 | load_dotenv() 19 | PIAZZA_EMAIL = os.getenv("PIAZZA_EMAIL") 20 | PIAZZA_PASSWORD = os.getenv("PIAZZA_PASSWORD") 21 | 22 | 23 | class Piazza(commands.Cog): 24 | def __init__(self, bot: commands.Bot): 25 | self.bot = bot 26 | 27 | if not isfile(PIAZZA_FILE): 28 | create_file_if_not_exists(PIAZZA_FILE) 29 | write_json({}, PIAZZA_FILE) 30 | 31 | self.piazza_dict = read_json(PIAZZA_FILE) 32 | 33 | # # start of Piazza functions # # 34 | # didn't want to support multiple PiazzaHandler instances because it's associated with 35 | # a single account (unsafe to send sensitive information through Discord, so there's 36 | # no way to login to another account without also having access to prod env variables) 37 | # and the API is also rate-limited, so it's probably not a good idea to spam Piazza's server 38 | # with an unlimited # of POST requests per instance everyday. One instance should be safe 39 | @commands.command() 40 | @commands.has_permissions(administrator=True) 41 | async def pinit(self, ctx: commands.Context, name: str, pid: str): 42 | """ 43 | `!pinit` _ `course name` _ _ `piazza id` _ 44 | 45 | **Usage:** !pinit 46 | 47 | **Examples:** 48 | `!pinit CPSC221 abcdef1234` creates a CPSC221 Piazza instance for the server 49 | 50 | *Only usable by TAs and Profs 51 | """ 52 | 53 | self.bot.d_handler.piazza_handler = PiazzaHandler(name, pid, PIAZZA_EMAIL, PIAZZA_PASSWORD, ctx.guild) 54 | 55 | # dict.get default to empty list so KeyError is never thrown 56 | for channel in self.piazza_dict.get("channels", []): 57 | self.bot.d_handler.piazza_handler.add_channel(channel) 58 | 59 | self.piazza_dict["channels"] = [ctx.channel.id] 60 | self.piazza_dict["course_name"] = name 61 | self.piazza_dict["piazza_id"] = pid 62 | self.piazza_dict["guild_id"] = ctx.guild.id 63 | write_json(self.piazza_dict, "data/piazza.json") 64 | response = f"Piazza instance created!\nName: {name}\nPiazza ID: {pid}\n" 65 | response += "If the above doesn't look right, please use `!pinit` again with the correct arguments" 66 | await ctx.send(response) 67 | 68 | @commands.command() 69 | @commands.has_permissions(administrator=True) 70 | async def ptrack(self, ctx: commands.Context): 71 | """ 72 | `!ptrack` __`Tracks Piazza posts in channel`__ 73 | 74 | **Usage:** !ptrack 75 | 76 | **Examples:** 77 | `!ptrack` adds the current channel's id to the Piazza instance's list of channels 78 | 79 | The channels added through `!ptrack` are where send_pupdate and track_inotes send their responses. 80 | 81 | *Only usable by TAs and Profs 82 | """ 83 | 84 | cid = ctx.message.channel.id 85 | 86 | self.bot.d_handler.piazza_handler.add_channel(cid) 87 | self.piazza_dict["channels"] = self.bot.d_handler.piazza_handler.channels 88 | write_json(self.piazza_dict, "data/piazza.json") 89 | await ctx.send("Channel added to tracking!") 90 | 91 | @commands.command() 92 | @commands.has_permissions(administrator=True) 93 | async def puntrack(self, ctx: commands.Context): 94 | """ 95 | `!puntrack` __`Untracks Piazza posts in channel`__ 96 | 97 | **Usage:** !puntrack 98 | 99 | **Examples:** 100 | `!puntrack` removes the current channel's id to the Piazza instance's list of channels 101 | 102 | The channels removed through `!puntrack` are where send_pupdate and track_inotes send their responses. 103 | 104 | *Only usable by TAs and Profs 105 | """ 106 | 107 | cid = ctx.message.channel.id 108 | 109 | self.bot.d_handler.piazza_handler.remove_channel(cid) 110 | self.piazza_dict["channels"] = self.bot.d_handler.piazza_handler.channels 111 | write_json(self.piazza_dict, "data/piazza.json") 112 | await ctx.send("Channel removed from tracking!") 113 | 114 | @commands.command() 115 | @commands.cooldown(1, 5, commands.BucketType.channel) 116 | async def ppinned(self, ctx: commands.Context): 117 | """ 118 | `!ppinned` 119 | 120 | **Usage:** !ppinned 121 | 122 | **Examples:** 123 | `!ppinned` sends a list of the Piazza's pinned posts to the calling channel 124 | 125 | *to prevent hitting the rate-limit, only usable once every 5 secs channel-wide* 126 | """ 127 | 128 | if self.bot.d_handler.piazza_handler: 129 | posts = self.bot.d_handler.piazza_handler.get_pinned() 130 | embed = discord.Embed(title=f"**Pinned posts for {self.bot.d_handler.piazza_handler.course_name}:**", colour=0x497aaa) 131 | 132 | for post in posts: 133 | embed.add_field(name=f"@{post['num']}", value=f"[{post['subject']}]({post['url']})", inline=False) 134 | 135 | embed.set_footer(text=f"Requested by {ctx.author.display_name}", icon_url=str(ctx.author.avatar_url)) 136 | 137 | await ctx.send(embed=embed) 138 | else: 139 | raise BadArgs("Piazza hasn't been instantiated yet!") 140 | 141 | @commands.command() 142 | @commands.cooldown(1, 5, commands.BucketType.user) 143 | async def pread(self, ctx: commands.Context, post_id: int): 144 | """ 145 | `!pread` __`post id`__ 146 | 147 | **Usage:** !pread 148 | 149 | **Examples:** 150 | `!pread 828` returns an embed with the [post](https://piazza.com/class/ke1ukp9g4xx6oi?cid=828)'s 151 | info (question, answer, answer type, tags) 152 | """ 153 | 154 | if not self.bot.d_handler.piazza_handler: 155 | raise BadArgs("Piazza hasn't been instantiated yet!") 156 | 157 | try: 158 | post = self.bot.d_handler.piazza_handler.get_post(post_id) 159 | except InvalidPostID: 160 | raise BadArgs("Post not found.") 161 | 162 | if post: 163 | post_embed = self.create_post_embed(post) 164 | await ctx.send(embed=post_embed) 165 | 166 | @commands.command(hidden=True) 167 | @commands.has_permissions(administrator=True) 168 | async def ptest(self, ctx: commands.Context): 169 | """ 170 | `!ptest` 171 | 172 | **Usage:** !ptest 173 | 174 | **Examples:** 175 | `!ptest` simulates a single call of `send_pupdate` to ensure the set-up was done correctly. 176 | """ 177 | 178 | await self.send_piazza_posts() 179 | 180 | def create_post_embed(self, post: dict) -> discord.Embed: 181 | if post: 182 | post_embed = discord.Embed(title=post["subject"], url=post["url"], description=post["num"]) 183 | post_embed.add_field(name=post["post_type"], value=post["post_body"] or None, inline=False) 184 | post_embed.add_field(name=f"{post['num_followups']} followup comments hidden", value="Click the title above to access the rest of the post.", inline=False) 185 | 186 | if post["post_type"] != "Note": 187 | post_embed.add_field(name="Instructor Answer", value=post["i_answer"] or None, inline=False) 188 | post_embed.add_field(name="Student Answer", value=post["s_answer"] or None, inline=False) 189 | 190 | if post["first_image"]: 191 | post_embed.set_image(url=post["first_image"]) 192 | 193 | post_embed.set_thumbnail(url=PIAZZA_THUMBNAIL_URL) 194 | post_embed.set_footer(text=f"tags: {post['tags']}") 195 | return post_embed 196 | 197 | async def send_at_time(self) -> None: 198 | # default set to midnight UTC (4/5 PM PT) 199 | now = datetime.now(timezone.utc) 200 | post_time = datetime(now.year, now.month, now.day, tzinfo=timezone.utc) + timedelta(days=1) 201 | time_until_post = post_time - now 202 | 203 | if time_until_post.total_seconds(): 204 | await asyncio.sleep(time_until_post.total_seconds()) 205 | 206 | async def send_pupdate(self) -> None: 207 | while True: 208 | # Sends at midnight 209 | await self.send_at_time() 210 | await self.send_piazza_posts() 211 | 212 | async def send_piazza_posts(self) -> None: 213 | if not self.bot.d_handler.piazza_handler: 214 | return 215 | 216 | posts = await self.bot.d_handler.piazza_handler.get_posts_in_range() 217 | 218 | if posts: 219 | response = f"**{self.bot.d_handler.piazza_handler.course_name}'s posts for {datetime.today().strftime('%a. %B %d, %Y')}**\n" 220 | 221 | response += "Instructor's Notes:\n" 222 | 223 | if posts[0]: 224 | for ipost in posts[0]: 225 | response += f"@{ipost['num']}: {ipost['subject']} <{ipost['url']}>\n" 226 | else: 227 | response += "None today!\n" 228 | 229 | response += "\nDiscussion posts: \n" 230 | 231 | if not posts[1]: 232 | response += "None today!" 233 | 234 | for post in posts[1]: 235 | response += f"@{post['num']}: {post['subject']} <{post['url']}>\n" 236 | 237 | for ch in self.bot.d_handler.piazza_handler.channels: 238 | channel = self.bot.get_channel(ch) 239 | await channel.send(response) 240 | 241 | def piazza_start(self) -> None: 242 | if all(field in self.piazza_dict for field in ("course_name", "piazza_id", "guild_id")): 243 | self.bot.d_handler.piazza_handler = PiazzaHandler(self.piazza_dict["course_name"], self.piazza_dict["piazza_id"], PIAZZA_EMAIL, PIAZZA_PASSWORD, self.piazza_dict["guild_id"]) 244 | 245 | # dict.get will default to an empty tuple so a key error is never raised 246 | # We need to have the empty tuple because if the default value is None, an error is raised (NoneType object 247 | # is not iterable). 248 | for ch in self.piazza_dict.get("channels", tuple()): 249 | self.bot.d_handler.piazza_handler.add_channel(int(ch)) 250 | 251 | 252 | def setup(bot: commands.Bot) -> None: 253 | bot.add_cog(Piazza(bot)) 254 | -------------------------------------------------------------------------------- /cogs/tree.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import glob 3 | import math 4 | import os 5 | import random 6 | import re 7 | from typing import Optional 8 | 9 | import discord 10 | from binarytree import Node 11 | from discord.ext import commands 12 | 13 | from util.badargs import BadArgs 14 | 15 | 16 | class Tree(commands.Cog): 17 | def __init__(self, bot: commands.Bot) -> None: 18 | self.bot = bot 19 | 20 | @commands.command() 21 | @commands.cooldown(1, 5, commands.BucketType.user) 22 | async def bst(self, ctx: commands.Context): 23 | """ 24 | `!bst` __`Binary Search Tree analysis tool`__ 25 | **Usage:** !bst [node] [...] 26 | **Examples:** 27 | `!bst 2 1 3` displays a BST in ASCII and PNG form with root node 2 and leaves 1, 3 28 | `!bst 4 5 6` displays a BST in ASCII and PNG form with root node 4, parent node 5 and leaf 6 29 | 30 | Launching the command activates a 60 second window during which additional unprefixed commands can be called: 31 | 32 | `pre` displays pre-order traversal of the tree 33 | `in` displays in-order traversal of the tree 34 | `level` displays level-order traversal of the tree 35 | `post` displays post-order traversal of the tree 36 | `about` displays characteristics of the tree 37 | `pause` stops the 60 second countdown timer 38 | `unpause` starts the 60 second countdown timer 39 | `show` displays the ASCII and PNG representations of the tree again 40 | `exit` exits the window 41 | 42 | `insert [node] [...]` inserts nodes into the tree 43 | **Example:** `insert 5 7 6` inserts nodes 5, 7 and 6, in that order 44 | 45 | `delete [node] [...]` deletes nodes from the tree 46 | **Example:** `delete 7 8 9` deletes nodes 7, 8 and 9, in that order 47 | """ 48 | 49 | numbers = [] 50 | 51 | for num in ctx.message.content[5:].replace(",", "").split(): 52 | if re.fullmatch(r"[+-]?((\d+(\.\d*)?)|(\.\d+))", num): 53 | try: 54 | numbers.append(int(num)) 55 | except ValueError: 56 | numbers.append(float(num)) 57 | else: 58 | raise BadArgs("Please provide valid numbers for the tree.") 59 | 60 | if not numbers: 61 | raise BadArgs("Please provide some numbers for the tree.", show_help=True) 62 | elif len(numbers) > 50: 63 | raise BadArgs("Please enter 50 or fewer nodes.") 64 | 65 | root = Node(numbers[0]) 66 | 67 | nodes = [root] 68 | 69 | def insert(subroot: Node, num: int) -> None: 70 | if num < subroot.val: 71 | if not subroot.left: 72 | node = Node(num) 73 | subroot.left = node 74 | nodes.append(node) 75 | else: 76 | insert(subroot.left, num) 77 | else: 78 | if not subroot.right: 79 | node = Node(num) 80 | subroot.right = node 81 | nodes.append(node) 82 | else: 83 | insert(subroot.right, num) 84 | 85 | def delete(subroot: Node, num: int) -> Node: 86 | if subroot: 87 | if subroot.val == num: 88 | if subroot.left is not None and subroot.right is not None: 89 | parent = subroot 90 | predecessor = subroot.left 91 | 92 | while predecessor.right is not None: 93 | parent = predecessor 94 | predecessor = predecessor.right 95 | 96 | if parent.right == predecessor: 97 | parent.right = predecessor.left 98 | else: 99 | parent.left = predecessor.left 100 | 101 | predecessor.left = subroot.left 102 | predecessor.right = subroot.right 103 | 104 | ret = predecessor 105 | else: 106 | if subroot.left is not None: 107 | ret = subroot.left 108 | else: 109 | ret = subroot.right 110 | 111 | nodes.remove(subroot) 112 | del subroot 113 | return ret 114 | else: 115 | if subroot.val > num: 116 | if subroot.left: 117 | subroot.left = delete(subroot.left, num) 118 | else: 119 | if subroot.right: 120 | subroot.right = delete(subroot.right, num) 121 | 122 | return subroot 123 | 124 | def get_node(num: int) -> Optional[Node]: 125 | for node in nodes: 126 | if node.val == num: 127 | return node 128 | 129 | return None 130 | 131 | for num in numbers[1:]: 132 | if not get_node(num): 133 | insert(root, num) 134 | 135 | timeout = 60 136 | display = True 137 | 138 | def draw_bst(root: Node) -> None: 139 | graph = root.graphviz() 140 | graph.render("bst", format="png") 141 | 142 | while True: 143 | draw_bst(root) 144 | text = f"```{root}\n```" 145 | 146 | if display: 147 | await ctx.send(text, file=discord.File("bst.png")) 148 | display = False 149 | 150 | try: 151 | message = await self.bot.wait_for("message", timeout=timeout, check=lambda m: m.channel.id == ctx.channel.id and m.author.id == ctx.author.id) 152 | except asyncio.exceptions.TimeoutError: 153 | for f in glob.glob("bst*"): 154 | os.remove(f) 155 | 156 | return await ctx.send("Timed out.", delete_after=5) 157 | 158 | command = message.content.replace(",", "").replace("!", "").lower().split() 159 | 160 | match command[0]: 161 | case "insert": 162 | add = [] 163 | 164 | for entry in command[1:]: 165 | if re.fullmatch(r"[+-]?((\d+(\.\d*)?)|(\.\d+))", entry): 166 | try: 167 | num = int(entry) 168 | except ValueError: 169 | num = float(entry) 170 | else: 171 | continue 172 | 173 | add.append(str(num)) 174 | 175 | if not get_node(num): 176 | insert(root, num) 177 | 178 | await ctx.send(f"Inserted {','.join(add)}.") 179 | display = True 180 | case "delete": 181 | remove = [] 182 | 183 | for entry in command[7:].split(): 184 | try: 185 | num = float(entry) 186 | except ValueError: 187 | continue 188 | 189 | if root.size == 1: 190 | await ctx.send("Tree has reached one node in size. Stopping deletions.") 191 | break 192 | 193 | if math.modf(num)[0] == 0: 194 | num = int(round(num)) 195 | 196 | if not get_node(num): 197 | continue 198 | 199 | remove.append(str(num)) 200 | root = delete(root, num) 201 | 202 | await ctx.send(f"Deleted {','.join(remove)}.") 203 | display = True 204 | case "level": 205 | await ctx.send("Level-Order Traversal:\n**" + " ".join([str(n.val) for n in root.levelorder]) + "**") 206 | case "pre": 207 | await ctx.send("Pre-Order Traversal:\n**" + " ".join([str(n.val) for n in root.preorder]) + "**") 208 | case "post": 209 | await ctx.send("Post-Order Traversal:\n**" + " ".join([str(n.val) for n in root.postorder]) + "**") 210 | case "in": 211 | await ctx.send("In-Order Traversal:\n**" + " ".join([str(n.val) for n in root.inorder]) + "**") 212 | case "about": 213 | embed = discord.Embed(title="Binary Search Tree Info", description="> " + text.replace("\n", "\n> "), color=random.randint(0, 0xffffff)) 214 | embed.add_field(name="Height:", value=str(root.height)) 215 | embed.add_field(name="Balanced?", value=str(root.is_balanced)) 216 | embed.add_field(name="Complete?", value=str(root.is_complete)) 217 | embed.add_field(name="Full?", value=str(root.is_strict)) 218 | embed.add_field(name="Perfect?", value=str(root.is_perfect)) 219 | embed.add_field(name="Number of leaves:", value=str(root.leaf_count)) 220 | embed.add_field(name="Max Leaf Depth:", value=str(root.max_leaf_depth)) 221 | embed.add_field(name="Min Leaf Depth:", value=str(root.min_leaf_depth)) 222 | embed.add_field(name="Max Node Value:", value=str(root.max_node_value)) 223 | embed.add_field(name="Min Node Value:", value=str(root.min_node_value)) 224 | embed.add_field(name="Entries:", value=str(root.size)) 225 | embed.add_field(name="Pre-Order Traversal:", value=" ".join([str(n.val) for n in root.preorder])) 226 | embed.add_field(name="In-Order Traversal:", value=" ".join([str(n.val) for n in root.inorder])) 227 | embed.add_field(name="Level-Order Traversal:", value=" ".join([str(n.val) for n in root.levelorder])) 228 | embed.add_field(name="Post-Order Traversal:", value=" ".join([str(n.val) for n in root.postorder])) 229 | 230 | if root.left: 231 | embed.add_field(name="In-Order Predecessor:", value=str(max(filter(lambda x: x is not None, root.left.values)))) 232 | 233 | if root.right: 234 | embed.add_field(name="In-Order Successor:", value=str(min(filter(lambda x: x is not None, root.right.values)))) 235 | 236 | await ctx.send(embed=embed, file=discord.File("bst.png")) 237 | case "pause": 238 | timeout = 86400 239 | await ctx.send("Timeout paused.") 240 | case "unpause": 241 | timeout = 60 242 | await ctx.send("Timeout reset to 60 seconds.") 243 | case "show": 244 | display = True 245 | case "exit": 246 | for f in glob.glob("bst*"): 247 | os.remove(f) 248 | 249 | return await ctx.send("Exiting.") 250 | case "bst": 251 | return 252 | 253 | 254 | def setup(bot: commands.Bot) -> None: 255 | bot.add_cog(Tree(bot)) 256 | -------------------------------------------------------------------------------- /util/piazza_handler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import html 4 | import re 5 | from operator import itemgetter 6 | from typing import List, Optional 7 | 8 | import discord 9 | import piazza_api.exceptions 10 | from bs4 import BeautifulSoup 11 | from piazza_api import Piazza 12 | 13 | 14 | # Exception for when a post ID is invalid or the post is private etc. 15 | class InvalidPostID(Exception): 16 | """ 17 | Exception raised when a Piazza post ID is invalid, refers to a non-existent post, or refers to a private post. 18 | """ 19 | pass 20 | 21 | 22 | class PiazzaHandler: 23 | """ 24 | Handles requests to a specific Piazza network. Requires an e-mail and password, but if none are 25 | provided, then they will be asked for in the console (doesn't work for Heroku deploys). API is rate-limited 26 | (max is 55 posts in about 2 minutes?) so it's recommended to be conservative with fetch_max, fetch_min and only change them if necessary. 27 | 28 | All `fetch_*` functions return JSON directly from Piazza's API and all `get_*` functions parse that JSON. 29 | 30 | # todo missing docs for some attributes 31 | Attributes 32 | ---------- 33 | name : `str` 34 | Name of class (ex. CPSC221) 35 | 36 | nid : `str` 37 | ID of Piazza forum (usually found at the end of a Piazza's home url) 38 | 39 | email : `str (optional)` 40 | Piazza log-in email 41 | 42 | password : `str (optional)` 43 | Piazza password 44 | 45 | guild : `discord.Guild` 46 | Guild assigned to the handler 47 | 48 | fetch_max : `int (optional)` 49 | Upper limit on posts fetched from Piazza. 50 | 51 | fetch_min: `int (optional)` 52 | Lower limit on posts fetched from Piazza. Used as the default value for functions that don't need to fetch a lot of posts 53 | """ 54 | 55 | def __init__(self, name: str, nid: str, email: str, password: str, guild: discord.Guild, fetch_max: int = 55, fetch_min: int = 30): 56 | self._name = name 57 | self.nid = nid 58 | self._guild = guild 59 | self._channels = [] 60 | self.url = f"https://piazza.com/class/{self.nid}" 61 | self.p = Piazza() 62 | self.p.user_login(email=email, password=password) 63 | self.network = self.p.network(self.nid) 64 | self.fetch_max = fetch_max 65 | self.fetch_min = fetch_min 66 | 67 | @property 68 | def piazza_url(self) -> str: 69 | return self.url 70 | 71 | @piazza_url.setter 72 | def piazza_url(self, url: str) -> None: 73 | self.url = url 74 | 75 | @property 76 | def course_name(self) -> str: 77 | return self._name 78 | 79 | @course_name.setter 80 | def course_name(self, name: str) -> None: 81 | self._name = name 82 | 83 | @property 84 | def piazza_id(self) -> str: 85 | return self.nid 86 | 87 | @piazza_id.setter 88 | def piazza_id(self, nid: str) -> None: 89 | self.nid = nid 90 | 91 | @property 92 | def guild(self) -> discord.Guild: 93 | return self._guild 94 | 95 | @guild.setter 96 | def guild(self, guild: discord.Guild) -> None: 97 | self._guild = guild 98 | 99 | @property 100 | def channels(self) -> List[int]: 101 | return self._channels 102 | 103 | @channels.setter 104 | def channels(self, channels: List[int]) -> None: 105 | self._channels = channels 106 | 107 | def add_channel(self, channel: int) -> None: 108 | if channel not in self.channels: 109 | self._channels.append(channel) 110 | 111 | def remove_channel(self, channel: int) -> None: 112 | if channel in self.channels: 113 | self._channels.remove(channel) 114 | 115 | def fetch_post_instance(self, post_id: int) -> dict: 116 | """ 117 | Returns a JSON object representing a Piazza post with ID `post_id`, or returns None if post doesn't exist 118 | 119 | Parameters 120 | ---------- 121 | post_id : `int` 122 | requested post ID 123 | """ 124 | 125 | try: 126 | post = self.network.get_post(post_id) 127 | except piazza_api.exceptions.RequestError as ex: 128 | raise InvalidPostID("Post not found.") from ex 129 | 130 | if self.check_if_private(post): 131 | raise InvalidPostID("Post is Private.") 132 | 133 | return post 134 | 135 | async def fetch_recent_notes(self, lim: int = 55) -> List[dict]: 136 | """ 137 | Returns up to `lim` JSON objects representing instructor's notes that were posted today 138 | 139 | Parameters 140 | ---------- 141 | lim : `int (optional)` 142 | Upper limit on posts fetched. Must be in range [fetch_min, fetch_max] (inclusive) 143 | """ 144 | 145 | posts = await self.fetch_posts_in_range(days=0, seconds=60 * 60 * 5, lim=lim) 146 | response = [] 147 | 148 | for post in posts: 149 | if post["tags"][0] == "instructor-note" or post["bucket_name"] == "Pinned": 150 | response.append(post) 151 | 152 | return response 153 | 154 | def fetch_pinned(self, lim: int = 0) -> List[dict]: 155 | """ 156 | Returns up to `lim` JSON objects representing pinned posts\n 157 | Since pinned posts are always the first notes shown in a Piazza, lim can be a small value. 158 | 159 | Parameters 160 | ---------- 161 | lim : `int` 162 | Upper limit on posts fetched. Must be in range [fetch_min, fetch_max] (inclusive) 163 | """ 164 | 165 | posts = self.network.iter_all_posts(limit=lim or self.fetch_min) 166 | response = [] 167 | 168 | for post in posts: 169 | if self.check_if_private(post): 170 | continue 171 | 172 | if post.get("bucket_name", "") == "Pinned": 173 | response.append(post) 174 | 175 | return response 176 | 177 | async def fetch_posts_in_range(self, days: int = 1, seconds: int = 0, lim: int = 55) -> List[dict]: 178 | """ 179 | Returns up to `lim` JSON objects that represent a Piazza post posted today 180 | """ 181 | 182 | if lim < 0: 183 | raise ValueError(f"Invalid lim for fetch_posts_in_days(): {lim}") 184 | 185 | posts = [] 186 | 187 | feed = self.network.get_feed(limit=lim, offset=0) 188 | 189 | for cid in map(itemgetter("id"), feed["feed"]): 190 | post = None 191 | retries = 5 192 | 193 | while not post and retries: 194 | try: 195 | post = self.network.get_post(cid) 196 | except piazza_api.exceptions.RequestError as ex: 197 | retries -= 1 198 | 199 | if "foo fast" in str(ex): 200 | await asyncio.sleep(1) 201 | else: 202 | break 203 | if post: 204 | posts.append(post) 205 | 206 | date = datetime.date.today() 207 | result = [] 208 | 209 | for post in posts: 210 | # [2020,9,19] from 2020-09-19T22:41:52Z 211 | created_at = datetime.date(*[int(x) for x in post["created"][:10].split("-")]) 212 | 213 | if self.check_if_private(post): 214 | continue 215 | elif (date - created_at).days <= days and (date - created_at).seconds <= seconds: 216 | result.append(post) 217 | 218 | return result 219 | 220 | def get_pinned(self) -> List[dict]: 221 | """ 222 | Returns an array of `self.min` objects containing a pinned post's post id, title, and url. 223 | """ 224 | 225 | posts = self.fetch_pinned() 226 | response = [] 227 | 228 | for post in posts: 229 | post_details = { 230 | "num": post["nr"], 231 | "subject": post["history"][0]["subject"], 232 | "url": f"{self.url}?cid={post['nr']}", 233 | } 234 | response.append(post_details) 235 | 236 | return response 237 | 238 | def get_post(self, post_id: int) -> Optional[dict]: 239 | """ 240 | Returns a dict that contains post information to be formatted and returned as an embed 241 | 242 | Parameters 243 | ---------- 244 | post_id : `int` 245 | int associated with a Piazza post ID 246 | """ 247 | 248 | post = self.fetch_post_instance(post_id) 249 | 250 | if post: 251 | post_type = "Note" if post["type"] == "note" else "Question" 252 | response = { 253 | "subject": self.clean_response(post["history"][0]["subject"]), 254 | "num": f"@{post_id}", 255 | "url": f"{self.url}?cid={post_id}", 256 | "post_type": post_type, 257 | "post_body": self.clean_response(self.get_body(post)), 258 | "i_answer": None, 259 | "s_answer": None, 260 | "num_followups": 0, 261 | "first_image": self.get_first_image_url(self.get_body(post)) 262 | } 263 | 264 | answers = post["children"] 265 | 266 | if answers: 267 | num_followups = 0 268 | 269 | for answer in answers: 270 | if answer["type"] == "i_answer": 271 | response["i_answer"] = self.clean_response(self.get_body(answer)) 272 | elif answer["type"] == "s_answer": 273 | response["s_answer"] = self.clean_response(self.get_body(answer)) 274 | else: 275 | num_followups += self.get_num_follow_ups(answer) 276 | 277 | response.update({"num_followups": num_followups}) 278 | 279 | response.update({"tags": ", ".join(post["tags"] or "None")}) 280 | return response 281 | else: 282 | return None 283 | 284 | def get_num_follow_ups(self, answer: dict) -> int: 285 | return 1 + sum(self.get_num_follow_ups(i) for i in answer["children"]) 286 | 287 | async def get_posts_in_range(self, show_limit: int = 10, days: int = 1, seconds: int = 0) -> List[List[dict]]: 288 | if show_limit < 1: 289 | raise ValueError(f"Invalid show_limit for get_posts_in_range(): {show_limit}") 290 | 291 | posts = await self.fetch_posts_in_range(days=days, seconds=seconds, lim=self.fetch_max) 292 | instr, stud = [], [] 293 | response = [] 294 | 295 | def create_post_dict(post: dict, tag: str) -> dict: 296 | return { 297 | "type": tag, 298 | "num": post["nr"], 299 | "subject": self.clean_response(post["history"][0]["subject"]), 300 | "url": f"{self.url}?cid={post['nr']}" 301 | } 302 | 303 | def filter_tag(post: dict, arr: List[dict], tagged: str) -> None: 304 | """Sorts posts by instructor or student and append it to the respective array of posts""" 305 | 306 | for tag in post["tags"]: 307 | if tag == tagged: 308 | arr.append(create_post_dict(post, tag)) 309 | break 310 | 311 | # first adds all instructor notes to update, then student notes 312 | # for student notes, show first 10 and indicate there's more to be seen for today 313 | for post in posts: 314 | filter_tag(post, instr, "instructor-note") 315 | 316 | if len(posts) - len(instr) <= show_limit: 317 | for p in posts: 318 | filter_tag(p, stud, "student") 319 | else: 320 | for i in range(show_limit + 1): 321 | filter_tag(posts[i], stud, "student") 322 | 323 | response.append(instr) 324 | response.append(stud) 325 | return response 326 | 327 | async def get_recent_notes(self) -> List[dict]: 328 | """ 329 | Fetches `fetch_min` posts, filters out non-important (not instructor notes or pinned) posts and 330 | returns an array of corresponding post details 331 | """ 332 | 333 | posts = await self.fetch_recent_notes(lim=self.fetch_min) 334 | response = [] 335 | 336 | for post in posts: 337 | post_details = { 338 | "num": post["nr"], 339 | "subject": self.clean_response(post["history"][0]["subject"]), 340 | "url": f"{self.url}?cid={post['nr']}" 341 | } 342 | response.append(post_details) 343 | 344 | return response 345 | 346 | def check_if_private(self, post: dict) -> bool: 347 | return post["status"] == "private" 348 | 349 | def clean_response(self, res: Optional[str]) -> str: 350 | if not res: 351 | return "" 352 | 353 | if len(res) > 1024: 354 | res = res[:1000] 355 | res += "...\n\n *(Read more)*" 356 | 357 | tag_regex = re.compile("<.*?>") 358 | res = html.unescape(re.sub(tag_regex, "", res)) 359 | 360 | return res 361 | 362 | def get_body(self, res: dict) -> str: 363 | return res["history"][0]["content"] 364 | 365 | def get_first_image_url(self, res: str) -> str: 366 | if res: 367 | soup = BeautifulSoup(res, "html.parser") 368 | img = soup.find('img') 369 | 370 | if img: 371 | return f"https://piazza.com{img['src']}" if img["src"].startswith("/") else img["src"] 372 | 373 | return "" 374 | -------------------------------------------------------------------------------- /cogs/music.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2019 Valentin B. 3 | A simple music bot written in discord.py using youtube-dl. 4 | Though it's a simple example, music bots are complex and require much time and knowledge until they work perfectly. 5 | Use this as an example or a base for your own bot and extend it as you want. If there are any bugs, please let me know. 6 | Requirements: 7 | Python 3.5+ 8 | You also need FFmpeg in your PATH environment variable or the FFmpeg.exe binary in your bot's directory on Windows. 9 | """ 10 | 11 | import asyncio 12 | import functools 13 | import itertools 14 | import math 15 | import random 16 | import re 17 | 18 | import discord 19 | import youtube_dl 20 | from async_timeout import timeout 21 | from discord.ext import commands 22 | 23 | # Silence useless bug reports messages 24 | from util.badargs import BadArgs 25 | 26 | youtube_dl.utils.bug_reports_message = lambda: "" 27 | 28 | 29 | class VoiceError(Exception): 30 | pass 31 | 32 | 33 | class YTDLError(Exception): 34 | pass 35 | 36 | 37 | class YTDLSource(discord.PCMVolumeTransformer): 38 | YTDL_OPTIONS = { 39 | "format": "bestaudio/best", 40 | "extractaudio": True, 41 | "audioformat": "mp3", 42 | "outtmpl": "%(extractor)s-%(id)s-%(title)s.%(ext)s", 43 | "restrictfilenames": True, 44 | "noplaylist": True, 45 | "nocheckcertificate": True, 46 | "ignoreerrors": False, 47 | "logtostderr": False, 48 | "quiet": True, 49 | "no_warnings": True, 50 | "default_search": "auto", 51 | "source_address": "0.0.0.0", 52 | } 53 | 54 | FFMPEG_OPTIONS = { 55 | "before_options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5", 56 | "options": "-vn", 57 | "executable": "C:/FFmpeg/bin/ffmpeg.exe" 58 | } 59 | 60 | ytdl = youtube_dl.YoutubeDL(YTDL_OPTIONS) 61 | 62 | def __init__(self, ctx: commands.Context, source: discord.FFmpegPCMAudio, *, data: dict, volume: float = 0.5): 63 | super().__init__(source, volume) 64 | self.requester = ctx.author 65 | self.channel = ctx.channel 66 | self.data = data 67 | self.uploader = data.get("uploader") 68 | self.uploader_url = data.get("uploader_url") 69 | date = data.get("upload_date") 70 | self.upload_date = date[6:8] + "." + date[4:6] + "." + date[0:4] 71 | self.title = data.get("title") 72 | self.thumbnail = data.get("thumbnail") 73 | self.description = data.get("description") 74 | self.duration = self.parse_duration(int(data.get("duration"))) 75 | self.tags = data.get("tags") 76 | self.url = data.get("webpage_url") 77 | self.views = data.get("view_count") 78 | self.likes = data.get("like_count") 79 | self.dislikes = data.get("dislike_count") 80 | self.stream_url = data.get("url") 81 | 82 | def __str__(self): 83 | return f"**{self.title}** by **{self.uploader}**" 84 | 85 | @classmethod 86 | async def create_source(cls, ctx: commands.Context, search: str, *, loop: asyncio.BaseEventLoop = None): 87 | loop = loop or asyncio.get_event_loop() 88 | 89 | partial = functools.partial(cls.ytdl.extract_info, search, download=False, process=False) 90 | data = await loop.run_in_executor(None, partial) 91 | 92 | if data is None: 93 | raise YTDLError(f"Couldn't find anything that matches `{search}`") 94 | 95 | if "entries" not in data: 96 | process_info = data 97 | else: 98 | process_info = None 99 | 100 | for entry in data["entries"]: 101 | if entry: 102 | process_info = entry 103 | break 104 | 105 | if process_info is None: 106 | raise YTDLError(f"Couldn't find anything that matches `{search}`") 107 | 108 | webpage_url = process_info["webpage_url"] 109 | partial = functools.partial(cls.ytdl.extract_info, webpage_url, download=False) 110 | processed_info = await loop.run_in_executor(None, partial) 111 | 112 | if processed_info is None: 113 | raise YTDLError(f"Couldn't fetch `{webpage_url}`") 114 | 115 | if "entries" not in processed_info: 116 | info = processed_info 117 | else: 118 | info = None 119 | 120 | while info is None: 121 | try: 122 | info = processed_info["entries"].pop(0) 123 | except IndexError: 124 | raise YTDLError(f"Couldn't retrieve any matches for `{webpage_url}`") 125 | 126 | return cls(ctx, discord.FFmpegPCMAudio(info["url"], **cls.FFMPEG_OPTIONS), data=info) 127 | 128 | @staticmethod 129 | def parse_duration(duration: int): 130 | minutes, seconds = divmod(duration, 60) 131 | hours, minutes = divmod(minutes, 60) 132 | days, hours = divmod(hours, 24) 133 | 134 | duration = [] 135 | 136 | if days > 0: 137 | duration.append(f"{days} days") 138 | if hours > 0: 139 | duration.append(f"{hours} hours") 140 | if minutes > 0: 141 | duration.append(f"{minutes} minutes") 142 | if seconds > 0: 143 | duration.append(f"{seconds} seconds") 144 | 145 | return ", ".join(duration) 146 | 147 | 148 | class Song: 149 | __slots__ = ("source", "requester") 150 | 151 | def __init__(self, source: YTDLSource): 152 | self.source = source 153 | self.requester = source.requester 154 | 155 | def create_embed(self): 156 | embed = discord.Embed(title="Now playing", description=f"```css\n{self.source.title}\n```", colour=random.randint(0, 0xFFFFFF)) 157 | embed.add_field(name="Duration", value=self.source.duration) 158 | embed.add_field(name="Requested by", value=self.requester.mention) 159 | embed.add_field(name="Uploader", value=f"[{self.source.uploader}]({self.source.uploader_url})") 160 | embed.add_field(name="URL", value=f"[Click]({self.source.url})") 161 | embed.set_thumbnail(url=self.source.thumbnail) 162 | return embed 163 | 164 | 165 | class SongQueue(asyncio.Queue): 166 | def __getitem__(self, item): 167 | if isinstance(item, slice): 168 | return list(itertools.islice(self._queue, item.start, item.stop, item.step)) 169 | else: 170 | return self._queue[item] 171 | 172 | def __iter__(self): 173 | return self._queue.__iter__() 174 | 175 | def __len__(self): 176 | return self.qsize() 177 | 178 | def clear(self): 179 | self._queue.clear() 180 | 181 | def shuffle(self): 182 | random.shuffle(self._queue) 183 | 184 | def remove(self, index: int): 185 | del self._queue[index] 186 | 187 | 188 | class VoiceState: 189 | def __init__(self, bot: commands.Bot, ctx: commands.Context): 190 | self.bot = bot 191 | self._ctx = ctx 192 | self.current = None 193 | self.voice = None 194 | self.next = asyncio.Event() 195 | self.songs = SongQueue() 196 | self._loop = False 197 | self._volume = 0.5 198 | self.skip_votes = set() 199 | self.audio_player = bot.loop.create_task(self.audio_player_task()) 200 | 201 | def __del__(self): 202 | self.audio_player.cancel() 203 | 204 | @property 205 | def loop(self): 206 | return self._loop 207 | 208 | @loop.setter 209 | def loop(self, value: bool): 210 | self._loop = value 211 | 212 | @property 213 | def volume(self): 214 | return self._volume 215 | 216 | @volume.setter 217 | def volume(self, value: float): 218 | self._volume = value 219 | 220 | @property 221 | def is_playing(self): 222 | return self.voice and self.current 223 | 224 | async def audio_player_task(self): 225 | while True: 226 | self.next.clear() 227 | 228 | if not self.loop: 229 | # Try to get the next song within 180 seconds. 230 | # If no song will be added to the queue in time, 231 | # the player will disconnect due to performance 232 | # reasons. 233 | try: 234 | async with timeout(180): 235 | self.current = await self.songs.get() 236 | except asyncio.TimeoutError: 237 | self.bot.loop.create_task(self.stop()) 238 | return 239 | 240 | self.current.source.volume = self._volume 241 | self.voice.play(self.current.source, after=self.play_next_song) 242 | await self.current.source.channel.send(embed=self.current.create_embed()) 243 | await self.next.wait() 244 | 245 | def play_next_song(self, error=None): 246 | if error: 247 | raise VoiceError(str(error)) 248 | 249 | self.next.set() 250 | 251 | def skip(self): 252 | self.skip_votes.clear() 253 | 254 | if self.is_playing: 255 | self.voice.stop() 256 | 257 | async def stop(self): 258 | self.songs.clear() 259 | 260 | if self.voice: 261 | await self.voice.disconnect() 262 | self.voice = None 263 | 264 | 265 | class Music(commands.Cog): 266 | def __init__(self, bot: commands.Bot): 267 | self.bot = bot 268 | self.voice_states = {} 269 | self.voice_state = None 270 | 271 | def get_voice_state(self, ctx: commands.Context): 272 | state = self.voice_states.get(ctx.guild.id) 273 | 274 | if not state: 275 | state = VoiceState(self.bot, ctx) 276 | self.voice_states[ctx.guild.id] = state 277 | 278 | return state 279 | 280 | def cog_unload(self): 281 | for state in self.voice_states.values(): 282 | self.bot.loop.create_task(state.stop()) 283 | 284 | async def cog_before_invoke(self, ctx: commands.Context): 285 | self.voice_state = self.get_voice_state(ctx) 286 | 287 | @commands.command(aliases=["l"]) 288 | @commands.cooldown(1, 5, commands.BucketType.user) 289 | async def leave(self, ctx: commands.Context): 290 | """ 291 | `!leave` __`Leaves voice channel`__ 292 | **Aliases**: l 293 | 294 | **Usage:** !leave 295 | 296 | **Examples:** 297 | `!leave` leaves the current voice channel if any 298 | """ 299 | 300 | if not self.voice_state.voice: 301 | return await ctx.send("Not connected to any voice channel.") 302 | 303 | await self.voice_state.stop() 304 | del self.voice_states[ctx.guild.id] 305 | 306 | @commands.command() 307 | @commands.cooldown(1, 5, commands.BucketType.user) 308 | async def loop(self, ctx: commands.Context): 309 | """ 310 | `!loop` __`Loops current song`__ 311 | 312 | **Usage:** !loop 313 | 314 | **Examples:** 315 | `!loop` loops current song 316 | """ 317 | 318 | if not self.voice_state.is_playing: 319 | return await ctx.send("Nothing being played at the moment.") 320 | 321 | # Inverse boolean value to loop and unloop. 322 | self.voice_state.loop = not self.voice_state.loop 323 | await ctx.message.add_reaction("✅") 324 | 325 | @commands.command() 326 | @commands.cooldown(1, 5, commands.BucketType.user) 327 | async def now(self, ctx: commands.Context): 328 | """ 329 | `!now` __`Currently playing`__ 330 | 331 | **Usage:** !now 332 | 333 | **Examples:** 334 | `!now` shows what's currently playing 335 | """ 336 | 337 | await ctx.send(embed=self.voice_state.current.create_embed()) 338 | 339 | @commands.command() 340 | @commands.cooldown(1, 5, commands.BucketType.user) 341 | async def pause(self, ctx: commands.Context): 342 | """ 343 | `!pause` __`Pause song`__ 344 | 345 | **Usage:** !pause 346 | 347 | **Examples:** 348 | `!pause` pauses current song 349 | """ 350 | 351 | if not self.voice_state.is_playing and self.voice_state.voice.is_playing(): 352 | self.voice_state.voice.pause() 353 | await ctx.message.add_reaction("⏯") 354 | 355 | @commands.command(aliases=["p"]) 356 | @commands.cooldown(1, 5, commands.BucketType.user) 357 | async def play(self, ctx: commands.Context, *, search: str): 358 | """ 359 | `!play` __`Play song`__ 360 | **Aliases:** p 361 | 362 | **Usage:** !play 363 | 364 | **Examples:** 365 | `!play https://www.youtube.com/watch?v=dQw4w9WgXcQ` plays Never Gonna Give You Up 366 | """ 367 | 368 | if not re.match(r"https://(www\.youtube|soundcloud)\.com", search, flags=re.IGNORECASE): 369 | raise BadArgs("Only links allowed.") 370 | 371 | if not ctx.voice_client: 372 | destination = ctx.author.voice.channel 373 | 374 | if self.voice_state.voice: 375 | await self.voice_state.voice.move_to(destination) 376 | return 377 | 378 | self.voice_state.voice = await destination.connect() 379 | 380 | async with ctx.typing(): 381 | try: 382 | source = await YTDLSource.create_source(ctx, search, loop=self.bot.loop) 383 | except YTDLError as e: 384 | await ctx.send(f"An error occurred while processing this request: {e}", delete_after=5) 385 | else: 386 | song = Song(source) 387 | 388 | await self.voice_state.songs.put(song) 389 | await ctx.send(f"Enqueued {source}") 390 | 391 | @commands.command(aliases=["q"]) 392 | @commands.cooldown(1, 5, commands.BucketType.user) 393 | async def queue(self, ctx: commands.Context, *, page: int = 1): 394 | """ 395 | `!queue` __`Song queue`__ 396 | **Aliases:** q 397 | 398 | **Usage:** !queue 399 | 400 | **Examples:** 401 | `!queue` shows current queue 402 | """ 403 | 404 | if not self.voice_state.songs: 405 | raise BadArgs("Empty queue.") 406 | 407 | items_per_page = 10 408 | pages = math.ceil(len(self.voice_state.songs) / items_per_page) 409 | 410 | start = (page - 1) * items_per_page 411 | end = start + items_per_page 412 | 413 | queue = "" 414 | 415 | for i, song in enumerate(self.voice_state.songs[start:end], start=start): 416 | queue += f"`{i + 1}.` [**{song.source.title}**]({song.source.url})\n" 417 | 418 | embed = discord.Embed(description=f"**{len(self.voice_state.songs)} tracks:**\n\n{queue}", colour=random.randint(0, 0xFFFFFF)) 419 | embed.set_footer(text=f"Viewing page {page}/{pages}") 420 | await ctx.send(embed=embed) 421 | 422 | @commands.command(aliases=["r"]) 423 | @commands.cooldown(1, 5, commands.BucketType.user) 424 | async def remove(self, ctx: commands.Context, index: int): 425 | """ 426 | `!remove` __`Remove song`__ 427 | **Aliases:** r 428 | 429 | **Usage:** !remove 430 | 431 | **Examples:** 432 | `!remove 2` removes second song 433 | """ 434 | 435 | if not self.voice_state.songs: 436 | raise BadArgs("Empty queue.") 437 | 438 | self.voice_state.songs.remove(index - 1) 439 | await ctx.message.add_reaction("✅") 440 | 441 | @commands.command() 442 | @commands.cooldown(1, 5, commands.BucketType.user) 443 | async def resume(self, ctx: commands.Context): 444 | """ 445 | `!resume` __`Resume song`__ 446 | 447 | **Usage:** !resume 448 | 449 | **Examples:** 450 | `!resume` resumes current song 451 | """ 452 | 453 | if not self.voice_state.is_playing and self.voice_state.voice.is_paused(): 454 | self.voice_state.voice.resume() 455 | await ctx.message.add_reaction("⏯") 456 | 457 | @commands.command() 458 | @commands.cooldown(1, 5, commands.BucketType.user) 459 | async def shuffle(self, ctx: commands.Context): 460 | """ 461 | `!shuffle` __`Shuffle queue`__ 462 | 463 | **Usage:** !shuffle 464 | 465 | **Examples:** 466 | `!shuffle` shuffles queue 467 | """ 468 | 469 | if not self.voice_state.songs: 470 | raise BadArgs("Empty queue.") 471 | 472 | self.voice_state.songs.shuffle() 473 | await ctx.message.add_reaction("✅") 474 | 475 | @commands.command(aliases=["s"]) 476 | @commands.cooldown(1, 5, commands.BucketType.user) 477 | async def skip(self, ctx: commands.Context): 478 | """ 479 | `!skip` __`Skip song`__ 480 | **Aliases:** s 481 | 482 | **Usage:** !skip 483 | 484 | **Examples:** 485 | `!skip` skips current song 486 | 487 | **Note:** requires at least 3 votes. 488 | """ 489 | 490 | if not self.voice_state.is_playing: 491 | raise BadArgs("Not playing any music right now...") 492 | 493 | voter = ctx.message.author 494 | 495 | if voter == self.voice_state.current.requester: 496 | await ctx.message.add_reaction("⏭") 497 | self.voice_state.skip() 498 | elif voter.id not in self.voice_state.skip_votes: 499 | self.voice_state.skip_votes.add(voter.id) 500 | total_votes = len(self.voice_state.skip_votes) 501 | 502 | if total_votes >= 3 or voter.id in self.bot.owner_ids: 503 | await ctx.message.add_reaction("⏭") 504 | self.voice_state.skip() 505 | else: 506 | await ctx.send(f"Skip vote added, currently at **{total_votes}/3**") 507 | else: 508 | await ctx.send("You have already voted to skip this song.") 509 | 510 | @commands.command() 511 | @commands.cooldown(1, 5, commands.BucketType.user) 512 | async def stop(self, ctx: commands.Context): 513 | """ 514 | `!stop` __`Stop player`__ 515 | 516 | **Usage:** !stop 517 | 518 | **Examples:** 519 | `!stop` stops player 520 | """ 521 | 522 | self.voice_state.songs.clear() 523 | 524 | if not self.voice_state.is_playing: 525 | self.voice_state.voice.stop() 526 | await ctx.message.add_reaction("⏹") 527 | 528 | @commands.command() 529 | @commands.cooldown(1, 5, commands.BucketType.user) 530 | async def volume(self, ctx: commands.Context, *, volume: int): 531 | """ 532 | `!volume` __`Change volume`__ 533 | 534 | **Usage:** !volume 535 | 536 | **Examples:** 537 | `!volume 5` changes volume to 5% 538 | """ 539 | 540 | if not self.voice_state.is_playing: 541 | return await ctx.send("Nothing being played at the moment.") 542 | 543 | if 0 > volume > 200: 544 | return await ctx.send("Volume must be between 0 and 200") 545 | 546 | self.voice_state.volume = volume / 100 547 | await ctx.send(f"Volume of the player set to {volume}%") 548 | 549 | @play.before_invoke 550 | async def ensure_voice_state(self, ctx: commands.Context): 551 | if not ctx.author.voice or not ctx.author.voice.channel: 552 | raise BadArgs("You are not connected to any voice channel.") 553 | 554 | if ctx.voice_client: 555 | if ctx.voice_client.channel != ctx.author.voice.channel: 556 | raise BadArgs("Bot is already in a voice channel.") 557 | 558 | 559 | def setup(bot: commands.Bot) -> None: 560 | bot.add_cog(Music(bot)) 561 | -------------------------------------------------------------------------------- /util/canvas_handler.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | import time 5 | from datetime import datetime, timedelta 6 | from typing import Optional 7 | 8 | import discord 9 | from bs4 import BeautifulSoup 10 | from canvasapi.canvas import Canvas 11 | from canvasapi.course import Course 12 | from canvasapi.module import Module, ModuleItem 13 | from canvasapi.paginated_list import PaginatedList 14 | from dateutil.parser import isoparse 15 | 16 | from util import create_file 17 | from util.canvas_api_extension import get_course_stream, get_course_url, get_staff_ids 18 | 19 | # Stores course modules and channels that are live tracking courses 20 | # Do *not* put a slash at the end of this path 21 | COURSES_DIRECTORY = "./data/courses" 22 | 23 | 24 | class CanvasHandler(Canvas): 25 | """ 26 | Represents a handler for Canvas information for a guild 27 | 28 | Attributes 29 | ---------- 30 | courses : `list[canvasapi.Course]` 31 | Courses tracked in guild mode. 32 | 33 | guild : `discord.Guild` 34 | Guild assigned to this handler. 35 | 36 | timings : `dict[str, str]` 37 | Contains course and its last announcement date and time. 38 | 39 | due_week : `dict[str, list[int]]` 40 | Contains course and assignment IDs due in less than a week. 41 | 42 | due_day : `dict[str, list[int]]` 43 | Contains course and assignment IDs due in less than a day. 44 | """ 45 | 46 | def __init__(self, api_url: str, api_key: str, guild: discord.Guild): 47 | """ 48 | Parameters 49 | ---------- 50 | api_url : `str` 51 | Base URL of the Canvas instance's API 52 | 53 | api_key : `str` 54 | API key to authenticate requests with 55 | 56 | guild : `discord.Guild` 57 | Guild to assign to this handler 58 | """ 59 | 60 | super().__init__(api_url, api_key) 61 | self._courses: list[Course] = [] 62 | self._guild = guild 63 | self._live_channels: list[discord.TextChannel] = [] 64 | self._timings: dict[str, str] = {} 65 | self._due_week: dict[str, list[int]] = {} 66 | self._due_day: dict[str, list[int]] = {} 67 | 68 | @property 69 | def courses(self) -> list[Course]: 70 | return self._courses 71 | 72 | @courses.setter 73 | def courses(self, courses: list[Course]) -> None: 74 | self._courses = courses 75 | 76 | @property 77 | def guild(self) -> discord.Guild: 78 | return self._guild 79 | 80 | @guild.setter 81 | def guild(self, guild: discord.Guild) -> None: 82 | self._guild = guild 83 | 84 | @property 85 | def live_channels(self) -> list[discord.TextChannel]: 86 | return self._live_channels 87 | 88 | @live_channels.setter 89 | def live_channels(self, live_channels: list[discord.TextChannel]) -> None: 90 | self._live_channels = live_channels 91 | 92 | @property 93 | def timings(self) -> dict[str, str]: 94 | return self._timings 95 | 96 | @timings.setter 97 | def timings(self, timings: dict[str, str]) -> None: 98 | self._timings = timings 99 | 100 | @property 101 | def due_week(self) -> dict[str, list[int]]: 102 | return self._due_week 103 | 104 | @due_week.setter 105 | def due_week(self, due_week: dict[str, list[int]]) -> None: 106 | self._due_week = due_week 107 | 108 | @property 109 | def due_day(self) -> dict[str, list[int]]: 110 | return self._due_day 111 | 112 | @due_day.setter 113 | def due_day(self, due_day: dict[str, list[int]]) -> None: 114 | self._due_day = due_day 115 | 116 | def _ids_converter(self, ids: tuple[str]) -> set[int]: 117 | """ 118 | Converts tuple of string to set of int, removing duplicates. Each string 119 | must be parsable to an int. 120 | 121 | Parameters 122 | ---------- 123 | ids : `tuple[str]` 124 | Tuple of string ids 125 | 126 | Returns 127 | ------- 128 | `Set[int]` 129 | List of int ids 130 | """ 131 | 132 | return set(int(i) for i in ids) 133 | 134 | def track_course(self, course_ids_str: tuple[str], get_unpublished_modules: bool) -> None: 135 | """ 136 | Cause this CanvasHandler to start tracking the courses with given IDs. 137 | 138 | For each course, if the bot is tracking the course for the first time, 139 | the course's modules will be downloaded from Canvas and saved in the course's 140 | directory (located in /data/courses/). If `get_unpublished_modules` is `True`, and 141 | we have access to unpublished modules for the course, then we will save both published and 142 | unpublished modules to file. Otherwise, we will only save published modules. 143 | 144 | Parameters 145 | ---------- 146 | course_ids_str : `tuple[str]` 147 | Tuple of course ids 148 | 149 | get_unpublished_modules: `bool` 150 | True if we should attempt to store unpublished modules for the courses in `course_ids_str`; 151 | False otherwise 152 | """ 153 | 154 | course_ids = self._ids_converter(course_ids_str) 155 | c_ids = {c.id for c in self.courses} 156 | 157 | new_courses = tuple(self.get_course(i) for i in course_ids if i not in c_ids) 158 | self.courses.extend(new_courses) 159 | 160 | for c in course_ids_str: 161 | if c not in self.timings: 162 | self.timings[c] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 163 | 164 | if c not in self.due_week: 165 | self.due_week[c] = [] 166 | 167 | if c not in self.due_day: 168 | self.due_day[c] = [] 169 | 170 | for c in new_courses: 171 | modules_file = f"{COURSES_DIRECTORY}/{c.id}/modules.txt" 172 | watchers_file = f"{COURSES_DIRECTORY}/{c.id}/watchers.txt" 173 | self.store_channels_in_file(self.live_channels, watchers_file) 174 | 175 | if self.live_channels: 176 | create_file.create_file_if_not_exists(modules_file) 177 | 178 | # Here, we will only download modules if modules_file is empty. 179 | if os.stat(modules_file).st_size == 0: 180 | self.download_modules(c, get_unpublished_modules) 181 | 182 | def download_modules(self, course: Course, incl_unpublished: bool) -> None: 183 | """ 184 | Download all modules for a Canvas course, storing each module's id 185 | in `{COURSES_DIRECTORY}/{course.id}/modules.txt`. Includes unpublished modules if 186 | `incl_unpublished` is `True` and we have access to unpublished modules for the course. 187 | 188 | Assumption: {COURSES_DIRECTORY}/{course.id}/modules.txt exists. 189 | """ 190 | 191 | modules_file = f"{COURSES_DIRECTORY}/{course.id}/modules.txt" 192 | 193 | with open(modules_file, "w") as m: 194 | for module in self.get_all_modules(course, incl_unpublished): 195 | m.write(f"{str(module.id)}\n") 196 | 197 | @staticmethod 198 | def get_all_modules(course: Course, incl_unpublished: bool) -> list[Module | ModuleItem]: 199 | """ 200 | Returns a list of all modules for the given course. Includes unpublished modules if 201 | `incl_unpublished` is `True` and we have access to unpublished modules for the course. 202 | """ 203 | 204 | all_modules = [] 205 | 206 | for module in course.get_modules(): 207 | # If module does not have the "published" attribute, then the host of the bot does 208 | # not have access to unpublished modules. Reference: https://canvas.instructure.com/doc/api/modules.html 209 | if incl_unpublished or not hasattr(module, "published") or module.published: 210 | all_modules.append(module) 211 | 212 | for item in module.get_module_items(): 213 | # See comment about the "published" attribute above. 214 | if incl_unpublished or not hasattr(item, "published") or item.published: 215 | all_modules.append(item) 216 | 217 | return all_modules 218 | 219 | def store_channels_in_file(self, text_channels: list[discord.TextChannel], file_path: str) -> None: 220 | """ 221 | For each text channel provided, we add its id to the file with given path if the file does 222 | not already contain the id. 223 | """ 224 | 225 | if text_channels: 226 | create_file.create_file_if_not_exists(file_path) 227 | 228 | with open(file_path, "r") as f: 229 | existing_ids = f.readlines() 230 | 231 | ids_to_add = set(map(lambda channel: str(channel.id) + "\n", text_channels)) 232 | 233 | with open(file_path, "w") as f: 234 | for channel_id in existing_ids: 235 | if channel_id in ids_to_add: 236 | ids_to_add.remove(channel_id) 237 | 238 | f.write(channel_id) 239 | 240 | for channel_id in ids_to_add: 241 | f.write(channel_id) 242 | 243 | def untrack_course(self, course_ids_str: tuple[str]) -> None: 244 | """ 245 | Cause this CanvasHandler to stop tracking the courses with given IDs. 246 | 247 | Parameters 248 | ---------- 249 | course_ids_str : `tuple[str, ...]` 250 | Tuple of course ids 251 | """ 252 | 253 | course_ids = self._ids_converter(course_ids_str) 254 | c_ids = {c.id: c for c in self.courses} 255 | 256 | ids_of_removed_courses = [] 257 | 258 | for i in filter(c_ids.__contains__, course_ids): 259 | self.courses.remove(c_ids[i]) 260 | ids_of_removed_courses.append(i) 261 | 262 | for c in course_ids_str: 263 | if c in self.timings: 264 | del self.timings[c] 265 | 266 | if c in self.due_week: 267 | del self.due_week[c] 268 | 269 | if c in self.due_day: 270 | del self.due_day[c] 271 | 272 | for i in ids_of_removed_courses: 273 | watchers_file = f"{COURSES_DIRECTORY}/{i}/watchers.txt" 274 | self.delete_channels_from_file(self.live_channels, watchers_file) 275 | 276 | # If there are no more channels watching the course, we should delete that course's directory. 277 | if os.stat(watchers_file).st_size == 0: 278 | shutil.rmtree(f"{COURSES_DIRECTORY}/{i}") 279 | 280 | def delete_channels_from_file(self, text_channels: list[discord.TextChannel], file_path: str) -> None: 281 | """ 282 | For each text channel provided, we remove its id from the file with given path 283 | if the id is contained in the file. 284 | """ 285 | 286 | create_file.create_file_if_not_exists(file_path) 287 | 288 | with open(file_path, "r") as f: 289 | channel_ids = f.readlines() 290 | 291 | ids_to_remove = set(map(lambda channel: str(channel.id) + "\n", text_channels)) 292 | 293 | with open(file_path, "w") as f: 294 | for channel_id in channel_ids: 295 | if channel_id not in ids_to_remove: 296 | f.write(channel_id) 297 | 298 | def get_course_stream_ch(self, since: Optional[str], course_ids_str: tuple[str, ...], base_url: str, access_token: str) -> list[list[str]]: 299 | """ 300 | Gets announcements for course(s) 301 | 302 | Parameters 303 | ---------- 304 | since : `None or str` 305 | Date/Time from announcement creation to now. If None, then all announcements are returned, 306 | regardless of date of creation. 307 | 308 | course_ids_str : `tuple[str, ...]` 309 | Tuple of course ids. If this parameter is an empty tuple, then this function gets announcements 310 | for *all* courses being tracked by this CanvasHandler. 311 | 312 | base_url : `str` 313 | Base URL of the Canvas instance's API 314 | 315 | access_token : `str` 316 | API key to authenticate requests with 317 | 318 | Returns 319 | ------- 320 | `list[list[str]]` 321 | List of announcement data to be formatted and sent as embeds 322 | """ 323 | 324 | course_ids = self._ids_converter(course_ids_str) 325 | course_streams = tuple(get_course_stream(c.id, base_url, access_token) for c in self.courses if (not course_ids) or c.id in course_ids) 326 | data_list = [] 327 | 328 | for stream in course_streams: 329 | for item in filter(lambda i: i["type"] == "Conversation" and i["participant_count"] == 2, iter(stream)): 330 | messages = item.get("latest_messages") 331 | 332 | # Idea behind this hack: 333 | # If we assume that any message from any course staff is an announcement, then 334 | # we can just treat all such messages as announcements. This assumption is safe 335 | # because a TA runs this bot, and TAs are not going to be sending PMs to each 336 | # other through Canvas. 337 | # 338 | # Below are the conditions necessary to consider a message as an announcement: 339 | # 1. The message cannot have any replies (no one replies to announcements). 340 | # 2. The number of participants is 2. This is checked in the filter condition above. 341 | # 3. The message is authored by a professor or a TA. 342 | 343 | if messages and len(messages) == 1: 344 | course = self.get_course(item["course_id"]) 345 | 346 | if messages[0].get("author_id") in get_staff_ids(course): 347 | course_url = get_course_url(course.id, base_url) 348 | title = "Announcement: " + item["title"] 349 | short_desc = "\n".join(item["latest_messages"][0]["message"].split("\n")[:4]) 350 | ctime_iso = item["created_at"] 351 | 352 | if ctime_iso is None: 353 | ctime_text = "No info" 354 | else: 355 | time_shift = timedelta(seconds=-time.timezone) 356 | ctime_iso_parsed = (isoparse(ctime_iso) + time_shift).replace(tzinfo=None) 357 | 358 | # A timedelta representing how long ago the conversation was created. 359 | now = datetime.now() 360 | ctime_timedelta = now - ctime_iso_parsed 361 | 362 | if since and ctime_timedelta >= self._make_timedelta(since, now): 363 | break 364 | 365 | ctime_text = ctime_iso_parsed.strftime("%Y-%m-%d %H:%M:%S") 366 | 367 | data_list.append([course.name, course_url, title, item["html_url"], short_desc, ctime_text, course.id]) 368 | 369 | return data_list 370 | 371 | def get_assignments(self, due: Optional[str], course_ids_str: tuple[str, ...], base_url: str) -> list[list[str]]: 372 | """ 373 | Gets assignments for course(s) 374 | 375 | Parameters 376 | ---------- 377 | due : `None or str` 378 | Date/Time from due date of assignments 379 | 380 | course_ids_str : `tuple[str, ...]` 381 | Tuple of course ids 382 | 383 | base_url : `str` 384 | Base URL of the Canvas instance's API 385 | 386 | Returns 387 | ------- 388 | `list[list[str]]` 389 | List of assignment data to be formatted and sent as embeds 390 | """ 391 | 392 | course_ids = self._ids_converter(course_ids_str) 393 | courses_assignments = {c: c.get_assignments() for c in self.courses if not course_ids or c.id in course_ids} 394 | 395 | return self._get_assignment_data(due, courses_assignments, base_url) 396 | 397 | def _get_assignment_data(self, due: Optional[str], courses_assignments: dict[Course, PaginatedList], base_url: str) -> list[list[str]]: 398 | """ 399 | Formats all courses assignments as separate assignments 400 | 401 | Parameters 402 | ---------- 403 | due : `None or str` 404 | Date/Time from due date of assignments 405 | 406 | courses_assignments : `dict[Course, PaginatedList of Assignments]` 407 | List of courses and their assignments 408 | 409 | base_url : `str` 410 | Base URL of the Canvas instance's API 411 | 412 | Returns 413 | ------- 414 | `list[list[str]]` 415 | List of assignment data to be formatted and sent as embeds 416 | """ 417 | 418 | data_list = [] 419 | 420 | for course, assignments in courses_assignments.items(): 421 | course_name = course.name 422 | course_url = get_course_url(course.id, base_url) 423 | 424 | for assignment in filter(lambda asgn: asgn.published, assignments): 425 | ass_id = assignment.__getattribute__("id") 426 | title = "Assignment: " + assignment.__getattribute__("name") 427 | url = assignment.__getattribute__("html_url") 428 | desc_html = assignment.__getattribute__("description") or "No description" 429 | 430 | short_desc = "\n".join(BeautifulSoup(desc_html, "html.parser").get_text().split("\n")[:4]) 431 | 432 | ctime_iso = assignment.__getattribute__("created_at") 433 | dtime_iso = assignment.__getattribute__("due_at") 434 | 435 | time_shift = timedelta(seconds=-time.timezone) 436 | 437 | if ctime_iso is None: 438 | ctime_text = "No info" 439 | else: 440 | ctime_text = (isoparse(ctime_iso) + time_shift).strftime("%Y-%m-%d %H:%M:%S") 441 | 442 | if dtime_iso is None: 443 | dtime_text = "No info" 444 | else: 445 | now = datetime.now() 446 | dtime_iso_parsed = (isoparse(dtime_iso) + time_shift).replace(tzinfo=None) 447 | dtime_timedelta = dtime_iso_parsed - now 448 | 449 | if dtime_timedelta < timedelta(0) or (due and dtime_timedelta > self._make_timedelta(due, now)): 450 | continue 451 | 452 | dtime_text = dtime_iso_parsed.strftime("%Y-%m-%d %H:%M:%S") 453 | 454 | data_list.append([course_name, course_url, title, url, short_desc, ctime_text, dtime_text, course.id, ass_id]) 455 | 456 | return data_list 457 | 458 | def _make_timedelta(self, till_str: str, now: datetime) -> timedelta: 459 | """ 460 | Makes a datetime.timedelta 461 | 462 | Parameters 463 | ---------- 464 | till_str : `str` 465 | Date/Time from due date of assignments 466 | 467 | now: `datetime` 468 | Current time 469 | 470 | Returns 471 | ------- 472 | `datetime.timedelta` 473 | Time delta between till and now 474 | """ 475 | 476 | till = re.split(r"[-:]", till_str) 477 | 478 | if till[1] in ["hour", "day", "week"]: 479 | return abs(timedelta(**{till[1] + "s": float(till[0])})) 480 | elif till[1] in ["month", "year"]: 481 | return abs(timedelta(days=(30 if till[1] == "month" else 365) * float(till[0]))) 482 | 483 | year, month, day = int(till[0]), int(till[1]), int(till[2]) 484 | 485 | if len(till) == 3: 486 | return abs(datetime(year, month, day) - now) 487 | 488 | hour, minute, second = int(till[3]), int(till[4]), int(till[5]) 489 | return abs(datetime(year, month, day, hour, minute, second) - now) 490 | 491 | def get_course_names(self, url: str) -> list[list[str]]: 492 | """ 493 | Gives a list of tracked courses and their urls 494 | 495 | Parameters 496 | ---------- 497 | url : `str` 498 | Base URL of the Canvas instance's API 499 | 500 | Returns 501 | ------- 502 | `list[list[str]]` 503 | List of course names and their page urls 504 | """ 505 | 506 | return [[c.name, get_course_url(c.id, url)] for c in self.courses] 507 | -------------------------------------------------------------------------------- /cogs/commands.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | import random 3 | import re 4 | import string 5 | from datetime import datetime, timedelta, timezone 6 | from io import BytesIO 7 | from os.path import isfile 8 | from urllib import parse 9 | 10 | import discord 11 | import pytz 12 | import requests 13 | import requests.models 14 | from discord.ext import commands 15 | from discord.ext.commands import BadArgument, MemberConverter 16 | 17 | from util.badargs import BadArgs 18 | from util.create_file import create_file_if_not_exists 19 | from util.custom_role_converter import CustomRoleConverter 20 | from util.discord_handler import DiscordHandler 21 | from util.json import read_json, write_json 22 | 23 | POLL_FILE = "data/poll.json" 24 | 25 | 26 | # This is a huge hack but it technically works 27 | def _urlencode(*args, **kwargs) -> str: 28 | kwargs.update(quote_via=parse.quote) 29 | return parse.urlencode(*args, **kwargs) 30 | 31 | 32 | requests.models.urlencode = _urlencode 33 | 34 | 35 | # ################### COMMANDS ################### # 36 | 37 | 38 | class Commands(commands.Cog): 39 | def __init__(self, bot: commands.Bot): 40 | self.bot = bot 41 | self.add_instructor_role_counter = 0 42 | self.bot.d_handler = DiscordHandler() 43 | self.role_converter = CustomRoleConverter() 44 | 45 | if not isfile(POLL_FILE): 46 | create_file_if_not_exists(POLL_FILE) 47 | write_json({}, POLL_FILE) 48 | 49 | self.poll_dict = read_json(POLL_FILE) 50 | 51 | for channel in filter(lambda ch: not self.bot.get_channel(int(ch)), list(self.poll_dict)): 52 | del self.poll_dict[channel] 53 | 54 | for channel in (c for g in self.bot.guilds for c in g.text_channels if str(c.id) not in self.poll_dict): 55 | self.poll_dict.update({str(channel.id): ""}) 56 | 57 | write_json(self.poll_dict, POLL_FILE) 58 | 59 | @commands.command() 60 | @commands.cooldown(1, 5, commands.BucketType.user) 61 | async def emojify(self, ctx: commands.Context): 62 | """ 63 | `!emojify` __`Emoji text generator`__ 64 | 65 | **Usage:** !emojify 66 | 67 | **Examples:** 68 | `!emojify hello` prints "hello" with emoji 69 | `!emojify b` prints b with emoji" 70 | """ 71 | 72 | mapping = {"A": "🇦", "B": "🅱", "C": "🇨", "D": "🇩", "E": "🇪", "F": "🇫", "G": "🇬", "H": "🇭", "I": "🇮", "J": "🇯", "K": "🇰", "L": "🇱", "M": "🇲", "N": "🇳", "O": "🇴", "P": "🇵", "Q": "🇶", "R": "🇷", "S": "🇸", "T": "🇹", "U": "🇺", "V": "🇻", "W": "🇼", "X": "🇽", "Y": "🇾", "Z": "🇿", "0": "0️⃣", "1": "1️⃣", "2": "2️⃣", "3": "3️⃣", "4": "4️⃣", "5": "5️⃣", "6": "6️⃣", "7": "7️⃣", "8": "8️⃣", "9": "9️⃣"} 73 | 74 | text = ctx.message.content[9:].upper() 75 | output = "".join(mapping[i] + (" " if i in string.ascii_uppercase else "") if i in mapping else i for i in text) 76 | 77 | await ctx.send(output) 78 | 79 | @commands.command() 80 | @commands.cooldown(1, 5, commands.BucketType.user) 81 | async def joinrole(self, ctx: commands.Context, *arg: str): 82 | """ 83 | `!joinrole` __`Adds a role to yourself`__ 84 | 85 | **Usage:** !joinrole [role name] 86 | 87 | **Examples:** 88 | `!joinrole Study Group` adds the Study Group role to yourself 89 | 90 | **Valid Roles:** 91 | Looking for Partners, Study Group, He/Him/His, She/Her/Hers, They/Them/Theirs, Ze/Zir/Zirs, notify 92 | """ 93 | 94 | await ctx.message.delete() 95 | 96 | # case where role name is space separated 97 | name = " ".join(arg) 98 | 99 | # Display help if given no argument 100 | if not name: 101 | raise BadArgs("", show_help=True) 102 | 103 | # make sure that you can't add roles like "prof" or "ta" 104 | valid_roles = ["Looking for Partners", "Study Group", "He/Him/His", "She/Her/Hers", "They/Them/Theirs", "Ze/Zir/Zirs", "notify"] 105 | 106 | # Grab the role that the user selected 107 | # Converters! this also makes aliases unnecessary 108 | try: 109 | role = await self.role_converter.convert(ctx, name) 110 | except commands.RoleNotFound: 111 | raise BadArgs("You can't add that role!", show_help=True) 112 | 113 | # Ensure that the author does not already have the role 114 | if role in ctx.author.roles: 115 | raise BadArgs("you already have that role!") 116 | 117 | # Special handling for roles that exist but can not be selected by a student 118 | if role.name not in valid_roles: 119 | self.add_instructor_role_counter += 1 120 | 121 | if self.add_instructor_role_counter > 5: 122 | if self.add_instructor_role_counter == 42: 123 | if random.random() > 0.999: 124 | raise BadArgs("Congratulations, you found the secret message. IDEK how you did it, but good job. Still can't add the instructor role though. Bummer, I know.") 125 | elif self.add_instructor_role_counter == 69: 126 | if random.random() > 0.9999: 127 | raise BadArgs("nice.") 128 | raise BadArgs("You can't add that role, but if you try again, maybe something different will happen on the 42nd attempt") 129 | else: 130 | raise BadArgs("you cannot add an instructor/invalid role!", show_help=True) 131 | 132 | await ctx.author.add_roles(role) 133 | await ctx.send("role added!", delete_after=5) 134 | 135 | @commands.command() 136 | @commands.cooldown(1, 10, commands.BucketType.user) 137 | async def latex(self, ctx: commands.Context, *args: str): 138 | """ 139 | `!latex` __`LaTeX equation render`__ 140 | 141 | **Usage:** !latex 142 | 143 | **Examples:** 144 | `!latex $\\frac{a}{b}$` [img] 145 | """ 146 | 147 | formula = " ".join(args).strip("\n ") 148 | 149 | if sm := re.match(r"```(latex|tex)", formula): 150 | formula = formula[6 if sm.group(1) == "tex" else 8:].strip("`") 151 | 152 | data = requests.get(f"https://latex.codecogs.com/png.image?\dpi{{300}} \\bg_white {formula}") 153 | 154 | await ctx.send(file=discord.File(BytesIO(data.content), filename="latex.png")) 155 | 156 | @commands.command() 157 | @commands.cooldown(1, 5, commands.BucketType.user) 158 | async def leaverole(self, ctx: commands.Context, *arg: str): 159 | """ 160 | `!leaverole` __`Removes an existing role from yourself`__ 161 | 162 | **Usage:** !leave [role name] 163 | 164 | **Examples:** 165 | `!leaverole Study Group` removes the Study Group role from yourself 166 | """ 167 | 168 | await ctx.message.delete() 169 | 170 | # case where role name is space separated 171 | name = " ".join(arg).lower() 172 | 173 | if not name: 174 | raise BadArgs("", show_help=True) 175 | 176 | try: 177 | role = await self.role_converter.convert(ctx, name) 178 | except commands.RoleNotFound: 179 | raise BadArgs("That role doesn't exist!", show_help=True) 180 | 181 | if role not in ctx.author.roles: 182 | raise BadArgs("you don't have that role!") 183 | 184 | await ctx.author.remove_roles(role) 185 | await ctx.send("role removed!", delete_after=5) 186 | 187 | @commands.command() 188 | @commands.cooldown(1, 5, commands.BucketType.user) 189 | async def poll(self, ctx: commands.Context): 190 | """ 191 | `!poll` __`Poll generator`__ 192 | 193 | **Usage:** !poll |