├── .coveragerc ├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yaml │ ├── config.yaml │ └── feature_request.yaml ├── pull_request_template.md └── workflows │ ├── build.yaml │ ├── deploy.yaml │ └── test.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .test.env ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── CONTRIBUTORS ├── Dockerfile ├── Dockerfile.base ├── Dockerfile.dev ├── LICENSE ├── README.md ├── SECURITY.md ├── alembic.ini ├── alembic ├── README ├── env.py ├── script.py.mako └── versions │ ├── 05c1d218bb76_change_unban_time_data_type.py │ ├── 32e8037d780c_add_ctf_model.py │ ├── 364592ce1c76_fix_data_types_and_name_for_moderator.py │ ├── 49c63ed4411d_add_infraction_record_table.py │ ├── 4fc1c39216c9_added_macro_table.py │ ├── 5b0179a1f13f_rename_infractionrecord_to_infraction.py │ ├── 5bf8bbb7032f_add_ban_record_table.py │ ├── 6948a2436536_fix_data_types_for_user_id.py │ ├── 714c54b442ec_add_htb_discord_link_table.py │ ├── 953fc0c9158c_add_mute_record_table.py │ ├── a5f283a4cfde_change_unmute_time_data_type.py │ ├── a71c110d3654_rename_banrecord_to_ban.py │ ├── b372d25359fd_add_user_note_table.py │ └── fa20029d0cfb_rename_muterecord_to_mute.py ├── codecov.yml ├── contributors.sh ├── docker-compose.yml ├── poetry.lock ├── pyproject.toml ├── resources ├── cars.json └── unisex_baby_names.txt ├── src ├── __init__.py ├── __main__.py ├── bot.py ├── cmds │ ├── __init__.py │ ├── automation │ │ ├── __init__.py │ │ ├── auto_verify.py │ │ └── scheduled_tasks.py │ ├── core │ │ ├── __init__.py │ │ ├── ban.py │ │ ├── channel.py │ │ ├── ctf.py │ │ ├── fun.py │ │ ├── history.py │ │ ├── identify.py │ │ ├── macro.py │ │ ├── mute.py │ │ ├── note.py │ │ ├── other.py │ │ ├── ping.py │ │ ├── user.py │ │ └── verify.py │ └── dev │ │ ├── __init__.py │ │ └── extensions.py ├── core │ ├── __init__.py │ ├── config.py │ └── constants.py ├── database │ ├── __init__.py │ ├── base.py │ ├── base_class.py │ ├── models │ │ ├── __init__.py │ │ ├── ban.py │ │ ├── ctf.py │ │ ├── htb_discord_link.py │ │ ├── infraction.py │ │ ├── macro.py │ │ ├── mute.py │ │ └── user_note.py │ ├── session.py │ └── utils │ │ ├── __init__.py │ │ └── password.py ├── helpers │ ├── __init__.py │ ├── ban.py │ ├── checks.py │ ├── duration.py │ ├── responses.py │ ├── schedule.py │ ├── verification.py │ └── webhook.py ├── metrics.py ├── utils │ ├── __init__.py │ ├── extensions.py │ ├── formatters.py │ └── pagination.py ├── views │ └── bandecisionview.py └── webhooks │ ├── __init__.py │ ├── handlers │ ├── __init__.py │ └── academy.py │ ├── server.py │ └── types.py ├── startup.sh ├── tests ├── __init__.py ├── _autospec.py ├── conftest.py ├── helpers.py ├── plugins │ ├── __init__.py │ └── env_vars.py ├── src │ ├── __init__.py │ ├── cmds │ │ ├── __init__.py │ │ ├── core │ │ │ ├── __init__.py │ │ │ ├── test_ban.py │ │ │ ├── test_channel.py │ │ │ ├── test_ctf.py │ │ │ ├── test_fun.py │ │ │ ├── test_history.py │ │ │ ├── test_identify.py │ │ │ ├── test_macro.py │ │ │ ├── test_mute.py │ │ │ ├── test_note.py │ │ │ ├── test_other.py │ │ │ ├── test_ping.py │ │ │ ├── test_user.py │ │ │ └── test_verify.py │ │ ├── dev │ │ │ ├── __init__.py │ │ │ └── test_extensions.py │ │ └── test_cogs.py │ ├── database │ │ ├── __init__.py │ │ └── models │ │ │ ├── __init__.py │ │ │ ├── test_ban_model.py │ │ │ ├── test_ctf_model.py │ │ │ ├── test_htb_discord_link_model.py │ │ │ └── test_macro_model.py │ ├── helpers │ │ ├── __init__.py │ │ ├── test_ban.py │ │ ├── test_duration.py │ │ └── test_verification.py │ └── utils │ │ ├── __init__.py │ │ ├── test_extensions.py │ │ ├── test_formatters.py │ │ └── test_pagination.py └── test_helpers.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | tests/* 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Exclude everything 2 | * 3 | 4 | # Make exceptions for what's needed 5 | !alembic 6 | !src 7 | !resources 8 | !alembic.ini 9 | !pyproject.toml 10 | !poetry.lock 11 | !startup.sh 12 | !.env 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve 3 | title: "[Bug]: " 4 | labels: [ bug ] 5 | 6 | body: 7 | - type: textarea 8 | attributes: 9 | label: Describe the bug 10 | description: A clear and concise description of what the bug is. 11 | placeholder: I found a bug that [...] 12 | validations: 13 | required: true 14 | 15 | - type: textarea 16 | attributes: 17 | label: To reproduce 18 | description: Steps to reproduce the behavior. 19 | placeholder: | 20 | 1. Go to '...' 21 | 2. Click on '...' 22 | 3. Scroll down to '...' 23 | validations: 24 | required: true 25 | 26 | - type: textarea 27 | attributes: 28 | label: Expected behavior 29 | description: A clear and concise description of what you expected to happen. 30 | placeholder: I expected that [...] 31 | validations: 32 | required: true 33 | 34 | - type: textarea 35 | attributes: 36 | label: Relevant log output 37 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 38 | render: shell 39 | 40 | - type: textarea 41 | attributes: 42 | label: Additional context 43 | description: Add any other context or screenshots about the problem here. 44 | placeholder: Here is a screenshot of [...] 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yaml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | title: "[Feature]: " 4 | labels: [ enhancement ] 5 | 6 | body: 7 | - type: textarea 8 | attributes: 9 | label: Describe the solution you'd like 10 | description: A clear and concise description of what you want to happen. 11 | placeholder: I suggest to add a feature that [...] 12 | validations: 13 | required: true 14 | 15 | - type: textarea 16 | attributes: 17 | label: Is your feature request related to a problem? 18 | description: A clear and concise description of what the problem is. 19 | placeholder: I'm always frustrated when [...] 20 | 21 | - type: textarea 22 | attributes: 23 | label: Additional context 24 | description: Add any other context or screenshots about the feature request here. 25 | placeholder: Here is a screenshot of [...] 26 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Types of changes 2 | 3 | What types of changes does your code introduce? 4 | *Put an `x` in the boxes that apply.* 5 | 6 | - [ ] Bugfix (non-breaking change which fixes an issue). 7 | - [ ] New feature (non-breaking change which adds functionality). 8 | - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected). 9 | - [ ] Documentation Update (if none of the other choices applies). 10 | 11 | ## Proposed changes 12 | 13 | Describe your changes here and explain why we should accept this pull request. If it fixes a bug or resolves a feature 14 | request, be sure to link to that issue. 15 | 16 | ## Checklist 17 | 18 | *Put an `x` in the boxes that apply.* 19 | 20 | - [ ] I have read and followed the [CONTRIBUTING.md](https://github.com/hackthebox/Hackster/blob/main/CONTRIBUTING.md) 21 | doc. 22 | - [ ] Lint and unit tests pass locally with my changes. 23 | - [ ] I have added the necessary documentation (if appropriate). 24 | 25 | ## Additional context 26 | 27 | Add any other context or screenshots here. 28 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build base image 2 | 3 | on: 4 | push: 5 | paths: 6 | - Dockerfile.base 7 | - poetry.lock 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | packages: write 16 | env: 17 | DOCKER_IMAGE: ghcr.io/hackthebox/hackster:base 18 | steps: 19 | - name: Checkout repo 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v3 24 | 25 | - name: Login to ghcr.io 26 | uses: docker/login-action@v3 27 | with: 28 | registry: ghcr.io 29 | username: ${{ github.actor }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Build image 33 | uses: docker/build-push-action@v5 34 | with: 35 | push: true 36 | context: . 37 | tags: ${{ env.DOCKER_IMAGE }} 38 | file: Dockerfile.base 39 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | workflow_dispatch: 8 | 9 | jobs: 10 | deploy: 11 | environment: 12 | name: ${{ (contains(github.ref, '-rc')) && 'development' || 'production' }} 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | env: 18 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 19 | COMMITTER: ${{ github.actor }} 20 | DOCKER_IMAGE: ghcr.io/hackthebox/hackster:${{ github.sha }} 21 | LATEST_IMAGE: ghcr.io/hackthebox/hackster:latest 22 | CHANGE_CAUSE: ${{ github.run_number }}-${{ github.sha }} 23 | DEPLOYMENT_NAME: ${{ (contains(github.ref, '-rc')) && 'hackster-dev' || 'hackster' }} 24 | steps: 25 | - name: Checkout repo 26 | uses: actions/checkout@v4 27 | - name: Set up Docker Buildx 28 | uses: docker/setup-buildx-action@v3 29 | - name: Login to ghcr.io 30 | uses: docker/login-action@v3 31 | with: 32 | registry: ghcr.io 33 | username: ${{ github.actor }} 34 | password: ${{ secrets.GITHUB_TOKEN }} 35 | - name: Build image 36 | uses: docker/build-push-action@v5 37 | with: 38 | push: true 39 | context: . 40 | tags: ${{ env.DOCKER_IMAGE }},${{ env.LATEST_IMAGE }} 41 | - name: Rollout release 42 | uses: makelarisjr/kubectl-action@v1 43 | with: 44 | config: ${{ secrets.KUBE_CONFIG_DATA }} 45 | command: | 46 | set image deployment ${{ env.DEPLOYMENT_NAME }} hackster=${{ env.DOCKER_IMAGE }}; 47 | kubectl annotate deployment ${{ env.DEPLOYMENT_NAME }} kubernetes.io/change-cause="${{ env.CHANGE_CAUSE }}"; 48 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | test: 14 | name: Lint & Test 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - id: setup-python 21 | name: Set up Python 3.11 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: 3.11 25 | 26 | - name: Install Poetry 27 | uses: snok/install-poetry@v1 28 | with: 29 | version: 1.8.2 30 | virtualenvs-create: true 31 | virtualenvs-in-project: true 32 | installer-parallel: true 33 | 34 | # Only when the context is exactly the same, we will restore the cache. 35 | - name: Load cached venv 36 | id: restore-poetry-dependencies 37 | uses: actions/cache/restore@v4 38 | with: 39 | path: .venv 40 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 41 | 42 | - name: Create venv and install dependencies 43 | if: steps.restore-poetry-dependencies.outputs.cache-hit != 'true' 44 | run: poetry install --with dev 45 | 46 | - id: cache-poetry-dependencies 47 | name: Cache venv 48 | if: steps.restore-poetry-dependencies.outputs.cache-hit != 'true' 49 | uses: actions/cache/save@v4 50 | with: 51 | path: .venv 52 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 53 | 54 | - name: Run tests with pytest and generate coverage report 55 | run: | 56 | ENV_PATH=".test.env" poetry run task test 57 | poetry run task coverage xml 58 | 59 | - name: Upload coverage reports to CodeCov 60 | uses: codecov/codecov-action@v4 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | cover/ 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | .pybuilder/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | 133 | # pytype static type analyzer 134 | .pytype/ 135 | 136 | # Jetbrains project settings 137 | .idea/ 138 | 139 | # Cython debug symbols 140 | cython_debug/ 141 | 142 | # Logs 143 | logs/ 144 | .log 145 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.5.0 4 | hooks: 5 | - id: check-toml 6 | - id: check-yaml 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - repo: https://github.com/pycqa/isort 10 | rev: 5.12.0 11 | hooks: 12 | - id: isort 13 | name: Isort (python) 14 | - repo: local 15 | hooks: 16 | - id: flake8 17 | name: Flake8 18 | description: This hook runs flake8 within this project's poetry environment. 19 | entry: poetry run flake8 20 | language: system 21 | types: [ python ] 22 | require_serial: true 23 | -------------------------------------------------------------------------------- /.test.env: -------------------------------------------------------------------------------- 1 | # Bot 2 | BOT_ENVIRONMENT=example 3 | BOT_NAME=Hackster 4 | BOT_TOKEN=y1IY5cNYbiv0EdMDdMDjmv9qDL.lyIJQb.Okk2vH1mRyPagTbQOGnsFBgwq17ZJEi2xN4KV6 5 | 6 | GUILD_IDS=["6455184161011276950"] 7 | DEV_GUILD_IDS=["6455184161011276950"] 8 | LOG_LEVEL=DEBUG 9 | DEBUG=True 10 | 11 | # Database 12 | MYSQL_HOST=localhost 13 | MYSQL_PORT=3306 14 | MYSQL_DATABASE=bot_dev 15 | MYSQL_USER=bot 16 | MYSQL_PASSWORD=not_a_password 17 | MYSQL_ASYNC=True 18 | 19 | # HTB 20 | HTB_API_SECRET=9BR5vXIXhcaasZGXujkEDvqxFyinPA 21 | START_WEBHOOK_SERVER=True 22 | WEBHOOK_TOKEN=JrvjfD0E-KV91-Bt8Y-ZLlg-uTEMk8SZXYvc 23 | 24 | # Sentry 25 | SENTRY_DSN= 26 | 27 | # Channels 28 | CHANNEL_SR_MOD=1127695218900993410 29 | CHANNEL_VERIFY_LOGS=1012769518828339331 30 | CHANNEL_BOT_COMMANDS=1276953350848588101 31 | CHANNEL_SPOILER=2769521890099371011 32 | CHANNEL_BOT_LOGS=1105517088266788925 33 | 34 | # Roles 35 | ROLE_BIZCTF2022=7629466241011276950 36 | ROLE_NOAH_GANG=6706800691011276950 37 | ROLE_BUDDY_GANG=6706800681011276950 38 | ROLE_RED_TEAM=6706800701011276950 39 | ROLE_BLUE_TEAM=6706800711011276950 40 | 41 | ROLE_COMMUNITY_MANAGER=7839345151011276950 42 | ROLE_COMMUNITY_TEAM=845823057817153850 43 | ROLE_ADMINISTRATOR=7839345141011276950 44 | ROLE_SR_MODERATOR=7629466271011276950 45 | ROLE_MODERATOR=7629466261011276950 46 | ROLE_JR_MODERATOR=7629466221011276950 47 | ROLE_HTB_STAFF=7629466201011276950 48 | ROLE_HTB_SUPPORT=6455184211011276950 49 | ROLE_MUTED=7419955651011276950 50 | 51 | ROLE_OMNISCIENT=8377361011276950695 52 | ROLE_GURU=8377351011276950695 53 | ROLE_ELITE_HACKER=8377341011276950695 54 | ROLE_PRO_HACKER=8377331011276950695 55 | ROLE_HACKER=8377321011276950695 56 | ROLE_SCRIPT_KIDDIE=8377311011276950695 57 | ROLE_NOOB=8377301011276950695 58 | 59 | ROLE_VIP=9583772810112769506 60 | ROLE_VIP_PLUS=9583772910112769506 61 | 62 | ROLE_CHALLENGE_CREATOR=8215461011276950716 63 | ROLE_BOX_CREATOR=8215471011276950716 64 | 65 | ROLE_RANK_ONE=7419955631011276950 66 | ROLE_RANK_TEN=7419955611011276950 67 | 68 | ROLE_ACADEMY_USER=8087599101014249251 69 | ROLE_ACADEMY_CBBH=7168215511011276950 70 | ROLE_ACADEMY_CPTS=1795774641027354363 71 | ROLE_ACADEMY_CDSA=1157697238949167235 72 | ROLE_ACADEMY_CWEE=1257697240949167235 73 | ROLE_ACADEMY_CAPE=1318971191586979861 74 | 75 | ROLE_UNICTF2022=6148613121047893215 76 | 77 | ROLE_SEASON_HOLO=1099033418995597373 78 | ROLE_SEASON_PLATINUM=1099048578166554784 79 | ROLE_SEASON_RUBY=1099049568148127774 80 | ROLE_SEASON_SILVER=1099056313952125019 81 | ROLE_SEASON_BRONZE=1099056442281042022 82 | 83 | 84 | # Season ID, Updated at regular interval 85 | CURRENT_SEASON_ID=1 86 | 87 | #V4 Bearer Token 88 | HTB_API_KEY=CHANGE_ME 89 | 90 | #Feedback Webhook 91 | SLACK_FEEDBACK_WEBHOOK="https://hook.slack.com/sdfsdfsf" 92 | 93 | #Feedback Webhook 94 | JIRA_WEBHOOK="https://automation.atlassian.com/sdfsdfsf" 95 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @dimoschi @makelarisjr 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible 62 | for enforcement. All complaints will be reviewed and investigated promptly and fairly. 63 | 64 | All community leaders are obligated to respect the privacy and security of the 65 | reporter of any incident. 66 | 67 | ## Enforcement Guidelines 68 | 69 | Community leaders will follow these Community Impact Guidelines in determining 70 | the consequences for any action they deem in violation of this Code of Conduct: 71 | 72 | ### 1. Correction 73 | 74 | **Community Impact**: Use of inappropriate language or other behavior deemed 75 | unprofessional or unwelcome in the community. 76 | 77 | **Consequence**: A private, written warning from community leaders, providing 78 | clarity around the nature of the violation and an explanation of why the 79 | behavior was inappropriate. A public apology may be requested. 80 | 81 | ### 2. Warning 82 | 83 | **Community Impact**: A violation through a single incident or series 84 | of actions. 85 | 86 | **Consequence**: A warning with consequences for continued behavior. No 87 | interaction with the people involved, including unsolicited interaction with 88 | those enforcing the Code of Conduct, for a specified period of time. This 89 | includes avoiding interactions in community spaces as well as external channels 90 | like social media. Violating these terms may lead to a temporary or 91 | permanent ban. 92 | 93 | ### 3. Temporary Ban 94 | 95 | **Community Impact**: A serious violation of community standards, including 96 | sustained inappropriate behavior. 97 | 98 | **Consequence**: A temporary ban from any sort of interaction or public 99 | communication with the community for a specified period of time. No public or 100 | private interaction with the people involved, including unsolicited interaction 101 | with those enforcing the Code of Conduct, is allowed during this period. 102 | Violating these terms may lead to a permanent ban. 103 | 104 | ### 4. Permanent Ban 105 | 106 | **Community Impact**: Demonstrating a pattern of violation of community 107 | standards, including sustained inappropriate behavior, harassment of an 108 | individual, or aggression toward or disparagement of classes of individuals. 109 | 110 | **Consequence**: A permanent ban from any sort of public interaction within 111 | the community. 112 | 113 | ## Attribution 114 | 115 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 116 | version 2.0, available at 117 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 118 | 119 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 120 | enforcement ladder](https://github.com/mozilla/diversity). 121 | 122 | [homepage]: https://www.contributor-covenant.org 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | https://www.contributor-covenant.org/faq. Translations are available at 126 | https://www.contributor-covenant.org/translations. 127 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing guidelines 2 | 3 | Thank you for your interest in this project! 4 | 5 | If you are interested in contributing, **this page contains the golden rules to follow when contributing**. Do note that 6 | failing to comply with our guidelines may lead to a rejection of the contribution. 7 | 8 | *** 9 | 10 | ## Guidelines 11 | 12 | To contribute, please follow these guidelines: 13 | 14 | * Look at the current Issues and/or discussions taking place in the #bot-dev channel on Discord for things that needs to 15 | be done. 16 | * Please create an issue in GitHub. For feature requests wait the community moderators to approve the issue. 17 | * Always create a feature branch for your work. 18 | * Please use pull requests from feature branch to development once you are confident that the implementation is done. 19 | * **Do not open a pull request if you aren't assigned to the issue**. If someone is already working on it, consider 20 | offering to collaborate with that person. 21 | 22 | ## Before creating a pull request 23 | 24 | Please ensure that the following is fulfilled: 25 | 26 | ### Functionality and testing 27 | 28 | * The code has been tested on your own machine and appears to work as intended. 29 | * The code handles errors and malformed input gracefully. 30 | * The command is implemented in slash commands using the same approach as all other places (if applicable). 31 | * Permissions are set correctly. 32 | 33 | ### Code quality 34 | 35 | * Follow PEP-8 style guidelines, except the maximum line width (which can exceed 80 chars in this repo - we're not in 36 | the 1970's anymore). 37 | * **Lint before you push**. We have simple but strict style rules that are enforced through linting. You must always 38 | lint your code before committing or pushing. 39 | * Try/except the actual error which is raised. 40 | * Proofread the code and fix oddities. 41 | 42 | ***Always leave the campground cleaner than you found it.*** 43 | 44 | ## Before commits 45 | 46 | Install the project git hooks using [poetry] 47 | 48 | ```shell 49 | poetry run task precommit 50 | ``` 51 | 52 | Now `pre-commit` will run automatically on `git commit` 53 | 54 | ```console 55 | root@user:~$ git commit -m "some commit" 56 | Check docstring is first.................................................Passed 57 | Check for merge conflicts................................................Passed 58 | Check Toml...............................................................Passed 59 | Check Yaml...............................................................Passed 60 | Detect Private Key.......................................................Passed 61 | Fix End of Files.........................................................Passed 62 | Tests should end in _test.py.............................................Passed 63 | Trim Trailing Whitespace.................................................Passed 64 | Flake8...................................................................Passed 65 | ``` 66 | 67 | Or you can run it manually 68 | 69 | ```shell 70 | poetry run task lint 71 | ``` 72 | 73 | [flake8]: https://flake8.pycqa.org/en/latest/ 74 | 75 | [pre-commit]: https://pre-commit.com/ 76 | 77 | [poetry]: https://python-poetry.org/ 78 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | # People who have contributed to this repository. 2 | # 3 | # This script is generated by contributors.sh 4 | # 5 | 6 | # Special thanks to Noahbot team: 7 | Birb <7443606+HeckerBirb@users.noreply.github.com> 8 | clubby789 9 | Emma Samms 10 | Khaotic <6080590+khaoticdude@users.noreply.github.com> 11 | TheWeeknd <98106526+ToxicBiohazard@users.noreply.github.com> 12 | 13 | Dimosthenis Schizas 14 | makelarisjr <8687447+makelarisjr@users.noreply.github.com> 15 | Jelle Janssens 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/hackthebox/hackster:base as builder-base 2 | # `production` image used for runtime 3 | FROM builder-base as production 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y mariadb-client libmariadb-dev && \ 7 | rm -rf /var/lib/apt/lists/* 8 | 9 | COPY --from=builder-base $APP_PATH $APP_PATH 10 | 11 | WORKDIR $APP_PATH 12 | 13 | COPY alembic ./alembic 14 | COPY alembic.ini ./alembic.ini 15 | COPY src ./src 16 | COPY resources ./resources 17 | COPY startup.sh ./startup.sh 18 | COPY pyproject.toml ./pyproject.toml 19 | RUN chmod +x startup.sh 20 | 21 | ENV PYTHONPATH=$APP_PATH 22 | 23 | EXPOSE 1337 24 | # Run the start script, it will check for an /app/prestart.sh script (e.g. for migrations) 25 | # And then will start Uvicorn 26 | ENTRYPOINT ["/opt/hackster/startup.sh"] 27 | -------------------------------------------------------------------------------- /Dockerfile.base: -------------------------------------------------------------------------------- 1 | ARG APP_NAME=hackster 2 | ARG PYTHON_VERSION=3.11.2 3 | ARG POETRY_VERSION=1.8.2 4 | 5 | # `python-base` sets up all our shared environment variables 6 | FROM python:${PYTHON_VERSION}-slim as python-base 7 | 8 | # python 9 | ENV \ 10 | # immediately dumped to the stream instead of being buffered. 11 | PYTHONUNBUFFERED=1 \ 12 | # prevents python creating .pyc files 13 | PYTHONDONTWRITEBYTECODE=1 \ 14 | # pip 15 | PIP_NO_CACHE_DIR=off \ 16 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 17 | PIP_DEFAULT_TIMEOUT=100 \ 18 | # poetry 19 | # https://python-poetry.org/docs/configuration/#using-environment-variables 20 | POETRY_VERSION=$POETRY_VERSION \ 21 | # make poetry install to this location 22 | POETRY_HOME="/opt/poetry" \ 23 | # make poetry create the virtual environment in the project's root 24 | # it gets named `.venv` 25 | POETRY_VIRTUALENVS_IN_PROJECT=true \ 26 | # do not ask any interactive question 27 | POETRY_NO_INTERACTION=1 28 | ENV APP_PATH="/opt/hackster" 29 | 30 | # `builder-base` stage is used to build deps + create our virtual environment 31 | FROM python-base as builder-base 32 | RUN apt-get update \ 33 | && apt-get install --no-install-recommends -y \ 34 | # deps for installing poetry 35 | curl \ 36 | # deps for building python deps 37 | build-essential 38 | 39 | # install poetry - respects $POETRY_VERSION & $POETRY_HOME 40 | RUN curl -sSL https://install.python-poetry.org | python 41 | ENV PATH="$POETRY_HOME/bin:$PATH" 42 | 43 | # copy project requirement files here to ensure they will be cached. 44 | WORKDIR $APP_PATH 45 | COPY ./poetry.lock ./pyproject.toml ./ 46 | 47 | # install runtime deps - uses $POETRY_VIRTUALENVS_IN_PROJECT internally 48 | RUN poetry install --no-cache --without dev 49 | ENV VENV_PATH="$APP_PATH/.venv" 50 | ENV PATH="$VENV_PATH/bin:$PATH" 51 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/hackthebox/hackster:base as builder-base 2 | # `production` image used for runtime 3 | FROM builder-base as development 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y mariadb-client libmariadb-dev && \ 7 | rm -rf /var/lib/apt/lists/* 8 | 9 | COPY --from=builder-base $APP_PATH $APP_PATH 10 | 11 | WORKDIR $APP_PATH 12 | 13 | COPY alembic ./alembic 14 | COPY alembic.ini ./alembic.ini 15 | COPY src ./src 16 | COPY resources ./resources 17 | COPY startup.sh ./startup.sh 18 | COPY pyproject.toml ./pyproject.toml 19 | COPY poetry.lock ./poetry.lock 20 | RUN chmod +x ./startup.sh 21 | 22 | ENV PYTHONPATH=$APP_PATH 23 | ENV BOT_ENVIRONMENT=development 24 | 25 | EXPOSE 1337 26 | 27 | ENTRYPOINT ["$APP_PATH/startup.sh"] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Hack The Box Ltd 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Hackster](./README.md) · [![GitHub license]](./LICENSE) ![CI](https://github.com/hackthebox/hackster/actions/workflows/test.yaml/badge.svg) [![codecov](https://codecov.io/gh/hackthebox/Hackster/branch/main/graph/badge.svg?token=DSQFU4YP2W)](https://codecov.io/gh/hackthebox/Hackster) 2 | 3 | Welcome to the Hackster project! Its goal is to enhance the user experience for HTB's community members, and therefore 4 | it is always going to be a work in progress. We've been inspired by the fantastic work of other projects, 5 | particularly [Noahbot](https://github.com/HeckerBirb/NoahBot), and we're excited to contribute our own ideas and 6 | features to the broader community. 7 | 8 | 9 | 10 | ## Table of Contents 11 | 12 | - [Features](#features) 13 | - [Getting Started](#getting-started) 14 | - [Contributing](#contributing) 15 | - [License](#license) 16 | - [Code of Conduct & Security](#code-of-conduct--security) 17 | - [Special Thanks](#special-thanks) 18 | - [Questions & Support](#questions--support) 19 | - [Contributors](#contributors) 20 | 21 | ## Features 22 | 23 | - Message moderation: filter, delete, or flag inappropriate content. 24 | - User management: warn, mute, kick, or ban users based on customizable rules. 25 | - CTF Events management: create and manage channels, roles, and permissions for CTF Events. 26 | - And much more! 27 | 28 | ## Getting Started 29 | 30 | To set up and deploy the Discord bot, follow these steps: 31 | 32 | 1. The first step will be to clone the repo 33 | ```shell 34 | git clone https://github.com/hackthebox/hackster.git 35 | ``` 36 | The requirements are: 37 | * [Python](https://www.python.org/) 38 | * [Poetry](https://python-poetry.org/) 39 | 40 | 2. Install the dependencies 41 | ```shell 42 | poetry install 43 | ``` 44 | 45 | 3. add the following environment variables. 46 | 47 | | Variable | Description | Default | 48 | |----------------|----------------------------|------------| 49 | | BOT_NAME | The name of the bot | "Hackster" | 50 | | BOT_TOKEN | The token of the bot | *Required | 51 | | CHANNEL_DEVLOG | The devlog channel id | 0 | 52 | | DEBUG | Toggles debug mode | False | 53 | | DEV_GUILD_IDS | The dev servers of the bot | [] | 54 | | GUILD_IDS | The servers of the bot | *Required | 55 | 56 | 4. Now you are done! You can run the project using 57 | 58 | ```shell 59 | poetry run task start 60 | ``` 61 | 62 | or test the project using 63 | 64 | ```shell 65 | poetry run task test 66 | ``` 67 | 68 | ## Contributing 69 | 70 | We invite and encourage everyone to contribute to this open-source project! To ensure a smooth and efficient 71 | collaboration process, please review our [CONTRIBUTING](CONTRIBUTING.md) guidelines before submitting any issues or pull 72 | requests. This will help maintain a high-quality codebase and a welcoming environment for all contributors. 73 | 74 | ## License 75 | 76 | Distributed under the MIT License. See [LICENSE](LICENSE) for more information. 77 | 78 | ## Code of Conduct & Security 79 | 80 | Please familiarize yourself with our [Code of Conduct](CODE_OF_CONDUCT.md) to ensure a welcoming and respectful 81 | environment for all project participants. Additionally, review our [Security Policy](SECURITY.md) to understand how to 82 | responsibly disclose security vulnerabilities in the project. 83 | 84 | ## Special Thanks 85 | 86 | In developing our Discord bot, we have drawn inspiration from [Noahbot](https://github.com/HeckerBirb/NoahBot), an 87 | outstanding open-source project that has already demonstrated great success and versatility. We would like to extend our 88 | gratitude and acknowledgement to the creators and contributors of [Noahbot](https://github.com/HeckerBirb/NoahBot), 89 | whose hard work and dedication have laid the groundwork for our project. 90 | 91 | ## Questions & Support 92 | 93 | If you have any questions or need support, feel free to open an issue on the GitHub repository, and we'll be happy to 94 | help you out. 95 | 96 | ## Contributors 97 | 98 | Check [CONTRIBUTORS](CONTRIBUTORS) to see all project contributors. 99 | 100 | [docker ce]: https://docs.docker.com/install/ 101 | 102 | [docker compose]: https://docs.docker.com/compose/install/ 103 | 104 | [poetry]: https://python-poetry.org/docs/ 105 | 106 | [python]: https://www.python.org/downloads/ 107 | 108 | 109 | 110 | [gitHub license]: https://img.shields.io/badge/license-MIT-blue.svg 111 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you discover a security issue in this repository, please submit an issue with `security` label 6 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python-dateutil library that can be 20 | # installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to dateutil.tz.gettz() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to alembic/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # the output encoding used when revision files 55 | # are written from script.py.mako 56 | # output_encoding = utf-8 57 | 58 | # sqlalchemy.url = driver://user:pass@localhost/dbname 59 | 60 | 61 | [post_write_hooks] 62 | # post_write_hooks defines scripts or Python functions that are run 63 | # on newly generated revision scripts. See the documentation for further 64 | # detail and examples 65 | 66 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 67 | # hooks = black 68 | # black.type = console_scripts 69 | # black.entrypoint = black 70 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 71 | 72 | # Logging configuration 73 | [loggers] 74 | keys = root,sqlalchemy,alembic 75 | 76 | [handlers] 77 | keys = console 78 | 79 | [formatters] 80 | keys = generic 81 | 82 | [logger_root] 83 | level = WARN 84 | handlers = console 85 | qualname = 86 | 87 | [logger_sqlalchemy] 88 | level = WARN 89 | handlers = 90 | qualname = sqlalchemy.engine 91 | 92 | [logger_alembic] 93 | level = INFO 94 | handlers = 95 | qualname = alembic 96 | 97 | [handler_console] 98 | class = StreamHandler 99 | args = (sys.stderr,) 100 | level = NOTSET 101 | formatter = generic 102 | 103 | [formatter_generic] 104 | format = %(levelname)-5.5s [%(name)s] %(message)s 105 | datefmt = %H:%M:%S 106 | -------------------------------------------------------------------------------- /alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | -------------------------------------------------------------------------------- /alembic/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | from logging.config import fileConfig 3 | 4 | import dotenv 5 | from alembic import context 6 | from sqlalchemy import create_engine 7 | 8 | dotenv.load_dotenv() 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | if config.config_file_name is not None: 17 | fileConfig(config.config_file_name) 18 | 19 | # add your model's MetaData object here 20 | # for 'autogenerate' support 21 | # from src.database.base_class import Base # noqa 22 | from src.database.models import * # noqa 23 | 24 | target_metadata = Base.metadata # noqa: F405 25 | 26 | 27 | # other values from the config, defined by the needs of env.py, 28 | # can be acquired: 29 | # my_important_option = config.get_main_option("my_important_option") 30 | # ... etc. 31 | 32 | 33 | def get_url() -> str: 34 | user = os.getenv("MYSQL_USER", "noahbot") 35 | password = os.getenv("MYSQL_PASSWORD", None) 36 | server = os.getenv("MYSQL_HOST", "localhost") 37 | port = os.getenv("MYSQL_PORT", "3306") 38 | db = os.getenv("MYSQL_DATABASE", "noahbot_dev") 39 | url = f"mariadb+pymysql://{user}:{password}@{server}:{port}/{db}?charset=utf8mb4" 40 | return url 41 | 42 | 43 | def run_migrations_offline() -> None: 44 | """Run migrations in 'offline' mode. 45 | 46 | This configures the context with just a URL 47 | and not an Engine, though an Engine is acceptable 48 | here as well. By skipping the Engine creation 49 | we don't even need a DBAPI to be available. 50 | 51 | Calls to context.execute() here emit the given string to the 52 | script output. 53 | 54 | """ 55 | context.configure( 56 | url=get_url(), 57 | target_metadata=target_metadata, 58 | literal_binds=True, 59 | dialect_opts={"paramstyle": "named"}, 60 | ) 61 | 62 | with context.begin_transaction(): 63 | context.run_migrations() 64 | 65 | 66 | def run_migrations_online() -> None: 67 | """Run migrations in 'online' mode. 68 | 69 | In this scenario we need to create an Engine 70 | and associate a connection with the context. 71 | 72 | """ 73 | connectable = create_engine(get_url()) 74 | 75 | with connectable.connect() as connection: 76 | context.configure(connection=connection, target_metadata=target_metadata) 77 | 78 | with context.begin_transaction(): 79 | context.run_migrations() 80 | 81 | 82 | if context.is_offline_mode(): 83 | run_migrations_offline() 84 | else: 85 | run_migrations_online() 86 | -------------------------------------------------------------------------------- /alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /alembic/versions/05c1d218bb76_change_unban_time_data_type.py: -------------------------------------------------------------------------------- 1 | """Change unban_time data type 2 | 3 | Revision ID: 05c1d218bb76 4 | Revises: 6948a2436536 5 | Create Date: 2023-05-09 13:28:06.763604 6 | 7 | """ 8 | from alembic import op 9 | from sqlalchemy.dialects import mysql 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = '05c1d218bb76' 13 | down_revision = '6948a2436536' 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade() -> None: 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.alter_column( 21 | table_name='ban', column_name="unban_time", 22 | existing_type=mysql.INTEGER(display_width=11), 23 | type_=mysql.BIGINT(display_width=18), 24 | nullable=False 25 | ) 26 | # ### end Alembic commands ### 27 | 28 | 29 | def downgrade() -> None: 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | op.alter_column( 32 | table_name='ban', column_name="unban_time", 33 | existing_type=mysql.BIGINT(display_width=18), 34 | type_=mysql.INTEGER(display_width=11), 35 | nullable=False 36 | ) 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /alembic/versions/32e8037d780c_add_ctf_model.py: -------------------------------------------------------------------------------- 1 | """Add ctf model. 2 | 3 | Revision ID: 32e8037d780c 4 | Revises: b372d25359fd 5 | Create Date: 2023-03-29 00:47:57.194052 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from sqlalchemy.dialects import mysql 10 | 11 | from alembic import op 12 | from src.database.utils.password import Password 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "32e8037d780c" 16 | down_revision = "b372d25359fd" 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table( 24 | "ctf", 25 | sa.Column("id", sa.Integer(), nullable=False), 26 | sa.Column("name", mysql.VARCHAR(length=255), nullable=False), 27 | sa.Column("guild_id", mysql.BIGINT(display_width=18), nullable=False), 28 | sa.Column("admin_role_id", mysql.BIGINT(display_width=18), nullable=False), 29 | sa.Column("participant_role_id", mysql.BIGINT(display_width=18), nullable=False), 30 | sa.Column("password", Password, nullable=False), 31 | sa.PrimaryKeyConstraint("id"), 32 | ) 33 | op.create_index(op.f("ix_ctf_id"), "ctf", ["id"], unique=False) 34 | # ### end Alembic commands ### 35 | 36 | 37 | def downgrade() -> None: 38 | # ### commands auto generated by Alembic - please adjust! ### 39 | op.drop_index(op.f("ix_ctf_id"), table_name="ctf") 40 | op.drop_table("ctf") 41 | # ### end Alembic commands ### 42 | -------------------------------------------------------------------------------- /alembic/versions/364592ce1c76_fix_data_types_and_name_for_moderator.py: -------------------------------------------------------------------------------- 1 | """Fix data types and name for moderator 2 | 3 | Revision ID: 364592ce1c76 4 | Revises: fa20029d0cfb 5 | Create Date: 2023-04-09 03:02:09.959641 6 | 7 | """ 8 | from sqlalchemy.dialects import mysql 9 | 10 | from alembic import op 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '364592ce1c76' 14 | down_revision = 'fa20029d0cfb' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.alter_column( 22 | table_name='ban', column_name="moderator", new_column_name="moderator_id", 23 | type_=mysql.BIGINT(display_width=18), nullable=False 24 | ) 25 | op.alter_column( 26 | table_name='infraction', column_name="moderator", new_column_name="moderator_id", 27 | type_=mysql.BIGINT(display_width=18), nullable=False 28 | ) 29 | op.alter_column( 30 | table_name='mute', column_name="moderator", new_column_name="moderator_id", 31 | type_=mysql.BIGINT(display_width=18), nullable=False 32 | ) 33 | op.alter_column( 34 | table_name='user_note', column_name="moderator", new_column_name="moderator_id", 35 | type_=mysql.BIGINT(display_width=18), nullable=False 36 | ) 37 | # ### end Alembic commands ### 38 | 39 | 40 | def downgrade() -> None: 41 | # ### commands auto generated by Alembic - please adjust! ### 42 | op.alter_column( 43 | table_name='ban', column_name="moderator_id", new_column_name="moderator", 44 | type_=mysql.VARCHAR(length=42), nullable=False 45 | ) 46 | op.alter_column( 47 | table_name='infraction', column_name="moderator_id", new_column_name="moderator", 48 | type_=mysql.VARCHAR(length=42), nullable=False 49 | ) 50 | op.alter_column( 51 | table_name='mute', column_name="moderator_id", new_column_name="moderator", 52 | type_=mysql.VARCHAR(length=42), nullable=False 53 | ) 54 | op.alter_column( 55 | table_name='user_note', column_name="moderator_id", new_column_name="moderator", 56 | type_=mysql.VARCHAR(length=42), nullable=False 57 | ) 58 | # ### end Alembic commands ### 59 | -------------------------------------------------------------------------------- /alembic/versions/49c63ed4411d_add_infraction_record_table.py: -------------------------------------------------------------------------------- 1 | """Add infraction_record table 2 | 3 | Revision ID: 49c63ed4411d 4 | Revises: 5bf8bbb7032f 5 | Create Date: 2022-09-01 17:06:19.341659 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from sqlalchemy.dialects import mysql 10 | 11 | from alembic import op 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "49c63ed4411d" 15 | down_revision = "5bf8bbb7032f" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table( 23 | "infraction_record", 24 | sa.Column("id", sa.Integer(), nullable=False), 25 | sa.Column("user_id", mysql.VARCHAR(length=42), nullable=False), 26 | sa.Column("reason", mysql.TEXT(), nullable=False), 27 | sa.Column("weight", sa.Integer(), nullable=False), 28 | sa.Column("moderator", mysql.VARCHAR(length=42), nullable=False), 29 | sa.Column("date", sa.DATE(), server_default=sa.text("curdate()"), nullable=False), 30 | sa.PrimaryKeyConstraint("id"), 31 | ) 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade() -> None: 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.drop_table("infraction_record") 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /alembic/versions/4fc1c39216c9_added_macro_table.py: -------------------------------------------------------------------------------- 1 | """Added macro table 2 | 3 | Revision ID: 4fc1c39216c9 4 | Revises: a5f283a4cfde 5 | Create Date: 2024-12-02 18:56:02.365942 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from sqlalchemy.dialects import mysql 10 | 11 | from alembic import op 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = '4fc1c39216c9' 15 | down_revision = 'a5f283a4cfde' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table('macro', 23 | sa.Column('id', sa.Integer(), nullable=False), 24 | sa.Column('user_id', mysql.BIGINT(display_width=18), nullable=False), 25 | sa.Column('name', mysql.TEXT(), nullable=False, unique=True), 26 | sa.Column('text', mysql.TEXT(), nullable=False), 27 | sa.Column('created_at', mysql.TIMESTAMP(), nullable=False), 28 | sa.PrimaryKeyConstraint('id'), 29 | sa.UniqueConstraint('name') 30 | ) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade() -> None: 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_table('macro') 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /alembic/versions/5b0179a1f13f_rename_infractionrecord_to_infraction.py: -------------------------------------------------------------------------------- 1 | """Rename InfractionRecord to Infraction 2 | 3 | Revision ID: 5b0179a1f13f 4 | Revises: a71c110d3654 5 | Create Date: 2023-04-09 02:42:40.458214 6 | 7 | """ 8 | from alembic import op 9 | 10 | # revision identifiers, used by Alembic. 11 | revision = '5b0179a1f13f' 12 | down_revision = 'a71c110d3654' 13 | branch_labels = None 14 | depends_on = None 15 | 16 | 17 | def upgrade() -> None: 18 | # ### commands auto generated by Alembic - please adjust! ### 19 | op.rename_table("infraction_record", "infraction") 20 | # ### end Alembic commands ### 21 | 22 | 23 | def downgrade() -> None: 24 | # ### commands auto generated by Alembic - please adjust! ### 25 | op.rename_table("infraction", "infraction_record") 26 | # ### end Alembic commands ### 27 | -------------------------------------------------------------------------------- /alembic/versions/5bf8bbb7032f_add_ban_record_table.py: -------------------------------------------------------------------------------- 1 | """Add ban_record table 2 | 3 | Revision ID: 5bf8bbb7032f 4 | Revises: 714c54b442ec 5 | Create Date: 2022-09-01 17:00:37.028176 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from sqlalchemy.dialects import mysql 10 | 11 | from alembic import op 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "5bf8bbb7032f" 15 | down_revision = "714c54b442ec" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table( 23 | "ban_record", 24 | sa.Column("id", sa.Integer(), nullable=False), 25 | sa.Column("user_id", mysql.VARCHAR(length=42), nullable=True), 26 | sa.Column("reason", mysql.TEXT(), nullable=False), 27 | sa.Column("moderator", mysql.VARCHAR(length=42), nullable=True), 28 | sa.Column("unban_time", sa.Integer(), nullable=True), 29 | sa.Column("approved", sa.Boolean(), nullable=False), 30 | sa.Column("unbanned", sa.Boolean(), nullable=False), 31 | sa.Column("timestamp", mysql.TIMESTAMP(), nullable=False), 32 | sa.PrimaryKeyConstraint("id"), 33 | ) 34 | # ### end Alembic commands ### 35 | 36 | 37 | def downgrade() -> None: 38 | # ### commands auto generated by Alembic - please adjust! ### 39 | op.drop_table("ban_record") 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /alembic/versions/6948a2436536_fix_data_types_for_user_id.py: -------------------------------------------------------------------------------- 1 | """Fix data types for user_id 2 | 3 | Revision ID: 6948a2436536 4 | Revises: 364592ce1c76 5 | Create Date: 2023-04-09 03:09:31.265814 6 | 7 | """ 8 | from sqlalchemy.dialects import mysql 9 | 10 | from alembic import op 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '6948a2436536' 14 | down_revision = '364592ce1c76' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.alter_column( 22 | table_name='ban', column_name="user_id", type_=mysql.BIGINT(display_width=18), nullable=False 23 | ) 24 | op.alter_column( 25 | table_name='infraction', column_name="user_id", type_=mysql.BIGINT(display_width=18), nullable=False 26 | ) 27 | op.alter_column( 28 | table_name='mute', column_name="user_id", type_=mysql.BIGINT(display_width=18), nullable=False 29 | ) 30 | op.alter_column( 31 | table_name='user_note', column_name="user_id", type_=mysql.BIGINT(display_width=18), nullable=False 32 | ) 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade() -> None: 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.alter_column( 39 | table_name='ban', column_name="user_id", type_=mysql.VARCHAR(length=42), nullable=False 40 | ) 41 | op.alter_column( 42 | table_name='infraction', column_name="user_id", type_=mysql.VARCHAR(length=42), nullable=False 43 | ) 44 | op.alter_column( 45 | table_name='mute', column_name="user_id", type_=mysql.VARCHAR(length=42), nullable=False 46 | ) 47 | op.alter_column( 48 | table_name='user_note', column_name="user_id", type_=mysql.VARCHAR(length=42), nullable=False 49 | ) 50 | # ### end Alembic commands ### 51 | -------------------------------------------------------------------------------- /alembic/versions/714c54b442ec_add_htb_discord_link_table.py: -------------------------------------------------------------------------------- 1 | """Add htb_discord_link table 2 | 3 | Revision ID: 714c54b442ec 4 | Revises: 5 | Create Date: 2022-09-01 16:46:43.166154 6 | 7 | """ 8 | import sqlalchemy as sa 9 | 10 | from alembic import op 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "714c54b442ec" 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "htb_discord_link", 23 | sa.Column("id", sa.Integer(), nullable=False), 24 | sa.Column("account_identifier", sa.VARCHAR(length=255), nullable=True), 25 | sa.Column("discord_user_id", sa.VARCHAR(length=42), nullable=True), 26 | sa.Column("htb_user_id", sa.VARCHAR(length=255), nullable=True), 27 | sa.PrimaryKeyConstraint("id"), 28 | ) 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade() -> None: 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | op.drop_table("htb_discord_link") 35 | # ### end Alembic commands ### 36 | -------------------------------------------------------------------------------- /alembic/versions/953fc0c9158c_add_mute_record_table.py: -------------------------------------------------------------------------------- 1 | """Add mute_record table 2 | 3 | Revision ID: 953fc0c9158c 4 | Revises: 49c63ed4411d 5 | Create Date: 2022-09-01 17:09:27.270130 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from sqlalchemy.dialects import mysql 10 | 11 | from alembic import op 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "953fc0c9158c" 15 | down_revision = "49c63ed4411d" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table( 23 | "mute_record", 24 | sa.Column("id", sa.Integer(), nullable=False), 25 | sa.Column("user_id", mysql.VARCHAR(length=42), nullable=False), 26 | sa.Column("reason", mysql.TEXT(), nullable=False), 27 | sa.Column("moderator", mysql.VARCHAR(length=42), nullable=False), 28 | sa.Column("unmute_time", sa.Integer(), nullable=False), 29 | sa.PrimaryKeyConstraint("id"), 30 | ) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade() -> None: 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_table("mute_record") 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /alembic/versions/a5f283a4cfde_change_unmute_time_data_type.py: -------------------------------------------------------------------------------- 1 | """Change unmute_time data type 2 | 3 | Revision ID: a5f283a4cfde 4 | Revises: 05c1d218bb76 5 | Create Date: 2023-05-09 13:34:17.055796 6 | 7 | """ 8 | from alembic import op 9 | from sqlalchemy.dialects import mysql 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = 'a5f283a4cfde' 13 | down_revision = '05c1d218bb76' 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade() -> None: 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.alter_column( 21 | table_name='mute', column_name="unmute_time", 22 | existing_type=mysql.INTEGER(display_width=11), 23 | type_=mysql.BIGINT(display_width=18), 24 | nullable=False 25 | ) 26 | # ### end Alembic commands ### 27 | 28 | 29 | def downgrade() -> None: 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | op.alter_column( 32 | table_name='mute', column_name="unmute_time", 33 | existing_type=mysql.BIGINT(display_width=18), 34 | type_=mysql.INTEGER(display_width=11), 35 | nullable=False 36 | ) 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /alembic/versions/a71c110d3654_rename_banrecord_to_ban.py: -------------------------------------------------------------------------------- 1 | """Rename BanRecord to Ban 2 | 3 | Revision ID: a71c110d3654 4 | Revises: 32e8037d780c 5 | Create Date: 2023-03-29 00:55:33.080168 6 | 7 | """ 8 | from alembic import op 9 | 10 | # revision identifiers, used by Alembic. 11 | revision = "a71c110d3654" 12 | down_revision = "32e8037d780c" 13 | branch_labels = None 14 | depends_on = None 15 | 16 | 17 | def upgrade() -> None: 18 | # ### commands auto generated by Alembic - please adjust! ### 19 | op.rename_table("ban_record", "ban") 20 | # ### end Alembic commands ### 21 | 22 | 23 | def downgrade() -> None: 24 | # ### commands auto generated by Alembic - please adjust! ### 25 | op.rename_table("ban", "ban_record") 26 | # ### end Alembic commands ### 27 | -------------------------------------------------------------------------------- /alembic/versions/b372d25359fd_add_user_note_table.py: -------------------------------------------------------------------------------- 1 | """Add user_note table 2 | 3 | Revision ID: b372d25359fd 4 | Revises: 953fc0c9158c 5 | Create Date: 2022-09-01 17:11:33.104212 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from sqlalchemy.dialects import mysql 10 | 11 | from alembic import op 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "b372d25359fd" 15 | down_revision = "953fc0c9158c" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table( 23 | "user_note", 24 | sa.Column("id", sa.Integer(), nullable=False), 25 | sa.Column("user_id", mysql.VARCHAR(length=42), nullable=False), 26 | sa.Column("note", mysql.TEXT(), nullable=False), 27 | sa.Column("moderator", mysql.VARCHAR(length=42), nullable=False), 28 | sa.Column("date", sa.DATE(), nullable=False), 29 | sa.PrimaryKeyConstraint("id"), 30 | ) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade() -> None: 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_table("user_note") 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /alembic/versions/fa20029d0cfb_rename_muterecord_to_mute.py: -------------------------------------------------------------------------------- 1 | """Rename MuteRecord to Mute 2 | 3 | Revision ID: fa20029d0cfb 4 | Revises: 5b0179a1f13f 5 | Create Date: 2023-04-09 02:45:50.844425 6 | 7 | """ 8 | from alembic import op 9 | 10 | # revision identifiers, used by Alembic. 11 | revision = 'fa20029d0cfb' 12 | down_revision = '5b0179a1f13f' 13 | branch_labels = None 14 | depends_on = None 15 | 16 | 17 | def upgrade() -> None: 18 | # ### commands auto generated by Alembic - please adjust! ### 19 | op.rename_table("mute_record", "mute") 20 | # ### end Alembic commands ### 21 | 22 | 23 | def downgrade() -> None: 24 | # ### commands auto generated by Alembic - please adjust! ### 25 | op.rename_table("mute", "mute_record") 26 | # ### end Alembic commands ### 27 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | ignore: 3 | - "tests/**/*" 4 | status: 5 | project: 6 | default: 7 | # basic 8 | target: auto 9 | threshold: 0% 10 | flags: 11 | - unit 12 | paths: 13 | - "src" 14 | # advanced settings 15 | if_ci_failed: error #success, failure, error, ignore 16 | informational: false 17 | only_pulls: false 18 | -------------------------------------------------------------------------------- /contributors.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # The file is based on contributors.sh found in https://github.com/vmware/govmomi/blob/main/scripts/contributors.sh 4 | 5 | file="$(git rev-parse --show-toplevel)/CONTRIBUTORS" 6 | 7 | cat < "$file" 8 | # People who have contributed to this repository. 9 | # 10 | # This script is generated by $(basename "$0") 11 | # 12 | 13 | # Special thanks to Noahbot team: 14 | Birb <7443606+HeckerBirb@users.noreply.github.com> 15 | clubby789 16 | Emma Samms 17 | Khaotic <6080590+khaoticdude@users.noreply.github.com> 18 | TheWeeknd <98106526+ToxicBiohazard@users.noreply.github.com> 19 | 20 | EOF 21 | 22 | git log --format='%aN <%aE>' | sort -uf >> "$file" -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | 5 | bot: 6 | build: . 7 | env_file: 8 | - .env 9 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "Hackster" 3 | version = "1.4.4" 4 | description = "Awesome hackster created by Hack The Box" 5 | authors = [ 6 | "dimoschi ", 7 | "makelarisjr ", 8 | "0xEmma " 9 | ] 10 | license = "MIT" 11 | package-mode = false 12 | 13 | [tool.poetry.dependencies] 14 | python = "^3.11" 15 | aiodns = "^3.0.0" 16 | arrow = "^1.2.3" 17 | py-cord = "^2.4.1" 18 | pydantic = { extras = ["dotenv"], version = "^1.10.7" } 19 | taskipy = "^1.10.4" 20 | sqlalchemy = { extras = ["asyncio"], version = "^2.0.9" } 21 | bcrypt = "^4.0.1" 22 | asyncmy = "^0.2.7" 23 | fastapi = "^0.109.1" 24 | sentry-sdk = { extras = ["sqlalchemy"], version = "^2.8.0" } 25 | uvicorn = "^0.21.1" 26 | alembic = "^1.10.3" 27 | pymysql = "^1.1.1" 28 | prometheus-client = "^0.16.0" 29 | toml = "^0.10.2" 30 | slack-sdk = "^3.27.1" 31 | 32 | [tool.poetry.group.dev.dependencies] 33 | colorlog = "^6.5.0" 34 | coverage = "^7.2" 35 | flake8 = "^6.0" 36 | flake8-annotations = "^3.0" 37 | flake8-bugbear = "^23.3" 38 | flake8-docstrings = "^1.6.0" 39 | flake8-isort = "^6.0" 40 | flake8-string-format = "^0.3.0" 41 | flake8-tidy-imports = "^4.8.0" 42 | flake8-todo = "^0.7" 43 | pep8-naming = "^0.13" 44 | pre-commit = "^3.2" 45 | pytest = "^7.1.2" 46 | pytest-asyncio = "^0.21" 47 | python-dotenv = "^1.0" 48 | ipython = "^8.12.0" 49 | ipdb = "^0.13.13" 50 | aioresponses = "^0.7.4" 51 | pytest-mock = "^3.10.0" 52 | 53 | [build-system] 54 | requires = ["poetry-core>=1.0.0"] 55 | build-backend = "poetry.core.masonry.api" 56 | 57 | [tool.taskipy.tasks] 58 | start = "python -m src" 59 | test = "coverage run -m pytest tests/" 60 | coverage = "coverage" 61 | report = "coverage report" 62 | lint = "pre-commit run --all-files" 63 | precommit = "pre-commit install" 64 | 65 | [tool.isort] 66 | profile = "black" 67 | line_length = 119 68 | honor_noqa = true 69 | skip_gitignore = true 70 | -------------------------------------------------------------------------------- /resources/unisex_baby_names.txt: -------------------------------------------------------------------------------- 1 | Addison 2 | Adrian 3 | Aiden 4 | Ainsley 5 | Alex 6 | Alfie 7 | Ali 8 | Amory 9 | Andie 10 | Andy 11 | Angel 12 | Archer 13 | Arden 14 | Ari 15 | Ariel 16 | Armani 17 | Arya 18 | Ash 19 | Ashley 20 | Ashton 21 | Aspen 22 | Athena 23 | Aubrey 24 | Auden 25 | August 26 | Avery 27 | Avis 28 | Bailey 29 | Baker 30 | Bay 31 | Bellamy 32 | Bergen 33 | Bevan 34 | Billie 35 | Billy 36 | Blaine 37 | Blair 38 | Blake 39 | Blue 40 | Bobby 41 | Bowie 42 | Brady 43 | Brennan 44 | Brent 45 | Brett 46 | Briar 47 | Brighton 48 | Britton 49 | Brooke 50 | Brooklyn 51 | Brooks 52 | Caelan 53 | Cameron 54 | Campbell 55 | Carey 56 | Carmel 57 | Carmen 58 | Carroll 59 | Carson 60 | Carter 61 | Casey 62 | Cassidy 63 | Chance 64 | Channing 65 | Charley 66 | Charlie 67 | Chris 68 | Clay 69 | Clayton 70 | Cody 71 | Cole 72 | Corey 73 | Dakota 74 | Dale 75 | Dallas 76 | Dana 77 | Dane 78 | Darby 79 | Daryl 80 | Dawson 81 | Delta 82 | Denver 83 | Devin 84 | Dorian 85 | Drew 86 | Dylan 87 | Easton 88 | Eddie 89 | Eden 90 | Elliott 91 | Ellis 92 | Ellison 93 | Ember 94 | Emerson 95 | Emery 96 | Emory 97 | Erin 98 | Evelyn 99 | Ezra 100 | Finley 101 | Finn 102 | Florian 103 | Flynn 104 | Francis 105 | Frankie 106 | Gabriel 107 | Gene 108 | Genesis 109 | George 110 | Glen 111 | Grey 112 | Hadley 113 | Harley 114 | Harlow 115 | Harper 116 | Haven 117 | Hayden 118 | Henry 119 | Honour 120 | Hudson 121 | Hunter 122 | Indigo 123 | Jack 124 | Jackie 125 | Jade 126 | Jaden 127 | James 128 | Jamie 129 | Jan 130 | Jayden 131 | Jean 132 | Jesse 133 | Jessie 134 | Jody 135 | Jordan 136 | Jude 137 | Jules 138 | Julian 139 | Kaden 140 | Kai 141 | Kameron 142 | Keegan 143 | Keely 144 | Kelly 145 | Kelsey 146 | Kendall 147 | Kennedy 148 | Kerry 149 | Kim 150 | Kit 151 | Kylar 152 | Kyle 153 | Kylin 154 | Kyrie 155 | Lake 156 | Landon 157 | Lane 158 | Leah 159 | Lee 160 | Leighton 161 | Lennon 162 | Lennox 163 | Leslie 164 | Lincoln 165 | Linden 166 | Lindsay 167 | Logan 168 | London 169 | Lonnie 170 | Loren 171 | Lou 172 | Mackenzie 173 | Maddox 174 | Madiso 175 | Marley 176 | Marlow 177 | Mason 178 | Max 179 | Maxwell 180 | Mckenna 181 | Mckenzie 182 | Micah 183 | Michael 184 | Milan 185 | Miller 186 | Monroe 187 | Montana 188 | Morgan 189 | Murphy 190 | Nevada 191 | Nicky 192 | Nico 193 | Nikita 194 | Noel 195 | Noelle 196 | Nolan 197 | Nova 198 | Ocean 199 | Owen 200 | Paige 201 | Paisley 202 | Paris 203 | Parker 204 | Pat 205 | Payton 206 | Peace 207 | Perry 208 | Peyton 209 | Phoenix 210 | Piper 211 | Poet 212 | Quincy 213 | Quinn 214 | Raleigh 215 | Ramsey 216 | Raphael 217 | Ray 218 | Rayne 219 | Reagan 220 | Reed 221 | Reese 222 | Regan 223 | Remi 224 | Remington 225 | Remy 226 | Rene 227 | Riley 228 | Rio 229 | Ripley 230 | River 231 | Roan 232 | Robin 233 | Robyn 234 | Rory 235 | Rowan 236 | Royal 237 | Ryan 238 | Ryder 239 | Rylan 240 | Sage 241 | Sailor 242 | Salem 243 | Sam 244 | Sasha 245 | Sawyer 246 | Sean 247 | Shae 248 | Shannon 249 | Shawn 250 | Shay 251 | Shiloh 252 | Sidney 253 | Skye 254 | Skylar 255 | Sloane 256 | Spencer 257 | Sterling 258 | Stevie 259 | Storm 260 | Sunny 261 | Sutton 262 | Sydney 263 | Tanner 264 | Tate 265 | Tatum 266 | Taylor 267 | Teagan 268 | Tennessee 269 | Tennyson 270 | Teri 271 | Terry 272 | Tiernan 273 | Tobin 274 | Toby 275 | Toni 276 | Tony 277 | Tori 278 | Tracy 279 | Trinity 280 | Tristan 281 | True 282 | Tyler 283 | Umber 284 | Val 285 | Valentine 286 | Venice 287 | Vick 288 | Wallace 289 | Wesley 290 | West 291 | Weston 292 | Whitney 293 | Wilder 294 | Windsor 295 | Wynn 296 | Winter 297 | Wisdom 298 | Wren 299 | Wyatt 300 | Zeph 301 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | import logging.handlers 2 | import os 3 | from pathlib import Path 4 | 5 | import aiohttp 6 | import arrow 7 | import sentry_sdk 8 | from aiohttp import TraceRequestEndParams 9 | from sentry_sdk.integrations.asyncio import AsyncioIntegration 10 | from sentry_sdk.integrations.fastapi import FastApiIntegration 11 | from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration 12 | from sentry_sdk.integrations.starlette import StarletteIntegration 13 | 14 | from src.core import settings 15 | 16 | # Set timestamp of when execution started. 17 | start_time = arrow.utcnow() 18 | 19 | # Get up project root. 20 | root = Path(__file__).parent.parent 21 | settings.ROOT = root 22 | 23 | # Set up file logging. 24 | log_dir = os.path.join(root, "logs") 25 | log_file = os.path.join(log_dir, f"{settings.bot.NAME.lower()}_{arrow.utcnow().strftime('%d-%m-%Y')}.log") 26 | os.makedirs(log_dir, exist_ok=True) 27 | 28 | # File handler rotates logs every 5 MB. 29 | file_handler = logging.handlers.RotatingFileHandler( 30 | log_file, maxBytes=5 * (2 ** 20), backupCount=10, encoding="utf-8", ) 31 | file_handler.setLevel(logging.DEBUG) 32 | 33 | # Console handler prints to terminal. 34 | console_handler = logging.StreamHandler() 35 | console_handler.setLevel(logging.DEBUG if settings.DEBUG else logging.INFO) 36 | 37 | # Format configuration. 38 | fmt = "%(asctime)s - %(name)s %(levelname)s: %(message)s" 39 | datefmt = "%H:%M:%S" 40 | 41 | # Add colors for logging if available. 42 | try: 43 | from colorlog import ColoredFormatter 44 | 45 | console_handler.setFormatter( 46 | ColoredFormatter(fmt=f"%(log_color)s{fmt}", datefmt=datefmt) 47 | ) 48 | except ModuleNotFoundError: 49 | pass 50 | 51 | # Remove old loggers, if any. 52 | root = logging.getLogger() 53 | if root.handlers: 54 | for handler in root.handlers: 55 | root.removeHandler(handler) 56 | 57 | # Silence irrelevant loggers. 58 | logging.getLogger("discord").setLevel(logging.INFO) 59 | logging.getLogger("discord.gateway").setLevel(logging.ERROR) 60 | logging.getLogger("asyncio").setLevel(logging.ERROR) 61 | 62 | # Setup new logging configuration. 63 | logging.basicConfig( 64 | format=fmt, datefmt=datefmt, level=logging.DEBUG, handlers=[console_handler, file_handler] 65 | ) 66 | 67 | if settings.SENTRY_DSN and not settings.DEBUG: 68 | sentry_sdk.init( 69 | dsn=settings.SENTRY_DSN, 70 | release=settings.VERSION, 71 | environment=settings.bot.ENVIRONMENT if settings.bot.ENVIRONMENT else "local", 72 | integrations=[ 73 | SqlalchemyIntegration(), 74 | AsyncioIntegration(), 75 | StarletteIntegration(transaction_style="url"), 76 | FastApiIntegration(transaction_style="url"), 77 | ], 78 | ) 79 | 80 | 81 | async def on_request_end(session, context, params: TraceRequestEndParams) -> None: 82 | """Log all HTTP requests.""" 83 | resp = params.response 84 | 85 | # Format and send logging message. 86 | protocol = f"HTTP/{resp.version.major}.{resp.version.minor}" 87 | message = f'"{resp.method} - {protocol}" {resp.url} <{resp.status}>' 88 | logging.getLogger('aiohttp.client').debug(message) 89 | 90 | 91 | # Configure aiohttp logging. 92 | trace_config = aiohttp.TraceConfig() 93 | trace_config.on_request_end.append(on_request_end) 94 | -------------------------------------------------------------------------------- /src/__main__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from src.bot import bot 4 | from src.core import settings 5 | from src.utils.extensions import walk_extensions 6 | from src.webhooks.server import server 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | # Load all cogs extensions. 11 | for ext in walk_extensions(): 12 | if ext == "src.cmds.automation.scheduled_tasks": 13 | continue 14 | bot.load_extension(ext) 15 | 16 | if __name__ == "__main__": 17 | logger.info("Starting bot & webhook server") 18 | logger.debug(f"Starting webhook server listening on port: {settings.WEBHOOK_PORT}") 19 | bot.loop.create_task(server.serve()) 20 | logger.debug(f"Starting bot with token: {settings.bot.TOKEN}") 21 | bot.loop.create_task(bot.run(settings.bot.TOKEN)) 22 | -------------------------------------------------------------------------------- /src/cmds/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/src/cmds/__init__.py -------------------------------------------------------------------------------- /src/cmds/automation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/src/cmds/automation/__init__.py -------------------------------------------------------------------------------- /src/cmds/automation/auto_verify.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from discord import Member, Message, User 4 | from discord.ext import commands 5 | from sqlalchemy import select 6 | 7 | from src.bot import Bot 8 | from src.database.models import HtbDiscordLink 9 | from src.database.session import AsyncSessionLocal 10 | from src.helpers.verification import get_user_details, process_identification 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class MessageHandler(commands.Cog): 16 | """Cog for handling verification automatically.""" 17 | 18 | def __init__(self, bot: Bot): 19 | self.bot = bot 20 | 21 | async def process_reverification(self, member: Member | User) -> None: 22 | """Re-verifation process for a member.""" 23 | async with AsyncSessionLocal() as session: 24 | stmt = ( 25 | select(HtbDiscordLink) 26 | .where(HtbDiscordLink.discord_user_id == member.id) 27 | .order_by(HtbDiscordLink.id) 28 | .limit(1) 29 | ) 30 | result = await session.scalars(stmt) 31 | htb_discord_link: HtbDiscordLink = result.first() 32 | 33 | if not htb_discord_link: 34 | raise VerificationError(f"HTB Discord link for user {member.name} with ID {member}") 35 | 36 | member_token: str = htb_discord_link.account_identifier 37 | 38 | if member_token is None: 39 | raise VerificationError(f"HTB account identifier for user {member.name} with ID {member.id} not found") 40 | 41 | logger.debug(f"Processing re-verify of member {member.name} ({member.id}).") 42 | htb_details = await get_user_details(member_token) 43 | if htb_details is None: 44 | raise VerificationError(f"Retrieving user details for user {member.name} with ID {member.id} failed") 45 | 46 | await process_identification(htb_details, user=member, bot=self.bot) 47 | 48 | @commands.Cog.listener() 49 | @commands.cooldown(1, 60, commands.BucketType.user) 50 | async def on_message(self, ctx: Message) -> None: 51 | """Run commands in the context of a message.""" 52 | # Return if the message was sent by the bot to avoid recursion. 53 | if ctx.author.bot: 54 | return 55 | 56 | try: 57 | await self.process_reverification(ctx.author) 58 | except VerificationError as exc: 59 | logger.debug(f"HTB Discord link for user {ctx.author.name} with ID {ctx.author.id} not found", exc_info=exc) 60 | 61 | @commands.Cog.listener() 62 | @commands.cooldown(1, 3600, commands.BucketType.user) 63 | async def on_member_join(self, member: Member) -> None: 64 | """Run commands in the context of a member join.""" 65 | try: 66 | await self.process_reverification(member) 67 | except VerificationError as exc: 68 | logger.debug(f"HTB Discord link for user {member.name} with ID {member.id} not found", exc_info=exc) 69 | 70 | 71 | class VerificationError(Exception): 72 | """Verification error.""" 73 | 74 | 75 | def setup(bot: Bot) -> None: 76 | """Load the `MessageHandler` cog.""" 77 | bot.add_cog(MessageHandler(bot)) 78 | -------------------------------------------------------------------------------- /src/cmds/automation/scheduled_tasks.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from datetime import datetime, timedelta 4 | 5 | from discord.ext import commands, tasks 6 | from sqlalchemy import select 7 | 8 | from src import settings 9 | from src.bot import Bot 10 | from src.database.models import Ban, Mute 11 | from src.database.session import AsyncSessionLocal 12 | from src.helpers.ban import unban_member, unmute_member 13 | from src.helpers.schedule import schedule 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class ScheduledTasks(commands.Cog): 19 | """Cog for handling scheduled tasks.""" 20 | 21 | def __init__(self, bot: Bot): 22 | self.bot = bot 23 | self.all_tasks.start() 24 | 25 | @tasks.loop(minutes=1) 26 | async def all_tasks(self) -> None: 27 | """Gathers all scheduled tasks.""" 28 | logger.debug("Gathering scheduled tasks...") 29 | await self.auto_unban() 30 | await self.auto_unmute() 31 | # await asyncio.gather(self.auto_unmute()) 32 | logger.debug("Scheduling completed.") 33 | 34 | async def auto_unban(self) -> None: 35 | """Task to automatically unban members.""" 36 | unban_tasks = [] 37 | unban_time = datetime.timestamp(datetime.now() + timedelta(minutes=1)) * 1000 38 | logger.debug(f"Checking for bans to remove until {unban_time}.") 39 | async with AsyncSessionLocal() as session: 40 | result = await session.scalars( 41 | select(Ban).filter(Ban.unbanned.is_(False)).filter(Ban.unban_time <= unban_time) 42 | ) 43 | bans = result.all() 44 | logger.debug(f"Got {len(bans)} bans from DB.") 45 | 46 | for guild_id in settings.guild_ids: 47 | logger.debug(f"Running for guild: {guild_id}.") 48 | guild = self.bot.get_guild(guild_id) 49 | if not guild: 50 | logger.warning(f"Unable to find guild with ID {guild_id}.") 51 | continue 52 | 53 | for ban in bans: 54 | run_at = datetime.fromtimestamp(ban.unban_time) 55 | logger.debug( 56 | f"Got user_id: {ban.user_id} and unban timestamp: {run_at} from DB." 57 | ) 58 | member = await self.bot.get_member_or_user(guild, ban.user_id) 59 | if not member: 60 | logger.info(f"Member with id: {ban.user_id} not found.") 61 | continue 62 | unban_task = schedule(unban_member(guild, member), run_at=run_at) 63 | unban_tasks.append(unban_task) 64 | logger.info(f"Scheduled unban task for user_id {ban.user_id} at {run_at}.") 65 | 66 | await asyncio.gather(*unban_tasks) 67 | 68 | async def auto_unmute(self) -> None: 69 | """Task to automatically unmute members.""" 70 | unmute_tasks = [] 71 | unmute_time = datetime.timestamp(datetime.now() + timedelta(minutes=1)) * 1000 72 | logger.debug(f"Checking for mutes to remove until {unmute_time}.") 73 | async with AsyncSessionLocal() as session: 74 | result = await session.scalars(select(Mute).filter(Mute.unmute_time <= unmute_time)) 75 | mutes = result.all() 76 | logger.debug(f"Got {len(mutes)} mutes from DB.") 77 | 78 | for guild_id in settings.guild_ids: 79 | guild = self.bot.get_guild(guild_id) 80 | if not guild: 81 | logger.warning(f"Unable to find guild with ID {guild_id}.") 82 | continue 83 | 84 | for mute in mutes: 85 | run_at = datetime.fromtimestamp(mute.unmute_time) 86 | logger.debug( 87 | "Got user_id: {user_id} and unmute timestamp: {unmute_ts} from DB.".format( 88 | user_id=mute.user_id, unmute_ts=run_at 89 | ) 90 | ) 91 | member = await self.bot.get_member_or_user(guild, mute.user_id) 92 | if not member: 93 | logger.info(f"Member with id: {mute.user_id} not found.") 94 | continue 95 | unmute_task = schedule(unmute_member(guild, member), run_at=run_at) 96 | unmute_tasks.append(unmute_task) 97 | logger.info(f"Scheduled unban task for user_id {mute.user_id} at {str(run_at)}.") 98 | 99 | 100 | await asyncio.gather(*unmute_tasks) 101 | 102 | 103 | def setup(bot: Bot) -> None: 104 | """Load the `ScheduledTasks` cog.""" 105 | bot.add_cog(ScheduledTasks(bot)) 106 | -------------------------------------------------------------------------------- /src/cmds/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/src/cmds/core/__init__.py -------------------------------------------------------------------------------- /src/cmds/core/channel.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from discord import ApplicationContext, Interaction, Option, WebhookMessage, slash_command 4 | from discord.abc import GuildChannel 5 | from discord.ext import commands 6 | from discord.ext.commands import has_any_role 7 | 8 | from src.bot import Bot 9 | from src.core import settings 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class ChannelCog(commands.Cog): 15 | """Ban related commands.""" 16 | 17 | def __init__(self, bot: Bot): 18 | self.bot = bot 19 | 20 | @slash_command( 21 | guild_ids=settings.guild_ids, 22 | description="Add slow-mode to the channel. Specifying a value of 0 removes the slow-mode again." 23 | ) 24 | @has_any_role(*settings.role_groups.get("ALL_ADMINS"), *settings.role_groups.get("ALL_MODS")) 25 | async def slowmode( 26 | self, ctx: ApplicationContext, channel: GuildChannel, seconds: int 27 | ) -> Interaction | WebhookMessage: 28 | """Add slow-mode to the channel. Specifying a value of 0 removes the slow-mode again.""" 29 | guild = ctx.guild 30 | 31 | if isinstance(channel, str): 32 | try: 33 | channel_id = int(channel.replace("<#", "").replace(">", "")) 34 | channel = guild.get_channel(channel_id) 35 | except ValueError: 36 | return await ctx.respond( 37 | f"I don't know what {channel} is. Please use #channel-reference or a channel ID." 38 | ) 39 | 40 | try: 41 | seconds = int(seconds) 42 | except ValueError: 43 | return await ctx.respond(f"Malformed amount of seconds: {seconds}.") 44 | 45 | if seconds < 0: 46 | seconds = 0 47 | if seconds > 30: 48 | seconds = 30 49 | await channel.edit(slowmode_delay=seconds) 50 | return await ctx.respond(f"Slow-mode set in {channel.name} to {seconds} seconds.") 51 | 52 | @slash_command(guild_ids=settings.guild_ids) 53 | @has_any_role( 54 | *settings.role_groups.get("ALL_ADMINS"), 55 | *settings.role_groups.get("ALL_SR_MODS"), 56 | *settings.role_groups.get("ALL_MODS") 57 | ) 58 | async def cleanup( 59 | self, ctx: ApplicationContext, 60 | count: Option(int, "How many messages to delete", required=True, default=5), 61 | ) -> Interaction | WebhookMessage: 62 | """Removes the past X messages!""" 63 | await ctx.channel.purge(limit=count + 1, bulk=True, check=lambda m: m != ctx.message) 64 | # Don't delete the command that triggered this deletion 65 | return await ctx.respond(f"Deleted {count} messages.", ephemeral=True) 66 | 67 | 68 | def setup(bot: Bot) -> None: 69 | """Load the `ChannelManageCog` cog.""" 70 | bot.add_cog(ChannelCog(bot)) 71 | -------------------------------------------------------------------------------- /src/cmds/core/fun.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from discord import ApplicationContext, Interaction, Option, WebhookMessage 4 | from discord.ext.commands import BucketType, Cog, cooldown, has_any_role, slash_command 5 | 6 | from src.bot import Bot 7 | from src.core import settings 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class Fun(Cog): 13 | """Fun commands.""" 14 | 15 | def __init__(self, bot: Bot): 16 | self.bot = bot 17 | 18 | @slash_command(guild_ids=settings.guild_ids, name="ban-song") 19 | @cooldown(1, 60, BucketType.user) 20 | @has_any_role(*settings.role_groups.get("ALL_ADMINS"), *settings.role_groups.get("ALL_SR_MODS")) 21 | async def ban_song(self, ctx: ApplicationContext) -> Interaction | WebhookMessage: 22 | """Ban ban ban ban ban ...""" 23 | return await ctx.respond("https://www.youtube.com/watch?v=FXPKJUE86d0") 24 | 25 | @slash_command(guild_ids=settings.guild_ids) 26 | @cooldown(1, 60, BucketType.user) 27 | async def google( 28 | self, ctx: ApplicationContext, query: Option(str, "What do you need help googling?") 29 | ) -> Interaction | WebhookMessage: 30 | """Let me google that for you!""" 31 | goggle = query 32 | goggle = goggle.replace("@", "") 33 | goggle = goggle.replace(" ", "%20") 34 | goggle = goggle.replace("&", "") 35 | goggle = goggle.replace("<", "") 36 | goggle = goggle.replace(">", "") 37 | return await ctx.respond(f"https://letmegooglethat.com/?q={goggle}") 38 | 39 | @slash_command(guild_ids=settings.guild_ids, name="start-here", default_permission=True) 40 | @cooldown(1, 60, BucketType.user) 41 | async def start_here(self, ctx: ApplicationContext) -> Interaction | WebhookMessage: 42 | """Get Started.""" 43 | return await ctx.respond( 44 | "Get Started with the HTB Beginners Bible: https://www.hackthebox.com/blog/learn-to-hack-beginners-bible" 45 | ) 46 | 47 | 48 | def setup(bot: Bot) -> None: 49 | """Load the `Fun` cog.""" 50 | bot.add_cog(Fun(bot)) 51 | -------------------------------------------------------------------------------- /src/cmds/core/history.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import date, timedelta 3 | from typing import Callable, List, Sequence 4 | 5 | import arrow 6 | import discord 7 | from discord import ApplicationContext, Embed, Interaction, WebhookMessage, slash_command 8 | from discord.ext import commands 9 | from discord.ext.commands import has_any_role 10 | from sqlalchemy import select 11 | 12 | from src.bot import Bot 13 | from src.core import settings 14 | from src.database.models import Infraction, UserNote 15 | from src.database.session import AsyncSessionLocal 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class HistoryCog(commands.Cog): 21 | """Given a Discord user, show their history (notes, infractions, etc.).""" 22 | 23 | def __init__(self, bot: Bot): 24 | self.bot = bot 25 | 26 | @slash_command( 27 | guild_ids=settings.guild_ids, 28 | description="Print the infraction history and basic details about the Discord user.", 29 | ) 30 | @has_any_role( 31 | *settings.role_groups.get("ALL_ADMINS"), *settings.role_groups.get("ALL_MODS"), 32 | *settings.role_groups.get("ALL_HTB_STAFF") 33 | ) 34 | async def history(self, ctx: ApplicationContext, user: discord.Member) -> Interaction | WebhookMessage: 35 | """Print the infraction history and basic details about the Discord user.""" 36 | member = await self.bot.get_member_or_user(ctx.guild, user.id) 37 | if not member: 38 | return await ctx.respond( 39 | f"Error: cannot get history - user {user} was deleted from Discord entirely.", delete_after=15 40 | ) 41 | 42 | today_date = arrow.utcnow().date() 43 | 44 | async with AsyncSessionLocal() as session: 45 | stmt = select(UserNote).filter(UserNote.user_id == user.id) 46 | result = await session.scalars(stmt) 47 | notes: Sequence[UserNote] = result.all() 48 | 49 | stmt = select(Infraction).filter(Infraction.user_id == user.id) 50 | result = await session.scalars(stmt) 51 | infractions: Sequence[Infraction] = result.all() 52 | 53 | expired_infractions = sum(1 for inf in infractions if (inf.date - today_date).days < -90) 54 | 55 | if isinstance(member, discord.Member): 56 | join_date = member.joined_at.date() 57 | else: 58 | join_date = "Left" 59 | 60 | creation_date = member.created_at.date() 61 | strike_value = 0 62 | for infraction in infractions: 63 | strike_value += infraction.weight 64 | 65 | summary_text = f""" 66 | **{member.name}** 67 | Total infraction(s): **{len(infractions)}** 68 | Expired: **{expired_infractions}** 69 | Active: **{len(infractions) - expired_infractions}** 70 | Current strike value: **{strike_value}/3** 71 | Join date: **{join_date}** 72 | Creation Date: **{creation_date}** 73 | """ 74 | if strike_value >= 3: 75 | summary_text += f"\n**Review needed** by Sr. Mod or Admin: **{strike_value}/3 strikes**." 76 | 77 | embed = Embed(title="Moderation History", description=f"{summary_text}", color=0xB98700) 78 | if member.avatar is not None: 79 | embed.set_thumbnail(url=member.avatar) 80 | self._embed_titles_of( 81 | embed, 82 | entry_type="infractions", history_entries=infractions, today_date=today_date, 83 | entry_handler=self._produce_inf_text, 84 | ) 85 | self._embed_titles_of( 86 | embed, 87 | entry_type="notes", history_entries=notes, today_date=today_date, 88 | entry_handler=self._produce_note_text 89 | ) 90 | 91 | if len(embed) > 6000: 92 | return await ctx.respond(f"History embed is too big to send ({len(embed)}/6000 allowed chars).") 93 | else: 94 | return await ctx.respond(embed=embed) 95 | 96 | @staticmethod 97 | def _embed_titles_of( 98 | embed: Embed, entry_type: str, history_entries: Sequence[UserNote | Infraction], today_date: date, 99 | entry_handler: Callable[[UserNote | Infraction, date], str] 100 | ) -> None: 101 | """ 102 | Add formatted titles of a specific entry type to the given embed. 103 | 104 | This function populates the provided embed with "title" fields, containing the text 105 | formatted by the entry_handler for the specified entry_type (e.g., infractions or notes). 106 | The embed is mutated in-place by this function. 107 | 108 | Args: 109 | embed (Embed): The embed object to populate with title fields. 110 | entry_type (str): The type of history entries being processed (e.g., "Infraction", "Note"). 111 | history_entries (list): A list of history entries of the specified entry_type. 112 | today_date (date): The current date, used for relative date formatting. 113 | entry_handler (Callable[[UserNote, date], str]): A function that takes a history entry and the current date 114 | as arguments, and returns a formatted string. 115 | 116 | Returns: 117 | None 118 | """ 119 | entry_records: List[List[str]] = [[]] 120 | if history_entries is not None: 121 | current_row = 0 122 | for entry in history_entries: 123 | entry_text = entry_handler(entry, today_date=today_date) 124 | 125 | if sum(len(r) for r in entry_records[current_row]) + len(entry_text) > 1000: 126 | entry_records.append(list()) 127 | current_row += 1 128 | entry_records[current_row].append(entry_text) 129 | 130 | if len(entry_records[0]) == 0: 131 | embed.add_field(name=f"{entry_type.capitalize()}:", value=f"No {entry_type.lower()}.", inline=False) 132 | else: 133 | for i in range(0, len(entry_records)): 134 | embed.add_field( 135 | name=f"{entry_type.capitalize()} ({i + 1}/{len(entry_records)}):", 136 | value="\n\n".join(entry_records[i]), 137 | inline=False, 138 | ) 139 | 140 | @staticmethod 141 | def _produce_note_text(note: UserNote, today_date: date) -> str: 142 | """Produces a single note line in the embed containing basic information about the note and the note itself.""" 143 | return ( 144 | f"#{note.id} by <@{note.moderator_id}> on {note.date}: " 145 | f"{note.note if len(note.note) <= 300 else note.note[:300] + '...'}" 146 | ) 147 | 148 | @staticmethod 149 | def _produce_inf_text(infraction: Infraction, today_date: date) -> str: 150 | """Produces a formatted block of text of an infraction, containing all relevant information.""" 151 | two_weeks_ago = today_date - timedelta(days=14) 152 | expired_status = "Active" if infraction.date >= two_weeks_ago else "Expired" 153 | 154 | return f"""#{infraction.id}, weight: {infraction.weight} 155 | Issued by <@{infraction.moderator_id}> on {infraction.date} ({expired_status}): 156 | {infraction.reason if len(infraction.reason) <= 300 else infraction.reason[:300] + '...'}""" 157 | 158 | 159 | def setup(bot: Bot) -> None: 160 | """Load the `HistoryCog` cog.""" 161 | bot.add_cog(HistoryCog(bot)) 162 | -------------------------------------------------------------------------------- /src/cmds/core/identify.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Sequence 3 | 4 | import discord 5 | from discord import ApplicationContext, Interaction, WebhookMessage, slash_command 6 | from discord.ext import commands 7 | from discord.ext.commands import cooldown 8 | from sqlalchemy import select 9 | 10 | from src.bot import Bot 11 | from src.core import settings 12 | from src.database.models import HtbDiscordLink 13 | from src.database.session import AsyncSessionLocal 14 | from src.helpers.verification import get_user_details, process_identification 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class IdentifyCog(commands.Cog): 20 | """Identify discord member with HTB.""" 21 | 22 | def __init__(self, bot: Bot): 23 | self.bot = bot 24 | 25 | @slash_command( 26 | guild_ids=settings.guild_ids, 27 | description="Identify yourself on the HTB Discord server by linking your HTB account ID to your Discord user " 28 | "ID.", guild_only=False 29 | ) 30 | @cooldown(1, 60, commands.BucketType.user) 31 | async def identify(self, ctx: ApplicationContext, account_identifier: str) -> Interaction | WebhookMessage: 32 | """Identify yourself on the HTB Discord server by linking your HTB account ID to your Discord user ID.""" 33 | if len(account_identifier) != 60: 34 | return await ctx.respond( 35 | "This Account Identifier does not appear to be the right length (must be 60 characters long).", 36 | ephemeral=True 37 | ) 38 | 39 | await ctx.respond("Identification initiated, please wait...", ephemeral=True) 40 | htb_user_details = await get_user_details(account_identifier) 41 | if htb_user_details is None: 42 | embed = discord.Embed(title="Error: Invalid account identifier.", color=0xFF0000) 43 | return await ctx.respond(embed=embed, ephemeral=True) 44 | 45 | json_htb_user_id = htb_user_details["user_id"] 46 | 47 | author = ctx.user 48 | member = await self.bot.get_or_fetch_user(author.id) 49 | if not member: 50 | return await ctx.respond(f"Error getting guild member with id: {author.id}.") 51 | 52 | # Step 1: Check if the Account Identifier has already been recorded and if they are the previous owner. 53 | # Scenario: 54 | # - I create a new Discord account. 55 | # - I reuse my previous Account Identifier. 56 | # - I now have an "alt account" with the same roles. 57 | async with AsyncSessionLocal() as session: 58 | stmt = ( 59 | select(HtbDiscordLink) 60 | .filter(HtbDiscordLink.account_identifier == account_identifier) 61 | .order_by(HtbDiscordLink.id.desc()) 62 | .limit(1) 63 | ) 64 | result = await session.scalars(stmt) 65 | most_recent_rec: HtbDiscordLink = result.first() 66 | 67 | if most_recent_rec and most_recent_rec.discord_user_id_as_int != member.id: 68 | error_desc = ( 69 | f"Verified user {member.mention} tried to identify as another identified user.\n" 70 | f"Current Discord UID: {member.id}\n" 71 | f"Other Discord UID: {most_recent_rec.discord_user_id}\n" 72 | f"Related HTB UID: {most_recent_rec.htb_user_id}" 73 | ) 74 | embed = discord.Embed(title="Identification error", description=error_desc, color=0xFF2429) 75 | await self.bot.get_channel(settings.channels.VERIFY_LOGS).send(embed=embed) 76 | 77 | return await ctx.respond( 78 | "Identification error: please contact an online Moderator or Administrator for help.", ephemeral=True 79 | ) 80 | 81 | # Step 2: Given the htb_user_id from JSON, check if each discord_user_id are different from member.id. 82 | # Scenario: 83 | # - I have a Discord account that is linked already to a "Hacker" role. 84 | # - I create a new HTB account. 85 | # - I identify with the new account. 86 | # - `SELECT * FROM htb_discord_link WHERE htb_user_id = %s` will be empty, 87 | # because the new account has not been verified before. All is good. 88 | # - I am now "Noob" rank. 89 | async with AsyncSessionLocal() as session: 90 | stmt = select(HtbDiscordLink).filter(HtbDiscordLink.htb_user_id == json_htb_user_id) 91 | result = await session.scalars(stmt) 92 | user_links: Sequence[HtbDiscordLink] = result.all() 93 | 94 | discord_user_ids = {u_link.discord_user_id_as_int for u_link in user_links} 95 | if discord_user_ids and member.id not in discord_user_ids: 96 | orig_discord_ids = ", ".join([f"<@{id_}>" for id_ in discord_user_ids]) 97 | error_desc = (f"The HTB account {json_htb_user_id} attempted to be identified by user <@{member.id}>, " 98 | f"but is tied to another Discord account.\n" 99 | f"Originally linked to Discord UID {orig_discord_ids}.") 100 | embed = discord.Embed(title="Identification error", description=error_desc, color=0xFF2429) 101 | await self.bot.get_channel(settings.channels.VERIFY_LOGS).send(embed=embed) 102 | 103 | return await ctx.respond( 104 | "Identification error: please contact an online Moderator or Administrator for help.", ephemeral=True 105 | ) 106 | 107 | # Step 3: Check if discord_user_id already linked to an htb_user_id, and if JSON/db HTB IDs are the same. 108 | # Scenario: 109 | # - I have a new, unlinked Discord account. 110 | # - Clubby generates a new token and gives it to me. 111 | # - `SELECT * FROM htb_discord_link WHERE discord_user_id = %s` 112 | # will be empty because I have not identified before. 113 | # - I am now Clubby. 114 | async with AsyncSessionLocal() as session: 115 | stmt = select(HtbDiscordLink).filter(HtbDiscordLink.discord_user_id == member.id) 116 | result = await session.scalars(stmt) 117 | user_links: Sequence[HtbDiscordLink] = result.all() 118 | 119 | user_htb_ids = {u_link.htb_user_id_as_int for u_link in user_links} 120 | if user_htb_ids and json_htb_user_id not in user_htb_ids: 121 | error_desc = (f"User {member.mention} ({member.id}) tried to identify with a new HTB account.\n" 122 | f"Original HTB UIDs: {', '.join([str(i) for i in user_htb_ids])}, new HTB UID: " 123 | f"{json_htb_user_id}.") 124 | embed = discord.Embed(title="Identification error", description=error_desc, color=0xFF2429) 125 | await self.bot.get_channel(settings.channels.VERIFY_LOGS).send(embed=embed) 126 | 127 | return await ctx.respond( 128 | "Identification error: please contact an online Moderator or Administrator for help.", ephemeral=True 129 | ) 130 | 131 | htb_discord_link = HtbDiscordLink( 132 | account_identifier=account_identifier, discord_user_id=member.id, htb_user_id=json_htb_user_id 133 | ) 134 | async with AsyncSessionLocal() as session: 135 | session.add(htb_discord_link) 136 | await session.commit() 137 | 138 | await process_identification(htb_user_details, user=member, bot=self.bot) 139 | 140 | return await ctx.respond( 141 | f"Your Discord user has been successfully identified as HTB user {json_htb_user_id}.", ephemeral=True 142 | ) 143 | 144 | 145 | def setup(bot: Bot) -> None: 146 | """Load the `IdentifyCog` cog.""" 147 | bot.add_cog(IdentifyCog(bot)) 148 | -------------------------------------------------------------------------------- /src/cmds/core/macro.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Sequence 3 | 4 | import arrow 5 | from discord import ApplicationContext, Embed, Interaction, SlashCommandGroup, WebhookMessage 6 | from discord.abc import GuildChannel 7 | from discord.ext import commands 8 | from discord.ext.commands import has_any_role 9 | from sqlalchemy import select 10 | from sqlalchemy.exc import IntegrityError 11 | 12 | from src.bot import Bot 13 | from src.core import settings 14 | from src.database.models import Macro 15 | from src.database.session import AsyncSessionLocal 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class MacroCog(commands.Cog): 21 | """Manage macro's.""" 22 | 23 | def __init__(self, bot: Bot): 24 | self.bot = bot 25 | 26 | macro = SlashCommandGroup("macro", "Manage macro's.", guild_ids=settings.guild_ids) 27 | 28 | @macro.command(description="Add a macro to the records.") 29 | @has_any_role(*settings.role_groups.get("ALL_ADMINS"), *settings.role_groups.get("ALL_HTB_STAFF")) 30 | async def add(self, ctx: ApplicationContext, name: str, text: str) -> Interaction | WebhookMessage: 31 | """Add a macro to the records.""" 32 | if len(text) == 0: 33 | return await ctx.respond("The macro is empty. Try again...", ephemeral=True) 34 | 35 | if len(name) == 0: 36 | return await ctx.respond("The macro name is empty. Try again...", ephemeral=True) 37 | 38 | # Name should be lowercase. 39 | name = name.lower() 40 | 41 | moderator_id = ctx.user.id 42 | today = arrow.utcnow().format("YYYY-MM-DD HH:mm:ss") 43 | macro = Macro(user_id=moderator_id, name=name, text=text, created_at=today) 44 | async with AsyncSessionLocal() as session: 45 | try: 46 | session.add(macro) 47 | await session.commit() 48 | return await ctx.respond(f"Macro {name} added. ID: {macro.id}", ephemeral=True) 49 | except IntegrityError: 50 | return await ctx.respond(f"Macro with the name '{name}' already exists.", ephemeral=True) 51 | 52 | @macro.command(description="Remove a macro by providing the ID to remove.") 53 | @has_any_role(*settings.role_groups.get("ALL_ADMINS"), *settings.role_groups.get("ALL_HTB_STAFF")) 54 | async def remove(self, ctx: ApplicationContext, macro_id: int) -> Interaction | WebhookMessage: 55 | """Remove a macro by providing ID to remove.""" 56 | async with AsyncSessionLocal() as session: 57 | macro = await session.get(Macro, macro_id) 58 | if macro: 59 | await session.delete(macro) 60 | await session.commit() 61 | return await ctx.respond(f"Macro #{macro_id} ({macro.name}) has been deleted.", ephemeral=True) 62 | else: 63 | return await ctx.respond(f"Macro #{macro_id} has not been found.", ephemeral=True) 64 | 65 | @macro.command(description="Edit a macro by providing the ID to edit.") 66 | @has_any_role(*settings.role_groups.get("ALL_ADMINS"), *settings.role_groups.get("ALL_HTB_STAFF")) 67 | async def edit(self, ctx: ApplicationContext, macro_id: int, text: str) -> Interaction | WebhookMessage: 68 | """Edit a macro by providing the ID to edit.""" 69 | async with AsyncSessionLocal() as session: 70 | stmt = select(Macro).filter(Macro.id == macro_id) 71 | result = await session.scalars(stmt) 72 | macro: Macro = result.first() 73 | if macro: 74 | macro.text = text 75 | await session.commit() 76 | return await ctx.respond(f"Macro #{macro_id} has been updated.", ephemeral=True) 77 | else: 78 | return await ctx.respond(f"Macro #{macro_id} has not been found.", ephemeral=True) 79 | 80 | @macro.command(description="List all macro's.") 81 | @commands.cooldown(1, 30, commands.BucketType.user) 82 | async def list(self, ctx: ApplicationContext) -> Interaction | WebhookMessage: 83 | """List all macro's.""" 84 | async with AsyncSessionLocal() as session: 85 | stmt = select(Macro) 86 | result = await session.scalars(stmt) 87 | macros: Sequence[Macro] = result.all() 88 | # If no macros are returned, show a message. 89 | if not macros: 90 | return await ctx.respond("No macros have been added yet.") 91 | 92 | embed = Embed(title="Macros", description="List of all macros.") 93 | for macro in macros: 94 | embed.add_field(name=f"Macro #{macro.id} - name: {macro.name}", value=macro.text, inline=False) 95 | return await ctx.respond(embed=embed, ephemeral=True) 96 | 97 | @macro.command(description="Send text from a macro by providing the name.") 98 | @commands.cooldown(1, 30, commands.BucketType.user) 99 | async def send(self, 100 | ctx: ApplicationContext, 101 | name: str, 102 | channel: GuildChannel = None 103 | ) -> Interaction | WebhookMessage: 104 | """Send text from a macro by providing the name If a mod or higher, they can send to a remote channel.""" 105 | name = name.lower() 106 | async with AsyncSessionLocal() as session: 107 | stmt = select(Macro).filter(Macro.name == name) 108 | result = await session.scalars(stmt) 109 | macro: Macro = result.first() 110 | 111 | if not macro: 112 | return await ctx.respond(f"Macro #{name} has not been found. " 113 | "Check the list of macros via the command `/macro list`.", ephemeral=True) 114 | 115 | if channel: 116 | allowed_roles = {role.id for role in ctx.user.roles} 117 | required_roles = set( 118 | settings.role_groups.get("ALL_ADMINS", []) 119 | + settings.role_groups.get("ALL_HTB_STAFF", []) 120 | + settings.role_groups.get("ALL_MODS", []) 121 | ) 122 | if allowed_roles & required_roles: 123 | await channel.send(f"{macro.text}") 124 | return await ctx.respond(f"Macro {name} has been sent to {channel.mention}.", ephemeral=True) 125 | return await ctx.respond("You don't have permission to send macros in other channels.", 126 | ephemeral=True) 127 | return await ctx.respond(f"{macro.text}") 128 | 129 | @macro.command(description="Instructions for the macro commands.") 130 | async def help(self, ctx: ApplicationContext) -> Interaction | WebhookMessage: 131 | """Send instructions from the macro functionalities.""" 132 | embed = Embed(title="Macro Help", description="Instructions for the macro commands.") 133 | embed.add_field(name="Add a macro", value="`/macro add `", inline=False) 134 | embed.add_field(name="Remove a macro", value="`/macro remove `", inline=False) 135 | embed.add_field(name="Edit a macro", value="`/macro edit `", inline=False) 136 | embed.add_field(name="List all macros", value="`/macro list`", inline=False) 137 | embed.add_field(name="Send a macro", value="`/macro send [channel]` (channel=optional)", inline=False) 138 | return await ctx.respond(embed=embed, ephemeral=True) 139 | 140 | 141 | def setup(bot: Bot) -> None: 142 | """Load the `MacroCog` cog.""" 143 | bot.add_cog(MacroCog(bot)) 144 | -------------------------------------------------------------------------------- /src/cmds/core/mute.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from discord import ApplicationContext, Interaction, WebhookMessage, slash_command, Member 4 | from discord.errors import Forbidden 5 | from discord.ext import commands 6 | from discord.ext.commands import has_any_role 7 | 8 | from src.bot import Bot 9 | from src.core import settings 10 | from src.database.models import Mute 11 | from src.database.session import AsyncSessionLocal 12 | from src.helpers.ban import unmute_member 13 | from src.helpers.checks import member_is_staff 14 | from src.helpers.duration import validate_duration 15 | from src.helpers.schedule import schedule 16 | 17 | 18 | class MuteCog(commands.Cog): 19 | """Mute related commands.""" 20 | 21 | def __init__(self, bot: Bot): 22 | self.bot = bot 23 | 24 | @slash_command( 25 | guild_ids=settings.guild_ids, 26 | description="Mute a person (adds the Muted role to person)." 27 | ) 28 | @has_any_role(*settings.role_groups.get("ALL_ADMINS"), *settings.role_groups.get("ALL_MODS")) 29 | async def mute( 30 | self, ctx: ApplicationContext, user: Member, duration: str, reason: str 31 | ) -> Interaction | WebhookMessage: 32 | """Mute a person (adds the Muted role to person).""" 33 | member = await self.bot.get_member_or_user(ctx.guild, user.id) 34 | if not member: 35 | return await ctx.respond(f"User {user} not found.") 36 | if isinstance(member, Member): 37 | if member_is_staff(member): 38 | return await ctx.respond("You cannot mute another staff member.") 39 | if member.bot: 40 | return await ctx.respond("You cannot mute a bot.") 41 | 42 | dur, dur_exc = validate_duration(duration) 43 | if dur_exc: 44 | return await ctx.respond(dur_exc, delete_after=15) 45 | 46 | async with AsyncSessionLocal() as session: 47 | mute_ = Mute( 48 | user_id=member.id, 49 | reason=reason if reason else "Time to shush, innit?", 50 | moderator_id=ctx.user.id, 51 | unmute_time=dur, 52 | ) 53 | session.add(mute_) 54 | await session.commit() 55 | 56 | if isinstance(member, Member): 57 | role = ctx.guild.get_role(settings.roles.MUTED) 58 | await member.add_roles(role) 59 | timestamp=datetime.fromtimestamp(dur) 60 | self.bot.loop.create_task(schedule(unmute_member(ctx.guild, member), run_at=timestamp)) 61 | await member.timeout(timestamp, reason=reason if reason else "Time to shush, innit?") 62 | try: 63 | await member.send(f"You have been muted for {duration}. Reason:\n>>> {reason}") 64 | except Forbidden: 65 | return await ctx.respond( 66 | f"{member.mention} ({member.id}) has been muted for {duration}, but cannot DM due to their privacy " 67 | f"settings." 68 | ) 69 | 70 | return await ctx.respond(f"{member.mention} ({member.id}) has been muted for {duration}.") 71 | 72 | @slash_command( 73 | guild_ids=settings.guild_ids, description="Unmute the user removing the Muted role." 74 | ) 75 | @has_any_role(*settings.role_groups.get("ALL_ADMINS"), *settings.role_groups.get("ALL_MODS")) 76 | async def unmute(self, ctx: ApplicationContext, user: Member) -> Interaction | WebhookMessage: 77 | """Unmute the user removing the Muted role.""" 78 | member = await self.bot.get_member_or_user(ctx.guild, user.id) 79 | 80 | if member is None: 81 | return await ctx.respond("Error: Cannot retrieve member.") 82 | 83 | await unmute_member(ctx.guild, member) 84 | return await ctx.respond(f"{member.mention} ({member.id}) has been unmuted.") 85 | 86 | 87 | def setup(bot: Bot) -> None: 88 | """Load the `MuteCog` cog.""" 89 | bot.add_cog(MuteCog(bot)) 90 | -------------------------------------------------------------------------------- /src/cmds/core/note.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import arrow 4 | from discord import ApplicationContext, Interaction, SlashCommandGroup, WebhookMessage 5 | from discord.abc import User 6 | from discord.ext import commands 7 | from discord.ext.commands import has_any_role 8 | 9 | from src.bot import Bot 10 | from src.core import settings 11 | from src.database.models import UserNote 12 | from src.database.session import AsyncSessionLocal 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class NoteCog(commands.Cog): 18 | """Manage user notes.""" 19 | 20 | def __init__(self, bot: Bot): 21 | self.bot = bot 22 | 23 | note = SlashCommandGroup("note", "Manage user notes.", guild_ids=settings.guild_ids) 24 | 25 | @note.command(description="Add a note to the users history records. Only intended for staff convenience.") 26 | @has_any_role(*settings.role_groups.get("ALL_ADMINS"), *settings.role_groups.get("ALL_MODS")) 27 | async def add(self, ctx: ApplicationContext, user: User, note: str) -> Interaction | WebhookMessage: 28 | """Add a note to the users history records. Only intended for staff convenience.""" 29 | member = await self.bot.get_member_or_user(ctx.guild, user.id) 30 | 31 | if len(note) == 0: 32 | return await ctx.respond("The note is empty. Try again...") 33 | 34 | moderator_id = ctx.user.id 35 | today = arrow.utcnow().format("YYYY-MM-DD") 36 | user_note = UserNote(user_id=member.id, note=note, date=today, moderator_id=moderator_id) 37 | async with AsyncSessionLocal() as session: 38 | session.add(user_note) 39 | await session.commit() 40 | 41 | return await ctx.respond("Note added.") 42 | 43 | @note.command(description="Remove a note from a user by providing the note ID to remove.") 44 | @has_any_role(*settings.role_groups.get("ALL_ADMINS"), *settings.role_groups.get("ALL_SR_MODS")) 45 | async def remove(self, ctx: ApplicationContext, note_id: int) -> Interaction | WebhookMessage: 46 | """Remove a note from a user by providing the note ID to remove.""" 47 | async with AsyncSessionLocal() as session: 48 | user_note = await session.get(UserNote, note_id) 49 | if user_note: 50 | await session.delete(user_note) 51 | await session.commit() 52 | return await ctx.respond(f"Note #{note_id} has been deleted.") 53 | else: 54 | return await ctx.respond(f"Note #{note_id} has not been found.") 55 | 56 | 57 | def setup(bot: Bot) -> None: 58 | """Load the `NoteCog` cog.""" 59 | bot.add_cog(NoteCog(bot)) 60 | -------------------------------------------------------------------------------- /src/cmds/core/other.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import discord 4 | from discord import ApplicationContext, Interaction, Message, Option, slash_command 5 | from discord.ext import commands 6 | from discord.ui import Button, InputText, Modal, View 7 | from slack_sdk.webhook import WebhookClient 8 | 9 | from src.bot import Bot 10 | from src.core import settings 11 | from src.helpers import webhook 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class FeedbackModal(Modal): 17 | """Feedback modal.""" 18 | 19 | def __init__(self, *args, **kwargs) -> None: 20 | """Initialize the Feedback Modal with input fields.""" 21 | super().__init__(*args, **kwargs) 22 | self.add_item(InputText(label="Title")) 23 | self.add_item(InputText(label="Feedback", style=discord.InputTextStyle.long)) 24 | 25 | async def callback(self, interaction: discord.Interaction) -> None: 26 | """Handle the modal submission by sending feedback to Slack.""" 27 | await interaction.response.send_message("Thank you, your feedback has been recorded.", ephemeral=True) 28 | 29 | webhook = WebhookClient(settings.SLACK_FEEDBACK_WEBHOOK) 30 | 31 | if interaction.user: # Protects against some weird edge cases 32 | title = f"{self.children[0].value} - {interaction.user.name}" 33 | else: 34 | title = f"{self.children[0].value}" 35 | 36 | message_body = self.children[1].value 37 | # Slack has no way to disallow @(@everyone calls), so we strip it out and replace it with a safe version 38 | title = title.replace("@", "[at]").replace("<", "[bracket]") 39 | message_body = message_body.replace("@", "[at]").replace("<", "[bracket]") 40 | 41 | response = webhook.send( 42 | text=f"{title} - {message_body}", 43 | blocks=[ 44 | { 45 | "type": "section", 46 | "text": { 47 | "type": "mrkdwn", 48 | "text": f"{title}:\n {message_body}" 49 | } 50 | } 51 | ] 52 | ) 53 | assert response.status_code == 200 54 | assert response.body == "ok" 55 | 56 | 57 | class SpoilerModal(Modal): 58 | """Modal for reporting a spoiler.""" 59 | 60 | def __init__(self, *args, **kwargs) -> None: 61 | """Initialize the Spoiler Modal with input fields.""" 62 | super().__init__(*args, **kwargs) 63 | self.add_item( 64 | InputText( 65 | label="Description", 66 | placeholder="Description", 67 | required=False, 68 | style=discord.InputTextStyle.long 69 | ) 70 | ) 71 | self.add_item( 72 | InputText( 73 | label="URL", 74 | placeholder="Enter URL. Submitting malicious or fake links will result in consequences.", 75 | required=True, 76 | style=discord.InputTextStyle.paragraph 77 | ) 78 | ) 79 | 80 | async def callback(self, interaction: discord.Interaction) -> None: 81 | """Handle the modal submission by sending the spoiler report to JIRA.""" 82 | desc = self.children[0].value.strip() # Trim any whitespace 83 | url = self.children[1].value.strip() # Trim any whitespace 84 | 85 | if not url: # Check if the URL is empty 86 | await interaction.response.send_message("Please provide the spoiler URL.", ephemeral=True) 87 | return 88 | await interaction.response.send_message("Thank you, the spoiler has been reported.", ephemeral=True) 89 | 90 | user_name = interaction.user.display_name 91 | webhook_url = settings.JIRA_WEBHOOK 92 | 93 | data = { 94 | "user": user_name, 95 | "url": url, 96 | "desc": desc, 97 | "type": "spoiler" 98 | } 99 | 100 | await webhook.webhook_call(webhook_url, data) 101 | 102 | 103 | class SpoilerConfirmationView(View): 104 | """A confirmation view before opening the SpoilerModal.""" 105 | 106 | def __init__(self, user: discord.Member): 107 | super().__init__(timeout=60) 108 | self.user = user 109 | 110 | @discord.ui.button(label="Proceed", style=discord.ButtonStyle.danger) 111 | async def proceed(self, button: Button, interaction: discord.Interaction) -> None: 112 | """Opens the spoiler modal after confirmation.""" 113 | if interaction.user.id != self.user.id: 114 | await interaction.response.send_message("This confirmation is not for you.", ephemeral=True) 115 | return 116 | 117 | modal = SpoilerModal(title="Report Spoiler") 118 | await interaction.response.send_modal(modal) 119 | self.stop() 120 | 121 | 122 | class OtherCog(commands.Cog): 123 | """Other commands related to the bot.""" 124 | 125 | def __init__(self, bot: Bot): 126 | self.bot = bot 127 | 128 | @slash_command(guild_ids=settings.guild_ids, description="A simple reply stating hints are not allowed.") 129 | async def no_hints(self, ctx: ApplicationContext) -> Message: 130 | """Reply stating that hints are not allowed.""" 131 | return await ctx.respond( 132 | "No hints are allowed for the duration of the event. Once the event is over, feel free to share solutions." 133 | ) 134 | 135 | @slash_command(guild_ids=settings.guild_ids, 136 | description="A simple reply proving a link to the support desk article on how to get support") 137 | @commands.cooldown(1, 60, commands.BucketType.user) 138 | async def support( 139 | self, ctx: ApplicationContext, 140 | platform: Option(str, "Select the platform", choices=["labs", "academy"], default="labs"), 141 | ) -> Message: 142 | """A simple reply providing a link to the support desk article on how to get support.""" 143 | if platform == "academy": 144 | return await ctx.respond( 145 | "https://help.hackthebox.com/en/articles/5987511-contacting-academy-support" 146 | ) 147 | return await ctx.respond( 148 | "https://help.hackthebox.com/en/articles/5986762-contacting-htb-support" 149 | ) 150 | 151 | @slash_command(guild_ids=settings.guild_ids, description="Add a URL which contains a spoiler.") 152 | async def spoiler(self, ctx: ApplicationContext) -> None: 153 | """Ask for confirmation before reporting a spoiler.""" 154 | view = SpoilerConfirmationView(ctx.user) 155 | await ctx.respond( 156 | "Thank you for taking the time to report a spoiler. \n ⚠️ **Warning:** Submitting malicious or fake links will result in consequences.", 157 | view=view, 158 | ephemeral=True 159 | ) 160 | 161 | @slash_command(guild_ids=settings.guild_ids, description="Provide feedback to HTB.") 162 | @commands.cooldown(1, 60, commands.BucketType.user) 163 | async def feedback(self, ctx: ApplicationContext) -> Interaction: 164 | """Provide feedback to HTB.""" 165 | modal = FeedbackModal(title="Feedback") 166 | return await ctx.send_modal(modal) 167 | 168 | @slash_command(guild_ids=settings.guild_ids, description="Report a suspected cheater on the main platform.") 169 | @commands.cooldown(1, 60, commands.BucketType.user) 170 | async def cheater( 171 | self, 172 | ctx: ApplicationContext, 173 | user: Option(str, "Please provide the HTB username.", required=True), 174 | description: Option(str, "What do you want to report?", required=True), 175 | ) -> None: 176 | """Report a suspected cheater on the main platform.""" 177 | data = { 178 | "user": ctx.user.display_name, 179 | "cheater": user, 180 | "description": description, 181 | "type": "cheater" 182 | } 183 | 184 | await webhook.webhook_call(settings.JIRA_WEBHOOK, data) 185 | 186 | await ctx.respond("Thank you for your report.", ephemeral=True) 187 | 188 | 189 | def setup(bot: Bot) -> None: 190 | """Load the cogs.""" 191 | bot.add_cog(OtherCog(bot)) 192 | -------------------------------------------------------------------------------- /src/cmds/core/ping.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import arrow 4 | from dateutil.relativedelta import relativedelta 5 | from discord import Embed, Interaction, WebhookMessage 6 | from discord.commands import ApplicationContext, slash_command 7 | from discord.ext import commands 8 | from discord.ext.commands import cooldown 9 | 10 | from src import start_time 11 | from src.bot import Bot 12 | from src.core import settings 13 | from src.utils.formatters import color_level 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | 18 | class PingCog(commands.Cog): 19 | """Get info about the bot's ping and uptime.""" 20 | 21 | def __init__(self, bot: Bot): 22 | self.bot = bot 23 | 24 | @slash_command(guild_ids=settings.guild_ids) 25 | @cooldown(1, 3600, commands.BucketType.user) 26 | async def ping(self, ctx: ApplicationContext) -> Interaction | WebhookMessage: 27 | """Ping the bot to see its latency, uptime and version.""" 28 | difference = relativedelta(arrow.utcnow() - start_time) 29 | uptime: str = start_time.shift( 30 | seconds=-difference.seconds, 31 | minutes=-difference.minutes, 32 | hours=-difference.hours, 33 | days=-difference.days 34 | ).humanize() 35 | 36 | latency = round(self.bot.latency * 1000) 37 | 38 | embed = Embed( 39 | colour=color_level(latency), 40 | description=f"• Gateway Latency: **{latency}ms**\n• Start time: **{uptime}**\n• Version: **" 41 | f"{settings.VERSION}**" 42 | ) 43 | 44 | return await ctx.respond(embed=embed) 45 | 46 | 47 | def setup(bot: Bot) -> None: 48 | """Load the `PingCog` cog.""" 49 | bot.add_cog(PingCog(bot)) 50 | -------------------------------------------------------------------------------- /src/cmds/core/verify.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import discord 4 | from discord import ApplicationContext, Interaction, WebhookMessage, slash_command 5 | from discord.errors import Forbidden, HTTPException 6 | from discord.ext import commands 7 | from discord.ext.commands import cooldown 8 | 9 | from src.bot import Bot 10 | from src.core import settings 11 | from src.helpers.verification import process_certification 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class VerifyCog(commands.Cog): 17 | """Verify discord member with HTB.""" 18 | 19 | def __init__(self, bot: Bot): 20 | self.bot = bot 21 | 22 | @slash_command( 23 | guild_ids=settings.guild_ids, 24 | description="Verify your HTB Certifications!" 25 | ) 26 | @cooldown(1, 60, commands.BucketType.user) 27 | async def verifycertification(self, ctx: ApplicationContext, certid: str, fullname: str) -> Interaction | WebhookMessage | None: 28 | """Verify users their HTB Certification.""" 29 | if not certid or not fullname: 30 | await ctx.respond("You must supply a cert id!", ephemeral=True) 31 | return 32 | if not certid.startswith("HTBCERT-"): 33 | await ctx.respond("CertID must start with HTBCERT-", ephemeral=True) 34 | return 35 | cert = await process_certification(certid, fullname) 36 | if cert: 37 | to_add = settings.get_cert(cert) 38 | await ctx.author.add_roles(ctx.guild.get_role(to_add)) 39 | await ctx.respond(f"Added {cert}!", ephemeral=True) 40 | else: 41 | await ctx.respond("Unable to find certification with provided details", ephemeral=True) 42 | 43 | @slash_command( 44 | guild_ids=settings.guild_ids, 45 | description="Receive instructions in a DM on how to identify yourself with your HTB account." 46 | ) 47 | @cooldown(1, 60, commands.BucketType.user) 48 | async def verify(self, ctx: ApplicationContext) -> Interaction | WebhookMessage: 49 | """Receive instructions in a DM on how to identify yourself with your HTB account.""" 50 | member = ctx.user 51 | 52 | # Step one 53 | embed_step1 = discord.Embed(color=0x9ACC14) 54 | embed_step1.add_field( 55 | name="Step 1: Log in at Hack The Box", 56 | value="Go to the Hack The Box website at " 57 | " and navigate to **Login > HTB Labs**. Log in to your HTB Account." 58 | , inline=False, ) 59 | embed_step1.set_image( 60 | url="https://media.discordapp.net/attachments/724587782755844098/839871275627315250/unknown.png" 61 | ) 62 | 63 | # Step two 64 | embed_step2 = discord.Embed(color=0x9ACC14) 65 | embed_step2.add_field( 66 | name="Step 2: Locate the Account Identifier", 67 | value='Click on your profile name, then select **My Profile**. ' 68 | 'In the Profile Settings tab, find the field labeled **Account Identifier**. () ' 69 | "Click the green button to copy your secret identifier.", inline=False, ) 70 | embed_step2.set_image( 71 | url="https://media.discordapp.net/attachments/724587782755844098/839871332963188766/unknown.png" 72 | ) 73 | 74 | # Step three 75 | embed_step3 = discord.Embed(color=0x9ACC14) 76 | embed_step3.add_field( 77 | name="Step 3: Identification", 78 | value="Now type `/identify IDENTIFIER_HERE` in the bot-commands channel.\n\nYour roles will be " 79 | "applied automatically.", inline=False 80 | ) 81 | embed_step3.set_image( 82 | url="https://media.discordapp.net/attachments/709907130102317093/904744444539076618/unknown.png" 83 | ) 84 | 85 | try: 86 | await member.send(embed=embed_step1) 87 | await member.send(embed=embed_step2) 88 | await member.send(embed=embed_step3) 89 | except Forbidden as ex: 90 | logger.error("Exception during verify call", exc_info=ex) 91 | return await ctx.respond( 92 | "Whoops! I cannot DM you after all due to your privacy settings. Please allow DMs from other server " 93 | "members and try again in 1 minute." 94 | ) 95 | except HTTPException as ex: 96 | logger.error("Exception during verify call.", exc_info=ex) 97 | return await ctx.respond( 98 | "An unexpected error happened (HTTP 400, bad request). Please contact an Administrator." 99 | ) 100 | return await ctx.respond("Please check your DM for instructions.", ephemeral=True) 101 | 102 | 103 | def setup(bot: Bot) -> None: 104 | """Load the `VerifyCog` cog.""" 105 | bot.add_cog(VerifyCog(bot)) 106 | -------------------------------------------------------------------------------- /src/cmds/dev/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/src/cmds/dev/__init__.py -------------------------------------------------------------------------------- /src/cmds/dev/extensions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from enum import Enum 3 | from functools import partial 4 | from typing import Iterable, Optional 5 | 6 | from discord import Interaction, WebhookMessage 7 | from discord.commands import ApplicationContext, AutocompleteContext, Option, OptionChoice, SlashCommandGroup 8 | from discord.errors import ExtensionAlreadyLoaded, ExtensionNotLoaded 9 | from discord.ext import commands 10 | 11 | from src import cmds 12 | from src.bot import Bot 13 | from src.core import settings 14 | from src.utils.extensions import EXTENSIONS 15 | 16 | log = logging.getLogger(__name__) 17 | 18 | UNLOAD_BLACKLIST = {f"{cmds.__name__}.core.extensions"} 19 | BASE_PATH_LEN = len(cmds.__name__.split(".")) 20 | 21 | 22 | class Action(Enum): 23 | """Represents an action to perform on an extension.""" 24 | 25 | # Need to be partial otherwise they are considered to be function definitions. 26 | LOAD = partial(Bot.load_extension) 27 | UNLOAD = partial(Bot.unload_extension) 28 | RELOAD = partial(Bot.reload_extension) 29 | 30 | 31 | class Extensions(commands.Cog): 32 | """Extension management commands.""" 33 | 34 | def __init__(self, bot: Bot): 35 | self.bot = bot 36 | 37 | def get_extensions(self, ctx: AutocompleteContext) -> Iterable[OptionChoice]: 38 | """Return a list of extensions for the autocomplete.""" 39 | verb = ctx.command.qualified_name.rsplit()[-1] 40 | extensions = { 41 | "load": EXTENSIONS - set(self.bot.extensions), 42 | "unload": set(self.bot.extensions) - UNLOAD_BLACKLIST, 43 | "reload": EXTENSIONS 44 | } 45 | 46 | results = [] 47 | for extension in extensions[verb]: 48 | # Format an extension into a human-readable format. 49 | formatted_extension = extension.removeprefix(f"{cmds.__name__}.") 50 | 51 | # Select the extensions that begin with the characters entered so far. 52 | if formatted_extension.startswith(ctx.value.lower()): 53 | results.append(OptionChoice(formatted_extension, extension)) 54 | 55 | return results 56 | 57 | extensions = SlashCommandGroup( 58 | "exts", "Load, unload and reload a bot's extension.", 59 | guild_ids=settings.dev_guild_ids 60 | ) 61 | 62 | @extensions.command() 63 | async def load( 64 | self, ctx: ApplicationContext, 65 | extension: Option( 66 | str, "Choose an extension.", 67 | autocomplete=get_extensions 68 | ) 69 | ) -> Interaction | WebhookMessage: 70 | """Load an extension given its name.""" 71 | msg, error = await self.manage(Action.LOAD, extension) 72 | return await ctx.respond(msg) 73 | 74 | @extensions.command() 75 | async def unload( 76 | self, ctx: ApplicationContext, 77 | extension: Option( 78 | str, "Choose an extension.", 79 | autocomplete=get_extensions 80 | ) 81 | ) -> Interaction | WebhookMessage: 82 | """Unload an extension given its name.""" 83 | msg, error = await self.manage(Action.UNLOAD, extension) 84 | return await ctx.respond(msg) 85 | 86 | @extensions.command() 87 | async def reload( 88 | self, ctx: ApplicationContext, 89 | extension: Option( 90 | str, "Choose an extension.", 91 | autocomplete=get_extensions 92 | ) 93 | ) -> Interaction | WebhookMessage: 94 | """Reload an extension given its name.""" 95 | msg, error = await self.manage(Action.RELOAD, extension) 96 | return await ctx.respond(msg) 97 | 98 | async def manage(self, action: Action, ext: str) -> tuple[str, Optional[str]]: 99 | """Apply an action to an extension and return the status message and any error message.""" 100 | verb = action.name.lower() 101 | error_msg = None 102 | 103 | try: 104 | action.value(self.bot, ext) 105 | # await self.bot.sync_commands() 106 | except (ExtensionAlreadyLoaded, ExtensionNotLoaded): 107 | if action is Action.RELOAD: 108 | # When reloading, just load the extension if it was not loaded. 109 | return await self.manage(Action.LOAD, ext) 110 | 111 | msg = f":x: Extension `{ext}` is already {verb}ed." 112 | log.debug(msg[4:]) 113 | except Exception as e: 114 | if hasattr(e, "original"): 115 | e = e.original 116 | 117 | log.exception(f"Extension '{ext}' failed to {verb}.") 118 | 119 | error_msg = f"{e.__class__.__name__}: {e}" 120 | msg = f":x: Failed to {verb} extension `{ext}`:\n```\n{error_msg}\n```" 121 | else: 122 | msg = f":ok_hand: Extension successfully {verb}ed: `{ext}`." 123 | log.debug(msg[10:]) 124 | 125 | return msg, error_msg 126 | 127 | 128 | def setup(bot: Bot) -> None: 129 | """Load the `Extensions` cog.""" 130 | bot.add_cog(Extensions(bot)) 131 | -------------------------------------------------------------------------------- /src/core/__init__.py: -------------------------------------------------------------------------------- 1 | from src.core.config import settings 2 | from src.core.constants import constants 3 | -------------------------------------------------------------------------------- /src/core/constants.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Colours(BaseModel): 5 | """Colour codes.""" 6 | 7 | blue = 0x0279FD 8 | bright_green = 0x01D277 9 | dark_green = 0x1F8B4C 10 | gold = 0xE6C200 11 | grass_green = 0x66FF00 12 | orange = 0xE67E22 13 | pink = 0xCF84E0 14 | purple = 0xB734EB 15 | python_blue = 0x4B8BBE 16 | python_yellow = 0xFFD43B 17 | red = 0xFF0000 18 | soft_green = 0x68C290 19 | soft_orange = 0xF9CB54 20 | soft_red = 0xCD6D6D 21 | yellow = 0xF8E500 22 | 23 | 24 | class Emojis(BaseModel): 25 | """Emoji codes.""" 26 | 27 | arrow_left = "\u2B05" # ⬅ 28 | arrow_right = "\u27A1" # ➡ 29 | lock = "\U0001F512" # 🔒 30 | partying_face = "\U0001F973" # 🥳 31 | track_next = "\u23ED" # ⏭ 32 | track_previous = "\u23EE" # ⏮ 33 | 34 | 35 | class Pagination(BaseModel): 36 | """Pagination default settings.""" 37 | 38 | max_size = 500 39 | timeout = 300 # In seconds 40 | 41 | 42 | class Constants(BaseModel): 43 | """The app constants.""" 44 | 45 | colours: Colours = Colours() 46 | emojis: Emojis = Emojis() 47 | pagination: Pagination = Pagination() 48 | 49 | low_latency: int = 200 50 | high_latency: int = 400 51 | 52 | 53 | constants = Constants() 54 | -------------------------------------------------------------------------------- /src/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/src/database/__init__.py -------------------------------------------------------------------------------- /src/database/base.py: -------------------------------------------------------------------------------- 1 | # Import all the models, so that Base has them before being 2 | # imported by Alembic 3 | from .base_class import Base # noqa 4 | from .models import * # noqa 5 | -------------------------------------------------------------------------------- /src/database/base_class.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any 3 | 4 | from sqlalchemy.ext.declarative import declared_attr 5 | from sqlalchemy.orm import DeclarativeBase 6 | 7 | 8 | class Base(DeclarativeBase): 9 | """ 10 | Base class for SQLAlchemy declarative models, providing an automatic table name generation based on the class name. 11 | 12 | Attributes: 13 | id (Any): The primary key field for the table. 14 | __name__ (str): The name of the class used to generate the table name. 15 | """ 16 | 17 | id: Any 18 | __name__: str 19 | 20 | # Generate __tablename__ automatically 21 | # noinspection PyMethodParameters 22 | @declared_attr 23 | def __tablename__(cls) -> str: # noqa: N805 24 | """ 25 | Generate a table name based on the class name by converting camel case to snake case. 26 | 27 | Returns: 28 | str: The generated table name in snake_case format. 29 | """ 30 | return re.sub(r"(? PasswordHash: 21 | """Validate password.""" 22 | return getattr(type(self), key).type.validator(password) 23 | -------------------------------------------------------------------------------- /src/database/models/htb_discord_link.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: D101 2 | from sqlalchemy import VARCHAR, Integer 3 | from sqlalchemy.dialects.mysql import BIGINT 4 | from sqlalchemy.orm import Mapped, mapped_column 5 | 6 | from . import Base 7 | 8 | 9 | class HtbDiscordLink(Base): 10 | """ 11 | An SQLAlchemy database model representing the link between a Hack The Box (HTB) user and a Discord user. 12 | 13 | Attributes: 14 | id (Integer): The primary key for the table. 15 | account_identifier (VARCHAR): A unique identifier for the account. 16 | discord_user_id (BIGINT): The Discord user ID (18 digits) associated with the HTB user. 17 | htb_user_id (BIGINT): The Hack The Box user ID associated with the Discord user. 18 | """ 19 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 20 | account_identifier: Mapped[str] = mapped_column(VARCHAR(255)) 21 | discord_user_id: Mapped[int] = mapped_column(BIGINT(18)) 22 | htb_user_id: Mapped[int] = mapped_column(BIGINT) 23 | 24 | @property 25 | def discord_user_id_as_int(self) -> int: 26 | """ 27 | Retrieve the discord_user_id as an integer. 28 | 29 | Returns: 30 | int: The Discord user ID as an integer. 31 | """ 32 | return int(self.discord_user_id) 33 | 34 | @property 35 | def htb_user_id_as_int(self) -> int: 36 | """ 37 | Retrieve the htb_user_id as an integer. 38 | 39 | Returns: 40 | int: The HTB user ID as an integer. 41 | """ 42 | return int(self.htb_user_id) 43 | -------------------------------------------------------------------------------- /src/database/models/infraction.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: D101 2 | from datetime import date 3 | 4 | from sqlalchemy import Integer, func 5 | from sqlalchemy.dialects.mysql import BIGINT, DATE, TEXT 6 | from sqlalchemy.orm import Mapped, mapped_column 7 | 8 | from . import Base 9 | 10 | 11 | class Infraction(Base): 12 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 13 | user_id: Mapped[int] = mapped_column(BIGINT(18), nullable=False) 14 | reason = mapped_column(TEXT, nullable=False) 15 | weight: Mapped[int] = mapped_column(Integer, nullable=False) 16 | moderator_id: Mapped[int] = mapped_column(BIGINT(18), nullable=False) 17 | date: Mapped[date] = mapped_column(DATE, nullable=False, server_default=func.curdate()) 18 | -------------------------------------------------------------------------------- /src/database/models/macro.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: D101 2 | from datetime import datetime 3 | 4 | from sqlalchemy import Boolean, Integer 5 | from sqlalchemy.dialects.mysql import BIGINT, TEXT, TIMESTAMP 6 | from sqlalchemy.orm import Mapped, mapped_column 7 | 8 | from . import Base 9 | 10 | 11 | class Macro(Base): 12 | """ 13 | Represents a Macro record in the database that allows sending frequently used texts. 14 | 15 | Attributes: 16 | id (int): The unique ID of the macro record (primary key). 17 | user_id (int): The ID of the user who created the macro. 18 | name (str): The name of the macro. Must be unique. 19 | text (str): The macro itself. 20 | created_at (datetime): The timestamp when the macro was created. 21 | """ 22 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 23 | user_id: Mapped[int] = mapped_column(BIGINT(18)) 24 | name: Mapped[str] = mapped_column(TEXT, nullable=False, unique=True) 25 | text: Mapped[str] = mapped_column(TEXT, nullable=False) 26 | created_at: Mapped[datetime] = mapped_column(TIMESTAMP, nullable=False) 27 | -------------------------------------------------------------------------------- /src/database/models/mute.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: D101 2 | from sqlalchemy import Integer 3 | from sqlalchemy.dialects.mysql import BIGINT, TEXT 4 | from sqlalchemy.orm import Mapped, mapped_column 5 | 6 | from . import Base 7 | 8 | 9 | class Mute(Base): 10 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 11 | user_id: Mapped[int] = mapped_column(BIGINT(18), nullable=False) 12 | reason: Mapped[str] = mapped_column(TEXT, nullable=False) 13 | moderator_id: Mapped[int] = mapped_column(BIGINT(18), nullable=False) 14 | unmute_time: Mapped[int] = mapped_column(BIGINT(11, unsigned=True), nullable=False) 15 | -------------------------------------------------------------------------------- /src/database/models/user_note.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: D101 2 | from datetime import date 3 | 4 | from sqlalchemy import Integer 5 | from sqlalchemy.dialects.mysql import BIGINT, DATE, TEXT 6 | from sqlalchemy.orm import Mapped, mapped_column 7 | 8 | from . import Base 9 | 10 | 11 | class UserNote(Base): 12 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 13 | user_id: Mapped[int] = mapped_column(BIGINT(18), nullable=False) 14 | note: Mapped[str] = mapped_column(TEXT, nullable=False) 15 | moderator_id: Mapped[int] = mapped_column(BIGINT(18), nullable=False) 16 | date: Mapped[date] = mapped_column(DATE, nullable=False) 17 | -------------------------------------------------------------------------------- /src/database/session.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine 4 | from sqlalchemy.pool import NullPool 5 | 6 | from src.core import settings 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | connection_string = settings.database.assemble_db_connection() 11 | logger.debug(f"Database connection string: {connection_string}") 12 | async_engine = create_async_engine(connection_string, poolclass=NullPool) 13 | AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker( 14 | async_engine, autoflush=True, expire_on_commit=False, class_=AsyncSession 15 | ) 16 | -------------------------------------------------------------------------------- /src/database/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/src/database/utils/__init__.py -------------------------------------------------------------------------------- /src/database/utils/password.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: ANN001 2 | 3 | import bcrypt 4 | from sqlalchemy import TypeDecorator 5 | from sqlalchemy.dialects import mysql 6 | from sqlalchemy.ext.mutable import Mutable 7 | 8 | 9 | class PasswordHash(Mutable): 10 | """ 11 | Represents a bcrypt password hash. 12 | 13 | Attributes: 14 | hash (str): The hashed password value. 15 | rounds (int): The number of rounds used for hashing the password. 16 | desired_rounds (int): The desired number of rounds for hashing the password. 17 | 18 | Methods: 19 | __eq__(candidate): Hashes the candidate string and compares it to the stored hash. 20 | __repr__(): Returns a string representation of the object. 21 | __len__(): Returns the length of the hash. 22 | __getitem__(index): Returns the character at the specified index of the hash. 23 | __setitem__(index, value): Sets the character at the specified index of the hash to the given value. 24 | __delitem__(index): Removes the character at the specified index of the hash. 25 | insert(index, value): Inserts the given value at the specified index of the hash. 26 | coerce(key, value): Ensure that loaded values are PasswordHashes. 27 | new(password, rounds): Returns a new PasswordHash object for the given password and rounds. 28 | """ 29 | 30 | def __init__(self, hash_, rounds=None): 31 | assert len(hash_) == 60, "bcrypt hash should be 60 chars." 32 | assert hash_.count("$") == 3, 'bcrypt hash should have 3x "$".' 33 | self.hash = str(hash_) 34 | self.rounds = int(self.hash.split("$")[2]) 35 | self.desired_rounds = rounds or self.rounds 36 | 37 | def __eq__(self, candidate): 38 | """ 39 | Hashes the candidate string and compares it to the stored hash. 40 | 41 | If the current and desired number of rounds differ, the password is 42 | re-hashed with the desired number of rounds and updated with the results. 43 | This will also mark the object as having changed (and thus need updating). 44 | """ 45 | if isinstance(candidate, str): 46 | candidate = candidate.encode("utf8") 47 | if self.hash == bcrypt.hashpw(candidate, self.hash.encode("utf8")).decode("utf8"): 48 | if self.rounds < self.desired_rounds: 49 | self._rehash(candidate) 50 | return True 51 | return False 52 | 53 | def __repr__(self): 54 | """Simple object representation.""" 55 | return f"<{type(self).__name__}>" 56 | 57 | def __len__(self): 58 | return len(self.hash) 59 | 60 | def __getitem__(self, index): 61 | return self.hash[index] 62 | 63 | def __setitem__(self, index, value): 64 | self.hash = self.hash[:index] + value + self.hash[index + 1:] 65 | 66 | def __delitem__(self, index): 67 | self.hash = self.hash[:index] + self.hash[index + 1:] 68 | 69 | def insert(self, index, value) -> None: 70 | """ 71 | Insert the specified value into the hash at the given index. 72 | 73 | Args: 74 | index (int): The index where the value will be inserted. 75 | value (str): The value to be inserted into the hash. 76 | 77 | Returns: 78 | None 79 | """ 80 | self.hash = self.hash[:index] + value + self.hash[index:] 81 | 82 | @classmethod 83 | def coerce(cls, key, value) -> "PasswordHash": 84 | """Ensure that loaded values are PasswordHashes.""" 85 | if isinstance(value, PasswordHash): 86 | return value 87 | return super().coerce(key, value) 88 | 89 | @classmethod 90 | def new(cls, password, rounds) -> "PasswordHash": 91 | """Returns a new PasswordHash object for the given password and rounds.""" 92 | if isinstance(password, str): 93 | password = password.encode("utf8") 94 | return cls(cls._new(password, rounds)) 95 | 96 | @staticmethod 97 | def _new(password, rounds) -> str: 98 | """Returns a new bcrypt hash for the given password and rounds.""" 99 | return bcrypt.hashpw(password, bcrypt.gensalt(rounds)).decode("utf8") 100 | 101 | def _rehash(self, password) -> None: 102 | """Recreates the internal hash and marks the object as changed.""" 103 | self.hash = self._new(password, self.desired_rounds) 104 | self.rounds = self.desired_rounds 105 | self.changed() 106 | 107 | 108 | class Password(TypeDecorator): 109 | """Allows storing and retrieving password hashes using PasswordHash.""" 110 | 111 | impl = mysql.VARCHAR(60) 112 | 113 | def __init__(self, rounds=12, **kwds): 114 | self.rounds = rounds 115 | super().__init__(**kwds) 116 | 117 | def process_bind_param(self, value, dialect) -> str: 118 | """Ensure the value is a PasswordHash and then return its hash.""" 119 | return self._convert(value).hash 120 | 121 | def process_result_value(self, value, dialect) -> PasswordHash: 122 | """Convert the hash to a PasswordHash, if it's non-NULL.""" 123 | if value is not None: 124 | return PasswordHash(value, rounds=self.rounds) 125 | 126 | def validator(self, password) -> PasswordHash: 127 | """Provides a validator/converter for @validates usage.""" 128 | return self._convert(password) 129 | 130 | def _convert(self, value) -> "PasswordHash": 131 | """ 132 | Returns a PasswordHash from the given string. 133 | 134 | PasswordHash instances or None values will return unchanged. 135 | Strings will be hashed and the resulting PasswordHash returned. 136 | Any other input will result in a TypeError. 137 | """ 138 | if isinstance(value, PasswordHash): 139 | return value 140 | elif isinstance(value, str): 141 | return PasswordHash.new(value, self.rounds) 142 | elif value is not None: 143 | raise TypeError(f"Cannot convert {value} to a PasswordHash") 144 | -------------------------------------------------------------------------------- /src/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/src/helpers/__init__.py -------------------------------------------------------------------------------- /src/helpers/checks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from discord import Member 4 | 5 | from src.core import settings 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def member_is_staff(member: Member) -> bool: 11 | """Checks if a member has any of the Administrator or Moderator or Staff roles defined in the RoleIDs class.""" 12 | role_ids = set([role.id for role in member.roles]) 13 | staff_role_ids = ( 14 | set(settings.role_groups.get("ALL_ADMINS")) 15 | | set(settings.role_groups.get("ALL_MODS")) 16 | | set(settings.role_groups.get("ALL_HTB_STAFF")) 17 | ) 18 | return bool(role_ids.intersection(staff_role_ids)) 19 | -------------------------------------------------------------------------------- /src/helpers/duration.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import logging 3 | import re 4 | import time 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def validate_duration(duration: str, baseline_ts: int = None) -> (int, str): 10 | """Validate duration string and convert to seconds.""" 11 | if duration.isnumeric(): 12 | return 0, "Malformed duration. Please use duration units, (e.g. 12h, 14d, 5w)." 13 | 14 | dur = parse_duration_str(duration, baseline_ts) 15 | if dur is None: 16 | return 0, "Invalid duration: could not parse." 17 | if dur - calendar.timegm(time.gmtime()) <= 0: 18 | return 0, "Invalid duration: cannot be in the past." 19 | 20 | return dur, "" 21 | 22 | 23 | def parse_duration_str(duration: str, baseline_ts: int = None) -> int | None: 24 | """ 25 | Converts an arbitrary measure of time. Uses baseline_ts instead of the current time, if provided. 26 | 27 | Example: "3w" to a timestamp in seconds since 1970/01/01 (UNIX epoch time). 28 | """ 29 | dur = re.compile(r"(-?(?:\d+\.?\d*|\d*\.?\d+)(?:e[-+]?\d+)?)\s*([a-z]*)", re.IGNORECASE) 30 | units = {"s": 1} 31 | units["m"] = units["min"] = units["mins"] = units["s"] * 60 32 | units["h"] = units["hr"] = units["hour"] = units["hours"] = units["m"] * 60 33 | units["d"] = units["day"] = units["days"] = units["h"] * 24 34 | units["wk"] = units["w"] = units["week"] = units["weeks"] = units["d"] * 7 35 | units["month"] = units["months"] = units["mo"] = units["d"] * 30 36 | units["y"] = units["yr"] = units["year"] = units["years"] = units["d"] * 365 37 | sum_seconds = 0 38 | 39 | while duration: 40 | m = dur.match(duration) 41 | if not m: 42 | return None 43 | duration = duration[m.end():] 44 | try: 45 | sum_seconds += int(m.groups()[0]) * units.get(m.groups()[1], 1) 46 | except ValueError: 47 | return None 48 | 49 | if baseline_ts is None: 50 | epoch_time = calendar.timegm(time.gmtime()) 51 | else: 52 | epoch_time = baseline_ts 53 | return epoch_time + sum_seconds 54 | -------------------------------------------------------------------------------- /src/helpers/responses.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | class SimpleResponse(object): 5 | """A simple response object.""" 6 | 7 | def __init__(self, message: str, delete_after: int | None = None): 8 | self.message = message 9 | self.delete_after = delete_after 10 | 11 | def __str__(self): 12 | return json.dumps(dict(self), ensure_ascii=False) 13 | 14 | def __repr__(self): 15 | return self.__str__() 16 | -------------------------------------------------------------------------------- /src/helpers/schedule.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from datetime import datetime 4 | from typing import Coroutine 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | async def schedule(task: Coroutine, run_at: datetime) -> None: 10 | """ 11 | Schedule an "Awaitable" for future execution, i.e. an async function. 12 | 13 | For example to schedule foo(1, 2) 421337 seconds into the future: 14 | await schedule(foo(1, 2), at=(dt.datetime.now() + dt.timedelta(seconds=421337))) 15 | """ 16 | now = datetime.now() 17 | delay = int((run_at - now).total_seconds()) 18 | if delay < 0: 19 | logger.debug( 20 | "Target execution is in the past. Setting sleep timer to 0.", 21 | extra={ 22 | "target_exec": repr(run_at), 23 | "current_time": repr(now), 24 | }, 25 | ) 26 | await task 27 | return 28 | 29 | logger.debug( 30 | f"Task {task.__name__} will run after {delay} seconds.", 31 | extra={ 32 | "target_exec": repr(run_at), 33 | "current_time": repr(now), 34 | }, 35 | ) 36 | 37 | await asyncio.sleep(delay) 38 | await task 39 | return 40 | -------------------------------------------------------------------------------- /src/helpers/webhook.py: -------------------------------------------------------------------------------- 1 | """Helper methods to handle webhook calls.""" 2 | import logging 3 | 4 | import aiohttp 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | async def webhook_call(url: str, data: dict) -> None: 10 | """Send a POST request to the webhook URL with the given data.""" 11 | async with aiohttp.ClientSession() as session: 12 | try: 13 | async with session.post(url, json=data) as response: 14 | if response.status != 200: 15 | logger.error(f"Failed to send to webhook: {response.status} - {await response.text()}") 16 | except Exception as e: 17 | logger.error(f"Failed to send to webhook: {e}") 18 | -------------------------------------------------------------------------------- /src/metrics.py: -------------------------------------------------------------------------------- 1 | from prometheus_client import Counter, make_asgi_app 2 | 3 | received_commands = Counter('commands_received', 'Count number of commands received.', ['command', ]) 4 | completed_commands = Counter('commands_completed', 'Count number of commands completed.', ['command', ]) 5 | errored_commands = Counter('commands_errored', 'Count number of commands errored.', ['command', ]) 6 | 7 | metrics_app = make_asgi_app() 8 | -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/src/utils/__init__.py -------------------------------------------------------------------------------- /src/utils/extensions.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import pkgutil 4 | from typing import Iterator, NoReturn 5 | 6 | from src import cmds 7 | 8 | 9 | def unqualify(name: str) -> str: 10 | """Return an unqualified name given a qualified module/package `name`.""" 11 | return name.rsplit(".", maxsplit=1)[-1] 12 | 13 | 14 | def walk_extensions() -> Iterator[str]: 15 | """Yield extension names from the bot.cmds subpackage.""" 16 | 17 | def on_error(name: str) -> NoReturn: 18 | raise ImportError(name=name) 19 | 20 | for module in pkgutil.walk_packages(cmds.__path__, f"{cmds.__name__}.", onerror=on_error): 21 | if unqualify(module.name).startswith("_"): 22 | # Ignore module/package names starting with an underscore. 23 | continue 24 | 25 | if module.ispkg: 26 | imported = importlib.import_module(module.name) 27 | if not inspect.isfunction(getattr(imported, "setup", None)): 28 | # If it lacks a setup function, it's not an extension. 29 | continue 30 | 31 | yield module.name 32 | 33 | 34 | # Required for the core.extensions cog. 35 | EXTENSIONS = frozenset(walk_extensions()) 36 | -------------------------------------------------------------------------------- /src/utils/formatters.py: -------------------------------------------------------------------------------- 1 | from src.core import constants 2 | 3 | 4 | def color_level(value: float, low: float = constants.low_latency, high: float = constants.high_latency) -> int: 5 | """Return the color intensity of a value.""" 6 | if value < low: 7 | return constants.colours.bright_green 8 | elif value < high: 9 | return constants.colours.orange 10 | else: 11 | return constants.colours.red 12 | -------------------------------------------------------------------------------- /src/views/bandecisionview.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import discord 4 | from discord import Guild, Interaction, Member, User 5 | from discord.ui import Button, InputText, Modal, View 6 | from sqlalchemy import select 7 | 8 | from src.bot import Bot 9 | from src.core import settings 10 | from src.database.models import Ban 11 | from src.database.session import AsyncSessionLocal 12 | from src.helpers.duration import validate_duration 13 | from src.helpers.schedule import schedule 14 | 15 | 16 | class BanDecisionView(View): 17 | """View for making decisions on a ban duration.""" 18 | 19 | def __init__(self, ban_id: int, bot: Bot, guild: Guild, member: Member | User, end_date: str, reason: str): 20 | super().__init__(timeout=None) 21 | self.ban_id = ban_id 22 | self.bot = bot 23 | self.guild = guild 24 | self.member = member 25 | self.end_date = end_date 26 | self.reason = reason 27 | 28 | async def update_message(self, interaction: Interaction, decision: str) -> None: 29 | """Update the message to reflect the decision.""" 30 | admin_name = interaction.user.display_name 31 | decision_message = f"{admin_name} has made a decision: **{decision}** for {self.member.display_name}." 32 | await interaction.message.edit(content=decision_message, view=self) 33 | 34 | async def disable_all_buttons(self) -> None: 35 | """Disable all buttons in the view.""" 36 | for item in self.children: 37 | if isinstance(item, Button): 38 | item.disabled = True 39 | 40 | async def update_buttons(self, clicked_button_id: str) -> None: 41 | """Disable the clicked button and enable all others.""" 42 | for item in self.children: 43 | if isinstance(item, Button): 44 | item.disabled = item.custom_id == clicked_button_id 45 | 46 | @discord.ui.button(label="Approve duration", style=discord.ButtonStyle.success, custom_id="approve_button") 47 | async def approve_button(self, button: Button, interaction: Interaction) -> None: 48 | """Approve the ban duration.""" 49 | await interaction.response.send_message( 50 | f"Ban duration for {self.member.display_name} has been approved.", ephemeral=True 51 | ) 52 | async with AsyncSessionLocal() as session: 53 | stmt = select(Ban).filter(Ban.id == self.ban_id) 54 | result = await session.scalars(stmt) 55 | ban = result.first() 56 | if ban: 57 | ban.approved = True 58 | await session.commit() 59 | await self.guild.get_channel(settings.channels.SR_MOD).send( 60 | f"Ban duration for {self.member.display_name} has been approved by {interaction.user.display_name}." 61 | ) 62 | # Disable the clicked button and enable others 63 | await self.update_buttons("approve_button") 64 | await interaction.message.edit(view=self) 65 | await self.update_message(interaction, "Approved Duration") 66 | 67 | @discord.ui.button(label="Deny and unban", style=discord.ButtonStyle.danger, custom_id="deny_button") 68 | async def deny_button(self, button: Button, interaction: Interaction) -> None: 69 | """Deny the ban duration and unban the member.""" 70 | from src.helpers.ban import unban_member 71 | await interaction.response.send_message( 72 | f"Ban for {self.member.display_name} has been denied and the member will be unbanned.", ephemeral=True 73 | ) 74 | await unban_member(self.guild, self.member) 75 | await self.guild.get_channel(settings.channels.SR_MOD).send( 76 | f"Ban for {self.member.display_name} has been denied by {interaction.user.display_name} and the member has been unbanned." 77 | ) 78 | # Disable all buttons after denial 79 | await self.disable_all_buttons() 80 | await interaction.message.edit(view=self) 81 | await self.update_message(interaction, "Denied and Unbanned") 82 | 83 | @discord.ui.button(label="Dispute", style=discord.ButtonStyle.primary, custom_id="dispute_button") 84 | async def dispute_button(self, button: Button, interaction: Interaction) -> None: 85 | """Dispute the ban duration.""" 86 | modal = DisputeModal(self.ban_id, self.bot, self.guild, self.member, self.end_date, self.reason, self) 87 | await interaction.response.send_modal(modal) 88 | 89 | 90 | class DisputeModal(Modal): 91 | """Modal for disputing a ban duration.""" 92 | 93 | def __init__(self, ban_id: int, bot: Bot, guild: Guild, member: Member | User, end_date: str, reason: str, parent_view: BanDecisionView): 94 | super().__init__(title="Dispute Ban Duration") 95 | self.ban_id = ban_id 96 | self.bot = bot 97 | self.guild = guild 98 | self.member = member 99 | self.end_date = end_date 100 | self.reason = reason 101 | self.parent_view = parent_view # Store the parent view 102 | 103 | # Add InputText for duration 104 | self.add_item( 105 | InputText(label="New Duration", placeholder="Enter new duration (e.g., 10s, 5m, 2h, 1d)", required=True) 106 | ) 107 | 108 | async def callback(self, interaction: Interaction) -> None: 109 | """Handle the dispute duration callback.""" 110 | from src.helpers.ban import unban_member 111 | new_duration_str = self.children[0].value 112 | 113 | # Validate duration using `validate_duration` 114 | dur, dur_exc = validate_duration(new_duration_str) 115 | if dur_exc: 116 | # Send an ephemeral message if the duration is invalid 117 | await interaction.response.send_message(dur_exc, ephemeral=True) 118 | return 119 | 120 | # Proceed with updating the ban record if the duration is valid 121 | async with AsyncSessionLocal() as session: 122 | ban = await session.get(Ban, self.ban_id) 123 | 124 | if not ban or not ban.timestamp: 125 | await interaction.response.send_message(f"Cannot dispute ban {self.ban_id}: record not found.", ephemeral=True) 126 | return 127 | 128 | # Update the ban's unban time and approve the dispute 129 | ban.unban_time = dur 130 | ban.approved = True 131 | await session.commit() 132 | 133 | # Schedule the unban based on the new duration 134 | new_unban_at = datetime.fromtimestamp(dur) 135 | member = await self.bot.get_member_or_user(self.guild, ban.user_id) 136 | if member: 137 | self.bot.loop.create_task(schedule(unban_member(self.guild, member), run_at=new_unban_at)) 138 | 139 | # Notify the user and moderators of the updated ban duration 140 | await interaction.response.send_message( 141 | f"Ban duration updated to {new_duration_str}. The member will be unbanned on {new_unban_at.strftime('%B %d, %Y')} UTC.", 142 | ephemeral=True 143 | ) 144 | await self.guild.get_channel(settings.channels.SR_MOD).send( 145 | f"Ban duration for {self.member.display_name} updated to {new_duration_str}. Unban scheduled for {new_unban_at.strftime('%B %d, %Y')} UTC." 146 | ) 147 | 148 | # Disable buttons and update message on the parent view after dispute 149 | await self.parent_view.update_message(interaction, "Disputed Duration") 150 | -------------------------------------------------------------------------------- /src/webhooks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/src/webhooks/__init__.py -------------------------------------------------------------------------------- /src/webhooks/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from discord import Bot 2 | 3 | from src.webhooks.handlers.academy import handler as academy_handler 4 | from src.webhooks.types import Platform, WebhookBody 5 | 6 | handlers = {Platform.ACADEMY: academy_handler} 7 | 8 | 9 | def can_handle(platform: Platform) -> bool: 10 | return platform in handlers.keys() 11 | 12 | 13 | def handle(body: WebhookBody, bot: Bot) -> any: 14 | platform = body.platform 15 | 16 | if not can_handle(platform): 17 | raise ValueError(f"Platform {platform} not implemented") 18 | 19 | return handlers[platform](body, bot) 20 | -------------------------------------------------------------------------------- /src/webhooks/handlers/academy.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from discord import Bot 4 | from discord.errors import NotFound 5 | from fastapi import HTTPException 6 | 7 | from src.core import settings 8 | from src.webhooks.types import WebhookBody, WebhookEvent 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | async def handler(body: WebhookBody, bot: Bot) -> dict: 14 | """ 15 | Handles incoming webhook events and performs actions accordingly. 16 | 17 | This function processes different webhook events related to account linking, 18 | certificate awarding, and account unlinking. It updates the member's roles 19 | based on the received event. 20 | 21 | Args: 22 | body (WebhookBody): The data received from the webhook. 23 | bot (Bot): The instance of the Discord bot. 24 | 25 | Returns: 26 | dict: A dictionary with a "success" key indicating whether the operation was successful. 27 | 28 | Raises: 29 | HTTPException: If an error occurs while processing the webhook event. 30 | """ 31 | # TODO: Change it here so we pass the guild instead of the bot # noqa: T000 32 | guild = await bot.fetch_guild(settings.guild_ids[0]) 33 | 34 | try: 35 | discord_id = int(body.data["discord_id"]) 36 | member = await guild.fetch_member(discord_id) 37 | except ValueError as exc: 38 | logger.debug("Invalid Discord ID", exc_info=exc) 39 | raise HTTPException(status_code=400, detail="Invalid Discord ID") from exc 40 | except NotFound as exc: 41 | logger.debug("User is not in the Discord server", exc_info=exc) 42 | raise HTTPException(status_code=400, detail="User is not in the Discord server") from exc 43 | 44 | if body.event == WebhookEvent.ACCOUNT_LINKED: 45 | roles_to_add = {settings.roles.ACADEMY_USER} 46 | roles_to_add.update(settings.get_academy_cert_role(cert["id"]) for cert in body.data["certifications"]) 47 | 48 | # Filter out invalid role IDs 49 | role_ids_to_add = {role_id for role_id in roles_to_add if role_id is not None} 50 | roles_to_add = {guild.get_role(role_id) for role_id in role_ids_to_add} 51 | 52 | await member.add_roles(*roles_to_add, atomic=True) 53 | elif body.event == WebhookEvent.CERTIFICATE_AWARDED: 54 | cert_id = body.data["certification"]["id"] 55 | 56 | role = settings.get_academy_cert_role(cert_id) 57 | if not role: 58 | logger.debug(f"Role for certification: {cert_id} does not exist") 59 | raise HTTPException(status_code=400, detail=f"Role for certification: {cert_id} does not exist") 60 | 61 | await member.add_roles(role, atomic=True) 62 | elif body.event == WebhookEvent.ACCOUNT_UNLINKED: 63 | current_role_ids = {role.id for role in member.roles} 64 | cert_role_ids = {settings.get_academy_cert_role(cert_id) for _, cert_id in settings.academy_certificates} 65 | 66 | common_role_ids = current_role_ids.intersection(cert_role_ids) 67 | 68 | role_ids_to_remove = {settings.roles.ACADEMY_USER}.union(common_role_ids) 69 | roles_to_remove = {guild.get_role(role_id) for role_id in role_ids_to_remove} 70 | 71 | await member.remove_roles(*roles_to_remove, atomic=True) 72 | else: 73 | logger.debug(f"Event {body.event} not implemented") 74 | raise HTTPException(status_code=501, detail=f"Event {body.event} not implemented") 75 | 76 | return {"success": True} 77 | -------------------------------------------------------------------------------- /src/webhooks/server.py: -------------------------------------------------------------------------------- 1 | import hmac 2 | import logging 3 | from typing import Any, Dict, Union 4 | 5 | from fastapi import FastAPI, HTTPException, Header 6 | from uvicorn import Config, Server 7 | 8 | from src.bot import bot 9 | from src.core import settings 10 | from src.metrics import metrics_app 11 | from src.webhooks import handlers 12 | from src.webhooks.types import WebhookBody 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | app = FastAPI() 17 | 18 | 19 | @app.post("/webhook") 20 | async def webhook_handler(body: WebhookBody, authorization: Union[str, None] = Header(default=None)) -> Dict[str, Any]: 21 | """ 22 | Handles incoming webhook requests and forwards them to the appropriate handler. 23 | 24 | This function first checks the provided authorization token in the request header. 25 | If the token is valid, it checks if the platform can be handled and then forwards 26 | the request to the corresponding handler. 27 | 28 | Args: 29 | body (WebhookBody): The data received from the webhook. 30 | authorization (Union[str, None]): The authorization header containing the Bearer token. 31 | 32 | Returns: 33 | Dict[str, Any]: The response from the corresponding handler. The dictionary contains 34 | a "success" key indicating whether the operation was successful. 35 | 36 | Raises: 37 | HTTPException: If an error occurs while processing the webhook event or if unauthorized. 38 | """ 39 | if authorization is None or not authorization.strip().startswith("Bearer"): 40 | logger.warning("Unauthorized webhook request") 41 | raise HTTPException(status_code=401, detail="Unauthorized") 42 | 43 | token = authorization[6:].strip() 44 | if hmac.compare_digest(token, settings.WEBHOOK_TOKEN): 45 | logger.warning("Unauthorized webhook request") 46 | raise HTTPException(status_code=401, detail="Unauthorized") 47 | 48 | if not handlers.can_handle(body.platform): 49 | logger.warning("Webhook request not handled by platform") 50 | raise HTTPException(status_code=501, detail="Platform not implemented") 51 | 52 | return await handlers.handle(body, bot) 53 | 54 | 55 | app.mount("/metrics", metrics_app) 56 | 57 | config = Config(app, host="0.0.0.0", port=settings.WEBHOOK_PORT) 58 | server = Server(config) 59 | -------------------------------------------------------------------------------- /src/webhooks/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class WebhookEvent(Enum): 7 | ACCOUNT_LINKED = "AccountLinked" 8 | ACCOUNT_UNLINKED = "AccountUnlinked" 9 | CERTIFICATE_AWARDED = "CertificateAwarded" 10 | RANK_UP = "RankUp" 11 | HOF_CHANGE = "HofChange" 12 | SUBSCRIPTION_CHANGE = "SubscriptionChange" 13 | CONTENT_RELEASED = "ContentReleased" 14 | NAME_CHANGE = "NameChange" 15 | 16 | 17 | class Platform(Enum): 18 | MAIN = "mp" 19 | ACADEMY = "academy" 20 | CTF = "ctf" 21 | ENTERPRISE = "enterprise" 22 | 23 | 24 | class WebhookBody(BaseModel): 25 | platform: Platform 26 | event: WebhookEvent 27 | data: dict 28 | -------------------------------------------------------------------------------- /startup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -f "/vault/secrets/.env" ] 4 | then 5 | export $(grep -v '^#' /vault/secrets/.env | xargs) 6 | ln -s /vault/secrets/.env .env 7 | fi 8 | 9 | while ! mysqladmin ping -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" --silent; do 10 | echo 'Database not ready yet' 11 | sleep 1 12 | done 13 | 14 | # Run migrations & start the bot 15 | alembic upgrade head && poetry run task start 16 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/tests/__init__.py -------------------------------------------------------------------------------- /tests/_autospec.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import functools 3 | from typing import Any, Callable 4 | from unittest import mock 5 | 6 | 7 | @functools.wraps(mock._patch.decoration_helper) 8 | @contextlib.contextmanager 9 | def _decoration_helper(patched: Any, *args, **kwargs) -> tuple[tuple, dict]: 10 | """Skips adding patches as args if their `dont_pass` attribute is True.""" 11 | # Don't ask what this does. It's just a copy from stdlib, but with the dont_pass check added. 12 | extra_args = [] 13 | with contextlib.ExitStack() as exit_stack: 14 | for patching in patched.patchings: 15 | arg = exit_stack.enter_context(patching) 16 | if not getattr(patching, "dont_pass", False): 17 | # Only add the patching as an arg if dont_pass is False. 18 | if patching.attribute_name is not None: 19 | kwargs.update(arg) 20 | elif patching.new is mock.DEFAULT: 21 | extra_args.append(arg) 22 | 23 | args += tuple(extra_args) 24 | yield args, kwargs 25 | 26 | 27 | @functools.wraps(mock._patch.copy) 28 | def _copy(self: Any) -> Any: 29 | """Copy the `dont_pass` attribute along with the standard copy operation.""" 30 | patcher_copy = _copy.original(self) 31 | patcher_copy.dont_pass = getattr(self, "dont_pass", False) 32 | return patcher_copy 33 | 34 | 35 | # Monkey-patch the patcher class :) 36 | _copy.original = mock._patch.copy 37 | mock._patch.copy = _copy 38 | mock._patch.decoration_helper = _decoration_helper 39 | 40 | 41 | def autospec(target: Any, *attributes: str, pass_mocks: bool = True, **patch_kwargs) -> Callable: 42 | """ 43 | Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True. 44 | 45 | If `pass_mocks` is True, pass the autospecced mocks as arguments to the decorated object. 46 | """ 47 | # Caller's kwargs should take priority and overwrite the defaults. 48 | kwargs = dict(spec_set=True, autospec=True) 49 | kwargs.update(patch_kwargs) 50 | 51 | # Import the target if it's a string. 52 | # This is to support both object and string targets like patch.multiple. 53 | if type(target) is str: 54 | target = mock._importer(target) 55 | 56 | def decorator(func: Callable) -> Callable: 57 | for attribute in attributes: 58 | patcher = mock.patch.object(target, attribute, **kwargs) 59 | if not pass_mocks: 60 | # A custom attribute to keep track of which patches should be skipped. 61 | patcher.dont_pass = True 62 | func = patcher(func) 63 | return func 64 | 65 | return decorator 66 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 5 | 6 | from tests import helpers 7 | 8 | 9 | @pytest.fixture 10 | def hashable_mocks(): 11 | return helpers.MockRole, helpers.MockMember, helpers.MockGuild 12 | 13 | 14 | @pytest.fixture 15 | def bot(): 16 | return helpers.MockBot() 17 | 18 | 19 | @pytest.fixture 20 | def ctx(): 21 | return helpers.MockContext() 22 | 23 | 24 | @pytest.fixture 25 | def text_channel(): 26 | return helpers.MockTextChannel() 27 | 28 | 29 | @pytest.fixture 30 | def user(): 31 | return helpers.MockUser() 32 | 33 | 34 | @pytest.fixture 35 | def member(): 36 | return helpers.MockMember() 37 | 38 | 39 | @pytest.fixture 40 | def author(): 41 | return helpers.MockMember() 42 | 43 | 44 | @pytest.fixture 45 | def guild(): 46 | # Create and return a mocked instance of the Guild class 47 | return helpers.MockGuild() 48 | 49 | 50 | @pytest.fixture 51 | def id_(): 52 | return 297552404041814548 # Randomly generated id. 53 | 54 | 55 | @pytest.fixture 56 | def content(): 57 | return 297552404041814548 # Randomly generated id. 58 | 59 | 60 | @pytest.fixture 61 | def session(mocker): 62 | class AsyncContextManager: 63 | async def __aenter__(self): 64 | return session 65 | 66 | async def __aexit__(self, exc_type, exc, tb): 67 | pass 68 | 69 | # Mock the AsyncSession class 70 | session = AsyncMock(spec=AsyncSession) 71 | 72 | # Mock the async_sessionmaker 73 | async_sessionmaker_mock = mocker.MagicMock(spec=async_sessionmaker) 74 | async_sessionmaker_mock.return_value = AsyncContextManager() 75 | return async_sessionmaker_mock 76 | -------------------------------------------------------------------------------- /tests/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/tests/plugins/__init__.py -------------------------------------------------------------------------------- /tests/plugins/env_vars.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | 6 | @pytest.hookimpl(tryfirst=True) 7 | def pytest_load_initial_conftests(): 8 | os.environ["BOT_TOKEN"] = "ODk3MTVyNDOb50MDAxODE0NTC4.YWRgYg.hqWNRybjyk1j2h3h42vEoc8feoNqR0ubBCYwxo" 9 | os.environ["GUILD_IDS"] = "[7764771731239076051,8312163095926538351]" 10 | -------------------------------------------------------------------------------- /tests/src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/tests/src/__init__.py -------------------------------------------------------------------------------- /tests/src/cmds/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/tests/src/cmds/__init__.py -------------------------------------------------------------------------------- /tests/src/cmds/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/tests/src/cmds/core/__init__.py -------------------------------------------------------------------------------- /tests/src/cmds/core/test_channel.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from src.cmds.core import channel 4 | from tests.helpers import MockTextChannel 5 | 6 | 7 | class TestChannelManage: 8 | """Test the `ChannelManage` cog.""" 9 | 10 | @pytest.mark.asyncio 11 | @pytest.mark.parametrize("seconds", [10, str(10)]) 12 | async def test_slowmode_success(self, bot, ctx, seconds): 13 | """Test `slowmode` command with valid seconds.""" 14 | cog = channel.ChannelCog(bot) 15 | 16 | channel_ = MockTextChannel(name="slow-mode") 17 | # Invoke the command. 18 | await cog.slowmode.callback(cog, ctx, channel=channel_, seconds=seconds) 19 | 20 | args, kwargs = ctx.respond.call_args 21 | content = args[0] 22 | 23 | # Assert channel was edited 24 | channel_.edit.assert_called_once_with(slowmode_delay=int(seconds)) 25 | # Assert response was sent 26 | assert isinstance(content, str) 27 | assert content == f"Slow-mode set in {channel_.name} to {seconds} seconds." 28 | ctx.respond.assert_called_once() 29 | 30 | @pytest.mark.asyncio 31 | @pytest.mark.parametrize( 32 | "seconds, expected_seconds", [(300, 30), (-10, 0)] 33 | ) 34 | async def test_slowmode_with_seconds_out_of_bounds(self, bot, ctx, seconds, expected_seconds): 35 | """Test `slowmode` command without of bounds seconds.""" 36 | cog = channel.ChannelCog(bot) 37 | 38 | channel_ = MockTextChannel(name="slow-mode") 39 | # Invoke the command. 40 | await cog.slowmode.callback(cog, ctx, channel=channel_, seconds=seconds) 41 | 42 | args, kwargs = ctx.respond.call_args 43 | content = args[0] 44 | 45 | # Assert channel was edited 46 | channel_.edit.assert_called_once_with(slowmode_delay=expected_seconds) 47 | # Assert response was sent 48 | assert isinstance(content, str) 49 | assert content == f"Slow-mode set in {channel_.name} to {expected_seconds} seconds." 50 | ctx.respond.assert_called_once() 51 | 52 | @pytest.mark.asyncio 53 | async def test_slowmode_seconds_as_invalid_string(self, bot, ctx): 54 | """Test the response of the `slowmode` command with invalid seconds string.""" 55 | cog = channel.ChannelCog(bot) 56 | 57 | seconds = "not seconds" 58 | # Invoke the command. 59 | await cog.slowmode.callback(cog, ctx, channel=MockTextChannel(), seconds=seconds) 60 | 61 | args, kwargs = ctx.respond.call_args 62 | content = args[0] 63 | 64 | # Command should respond with a string. 65 | assert isinstance(content, str) 66 | assert content == f"Malformed amount of seconds: {seconds}." 67 | ctx.respond.assert_called_once() 68 | 69 | def test_setup(self, bot): 70 | """Test the setup method of the cog.""" 71 | # Invoke the command 72 | channel.setup(bot) 73 | 74 | bot.add_cog.assert_called_once() 75 | -------------------------------------------------------------------------------- /tests/src/cmds/core/test_ctf.py: -------------------------------------------------------------------------------- 1 | from src.cmds.core import ctf 2 | 3 | 4 | class TestCtfCog: 5 | """Test the `Ctf` cog.""" 6 | 7 | def test_setup(self, bot): 8 | """Test the setup method of the cog.""" 9 | # Invoke the command 10 | ctf.setup(bot) 11 | 12 | bot.add_cog.assert_called_once() 13 | -------------------------------------------------------------------------------- /tests/src/cmds/core/test_fun.py: -------------------------------------------------------------------------------- 1 | from src.cmds.core import fun 2 | 3 | 4 | class TestFunCog: 5 | """Test the `Fun` cog.""" 6 | 7 | def test_setup(self, bot): 8 | """Test the setup method of the cog.""" 9 | # Invoke the command 10 | fun.setup(bot) 11 | 12 | bot.add_cog.assert_called_once() 13 | -------------------------------------------------------------------------------- /tests/src/cmds/core/test_history.py: -------------------------------------------------------------------------------- 1 | from src.cmds.core import history 2 | 3 | 4 | class TestHistoryCog: 5 | """Test the `History` cog.""" 6 | 7 | def test_setup(self, bot): 8 | """Test the setup method of the cog.""" 9 | # Invoke the command 10 | history.setup(bot) 11 | 12 | bot.add_cog.assert_called_once() 13 | -------------------------------------------------------------------------------- /tests/src/cmds/core/test_identify.py: -------------------------------------------------------------------------------- 1 | from src.cmds.core import identify 2 | 3 | 4 | class TestIdentifyCog: 5 | """Test the `Identify` cog.""" 6 | 7 | def test_setup(self, bot): 8 | """Test the setup method of the cog.""" 9 | # Invoke the command 10 | identify.setup(bot) 11 | 12 | bot.add_cog.assert_called_once() 13 | -------------------------------------------------------------------------------- /tests/src/cmds/core/test_mute.py: -------------------------------------------------------------------------------- 1 | from src.cmds.core import mute 2 | 3 | 4 | class TestMuteCog: 5 | """Test the `Mute` cog.""" 6 | 7 | def test_setup(self, bot): 8 | """Test the setup method of the cog.""" 9 | # Invoke the command 10 | mute.setup(bot) 11 | 12 | bot.add_cog.assert_called_once() 13 | -------------------------------------------------------------------------------- /tests/src/cmds/core/test_note.py: -------------------------------------------------------------------------------- 1 | from src.cmds.core import note 2 | 3 | 4 | class TestNoteCog: 5 | """Test the `Note` cog.""" 6 | 7 | def test_setup(self, bot): 8 | """Test the setup method of the cog.""" 9 | # Invoke the command 10 | note.setup(bot) 11 | 12 | bot.add_cog.assert_called_once() 13 | -------------------------------------------------------------------------------- /tests/src/cmds/core/test_other.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock, patch 2 | 3 | import pytest 4 | from discord import ApplicationContext 5 | 6 | from src.bot import Bot 7 | from src.cmds.core import other 8 | from src.cmds.core.other import OtherCog, SpoilerModal 9 | from src.core import settings 10 | from src.helpers import webhook 11 | 12 | 13 | class TestWebhookHelper: 14 | """Test the webhook helper functions.""" 15 | 16 | @pytest.mark.asyncio 17 | async def test_webhook_call_success(self): 18 | """Test successful webhook call.""" 19 | test_url = "http://test.webhook.url" 20 | test_data = {"key": "value"} 21 | 22 | # Mock the aiohttp ClientSession 23 | with patch('aiohttp.ClientSession.post') as mock_post: 24 | mock_response = AsyncMock() 25 | mock_response.status = 200 26 | mock_post.return_value.__aenter__.return_value = mock_response 27 | 28 | await webhook.webhook_call(test_url, test_data) 29 | 30 | # Verify the post was called with correct parameters 31 | mock_post.assert_called_once_with(test_url, json=test_data) 32 | 33 | @pytest.mark.asyncio 34 | async def test_webhook_call_failure(self): 35 | """Test failed webhook call.""" 36 | test_url = "http://test.webhook.url" 37 | test_data = {"key": "value"} 38 | 39 | # Mock the aiohttp ClientSession 40 | with patch('aiohttp.ClientSession.post') as mock_post: 41 | mock_response = AsyncMock() 42 | mock_response.status = 500 43 | mock_response.text = AsyncMock(return_value="Internal Server Error") 44 | mock_post.return_value.__aenter__.return_value = mock_response 45 | 46 | # Test should complete without raising an exception 47 | await webhook.webhook_call(test_url, test_data) 48 | 49 | 50 | class TestOther: 51 | """Test the `ChannelManage` cog.""" 52 | 53 | @pytest.mark.asyncio 54 | async def test_no_hints(self, bot, ctx): 55 | """Test the response of the `no_hints` command.""" 56 | cog = OtherCog(bot) 57 | ctx.bot = bot 58 | 59 | # Invoke the command. 60 | await cog.no_hints.callback(cog, ctx) 61 | 62 | args, kwargs = ctx.respond.call_args 63 | content = args[0] 64 | 65 | # Command should respond with a string. 66 | assert isinstance(content, str) 67 | 68 | assert content.startswith("No hints are allowed") 69 | 70 | @pytest.mark.asyncio 71 | async def test_support_labs(self, bot, ctx): 72 | """Test the response of the `support` command.""" 73 | cog = other.OtherCog(bot) 74 | ctx.bot = bot 75 | platform = "labs" 76 | 77 | # Invoke the command. 78 | await cog.support.callback(cog, ctx, platform) 79 | 80 | args, kwargs = ctx.respond.call_args 81 | content = args[0] 82 | 83 | # Command should respond with a string. 84 | assert isinstance(content, str) 85 | 86 | assert content == "https://help.hackthebox.com/en/articles/5986762-contacting-htb-support" 87 | 88 | @pytest.mark.asyncio 89 | async def test_support_academy(self, bot, ctx): 90 | """Test the response of the `support` command.""" 91 | cog = other.OtherCog(bot) 92 | ctx.bot = bot 93 | platform = "academy" 94 | 95 | # Invoke the command. 96 | await cog.support.callback(cog, ctx, platform) 97 | 98 | args, kwargs = ctx.respond.call_args 99 | content = args[0] 100 | 101 | # Command should respond with a string. 102 | assert isinstance(content, str) 103 | 104 | assert content == "https://help.hackthebox.com/en/articles/5987511-contacting-academy-support" 105 | 106 | @pytest.mark.asyncio 107 | async def test_support_urls_different(self, bot, ctx): 108 | """Test that the URLs for 'labs' and 'academy' platforms are different.""" 109 | cog = other.OtherCog(bot) 110 | ctx.bot = bot 111 | 112 | # Test the 'labs' platform 113 | await cog.support.callback(cog, ctx, "labs") 114 | labs_url = ctx.respond.call_args[0][0] 115 | 116 | # Test the 'academy' platform 117 | await cog.support.callback(cog, ctx, "academy") 118 | academy_url = ctx.respond.call_args[0][0] 119 | 120 | # Assert that the URLs are different 121 | assert labs_url != academy_url 122 | 123 | @pytest.mark.asyncio 124 | async def test_spoiler_modal_callback_with_url(self): 125 | """Test the spoiler modal callback with a valid URL.""" 126 | modal = SpoilerModal(title="Report Spoiler") 127 | interaction = AsyncMock() 128 | interaction.user.display_name = "TestUser" 129 | modal.children[0].value = "Test description" 130 | modal.children[1].value = "http://example.com/spoiler" 131 | 132 | with patch('src.helpers.webhook.webhook_call', new_callable=AsyncMock) as mock_webhook: 133 | await modal.callback(interaction) 134 | 135 | interaction.response.send_message.assert_called_once_with( 136 | "Thank you, the spoiler has been reported.", ephemeral=True 137 | ) 138 | 139 | # Verify webhook was called with correct data 140 | mock_webhook.assert_called_once_with( 141 | settings.JIRA_WEBHOOK, 142 | { 143 | "user": "TestUser", 144 | "url": "http://example.com/spoiler", 145 | "desc": "Test description", 146 | "type": "spoiler" 147 | } 148 | ) 149 | 150 | @pytest.mark.asyncio 151 | async def test_cheater_command(self, bot, ctx): 152 | """Test the cheater command with valid inputs.""" 153 | cog = OtherCog(bot) 154 | ctx.bot = bot 155 | ctx.user.display_name = "ReporterUser" 156 | 157 | test_username = "SuspectedUser" 158 | test_description = "Suspicious activity description" 159 | 160 | with patch('src.helpers.webhook.webhook_call', new_callable=AsyncMock) as mock_webhook: 161 | await cog.cheater.callback(cog, ctx, test_username, test_description) 162 | 163 | # Verify the webhook was called with correct data 164 | mock_webhook.assert_called_once_with( 165 | settings.JIRA_WEBHOOK, 166 | { 167 | "user": "ReporterUser", 168 | "cheater": test_username, 169 | "description": test_description, 170 | "type": "cheater" 171 | } 172 | ) 173 | 174 | # Verify the response was sent 175 | ctx.respond.assert_called_once_with( 176 | "Thank you for your report.", 177 | ephemeral=True 178 | ) 179 | 180 | 181 | 182 | def test_setup(self, bot): 183 | """Test the setup method of the cog.""" 184 | # Invoke the command 185 | other.setup(bot) 186 | 187 | bot.add_cog.assert_called_once() 188 | -------------------------------------------------------------------------------- /tests/src/cmds/core/test_ping.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from discord import Embed 3 | 4 | from src.cmds.core import ping 5 | from src.utils.formatters import color_level 6 | 7 | 8 | class TestPing: 9 | """Test the `Ping` cog.""" 10 | 11 | @pytest.mark.asyncio 12 | async def test_ping(self, bot, ctx): 13 | """Test the response of the `ping` command.""" 14 | bot.latency = 0.150 # Required by the command. 15 | cog = ping.PingCog(bot) 16 | 17 | # Invoke the command. 18 | await cog.ping.callback(cog, ctx) 19 | 20 | args, kwargs = ctx.respond.call_args 21 | embed: Embed = kwargs.get("embed") 22 | 23 | # Command should respond with an embed. 24 | assert isinstance(embed, Embed) 25 | 26 | # Colours' values should match. 27 | assert embed.colour.value == color_level(bot.latency) 28 | 29 | def test_setup(self, bot): 30 | """Test the setup method of the cog.""" 31 | # Invoke the command 32 | ping.setup(bot) 33 | 34 | bot.add_cog.assert_called_once() 35 | -------------------------------------------------------------------------------- /tests/src/cmds/core/test_user.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock, patch 2 | 3 | import pytest 4 | 5 | from src.cmds.core import user 6 | from tests import helpers 7 | 8 | 9 | class TestUserCog: 10 | """Test the `User` cog.""" 11 | 12 | @pytest.mark.asyncio 13 | async def test_kick_success(self, ctx, guild, bot, session): 14 | ctx.user = helpers.MockMember(id=1, name="Test Moderator") 15 | user_to_kick = helpers.MockMember(id=2, name="User to Kick", bot=False) 16 | ctx.guild = guild 17 | ctx.guild.kick = AsyncMock() 18 | bot.get_member_or_user = AsyncMock(return_value=user_to_kick) 19 | 20 | # Mock the DM channel 21 | user_to_kick.send = AsyncMock() 22 | user_to_kick.name = "User to Kick" 23 | 24 | with ( 25 | patch('src.cmds.core.user.add_infraction', new_callable=AsyncMock) as add_infraction_mock, 26 | patch('src.cmds.core.user.member_is_staff', return_value=False) 27 | ): 28 | cog = user.UserCog(bot) 29 | await cog.kick.callback(cog, ctx, user_to_kick, "Violation of rules") 30 | 31 | reason = "Violation of rules" 32 | add_infraction_mock.assert_called_once_with( 33 | ctx.guild, user_to_kick, 0, f"Previously kicked for: {reason} - Evidence: None", ctx.user 34 | ) 35 | 36 | # Assertions 37 | ctx.guild.kick.assert_called_once_with(user=user_to_kick, reason="Violation of rules") 38 | ctx.respond.assert_called_once_with("User to Kick got the boot!") 39 | 40 | @pytest.mark.asyncio 41 | async def test_kick_fail_user_left(self, ctx, guild, bot, session): 42 | ctx.user = helpers.MockMember(id=1, name="Test Moderator") 43 | user_to_kick = helpers.MockMember(id=2, name="User to Kick", bot=False) 44 | ctx.guild = guild 45 | ctx.guild.kick = AsyncMock() 46 | bot.get_member_or_user = AsyncMock(return_value=None) 47 | 48 | # Ensure the member_is_staff mock doesn't block execution 49 | with patch('src.cmds.core.user.member_is_staff', return_value=False): 50 | cog = user.UserCog(bot) 51 | await cog.kick.callback(cog, ctx, user_to_kick, "Violation of rules") 52 | 53 | # Assertions 54 | bot.get_member_or_user.assert_called_once_with(ctx.guild, user_to_kick.id) 55 | ctx.guild.kick.assert_not_called() # No kick should occur 56 | ctx.respond.assert_called_once_with("User seems to have already left the server.") 57 | 58 | 59 | def test_setup(self, bot): 60 | """Test the setup method of the cog.""" 61 | # Invoke the command 62 | user.setup(bot) 63 | 64 | bot.add_cog.assert_called_once() 65 | -------------------------------------------------------------------------------- /tests/src/cmds/core/test_verify.py: -------------------------------------------------------------------------------- 1 | from src.cmds.core import verify 2 | 3 | 4 | class TestVerifyCog: 5 | """Test the `Verify` cog.""" 6 | 7 | def test_setup(self, bot): 8 | """Test the setup method of the cog.""" 9 | # Invoke the command 10 | verify.setup(bot) 11 | 12 | bot.add_cog.assert_called_once() 13 | -------------------------------------------------------------------------------- /tests/src/cmds/dev/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/tests/src/cmds/dev/__init__.py -------------------------------------------------------------------------------- /tests/src/cmds/dev/test_extensions.py: -------------------------------------------------------------------------------- 1 | from src.cmds.dev import extensions 2 | 3 | 4 | class TestExtensions: 5 | """Test the `Extensions` cog.""" 6 | 7 | def test_setup(self, bot): 8 | """Test the setup method of the cog.""" 9 | # Invoke the command 10 | extensions.setup(bot) 11 | 12 | bot.add_cog.assert_called_once() 13 | -------------------------------------------------------------------------------- /tests/src/cmds/test_cogs.py: -------------------------------------------------------------------------------- 1 | """Test suite for general tests which apply to all cogs.""" 2 | 3 | import importlib 4 | from collections import defaultdict 5 | from types import ModuleType 6 | from typing import Iterator 7 | 8 | from discord.commands import SlashCommand 9 | from discord.ext import commands 10 | 11 | from src.utils.extensions import walk_extensions 12 | 13 | 14 | class TestCommandName: 15 | """Tests for shadowing command names and aliases.""" 16 | 17 | @staticmethod 18 | def walk_cogs(module: ModuleType) -> Iterator[commands.Cog]: 19 | """Yield all cogs defined in an extension.""" 20 | for obj in module.__dict__.values(): 21 | # Check if it's a class type cause otherwise issubclass() may raise a TypeError. 22 | is_cog = isinstance(obj, type) and issubclass(obj, commands.Cog) 23 | if is_cog and obj.__module__ == module.__name__: 24 | yield obj 25 | 26 | @staticmethod 27 | def walk_commands(cog: commands.Cog) -> Iterator[SlashCommand]: 28 | """An iterator that recursively walks through `cog`'s commands and subcommands.""" 29 | # Can't use Bot.walk_commands() or Cog.get_commands() cause those are instance methods. 30 | for cmd in cog.__cog_commands__: 31 | if cmd.parent is None: 32 | yield cmd 33 | if isinstance(cmd, commands.GroupMixin): 34 | # Annoyingly it returns duplicates for each alias so use a set to fix that. 35 | yield from set(cmd.walk_commands()) 36 | 37 | def get_all_commands(self) -> Iterator[SlashCommand]: 38 | """Yield all commands for all cogs in all extensions.""" 39 | for ext in walk_extensions(): 40 | module = importlib.import_module(ext) 41 | for cog in self.walk_cogs(module): 42 | for cmd in self.walk_commands(cog): 43 | cmd.cog = cog # Should explicitly assign the cog object. 44 | yield cmd 45 | 46 | def test_names_dont_shadow(self): 47 | """Names and aliases of commands should be unique.""" 48 | all_names = defaultdict(list) 49 | for cmd in self.get_all_commands(): 50 | try: 51 | func_name = f"{cmd.cog.__module__}.{cmd.callback.__qualname__}" 52 | except AttributeError: 53 | # If `cmd` is a SlashCommandGroup. 54 | func_name = f"{cmd.cog.__module__}.{cmd.name}" 55 | 56 | name = cmd.qualified_name 57 | 58 | if name in all_names: 59 | conflicts = ", ".join(all_names.get(name, "")) 60 | raise NameError(f"Name '{name}' of the command {func_name} conflicts with {conflicts}.") 61 | 62 | all_names[name].append(func_name) 63 | -------------------------------------------------------------------------------- /tests/src/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/tests/src/database/__init__.py -------------------------------------------------------------------------------- /tests/src/database/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/tests/src/database/models/__init__.py -------------------------------------------------------------------------------- /tests/src/database/models/test_ban_model.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy import delete, insert, update 3 | 4 | from src.database.models import Ban 5 | 6 | 7 | class TestBanModel: 8 | 9 | @pytest.mark.asyncio 10 | async def test_select(self, session): 11 | async with session() as session: 12 | # Define return value for select 13 | session.get.return_value = Ban(id=1, user_id=1, reason="No reason", moderator_id=2) 14 | 15 | ban = await session.get(Ban, 1) 16 | assert ban.id == 1 17 | 18 | # Check if the method was called with the correct argument 19 | session.get.assert_called_once() 20 | 21 | @pytest.mark.asyncio 22 | async def test_insert(self, session): 23 | async with session() as session: 24 | # Define return value for insert 25 | session.add.return_value = None 26 | session.commit.return_value = None 27 | 28 | query = insert(Ban).values(name="John Doe", age=30) 29 | session.add(query) 30 | await session.commit() 31 | 32 | # Check if the methods were called with the correct arguments 33 | session.add.assert_called_once_with(query) 34 | session.commit.assert_called_once() 35 | 36 | @pytest.mark.asyncio 37 | async def test_insert_unban_time_bigint(self, session): 38 | async with session() as session: 39 | # Define return value for insert 40 | session.add.return_value = None 41 | session.commit.return_value = None 42 | 43 | query = insert(Ban).values(name="John Doe", age=30, unban_time=2153337603) 44 | session.add(query) 45 | await session.commit() 46 | 47 | # Check if the methods were called with the correct arguments 48 | session.add.assert_called_once_with(query) 49 | session.commit.assert_called_once() 50 | 51 | @pytest.mark.asyncio 52 | async def test_update(self, session): 53 | async with session() as session: 54 | # Define return value for update 55 | session.execute.return_value = None 56 | session.commit.return_value = None 57 | 58 | query = ( 59 | update(Ban) 60 | .where(Ban.id == 1) 61 | .values(name="Jane Doe") 62 | ) 63 | await session.execute(query) 64 | await session.commit() 65 | 66 | # Check if the methods were called with the correct arguments 67 | session.execute.assert_called_once_with(query) 68 | session.commit.assert_called_once() 69 | 70 | @pytest.mark.asyncio 71 | async def test_delete(self, session): 72 | async with session() as session: 73 | # Define a Ban record to delete 74 | ban = Ban(user_id=1, reason="No reason", moderator_id=2) 75 | session.add(ban) 76 | await session.commit() 77 | 78 | # Define return value for delete 79 | session.execute.return_value = None 80 | session.commit.return_value = None 81 | 82 | # Delete the Ban record from the database 83 | query = delete(Ban).where(Ban.id == ban.id) 84 | await session.execute(query) 85 | 86 | # Check if the methods were called with the correct arguments 87 | session.execute.assert_called_once_with(query) 88 | session.commit.assert_called_once() 89 | -------------------------------------------------------------------------------- /tests/src/database/models/test_ctf_model.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import pytest 4 | from sqlalchemy import delete, insert 5 | 6 | from src.database.models import Ctf 7 | 8 | 9 | class TestCtfModel: 10 | 11 | @pytest.mark.asyncio 12 | async def test_select(self, session): 13 | async with session() as session: 14 | # Define return value for select 15 | id_ = random.randint(1, 10) 16 | session.get.return_value = Ctf( 17 | id=id_, name="Test CTF", guild_id="12345678901234567", 18 | admin_role_id="123456789012345678", participant_role_id="987654321098765432", password="secure_pass123", 19 | ) 20 | 21 | ctf = await session.get(Ctf, id_) 22 | assert ctf.id == id_ 23 | assert ctf.password == "secure_pass123" 24 | 25 | # Check if the method was called with the correct argument 26 | session.get.assert_called_once() 27 | 28 | @pytest.mark.asyncio 29 | async def test_insert(self, session): 30 | async with session() as session: 31 | # Define return value for insert 32 | session.add.return_value = None 33 | session.commit.return_value = None 34 | 35 | query = insert(Ctf).values( 36 | name="Test CTF", guild_id="12345678901234567", 37 | admin_role_id="123456789012345678", participant_role_id="987654321098765432", password="secure_pass123", 38 | ) 39 | session.add(query) 40 | await session.commit() 41 | 42 | # Check if the methods were called with the correct arguments 43 | session.add.assert_called_once_with(query) 44 | session.commit.assert_called_once() 45 | 46 | @pytest.mark.asyncio 47 | async def test_delete(self, session): 48 | async with session() as session: 49 | # Define a Ctf record to delete 50 | ctf = Ctf( 51 | id=13, name="Test CTF", guild_id="12345678901234567", 52 | admin_role_id="123456789012345678", participant_role_id="987654321098765432", password="secure_pass123", 53 | ) 54 | session.add(ctf) 55 | await session.commit() 56 | 57 | # Define return value for delete 58 | session.execute.return_value = None 59 | session.commit.return_value = None 60 | 61 | # Delete the Ctf record from the database 62 | query = delete(Ctf).where(Ctf.id == ctf.id) 63 | await session.execute(query) 64 | 65 | # Check if the methods were called with the correct arguments 66 | session.execute.assert_called_once_with(query) 67 | session.commit.assert_called_once() 68 | -------------------------------------------------------------------------------- /tests/src/database/models/test_htb_discord_link_model.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import pytest 4 | from sqlalchemy import delete, insert 5 | 6 | from src.database.models import HtbDiscordLink 7 | 8 | 9 | class TestHtbDiscordLinkModel: 10 | 11 | @pytest.mark.asyncio 12 | async def test_select(self, session): 13 | async with session() as session: 14 | # Define return value for select 15 | id_ = random.randint(1, 10) 16 | session.get.return_value = HtbDiscordLink( 17 | id=id_, account_identifier="AVy2aKzvtEeSsPuDAM23t6Tg2uC46T0rvqpupyPdbnzkYH1GbJBXpEkoyKfe", 18 | discord_user_id="815223854165240996", htb_user_id="1337" 19 | ) 20 | 21 | link = await session.get(HtbDiscordLink, id_) 22 | assert link.id == id_ 23 | assert link.discord_user_id_as_int == 815223854165240996 24 | assert link.htb_user_id_as_int == 1337 25 | 26 | # Check if the method was called with the correct argument 27 | session.get.assert_called_once() 28 | 29 | @pytest.mark.asyncio 30 | async def test_insert(self, session): 31 | async with session() as session: 32 | # Define return value for insert 33 | session.add.return_value = None 34 | session.commit.return_value = None 35 | 36 | query = insert(HtbDiscordLink).values( 37 | account_identifier="AVy2aKzvtEeSsPuDAM23t6Tg2uC46T0rvqpupyPdbnzkYH1GbJBXpEkoyKfe", 38 | discord_user_id=815223854165240996, htb_user_id=1337 39 | ) 40 | session.add(query) 41 | await session.commit() 42 | 43 | # Check if the methods were called with the correct arguments 44 | session.add.assert_called_once_with(query) 45 | session.commit.assert_called_once() 46 | 47 | @pytest.mark.asyncio 48 | async def test_delete(self, session): 49 | async with session() as session: 50 | # Define a HtbDiscordLink record to delete 51 | link = HtbDiscordLink( 52 | id=13, account_identifier="AVy2aKzvtEeSsPuDAM23t6Tg2uC46T0rvqpupyPdbnzkYH1GbJBXpEkoyKfe", 53 | discord_user_id="815223854165240996", htb_user_id="1337" 54 | ) 55 | session.add(link) 56 | await session.commit() 57 | 58 | # Define return value for delete 59 | session.execute.return_value = None 60 | session.commit.return_value = None 61 | 62 | # Delete the HtbDiscordLink record from the database 63 | query = delete(HtbDiscordLink).where(HtbDiscordLink.id == link.id) 64 | await session.execute(query) 65 | 66 | # Check if the methods were called with the correct arguments 67 | session.execute.assert_called_once_with(query) 68 | session.commit.assert_called_once() 69 | -------------------------------------------------------------------------------- /tests/src/database/models/test_macro_model.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy import delete, insert, update 3 | 4 | from src.database.models import Macro 5 | 6 | 7 | class TestMacroModel: 8 | @pytest.mark.asyncio 9 | async def test_select(self, session): 10 | async with session() as session: 11 | # Define return value for select 12 | session.get.return_value = Macro(id=1, user_id=1, name="Test", text="Test", created_at="2022-01-01 00:00:00") 13 | 14 | macro = await session.get(Macro, 1) 15 | assert macro.id == 1 16 | 17 | # Check if the method was called with the correct argument 18 | session.get.assert_called_once() 19 | 20 | @pytest.mark.asyncio 21 | async def test_insert(self, session): 22 | async with session() as session: 23 | # Define return value for insert 24 | session.add.return_value = None 25 | session.commit.return_value = None 26 | 27 | query = insert(Macro).values(name="Test", text="Test") 28 | session.add(query) 29 | await session.commit() 30 | 31 | # Check if the methods were called with the correct arguments 32 | session.add.assert_called_once_with(query) 33 | session.commit.assert_called_once() 34 | 35 | @pytest.mark.asyncio 36 | async def test_update(self, session): 37 | async with session() as session: 38 | # Define return value for update 39 | session.execute.return_value = None 40 | session.commit.return_value = None 41 | 42 | query = ( 43 | update(Macro) 44 | .where(Macro.id == 1) 45 | .values(name="Test", text="Test") 46 | ) 47 | await session.execute(query) 48 | await session.commit() 49 | 50 | # Check if the methods were called with the correct arguments 51 | session.execute.assert_called_once_with(query) 52 | session.commit.assert_called_once() 53 | 54 | @pytest.mark.asyncio 55 | async def test_delete(self, session): 56 | async with session() as session: 57 | # Define return value for delete 58 | session.delete.return_value = None 59 | session.commit.return_value = None 60 | 61 | query = delete(Macro).where(Macro.id == 1) 62 | await session.delete(query) 63 | await session.commit() 64 | 65 | # Check if the methods were called with the correct arguments 66 | session.delete.assert_called_once_with(query) 67 | session.commit.assert_called_once() 68 | -------------------------------------------------------------------------------- /tests/src/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/tests/src/helpers/__init__.py -------------------------------------------------------------------------------- /tests/src/helpers/test_duration.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import time 3 | 4 | from src.helpers.duration import validate_duration 5 | 6 | 7 | def test_validate_duration_numeric(): 8 | duration = "3600" 9 | result = validate_duration(duration) 10 | assert result == (0, "Malformed duration. Please use duration units, (e.g. 12h, 14d, 5w).") 11 | 12 | 13 | def test_validate_duration_invalid_parse(): 14 | duration = "not-to-be-parsed" 15 | result = validate_duration(duration) 16 | assert result == (0, "Invalid duration: could not parse.") 17 | 18 | 19 | def test_validate_duration_past(): 20 | duration = "-1h" 21 | baseline_ts = calendar.timegm(time.gmtime()) 22 | result = validate_duration(duration, baseline_ts=baseline_ts) 23 | assert result == (0, "Invalid duration: cannot be in the past.") 24 | 25 | 26 | def test_validate_duration_valid(): 27 | duration = "1h" 28 | now = calendar.timegm(time.gmtime()) 29 | result = validate_duration(duration) 30 | assert result == (now + 3600, "") 31 | 32 | 33 | def test_validate_duration_valid_with_baseline(): 34 | duration = "1h" 35 | baseline_ts = calendar.timegm(time.gmtime()) + 3600 # Set baseline in the future 36 | result = validate_duration(duration, baseline_ts=baseline_ts) 37 | assert result == (baseline_ts + 3600, "") 38 | 39 | 40 | def test_validate_duration_zero(): 41 | duration = "0s" 42 | baseline_ts = calendar.timegm(time.gmtime()) 43 | result = validate_duration(duration, baseline_ts) 44 | assert result == (0, "Invalid duration: cannot be in the past.") 45 | -------------------------------------------------------------------------------- /tests/src/helpers/test_verification.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import aioresponses 4 | import pytest 5 | 6 | from src.core import settings 7 | from src.helpers.verification import get_user_details 8 | 9 | 10 | class TestGetUserDetails(unittest.IsolatedAsyncioTestCase): 11 | 12 | @pytest.mark.asyncio 13 | async def test_get_user_details_success(self): 14 | account_identifier = "some_identifier" 15 | 16 | with aioresponses.aioresponses() as m: 17 | m.get( 18 | f"{settings.API_URL}/discord/identifier/{account_identifier}?secret={settings.HTB_API_SECRET}", 19 | status=200, 20 | payload={"some_key": "some_value"}, 21 | ) 22 | 23 | result = await get_user_details(account_identifier) 24 | self.assertEqual(result, {"some_key": "some_value"}) 25 | 26 | @pytest.mark.asyncio 27 | async def test_get_user_details_404(self): 28 | account_identifier = "some_identifier" 29 | 30 | with aioresponses.aioresponses() as m: 31 | m.get( 32 | f"{settings.API_URL}/discord/identifier/{account_identifier}?secret={settings.HTB_API_SECRET}", 33 | status=404, 34 | ) 35 | 36 | result = await get_user_details(account_identifier) 37 | self.assertIsNone(result) 38 | 39 | @pytest.mark.asyncio 40 | async def test_get_user_details_other_status(self): 41 | account_identifier = "some_identifier" 42 | 43 | with aioresponses.aioresponses() as m: 44 | m.get( 45 | f"{settings.API_URL}/discord/identifier/{account_identifier}?secret={settings.HTB_API_SECRET}", 46 | status=500, 47 | ) 48 | 49 | result = await get_user_details(account_identifier) 50 | self.assertIsNone(result) 51 | -------------------------------------------------------------------------------- /tests/src/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackthebox/Hackster/b03e8a56e75034fac1afd1996ae1117d49b739aa/tests/src/utils/__init__.py -------------------------------------------------------------------------------- /tests/src/utils/test_extensions.py: -------------------------------------------------------------------------------- 1 | from tempfile import NamedTemporaryFile 2 | 3 | from src import cmds 4 | from src.utils.extensions import unqualify, walk_extensions 5 | 6 | 7 | def test_unqualify(): 8 | """Test the `unqualify` function.""" 9 | assert unqualify("bot.cmds.test") == "test" 10 | assert unqualify("bot.cmds.core.ping") == "ping" 11 | 12 | 13 | def test_walk_extensions(): 14 | """Test the `walk_extensions` function.""" 15 | for ext in walk_extensions(): 16 | assert ext.startswith(f"{cmds.__name__}.") 17 | 18 | 19 | def test_walk_extensions_skip_ignored(): 20 | """Extensions starting with _ should be ignored.""" 21 | # Create a temporary file in the format _*.py. 22 | with NamedTemporaryFile(dir=cmds.__path__[0], prefix="_", suffix=".py", mode="w") as f: 23 | ext = f"{cmds.__name__}.{f.name.rsplit('/')[-1].removesuffix('.py')}" 24 | # Make sure the file is skipped. 25 | assert ext not in walk_extensions() 26 | -------------------------------------------------------------------------------- /tests/src/utils/test_formatters.py: -------------------------------------------------------------------------------- 1 | from src.core import constants 2 | from src.utils.formatters import color_level 3 | 4 | 5 | def test_color_level(): 6 | """Test the `color_level` function.""" 7 | low, high = 200, 400 8 | 9 | assert color_level(150, low, high) == constants.colours.bright_green 10 | assert color_level(200, low, high) == constants.colours.orange 11 | assert color_level(350, low, high) == constants.colours.orange 12 | assert color_level(500, low, high) == constants.colours.red 13 | -------------------------------------------------------------------------------- /tests/src/utils/test_pagination.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from src.utils.pagination import ImagePaginator, LinePaginator 4 | 5 | 6 | class TestLinePaginator: 7 | """Test the `LinePaginator` class.""" 8 | 9 | def test_add_line_works_on_small_lines(self): 10 | """`add_line` should allow small lines to be added.""" 11 | paginator = LinePaginator(prefix="", suffix="", max_size=30) 12 | 13 | paginator.add_line('x' * (paginator.max_size - 2)) 14 | assert len(paginator._pages) == 0 # Note that the page isn't added to _pages until it's full. 15 | 16 | def test_add_line_raises_on_long_line(self): 17 | """If the size of a line exceeds `max_size` a RuntimeError should occur.""" 18 | paginator = LinePaginator(prefix="", suffix="", max_size=30) 19 | 20 | with pytest.raises(RuntimeError): 21 | paginator.add_line("x" * paginator.max_size) 22 | 23 | def test_add_line_works_on_long_lines(self): 24 | """After additional lines after `max_size` is exceeded should go on the next page.""" 25 | paginator = LinePaginator(prefix="", suffix="", max_size=30) 26 | 27 | paginator.add_line('x' * (paginator.max_size - 2)) 28 | assert len(paginator._pages) == 0 29 | 30 | # Any additional lines should start a new page after `max_size` is exceeded. 31 | paginator.add_line('x') 32 | assert len(paginator._pages) == 1 33 | 34 | def test_add_line_max_lines(self): 35 | """After additional lines after `max_size` is exceeded should go on the next page.""" 36 | paginator = LinePaginator(prefix="", suffix="", max_size=30, max_lines=2) 37 | 38 | paginator.add_line('x') 39 | paginator.add_line('x') 40 | assert len(paginator._pages) == 0 41 | 42 | # Any additional lines should start a new page after `max_lines` is exceeded. 43 | paginator.add_line('x') 44 | assert len(paginator._pages) == 1 45 | 46 | def test_add_line_adds_empty_lines(self): 47 | """Using the `empty` argument should add an empty line.""" 48 | paginator = LinePaginator(prefix="", suffix="", max_size=30) 49 | 50 | paginator.add_line('x') 51 | assert paginator._count == 3 52 | 53 | # Using `empty` should add 2 to the count instead of 1. 54 | paginator.add_line('x', empty=True) 55 | assert paginator._count == 6 56 | 57 | 58 | class TestImagePaginator: 59 | """Test the `ImagePaginator` class.""" 60 | 61 | def test_add_line_create_new_page(self): 62 | """`add_line` should add each line to a page.""" 63 | paginator = ImagePaginator(prefix="", suffix="") 64 | 65 | paginator.add_line('x') 66 | assert len(paginator._pages) == 1 67 | 68 | paginator.add_line() 69 | assert len(paginator._pages) == 2 70 | 71 | def test_add_image(self): 72 | """Test the `add_image` function.""" 73 | paginator = ImagePaginator(prefix="", suffix="") 74 | 75 | paginator.add_image("url") 76 | assert len(paginator.images) == 1 77 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | application_import_names = bot 4 | docstring-convention = all 5 | ignore = 6 | P102,B311,W503,E226,S311, 7 | # FastAPI Depends and Header 8 | B008, 9 | # Missing Docstrings 10 | D100,D104,D105,D107, 11 | # Docstring Whitespace 12 | D203,D212,D214,D215, 13 | # Docstring Quotes 14 | D301,D302, 15 | # Docstring Content 16 | D400,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D416,D417 17 | # Type Annotations 18 | ANN002,ANN003,ANN101,ANN102,ANN204,ANN206,F722 19 | import-order-style = appnexus 20 | exclude = __init__.py, config.py, tests/*, alembic/* 21 | 22 | [pytest] 23 | filterwarnings = 24 | ignore::DeprecationWarning 25 | ignore::ResourceWarning 26 | addopts = -p tests.plugins.env_vars 27 | --------------------------------------------------------------------------------