├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-.md │ └── feature_request.md └── workflows │ ├── deploy-vm.yml │ └── test-pull-requests.yml ├── .gitignore ├── CONTRIBUTING.md ├── DOCUMENTATION.md ├── Dockerfile ├── INSTALLATION.md ├── LICENSE ├── Makefile ├── README.md ├── commits └── repository_links_commits.json ├── docker-compose.yml ├── logs └── discord_bot.log ├── pyproject.toml ├── requirements.txt ├── src ├── DiscordBot.py ├── GitHubUtilities.py ├── JobsUtilities.py └── __init__.py └── tests ├── __init__.py ├── test_GitHub.py └── test_Internship.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [DavidSalazar123, colorstackatuw] 2 | custom: [Zelle: colorstackatuw@cs.wisc.edu] 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Bug:' 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | Prove a step by step guide on how to reproduce this problem 16 | 1. 17 | 2. 18 | 3. 19 | 4. 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Desktop (please complete the following information):** 28 | - Oracle Cloud [ubuntu] 29 | - OS: [e.g. iOS] 30 | - Browser [e.g. chrome, safari] 31 | - Version [e.g. 22] 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/deploy-vm.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Oracle Cloud VM 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**/*.md' 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: SSH into Oracle VM 15 | uses: appleboy/ssh-action@master 16 | with: 17 | host: ${{ secrets.VM_HOST }} 18 | username: ${{ secrets.VM_USERNAME }} 19 | key: ${{ secrets.VM_SSH_KEY }} 20 | script: | 21 | cd ColorStack-Discord-Bot/ 22 | echo "Signed into VM" 23 | git pull 24 | echo "Pulled latest changes from GitHub" 25 | docker-compose down 26 | echo "Shutdown Docker Containers" 27 | docker-compose up -d --build 28 | echo "Started Docker Container!" 29 | env: 30 | DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }} 31 | CHANNEL_ID: ${{ secrets.CHANNEL_ID }} 32 | GIT_TOKEN: ${{ secrets.GIT_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test-pull-requests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests on Pull Request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | run-tests: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2 14 | 15 | - name: Install Docker Compose 16 | run: | 17 | if ! command -v docker-compose &> /dev/null; then 18 | echo "docker-compose could not be found, installing..." 19 | sudo apt-get update && sudo apt-get install -y docker-compose 20 | else 21 | echo "docker-compose is already installed." 22 | fi 23 | 24 | - name: Build Docker Image 25 | run: docker-compose build 26 | 27 | - name: Run Tests 28 | run: docker-compose run --rm main pytest /app/tests 29 | 30 | - name: Shutdown Docker Containers 31 | run: docker-compose down 32 | 33 | - name: Cleanup Unused Docker Images 34 | run: docker image prune -f 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Virtual environment directories 7 | env/ 8 | venv/ 9 | 10 | # VSCode directory 11 | .vscode/ 12 | 13 | # Environment variables 14 | .env 15 | 16 | # Logs 17 | *.log 18 | *.tmp 19 | 20 | # OS-specific files 21 | .DS_Store 22 | 23 | commits/repository_links_commits.json 24 | src/DatabaseConnector.py -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ColorStack-Discord-Bot 2 | 3 | Thank you for investing your time in our project! 4 | 5 | ## Issues 6 | 7 | Whether you have discovered a bug, want a new feature in ColorStack-Discord-Bot, or want to change code, [please create an issue](https://github.com/colorstackatuw/ColorStack-Discord-Bot/issues) or [start a discussion](https://github.com/colorstackatuw/ColorStack-Discord-Bot/discussions) 8 | before any PR. We like to discuss things before implementation. We want to be focused and consider any new features carefully before committing to them. A new idea can be really relevant to you, and we understand it; that's why we try to reflect on every aspect (maintainability, optimizations, and new features). 9 | 10 | ## How Can You Help ? 11 | 12 | - Make sure to install all the dependencies and follow the [installation guide](https://github.com/colorstackatuw/ColorStack-Discord-Bot/blob/main/INSTALLATION.md) to get started. 13 | - If you are new to the project, you can start by looking at the `good first issue` label in the issues section. 14 | 15 | ## Pull Requests 16 | 17 | - Fork the repository and create a new branch from `main` for a new feature or a bug fix. 18 | - Leave a comment on the issue you are working on to let the maintainers know that you are working on it. If there are no updates after **1 week**, please reach out to the maintainers so we can assign the issue to someone else. 19 | - All Git commits are required to be signed and reviewed by at least two maintainers. If you are not getting a response, please reach out to the maintainers after **1 week**. 20 | - If you are creating or editing a new class, method, or function, please make sure to add the appropriate [documentation](https://github.com/colorstackatuw/ColorStack-Discord-Bot/blob/main/DOCUMENTATION.md). 21 | - All tests must be green before merging. Our CI/CD will run [tests](https://github.com/colorstackatuw/ColorStack-Discord-Bot/actions) to ensure everything is OK. 22 | - Before submitting the PR, please make sure to format with `pyproject.toml` to keep it consistent 23 | 24 | ## Build and Test 25 | 26 | ColorStack-Discord-Bot is a Python-based project that uses Docker to run the service, so make sure to have Docker installed on your machine. You can follow the [installation guide](https://github.com/colorstackatuw/ColorStack-Discord-Bot/blob/main/DOCUMENTATION.md#installation) to get started. Once you have followed the installation guide, you can run the following commands to build and test the project: 27 | 28 | To build the changes into the Docker container, you can run: 29 | 30 | ``` 31 | docker compose up -d --build 32 | ``` 33 | 34 | **Be careful, as the `DISCORD_CHANNEL` you have the bot in will send messages** 35 | 36 | To run unit tests within Docker, you can run: 37 | 38 | ``` 39 | docker-compose run --rm main pytest /app/tests 40 | ``` 41 | 42 | If you want to debug the tests on your local machine, run your IDE debugger within `src/DiscordBot.py` to debug the bot (using print statments instead of `await.send()` would be beneficial). **Just be sure that it also works within Docker** 43 | 44 | Although the database connector is private code hosted within the VM, what you can do instead is copy your channel ID within your test discord server and replace the following within `src/DiscordBot.py` 45 | 46 | ```python 47 | # Get the channels to send the job postings 48 | #db = DatabaseConnector() 49 | channel_ids = [12345] # Your channel id 50 | ``` 51 | 52 | This will allow you send messages to your channel when running on your local machine. 53 | -------------------------------------------------------------------------------- /DOCUMENTATION.md: -------------------------------------------------------------------------------- 1 | # ColorStack-Discord-Bot Documentation 2 | 3 | ## Table of Contents 4 | 5 | - [Installation](#installation) 6 | - [Daily Usage](#daily-usage) 7 | - [Classes](#classes) 8 | - [DiscordBot](#discordbot) 9 | - [GitHubUtilities](#githubutilities) 10 | - [JobsUtilities](#JobsUtilities) 11 | - [DatabaseConnector](#databaseconnector) 12 | 13 | ## Installation 14 | 15 | Please refer to the [installation guide](https://github.com/colorstackatuw/ColorStack-Discord-Bot/blob/main/INSTALLATION.md) 16 | 17 | ## Daily Usage 18 | 19 | While the bot is running, it will review the GitHub repositories and post any new opportunities in the Discord server the minute they are released. Here is the daily workflow: 20 | 21 | 1. The bot runs every mintue within `DiscordBot.py` and checks for new opportunities. 22 | 1. If a new opportunity is found, the bot will process the opportunity string using `getInternships()` found in `InternshipUtilites.py` and verify: 23 | 1. It's in the United States or Remote 24 | 1. The job posting is from the past 7 days 25 | 1. The job posting is not a duplicate of a co-op or internship 26 | 1. Once the post is validated, it will be posted within all the discord servers it's apart of by getting the channels from NoSQL database. 27 | 1. After all the processing is done, the bot will save the commit SHA in `commits/repository_links_commits.json`, sleep for 60 seconds, and repeat the process. 28 | 29 | ## Classes 30 | 31 | There are four main Python classes that allow the bot to function properly. 32 | 33 | ## DiscordBot 34 | 35 | This class allows the bot to scrape the GitHub repositories and post the opportunities in the Discord server every 60 seconds. 36 | 37 | ### scheduled_task 38 | 39 | A scheduled task that runs every 60 seconds to check for new commits in the GitHub repository. 40 | 41 | | Parameter | Description | 42 | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | 43 | | `job_utilities` | An instance of the `GitHubUtilities` class, enabling the bot to connect to the GitHub API and scrape GitHub repositories | 44 | | `internship_github` | An instance of the `JobsUtilities` class, allowing the bot to scrape GitHub repositories and post opportunities to the Discord server every 60 seconds | 45 | 46 | ### on_guild_remove 47 | 48 | Event that occurs when the bot is removed from a discord server to remove the data from the NoSQL database to stop sending messages 49 | 50 | | Parameter | Description | 51 | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | 52 | | `guild` | The guild/server that the bot has been removed from | 53 | 54 | ### on_guild_join 55 | 56 | Event that occurs when the bot joins a discord server to add data to NoSQL database. If the server doesn't contain `opportunities-bot` text channel, the bot removes itself from the server 57 | 58 | | Parameter | Description | 59 | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | 60 | | `guild` | The guild/server that the bot joined | 61 | 62 | ### before_scheduled_task 63 | 64 | Wait until the bot is ready before starting the loop. 65 | 66 | ### on_ready 67 | 68 | Event that is triggered when the bot is ready to start sending messages. 69 | 70 | ## GitHubUtilities 71 | 72 | This class allows the bot to scrape the GitHub repositories and post the opportunities in the Discord server every 60 seconds. This script provides a set of utilities to interact with GitHub repositories using the PyGithub library. It includes functionalities to establish a connection to a specified GitHub repository, update and retrieve the last commit information, and check for new commits. 73 | 74 | ### GitHubUtilities Constructor 75 | 76 | This method initializes the GitHubUtilities class with the specified GitHub repository and the GitHub API token. 77 | 78 | | Parameter | Description | 79 | | ----------- | ----------------------------------------------------- | 80 | | `token` | A GitHub API Token to pass into the GitHub library | 81 | | `repo_name` | Name of the repository to collect the internship jobs | 82 | 83 | ### createGitHubConnection 84 | 85 | Create a connection to the specified GitHub repository. 86 | 87 | ### setNewCommit 88 | 89 | Save the last commit information to prevent duplicate job postings. 90 | 91 | | Parameter | Description | 92 | | ------------- | ---------------------------------------------------------------------- | 93 | | `last_commit` | The last saved commit SHA from `commits/repository_links_commits.json` | 94 | 95 | ### getLastCommit 96 | 97 | Retrieve the last commit information based on the repository. 98 | 99 | | Parameter | Description | 100 | | --------- | --------------------- | 101 | | `repo` | The GitHub repository | 102 | 103 | ### getSavedSha 104 | 105 | Retrieve the last commit information from the saved file. 106 | 107 | | Parameter | Description | 108 | | --------- | --------------------- | 109 | | `repo` | The GitHub repository | 110 | 111 | ### setComparison 112 | 113 | Set the comparison between the previous commit and the recent commit. 114 | 115 | | Parameter | Description | 116 | | --------- | --------------------- | 117 | | `repo` | The GitHub repository | 118 | 119 | ### clearComparison 120 | 121 | Clear up the comparison between the previous commit and the recent commit. 122 | 123 | ### isNewCommit 124 | 125 | Determine if there is a new commit on the GitHub repository. 126 | 127 | | Parameter | Description | 128 | | ------------- | ---------------------------------------------------------------------- | 129 | | `repo` | The GitHub repository | 130 | | `last_commit` | The last saved commit SHA from `commits/repository_links_commits.json` | 131 | 132 | ### getCommitChanges 133 | 134 | Retrieve the commit changes that make additions to the Markdown files. 135 | 136 | ## JobsUtilities 137 | 138 | This class scrapes the GitHub repositories, processes the opportunities, and posts the opportunities in the Discord server every 60 seconds. 139 | 140 | ### JobsUtilities Constructor 141 | 142 | This method initializes the JobsUtilities class with the specified GitHub repository and the Discord bot. 143 | 144 | | Parameter | Description | 145 | | --------- | --------------------------------------- | 146 | | `summer` | True, if looking for summer internships | 147 | | `coop` | True, if looking for coop internships | 148 | 149 | ### clearJobLinks 150 | 151 | Clear the Co-Op dictionary links. 152 | 153 | ### clearJobCounter 154 | 155 | Clear the job counter. 156 | 157 | ### isWithinDateRange 158 | 159 | Determine if the job posting is within the past week. 160 | 161 | | Parameter | Description | 162 | | -------------- | --------------------------- | 163 | | `job_date` | The date of the job posting | 164 | | `current_date` | The current date | 165 | 166 | ### saveCompanyName 167 | 168 | Save the previous job title into the class variable. 169 | 170 | | Parameter | Description | 171 | | -------------- | ---------------- | 172 | | `company_name` | The company name | 173 | 174 | ### getInternships 175 | 176 | Retrieve the Summer or Co-op internships from the GitHub repository. 177 | 178 | | Parameter | Description | 179 | | -------------- | ------------------------------------------------------------- | 180 | |`bot`| The Discord bot | 181 | | `channels` | The Discord channels to send the job postings | 182 | | `job_postings` | The list of job postings | 183 | | `current_date` | The current date | 184 | | `is_summer` | A boolean to record a job if it's summer or co-op internships | 185 | 186 | 187 | ## DatabaseConnector 188 | 189 | This class helps connect to NoSQL database to track all servers the bot is apart of. This class will not be available to the public as it contains private information about the database. 190 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | FROM python:3.9.18-bullseye 3 | 4 | # Set the working directory in the container to /app 5 | WORKDIR /app 6 | 7 | # Install git, required to clone the repository 8 | RUN apt-get update && apt-get install -y git 9 | 10 | # Copy the current directory contents into the container at /app 11 | COPY . . 12 | 13 | # Install any needed packages specified in requirements.txt 14 | RUN pip install --no-cache-dir -r requirements.txt 15 | 16 | # Change the working directory to /app/src where DiscordBot.py is located 17 | WORKDIR /app/src 18 | 19 | # Run DiscordBot.py when the container launches 20 | CMD ["python", "-u", "DiscordBot.py"] 21 | -------------------------------------------------------------------------------- /INSTALLATION.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Here we will be providing steps on how to install the Discord bot on your local machine. 4 | 5 | 1. Create your own `.env` file 6 | 2. Create the bot on the Discord Developer Portal (Optional) 7 | 3. Install the required dependencies 8 | 9 | # Create your own .env file 10 | 11 | Inorder to provide the bot with the necessary environment variables, you will need to create a `.env` file in the root directory of the project. 12 | 13 | ```env 14 | DISCORD_TOKEN= 15 | GIT_TOKEN= 16 | ``` 17 | 18 | **Discord Token** - This is the token that you will get from the Discord Developer Portal. This token is used to authenticate the bot with the Discord API. 19 | 20 | **GitHub Token** - This is the token that you will get from your [GitHub Developer Settings](https://github.com/settings/tokens). This token is used to authenticate the bot with the GitHub API. You can opt for the fine-grained token or classic token. 21 | 22 | # Create the bot on the Discord Developer Portal (Optional) 23 | 24 | In the previous step, we mentioned that you will need a Discord token to authenticate the bot with the Discord API. You can get this token by creating a bot on the Discord Developer Portal. However, you don't have to do this as simply printing the bot to your terminal is enough to get started. 25 | 26 | However, if you want to create a bot, you can follow the steps below: 27 | 28 | 1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) 29 | 2. Click on "New Application" 30 | 3. Give your application a name 31 | 4. Click on "Bot" in the left hand menu 32 | 5. Click on "Add Bot" and give the proper permissions 33 | 6. Click on "Copy" to copy the token to your clipboard 34 | 35 | This is the token that you will use in the `.env` file. Once you have this set up, make sure to invite the bot to your server and create a channel for it to send messages to. 36 | 37 | # Install the required dependencies 38 | 39 | Make sure to have docker installed on your machine. You can follow the [installation guide](https://docs.docker.com/get-docker/) to get started. 40 | 41 | Once you have followed the docker installtion guide, you want to make sure that all the libraries are installed from the imports and `requirements.txt` file. You can do this by running the following command: 42 | 43 | ``` 44 | pip3 install -r requirements.txt 45 | ``` 46 | 47 | # How to contribute 48 | 49 | Once you have made any changes to the bot, you can follow the [contribution guide](https://github.com/colorstackatuw/ColorStack-Discord-Bot/blob/main/CONTRIBUTING.md) to get started. 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 David Salazar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | python3 -m pytest tests/ 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ColorStack-Discord-Bot 2 | 3 |
4 | 5 | [![Pull Requests Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](https://github.com/colorstackatuw/ColorStack-Discord-Bot/blob/main/CONTRIBUTING.md) 6 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/colorstackatuw/ColorStack-Discord-Bot/blob/main/LICENSE) 7 | ![Github CI/CD Status](https://github.com/colorstackatuw/ColorStack-Discord-Bot/actions/workflows/deploy-vm.yml/badge.svg) 8 | [![LinkedIn](https://img.shields.io/badge/ColorStack%20at%20UW--Madison-0077B5?style=for-the-badge&logo=linkedin&logoColor=white&style=flat-square)](https://www.linkedin.com/company/colorstack-at-uw-madison) 9 | [![Instagram](https://img.shields.io/badge/colorstackatuw-E4405F?style=for-the-badge&logo=instagram&logoColor=white&style=flat-square)](https://instagram.com/colorstackatuw) 10 | 11 |
12 | 13 | This Discord bot posts summer and co-op internship opportunities from [Simplify and Pitt CSC](https://github.com/SimplifyJobs/Summer2025-Internships). It's built and maintained by [ColorStack at UW-Madison](https://colorstack.cs.wisc.edu/). 14 | 15 | > [!NOTE] 16 | > Please note that this bot only posts opportunities that are in the United States or remote 17 | 18 | ## Installation 19 | 20 | To add the Discord Bot to your server, you must have the invite link from the National ColorStack Slack as it's a beta release. Don't change any of the permission settings! 21 | 22 | If the bot was successfully added, you should see this message within the `opportunities-bot` channel: `Hello! I am the ColorStack Bot. I will be posting new job opportunities here.` 23 | 24 | If you don't see the message and the bot has left the server, then you didn't give it the proper permissions, or we are at capacity. 25 | 26 | If you believe there is an issue with the bot, please make sure to [create an issue](https://github.com/colorstackatuw/ColorStack-Discord-Bot/issues). 27 | 28 | ## Documentation 29 | 30 | The documentation can be found in [DOCUMENTATION.md](https://github.com/colorstackatuw/ColorStack-Discord-Bot/blob/main/DOCUMENTATION.md) 31 | 32 | ## Contributing 33 | 34 | The purpose of this repository is to continue to maintain and improve the bot. We are grateful to the ColorStack community for contributing bug fixes and improvements. Read the [CONTRIBUTING.md](https://github.com/colorstackatuw/ColorStack-Discord-Bot/blob/main/CONTRIBUTING.md) to learn how you can take part in improving the bot. 35 | 36 | ## License 37 | 38 | This code is distributed under the MIT license. For more info, read the [LICENSE](LICENSE) file. 39 | -------------------------------------------------------------------------------- /commits/repository_links_commits.json: -------------------------------------------------------------------------------- 1 | {"last_saved_sha_internship": "", "last_saved_sha_newgrad": ""} 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | main: 4 | build: . 5 | image: mybot:latest 6 | container_name: ColorStackBot 7 | environment: 8 | DISCORD_TOKEN: ${DISCORD_TOKEN} 9 | GIT_TOKEN: ${GIT_TOKEN} 10 | volumes: 11 | - ./commits:/app/commits 12 | - ./logs:/app/logs 13 | 14 | redis: 15 | image: redis:latest 16 | container_name: JobsRedis 17 | ports: 18 | - "6379:6379" 19 | volumes: 20 | - redis-data:/data 21 | 22 | volumes: 23 | redis-data: 24 | -------------------------------------------------------------------------------- /logs/discord_bot.log: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/colorstackatuw/ColorStack-Discord-Bot/a562a08e4eea30d4b21b6ea4b50935baf43898ac/logs/discord_bot.log -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length=120 3 | 4 | [tool.ruff] 5 | line-length=120 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | discord.py==2.3.2 2 | pytest==7.4.0 3 | pygithub==2.2.0 4 | python-dotenv==1.0.1 5 | pytest-asyncio==0.23.4 6 | oracledb==2.0.1 7 | redis==5.2.0 8 | -------------------------------------------------------------------------------- /src/DiscordBot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Discord Bot 3 | 4 | This bot is responsible for sending internship and co-op opportunities to a Discord channel. 5 | It uses the GitHub API to track changes in the repository and sends the new opportunities to the Discord channel. 6 | 7 | Prerequisites: 8 | - PyGithub: A Python library to access the GitHub API v3. 9 | - Discord: A Python library to interact with the Discord API 10 | - A Discord bot token with the necessary permissions. 11 | - A GitHub personal access token with the necessary permissions. 12 | """ 13 | 14 | import asyncio 15 | import logging 16 | import os 17 | from datetime import datetime 18 | from logging.handlers import RotatingFileHandler 19 | 20 | import discord 21 | import redis 22 | from DatabaseConnector import DatabaseConnector 23 | from discord.ext import commands, tasks 24 | from dotenv import load_dotenv 25 | 26 | from GitHubUtilities import GitHubUtilities 27 | from JobsUtilities import JobsUtilities 28 | 29 | load_dotenv() 30 | DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") 31 | GITHUB_TOKEN = os.getenv("GIT_TOKEN") 32 | 33 | # Global Lock 34 | lock = asyncio.Lock() 35 | 36 | # Set up logging: log INFO+ levels to file, appending new entries, with detailed format. 37 | logger = logging.getLogger("discord_bot_logger") 38 | logger.setLevel(logging.INFO) 39 | 40 | # Ensure that files are rotated every 5MB, and keep 3 backups. 41 | handler = RotatingFileHandler(filename="/app/logs/discord_bot.log", maxBytes=5 * 1024 * 1024, backupCount=3) 42 | handler.setFormatter( 43 | logging.Formatter( 44 | "%(asctime)s - %(levelname)s - %(name)s: %(message)s", 45 | datefmt="%Y-%m-%d %H:%M:%S", 46 | ) 47 | ) 48 | logger.addHandler(handler) 49 | 50 | # Set up the bot 51 | intents = discord.Intents.default() 52 | intents.messages = True 53 | intents.message_content = True 54 | bot = commands.Bot(command_prefix="$", intents=intents) 55 | 56 | 57 | @tasks.loop(hours=12) 58 | async def health_check_task(): 59 | """ 60 | A scheduled task that runs every 12 hours to check the database connection and prevent shutdown 61 | """ 62 | 63 | async with lock: 64 | try: 65 | logger.info("Conducting a health check...") 66 | db = DatabaseConnector() 67 | _ = db.getChannels() 68 | logger.info("Able to connect to database!") 69 | except Exception: 70 | logger.error("An error occurred in the health check task.", exc_info=True) 71 | 72 | 73 | @tasks.loop(seconds=60) 74 | async def scheduled_task(job_utilities: JobsUtilities): 75 | """ 76 | A scheduled task that runs every 60 seconds to check for new commits in the GitHub repository. 77 | 78 | Parameters: 79 | - job_utilities: The JobsUtilities object 80 | """ 81 | async with lock: 82 | try: 83 | start_time = datetime.now() 84 | 85 | latest_repo = JobsUtilities.get_latest_internship_repo() 86 | logger.info(f"Using latest internship repository: {latest_repo}") 87 | 88 | internship_github = GitHubUtilities( 89 | token=GITHUB_TOKEN, repo_name=f"SimplifyJobs/{latest_repo}", isSummer=True, isCoop=True 90 | ) 91 | newgrad_github = GitHubUtilities(token=GITHUB_TOKEN, repo_name="SimplifyJobs/New-Grad-Positions") 92 | 93 | internship_repo = internship_github.createGitHubConnection() 94 | internship_sha = internship_github.getSavedSha(internship_repo, False) 95 | newgrad_repo = newgrad_github.createGitHubConnection() 96 | newgrad_sha = newgrad_github.getSavedSha(newgrad_repo, True) 97 | redis_client = redis.Redis(host="redis", port=6379, db=0) 98 | 99 | # Process all internship 100 | if internship_github.isNewCommit(internship_repo, internship_sha): 101 | logger.info("New internship commit has been found. Finding new jobs...") 102 | internship_github.setComparison(internship_repo, False) 103 | 104 | # Get the channels to send the job postings 105 | db = DatabaseConnector() 106 | channel_ids = db.getChannels() 107 | 108 | if internship_github.is_coop: 109 | job_postings = internship_github.getCommitChanges("README-Off-Season.md") 110 | await job_utilities.getJobs(bot, redis_client, channel_ids[:20], job_postings, "Co-Op") 111 | 112 | if internship_github.is_summer: 113 | job_postings = internship_github.getCommitChanges("README.md") 114 | await job_utilities.getJobs(bot, redis_client, channel_ids[:20], job_postings, "Summer") 115 | 116 | sha_commit = internship_github.getLastCommit(internship_repo) 117 | internship_github.setNewCommit(sha_commit, False) 118 | logger.info(f"There were {job_utilities.total_jobs} new jobs found!") 119 | 120 | # Clear all the cached data 121 | job_utilities.clearJobLinks() 122 | job_utilities.clearJobCounter() 123 | internship_github.clearComparison() 124 | 125 | logger.info("All internship jobs have been posted!") 126 | 127 | # Process all new grad jobs 128 | if newgrad_github.isNewCommit(newgrad_repo, newgrad_sha): 129 | logger.info("New grad commit has been found. Finding new jobs...") 130 | newgrad_github.setComparison(newgrad_repo, True) 131 | 132 | # Get the channels to send the job postings 133 | db = DatabaseConnector() 134 | channel_ids = db.getChannels() 135 | job_postings = newgrad_github.getCommitChanges("README.md") 136 | await job_utilities.getJobs(bot, redis_client, channel_ids[:20], job_postings, "New Grad") 137 | 138 | sha_commit = newgrad_github.getLastCommit(newgrad_repo) 139 | newgrad_github.setNewCommit(sha_commit, True) 140 | logger.info(f"There were {job_utilities.total_jobs} new jobs found!") 141 | 142 | # Clear all the cached data 143 | job_utilities.clearJobLinks() 144 | job_utilities.clearJobCounter() 145 | newgrad_github.clearComparison() 146 | 147 | logger.info("All new grad jobs have been posted!") 148 | 149 | except Exception: 150 | logger.error("An error occurred in the scheduled task.", exc_info=True) 151 | await bot.close() 152 | finally: 153 | redis_client.close() 154 | end_time = datetime.now() 155 | execution_time = end_time - start_time 156 | logger.info(f"Task execution time: {execution_time}") 157 | 158 | 159 | @bot.event 160 | async def on_guild_remove(guild: discord.Guild): 161 | """ 162 | Event that is triggered when the bot is removed from a server. 163 | 164 | Parameters: 165 | - guild: The guild that the bot has been removed from. 166 | """ 167 | async with lock: 168 | logger.info(f"The bot has been removed from: {guild.name}") 169 | db = DatabaseConnector() 170 | db.deleteServer(guild) 171 | 172 | 173 | @bot.event 174 | async def on_guild_join(guild: discord.Guild): 175 | """ 176 | Event that is triggered when the bot joins a new server. 177 | 178 | Parameters: 179 | - guild: The guild that the bot has joined. 180 | """ 181 | try: 182 | async with lock: 183 | if len(bot.guilds) <= 20: 184 | logger.info("The bot joined a new server!") 185 | channel = await guild.create_text_channel("opportunities-bot") 186 | 187 | db = DatabaseConnector() 188 | db.writeChannel(guild, channel) 189 | await channel.send("Hello! I am the ColorStack Bot. I will be posting new job opportunities here.") 190 | else: 191 | logger.info("We have reached max capacity of 20 servers!") 192 | await guild.leave() 193 | except Exception: 194 | logger.error(f"Could not create a channel named 'opportunities-bot' in {guild.name}.", exc_info=True) 195 | await guild.leave() 196 | 197 | 198 | @scheduled_task.before_loop 199 | async def before_scheduled_task(): 200 | """ 201 | Wait until the bot is ready before starting the loop. 202 | """ 203 | await bot.wait_until_ready() 204 | 205 | 206 | @bot.event 207 | async def on_ready(): 208 | """ 209 | Event that is triggered when the bot is ready to start sending messages. 210 | """ 211 | logger.info(f"Logged in as {bot.user.name}") 212 | try: 213 | job_utilities = JobsUtilities() 214 | scheduled_task.start(job_utilities) # Start the loop 215 | health_check_task.start() # Start health check task 216 | except Exception as e: 217 | logger.error(f"Failed to start scheduled tasks: {e}", exc_info=True) 218 | 219 | 220 | if __name__ == "__main__": 221 | try: 222 | bot.run(DISCORD_TOKEN) 223 | except Exception: 224 | logger.error("Fatal error in main execution:", exc_info=True) 225 | -------------------------------------------------------------------------------- /src/GitHubUtilities.py: -------------------------------------------------------------------------------- 1 | """ 2 | GitHub Utilities Class 3 | 4 | This class provides a set of utilities to interact with GitHub repositories using the PyGithub library. 5 | It includes functionalities to establish a connection to a specified GitHub repository, update and retrieve 6 | the last commit information, and check for new commits. 7 | 8 | Prerequisites: 9 | - PyGithub: A Python library to access the GitHub API v3. 10 | - A GitHub personal access token with the necessary permissions. 11 | """ 12 | import json 13 | from collections.abc import Iterable 14 | from pathlib import Path 15 | 16 | import github 17 | from github import Auth, Github 18 | 19 | 20 | class GitHubUtilities: 21 | FILEPATH = Path("../commits/repository_links_commits.json") 22 | 23 | def __init__(self, token, repo_name, isSummer: bool = False, isCoop = False): 24 | self.is_summer = isSummer 25 | self.is_coop = isCoop 26 | self.repo_name = repo_name 27 | self.github = Github(auth=Auth.Token(token)) 28 | self.comparison = None 29 | 30 | def createGitHubConnection(self) -> github.Repository.Repository: 31 | """ 32 | Create a connection to the specified GitHub repository 33 | 34 | Returns: 35 | - github.Repository.Repository: The GitHub repository 36 | """ 37 | return self.github.get_repo(self.repo_name) 38 | 39 | def setNewCommit(self, last_commit: str, isNewGrad: True) -> None: 40 | """ 41 | Save the last commit information to prevent duplicate job postings 42 | 43 | Parameters: 44 | - last_commit: The last saved commit sha from `commits/repository_links_commits.json` 45 | - isNewGrad: True if commit is for repo 46 | """ 47 | key = "last_saved_sha_newgrad" if isNewGrad else "last_saved_sha_internship" 48 | with self.FILEPATH.open("r") as file: 49 | data_json = json.load(file) 50 | 51 | data_json[key] = last_commit 52 | 53 | with self.FILEPATH.open("w") as file: 54 | json.dump(data_json, file) 55 | 56 | def getLastCommit(self, repo: github.Repository.Repository) -> str: 57 | """ 58 | Retrieve the last commit information based on the repository 59 | 60 | Parameters: 61 | - repo: The GitHub repository 62 | Returns: 63 | - str: The last commit hexadecimal information on Github repository 64 | """ 65 | branch = repo.get_branch(branch="dev") # May need to be changed in future 66 | return branch.commit.sha 67 | 68 | def getSavedSha(self, repo: github.Repository.Repository, isNewGrad: bool) -> str: 69 | """ 70 | Retrieve the last commit information from the saved file 71 | 72 | Parameters: 73 | - repo: The GitHub repository 74 | - isNewGrad: True if getting new grad sha 75 | Returns: 76 | - str: The last commit hexadecimal information 77 | """ 78 | key = "last_saved_sha_newgrad" if isNewGrad else "last_saved_sha_internship" 79 | with self.FILEPATH.open("r") as file: 80 | commit_sha = json.load(file)[key] 81 | 82 | if not commit_sha: 83 | # If the file is empty, get the previous commit from the repository 84 | recent_commit_sha = self.getLastCommit(repo) 85 | previous_commit = repo.get_commit(sha=recent_commit_sha) 86 | return previous_commit.parents[0].sha 87 | else: 88 | return commit_sha 89 | 90 | def setComparison(self, repo: github.Repository.Repository, isNewGrad: bool) -> None: 91 | """ 92 | Set the comparison between the previous commit and the recent commit 93 | 94 | Parameters: 95 | - repo: The GitHub repository 96 | - isNewGrad: True if repo is for new grad 97 | """ 98 | recent_commit = self.getLastCommit(repo) 99 | if not recent_commit: 100 | self.comparison = None 101 | 102 | previous_commit = self.getSavedSha(repo, isNewGrad) # Get the saved commit 103 | comparison = repo.compare(base=previous_commit, head=recent_commit) 104 | self.comparison = comparison 105 | 106 | def clearComparison(self) -> None: 107 | """ 108 | Clear the comparison between the previous commit and the recent commit 109 | """ 110 | self.comparison = None 111 | 112 | def isNewCommit(self, repo: github.Repository.Repository, last_commit: str) -> bool: 113 | """ 114 | Determine if there is a new commit on the GitHub repository 115 | 116 | Parameters: 117 | - repo: The GitHub repository 118 | - last_commit: The last saved commit sha from `commits/repository_links_commits.json` 119 | Returns: 120 | - bool: True if there is a new commit, False otherwise 121 | """ 122 | return last_commit != self.getLastCommit(repo) 123 | 124 | def getCommitChanges(self, readme_file: str) -> Iterable[str]: 125 | """ 126 | Retrieve the commit changes that make additions to the .md files 127 | 128 | Parameters: 129 | - readme_file: The name of the .md file 130 | Returns: 131 | - Iterable[str]: The lines that contain the job postings 132 | """ 133 | if self.comparison is None: 134 | return [] 135 | 136 | for file in self.comparison.files: 137 | if file.filename == readme_file: 138 | commit_lines = file.patch.split("\n") if file.patch else [] 139 | for line in commit_lines: 140 | # Check if the line is an addition and not a file header or subtraction 141 | if ( 142 | line.startswith("+") 143 | and not line.startswith("+++") 144 | and "🔒" not in line 145 | ): 146 | yield line 147 | -------------------------------------------------------------------------------- /src/JobsUtilities.py: -------------------------------------------------------------------------------- 1 | """ 2 | Internship Utilities Class 3 | 4 | This class provides a set of utilities to interact with the GitHub repository containing the job postings 5 | 6 | Prerequisites: 7 | - PyGithub: A Python library to access the GitHub API v3. 8 | - Discord: A Python library to interact with the Discord API 9 | - A GitHub personal access token with the necessary permissions. 10 | """ 11 | 12 | import asyncio 13 | import logging 14 | import os 15 | import re 16 | from collections.abc import Iterable 17 | from datetime import datetime, timedelta 18 | 19 | import discord 20 | import redis 21 | from dotenv import load_dotenv 22 | from github import Github, GithubException 23 | 24 | load_dotenv() 25 | 26 | GITHUB_TOKEN = os.getenv("GIT_TOKEN") 27 | 28 | class JobsUtilities: 29 | NOT_US = ["canada", "uk", "united kingdom", "eu"] 30 | latest_cached_repo = None 31 | 32 | def __init__(self): 33 | self.previous_job_title = "" 34 | self.job_cache = set() 35 | self.total_jobs = 0 36 | 37 | def clearJobLinks(self) -> None: 38 | """ 39 | Clear the Co-Op dictionary links. 40 | """ 41 | self.job_cache = set() 42 | 43 | def clearJobCounter(self) -> None: 44 | """ 45 | Clear the job counter. 46 | """ 47 | self.total_jobs = 0 48 | 49 | def isWithinDateRange(self, job_date: datetime, current_date: datetime) -> bool: 50 | """ 51 | Determine if the job posting is within the past week. 52 | 53 | Parameters: 54 | - job_date: The date of the job posting. 55 | - current_date: The current date. 56 | Returns: 57 | - bool: True if the job posting is within the past week, False otherwise. 58 | """ 59 | return timedelta(days=0) <= current_date - job_date <= timedelta(days=7) 60 | 61 | def saveCompanyName(self, company_name: str) -> None: 62 | """ 63 | Save the previous job title into the class variable. 64 | 65 | Parameters: 66 | - company_name: The company name. 67 | """ 68 | self.previous_job_title = company_name 69 | 70 | async def getJobs( 71 | self, 72 | bot: discord.ext.commands.Bot, 73 | redis_client: redis.client.Redis, 74 | channels: list[int], 75 | job_postings: Iterable[str], 76 | term: str, 77 | ) -> None: 78 | """ 79 | Retrieve the job postings from the GitHub repository. 80 | 81 | Parameters: 82 | - bot: The Discord bot. 83 | - channels: All the channels to send the job postings to 84 | - job_postings: The list of job postings. 85 | - term: Timeline of the job posting 86 | """ 87 | if term not in ["Summer", "Co-Op", "New Grad"]: 88 | raise ValueError("Term must be one of these: Summer, Coop, NewGrad") 89 | 90 | current_date = datetime.now() 91 | has_printed = False 92 | for job in job_postings: 93 | try: 94 | # Determine the index of the job link 95 | job_link_index = 5 if term == "Co-Op" else 4 96 | 97 | # Grab the data and remove the empty elements 98 | non_empty_elements = [element.strip() for element in job.split("|") if element.strip()] 99 | 100 | # If the job link is already in the cache, we skip the job posting 101 | job_link = re.search(r'href="([^"]+)"', non_empty_elements[job_link_index]).group(1) 102 | if job_link in self.job_cache: 103 | continue 104 | 105 | # Verify it hasn't been posted 106 | if redis_client.exists(job_link): 107 | logging.info("It already exits within redis database: ", job_link) 108 | continue 109 | 110 | self.job_cache.add(job_link) # Save the job link 111 | 112 | # If the company name is not present, we need to use the previous company name 113 | if "↳" not in non_empty_elements[1]: 114 | job_header = non_empty_elements[1] 115 | start_pos = job_header.find("[") + 1 116 | end_pos = job_header.find("]", start_pos) 117 | 118 | # If the company doesn't have link embedded, we just use the company name 119 | if start_pos >= 0 and end_pos >= 0: 120 | company_name = job_header[start_pos:end_pos] 121 | else: 122 | company_name = non_empty_elements[1] 123 | self.saveCompanyName(company_name) 124 | else: 125 | company_name = self.previous_job_title 126 | 127 | # Verify that job posting date was within past week 128 | date_posted = non_empty_elements[-1] 129 | current_year = datetime.now().year 130 | search_date = f"{date_posted} {current_year}" 131 | job_date = datetime.strptime(search_date, "%b %d %Y") 132 | if not self.isWithinDateRange(job_date, current_date): 133 | # Save the previous_job_title in case a "↳" is in US while root is not 134 | self.saveCompanyName(company_name) 135 | continue 136 | 137 | # We need to check that the position is within the US or remote 138 | list_locations = [] 139 | location_html = non_empty_elements[3] 140 | if "
" in location_html: 141 | start = location_html.find("") + len("") 142 | end = location_html.find("
", start) 143 | locations_content = location_html[start:end] 144 | for location in locations_content.split("
"): 145 | location = location.strip() 146 | lower_location = location.lower() 147 | if location and not any(not_us_country in lower_location for not_us_country in self.NOT_US): 148 | list_locations.append(location) 149 | 150 | elif "
" in location_html: 151 | split_locations = location_html.split("
") 152 | for location in split_locations: 153 | lower_location = location.lower() 154 | if not any(not_us_country in lower_location for not_us_country in self.NOT_US): 155 | list_locations.append(location) 156 | elif location_html: 157 | location = "Remote" if "remote" in location_html.lower() else location_html 158 | lower_location = location.lower() 159 | is_outside_us = any(not_us_country in lower_location for not_us_country in self.NOT_US) 160 | 161 | if location == "Remote" or not is_outside_us: 162 | list_locations.append(location) 163 | else: 164 | self.saveCompanyName(company_name) 165 | continue 166 | 167 | if len(list_locations) >= 1: 168 | location = " | ".join(list_locations) 169 | else: 170 | self.saveCompanyName(company_name) 171 | continue 172 | 173 | job_title = non_empty_elements[2] 174 | if term == "Co-Op": 175 | terms = " |".join(non_empty_elements[4].split(",")) 176 | elif term == "Summer": 177 | terms = "Summer 2025" 178 | 179 | post = "" 180 | if not has_printed: 181 | post += f"# {term} Postings!\n\n" 182 | has_printed = True 183 | 184 | post += ( 185 | f"**📅 Date Posted:** {date_posted}\n" 186 | f"**ℹ️ Company:** __{company_name}__\n" 187 | f"**👨‍💻 Job Title:** {job_title}\n" 188 | f"**📍 Location:** {location}\n" 189 | ) 190 | if term != "New Grad": 191 | post += f"**➡️ When?:** {terms}\n" 192 | post += f"**👉 Job Link:** <{job_link}>\n" f"{'-' * 153}" 193 | self.total_jobs += 1 194 | 195 | # Add the job link to redis database 196 | redis_client.set(job_link, datetime.now().strftime("%Y-%m-%d %H:%M:%S")) 197 | logging.info("Added the job link to redis!") 198 | 199 | # Send the job posting to the Discord channel 200 | coroutines = (bot.get_channel(channel).send(post) for channel in channels if bot.get_channel(channel)) 201 | await asyncio.gather(*coroutines) 202 | except Exception as e: 203 | logging.exception("Failed to process job posting: %s\nJob: %s", e, job) 204 | continue 205 | 206 | @staticmethod 207 | def get_cached_latest_repo(): 208 | return JobsUtilities.latest_cached_repo 209 | 210 | @staticmethod 211 | def set_cached_latest_repo(repo_name): 212 | JobsUtilities.latest_cached_repo = repo_name 213 | 214 | @staticmethod 215 | def get_latest_internship_repo(): 216 | # Fallback if cached repository is invalid 217 | g = Github(GITHUB_TOKEN) 218 | org = g.get_organization("SimplifyJobs") 219 | 220 | # Try to use the cached repository first 221 | cached_repo = JobsUtilities.get_cached_latest_repo() 222 | if cached_repo: 223 | try: 224 | if org.get_repo(cached_repo): 225 | return cached_repo 226 | except GithubException as e: 227 | logging.warning(f"Cached repo '{cached_repo}' not valid: {e}") 228 | 229 | repos = org.get_repos() 230 | 231 | matching_repos = [] 232 | for repo in repos: 233 | if repo.name.startswith("Summer"): 234 | suffix = repo.name[len("Summer"):] 235 | year = suffix.split("-")[0] 236 | if len(year) == 4 and year.isdigit(): 237 | matching_repos.append(repo.name) 238 | 239 | if not matching_repos: 240 | raise ValueError( 241 | "No repositories matching the pattern 'SummerYYYY-Internships' were found in the organization SimplifyJobs. Make sure the naming format hasn't changed and that your GitHub token has the necessary permissions." 242 | ) 243 | 244 | latest_repo = max(matching_repos) 245 | JobsUtilities.set_cached_latest_repo(latest_repo) 246 | return latest_repo 247 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/colorstackatuw/ColorStack-Discord-Bot/a562a08e4eea30d4b21b6ea4b50935baf43898ac/src/__init__.py -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/colorstackatuw/ColorStack-Discord-Bot/a562a08e4eea30d4b21b6ea4b50935baf43898ac/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_GitHub.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | import pytest 3 | from src.GitHubUtilities import GitHubUtilities 4 | 5 | # How to test the code 6 | # 1) Run the cmd: pytest tests/test_GitHub.py 7 | 8 | @patch('os.getenv') 9 | def test_create_github_connection(mock_getenv): 10 | # Arrange 11 | mock_getenv.return_value = 'mock_token' 12 | repo_name = "SimplifyJobs/Summer2025-Internships" 13 | utilities = GitHubUtilities('mock_token', repo_name) 14 | 15 | # Act 16 | mock_repo = MagicMock() 17 | mock_repo.name = repo_name.split("/")[-1] 18 | with patch.object(utilities, 'createGitHubConnection', return_value=mock_repo): 19 | repo = utilities.createGitHubConnection() 20 | 21 | # Assert 22 | assert repo is not None 23 | assert repo.name == repo_name.split("/")[-1] 24 | 25 | 26 | @patch("github.Repository.Repository") 27 | def test_get_last_commit(mock_repo): 28 | utilities = GitHubUtilities("token", "repo") 29 | mock_branch = MagicMock() 30 | mock_branch.commit.sha = "123abc" 31 | mock_repo.get_branch.return_value = mock_branch 32 | result = utilities.getLastCommit(mock_repo) 33 | assert result == "123abc" 34 | 35 | 36 | @patch("github.Repository.Repository") 37 | def test_is_new_commit(mock_repo): 38 | utilities = GitHubUtilities("token", "repo") 39 | mock_branch = MagicMock() # Fake a respository 40 | mock_branch.commit.sha = "123abc" 41 | mock_repo.get_branches.return_value = [mock_branch] 42 | result = utilities.isNewCommit(mock_repo, "456def") 43 | assert result 44 | 45 | 46 | @pytest.fixture 47 | def setup_job_utilities(): 48 | token = "your_token" 49 | repo_name = "SimplifyJobs/Summer2025-Internships" 50 | utilities = GitHubUtilities(token, repo_name) 51 | return utilities 52 | -------------------------------------------------------------------------------- /tests/test_Internship.py: -------------------------------------------------------------------------------- 1 | import random 2 | from datetime import datetime, timedelta 3 | from unittest.mock import AsyncMock, MagicMock, patch 4 | 5 | import pytest 6 | 7 | # To test the code run cmd: make test 8 | 9 | with patch.dict( 10 | "sys.modules", 11 | { 12 | "discord": MagicMock(), 13 | "discord.ext": MagicMock(), 14 | "discord.ext.commands": MagicMock(), 15 | }, 16 | ): 17 | from src.JobsUtilities import JobsUtilities 18 | 19 | 20 | def test_is_within_date_range(): 21 | # Arrange 22 | internship_util = JobsUtilities() 23 | random_days = random.randint(1, 6) 24 | job_date = datetime.now() - timedelta(days=random_days) 25 | current_date = datetime.now() 26 | 27 | # Act 28 | is_within_range = internship_util.isWithinDateRange(job_date, current_date) 29 | 30 | # Assert 31 | assert is_within_range 32 | 33 | 34 | def test_save_company_name(): 35 | # Arrange 36 | internship_util = JobsUtilities() 37 | company_name = "Test Company" 38 | 39 | # Act 40 | internship_util.saveCompanyName(company_name) 41 | 42 | # Assert 43 | assert internship_util.previous_job_title == company_name 44 | 45 | 46 | @pytest.mark.asyncio 47 | async def test_valid_job_posting(): 48 | # Directly create the mock bot 49 | mock_bot = MagicMock() 50 | mock_channel = AsyncMock() 51 | mock_bot.get_channel.return_value = mock_channel 52 | 53 | channels = [123456789, 987654321] 54 | job = """ 55 | | **[Rivian](https://simplify.jobs/c/Rivian)** | UIUC Research Park Intern - Embedded Systems Software Engineer | Urbana, IL | Summer 2024, Fall 2024, Spring 2025 | 56 | 57 | Apply 58 | 59 | Simplify 60 | | Feb 05 | 61 | """ 62 | job_postings = [job] 63 | redis_mock = MagicMock() 64 | redis_mock.exists.return_value = False 65 | 66 | instance = JobsUtilities() 67 | instance.saveCompanyName = MagicMock() 68 | instance.isWithinDateRange = MagicMock(return_value=True) 69 | instance.previous_job_title = "" 70 | instance.job_cache = set() 71 | instance.total_jobs = 0 72 | 73 | # Act 74 | await instance.getJobs(mock_bot, redis_mock, channels, job_postings, "Summer") 75 | 76 | # Assert 77 | assert len(instance.job_cache) == 1 # Ensure the job link was added to the cache 78 | assert instance.total_jobs == 1 # Ensure the job count was incremented 79 | mock_bot.get_channel.assert_called() # Ensure get_channel was called for each channel 80 | --------------------------------------------------------------------------------