diff --git a/.gitignore b/.gitignore index aa6f0fd..7287d05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ +.env + venv/ **/__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5cdb26f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.12-alpine +WORKDIR /app +COPY . . +RUN pip install -r requirements.txt +CMD ["python", "master-bot"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..31c1657 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ + +# Hallo Informatik Utility Bot + +``` +https://discord.com/oauth2/authorize?client_id=1287513150614143017&permissions=8&integration_type=0&scope=bot +``` \ No newline at end of file diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..54c8f3a --- /dev/null +++ b/config.toml @@ -0,0 +1,20 @@ +[channels] +forum = 1283905750263005286 + +[curricula] +# Masterstudium Logic and Computation +066931 = 1283906091759046729 +# Masterstudium Visual Computing +066932 = 1283906123191156836 +# Masterstudium Media and Human-Centered Computing +066935 = 1283906160398831621 +# Masterstudium Medizinische Informatik +066936 = 1283906227599966220 +# Masterstudium Software Engineering & Internet Computing +066937 = 1283906271979896832 +# Masterstudium Technische Informatik +066938 = 1283906438909001808 +# Masterstudium Wirtschaftsinformatik +066926 = 1283907195125104640 +# Masterstudium Data Science +066645 = 1283907243657400402 diff --git a/debug.toml b/debug.toml new file mode 100644 index 0000000..57b7107 --- /dev/null +++ b/debug.toml @@ -0,0 +1,20 @@ +[channels] +forum = 1287512195025993890 + +[curricula] +# Masterstudium Logic and Computation +066931 = 1287512455475630203 +# Masterstudium Visual Computing +066932 = 1287512474672828529 +# Masterstudium Media and Human-Centered Computing +066935 = 1287512500216008837 +# Masterstudium Medizinische Informatik +066936 = 1287512512324964402 +# Masterstudium Software Engineering & Internet Computing +066937 = 1287512525138694164 +# Masterstudium Technische Informatik +066938 = 1287512538187304980 +# Masterstudium Wirtschaftsinformatik +066926 = 1287512548714741891 +# Masterstudium Data Science +066645 = 1287512561448783923 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d81cd03 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,3 @@ +services: + bot: + build: . \ No newline at end of file diff --git a/master-bot/__main__.py b/master-bot/__main__.py index e69de29..0065d57 100644 --- a/master-bot/__main__.py +++ b/master-bot/__main__.py @@ -0,0 +1,31 @@ + +import discord_api +import tiss + +from dotenv import load_dotenv +import os + +try: import tomllib +except ModuleNotFoundError: import pip._vendor.tomli as tomllib + + +load_dotenv() + +if __name__ == '__main__': + # course_nr = tiss.TISS.get_code_from_any('https://tiss.tuwien.ac.at/course/courseDetails.xhtml?dswid=7270&dsrid=953&courseNr=186140&semester=2024W') + # lva = tiss.TISS.get_lva(course_nr) + + # print(lva) + # print(lva.curricula) + + config_file = os.getenv("CONFIG_FILE", "config.toml") + discord_token = os.getenv("DISCORD_TOKEN") + + if discord_token is None: + print("[ERROR] Please set the DISCORD_TOKEN environment variable") + exit() + + with open(config_file, "rb") as f: + config = tomllib.load(f) + + discord_api.run(discord_token, config) \ No newline at end of file diff --git a/master-bot/discord_api.py b/master-bot/discord_api.py new file mode 100644 index 0000000..113b121 --- /dev/null +++ b/master-bot/discord_api.py @@ -0,0 +1,99 @@ +import nextcord +from nextcord.ext import commands, application_checks + +import tiss + +cache = {} + +def run(token: str, config: dict): + bot = commands.Bot() + + def get_channel(id): + global cache + if id not in cache: + cache[id] = bot.get_channel(id) + + return cache[id] + + + @bot.slash_command(description="Manager master channels") + @application_checks.has_guild_permissions(administrator=True) + async def master( + interaction: nextcord.Interaction, + ): + pass + + @master.subcommand(description="Sync Master LVA with Channel") + @application_checks.has_guild_permissions(administrator=True) + async def sync( + interaction: nextcord.Interaction, + link: str = nextcord.SlashOption(description="Enter TISS link") + ): + lva_code = tiss.TISS.get_code_from_any(link) + + if lva_code == None: + await interaction.send("Invalid TISS link", ephemeral=True) + return + + try: + lva = tiss.TISS.get_lva(lva_code) + except: + await interaction.send("Could not fetch TISS data", ephemeral=True) + return + + if lva is None: + await interaction.send("Could not fetch TISS data", ephemeral=True) + return + + forum = get_channel(config["channels"]["forum"]) + + # Get forum data + # Get tags + tags_available = forum.available_tags + thread_tags = [] + + config_curricula = config["curricula"] + # This should work? but doesn't somehow + # for curriculum in lva.curricula: + # if curriculum in config_curricula: + # print(f"Curriculum found: {curriculum}") + # try: + # print(f"Looking for id {config_curricula[curriculum]}") + # tags.append(forum.get_tag(id = config_curricula[curriculum])) + # print("Tag found") + # except: + # print(f"Tag not found for curriculum {curriculum}") + # else: + # print(f"Curriculum not found: {curriculum}") + + for curriculum in config_curricula: + if curriculum in lva.curricula: + # Find the tag in the available tags + tag = next(x for x in tags_available if x.id == config_curricula[curriculum]) + if tag is not None: + thread_tags.append(tag) + + # Get name + thread_name = f"{lva.type} {lva.name} ({lva.id_pretty()})" + + # Get content + thread_content = tiss.TISS.get_anonymous_tiss_link(lva.id) + + # Determine if the thread already exists + for thread in forum.threads: + # Does the thread name contain the LVA code? + if lva.id_pretty() in thread.name: + await thread.edit(name = thread_name, applied_tags = thread_tags) + + message = await thread.fetch_message(thread.id) + await message.edit(content = thread_content) + + await interaction.send(f"Existing forum thread has been synced {thread.jump_url}", ephemeral=False) + return + + # If it doesn't exist, create a new thread + thread = await forum.create_thread(name = thread_name, content = thread_content, applied_tags = thread_tags) + reply_message = f"Forum channel created! {thread.jump_url}{" No tags were found, are you sure this was a Master LVA?" if len(thread_tags) == 0 else ""}" + await interaction.send(reply_message, ephemeral=False) + + bot.run(token) diff --git a/master-bot/tiss.py b/master-bot/tiss.py new file mode 100644 index 0000000..3f7e620 --- /dev/null +++ b/master-bot/tiss.py @@ -0,0 +1,98 @@ +import requests + +from urllib.parse import urlparse, parse_qs +import re + +from bs4 import BeautifulSoup, NavigableString +import random + + +session = requests.Session() + +session.headers.update({ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0' +}) + + +class LVA: + def __init__(self, id: str, name: str, ects: float, curricula: list[str], type: str): + self.id = id + self.name = name + self.ects = ects + self.curricula = curricula + self.type = type + + def id_pretty(self): + # format 123123 -> 123.123 + return f'{self.id[:3]}.{self.id[3:]}' + + def __str__(self): + return f'{self.name} ({self.id}) - {self.ects} ECTS' + + def __repr__(self): + return f'{self.name} ({self.id})' + +def get_direct_text_clean(element): + text = ''.join(child for child in element.children if isinstance(child, NavigableString)) + return re.sub(r'\s+', ' ', text).strip() + + +def gen_request_tokens(): + request_token = str(random.randint(0, 999)) + window_id = str(random.randint(0, 999)) + + session.cookies.set(f"dsrwid-{request_token}", window_id) + return (request_token, window_id) + + +class TISS: + @staticmethod + def get_anonymous_tiss_link(code: str): + return f"https://tiss.tuwien.ac.at/course/courseDetails.xhtml?courseNr={code}" + + @staticmethod + def get_lva(code: str): + tokens = gen_request_tokens() + + res = session.get(f"https://tiss.tuwien.ac.at/course/courseDetails.xhtml?courseNr={code}&dsrid={tokens[0]}&dswid={tokens[1]}") + soup = BeautifulSoup(res.text, 'html.parser') + + course_name = get_direct_text_clean(soup.select('#contentInner > h1')[0]) + + subheader = soup.select('#subHeader')[0].text + ects = float(re.search(r'(\d+\.\d+)EC', subheader).group(1)) + # extract "VU" from "2024W, VU, 2.0h, 3.0EC" + type = subheader.split(",")[1].strip() + + + table = soup.select('th[aria-label="Studienkennzahl"]')[0].parent.parent.parent + curricula = [re.sub("\D", "", tr.find("td").text) for tr in table.find("tbody").find_all("tr")] + + lva = LVA(code, course_name, ects, curricula, type) + + return lva + + + @staticmethod + def get_code_from_any(any: str): + # Example: https://tiss.tuwien.ac.at/course/courseDetails.xhtml?dswid=5304&dsrid=381&courseNr=180771&semester=2024W + any = any.strip() + url = urlparse(any) + + if all([url.scheme, url.netloc]): + # Yes, this was an URL + params = parse_qs(url.query) + + course_nr = params.get('courseNr', [None])[0] + return course_nr + else: + if len(any) == 6: + return any + + match = re.search(r'\w{3}\.\w{3}', any) + + if match: + return match.group(0).replace(".", "") + else: + return None + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..36d4cd3 Binary files /dev/null and b/requirements.txt differ