1
Fork 0
mirror of https://github.com/allthingslinux/tux.git synced 2024-10-02 16:43:12 +00:00

Merge branch 'main' of github.com:allthingslinux/tux into setup

This commit is contained in:
kzndotsh 2024-08-24 17:57:53 -04:00
commit 2327ef69f5
51 changed files with 1875 additions and 552 deletions

View file

@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
allthingslinux@proton.me.
<mailto:allthingslinux@proton.me>.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
@ -116,7 +116,7 @@ the community.
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
@ -124,5 +124,5 @@ enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
<https://www.contributor-covenant.org/faq>. Translations are available at
<https://www.contributor-covenant.org/translations>.

View file

@ -1,38 +1,43 @@
# Contributing to Tux 🐧
## Topics
- [Contributing Flow](#contributing-flow)
- [Issues](#issues)
- [Branch Naming Conventions](#branch-naming-conventions)
- [Contributing to Tux 🐧](#contributing-to-tux-)
- [Topics](#topics)
- [Contributing Flow](#contributing-flow)
- [Issues](#issues)
- [Branch Naming Conventions](#branch-naming-conventions)
## Contributing Flow
1. See [Issues](#issues) topic.
2. Fork the project.
3. Create a new branch (please, see [Branch Naming Conventions](#branch-naming-conventions) topic if you don't know our conventions).
4. After done with modifications, time to commit and push. Example:
```
git add tux/help.py
git commit -m "feat(tux): add help command" -m "Help command description"
git push origin feat/add-help-command
```
```bash
git add tux/help.py
git commit -m "feat(tux): add help command" -m "Help command description"
git push origin feat/add-help-command
```
5. Send a Pull Request (PR) with the modifications, referencing the `main` branch.
6. Your contribution will be reviewed by the maintainers.
After merge:
- Delete the branch used to commit:
```
```bash
git checkout main
git push origin --delete feat/add-help-command
git branch -D feat/add-help-command
```
- Update your fork:
```
```bash
git remote add upstream https://github.com/allthingslinux/tux.git
git fetch upstream
git rebase upstream/main
@ -40,10 +45,12 @@ git push -f origin main
```
## Issues
Before submitting a large PR, please open an [issue](https://github.com/allthingslinux/tux/issues/new) so we can discuss the idea.
## Branch Naming Conventions
Before submitting a large PR, please open an [issue](https://github.com/allthingslinux/tux/issues/new) so we can discuss the idea.
## Branch Naming Conventions
- Documentation: `git checkout -b docs/contributing`
- Modifications: `git checkout -b chore/update-dependencies`
- Features: `git checkout -b feat/add-help-command`
- Fixing: `git checkout -b fix/help-command`
- Fixing: `git checkout -b fix/help-command`

42
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,42 @@
---
name: Bug Report
about: Create a report to help us improve
title: "[BUG] - "
labels: "type: bug"
assignees: ''
---
## Describe the Bug
A clear and concise description of what the bug is.
## To Reproduce
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
Please include code snippets or screenshots where applicable.
## Expected Behavior
A clear and concise description of what you expected to happen.
## Screenshots
If applicable, add screenshots to help explain your problem.
## Environment (please complete the following information)
- OS: [e.g. Ubuntu 20.04]
- Python version: [e.g. Python 3.12]
- Discord.py version: [e.g. 2.0.0]
- Tux version: [e.g. v1.0.0]
## Additional Context
Add any other context about the problem here, such as logs, configuration files, etc.

View file

@ -0,0 +1,24 @@
---
name: Feature Request
about: Suggest an idea for this project
title: "[FEATURE] - "
labels: "type: feature-request"
assignees: ''
---
## Is your feature request related to a problem? Please describe
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
## Describe the solution you'd like
A clear and concise description of what you want to happen.
## Describe alternatives you've considered
A clear and concise description of any alternative solutions or features you've considered.
## Additional Context
Add any other context or screenshots about the feature request here.

40
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,40 @@
# Pull Request Template
## Description
Please include a summary of the changes and the related issue. Please also include relevant motivation and context. List any dependencies that are required for this change.
Fixes # (issue)
## Type of Change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
## Checklist
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream modules
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration.
- [ ] Test A
- [ ] Test B
## Screenshots (if applicable)
Please add screenshots to help explain your changes.
## Additional Information
Please add any other information that is important to this PR.

View file

3
.gitignore vendored
View file

@ -164,6 +164,7 @@ github-private-key.pem
# Miscellaneous
/debug.csv
config/settings.json
config/settings.yml
# MacOS
.DS_Store
.DS_Store

266
.markdownlint.yaml Normal file
View file

@ -0,0 +1,266 @@
# Example markdownlint configuration with all properties set to their default value
# Default state for all rules
default: true
# Path to configuration file to extend
extends: null
# MD001/heading-increment : Heading levels should only increment by one level at a time : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md001.md
MD001: true
# MD003/heading-style : Heading style : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md003.md
MD003:
# Heading style
style: "consistent"
# MD004/ul-style : Unordered list style : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md004.md
MD004:
# List style
style: "consistent"
# MD005/list-indent : Inconsistent indentation for list items at the same level : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md005.md
MD005: true
# MD007/ul-indent : Unordered list indentation : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md007.md
MD007:
# Spaces for indent
indent: 2
# Whether to indent the first level of the list
start_indented: false
# Spaces for first level indent (when start_indented is set)
start_indent: 2
# MD009/no-trailing-spaces : Trailing spaces : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md009.md
MD009:
# Spaces for line break
br_spaces: 2
# Allow spaces for empty lines in list items
list_item_empty_lines: false
# Include unnecessary breaks
strict: false
# MD010/no-hard-tabs : Hard tabs : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md010.md
MD010:
# Include code blocks
code_blocks: true
# Fenced code languages to ignore
ignore_code_languages: []
# Number of spaces for each hard tab
spaces_per_tab: 1
# MD011/no-reversed-links : Reversed link syntax : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md011.md
MD011: true
# MD012/no-multiple-blanks : Multiple consecutive blank lines : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md012.md
MD012:
# Consecutive blank lines
maximum: 1
# MD013/line-length : Line length : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md013.md
MD013:
# Number of characters
line_length: 200
# Number of characters for headings
heading_line_length: 80
# Number of characters for code blocks
code_block_line_length: 80
# Include code blocks
code_blocks: true
# Include tables
tables: true
# Include headings
headings: true
# Strict length checking
strict: false
# Stern length checking
stern: false
# MD014/commands-show-output : Dollar signs used before commands without showing output : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md014.md
MD014: true
# MD018/no-missing-space-atx : No space after hash on atx style heading : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md018.md
MD018: true
# MD019/no-multiple-space-atx : Multiple spaces after hash on atx style heading : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md019.md
MD019: true
# MD020/no-missing-space-closed-atx : No space inside hashes on closed atx style heading : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md020.md
MD020: true
# MD021/no-multiple-space-closed-atx : Multiple spaces inside hashes on closed atx style heading : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md021.md
MD021: true
# MD022/blanks-around-headings : Headings should be surrounded by blank lines : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md022.md
MD022:
# Blank lines above heading
lines_above: 1
# Blank lines below heading
lines_below: 1
# MD023/heading-start-left : Headings must start at the beginning of the line : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md023.md
MD023: true
# MD024/no-duplicate-heading : Multiple headings with the same content : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md024.md
MD024:
# Only check sibling headings
siblings_only: false
# MD025/single-title/single-h1 : Multiple top-level headings in the same document : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md025.md
MD025:
# Heading level
level: 1
# RegExp for matching title in front matter
front_matter_title: "^\\s*title\\s*[:=]"
# MD026/no-trailing-punctuation : Trailing punctuation in heading : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md026.md
MD026:
# Punctuation characters
punctuation: ".,;:!。,;:!"
# MD027/no-multiple-space-blockquote : Multiple spaces after blockquote symbol : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md027.md
MD027: true
# MD028/no-blanks-blockquote : Blank line inside blockquote : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md028.md
MD028: false
# MD029/ol-prefix : Ordered list item prefix : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md029.md
MD029:
# List style
style: "one_or_ordered"
# MD030/list-marker-space : Spaces after list markers : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md030.md
MD030:
# Spaces for single-line unordered list items
ul_single: 1
# Spaces for single-line ordered list items
ol_single: 1
# Spaces for multi-line unordered list items
ul_multi: 1
# Spaces for multi-line ordered list items
ol_multi: 1
# MD031/blanks-around-fences : Fenced code blocks should be surrounded by blank lines : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md031.md
MD031:
# Include list items
list_items: true
# MD032/blanks-around-lists : Lists should be surrounded by blank lines : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md032.md
MD032: true
# MD033/no-inline-html : Inline HTML : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md033.md
MD033: false
# MD034/no-bare-urls : Bare URL used : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md034.md
MD034: true
# MD035/hr-style : Horizontal rule style : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md035.md
MD035:
# Horizontal rule style
style: "consistent"
# MD036/no-emphasis-as-heading : Emphasis used instead of a heading : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md036.md
MD036:
# Punctuation characters
punctuation: ".,;:!?。,;:!?"
# MD037/no-space-in-emphasis : Spaces inside emphasis markers : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md037.md
MD037: true
# MD038/no-space-in-code : Spaces inside code span elements : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md038.md
MD038: true
# MD039/no-space-in-links : Spaces inside link text : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md039.md
MD039: true
# MD040/fenced-code-language : Fenced code blocks should have a language specified : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md040.md
MD040:
# List of languages
allowed_languages: []
# Require language only
language_only: false
# MD041/first-line-heading/first-line-h1 : First line in a file should be a top-level heading : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md041.md
MD041:
# Heading level
level: 1
# RegExp for matching title in front matter
front_matter_title: "^\\s*title\\s*[:=]"
# MD042/no-empty-links : No empty links : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md042.md
MD042: true
# MD043/required-headings : Required heading structure : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md043.md
MD043: false
# MD044/proper-names : Proper names should have the correct capitalization : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md044.md
MD044:
# List of proper names
names: []
# Include code blocks
code_blocks: true
# Include HTML elements
html_elements: true
# MD045/no-alt-text : Images should have alternate text (alt text) : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md045.md
MD045: true
# MD046/code-block-style : Code block style : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md046.md
MD046:
# Block style
style: "consistent"
# MD047/single-trailing-newline : Files should end with a single newline character : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md047.md
MD047: true
# MD048/code-fence-style : Code fence style : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md048.md
MD048:
# Code fence style
style: "consistent"
# MD049/emphasis-style : Emphasis style : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md049.md
MD049:
# Emphasis style
style: "consistent"
# MD050/strong-style : Strong style : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md050.md
MD050:
# Strong style
style: "consistent"
# MD051/link-fragments : Link fragments should be valid : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md051.md
MD051: true
# MD052/reference-links-images : Reference links and images should use a label that is defined : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md052.md
MD052:
# Include shortcut syntax
shortcut_syntax: false
# MD053/link-image-reference-definitions : Link and image reference definitions should be needed : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md053.md
MD053:
# Ignored definitions
ignored_definitions:
- "//"
# MD054/link-image-style : Link and image style : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md054.md
MD054:
# Allow autolinks
autolink: true
# Allow inline links and images
inline: true
# Allow full reference links and images
full: true
# Allow collapsed reference links and images
collapsed: true
# Allow shortcut reference links and images
shortcut: true
# Allow URLs as inline links
url_inline: true
# MD055/table-pipe-style : Table pipe style : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md055.md
MD055:
# Table pipe style
style: "consistent"
# MD056/table-column-count : Table column count : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md056.md
MD056: true

View file

@ -12,7 +12,7 @@ repos:
- id: add-trailing-comma
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.1
rev: v0.6.2
hooks:
# Run the linter.
- id: ruff

View file

@ -1,8 +1,6 @@
<div align="center">
<img src="docs/resources/tux.gif" width=128 height=128></img>
<h1>Tux</h1>
<h3><b>A Discord bot for the All Things Linux Discord server</b></h3>
</div>
# Tux
## A Discord bot for the All Things Linux Discord server
<div align="center">
<p align="center">
@ -12,21 +10,23 @@
<img alt="Repo size" src="https://img.shields.io/github/repo-size/allthingslinux/tux?style=for-the-badge&logo=github&color=FAB387&logoColor=FAB387&labelColor=302D41"/></a>
<a href="https://github.com/allthingslinux/tux/issues">
<img alt="Issues" src="https://img.shields.io/github/issues/allthingslinux/tux?style=for-the-badge&logo=githubactions&color=F9E2AF&logoColor=F9E2AF&labelColor=302D41"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.html">
<img alt="License" src="https://img.shields.io/github/license/allthingslinux/tux?style=for-the-badge&logo=gitbook&color=A6E3A1&logoColor=A6E3A1&labelColor=302D41"></a>
<a href="https://discord.gg/linux">
<img alt="Discord" src="https://img.shields.io/discord/1172245377395728464?style=for-the-badge&logo=discord&color=B4BEFE&logoColor=B4BEFE&labelColor=302D41"></a>
</p>
</div>
# NOTE: This bot (without plenty of tweaking) is not ready for multi-server use, we recommend against using it until it is more complete
> [!WARNING]
**This bot (without plenty of tweaking) is not ready for production use, we suggest against using it until announced. Join our support server: [atl.dev](https://discord.gg/gpmSjcjQxg) for more info!**
## About
Tux is a Discord bot for the All Things Linux Discord server. It is designed to provide a variety of features to the server, including moderation, support, utility, and various fun commands. The bot is written in Python using the discord.py library.
Tux is an all in one Discord bot for the All Things Linux Discord server.
It is designed to provide a variety of features to the server, including moderation, support, utility, and various fun commands.
## Tech Stack
- Python 3.12 alongside the Discord.py library
- Poetry for dependency management
- Docker and Docker Compose for development and deployment
- Strict typing with Pyright and type hints
@ -36,8 +36,10 @@ Tux is a Discord bot for the All Things Linux Discord server. It is designed to
- Justfile for easy CLI commands
- Beautiful logging with Loguru
- Exception handling with Sentry
- Request handling with HTTPX
## Bot Features
- Asynchronous codebase
- Hybrid command system with both slash commands and traditional commands
- Cog loading system with hot reloading
@ -51,71 +53,100 @@ Tux is a Discord bot for the All Things Linux Discord server. It is designed to
## Installation
### Prerequisites
- Python 3.12
- [Poetry](https://python-poetry.org/docs/)
- Optional: [Docker](https://docs.docker.com/get-docker/) if you want to run the bot in a container.
- Optional: [Docker Compose](https://docs.docker.com/compose/install/) if you want to define the container environment in a `docker-compose.yml` file.
- Optional: [Just](https://github.com/casey/just/) if you want to use the Justfile for easy CLI commands.
- [Supabase](https://supabase.io/)
- Optional: [Docker](https://docs.docker.com/get-docker/)
- Optional: [Docker Compose](https://docs.docker.com/compose/install/)
- Optional: [Just](https://github.com/casey/just/)
### Steps to Install
Assuming you have the prerequisites installed, follow these steps to get started with the development of the project:
Further detailed instructions can be found in the [development guide](docs/development.md).
### Steps
1. Clone the repository
```bash
git clone https://github.com/allthingslinux/tux && cd tux
```
2. Install the dependencies
2. Install the project's dependencies
```bash
poetry install
```
3. Activate the virtual environment
```bash
poetry shell
```
4. Install the pre-commit hooks
```bash
pre-commit install
```
5. Generate the prisma client
```bash
prisma generate
```
6. Copy the `.env.example` file to `.env` and fill in the required values
Currently, you will need to have a Supabase database set up and the URL set in the `DATABASE_URL` environment variable.
In the future, we will provide a way to use a local database. We can provide a dev database on request.
6. Copy the `.env.example` file to `.env` and fill in the required values.
```bash
cp .env.example .env
```
7. Copy the `config/settings.json.example` file to `config/settings.json` and fill in the required values
You'll need to fill in your Discord bot token here, as well as the Sentry DSN if you want to use Sentry for error tracking.
We offer dev tokens on request in our Discord server.
7. Copy the `config/settings.yml.example` file to `config/settings.yml` and fill in the required values.
```bash
cp config/settings.json.example config/settings.json
cp config/settings.yml.example config/settings.yml
```
8. Run the bot
Be sure to add your Discord user ID to the `BOT_OWNER` key in the settings file.
You can also add your custom prefix here.
8. Start the bot!
```bash
poetry run python tux/main.py
```
9. Run the sync command in the server to sync the slash command tree.
```
```bash
{prefix}dev sync <server id>
```
## Development Notes
> [!NOTE]
> Make sure to add your discord ID to the sys admin list if you are testing locally.
> [!NOTE]
Make sure to add your discord ID to the sys admin list if you are testing locally.
> [!NOTE]
> Make sure to set the prisma schema database ENV variable to the DEV database URL.
Make sure to set the prisma schema database ENV variable to the DEV database URL.
## License
This project is licensed under the terms of the The GNU General Public License v3.0. See the [LICENSE](LICENSE.md) file for details.
This project is licensed under the terms of the The GNU General Public License v3.0.
See [LICENSE](LICENSE.md) for details.
## Metrics
![Alt](https://repobeats.axiom.co/api/embed/cd24c48127e0b6fbc9467711d6d4bd74b30ff8d2.svg "Repobeats analytics image")
![Alt](https://repobeats.axiom.co/api/embed/b988ba04401b7c68edf9def00f5132cd2a7f3735.svg "Repobeats analytics image")

View file

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

BIN
assets/emojis/tux_tag.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -1,58 +0,0 @@
{
"PREFIX": {
"PROD": "$",
"DEV": "$"
},
"ROLES": {
"ADMIN": 123456789012345679,
"MOD": 123456789012345679,
"JR_MOD": 123456789012345679,
"OWNER": 123456789012345679,
"TESTING": 123456789012345679
},
"USER_IDS": {
"SYSADMINS": [
123456789012345679,
123456789012345679
],
"BOT_OWNER": 123456789012345679
},
"TEMPVC_CATEGORY_ID": 1235096247442870292,
"TEMPVC_CHANNEL_ID": 1235096247442870292,
"LOG_CHANNELS": {
"AUDIT": 1235096271350399076,
"MOD": 1235096291672068106,
"REPORTS": 1235096305160814652,
"GATE": 1235096247442870292,
"DEV": 1235095919788167269,
"PRIVATE": 1235108340791513129
},
"EMBED_COLORS": {
"DEFAULT": 16044058,
"INFO": 12634869,
"WARNING": 16634507,
"ERROR": 16067173,
"SUCCESS": 10407530,
"POLL": 14724968,
"CASE": 16217742,
"NOTE": 16752228
},
"EMBED_ICONS": {
"DEFAULT": "https://i.imgur.com/owW4EZk.png",
"INFO": "https://i.imgur.com/8GRtR2G.png",
"SUCCESS": "https://i.imgur.com/JsNbN7D.png",
"ERROR": "https://i.imgur.com/zZjuWaU.png",
"CASE": "https://i.imgur.com/c43cwnV.png",
"NOTE": "https://i.imgur.com/VqPFbil.png",
"POLL": "https://i.imgur.com/pkPeG5q.png",
"ACTIVE_CASE": "https://github.com/allthingslinux/tux/blob/main/assets/embeds/active_case.png?raw=true",
"INACTIVE_CASE": "https://github.com/allthingslinux/tux/blob/main/assets/embeds/inactive_case.png?raw=true",
"ADD": "https://github.com/allthingslinux/tux/blob/main/assets/emojis/added.png?raw=true",
"REMOVE": "https://github.com/allthingslinux/tux/blob/main/assets/emojis/removed.png?raw=true",
"BAN": "https://github.com/allthingslinux/tux/blob/main/assets/emojis/ban.png?raw=true",
"JAIL": "https://github.com/allthingslinux/tux/blob/main/assets/emojis/jail.png?raw=true",
"KICK": "https://github.com/allthingslinux/tux/blob/main/assets/emojis/kick.png?raw=true",
"TIMEOUT": "https://github.com/allthingslinux/tux/blob/main/assets/emojis/timeout.png?raw=true",
"WARN": "https://github.com/allthingslinux/tux/blob/main/assets/emojis/warn.png?raw=true"
}
}

View file

@ -0,0 +1,42 @@
# config/settings.yml
PREFIX:
PROD: "$"
DEV: "~"
USER_IDS:
SYSADMINS:
- 123456789012345679
- 123456789012345679
BOT_OWNER: 123456789012345679
TEMPVC_CATEGORY_ID: 123456789012345679
TEMPVC_CHANNEL_ID: 123456789012345679
EMBED_COLORS:
DEFAULT: 16044058
INFO: 12634869
WARNING: 16634507
ERROR: 16067173
SUCCESS: 10407530
POLL: 14724968
CASE: 16217742
NOTE: 16752228
EMBED_ICONS:
DEFAULT: "https://i.imgur.com/owW4EZk.png"
INFO: "https://i.imgur.com/8GRtR2G.png"
SUCCESS: "https://i.imgur.com/JsNbN7D.png"
ERROR: "https://i.imgur.com/zZjuWaU.png"
CASE: "https://i.imgur.com/c43cwnV.png"
NOTE: "https://i.imgur.com/VqPFbil.png"
POLL: "https://i.imgur.com/pkPeG5q.png"
ACTIVE_CASE: "https://github.com/allthingslinux/tux/blob/main/assets/embeds/active_case.png?raw=true"
INACTIVE_CASE: "https://github.com/allthingslinux/tux/blob/main/assets/embeds/inactive_case.png?raw=true"
ADD: "https://github.com/allthingslinux/tux/blob/main/assets/emojis/added.png?raw=true"
REMOVE: "https://github.com/allthingslinux/tux/blob/main/assets/emojis/removed.png?raw=true"
BAN: "https://github.com/allthingslinux/tux/blob/main/assets/emojis/ban.png?raw=true"
JAIL: "https://github.com/allthingslinux/tux/blob/main/assets/emojis/jail.png?raw=true"
KICK: "https://github.com/allthingslinux/tux/blob/main/assets/emojis/kick.png?raw=true"
TIMEOUT: "https://github.com/allthingslinux/tux/blob/main/assets/emojis/timeout.png?raw=true"
WARN: "https://github.com/allthingslinux/tux/blob/main/assets/emojis/warn.png?raw=true"

View file

@ -1,6 +1,8 @@
# Project Documentation
# Project Documentation
This document outlines the essential commands and workflows needed for the installation, development, and management of this project. Each section provides relevant commands and instructions for specific tasks.
This document outlines the essential commands and workflows needed for the installation, development, and management of this project.
Each section provides relevant commands and instructions for specific tasks.
## Table of Contents
@ -116,4 +118,4 @@ git commit -m "Your commit message"
# Push changes to the remote repository.
git push
```
```

View file

@ -9,7 +9,6 @@
- Moderation
- Utility
## Commands
### Admin
@ -94,4 +93,4 @@
- `remindme`
- `snippets`
- `tldr`
- `wiki`
- `wiki`

View file

@ -2,17 +2,13 @@
## Introduction
This document is intended to provide a guide for developers who want to contribute to the development of the project. It is assumed that the reader has a basic understanding of the project and its goals and has a working knowledge of the tools and technologies used in the project.
This document is intended to provide a guide for developers who want to contribute to the development of the project.
It is assumed that the reader has a basic understanding of the project and its goals as well as the tools and technologies used in the project.
## Getting Started
To get started with the development of the project, you will need to have the following tools and technologies installed on your system:
- Python 3.12
- [Poetry](https://python-poetry.org/docs/)
- Optional: [Docker](https://docs.docker.com/get-docker/) if you want to run the bot in a container.
- Optional: [Docker Compose](https://docs.docker.com/compose/install/) if you want to define the container environment in a `docker-compose.yml` file.
- Optional: [Just](https://github.com/casey/just/) if you want to use the Justfile for easy CLI commands.
To get started with the development of the project, refer to the installation instructions in the project [README](../README.md).
## Installation
@ -53,11 +49,13 @@ The project is structured as follows:
- `help.py`: The help command class definition.
### Configuration
- `.env.example`: The example environment file containing the environment variables required for the bot.
- `.env`: The environment file containing the secret environment variables for the bot.
- `config/`: The config directory containing the configuration files for the bot.
- `settings.json`: The settings file containing the bot settings and configuration.
- `settings.yml`: The settings file containing the bot settings and configuration.
### Documentation
- `docs/`: The documentation directory containing the project documentation.
- `CONTRIBUTING.md`: The contributing guidelines for the project.
- `README.md`: The project README file containing the project overview and installation instructions.
@ -66,6 +64,7 @@ The project is structured as follows:
- `LICENSE.md`: The license file containing the project license information.
### Development
- `pyproject.toml`: The Poetry configuration file containing the project metadata and dependencies for the bot.
- `Dockerfile`: The Dockerfile containing the container configuration for the bot.
- `docker-compose.yml`: The Docker Compose file containing the container environment configuration for the bot.
@ -73,13 +72,37 @@ The project is structured as follows:
- `.gitignore`: The Git ignore file containing the files and directories to be ignored by Git.
### CI/CD
- `.pre-commit-config.yaml`: The pre-commit configuration file containing the pre-commit hooks for the bot.
- `.github/workflows/`: The GitHub Actions directory containing the CI/CD workflows for the bot.
- `renovate.json`: The Renovate configuration file containing the dependency update settings for the bot.
## Cogs Primer
TODO: Add cogs primer
There comes a point in your bots development when you want to organize a collection of commands, listeners, and some state into one class. Cogs allow you to do just that.
It should be noted that cogs are typically used alongside with Extensions.
An extension at its core is a python file with an entry point called setup. This setup function must be a Python coroutine. It takes a single parameter of the Bot that loads the extension.
With regards to Tux, we typically define one cog per extension. This allows us to keep our code organized and modular.
Furthermore, we have a `CogLoader` class that loads our cogs (technically, extensions) from the `cogs` directory and registers them with the bot at startup.
### Cog Essentials
- Each cog is a Python class that subclasses commands.Cog.
- Every regular command or "prefix" is marked with the `@commands.command()` decorator.
- Every app or "slash" command is marked with the `@app_commands.command()` decorator.
- Every hybrid command is marked with the `@commands.hybrid_command()` decorator.
- Every listener is marked with the `@commands.Cog.listener()` decorator.
tl;dr - Extensions are imported "modules", cogs are classes that are subclasses of `commands.Cog`.
Referance:
- [discord.py - Cogs](https://discordpy.readthedocs.io/en/stable/ext/commands/cogs.html)
- [discord.py - Extensions](https://discordpy.readthedocs.io/en/stable/ext/commands/extensions.html)
## Database Primer

View file

@ -1,14 +1,22 @@
# Permissions Management
Tux employs a level-based permissions system to control command execution. Each command is associated with a specific permission level, ensuring that only users with the necessary clearance can execute it.
Tux employs a level-based permissions system to control command execution.
Each command is associated with a specific permission level, ensuring that only users with the necessary clearance can execute it.
## Initial Setup
When setting up Tux for a new server, the server owner can assign one or multiple roles to each permission level. Users then inherit the highest permission level from their assigned roles. For instance, if a user has one role with a permission level of 2 and another with a level of 3, their effective permission level will be 3.
When setting up Tux for a new server, the server owner can assign one or multiple roles to each permission level. Users then inherit the highest permission level from their assigned roles.
For instance, if a user has one role with a permission level of 2 and another with a level of 3, their effective permission level will be 3.
## Advantages
The level-based system allows Tux to manage command execution efficiently across different servers. It offers a more flexible solution than just relying on Discord's built-in permissions, avoiding the need to hardcode permissions into the bot. This flexibility makes it easier to modify permissions without changing the bots underlying code, accommodating servers with custom role names seamlessly.
The level-based system allows Tux to manage command execution efficiently across different servers.
It offers a more flexible solution than just relying on Discord's built-in permissions, avoiding the need to hardcode permissions into the bot.
This flexibility makes it easier to modify permissions without changing the bots underlying code, accommodating servers with custom role names seamlessly.
## Available Permission Levels
@ -25,4 +33,5 @@ Below is the hierarchy of permission levels available in Tux:
- **8: Sys Admin** (User ID list in `config.json`)
- **9: Bot Owner** (User ID in `config.json`)
By leveraging these permission levels, Tux provides a robust and adaptable way to manage who can execute specific commands, making it suitable for various server environments.
By leveraging these permission levels, Tux provides a robust and adaptable way to manage who can execute specific commands,
making it suitable for various server environments.

View file

@ -2,7 +2,9 @@
## Overview
Services within the context of this bot are background tasks that run continuously and provide various functionalities to the server. They are typically not directly interacted with by users, but rather provide additional features to the server that are not covered by commands.
Services within the context of this bot are background tasks that run continuously and provide various functionalities to the server.
They are typically not directly interacted with by users, but rather provide additional features to the server that are not covered by commands.
## Available Services
@ -15,4 +17,4 @@ Services within the context of this bot are background tasks that run continuous
- **Auto Welcome**: Sends a welcome message to new members when they join the server.
- **Auto Role**: Assigns a role to new members when they join the server.
- **Auto Mod**: Automatically moderates the server by enforcing rules and restrictions
- **Auto Unban**: Automatically unbans users after a specified duration
- **Auto Unban**: Automatically unbans users after a specified duration

134
poetry.lock generated
View file

@ -190,6 +190,21 @@ doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphin
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
trio = ["trio (>=0.23)"]
[[package]]
name = "astunparse"
version = "1.6.3"
description = "An AST unparser for Python"
optional = false
python-versions = "*"
files = [
{file = "astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8"},
{file = "astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872"},
]
[package.dependencies]
six = ">=1.6.1,<2.0"
wheel = ">=0.23.0,<1.0"
[[package]]
name = "asynctempfile"
version = "0.5.0"
@ -237,6 +252,17 @@ files = [
[package.extras]
dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"]
[[package]]
name = "braceexpand"
version = "0.1.7"
description = "Bash-style brace expansion for Python"
optional = false
python-versions = "*"
files = [
{file = "braceexpand-0.1.7-py2.py3-none-any.whl", hash = "sha256:91332d53de7828103dcae5773fb43bc34950b0c8160e35e0f44c4427a3b85014"},
{file = "braceexpand-0.1.7.tar.gz", hash = "sha256:e6e539bd20eaea53547472ff94f4fb5c3d3bf9d0a89388c4b56663aba765f705"},
]
[[package]]
name = "cairocffi"
version = "1.7.1"
@ -881,6 +907,23 @@ files = [
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
]
[[package]]
name = "import-expression"
version = "1.1.5"
description = "Parses a superset of Python allowing for inline module import expressions"
optional = false
python-versions = "*"
files = [
{file = "import_expression-1.1.5-py3-none-any.whl", hash = "sha256:f60c3765dbf2f41928b9c6ef79d632209b6705fc8f30e281ed1a492ed026b10f"},
{file = "import_expression-1.1.5.tar.gz", hash = "sha256:9959588fcfc8dcb144a0725176cfef6c28c7db1fc2d683625025e687516d40c1"},
]
[package.dependencies]
astunparse = ">=1.6.3,<2.0.0"
[package.extras]
test = ["pytest", "pytest-cov"]
[[package]]
name = "jinja2"
version = "3.1.4"
@ -898,6 +941,31 @@ MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "jishaku"
version = "2.5.2"
description = "A discord.py extension including useful tools for bot development and debugging."
optional = false
python-versions = ">=3.8.0"
files = [
{file = "jishaku-2.5.2-py3-none-any.whl", hash = "sha256:87f34942ee44865f5ce08e36723b7c74a313d8a13a4db8a6b7cc12618cc3496c"},
{file = "jishaku-2.5.2.tar.gz", hash = "sha256:56d38c333036e37481df5e3c9e81d6033b5097738f0d171a81e2752124f0df5c"},
]
[package.dependencies]
braceexpand = ">=0.1.7"
click = ">=8.0.1"
import-expression = ">=1.0.0,<2.0.0"
[package.extras]
discordpy = ["discord.py (>=1.7.3)"]
docs = ["Sphinx (>=4.4.0)", "sphinxcontrib-trio (>=1.1.2)"]
procinfo = ["psutil (>=5.8.0)"]
profiling = ["line-profiler (>=3.5.1)"]
publish = ["Jinja2 (>=3.0.3)"]
test = ["coverage (>=6.3.2)", "flake8 (>=4.0.1)", "isort (>=5.10.1)", "pylint (>=2.11.1)", "pytest (>=7.0.1)", "pytest-asyncio (>=0.18.1)", "pytest-cov (>=3.0.0)", "pytest-mock (>=3.7.0)"]
voice = ["yt-dlp (>=2022.3.8)"]
[[package]]
name = "loguru"
version = "0.7.2"
@ -1059,13 +1127,13 @@ pyyaml = ">=5.1"
[[package]]
name = "mkdocs-material"
version = "9.5.31"
version = "9.5.32"
description = "Documentation that simply works"
optional = false
python-versions = ">=3.8"
files = [
{file = "mkdocs_material-9.5.31-py3-none-any.whl", hash = "sha256:1b1f49066fdb3824c1e96d6bacd2d4375de4ac74580b47e79ff44c4d835c5fcb"},
{file = "mkdocs_material-9.5.31.tar.gz", hash = "sha256:31833ec664772669f5856f4f276bf3fdf0e642a445e64491eda459249c3a1ca8"},
{file = "mkdocs_material-9.5.32-py3-none-any.whl", hash = "sha256:f3704f46b63d31b3cd35c0055a72280bed825786eccaf19c655b44e0cd2c6b3f"},
{file = "mkdocs_material-9.5.32.tar.gz", hash = "sha256:38ed66e6d6768dde4edde022554553e48b2db0d26d1320b19e2e2b9da0be1120"},
]
[package.dependencies]
@ -1649,13 +1717,13 @@ tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"]
[[package]]
name = "pyright"
version = "1.1.376"
version = "1.1.377"
description = "Command line wrapper for pyright"
optional = false
python-versions = ">=3.7"
files = [
{file = "pyright-1.1.376-py3-none-any.whl", hash = "sha256:0f2473b12c15c46b3207f0eec224c3cea2bdc07cd45dd4a037687cbbca0fbeff"},
{file = "pyright-1.1.376.tar.gz", hash = "sha256:bffd63b197cd0810395bb3245c06b01f95a85ddf6bfa0e5644ed69c841e954dd"},
{file = "pyright-1.1.377-py3-none-any.whl", hash = "sha256:af0dd2b6b636c383a6569a083f8c5a8748ae4dcde5df7914b3f3f267e14dd162"},
{file = "pyright-1.1.377.tar.gz", hash = "sha256:aabc30fedce0ded34baa0c49b24f10e68f4bfc8f68ae7f3d175c4b0f256b4fcf"},
]
[package.dependencies]
@ -1919,29 +1987,29 @@ pyasn1 = ">=0.1.3"
[[package]]
name = "ruff"
version = "0.6.1"
version = "0.6.2"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.6.1-py3-none-linux_armv6l.whl", hash = "sha256:b4bb7de6a24169dc023f992718a9417380301b0c2da0fe85919f47264fb8add9"},
{file = "ruff-0.6.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:45efaae53b360c81043e311cdec8a7696420b3d3e8935202c2846e7a97d4edae"},
{file = "ruff-0.6.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bc60c7d71b732c8fa73cf995efc0c836a2fd8b9810e115be8babb24ae87e0850"},
{file = "ruff-0.6.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c7477c3b9da822e2db0b4e0b59e61b8a23e87886e727b327e7dcaf06213c5cf"},
{file = "ruff-0.6.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a0af7ab3f86e3dc9f157a928e08e26c4b40707d0612b01cd577cc84b8905cc9"},
{file = "ruff-0.6.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:392688dbb50fecf1bf7126731c90c11a9df1c3a4cdc3f481b53e851da5634fa5"},
{file = "ruff-0.6.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5278d3e095ccc8c30430bcc9bc550f778790acc211865520f3041910a28d0024"},
{file = "ruff-0.6.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fe6d5f65d6f276ee7a0fc50a0cecaccb362d30ef98a110f99cac1c7872df2f18"},
{file = "ruff-0.6.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2e0dd11e2ae553ee5c92a81731d88a9883af8db7408db47fc81887c1f8b672e"},
{file = "ruff-0.6.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d812615525a34ecfc07fd93f906ef5b93656be01dfae9a819e31caa6cfe758a1"},
{file = "ruff-0.6.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:faaa4060f4064c3b7aaaa27328080c932fa142786f8142aff095b42b6a2eb631"},
{file = "ruff-0.6.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:99d7ae0df47c62729d58765c593ea54c2546d5de213f2af2a19442d50a10cec9"},
{file = "ruff-0.6.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9eb18dfd7b613eec000e3738b3f0e4398bf0153cb80bfa3e351b3c1c2f6d7b15"},
{file = "ruff-0.6.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c62bc04c6723a81e25e71715aa59489f15034d69bf641df88cb38bdc32fd1dbb"},
{file = "ruff-0.6.1-py3-none-win32.whl", hash = "sha256:9fb4c4e8b83f19c9477a8745e56d2eeef07a7ff50b68a6998f7d9e2e3887bdc4"},
{file = "ruff-0.6.1-py3-none-win_amd64.whl", hash = "sha256:c2ebfc8f51ef4aca05dad4552bbcf6fe8d1f75b2f6af546cc47cc1c1ca916b5b"},
{file = "ruff-0.6.1-py3-none-win_arm64.whl", hash = "sha256:3bc81074971b0ffad1bd0c52284b22411f02a11a012082a76ac6da153536e014"},
{file = "ruff-0.6.1.tar.gz", hash = "sha256:af3ffd8c6563acb8848d33cd19a69b9bfe943667f0419ca083f8ebe4224a3436"},
{file = "ruff-0.6.2-py3-none-linux_armv6l.whl", hash = "sha256:5c8cbc6252deb3ea840ad6a20b0f8583caab0c5ef4f9cca21adc5a92b8f79f3c"},
{file = "ruff-0.6.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:17002fe241e76544448a8e1e6118abecbe8cd10cf68fde635dad480dba594570"},
{file = "ruff-0.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3dbeac76ed13456f8158b8f4fe087bf87882e645c8e8b606dd17b0b66c2c1158"},
{file = "ruff-0.6.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:094600ee88cda325988d3f54e3588c46de5c18dae09d683ace278b11f9d4d534"},
{file = "ruff-0.6.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:316d418fe258c036ba05fbf7dfc1f7d3d4096db63431546163b472285668132b"},
{file = "ruff-0.6.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d72b8b3abf8a2d51b7b9944a41307d2f442558ccb3859bbd87e6ae9be1694a5d"},
{file = "ruff-0.6.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2aed7e243be68487aa8982e91c6e260982d00da3f38955873aecd5a9204b1d66"},
{file = "ruff-0.6.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d371f7fc9cec83497fe7cf5eaf5b76e22a8efce463de5f775a1826197feb9df8"},
{file = "ruff-0.6.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8f310d63af08f583363dfb844ba8f9417b558199c58a5999215082036d795a1"},
{file = "ruff-0.6.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7db6880c53c56addb8638fe444818183385ec85eeada1d48fc5abe045301b2f1"},
{file = "ruff-0.6.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1175d39faadd9a50718f478d23bfc1d4da5743f1ab56af81a2b6caf0a2394f23"},
{file = "ruff-0.6.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939f9c86d51635fe486585389f54582f0d65b8238e08c327c1534844b3bb9a"},
{file = "ruff-0.6.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d0d62ca91219f906caf9b187dea50d17353f15ec9bb15aae4a606cd697b49b4c"},
{file = "ruff-0.6.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7438a7288f9d67ed3c8ce4d059e67f7ed65e9fe3aa2ab6f5b4b3610e57e3cb56"},
{file = "ruff-0.6.2-py3-none-win32.whl", hash = "sha256:279d5f7d86696df5f9549b56b9b6a7f6c72961b619022b5b7999b15db392a4da"},
{file = "ruff-0.6.2-py3-none-win_amd64.whl", hash = "sha256:d9f3469c7dd43cd22eb1c3fc16926fb8258d50cb1b216658a07be95dd117b0f2"},
{file = "ruff-0.6.2-py3-none-win_arm64.whl", hash = "sha256:f28fcd2cd0e02bdf739297516d5643a945cc7caf09bd9bcb4d932540a5ea4fa9"},
{file = "ruff-0.6.2.tar.gz", hash = "sha256:239ee6beb9e91feb8e0ec384204a763f36cb53fb895a1a364618c6abb076b3be"},
]
[[package]]
@ -2204,6 +2272,20 @@ files = [
{file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"},
]
[[package]]
name = "wheel"
version = "0.44.0"
description = "A built-package format for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "wheel-0.44.0-py3-none-any.whl", hash = "sha256:2376a90c98cc337d18623527a97c31797bd02bad0033d41547043a1cbfbe448f"},
{file = "wheel-0.44.0.tar.gz", hash = "sha256:a29c3f2817e95ab89aa4660681ad547c0e9547f20e75b0562fe7723c9a2a9d49"},
]
[package.extras]
test = ["pytest (>=6.0.0)", "setuptools (>=65)"]
[[package]]
name = "win32-setctime"
version = "1.1.0"
@ -2324,4 +2406,4 @@ multidict = ">=4.0"
[metadata]
lock-version = "2.0"
python-versions = ">=3.12,<4"
content-hash = "30272c710183da26e169ea88fd077632a69fac2a891ba3f9a862bc815f368141"
content-hash = "e773f58b7548a102f8e9ebc152ec2390d1149a549fe9f77e9c248b37e896f56a"

View file

@ -28,11 +28,13 @@ enum CaseType {
HACKBAN
TEMPBAN
KICK
SNIPPETBAN
TIMEOUT
UNTIMEOUT
WARN
JAIL
UNJAIL
SNIPPETUNBAN
}
// Docs: https://www.prisma.io/docs/orm/prisma-schema/data-model/models#defining-models
@ -106,6 +108,8 @@ model Snippet {
snippet_created_at DateTime @default(now())
guild_id BigInt
guild Guild @relation(fields: [guild_id], references: [guild_id])
uses BigInt @default(0)
locked Boolean @default(false)
@@unique([snippet_name, guild_id])
@@index([snippet_name, guild_id])

View file

@ -27,6 +27,7 @@ pynacl = "^1.5.0"
pyright = "^1.1.358"
python = ">=3.12,<4"
python-dotenv = "^1.0.1"
pyyaml = "^6.0.2"
reactionmenu = "^3.1.7"
rsa = "^4.9"
ruff = "^0.6.0"
@ -34,6 +35,8 @@ sentry-sdk = {extras = ["httpx", "loguru"], version = "^2.7.0"}
types-aiofiles = "^24.1.0.20240626"
types-psutil = "^6.0.0.20240621"
typing-extensions = "^4.12.2"
jishaku = "^2.5.2"
pytz = "^2024.1"
[tool.poetry.group.docs.dependencies]
mkdocs-material = "^9.5.30"
@ -86,8 +89,7 @@ target-version = "py312"
[tool.ruff.lint]
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
fixable = ["ALL"]
ignore = ["E501", "N814"]
ignore = ["E501", "N814", "PLR0913", "PLR2004"]
select = [
"I", # isort
"E", # pycodestyle-error
@ -96,6 +98,8 @@ select = [
"N", # pep8-naming
"TRY", # tryceratops
"UP", # pyupgrade
"FURB", # refurb
"PL", # pylint
"B", # flake8-bugbear
"SIM", # flake8-simplify
"ASYNC", # flake8-async

View file

@ -32,6 +32,8 @@ class Tux(commands.Bot):
logger.critical(f"An error occurred while connecting to the database: {e}")
return
# Load Jishaku for debugging
await self.load_extension("jishaku")
# Load cogs via CogLoader
await self.load_cogs()

View file

@ -5,7 +5,6 @@ from discord.ext import commands
from loguru import logger
from tux.utils import checks
from tux.utils.constants import Constants as CONST
from tux.utils.embeds import EmbedCreator
@ -48,7 +47,7 @@ class Eval(commands.Cog):
usage="eval [expression]",
)
@commands.guild_only()
@checks.has_pl(9)
@checks.has_pl(8) # sysadmin or higher
async def eval(self, ctx: commands.Context[commands.Bot], *, cmd: str) -> None:
"""
Evaluate a Python expression. (Owner only)
@ -61,10 +60,15 @@ class Eval(commands.Cog):
The Python expression to evaluate.
"""
# Check if the user is the bot owner
if ctx.author.id != CONST.BOT_OWNER_ID:
# Check if the user is in the discord.py owner_ids list in the bot instance
if self.bot.owner_ids is None:
logger.warning("Bot owner IDs are not set.")
await ctx.send("Bot owner IDs are not set. Better luck next time!", ephemeral=True, delete_after=30)
return
if ctx.author.id not in self.bot.owner_ids:
logger.warning(
f"{ctx.author} tried to run eval but is not the bot owner. (Owner ID: {self.bot.owner_id}, User ID: {ctx.author.id})",
f"{ctx.author} tried to run eval but is not the bot owner. (User ID: {ctx.author.id})",
)
await ctx.send("You are not the bot owner. Better luck next time!", ephemeral=True, delete_after=30)
return

View file

@ -54,7 +54,7 @@ distro_ids = [
[1182152672447569972, "_slackware"],
[1178347123905929316, "_popos"],
[1175177750143848520, "_kisslinux"],
[1180570700734546031, "tux"],
[1180570700734546031, "_lfs"],
[1191106506276479067, "_garuda"],
[1192177499413684226, "_asahi"],
[1207599112585740309, "_fedoraatomic"],

View file

@ -1,83 +1,83 @@
import discord
from discord import app_commands
from discord.ext import commands
from tux.utils.embeds import EmbedCreator
class Info(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
info = app_commands.Group(name="info", description="Information commands.")
@commands.hybrid_group(
name="info",
aliases=["i"],
usage="info <subcommand>",
)
async def info(self, ctx: commands.Context[commands.Bot]) -> None:
"""
Information commands.
@info.command(name="server")
async def server(self, interaction: discord.Interaction) -> None:
Parameters
----------
ctx : commands.Context[commands.Bot]
The discord context object.
"""
if ctx.invoked_subcommand is None:
await ctx.send_help("info")
@info.command(
name="server",
aliases=["s"],
usage="info server",
)
async def server(self, ctx: commands.Context[commands.Bot]) -> None:
"""
Show information about the server.
Parameters
----------
interaction : discord.Interaction
The discord interaction object.
ctx : commands.Context[commands.Bot]
The discord context object.
"""
if not interaction.guild:
if not ctx.guild:
return
guild = ctx.guild
guild = interaction.guild
owner = str(guild.owner) if guild.owner else "Unknown"
embed = EmbedCreator.create_info_embed(
title=guild.name,
description="Here is some information about the server.",
interaction=interaction,
embed = discord.Embed(
title=ctx.guild.name,
description=guild.description or "No description available.",
color=discord.Color.blurple(),
)
embed.add_field(name="Members", value=str(guild.member_count))
bots = sum(member.bot for member in guild.members if member.bot)
embed.add_field(name="Bots", value=str(bots))
embed.add_field(name="Boosts", value=str(guild.premium_subscription_count))
embed.add_field(name="Vanity URL", value=str(guild.vanity_url_code or "None"))
embed.add_field(name="Owner", value=owner)
embed.add_field(name="Created", value=guild.created_at.strftime("%d/%m/%Y"))
embed.add_field(name="ID", value=str(guild.id))
embed.set_author(name="Server Information", icon_url=guild.icon)
embed.add_field(name="Owner", value=str(guild.owner.mention) if guild.owner else "Unknown")
embed.add_field(name="Vanity URL", value=guild.vanity_url_code or "None")
embed.add_field(name="Boosts", value=guild.premium_subscription_count)
embed.add_field(name="Text Channels", value=len(guild.text_channels))
embed.add_field(name="Voice Channels", value=len(guild.voice_channels))
embed.add_field(name="Forum Channels", value=len(guild.forums))
embed.add_field(name="Emojis", value=f"{len(guild.emojis)}/{guild.emoji_limit}")
embed.add_field(name="Stickers", value=f"{len(guild.stickers)}/{guild.sticker_limit}")
embed.add_field(name="Roles", value=len(guild.roles))
embed.add_field(name="Humans", value=sum(not member.bot for member in guild.members))
embed.add_field(name="Bots", value=sum(member.bot for member in guild.members))
embed.add_field(name="Bans", value=len([entry async for entry in guild.bans(limit=2000)]))
embed.set_footer(text=f"ID: {guild.id} | Created: {guild.created_at.strftime('%B %d, %Y')}")
embed.set_thumbnail(url=guild.icon)
await ctx.send(embed=embed)
await interaction.response.send_message(embed=embed)
@info.command(name="tux", description="Shows information about Tux.")
async def tux(self, interaction: discord.Interaction) -> None:
"""
Show information about Tux.
Parameters
----------
interaction : discord.Interaction
The discord interaction object.
"""
embed = EmbedCreator.create_info_embed(
title="Tux",
description="Tux is a Discord bot written in Python using discord.py.",
interaction=interaction,
)
embed.add_field(
name="GitHub",
value="[View the source code](https://github.com/allthingslinux/tux)",
)
await interaction.response.send_message(embed=embed)
@info.command(name="member", description="Shows information about a member.")
async def member(self, interaction: discord.Interaction, member: discord.Member) -> None:
@info.command(
name="member",
aliases=["m", "user", "u"],
usage="info member [member]",
)
async def member(self, ctx: commands.Context[commands.Bot], member: discord.Member) -> None:
"""
Show information about a member.
Parameters
----------
interaction : discord.Interaction
The discord interaction object.
ctx : commands.Context[commands.Bot]
The discord context object.
member : discord.Member
The member to get information about.
"""
@ -86,15 +86,16 @@ class Info(commands.Cog):
joined = discord.utils.format_dt(member.joined_at, "R") if member.joined_at else "Unknown"
created = discord.utils.format_dt(member.created_at, "R") if member.created_at else "Unknown"
roles = ", ".join(role.mention for role in member.roles[1:]) if member.roles[1:] else "No roles"
fetched_member = await self.bot.fetch_user(member.id)
embed = EmbedCreator.create_info_embed(
embed = discord.Embed(
title=member.display_name,
description="Here is some information about the member.",
interaction=interaction,
color=discord.Color.blurple(),
)
embed.set_thumbnail(url=member.display_avatar.url)
embed.set_image(url=fetched_member.banner)
embed.add_field(name="Bot?", value=bot_status, inline=False)
embed.add_field(name="Username", value=member.name, inline=False)
embed.add_field(name="ID", value=str(member.id), inline=False)
@ -102,10 +103,67 @@ class Info(commands.Cog):
embed.add_field(name="Registered", value=created, inline=False)
embed.add_field(name="Roles", value=roles, inline=False)
embed.set_thumbnail(url=member.display_avatar.url)
embed.set_image(url=fetched_member.banner)
await ctx.send(embed=embed)
await interaction.response.send_message(embed=embed)
@info.command(
name="roles",
aliases=["r"],
usage="info roles",
)
async def roles(self, ctx: commands.Context[commands.Bot]) -> None:
"""
List all roles in the server.
Parameters
----------
ctx : commands.Context[commands.Bot]
The discord context object.
"""
if not ctx.guild:
return
guild = ctx.guild
roles = [role.mention for role in guild.roles]
embed = discord.Embed(
title="Server Roles",
description=f"Role list for {guild.name}",
color=discord.Color.blurple(),
)
embed.add_field(name="Roles", value=", ".join(roles), inline=False)
await ctx.send(embed=embed)
@info.command(
name="emotes",
aliases=["e"],
usage="info emotes",
)
async def emotes(self, ctx: commands.Context[commands.Bot]) -> None:
"""
List all emotes in the server.
Parameters
----------
ctx : commands.Context[commands.Bot]
The discord context object.
"""
if not ctx.guild:
return
guild = ctx.guild
emotes = [str(emote) for emote in guild.emojis]
embed = discord.Embed(
title="Server Emotes",
description=f"Emote list for {guild.name}",
color=discord.Color.blurple(),
)
embed.add_field(name="Emotes", value=" ".join(emotes) if emotes else "No emotes available", inline=False)
await ctx.send(embed=embed)
async def setup(bot: commands.Bot) -> None:

View file

@ -18,7 +18,7 @@ class Ban(ModerationCogBase):
@commands.hybrid_command(
name="ban",
aliases=["b"],
usage="ban [target] <flags>",
usage="ban [target] [reason] <purge_days> <silent>",
)
@commands.guild_only()
@checks.has_pl(3)
@ -30,7 +30,7 @@ class Ban(ModerationCogBase):
flags: BanFlags,
) -> None:
"""
Ban a user from the server.
Ban a member from the server.
Parameters
----------
@ -39,7 +39,7 @@ class Ban(ModerationCogBase):
target : discord.Member
The member to ban.
flags : BanFlags
The flags for the command. (reason: str, purge_days: int, silent: bool)
The flags for the command. (reason: str, purge_days: int (< 7), silent: bool)
Raises
------
@ -58,7 +58,7 @@ class Ban(ModerationCogBase):
return
try:
await self.send_dm(ctx, flags.silent, target, flags.reason, "banned")
await self.send_dm(ctx, flags.silent, target, flags.reason, action="banned")
await ctx.guild.ban(target, reason=flags.reason, delete_message_days=flags.purge_days)
except (discord.Forbidden, discord.HTTPException) as e:

View file

@ -23,6 +23,8 @@ emojis: dict[str, int] = {
"timeout": 1268115809083981886,
"warn": 1268115764498399264,
"jail": 1268115750392954880,
"snippetban": 1275782294363312172, # Placeholder
"snippetunban": 1275782294363312172, # Placeholder
}
@ -47,7 +49,7 @@ class Cases(ModerationCogBase):
@cases.command(
name="view",
aliases=["v", "ls", "list"],
usage="cases view <case_number> <flags>",
usage="cases view <case_number> <type> <target> <moderator>",
)
@commands.guild_only()
@checks.has_pl(2)
@ -83,7 +85,7 @@ class Cases(ModerationCogBase):
@cases.command(
name="modify",
aliases=["m", "edit"],
usage="cases modify [case_number] <flags>",
usage="cases modify [case_number] <status> <reason>",
)
@commands.guild_only()
@checks.has_pl(2)
@ -150,10 +152,7 @@ class Cases(ModerationCogBase):
await ctx.send("Case not found.", delete_after=30)
return
target = await commands.MemberConverter().convert(
ctx,
str(case.case_target_id),
) or await commands.UserConverter().convert(ctx, str(case.case_target_id))
target = await commands.UserConverter().convert(ctx, str(case.case_target_id))
await self._handle_case_response(ctx, case, "viewed", case.case_reason, target)
@ -233,10 +232,7 @@ class Cases(ModerationCogBase):
await ctx.send("Failed to update case.", delete_after=30, ephemeral=True)
return
target = await commands.MemberConverter().convert(
ctx,
str(case.case_target_id),
) or await commands.UserConverter().convert(ctx, str(case.case_target_id))
target = await commands.UserConverter().convert(ctx, str(case.case_target_id))
await self._handle_case_response(ctx, updated_case, "updated", updated_case.case_reason, target)
@ -266,10 +262,10 @@ class Cases(ModerationCogBase):
"""
if case is not None:
moderator = await commands.MemberConverter().convert(
ctx,
str(case.case_moderator_id),
) or await commands.UserConverter().convert(ctx, str(case.case_moderator_id))
moderator = ctx.author
if not isinstance(moderator, discord.Member):
moderator = await commands.MemberConverter().convert(ctx, str(case.case_moderator_id))
fields = self._create_case_fields(moderator, target, reason)
@ -298,7 +294,7 @@ class Cases(ModerationCogBase):
cases: list[Case],
total_cases: int,
) -> None:
menu = ViewMenu(ctx, menu_type=ViewMenu.TypeEmbed)
menu = ViewMenu(ctx, menu_type=ViewMenu.TypeEmbed, all_can_click=True, delete_on_timeout=True)
if not cases:
embed = discord.Embed(
@ -314,9 +310,16 @@ class Cases(ModerationCogBase):
embed = self._create_case_list_embed(ctx, cases[i : i + cases_per_page], total_cases)
menu.add_page(embed)
menu.add_button(ViewButton.back())
menu.add_button(ViewButton.next())
menu.add_button(ViewButton.end_session())
menu.add_button(
ViewButton(style=discord.ButtonStyle.secondary, custom_id=ViewButton.ID_GO_TO_FIRST_PAGE, emoji="⏮️"),
)
menu.add_button(
ViewButton(style=discord.ButtonStyle.secondary, custom_id=ViewButton.ID_PREVIOUS_PAGE, emoji=""),
)
menu.add_button(ViewButton(style=discord.ButtonStyle.secondary, custom_id=ViewButton.ID_NEXT_PAGE, emoji=""))
menu.add_button(
ViewButton(style=discord.ButtonStyle.secondary, custom_id=ViewButton.ID_GO_TO_LAST_PAGE, emoji="⏭️"),
)
await menu.start()
@ -373,6 +376,8 @@ class Cases(ModerationCogBase):
CaseType.WARN: "warn",
CaseType.JAIL: "jail",
CaseType.UNJAIL: "jail",
CaseType.SNIPPETBAN: "snippetban",
CaseType.SNIPPETUNBAN: "snippetunban",
}
emoji_name = emoji_map.get(case_type)
if emoji_name is not None:
@ -384,9 +389,10 @@ class Cases(ModerationCogBase):
def _get_case_action_emoji(self, case_type: CaseType) -> discord.Emoji | None:
action = (
"added"
if case_type in [CaseType.BAN, CaseType.KICK, CaseType.TIMEOUT, CaseType.WARN, CaseType.JAIL]
if case_type
in [CaseType.BAN, CaseType.KICK, CaseType.TIMEOUT, CaseType.WARN, CaseType.JAIL, CaseType.SNIPPETBAN]
else "removed"
if case_type in [CaseType.UNBAN, CaseType.UNTIMEOUT, CaseType.UNJAIL]
if case_type in [CaseType.UNBAN, CaseType.UNTIMEOUT, CaseType.UNJAIL, CaseType.SNIPPETUNBAN]
else None
)
if action is not None:
@ -403,12 +409,14 @@ class Cases(ModerationCogBase):
case_action_emoji: str,
) -> str:
case_type_and_action = (
f"{case_action_emoji} {case_type_emoji}" if case_action_emoji and case_type_emoji else "?"
f"{case_action_emoji} {case_type_emoji}"
if case_action_emoji and case_type_emoji
else ":interrobang: :interrobang:"
)
case_date = discord.utils.format_dt(case.case_created_at, "R") if case.case_created_at else "?"
case_date = discord.utils.format_dt(case.case_created_at, "R") if case.case_created_at else ":interrobang:"
case_number = f"{case.case_number:04d}"
return f"{case_status_emoji} `{case_number}`\u2002\u2002 {case_type_and_action} \u2002\u2002*{case_date}*\n"
return f"{case_status_emoji} `{case_number}`\u2002\u2002 {case_type_and_action} \u2002\u2002__{case_date}__\n"
def _add_case_to_embed(self, embed: discord.Embed, case: Case) -> None:
case_status_emoji = self._format_emoji(self._get_case_status_emoji(case.case_status))

View file

@ -12,25 +12,25 @@ class Slowmode(commands.Cog):
@commands.hybrid_command(
name="slowmode",
aliases=["sm"],
usage="slowmode [delay] <channel>",
usage="slowmode [delay|get] <channel>",
)
@commands.guild_only()
@checks.has_pl(2)
async def slowmode(
self,
ctx: commands.Context[commands.Bot],
delay: str,
action: str,
channel: discord.TextChannel | discord.Thread | None = None,
) -> None:
"""
Sets slowmode for the current channel or specified channel.
Set or get the slowmode for a channel.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
delay : int
The slowmode time in seconds, max is 21600.
action : str
Either 'get' to get the current slowmode or the slowmode time in seconds, max is 21600.
channel : discord.TextChannel | discord.Thread | None
The channel to set the slowmode in.
"""
@ -38,10 +38,9 @@ class Slowmode(commands.Cog):
if ctx.guild is None:
return
# If the channel is not specified, default to the current channe
# Default to the current channel if none is specified
if channel is None:
# Check if the current channel is a text channel
if not isinstance(ctx.channel, discord.TextChannel | discord.Thread):
if not isinstance(ctx.channel, (discord.TextChannel | discord.Thread)):
await ctx.send(
"Invalid channel type, must be a text channel or thread.",
delete_after=30,
@ -50,31 +49,49 @@ class Slowmode(commands.Cog):
return
channel = ctx.channel
# Unsure of how to type hint this properly as it can be a string or int
# and I can't use a Union for the argument because discord.py nagging?
try:
if delay[-1] in ["s"]:
delay = delay[:-1]
if delay[-1] == "m":
delay = delay[:-1]
delay = int(delay) * 60 # type: ignore
if action.lower() in {"get", "g"}:
try:
await ctx.send(
f"The slowmode for {channel.mention} is {channel.slowmode_delay} seconds.",
delete_after=30,
ephemeral=True,
)
except Exception as error:
await ctx.send(f"Failed to get slowmode. Error: {error}", delete_after=30, ephemeral=True)
logger.error(f"Failed to get slowmode. Error: {error}")
else:
delay = action
try:
if delay[-1] in ["s"]:
delay = delay[:-1]
if delay[-1] == "m":
delay = delay[:-1]
delay = int(delay) * 60 # type: ignore
delay = int(delay) # type: ignore
except ValueError:
await ctx.send("Invalid delay value, must be an integer.", delete_after=30, ephemeral=True)
return
delay = int(delay) # type: ignore
except ValueError:
await ctx.send("Invalid delay value, must be an integer.", delete_after=30, ephemeral=True)
return
if delay < 0 or delay > 21600: # type: ignore
await ctx.send("The slowmode delay must be between 0 and 21600 seconds.", delete_after=30, ephemeral=True)
return
if delay < 0 or delay > 21600: # type: ignore
await ctx.send(
"The slowmode delay must be between 0 and 21600 seconds.",
delete_after=30,
ephemeral=True,
)
return
try:
await channel.edit(slowmode_delay=delay) # type: ignore
await ctx.send(f"Slowmode set to {delay} seconds in {channel.mention}.", delete_after=30, ephemeral=True)
try:
await channel.edit(slowmode_delay=delay) # type: ignore
await ctx.send(
f"Slowmode set to {delay} seconds in {channel.mention}.",
delete_after=30,
ephemeral=True,
)
except Exception as error:
await ctx.send(f"Failed to set slowmode. Error: {error}", delete_after=30, ephemeral=True)
logger.error(f"Failed to set slowmode. Error: {error}")
except Exception as error:
await ctx.send(f"Failed to set slowmode. Error: {error}", delete_after=30, ephemeral=True)
logger.error(f"Failed to set slowmode. Error: {error}")
async def setup(bot: commands.Bot) -> None:

View file

@ -0,0 +1,134 @@
import discord
from discord.ext import commands
from loguru import logger
from prisma.enums import CaseType
from prisma.models import Case
from tux.database.controllers.case import CaseController
from tux.utils import checks
from tux.utils.constants import Constants as CONST
from tux.utils.flags import SnippetBanFlags
from . import ModerationCogBase
class SnippetBan(ModerationCogBase):
def __init__(self, bot: commands.Bot) -> None:
super().__init__(bot)
self.case_controller = CaseController()
@commands.hybrid_command(
name="snippetban",
aliases=["sb"],
usage="snippetban [target]",
)
@commands.guild_only()
@checks.has_pl(3)
async def snippet_ban(
self,
ctx: commands.Context[commands.Bot],
target: discord.Member,
*,
flags: SnippetBanFlags,
) -> None:
"""
Ban a user from creating snippets.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context object.
target : discord.Member
The member to snippet ban.
flags : SnippetBanFlags
The flags for the command. (reason: str, silent: bool)
"""
if ctx.guild is None:
logger.warning("Snippet ban command used outside of a guild context.")
return
if await self.is_snippetbanned(ctx.guild.id, target.id):
await ctx.send("User is already snippet banned.", delete_after=30)
return
case = await self.db.case.insert_case(
case_target_id=target.id,
case_moderator_id=ctx.author.id,
case_type=CaseType.SNIPPETBAN,
case_reason=flags.reason,
guild_id=ctx.guild.id,
)
await self.send_dm(ctx, flags.silent, target, flags.reason, "Snippet banned")
await self.handle_case_response(ctx, case, "created", flags.reason, target)
async def handle_case_response(
self,
ctx: commands.Context[commands.Bot],
case: Case | None,
action: str,
reason: str,
target: discord.Member | discord.User,
previous_reason: str | None = None,
) -> None:
moderator = ctx.author
fields = [
("Moderator", f"__{moderator}__\n`{moderator.id}`", True),
("Target", f"__{target}__\n`{target.id}`", True),
("Reason", f"> {reason}", False),
]
if previous_reason:
fields.append(("Previous Reason", f"> {previous_reason}", False))
if case is not None:
embed = await self.create_embed(
ctx,
title=f"Case #{case.case_number} ({case.case_type}) {action}",
fields=fields,
color=CONST.EMBED_COLORS["CASE"],
icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"],
)
embed.set_thumbnail(url=target.avatar)
else:
embed = await self.create_embed(
ctx,
title=f"Case {action} ({CaseType.SNIPPETBAN})",
fields=fields,
color=CONST.EMBED_COLORS["CASE"],
icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"],
)
await self.send_embed(ctx, embed, log_type="mod")
await ctx.send(embed=embed, delete_after=30, ephemeral=True)
async def is_snippetbanned(self, guild_id: int, user_id: int) -> bool:
"""
Check if a user is snippet banned.
Parameters
----------
guild_id : int
The ID of the guild to check in.
user_id : int
The ID of the user to check.
Returns
-------
bool
True if the user is snippet banned, False otherwise.
"""
ban_cases = await self.case_controller.get_all_cases_by_type(guild_id, CaseType.SNIPPETBAN)
unban_cases = await self.case_controller.get_all_cases_by_type(guild_id, CaseType.SNIPPETUNBAN)
ban_count = sum(case.case_target_id == user_id for case in ban_cases)
unban_count = sum(case.case_target_id == user_id for case in unban_cases)
return ban_count > unban_count
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(SnippetBan(bot))

View file

@ -0,0 +1,135 @@
import discord
from discord.ext import commands
from loguru import logger
from prisma.enums import CaseType
from prisma.models import Case
from tux.database.controllers.case import CaseController
from tux.utils import checks
from tux.utils.constants import Constants as CONST
from tux.utils.flags import SnippetUnbanFlags
from . import ModerationCogBase
class SnippetUnban(ModerationCogBase):
def __init__(self, bot: commands.Bot) -> None:
super().__init__(bot)
self.case_controller = CaseController()
@commands.hybrid_command(
name="snippetunban",
aliases=["sub"],
usage="snippetunban [target]",
)
@commands.guild_only()
@checks.has_pl(3)
async def snippet_unban(
self,
ctx: commands.Context[commands.Bot],
target: discord.Member,
*,
flags: SnippetUnbanFlags,
):
"""
Unban a user from creating snippets.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context object.
target : discord.Member
The member to snippet unban.
flags : SnippetUnbanFlags
The flags for the command. (reason: str, silent: bool)
"""
if ctx.guild is None:
logger.warning("Snippet ban command used outside of a guild context.")
return
# Check if the user is already snippet banned
if not await self.is_snippetbanned(ctx.guild.id, target.id):
await ctx.send("User is not snippet banned.", delete_after=30)
return
case = await self.db.case.insert_case(
case_target_id=target.id,
case_moderator_id=ctx.author.id,
case_type=CaseType.SNIPPETUNBAN,
case_reason=flags.reason,
guild_id=ctx.guild.id,
)
await self.send_dm(ctx, flags.silent, target, flags.reason, "Snippet unbanned")
await self.handle_case_response(ctx, case, "created", flags.reason, target)
async def handle_case_response(
self,
ctx: commands.Context[commands.Bot],
case: Case | None,
action: str,
reason: str,
target: discord.Member | discord.User,
previous_reason: str | None = None,
) -> None:
moderator = ctx.author
fields = [
("Moderator", f"__{moderator}__\n`{moderator.id}`", True),
("Target", f"__{target}__\n`{target.id}`", True),
("Reason", f"> {reason}", False),
]
if previous_reason:
fields.append(("Previous Reason", f"> {previous_reason}", False))
if case is not None:
embed = await self.create_embed(
ctx,
title=f"Case #{case.case_number} ({case.case_type}) {action}",
fields=fields,
color=CONST.EMBED_COLORS["CASE"],
icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"],
)
embed.set_thumbnail(url=target.avatar)
else:
embed = await self.create_embed(
ctx,
title=f"Case {action} ({CaseType.SNIPPETUNBAN})",
fields=fields,
color=CONST.EMBED_COLORS["CASE"],
icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"],
)
await self.send_embed(ctx, embed, log_type="mod")
await ctx.send(embed=embed, delete_after=30, ephemeral=True)
async def is_snippetbanned(self, guild_id: int, user_id: int) -> bool:
"""
Check if a user is snippet banned.
Parameters
----------
guild_id : int
The ID of the guild to check in.
user_id : int
The ID of the user to check.
Returns
-------
bool
True if the user is snippet banned, False otherwise.
"""
ban_cases = await self.case_controller.get_all_cases_by_type(guild_id, CaseType.SNIPPETBAN)
unban_cases = await self.case_controller.get_all_cases_by_type(guild_id, CaseType.SNIPPETUNBAN)
ban_count = sum(case.case_target_id == user_id for case in ban_cases)
unban_count = sum(case.case_target_id == user_id for case in unban_cases)
return ban_count > unban_count
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(SnippetUnban(bot))

View file

@ -73,14 +73,14 @@ class Timeout(ModerationCogBase):
flags: TimeoutFlags,
) -> None:
"""
Timeout a user from the server.
Timeout a member from the server.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context in which the command is being invoked.
target : discord.Member
The user to timeout.
The member to timeout.
flags : TimeoutFlags
The flags for the command.
@ -89,6 +89,7 @@ class Timeout(ModerationCogBase):
discord.DiscordException
If an error occurs while timing out the user.
"""
if ctx.guild is None:
logger.warning("Timeout command used outside of a guild context.")
return

View file

@ -18,7 +18,7 @@ class Unban(ModerationCogBase):
@commands.hybrid_command(
name="unban",
aliases=["ub"],
usage="unban [target] [reason]",
usage="unban [username_or_id] [reason]",
)
@commands.guild_only()
@checks.has_pl(3)
@ -38,7 +38,7 @@ class Unban(ModerationCogBase):
target : discord.Member
The member to unban.
flags : UnbanFlags
The flags for the command.
The flags for the command (username_or_id: str, reason: str).
Raises
------
@ -69,11 +69,11 @@ class Unban(ModerationCogBase):
return
case = await self.db.case.insert_case(
guild_id=ctx.guild.id,
case_target_id=user.id,
case_moderator_id=ctx.author.id,
case_type=CaseType.UNBAN,
case_reason=flags.reason,
guild_id=ctx.guild.id,
)
await self.handle_case_response(ctx, case, "created", flags.reason, user)

View file

@ -30,7 +30,7 @@ class Unjail(ModerationCogBase):
flags: UnjailFlags,
) -> None:
"""
Unjail a user in the server.
Unjail a member in the server.
Parameters
----------
@ -41,6 +41,7 @@ class Unjail(ModerationCogBase):
flags : UnjailFlags
The flags for the command. (reason: str, silent: bool)
"""
if not ctx.guild:
logger.warning("Unjail command used outside of a guild context.")
return
@ -55,7 +56,7 @@ class Unjail(ModerationCogBase):
return
if jail_role not in target.roles:
await ctx.send("The user is not jailed.", delete_after=30, ephemeral=True)
await ctx.send("The member is not jailed.", delete_after=30, ephemeral=True)
return
if not await self._check_jail_channel(ctx):
@ -63,7 +64,7 @@ class Unjail(ModerationCogBase):
case = await self.db.case.get_last_jail_case_by_target_id(ctx.guild.id, target.id)
if not case:
await ctx.send("No jail case found for the user.", delete_after=30, ephemeral=True)
await ctx.send("No jail case found for this member.", delete_after=30, ephemeral=True)
return
await self._unjail_user(ctx, target, jail_role, case, flags.reason)
@ -121,11 +122,11 @@ class Unjail(ModerationCogBase):
if previous_roles:
await target.add_roles(*previous_roles, reason=reason, atomic=False)
else:
await ctx.send("No previous roles found for the user.", delete_after=30, ephemeral=True)
await ctx.send("No previous roles found for the member.", delete_after=30, ephemeral=True)
except (discord.Forbidden, discord.HTTPException) as e:
logger.error(f"Failed to unjail user {target}. {e}")
await ctx.send(f"Failed to unjail user {target}. {e}", delete_after=30, ephemeral=True)
logger.error(f"Failed to unjail member {target}. {e}")
await ctx.send(f"Failed to unjail member {target}. {e}", delete_after=30, ephemeral=True)
async def _insert_unjail_case(
self,

View file

@ -18,7 +18,7 @@ class Untimeout(ModerationCogBase):
@commands.hybrid_command(
name="untimeout",
aliases=["ut", "uto", "unmute"],
usage="untimeout [target] [reason]",
usage="untimeout [target] [reason] <silent>",
)
@commands.guild_only()
@checks.has_pl(2)
@ -30,16 +30,16 @@ class Untimeout(ModerationCogBase):
flags: UntimeoutFlags,
) -> None:
"""
Untimeout a user from the server.
Untimeout a member from the server.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context in which the command is being invoked.
target : discord.Member
The user to timeout.
The member to untimeout.
flags : UntimeoutFlags
The flags for the command.
The flags for the command (reason: str, silent: bool).
Raises
------

View file

@ -18,7 +18,7 @@ class Warn(ModerationCogBase):
@commands.hybrid_command(
name="warn",
aliases=["w"],
usage="warn [target] <flags>",
usage="warn [target] [reason] <silent>",
)
@commands.guild_only()
@checks.has_pl(2)
@ -30,7 +30,7 @@ class Warn(ModerationCogBase):
flags: WarnFlags,
) -> None:
"""
Warn a user from the server.
Warn a member from the server.
Parameters
----------

View file

@ -1,3 +1,4 @@
import contextlib
import datetime
import string
@ -7,8 +8,9 @@ from discord.ext import commands
from loguru import logger
from reactionmenu import ViewButton, ViewMenu
from prisma.enums import CaseType
from prisma.models import Snippet
from tux.database.controllers import DatabaseController
from tux.database.controllers import CaseController, DatabaseController
from tux.utils import checks
from tux.utils.constants import Constants as CONST
from tux.utils.embeds import EmbedCreator, create_embed_footer, create_error_embed
@ -19,6 +21,16 @@ class Snippets(commands.Cog):
self.bot = bot
self.db = DatabaseController().snippet
self.config = DatabaseController().guild_config
self.case_controller = CaseController()
async def is_snippetbanned(self, guild_id: int, user_id: int) -> bool:
ban_cases = await self.case_controller.get_all_cases_by_type(guild_id, CaseType.SNIPPETBAN)
unban_cases = await self.case_controller.get_all_cases_by_type(guild_id, CaseType.SNIPPETUNBAN)
ban_count = sum(1 for case in ban_cases if case.case_target_id == user_id)
unban_count = sum(1 for case in unban_cases if case.case_target_id == user_id)
return ban_count > unban_count
@commands.command(
name="snippets",
@ -101,6 +113,58 @@ class Snippets(commands.Cog):
return embed
@commands.command(
name="topsnippets",
aliases=["ts"],
usage="topsnippets",
)
@commands.guild_only()
async def top_snippets(self, ctx: commands.Context[commands.Bot]) -> None:
"""
List top snippets.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context object.
"""
# find the top 10 snippets by uses
snippets: list[Snippet] = await self.db.get_all_snippets_by_guild_id(ctx.guild.id) # type: ignore # wio
# If there are no snippets, send an error message
if not snippets:
embed = EmbedCreator.create_error_embed(
title="Error",
description="No snippets found.",
ctx=ctx,
)
await ctx.send(embed=embed, delete_after=30)
return
# sort the snippets by uses
snippets.sort(key=lambda x: x.uses, reverse=True)
# print in this format
# 1. snippet_name | uses: 10
# 2. snippet_name | uses: 9
# 3. snippet_name | uses: 8
# ...
text = "```\n"
for i, snippet in enumerate(snippets[:10]):
text += f"{i+1}. {snippet.snippet_name.ljust(20)} | uses: {snippet.uses}\n"
text += "```"
# only show top 10, no pagination
embed = discord.Embed(
title="Top Snippets",
description=text,
color=CONST.EMBED_COLORS["DEFAULT"],
)
await ctx.send(embed=embed)
@commands.command(
name="deletesnippet",
aliases=["ds"],
@ -130,6 +194,14 @@ class Snippets(commands.Cog):
await ctx.send(embed=embed, delete_after=30, ephemeral=True)
return
# check if the snippet is locked
if snippet.locked:
embed = create_error_embed(
error="This snippet is locked and cannot be deleted. If you are a moderator you can use the `forcedeletesnippet` command.",
)
await ctx.send(embed=embed, delete_after=30, ephemeral=True)
return
# Check if the author of the snippet is the same as the user who wants to delete it and if theres no author don't allow deletion
author_id = snippet.snippet_user_id or 0
if author_id != ctx.author.id:
@ -214,6 +286,9 @@ class Snippets(commands.Cog):
await ctx.send(embed=embed, delete_after=30, ephemeral=True)
return
# increment the usage count of the snippet
await self.db.increment_snippet_uses(snippet.snippet_id)
text = f"`/snippets/{snippet.snippet_name}.txt` || {snippet.snippet_content}"
await ctx.send(text, allowed_mentions=AllowedMentions.none())
@ -263,6 +338,8 @@ class Snippets(commands.Cog):
embed.add_field(name="Name", value=snippet.snippet_name, inline=False)
embed.add_field(name="Author", value=f"{author.mention}", inline=False)
embed.add_field(name="Content", value=f"> {snippet.snippet_content}", inline=False)
embed.add_field(name="Uses", value=snippet.uses, inline=False)
embed.add_field(name="Locked", value="Yes" if snippet.locked else "No", inline=False)
embed.timestamp = snippet.snippet_created_at or datetime.datetime.fromtimestamp(
0,
@ -293,6 +370,10 @@ class Snippets(commands.Cog):
await ctx.send("This command cannot be used in direct messages.")
return
if await self.is_snippetbanned(ctx.guild.id, ctx.author.id):
await ctx.send("You are banned from using snippets.")
return
args = arg.split(" ")
if len(args) < 2:
embed = create_error_embed(error="Please provide a name and content for the snippet.")
@ -333,6 +414,127 @@ class Snippets(commands.Cog):
await ctx.send("Snippet created.", delete_after=30, ephemeral=True)
logger.info(f"{ctx.author} created a snippet with the name {name} and content {content}.")
@commands.command(
name="editsnippet",
aliases=["es"],
usage="editsnippet [name]",
)
@commands.guild_only()
async def edit_snippet(self, ctx: commands.Context[commands.Bot], *, arg: str) -> None:
"""
Edit a snippet.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context object.
arg : str
The name and content of the snippet.
"""
if ctx.guild is None:
await ctx.send("This command cannot be used in direct messages.")
return
args = arg.split(" ")
if len(args) < 2:
embed = create_error_embed(error="Please provide a name and content for the snippet.")
await ctx.send(embed=embed, delete_after=30, ephemeral=True)
return
name = args[0]
content = " ".join(args[1:])
author_id = ctx.author.id
snippet = await self.db.get_snippet_by_name_and_guild_id(name, ctx.guild.id)
if snippet is None:
embed = create_error_embed(error="Snippet not found.")
await ctx.send(embed=embed, delete_after=30, ephemeral=True)
return
# Check if the author of the snippet is the same as the user who wants to edit it and if theres no author don't allow editing
author_id = snippet.snippet_user_id or 0
if author_id != ctx.author.id:
embed = create_error_embed(error="You can only edit your own snippets.")
await ctx.send(embed=embed, delete_after=30, ephemeral=True)
return
# check if the snippet is locked
if snippet.locked:
logger.info(
f"{ctx.author} is trying to edit a snippet with the name {name} and content {content}. Checking if they have the permission level to edit locked snippets.",
)
# dont make the check send its own error message
try:
await checks.has_pl(2).predicate(ctx)
except commands.CheckFailure:
embed = create_error_embed(
error="This snippet is locked and cannot be edited. If you are a moderator you can use the `forcedeletesnippet` command.",
)
await ctx.send(embed=embed, delete_after=30, ephemeral=True)
return
logger.info(f"{ctx.author} has the permission level to edit locked snippets.")
await self.db.update_snippet_by_id(
snippet.snippet_id,
snippet_content=content,
)
await ctx.send("Snippet Edited.", delete_after=30, ephemeral=True) # Correct indentation
logger.info(f"{ctx.author} Edited a snippet with the name {name} and content {content}.") # Correct indentation
@commands.command(
name="togglesnippetlock",
aliases=["tsl"],
usage="togglesnippetlock [name]",
)
@commands.guild_only()
@checks.has_pl(2)
async def toggle_snippet_lock(self, ctx: commands.Context[commands.Bot], name: str) -> None:
"""
Toggle a snippet lock.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context object.
name : str
The name of the snippet.
"""
if ctx.guild is None:
await ctx.send("This command cannot be used in direct messages.")
return
snippet = await self.db.get_snippet_by_name_and_guild_id(name, ctx.guild.id)
if snippet is None:
embed = create_error_embed(error="Snippet not found.")
await ctx.send(embed=embed, delete_after=30, ephemeral=True)
return
status = await self.db.toggle_snippet_lock_by_id(snippet.snippet_id)
if status is None:
embed = create_error_embed(error="No return value from locking the snippet. It may still have been locked.")
await ctx.send(embed=embed, delete_after=30, ephemeral=True)
return
if author := self.bot.get_user(snippet.snippet_user_id):
with contextlib.suppress(discord.Forbidden):
await author.send(
f"""Your snippet `{snippet.snippet_name}` has been {'locked' if status.locked else 'unlocked'}.
**What does this mean?**
If a snippet is locked, it cannot be edited by anyone other than moderators. This means that you can no longer edit this snippet.
**Why was it locked?**
Snippets are usually locked by moderators if they are important to usual use of the server. Changes or deletions to these snippets can have a big impact on the server. If you believe this was done in error, please open a ticket with /ticket.""",
)
await ctx.send("Snippet lock toggled.", delete_after=30, ephemeral=True)
logger.info(f"{ctx.author} toggled the lock of the snippet with the name {name}.")
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Snippets(bot))

View file

@ -0,0 +1,140 @@
from datetime import UTC, datetime
import discord
import pytz
from discord.ext import commands
from reactionmenu import Page, ViewButton, ViewMenu, ViewSelect
timezones = {
"North America": [
("🇺🇸", "US", "Pacific/Honolulu", "HST", -10),
("🇺🇸", "US", "America/Anchorage", "AKST", -9),
("🇺🇸", "US", "America/Los_Angeles", "PST", -8),
("🇺🇸", "US", "America/Denver", "MST", -7),
("🇺🇸", "US", "America/Chicago", "CST", -6),
("🇺🇸", "US", "America/New_York", "EST", -5),
("🇲🇽", "MX", "America/Mexico_City", "CST", -6),
("🇨🇦", "CA", "America/Toronto", "EST", -5),
("🇨🇦", "CA", "America/Vancouver", "PST", -8),
],
"South America": [
("🇧🇷", "BR", "America/Sao_Paulo", "BRT", -3),
("🇦🇷", "AR", "America/Argentina/Buenos_Aires", "ART", -3),
("🇨🇱", "CL", "America/Santiago", "CLT", -3),
("🇵🇪", "PE", "America/Lima", "PET", -5),
("🇨🇴", "CO", "America/Bogota", "COT", -5),
("🇻🇪", "VE", "America/Caracas", "VET", -4),
("🇧🇴", "BO", "America/La_Paz", "BOT", -4),
("🇵🇾", "PY", "America/Asuncion", "PYT", -4),
("🇺🇾", "UY", "America/Montevideo", "UYT", -3),
],
"Africa": [
("🇬🇭", "GH", "Africa/Accra", "GMT", 0),
("🇳🇬", "NG", "Africa/Lagos", "WAT", 1),
("🇿🇦", "ZA", "Africa/Johannesburg", "SAST", 2),
("🇪🇬", "EG", "Africa/Cairo", "EET", 2),
("🇰🇪", "KE", "Africa/Nairobi", "EAT", 3),
("🇲🇦", "MA", "Africa/Casablanca", "WET", 0),
("🇹🇿", "TZ", "Africa/Dar_es_Salaam", "EAT", 3),
("🇩🇿", "DZ", "Africa/Algiers", "CET", 1),
("🇳🇦", "NA", "Africa/Windhoek", "CAT", 2),
],
"Europe": [
("🇬🇧", "GB", "Europe/London", "GMT", 0),
("🇩🇪", "DE", "Europe/Berlin", "CET", 1),
("🇫🇷", "FR", "Europe/Paris", "CET", 1),
("🇮🇹", "IT", "Europe/Rome", "CET", 1),
("🇪🇸", "ES", "Europe/Madrid", "CET", 1),
("🇳🇱", "NL", "Europe/Amsterdam", "CET", 1),
("🇧🇪", "BE", "Europe/Brussels", "CET", 1),
("🇷🇺", "RU", "Europe/Moscow", "MSK", 3),
("🇬🇷", "GR", "Europe/Athens", "EET", 2),
],
"Asia": [
("🇦🇪", "AE", "Asia/Dubai", "GST", 4),
("🇮🇳", "IN", "Asia/Kolkata", "IST", 5.5),
("🇧🇩", "BD", "Asia/Dhaka", "BST", 6),
("🇲🇲", "MM", "Asia/Yangon", "MMT", 6.5),
("🇹🇭", "TH", "Asia/Bangkok", "ICT", 7),
("🇻🇳", "VN", "Asia/Ho_Chi_Minh", "ICT", 7),
("🇨🇳", "CN", "Asia/Shanghai", "CST", 8),
("🇭🇰", "HK", "Asia/Hong_Kong", "HKT", 8),
("🇯🇵", "JP", "Asia/Tokyo", "JST", 9),
],
"Australia/Oceania": [
("🇦🇺", "AU", "Australia/Perth", "AWST", 8),
("🇦🇺", "AU", "Australia/Sydney", "AEST", 10),
("🇫🇯", "FJ", "Pacific/Fiji", "FJT", 12),
("🇳🇿", "NZ", "Pacific/Auckland", "NZDT", 13),
("🇵🇬", "PG", "Pacific/Port_Moresby", "PGT", 10),
("🇼🇸", "WS", "Pacific/Apia", "WSST", 13),
("🇸🇧", "SB", "Pacific/Guadalcanal", "SBT", 11),
("🇻🇺", "VU", "Pacific/Efate", "VUT", 11),
("🇵🇫", "PF", "Pacific/Tahiti", "THAT", -10),
],
}
continent_emojis = {
"North America": "🌎",
"South America": "🌎",
"Africa": "🌍",
"Europe": "🌍",
"Asia": "🌏",
"Australia/Oceania": "🌏",
}
class Timezones(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
@commands.hybrid_command(
name="timezones",
aliases=["tz"],
usage="timezones",
)
async def timezones(self, ctx: commands.Context[commands.Bot]) -> None:
utc_now = datetime.now(UTC)
menu = ViewMenu(ctx, menu_type=ViewMenu.TypeEmbed)
default_embeds: list[discord.Embed] = []
options: dict[discord.SelectOption, list[Page]] = {}
for continent, tz_list in timezones.items():
embeds: list[discord.Embed] = []
pages = [tz_list[i : i + 9] for i in range(0, len(tz_list), 9)]
for page in pages:
embed = discord.Embed(title=f"Timezones in {continent}", color=discord.Color.blurple())
for flag, _country, tz_name, abbr, utc_offset in page:
tz = pytz.timezone(tz_name)
local_time = utc_now.astimezone(tz)
time_24hr = local_time.strftime("%H:%M")
time_12hr = local_time.strftime("%I:%M %p")
embed.add_field(
name=f"{flag} {abbr} (UTC{utc_offset:+.2f})",
value=f"`{time_24hr} | {time_12hr}`",
inline=True,
)
embeds.append(embed)
default_embeds.extend(embeds)
options[discord.SelectOption(label=continent, emoji=continent_emojis[continent])] = Page.from_embeds(embeds)
for embed in default_embeds:
menu.add_page(embed)
select = ViewSelect(title="Select Continent", options=options)
menu.add_select(select)
menu.add_button(ViewButton.end_session())
await menu.start()
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Timezones(bot))

View file

@ -18,6 +18,9 @@ class SnippetController:
async def get_all_snippets(self) -> list[Snippet]:
return await self.table.find_many()
async def get_all_snippets_by_guild_id(self, guild_id: int) -> list[Snippet]:
return await self.table.find_many(where={"guild_id": guild_id})
async def get_all_snippets_sorted(self, newestfirst: bool = True) -> list[Snippet]:
return await self.table.find_many(
order={"snippet_created_at": "desc" if newestfirst else "asc"},
@ -63,3 +66,35 @@ class SnippetController:
where={"snippet_id": snippet_id},
data={"snippet_content": snippet_content},
)
async def increment_snippet_uses(self, snippet_id: int) -> Snippet | None:
snippet = await self.table.find_first(where={"snippet_id": snippet_id})
if snippet is None:
return None
return await self.table.update(
where={"snippet_id": snippet_id},
data={"uses": snippet.uses + 1},
)
async def lock_snippet_by_id(self, snippet_id: int) -> Snippet | None:
return await self.table.update(
where={"snippet_id": snippet_id},
data={"locked": True},
)
async def unlock_snippet_by_id(self, snippet_id: int) -> Snippet | None:
return await self.table.update(
where={"snippet_id": snippet_id},
data={"locked": False},
)
async def toggle_snippet_lock_by_id(self, snippet_id: int) -> Snippet | None:
snippet = await self.table.find_first(where={"snippet_id": snippet_id})
if snippet is None:
return None
return await self.table.update(
where={"snippet_id": snippet_id},
data={"locked": not snippet.locked},
)

View file

@ -6,21 +6,8 @@ from discord import app_commands
from discord.ext import commands
from loguru import logger
import tux.handlers.error as error
from tux.utils.embeds import create_error_embed
class PermissionLevelError(commands.CheckFailure):
def __init__(self, permission: str) -> None:
self.permission = permission
super().__init__(f"User does not have the required permission: {permission}")
class AppCommandPermissionLevelError(app_commands.CheckFailure):
def __init__(self, permission: str) -> None:
self.permission = permission
super().__init__(f"User does not have the required permission: {permission}")
from tux.utils.exceptions import AppCommandPermissionLevelError, PermissionLevelError
error_map: dict[type[Exception], str] = {
# app_commands
@ -51,8 +38,8 @@ error_map: dict[type[Exception], str] = {
commands.NotOwner: "User not in sudoers file. This incident will be reported. (Not Owner)",
commands.BotMissingPermissions: "User not in sudoers file. This incident will be reported. (Bot Missing Permissions)",
# Custom errors
error.PermissionLevelError: "User not in sudoers file. This incident will be reported. (Missing required permission: {error.permission})",
error.AppCommandPermissionLevelError: "User not in sudoers file. This incident will be reported. (Missing required permission: {error.permission})",
PermissionLevelError: "User not in sudoers file. This incident will be reported. (Missing required permission: {error.permission})",
AppCommandPermissionLevelError: "User not in sudoers file. This incident will be reported. (Missing required permission: {error.permission})",
}

View file

@ -43,9 +43,7 @@ class EventHandler(commands.Cog):
flag_list = ["🏳️‍🌈", "🏳️‍⚧️"]
user = self.bot.get_user(payload.user_id)
if user is None:
return
if user.bot:
if user is None or user.bot:
return
if payload.guild_id is None:
@ -59,26 +57,46 @@ class EventHandler(commands.Cog):
return
channel = self.bot.get_channel(payload.channel_id)
if channel is None:
return
if channel.id != 1172343581495795752:
return
if not isinstance(channel, discord.TextChannel):
if channel is None or channel.id != 1172343581495795752 or not isinstance(channel, discord.TextChannel):
return
message = await channel.fetch_message(payload.message_id)
emoji = payload.emoji
if any(0x1F1E3 <= ord(char) <= 0x1F1FF for char in emoji.name):
await message.remove_reaction(emoji, member)
return
if "flag" in emoji.name.lower():
await message.remove_reaction(emoji, member)
return
if emoji.name in flag_list:
if (
any(0x1F1E3 <= ord(char) <= 0x1F1FF for char in emoji.name)
or "flag" in emoji.name.lower()
or emoji.name in flag_list
):
await message.remove_reaction(emoji, member)
return
@commands.Cog.listener()
async def on_thread_create(self, thread: discord.Thread) -> None:
# TODO: Add database configuration for primmary support forum
support_forum = 1172312653797007461
if thread.parent_id == support_forum:
owner_mention = thread.owner.mention if thread.owner else {thread.owner_id}
if tags := [tag.name for tag in thread.applied_tags]:
tag_list = ", ".join(tags)
msg = f"<:tux_notify:1274504953666474025> **New support thread created** - help is appreciated!\n{thread.mention} by {owner_mention}\n<:tux_tag:1274504955163709525> **Tags**: `{tag_list}`"
else:
msg = f"<:tux_notify:1274504953666474025> **New support thread created** - help is appreciated!\n{thread.mention} by {owner_mention}"
embed = discord.Embed(description=msg, color=discord.Color.random())
general_chat = 1172245377395728467
channel = self.bot.get_channel(general_chat)
if channel is not None and isinstance(channel, discord.TextChannel):
# TODO: Add database configuration for primary support role
support_role = "<@&1274823545087590533>"
await channel.send(content=support_role, embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(EventHandler(bot))

View file

@ -225,7 +225,7 @@ class TuxHelp(commands.HelpCommand):
)
embed.add_field(
name="Flag Help",
value=f"Flags in `[]` are required and `<>` are optional. Most flags have aliases that can be used.\n> e.g. `{prefix}ban @user --reason spamming` or `{prefix}b @user -r spamming`",
value=f"Flags in `[]` are required and `<>` are optional. Most flags have aliases that can be used.\n> e.g. `{prefix}ban @user -reason spamming` or `{prefix}b @user -r spamming`",
inline=False,
)
embed.add_field(

View file

@ -33,8 +33,9 @@ async def main() -> None:
bot = Tux(
command_prefix=CONST.PREFIX,
strip_after_prefix=True,
case_insensitive=True,
intents=discord.Intents.all(),
owner_id=CONST.BOT_OWNER_ID,
owner_ids=[*CONST.SYSADMIN_IDS, CONST.BOT_OWNER_ID],
allowed_mentions=discord.AllowedMentions(everyone=False),
)

View file

@ -6,8 +6,8 @@ from discord.ext import commands
from loguru import logger
from tux.database.controllers import DatabaseController
from tux.handlers.error import AppCommandPermissionLevelError, PermissionLevelError
from tux.utils.constants import CONST
from tux.utils.exceptions import AppCommandPermissionLevelError, PermissionLevelError
db = DatabaseController().guild_config
@ -18,12 +18,12 @@ async def has_permission(
higher_bound: int | None = None,
) -> bool:
"""
Check if a user has a permission level.
Check if the source has the required permission level.
Parameters
----------
source : commands.Context[commands.Bot] | discord.Interaction
The source object for the command.
The source of the command.
lower_bound : int
The lower bound of the permission level.
higher_bound : int | None, optional
@ -32,82 +32,152 @@ async def has_permission(
Returns
-------
bool
Whether the user has the permission level.
Whether the source has the required permission level.
"""
# If higher_bound is None, set it to lower_bound
higher_bound = higher_bound or lower_bound
# If the source is a context object and the guild is None, return False if the lower bound is not 0
if source.guild is None:
logger.debug("Guild is None, returning False if lower bound is not 0")
return lower_bound == 0
# If the source is a context object, set the context object to source
ctx = source if isinstance(source, commands.Context) else None
# If the source is an interaction object, set the interaction object to source
interaction = source if isinstance(source, discord.Interaction) else None
# Initialize the list of roles to an empty list to avoid type errors
roles: list[Any] = []
roles = await get_roles_for_bounds(source, lower_bound, higher_bound)
try:
if higher_bound == lower_bound:
# Get the role ID for the permission level
role_id = await get_perm_level_role_id(source, f"perm_level_{lower_bound}_role_id")
author = await get_author_from_source(ctx, interaction)
# If the role ID exists, append it to the list of roles
if role_id:
roles.append(role_id)
else:
logger.debug(f"No Role ID fetched for perm_level_{lower_bound}_role_id")
else:
# Get the role IDs for the permission levels
fetched_roles = await get_perm_level_roles(source, lower_bound)
# If the role IDs exist, extend the list of roles with the fetched roles
if fetched_roles:
roles.extend(fetched_roles)
else:
logger.debug(f"No roles fetched for levels above and equal to {lower_bound}")
# Set the author to None to avoid type errors
author = None
# If the context object or interaction object exists and the guild is not None, fetch the author
if ctx and ctx.guild:
author = await ctx.guild.fetch_member(ctx.author.id)
elif interaction and interaction.guild:
author = await interaction.guild.fetch_member(interaction.user.id)
# If the author is not None and the author has any of the roles in the list of roles, return True
if author and any(role.id in roles for role in author.roles):
return True
except Exception as e:
logger.error(f"Exception in permission check: {e}")
logger.debug("All checks failed, checking for sysadmin or bot owner status")
return await check_sysadmin_or_owner(ctx, interaction, lower_bound, higher_bound)
async def get_roles_for_bounds(
source: Any,
lower_bound: int,
higher_bound: int,
) -> list[int]:
"""
Get the roles for the given bounds.
Parameters
----------
source : Any
The source of the command.
lower_bound : int
The lower bound of the permission level.
higher_bound : int
The higher bound of the permission level.
Returns
-------
list[int]
The list of role IDs for the given bounds.
"""
roles: list[Any] = []
try:
# Get the user ID from the context object or interaction object
user_id = ctx.author.id if ctx else interaction.user.id if interaction else None
if higher_bound == lower_bound:
role_id = await get_perm_level_role_id(source, f"perm_level_{lower_bound}_role_id")
if role_id:
roles.append(role_id)
else:
logger.debug(f"No Role ID fetched for perm_level_{lower_bound}_role_id")
else:
fetched_roles = await get_perm_level_roles(source, lower_bound)
roles.extend(fetched_roles or [])
except Exception as e:
logger.error(f"Error fetching roles: {e}")
return roles
async def get_author_from_source(
ctx: Any,
interaction: Any,
) -> Any:
"""
Get the author from the source.
Parameters
----------
ctx : Any
The context of the command.
interaction : Any
The interaction of the command.
Returns
-------
Any
The author of the command.
"""
try:
if ctx and ctx.guild:
return await ctx.guild.fetch_member(ctx.author.id)
if interaction and interaction.guild:
return await interaction.guild.fetch_member(interaction.user.id)
except Exception as e:
logger.error(f"Error fetching author: {e}")
return None
async def check_sysadmin_or_owner(
ctx: Any,
interaction: Any,
lower_bound: int,
higher_bound: int,
) -> bool:
"""
Check if the user is a sysadmin or bot owner.
Parameters
----------
ctx : Any
The context of the command.
interaction : Any
The interaction of the command.
lower_bound : int
The lower bound of the permission level.
higher_bound : int
The higher bound of the permission level.
Returns
-------
bool
Whether the user is a sysadmin or bot owner.
"""
try:
user_id = ctx.author.id if ctx else (interaction.user.id if interaction else None)
if user_id:
# Check if the user is a sysadmin or the bot owner
if 8 in range(lower_bound, higher_bound + 1) and user_id in CONST.SYSADMIN_IDS:
logger.debug("User is a sysadmin")
return True
if 9 in range(lower_bound, higher_bound + 1) and user_id == CONST.BOT_OWNER_ID:
logger.debug("User is the bot owner")
return True
except Exception as e:
logger.error(f"Exception while checking sysadmin or bot owner status: {e}")
logger.debug("All checks failed, returning False")
return False
@ -117,14 +187,16 @@ async def level_to_name(
or_higher: bool = False,
) -> str:
"""
Convert a permission level to a name.
Get the name of the permission level.
Parameters
----------
source : commands.Context[commands.Bot] | discord.Interaction
The source of the command.
level : int
The permission level to convert.
The permission level.
or_higher : bool, optional
Whether the user should have the permission level or higher, by default False
Whether to include "or higher" in the name, by default False.
Returns
-------
@ -132,34 +204,15 @@ async def level_to_name(
The name of the permission level.
"""
# Check if the level is 8 or 9 and return the corresponding name
if level in {8, 9}:
return "Sys Admin" if level == 8 else "Bot Owner"
# Check if the source is a context object and the guild is not None
if isinstance(source, commands.Context):
ctx = source
if ctx.guild is None:
return "Error"
role_name = await get_role_name_from_source(source, level)
# Get the role ID from the database for the guild and the role field
role_id = await db.get_perm_level_role(ctx.guild.id, f"perm_level_{level}_role_id")
if role_id and (role := ctx.guild.get_role(role_id)):
return f"{role.name} or higher" if or_higher else role.name
if role_name:
return f"{role_name} or higher" if or_higher else role_name
else:
# Get the interaction object and check if it exists and is in a guild
interaction = source
if not interaction or not interaction.guild:
return "Error"
# Get the role ID from the database for the guild and the role field
role_id = await db.get_perm_level_role(interaction.guild.id, f"perm_level_{level}_role_id")
if role_id and (role := interaction.guild.get_role(role_id)):
return f"{role.name} or higher" if or_higher else role.name
# Dictionary of permission levels with the level as the key and the name as the value
dictionary = {
default_names = {
0: "Member",
1: "Support",
2: "Junior Moderator",
@ -172,9 +225,35 @@ async def level_to_name(
9: "Bot Owner",
}
# Return the name of the permission level from the dictionary
# or the name of the permission level with "or higher" appended if or_higher is True
return f"{dictionary[level]} or higher" if or_higher else dictionary[level]
return f"{default_names[level]} or higher" if or_higher else default_names[level]
async def get_role_name_from_source(
source: Any,
level: int,
) -> str | None:
"""
Get the name of the role for the given level from the source.
Parameters
----------
source : Any
The source of the command.
level : int
The permission level.
Returns
-------
str | None
The name of the role for the given level.
"""
role_id = await db.get_perm_level_role(source.guild.id, f"perm_level_{level}_role_id")
if role_id and (role := source.guild.get_role(role_id)):
return role.name
return None
async def get_perm_level_role_id(
@ -182,47 +261,28 @@ async def get_perm_level_role_id(
level: str,
) -> int | None:
"""
Get the role ID for a permission level.
Get the role ID for the given permission level.
Parameters
----------
source : commands.Context[commands.Bot] | discord.Interaction
The source object for the command.
The source of the command.
level : str
The permission level to get the role ID for.
The permission level.
Returns
-------
int | None
The role ID for the permission level or None if it does not exist.
The role ID for the given permission level or None.
"""
# Initialize the role ID to None to avoid type errors
role_id = None
try:
# Check if the source is a context object and if the guild is not None
if isinstance(source, commands.Context):
ctx = source
if ctx.guild is None:
return None
# Get the role ID from the database for the guild and the role field
role_id = await db.get_perm_level_role(ctx.guild.id, level)
else:
# Get the interaction object and check if it exists and is in a guild
interaction = source
if not interaction or not interaction.guild:
return None
# Get the role ID from the database for the guild and the role field
role_id = await db.get_perm_level_role(interaction.guild.id, level)
guild = source.guild
return await db.get_perm_level_role(guild.id, level) if guild else None
except Exception as e:
logger.error(f"Error retrieving role ID for level {level}: {e}")
return role_id
return None
async def get_perm_level_roles(
@ -230,23 +290,22 @@ async def get_perm_level_roles(
lower_bound: int,
) -> list[int] | None:
"""
Get the role IDs for a range of permission levels.
Get the role IDs for the given permission levels.
Parameters
----------
source : commands.Context[commands.Bot] | discord.Interaction
The source object for the command.
The source of the command.
lower_bound : int
The lower bound of the permission level.
Returns
-------
list[int] | None
The list of role IDs for the permission levels or None if they do not exist.
The role IDs for the given permission levels or None.
"""
# Dictionary of permission level roles with the level as the key and the field name as the value
perm_level_roles: dict[int, str] = {
perm_level_roles = {
0: "perm_level_0_role_id",
1: "perm_level_1_role_id",
2: "perm_level_2_role_id",
@ -257,29 +316,21 @@ async def get_perm_level_roles(
7: "perm_level_7_role_id",
}
# Initialize the list of role IDs to an empty list to avoid type errors
role_ids: list[int] = []
role_ids: list[Any] = []
try:
# For each level in the range of the lower bound to 8
for level in range(lower_bound, 8):
# If the role field exists, get the role ID by the field name (e.g. perm_level_1_role_id)
if role_field := perm_level_roles.get(level):
# Get the role ID from the database for the guild and the role field
role_id = await db.get_guild_config_field_value(source.guild.id, role_field) # type: ignore
# If the role ID exists, append it to the list of role IDs
if role_id:
role_ids.append(role_id)
else:
logger.debug(f"No role ID found for {role_field}, skipping")
# Catch any exceptions that occur while getting the role IDs
except KeyError as e:
logger.error(f"Key error when accessing role field: {e}")
except AttributeError as e:
logger.error(f"Attribute error, likely due to accessing a wrong attribute: {e}")
except Exception as e:
logger.error(f"General error getting perm level roles: {e}")
logger.error(f"Error getting perm level roles: {e}")
return None
return role_ids
@ -287,52 +338,26 @@ async def get_perm_level_roles(
def has_pl(level: int, or_higher: bool = True):
"""
Check if a user has a permission level via a decorator for prefix and hybrid commands.
Check if the source has the required permission level. This is a decorator for traditional "prefix" commands.
Parameters
----------
level : int
The permission level to check.
The permission level required.
or_higher : bool, optional
Whether the user should have the permission level or higher, by default True.
Returns
-------
commands.check
The check for the permission level
Whether to include "or higher" in the name, by default True.
"""
async def predicate(ctx: commands.Context[commands.Bot] | discord.Interaction) -> bool:
"""
Check if the user has the permission level.
Parameters
----------
ctx : commands.Context[commands.Bot] | discord.Interaction
The context or interaction object for the command.
Returns
-------
bool
Whether the user has the permission level.
Raises
------
PermissionLevelError
If the user does not have the permission level.
"""
if isinstance(ctx, discord.Interaction):
logger.error("Incorrect checks decorator used. Please use ac_has_pl instead.")
msg = "Incorrect checks decorator used. Please use ac_has_pl instead and report this as a issue."
raise PermissionLevelError(msg)
if not await has_permission(ctx, level, 9 if or_higher else None):
logger.error(
logger.info(
f"{ctx.author} tried to run a command without perms. Command: {ctx.command}, Perm Level: {level} or higher: {or_higher}",
)
raise PermissionLevelError(await level_to_name(ctx, level, or_higher))
logger.info(
@ -346,52 +371,26 @@ def has_pl(level: int, or_higher: bool = True):
def ac_has_pl(level: int, or_higher: bool = True):
"""
Check if a user has a permission level via a decorator for app commands.
Check if the source has the required permission level. This is a decorator for application "slash" commands.
Parameters
----------
level : int
The permission level to check.
The permission level required.
or_higher : bool, optional
Whether the user should have the permission level or higher, by default True.
Returns
-------
app_commands.check
The check for the permission level
Whether to include "or higher" in the name, by default True.
"""
async def predicate(ctx: commands.Context[commands.Bot] | discord.Interaction) -> bool:
"""
Check if the user has the permission level.
Parameters
----------
ctx : commands.Context[commands.Bot] | discord.Interaction
The context or interaction object for the command.
Returns
-------
bool
Whether the user has the permission level.
Raises
------
AppCommandPermissionLevelError
If the user does not have the permission level.
"""
if isinstance(ctx, commands.Context):
logger.error("Incorrect checks decorator used. Please use has_pl instead.")
msg = "Incorrect checks decorator used. Please use has_pl instead and report this as a issue."
raise AppCommandPermissionLevelError(msg)
if not await has_permission(ctx, level, 9 if or_higher else None):
logger.error(
logger.info(
f"{ctx.user} tried to run a command without perms. Command: {ctx.command}, Perm Level: {level} or higher: {or_higher}",
)
raise AppCommandPermissionLevelError(await level_to_name(ctx, level, or_higher))
logger.info(

View file

@ -1,15 +1,15 @@
import base64
import json
import os
from pathlib import Path
from typing import Final
import yaml
from dotenv import load_dotenv, set_key
load_dotenv(verbose=True)
config_file = Path("config/settings.json")
config = json.loads(config_file.read_text())
config_file = Path("config/settings.yml")
config = yaml.safe_load(config_file.read_text())
class Constants:
@ -29,7 +29,7 @@ class Constants:
DEV_COG_IGNORE_LIST: Final[set[str]] = set(os.getenv("DEV_COG_IGNORE_LIST", "").split(","))
# Debug env constants
DEBUG: Final[bool] = bool(os.getenv("DEBUG", True))
DEBUG: Final[bool] = bool(os.getenv("DEBUG", "True"))
# Final env constants
TOKEN: Final[str] = DEV_TOKEN if DEV and DEV.lower() == "true" else PROD_TOKEN
@ -37,7 +37,7 @@ class Constants:
COG_IGNORE_LIST: Final[set[str]] = DEV_COG_IGNORE_LIST if DEV and DEV.lower() == "true" else PROD_COG_IGNORE_LIST
# Sentry-related constants
SENTRY_URL: Final[str | None] = os.getenv("SENTRY_URL")
SENTRY_URL: Final[str | None] = os.getenv("SENTRY_URL", "")
# Database constants
PROD_DATABASE_URL: Final[str] = os.getenv("PROD_DATABASE_URL", "")
@ -52,26 +52,21 @@ class Constants:
GITHUB_REPO_OWNER: Final[str] = os.getenv("GITHUB_REPO_OWNER", "")
GITHUB_REPO: Final[str] = os.getenv("GITHUB_REPO", "")
GITHUB_TOKEN: Final[str] = os.getenv("GITHUB_TOKEN", "")
GITHUB_APP_ID: Final[int] = int(os.getenv("GITHUB_APP_ID", 0))
GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET")
GITHUB_PUBLIC_KEY = os.getenv("GITHUB_PUBLIC_KEY")
GITHUB_INSTALLATION_ID: Final[int] = int(os.getenv("GITHUB_INSTALLATION_ID", 0))
GITHUB_PRIVATE_KEY: str = base64.b64decode(os.getenv("GITHUB_PRIVATE_KEY_BASE64", "")).decode(
"utf-8",
GITHUB_APP_ID: Final[int] = int(os.getenv("GITHUB_APP_ID", "0"))
GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID", "")
GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET", "")
GITHUB_PUBLIC_KEY = os.getenv("GITHUB_PUBLIC_KEY", "")
GITHUB_INSTALLATION_ID: Final[str] = os.getenv("GITHUB_INSTALLATION_ID", "0")
GITHUB_PRIVATE_KEY: str = (
base64.b64decode(os.getenv("GITHUB_PRIVATE_KEY_BASE64", "")).decode("utf-8")
if os.getenv("GITHUB_PRIVATE_KEY_BASE64")
else ""
)
# Mailcow constants
MAILCOW_API_KEY: Final[str] = os.getenv("MAILCOW_API_KEY", "")
MAILCOW_API_URL: Final[str] = os.getenv("MAILCOW_API_URL", "")
# Channel constants
LOG_CHANNELS: Final[dict[str, int]] = config["LOG_CHANNELS"].copy()
if DEV and DEV.lower() == "true":
for key in LOG_CHANNELS:
LOG_CHANNELS[key] = LOG_CHANNELS["DEV"]
# Temp VC constants
TEMPVC_CATEGORY_ID: Final[str | None] = config["TEMPVC_CATEGORY_ID"]
TEMPVC_CHANNEL_ID: Final[str | None] = config["TEMPVC_CHANNEL_ID"]

14
tux/utils/converters.py Normal file
View file

@ -0,0 +1,14 @@
from typing import Any
from discord.ext import commands
from prisma.enums import CaseType
class CaseTypeConverter(commands.Converter[CaseType]):
async def convert(self, ctx: commands.Context[Any], argument: str) -> CaseType:
try:
return CaseType[argument.upper()]
except KeyError as e:
msg = f"Invalid CaseType: {argument}"
raise commands.BadArgument(msg) from e

14
tux/utils/exceptions.py Normal file
View file

@ -0,0 +1,14 @@
from discord import app_commands
from discord.ext import commands
class PermissionLevelError(commands.CheckFailure):
def __init__(self, permission: str) -> None:
self.permission = permission
super().__init__(f"User does not have the required permission: {permission}")
class AppCommandPermissionLevelError(app_commands.CheckFailure):
def __init__(self, permission: str) -> None:
self.permission = permission
super().__init__(f"User does not have the required permission: {permission}")

View file

@ -3,18 +3,19 @@ from discord.ext import commands
from discord.utils import MISSING
from prisma.enums import CaseType
from tux.utils.converters import CaseTypeConverter
class BanFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
class BanFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"):
reason: str = commands.flag(
name="reason",
description="The reason for the member ban.",
description="The reason for the ban.",
aliases=["r"],
default=MISSING,
)
purge_days: int = commands.flag(
name="purge_days",
description="Number of days in messages",
description="The number of days (< 7) to purge in messages.",
aliases=["p", "purge"],
default=0,
)
@ -26,17 +27,17 @@ class BanFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
)
class TempBanFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
class TempBanFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"):
reason: str = commands.flag(
name="reason",
description="The reason for the member temp ban.",
description="The reason for the temp ban.",
aliases=["r"],
default=MISSING,
)
expires_at: int = commands.flag(
name="expires_at",
description="The time in days the ban will last for.",
aliases=["t", "d", "e"],
aliases=["t", "d", "e", "duration", "expires", "time"],
)
purge_days: int = commands.flag(
name="purge_days",
@ -44,12 +45,18 @@ class TempBanFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
aliases=["p"],
default=0,
)
silent: bool = commands.flag(
name="silent",
description="Do not send a DM to the target.",
aliases=["s", "quiet"],
default=False,
)
class KickFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
class KickFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"):
reason: str = commands.flag(
name="reason",
description="The reason for the member kick.",
description="The reason for the kick.",
aliases=["r"],
default=MISSING,
)
@ -61,16 +68,16 @@ class KickFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
)
class TimeoutFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
class TimeoutFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"):
duration: str = commands.flag(
name="duration",
description="The duration of the timeout.",
description="The duration of the timeout. (e.g. 1d, 1h, 1m)",
aliases=["d"],
default=MISSING,
)
reason: str = commands.flag(
name="reason",
description="The reason for the member ban. (e.g. 1d, 1h, 1m)",
description="The reason for the timeout.",
aliases=["r"],
default=MISSING,
)
@ -82,10 +89,10 @@ class TimeoutFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
)
class UntimeoutFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
class UntimeoutFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"):
reason: str = commands.flag(
name="reason",
description="The reason for the member ban.",
description="The reason for the untimeout.",
aliases=["r"],
default=MISSING,
)
@ -97,26 +104,26 @@ class UntimeoutFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
)
class UnbanFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
class UnbanFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"):
username_or_id: str = commands.flag(
name="username_or_id",
description="The username or ID of the user to ban.",
description="The username or ID of the user.",
aliases=["u"],
default=MISSING,
positional=True,
)
reason: str = commands.flag(
name="reason",
description="The reason for the member ban.",
description="The reason for the unban.",
aliases=["r"],
default=MISSING,
)
class JailFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
class JailFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"):
reason: str = commands.flag(
name="reason",
description="The reason for the member jail.",
description="The reason for the jail.",
aliases=["r"],
default=MISSING,
)
@ -128,10 +135,10 @@ class JailFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
)
class UnjailFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
class UnjailFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"):
reason: str = commands.flag(
name="reason",
description="The reason for the member unjail.",
description="The reason for the unjail.",
aliases=["r"],
default=MISSING,
)
@ -143,20 +150,21 @@ class UnjailFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
)
class CasesViewFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
class CasesViewFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"):
type: CaseType = commands.flag(
name="case_type",
description="The case type to view.",
aliases=["t"],
default=None,
converter=CaseTypeConverter,
)
target: discord.Member = commands.flag(
target: discord.User = commands.flag(
name="case_target",
description="The member to view cases for.",
aliases=["memb", "m", "user", "u"],
description="The user to view cases for.",
aliases=["user", "u", "member", "memb", "m"],
default=None,
)
moderator: discord.Member = commands.flag(
moderator: discord.User = commands.flag(
name="case_moderator",
description="The moderator to view cases for.",
aliases=["mod"],
@ -164,7 +172,7 @@ class CasesViewFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
)
class CaseModifyFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
class CaseModifyFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"):
status: bool | None = commands.flag(
name="case_status",
description="The status of the case.",
@ -177,10 +185,40 @@ class CaseModifyFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
)
class WarnFlags(commands.FlagConverter, delimiter=" ", prefix="-"):
class WarnFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"):
reason: str = commands.flag(
name="reason",
description="The reason for the member warn.",
description="The reason for the warn.",
aliases=["r"],
default=MISSING,
)
silent: bool = commands.flag(
name="silent",
description="Do not send a DM to the target.",
aliases=["s", "quiet"],
default=False,
)
class SnippetBanFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"):
reason: str = commands.flag(
name="reason",
description="The reason for the snippet ban.",
aliases=["r"],
default=MISSING,
)
silent: bool = commands.flag(
name="silent",
description="Do not send a DM to the target.",
aliases=["s", "quiet"],
default=False,
)
class SnippetUnbanFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"):
reason: str = commands.flag(
name="reason",
description="The reason for the snippet unban.",
aliases=["r"],
default=MISSING,
)

View file

@ -17,7 +17,7 @@ class GithubService:
AppInstallationAuthStrategy(
CONST.GITHUB_APP_ID,
CONST.GITHUB_PRIVATE_KEY,
CONST.GITHUB_INSTALLATION_ID,
int(CONST.GITHUB_INSTALLATION_ID),
CONST.GITHUB_CLIENT_ID,
CONST.GITHUB_CLIENT_SECRET,
),