├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── cog-enhancement.md │ └── new-bot-cog.md ├── actions │ ├── check-json │ │ ├── action.yml │ │ ├── cog.json │ │ ├── json_checker.py │ │ └── repo.json │ └── setup │ │ ├── action.yml │ │ └── compile_requirements.py ├── pull_request_template.md └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── LabBot.png ├── README.md ├── autoreact ├── __init__.py ├── autoreact.py └── info.json ├── autoreply ├── __init__.py ├── autoreply.py └── info.json ├── bancount ├── __init__.py ├── bancount.py └── info.json ├── betterping ├── __init__.py ├── betterping.py └── info.json ├── convert ├── __init__.py ├── convert.py └── info.json ├── create_dev_bot.sh ├── custom_msg ├── __init__.py ├── custom_msg.py ├── info.json └── interactive_session.py ├── enforcer ├── __init__.py ├── enforcer.py └── info.json ├── feed ├── __init__.py ├── feed.py └── info.json ├── google ├── __init__.py ├── google.py └── info.json ├── guild_profiles ├── __init__.py ├── guild_profiles.py └── info.json ├── info.json ├── isitreadonlyfriday ├── __init__.py ├── info.json └── isitreadonlyfriday.py ├── jail ├── __init__.py ├── abstracts.py ├── info.json ├── jail.py └── utils.py ├── latex ├── __init__.py ├── info.json └── latex.py ├── letters ├── __init__.py ├── info.json └── letters.py ├── markov ├── __init__.py ├── info.json ├── markov.py └── notice.txt ├── notes ├── __init__.py ├── abstracts.py ├── info.json ├── notes.py └── utils.py ├── onboarding_role ├── __init__.py ├── info.json └── onboarding_role.py ├── penis ├── __init__.py ├── info.json └── penis.py ├── phishingdetection ├── __init__.py ├── info.json └── phishingdetection.py ├── prometheus_exporter ├── __init__.py ├── info.json ├── main.py ├── prom_server.py ├── stats.py └── utils.py ├── purge ├── __init__.py ├── info.json └── purge.py ├── pyproject.toml ├── quotes ├── __init__.py ├── info.json └── quotes.py ├── reactrole ├── __init__.py ├── info.json └── reactrole.py ├── report ├── __init__.py ├── info.json └── report.py ├── requirements-dev.txt ├── requirements.txt ├── role_welcome ├── __init__.py ├── info.json └── role_welcome.py ├── roleinfo ├── __init__.py ├── info.json └── roleinfo.py ├── sentry ├── __init__.py ├── info.json └── sentry.py ├── tags ├── __init__.py ├── abstracts.py ├── info.json ├── tags.py └── utils.py ├── tests ├── __init__.py └── test_phishingdetection.py ├── timeout ├── __init__.py ├── info.json ├── notice.txt └── timeout.py ├── topic ├── __init__.py ├── info.json └── topic.py ├── verify ├── __init__.py ├── info.json └── verify.py └── xkcd ├── __init__.py ├── info.json └── xkcd.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report. 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Run `[p]command example` 16 | 2. See error 17 | 18 | **Expected behavior** 19 | 20 | 21 | **Screenshots** 22 | 23 | 24 | **Additional context** 25 | 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/cog-enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Cog Enhancement 3 | about: Suggest an enhancement for an existing cog. 4 | title: "[FEAT] " 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | # Overview 10 | ## Describe the solution you'd like 11 | 12 | ## Is this suggestion based on an existing problem? If so, please describe it 13 | 14 | # DoD 15 | 16 | - [ ] 17 | 18 | # Stretch 19 | 20 | - [ ] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new-bot-cog.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New Bot Cog 3 | about: Suggest a new feature/cog for the bot. 4 | title: "[FEAT] " 5 | labels: new-cog 6 | assignees: '' 7 | 8 | --- 9 | 10 | # Overview 11 | 12 | # Commands 13 | ``` 14 | [p]suggested commands and usages 15 | ``` 16 | 17 | # DoD 18 | 19 | - [ ] 20 | 21 | # Stretch 22 | 23 | - [ ] -------------------------------------------------------------------------------- /.github/actions/check-json/action.yml: -------------------------------------------------------------------------------- 1 | name: Check JSON 2 | description: Checks the repo and cog info.json files against the relevant schemas 3 | runs: 4 | using: composite 5 | steps: 6 | - name: Run JSON checker script 7 | shell: bash 8 | run: python3 ./.github/actions/check-json/json_checker.py 9 | -------------------------------------------------------------------------------- /.github/actions/check-json/cog.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "https://raw.githubusercontent.com/Cog-Creators/Red-DiscordBot/V3/develop/schema/red_cog.schema.json", 3 | "$schema": "http://json-schema.org/draft-07/schema#", 4 | "title": "Red-DiscordBot Сog metadata file", 5 | "type": "object", 6 | "properties": { 7 | "author": { 8 | "type": "array", 9 | "description": "List of names of authors of the cog", 10 | "items": { 11 | "type": "string" 12 | } 13 | }, 14 | "name": { 15 | "type": "string", 16 | "description": "The name of the cog" 17 | }, 18 | "description": { 19 | "type": "string", 20 | "description": "A long description of the cog or repo. For cogs, this is displayed when a user executes [p]cog info." 21 | }, 22 | "install_msg": { 23 | "type": "string", 24 | "description": "The message that gets displayed when a cog is installed or a repo is added" 25 | }, 26 | "short": { 27 | "type": "string", 28 | "description": "A short description of the cog or repo. For cogs, this info is displayed when a user executes [p]cog list" 29 | }, 30 | "end_user_data_statement": { 31 | "type": "string", 32 | "description": "A statement explaining what end user data the cog is storing. This is displayed when a user executes [p]cog info. If the statement has changed since last update, user will be informed during the update." 33 | }, 34 | "min_bot_version": { 35 | "type": "string", 36 | "description": "Min version number of Red in the format MAJOR.MINOR.MICRO", 37 | "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)((a|b|rc)(0|[1-9][0-9]*))?(\\.post(0|[1-9][0-9]*))?(\\.dev(0|[1-9][0-9]*))?$" 38 | }, 39 | "max_bot_version": { 40 | "type": "string", 41 | "description": "Max version number of Red in the format MAJOR.MINOR.MICRO, if min_bot_version is newer than max_bot_version, max_bot_version will be ignored", 42 | "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)((a|b|rc)(0|[1-9][0-9]*))?(\\.post(0|[1-9][0-9]*))?(\\.dev(0|[1-9][0-9]*))?$" 43 | }, 44 | "min_python_version": { 45 | "type": "array", 46 | "description": "Min version number of Python in the format [MAJOR, MINOR, PATCH]", 47 | "minItems": 3, 48 | "maxItems": 3, 49 | "items": { 50 | "type": "integer" 51 | } 52 | }, 53 | "hidden": { 54 | "type": "boolean", 55 | "description": "Determines if a cog is visible in the cog list for a repo." 56 | }, 57 | "disabled": { 58 | "type": "boolean", 59 | "description": "Determines if a cog is available for install." 60 | }, 61 | "required_cogs": { 62 | "type": "object", 63 | "description": "A dict of required cogs that this cog depends on in the format {cog_name : repo_url}. Downloader will not deal with this functionality but it may be useful for other cogs.", 64 | "$ref": "#/definitions/required_cog" 65 | }, 66 | "requirements": { 67 | "type": "array", 68 | "description": "List of required libraries that are passed to pip on cog install.", 69 | "items": { 70 | "type": "string" 71 | } 72 | }, 73 | "tags": { 74 | "type": "array", 75 | "description": "A list of strings that are related to the functionality of the cog. Used to aid in searching.", 76 | "uniqueItems": true, 77 | "items": { 78 | "type": "string" 79 | } 80 | }, 81 | "type": { 82 | "type": "string", 83 | "description": "Optional, defaults to COG. Must be either COG or SHARED_LIBRARY. If SHARED_LIBRARY then hidden will be True.", 84 | "enum": [ 85 | "COG", 86 | "SHARED_LIBRARY" 87 | ] 88 | } 89 | }, 90 | "definitions": { 91 | "required_cog": { 92 | "type": "object", 93 | "patternProperties": { 94 | ".+": { 95 | "type": "string", 96 | "format": "uri" 97 | } 98 | }, 99 | "additionalProperties": false 100 | } 101 | }, 102 | "additionalProperties": false 103 | } 104 | -------------------------------------------------------------------------------- /.github/actions/check-json/json_checker.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import sys 4 | from glob import glob 5 | from typing import List, Tuple, Union 6 | 7 | import fastjsonschema 8 | 9 | 10 | def format_output(*, level: str, file: str, line: int, col: int, message: str) -> str: 11 | return "::{level} file={file},line={line},col={col}::{message}".format( 12 | level=level, file=file, line=line, col=col, message=message 13 | ) 14 | 15 | 16 | def get_json(filename: str) -> Union[dict, list]: 17 | """Returns an interpreted json file from a filepath""" 18 | with open(filename, "r") as f: 19 | return json.load(f) 20 | 21 | 22 | def validate(schema_name: str, filename: str) -> bool: 23 | """Validates a json file based on a schema""" 24 | try: 25 | schema = get_json(schema_name) 26 | json_file = get_json(filename) 27 | fastjsonschema.validate(schema, json_file) 28 | return True 29 | except fastjsonschema.exceptions.JsonSchemaValueException as error: 30 | print(error) 31 | 32 | if error.rule == "additionalProperties" and not error.rule_definition: 33 | error_keys, msg_bounds = list_from_str(error.message) 34 | for key in error_keys: 35 | line, col = get_key_pos(filename, key) 36 | message = f"{error.message[: msg_bounds[0] + 1]}{key}{error.message[msg_bounds[1] - 1 :]}" 37 | print(format_output(level="error", file=filename, line=line, col=col, message=message)) 38 | else: 39 | key_name = error.path[1] 40 | line, col = get_key_pos(filename, key_name) 41 | print(format_output(level="warning", file=filename, line=line, col=col, message=error.message)) 42 | return False 43 | 44 | 45 | def get_key_pos(filename: str, key: str) -> Tuple[int, int]: 46 | """Returns the position of a key in a json file""" 47 | escaped_key = re.escape(key) 48 | reg_match = re.compile(f'"{escaped_key}"\\s?:') 49 | with open(filename, "r") as f: 50 | lines = f.read().split("\n") 51 | for i, line in enumerate(lines, start=1): 52 | match = reg_match.search(line) 53 | if not match: 54 | continue 55 | span = match.span() 56 | return i, span[0] + 1 57 | raise Exception(f"could not get position of key: {key}") 58 | 59 | 60 | def list_from_str(set_str: str) -> Tuple[List[str], Tuple[int, int]]: 61 | """Returns a list from a string representation of a list""" 62 | list_reg = re.compile("^.*{(.*)}.*$") 63 | match = list_reg.match(set_str) 64 | if not match: 65 | raise Exception(f"Failed to parse set from string {set_str}") 66 | to_list = "[" + match.group(1).replace('"', '\\"').replace("'", '"') + "]" 67 | return json.loads(to_list), match.regs[1] 68 | 69 | 70 | def main() -> int: 71 | validation_success: List[bool] = [] 72 | for file_pattern, schema_path in { 73 | "info.json": ".github/actions/check-json/repo.json", 74 | "*/info.json": ".github/actions/check-json/cog.json", 75 | }.items(): 76 | for filename in glob(file_pattern): 77 | validation_success.append(validate(schema_path, filename)) 78 | 79 | return int(not all(validation_success)) 80 | 81 | 82 | if __name__ == "__main__": 83 | exit_code = main() 84 | sys.exit(exit_code) 85 | -------------------------------------------------------------------------------- /.github/actions/check-json/repo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "https://raw.githubusercontent.com/Cog-Creators/Red-DiscordBot/V3/develop/schema/red_cog_repo.schema.json", 3 | "$schema": "http://json-schema.org/draft-07/schema#", 4 | "title": "Red-DiscordBot Сog Repo metadata file", 5 | "type": "object", 6 | "properties": { 7 | "author": { 8 | "type": "array", 9 | "description": "List of names of authors of the cog", 10 | "items": { 11 | "type": "string" 12 | } 13 | }, 14 | "description": { 15 | "type": "string", 16 | "description": "A long description of the cog or repo. For cogs, this is displayed when a user executes [p]cog info." 17 | }, 18 | "install_msg": { 19 | "type": "string", 20 | "description": "The message that gets displayed when a cog is installed or a repo is added" 21 | }, 22 | "short": { 23 | "type": "string", 24 | "description": "A short description of the cog or repo. For cogs, this info is displayed when a user executes [p]cog list" 25 | } 26 | }, 27 | "additionalProperties": false 28 | } 29 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Install dependencies 2 | description: Installs project dependencies 3 | runs: 4 | using: composite 5 | steps: 6 | - name: Set up Python 7 | uses: actions/setup-python@v5 8 | with: 9 | python-version: "3.11" 10 | - name: Install base dependencies 11 | shell: bash 12 | run: pip install --quiet --upgrade --requirement requirements.txt 13 | - name: Install CI dependencies 14 | shell: bash 15 | run: pip install --quiet --upgrade --requirement requirements-dev.txt 16 | - name: Install cog dependencies 17 | shell: bash 18 | run: | 19 | python3 .github/actions/setup/compile_requirements.py 20 | pip install --quiet --upgrade --requirement requirements-cogs.txt 21 | -------------------------------------------------------------------------------- /.github/actions/setup/compile_requirements.py: -------------------------------------------------------------------------------- 1 | """Pipeline script for extracting imports from cogs""" 2 | 3 | import json 4 | from glob import glob 5 | from typing import Set 6 | 7 | 8 | def fetch_requirements() -> Set[str]: 9 | requirements = set() 10 | 11 | for filename in glob("*/info.json"): 12 | with open(filename, "r") as fp: 13 | info = json.load(fp) 14 | if "requirements" in info: 15 | requirements.update(info["requirements"]) 16 | 17 | return requirements 18 | 19 | 20 | def write_requirements(requirements: Set[str]): 21 | with open("requirements-cogs.txt", "a") as fp: 22 | fp.write("\n".join(requirements)) 23 | 24 | 25 | if __name__ == "__main__": 26 | all_requirements = fetch_requirements() 27 | write_requirements(all_requirements) 28 | print("Compiled requirements") 29 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Initial Checklist 2 | - [ ] Has @tigattack been added as a reviewer? 3 | - [ ] If applicable, have the relevant project(s), milestone(s), and label(s) been applied? 4 | - [ ] If applicable, have you added details of the cog to the readme as per [README.md](https://github.com/rHomelab/LabBot-Cogs/blob/main/README.md#cog-summaries)? 5 | 6 | 7 | 8 | # Details 9 | **Does this resolve an issue?** 10 | Resolves # 11 | 12 | ## Changes 13 | ### Features / Fixes 14 | * Adds feature/fix which does xyz. 15 | 16 | ### Breaking Changes 17 | * Adds breaking change which causes \. 18 | 19 | ## Additional 20 | Any further notes or comments you want to make. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | ruff: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout the repository at the current branch 13 | uses: actions/checkout@v4 14 | - name: Install dependencies 15 | uses: ./.github/actions/setup 16 | - uses: astral-sh/ruff-action@v3 17 | with: 18 | version-file: "./requirements-dev.txt" 19 | 20 | pyright: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout the repository at the current branch 24 | uses: actions/checkout@v4 25 | - name: Install dependencies 26 | uses: ./.github/actions/setup 27 | - uses: jakebailey/pyright-action@v2 28 | with: 29 | python-version: "3.11" 30 | 31 | check-json: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Checkout the repository at the current branch 35 | uses: actions/checkout@v4 36 | - name: Install dependencies 37 | uses: ./.github/actions/setup 38 | - name: Check cog and repo JSON files against schema 39 | uses: ./.github/actions/check-json 40 | 41 | unit-tests: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Checkout the repository at the current branch 45 | uses: actions/checkout@v4 46 | - name: Install dependencies 47 | uses: ./.github/actions/setup 48 | - name: Run unit tests 49 | run: python3 -m pytest . 50 | 51 | fixmes: 52 | name: FIXME check 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: actions/checkout@v4 56 | - uses: rippleFCL/action-fixme-annotate@v0.1.0 57 | with: 58 | terms: 'WIP|FIXME' 59 | case-sensitive: false 60 | severity: "WARNING" 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python,jupyternotebooks,vscode,pycharm+all,sublimetext 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,jupyternotebooks,vscode,pycharm+all,sublimetext 4 | 5 | ### JupyterNotebooks ### 6 | # gitignore template for Jupyter Notebooks 7 | # website: http://jupyter.org/ 8 | 9 | .ipynb_checkpoints 10 | */.ipynb_checkpoints/* 11 | 12 | # IPython 13 | profile_default/ 14 | ipython_config.py 15 | 16 | # Remove previous ipynb_checkpoints 17 | # git rm -r .ipynb_checkpoints/ 18 | 19 | ### PyCharm+all ### 20 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 21 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 22 | 23 | # User-specific stuff 24 | .idea/**/workspace.xml 25 | .idea/**/tasks.xml 26 | .idea/**/usage.statistics.xml 27 | .idea/**/dictionaries 28 | .idea/**/shelf 29 | 30 | # Generated files 31 | .idea/**/contentModel.xml 32 | 33 | # Sensitive or high-churn files 34 | .idea/**/dataSources/ 35 | .idea/**/dataSources.ids 36 | .idea/**/dataSources.local.xml 37 | .idea/**/sqlDataSources.xml 38 | .idea/**/dynamic.xml 39 | .idea/**/uiDesigner.xml 40 | .idea/**/dbnavigator.xml 41 | 42 | # Gradle 43 | .idea/**/gradle.xml 44 | .idea/**/libraries 45 | 46 | # Gradle and Maven with auto-import 47 | # When using Gradle or Maven with auto-import, you should exclude module files, 48 | # since they will be recreated, and may cause churn. Uncomment if using 49 | # auto-import. 50 | # .idea/artifacts 51 | # .idea/compiler.xml 52 | # .idea/jarRepositories.xml 53 | # .idea/modules.xml 54 | # .idea/*.iml 55 | # .idea/modules 56 | # *.iml 57 | # *.ipr 58 | 59 | # CMake 60 | cmake-build-*/ 61 | 62 | # Mongo Explorer plugin 63 | .idea/**/mongoSettings.xml 64 | 65 | # File-based project format 66 | *.iws 67 | 68 | # IntelliJ 69 | out/ 70 | 71 | # mpeltonen/sbt-idea plugin 72 | .idea_modules/ 73 | 74 | # JIRA plugin 75 | atlassian-ide-plugin.xml 76 | 77 | # Cursive Clojure plugin 78 | .idea/replstate.xml 79 | 80 | # Crashlytics plugin (for Android Studio and IntelliJ) 81 | com_crashlytics_export_strings.xml 82 | crashlytics.properties 83 | crashlytics-build.properties 84 | fabric.properties 85 | 86 | # Editor-based Rest Client 87 | .idea/httpRequests 88 | 89 | # Android studio 3.1+ serialized cache file 90 | .idea/caches/build_file_checksums.ser 91 | 92 | ### PyCharm+all Patch ### 93 | # Ignores the whole .idea folder and all .iml files 94 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 95 | 96 | .idea/ 97 | 98 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 99 | 100 | *.iml 101 | modules.xml 102 | .idea/misc.xml 103 | *.ipr 104 | 105 | # Sonarlint plugin 106 | .idea/sonarlint 107 | 108 | ### Python ### 109 | # Byte-compiled / optimized / DLL files 110 | __pycache__/ 111 | *.py[cod] 112 | *$py.class 113 | 114 | # C extensions 115 | *.so 116 | 117 | # Distribution / packaging 118 | .Python 119 | build/ 120 | develop-eggs/ 121 | dist/ 122 | downloads/ 123 | eggs/ 124 | .eggs/ 125 | parts/ 126 | sdist/ 127 | var/ 128 | wheels/ 129 | pip-wheel-metadata/ 130 | share/python-wheels/ 131 | *.egg-info/ 132 | .installed.cfg 133 | *.egg 134 | MANIFEST 135 | 136 | # PyInstaller 137 | # Usually these files are written by a python script from a template 138 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 139 | *.manifest 140 | *.spec 141 | 142 | # Installer logs 143 | pip-log.txt 144 | pip-delete-this-directory.txt 145 | 146 | # Unit test / coverage reports 147 | htmlcov/ 148 | .tox/ 149 | .nox/ 150 | .coverage 151 | .coverage.* 152 | .cache 153 | nosetests.xml 154 | coverage.xml 155 | *.cover 156 | *.py,cover 157 | .hypothesis/ 158 | .pytest_cache/ 159 | pytestdebug.log 160 | 161 | # Translations 162 | *.mo 163 | *.pot 164 | 165 | # Django stuff: 166 | *.log 167 | local_settings.py 168 | db.sqlite3 169 | db.sqlite3-journal 170 | 171 | # Flask stuff: 172 | instance/ 173 | .webassets-cache 174 | 175 | # Scrapy stuff: 176 | .scrapy 177 | 178 | # Sphinx documentation 179 | docs/_build/ 180 | doc/_build/ 181 | 182 | # PyBuilder 183 | target/ 184 | 185 | # Jupyter Notebook 186 | 187 | # IPython 188 | 189 | # pyenv 190 | .python-version 191 | 192 | # pipenv 193 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 194 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 195 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 196 | # install all needed dependencies. 197 | #Pipfile.lock 198 | 199 | # poetry 200 | #poetry.lock 201 | 202 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 203 | __pypackages__/ 204 | 205 | # Celery stuff 206 | celerybeat-schedule 207 | celerybeat.pid 208 | 209 | # SageMath parsed files 210 | *.sage.py 211 | 212 | # Environments 213 | # .env 214 | .env/ 215 | .venv/ 216 | env/ 217 | venv/ 218 | ENV/ 219 | env.bak/ 220 | venv.bak/ 221 | pythonenv* 222 | 223 | # Spyder project settings 224 | .spyderproject 225 | .spyproject 226 | 227 | # Rope project settings 228 | .ropeproject 229 | 230 | # mkdocs documentation 231 | /site 232 | 233 | # mypy 234 | .mypy_cache/ 235 | .dmypy.json 236 | dmypy.json 237 | 238 | # ruff 239 | .ruff_cache 240 | 241 | # Pyre type checker 242 | .pyre/ 243 | 244 | # pytype static type analyzer 245 | .pytype/ 246 | 247 | # operating system-related files 248 | *.DS_Store #file properties cache/storage on macOS 249 | Thumbs.db #thumbnail cache on Windows 250 | 251 | # profiling data 252 | .prof 253 | 254 | 255 | ### SublimeText ### 256 | # Cache files for Sublime Text 257 | *.tmlanguage.cache 258 | *.tmPreferences.cache 259 | *.stTheme.cache 260 | 261 | # Workspace files are user-specific 262 | *.sublime-workspace 263 | 264 | # Project files should be checked into the repository, unless a significant 265 | # proportion of contributors will probably not be using Sublime Text 266 | # *.sublime-project 267 | 268 | # SFTP configuration file 269 | sftp-config.json 270 | 271 | # Package control specific files 272 | Package Control.last-run 273 | Package Control.ca-list 274 | Package Control.ca-bundle 275 | Package Control.system-ca-bundle 276 | Package Control.cache/ 277 | Package Control.ca-certs/ 278 | Package Control.merged-ca-bundle 279 | Package Control.user-ca-bundle 280 | oscrypto-ca-bundle.crt 281 | bh_unicode_properties.cache 282 | 283 | # Sublime-github package stores a github token in this file 284 | # https://packagecontrol.io/packages/sublime-github 285 | GitHub.sublime-settings 286 | 287 | ### vscode ### 288 | .vscode/* 289 | !.vscode/settings.json 290 | !.vscode/tasks.json 291 | !.vscode/launch.json 292 | !.vscode/extensions.json 293 | *.code-workspace 294 | 295 | # End of https://www.toptal.com/developers/gitignore/api/python,jupyternotebooks,vscode,pycharm+all,sublimetext 296 | .red_data 297 | 298 | test.sh 299 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.9.6 5 | hooks: 6 | # Run the linter. 7 | - id: ruff 8 | args: ["--fix", "--exit-non-zero-on-fix"] 9 | # Run the formatter. 10 | - id: ruff-format 11 | 12 | - repo: https://github.com/RobertCraigie/pyright-python 13 | rev: v1.1.393 14 | hooks: 15 | - id: pyright 16 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "ms-python.vscode-pylance", 5 | "MarkLarah.pre-commit-vscode", 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python Debugger: Module", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "module": "redbot", 12 | "args": [ 13 | "--dev", 14 | "--debug", 15 | "RedBot_dev_homelab" 16 | ], 17 | "justMyCode": false 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true 7 | } 8 | -------------------------------------------------------------------------------- /LabBot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rHomelab/LabBot-Cogs/5423048c5975ec808ee1f7ea10c372c7920917d6/LabBot.png -------------------------------------------------------------------------------- /autoreact/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .autoreact import AutoReactCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(AutoReactCog(bot)) 8 | -------------------------------------------------------------------------------- /autoreact/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Issy" 4 | ], 5 | "short": "Auto react to messages", 6 | "description": "Automatically react to messages triggered by certain keywords", 7 | "disabled": false, 8 | "name": "autoreact", 9 | "tags": [ 10 | "reactions", 11 | "fun", 12 | "utility" 13 | ], 14 | "install_msg": "Usage: `[p]autoreact add reaction [emoji] [phrase]`", 15 | "min_bot_version": "3.5.1" 16 | } -------------------------------------------------------------------------------- /autoreply/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .autoreply import AutoReplyCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(AutoReplyCog(bot)) 8 | -------------------------------------------------------------------------------- /autoreply/autoreply.py: -------------------------------------------------------------------------------- 1 | """discord red-bot autoreply""" 2 | 3 | import asyncio 4 | from typing import Optional 5 | 6 | import discord 7 | import discord.utils 8 | from redbot.core import Config, checks, commands 9 | from redbot.core.utils.menus import menu, next_page, prev_page, start_adding_reactions 10 | from redbot.core.utils.predicates import ReactionPredicate 11 | 12 | CUSTOM_CONTROLS = {"⬅️": prev_page, "➡️": next_page} 13 | 14 | EMBED_TRIM_SIZE = 1010 15 | 16 | 17 | class AutoReplyCog(commands.Cog): 18 | """AutoReply Cog""" 19 | 20 | def __init__(self, bot): 21 | self.bot = bot 22 | self.config = Config.get_conf(self, identifier=377212919068229633005) 23 | 24 | default_guild_config = { 25 | "triggers": {}, # trigger: str response 26 | } 27 | 28 | self.config.register_guild(**default_guild_config) 29 | 30 | @commands.Cog.listener() 31 | async def on_message(self, message: discord.Message): 32 | if message.author.bot or not message.guild: 33 | return 34 | 35 | triggers = await self.config.guild(message.guild).triggers() 36 | 37 | for trigger in triggers: 38 | if trigger.lower() == message.content.lower(): 39 | await message.channel.send(triggers[trigger]) 40 | 41 | # Command groups 42 | 43 | @checks.admin() 44 | @commands.group(name="autoreply", pass_context=True) 45 | async def _autoreply(self, ctx): 46 | """Automatically reply to messages matching certain trigger phrases""" 47 | 48 | # Commands 49 | 50 | @_autoreply.command(name="add") # type: ignore 51 | async def _add(self, ctx, trigger: str = "", response: str = ""): 52 | """Add autoreply trigger""" 53 | if not trigger and not response: 54 | message_object = await ctx.send( 55 | "Let's set up an autoreply trigger. Please enter the phrase you want this autoreply to trigger on" 56 | ) 57 | 58 | def reply_check(message): 59 | return message.author == ctx.author and message.channel == ctx.channel 60 | 61 | try: 62 | msg = await self.bot.wait_for("message", check=reply_check, timeout=5 * 60) 63 | except asyncio.TimeoutError: 64 | await message_object.delete() 65 | return 66 | else: 67 | trigger = msg.content 68 | 69 | message_object1 = await ctx.send("Please enter the response for this trigger") 70 | 71 | try: 72 | msg = await self.bot.wait_for("message", check=reply_check, timeout=5 * 60) 73 | except asyncio.TimeoutError: 74 | await message_object1.delete() 75 | await message_object.delete() 76 | return 77 | else: 78 | response = msg.content 79 | 80 | async with self.config.guild(ctx.guild).triggers() as triggers: 81 | triggers[trigger] = response 82 | 83 | await ctx.send("✅ Autoreply trigger successfully added") 84 | 85 | @commands.guild_only() 86 | @_autoreply.command(name="view") # type: ignore 87 | async def _view(self, ctx): 88 | """View the configuration for the autoreply cog""" 89 | triggers = await self.ordered_list_from_config(ctx.guild) 90 | embed_list = [ 91 | await self.make_trigger_embed(ctx, triggers[i], {"current": i + 1, "max": len(triggers)}) 92 | for i in range(len(triggers)) 93 | ] 94 | 95 | if len(embed_list) > 1: 96 | await menu( 97 | ctx, 98 | pages=embed_list, 99 | controls=CUSTOM_CONTROLS, 100 | message=None, 101 | page=0, 102 | timeout=5 * 60, 103 | ) 104 | 105 | elif len(embed_list) == 1: 106 | await ctx.send(embed=embed_list[0]) 107 | 108 | else: 109 | error_embed = await self.make_error_embed(ctx, error_type="NoConfiguration") 110 | await ctx.send(embed=error_embed) 111 | 112 | @commands.guild_only() 113 | @_autoreply.command(name="remove", aliases=["delete"]) # type: ignore 114 | async def _remove(self, ctx, num: int): 115 | """Remove a reaction pair 116 | 117 | Example: 118 | - `[p]autoreply remove ` 119 | To find the index of an autoreply pair do `[p]autoreply view` 120 | """ 121 | items = await self.ordered_list_from_config(ctx.guild) 122 | to_del = items[num - 1] 123 | embed = await self.make_trigger_embed(ctx, to_del) 124 | msg = await ctx.send( 125 | embed=embed, 126 | content="Are you sure you want to remove this autoreply trigger?", 127 | ) 128 | confirmation = await self.get_confirmation(ctx, msg) 129 | if confirmation: 130 | await self.remove_trigger(ctx.guild, to_del["trigger"]) 131 | success_embed = await self.make_removal_success_embed(ctx, to_del) 132 | await ctx.send(embed=success_embed) 133 | 134 | # Helper functions 135 | 136 | async def remove_trigger(self, guild: discord.Guild, trigger: str): 137 | async with self.config.guild(guild).triggers() as triggers: 138 | if trigger in triggers: 139 | del triggers[trigger] 140 | 141 | async def ordered_list_from_config(self, guild): 142 | async with self.config.guild(guild).triggers() as triggers: 143 | return [{"trigger": i, "response": triggers[i]} for i in triggers] 144 | 145 | async def make_error_embed(self, ctx, error_type: str = ""): 146 | error_msgs = {"NoConfiguration": "No configuration has been set for this guild"} 147 | error_embed = discord.Embed( 148 | title="Error", 149 | description=error_msgs[error_type], 150 | colour=await ctx.embed_colour(), 151 | ) 152 | return error_embed 153 | 154 | async def make_removal_success_embed(self, ctx, trigger_dict: dict): 155 | trigger = ( 156 | trigger_dict["trigger"][:EMBED_TRIM_SIZE] 157 | if len(trigger_dict["trigger"]) > EMBED_TRIM_SIZE 158 | else trigger_dict["trigger"] 159 | ) 160 | response = ( 161 | trigger_dict["response"][:EMBED_TRIM_SIZE] 162 | if len(trigger_dict["response"]) > EMBED_TRIM_SIZE 163 | else trigger_dict["response"] 164 | ) 165 | desc = f"**Trigger:**\n{trigger}\n**Response:**\n{response}" 166 | embed = discord.Embed( 167 | title="Autoreply trigger removed", 168 | description=desc, 169 | colour=await ctx.embed_colour(), 170 | ) 171 | return embed 172 | 173 | async def make_trigger_embed(self, ctx, trigger_dict: dict, index=None): 174 | trigger = ( 175 | trigger_dict["trigger"][:EMBED_TRIM_SIZE] 176 | if len(trigger_dict["trigger"]) > EMBED_TRIM_SIZE 177 | else trigger_dict["trigger"] 178 | ) 179 | response = ( 180 | trigger_dict["response"][:EMBED_TRIM_SIZE] 181 | if len(trigger_dict["response"]) > EMBED_TRIM_SIZE 182 | else trigger_dict["response"] 183 | ) 184 | desc = f"**Trigger:**\n{trigger}\n**Response:**\n{response}" 185 | embed = discord.Embed(description=desc, colour=await ctx.embed_colour()) 186 | if index: 187 | embed.set_footer(text=f"{index['current']} of {index['max']}") 188 | return embed 189 | 190 | async def get_confirmation(self, ctx: commands.Context, msg: discord.Message) -> Optional[bool]: 191 | """Get confirmation from user with reactions""" 192 | emojis = ["❌", "✅"] 193 | start_adding_reactions(msg, emojis) 194 | 195 | try: 196 | reaction, _ = await self.bot.wait_for( 197 | "reaction_add", timeout=180.0, check=ReactionPredicate.with_emojis(emojis, msg, ctx.author) 198 | ) 199 | except asyncio.TimeoutError: 200 | await msg.clear_reactions() 201 | return 202 | else: 203 | await msg.clear_reactions() 204 | return bool(emojis.index(reaction.emoji)) 205 | -------------------------------------------------------------------------------- /autoreply/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Issy" 4 | ], 5 | "short": "Auto reply to messages", 6 | "description": "Automatically reply to messages triggered by certain keywords", 7 | "disabled": false, 8 | "name": "autoreply", 9 | "tags": [ 10 | "reply", 11 | "fun", 12 | "utility" 13 | ], 14 | "install_msg": "Usage: `[p]autoreply add`", 15 | "min_bot_version": "3.5.1" 16 | } -------------------------------------------------------------------------------- /bancount/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .bancount import BanCountCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(BanCountCog(bot)) 8 | -------------------------------------------------------------------------------- /bancount/bancount.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import discord.errors 4 | from redbot.core import Config, checks, commands 5 | from redbot.core.bot import Red 6 | from redbot.core.utils.chat_formatting import pagify 7 | from redbot.core.utils.menus import close_menu, menu, next_page, prev_page 8 | 9 | 10 | class BanCountCog(commands.Cog): 11 | """BanCount cog""" 12 | 13 | REPLACER = "$ban" 14 | 15 | def __init__(self, bot: Red, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | self.bot = bot 18 | 19 | default_guild_config = {"messages": ["Total users banned: $ban!"]} 20 | 21 | self.config = Config.get_conf(self, identifier=1289862744207523842001) 22 | self.config.register_guild(**default_guild_config) 23 | 24 | @commands.guild_only() 25 | @commands.group(name="bancount", pass_context=True, invoke_without_command=True) 26 | async def _bancount(self, ctx: commands.GuildContext): 27 | """Displays the total number of users banned.""" 28 | async with self.config.guild(ctx.guild).messages() as messages: 29 | if len(messages) < 1: 30 | await ctx.send("Error: guild has no configured messages. Use `[p]bancount add `.") 31 | return 32 | message = random.choice(messages) 33 | try: 34 | async with ctx.channel.typing(): 35 | message = message.replace(self.REPLACER, str(len([entry async for entry in ctx.guild.bans(limit=None)]))) 36 | except discord.errors.Forbidden: 37 | await ctx.send("I don't have permission to retrieve banned users.") 38 | return 39 | await ctx.send(message) 40 | 41 | @checks.mod() 42 | @_bancount.command(name="add") 43 | async def _bancount_add(self, ctx: commands.GuildContext, *, message: str): 44 | """Add a message to the message list.""" 45 | if self.REPLACER not in message: 46 | await ctx.send(f"You need to include `{self.REPLACER}` in your message so I know where to insert the count!") 47 | return 48 | async with self.config.guild(ctx.guild).messages() as messages: 49 | messages.append(message) 50 | await ctx.send("Message added!") 51 | 52 | @checks.mod() 53 | @_bancount.command(name="list") 54 | async def _bancount_list(self, ctx: commands.GuildContext): 55 | """Lists the message list.""" 56 | async with self.config.guild(ctx.guild).messages() as messages: 57 | # Credit to the Notes cog author(s) for this pagify structure 58 | pages = list(pagify("\n".join(f"`{i}) {message}`" for i, message in enumerate(messages)))) 59 | embed_opts = {"title": "Guild's BanCount Message List", "colour": await ctx.embed_colour()} 60 | embeds = [ 61 | discord.Embed(**embed_opts, description=page).set_footer(text=f"Page {index} of {len(pages)}") 62 | for index, page in enumerate(pages, start=1) 63 | ] 64 | if len(embeds) == 1: 65 | await ctx.send(embed=embeds[0]) 66 | else: 67 | controls = {"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page} 68 | ctx.bot.loop.create_task(menu(ctx=ctx, pages=embeds, controls=controls, timeout=180.0)) 69 | 70 | @checks.mod() 71 | @_bancount.command(name="remove") 72 | async def _bancount_remove(self, ctx: commands.GuildContext, index: int): 73 | """Removes the specified message from the message list.""" 74 | async with self.config.guild(ctx.guild).messages() as messages: 75 | if index >= len(messages): 76 | await ctx.send("Sorry, there isn't a message with that index. Use `[p]bancount list`.") 77 | else: 78 | del messages[index] 79 | await ctx.send("Message deleted!") 80 | -------------------------------------------------------------------------------- /bancount/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "portalBlock" 4 | ], 5 | "short": "Utility for displaying ban count.", 6 | "description": "Randomly selects a message from the guild-generated list to use to display the total ban count.", 7 | "disabled": false, 8 | "name": "bancount", 9 | "tags": [ 10 | "utility", 11 | "fun" 12 | ], 13 | "install_msg": "Usage: `[p]bancount`", 14 | "min_bot_version": "3.5.1" 15 | } -------------------------------------------------------------------------------- /betterping/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .betterping import BetterPing 4 | 5 | 6 | async def setup(bot: Red) -> None: 7 | # Find built-in ping command and replace it 8 | old_ping = bot.get_command("ping") 9 | if old_ping: 10 | bot.remove_command(old_ping.name) 11 | await bot.add_cog(BetterPing(bot, old_ping)) 12 | -------------------------------------------------------------------------------- /betterping/betterping.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from time import monotonic 3 | 4 | from redbot.core import commands 5 | from redbot.core.bot import Red 6 | 7 | 8 | class BetterPing(commands.Cog): 9 | """Upgraded version of the built-in ping command""" 10 | 11 | def __init__(self, bot: Red, old_ping: commands.Command | None): 12 | self.bot = bot 13 | self.old_ping = old_ping 14 | 15 | async def cog_unload(self) -> None: 16 | if self.old_ping: 17 | with contextlib.suppress(Exception): 18 | self.bot.remove_command("ping") 19 | self.bot.add_command(self.old_ping) 20 | 21 | @commands.hybrid_command() 22 | async def ping(self, ctx: commands.Context): 23 | """Ping command with latency information""" 24 | # Thanks Vexed01 https://github.com/Vexed01/Vex-Cogs/blob/9d6dbca/anotherpingcog/anotherpingcog.py#L95-L102 25 | try: 26 | ws_latency = round(self.bot.latency * 1000) 27 | except OverflowError: # ping float is infinity, ie last ping to discord failed 28 | await ctx.send( 29 | "I'm alive and working normally, but I've had connection issues in the last few " 30 | "seconds so precise ping times are unavailable. Try again in a minute.", 31 | ) 32 | return 33 | 34 | msg = f"**Pong!** \N{TABLE TENNIS PADDLE AND BALL}\nDiscord WS latency: {ws_latency} ms" 35 | 36 | start = monotonic() 37 | message = await ctx.send(msg) 38 | end = monotonic() 39 | 40 | m_latency = round((end - start) * 1000) 41 | 42 | new_msg = f"{msg}\nMessage latency: {m_latency} ms" 43 | await message.edit(content=new_msg) 44 | -------------------------------------------------------------------------------- /betterping/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["tigattack"], 3 | "description": "Usage: [p]ping", 4 | "short": "Upgraded version of the built-in ping command", 5 | "name": "Ping", 6 | "tags": [ 7 | "utility" 8 | ], 9 | "min_bot_version": "3.5.0" 10 | } 11 | -------------------------------------------------------------------------------- /convert/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .convert import Convert 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(Convert()) 8 | -------------------------------------------------------------------------------- /convert/convert.py: -------------------------------------------------------------------------------- 1 | import re 2 | import subprocess 3 | 4 | import discord 5 | from redbot.core import commands 6 | 7 | decimal = re.compile(r"\d+") 8 | 9 | 10 | class Convert(commands.Cog): 11 | """Convert related commands.""" 12 | 13 | @commands.command() 14 | async def convert(self, ctx, *, conversion: str): 15 | """Convert from different kinds of units to other units using pint 16 | Example: 17 | - `[p]convert to ` 18 | - `[p]convert 23cm to in` 19 | - `[p]convert 5in + 5ft to cm` 20 | """ 21 | 22 | if " to " not in conversion: 23 | await ctx.send( 24 | f"`{conversion}` is not a valid conversion. Please make sure it is in the format `[p]convert to `" 25 | ) 26 | await ctx.send_help() 27 | return 28 | 29 | arg1, end_unit = conversion.split(" to ") 30 | amount = decimal.search(arg1) 31 | if amount is None: 32 | embed = discord.Embed( 33 | title="Error", 34 | description="Error no decimal found", 35 | color=discord.Color.red(), 36 | ) 37 | else: 38 | amount_group = amount.group() 39 | unit = arg1.replace(amount_group, "").strip() 40 | 41 | arg1 = f"{amount_group} {unit}" 42 | 43 | try: 44 | result = await ctx.bot.loop.run_in_executor(None, subprocess.check_output, ["units", arg1, end_unit]) 45 | except subprocess.CalledProcessError as e: 46 | error = e.output.decode("utf-8") 47 | # grab the first line for the error type 48 | error_type = error.splitlines()[0] 49 | embed = discord.Embed( 50 | title="Error", 51 | description=f"Error when converting `{conversion}`\n{error_type}", 52 | color=discord.Color.red(), 53 | ) 54 | else: 55 | # the result is line 1 56 | result = result.decode("utf-8").splitlines()[0] 57 | # remove whitespace at the start and end 58 | result = result.strip() 59 | # check if first line has a * or / 60 | if "*" in result or "/" in result: 61 | # remove the first character 62 | result = result[1:] 63 | # create embed 64 | embed = discord.Embed( 65 | title="Convert", 66 | description=f"`{conversion}`\n`{result.strip()}{end_unit}`", 67 | color=discord.Color.green(), 68 | ) 69 | 70 | await ctx.send(embed=embed) 71 | -------------------------------------------------------------------------------- /convert/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["McTwist", "Darkflame72"], 3 | "description": "Usage: [p]convert to \nWill try to convert a unit to something else comparable.", 4 | "short": "Convert a unit to any other similar unit.", 5 | "disabled": false, 6 | "name": "Convert", 7 | "tags": ["utility"], 8 | "min_bot_version": "3.5.1" 9 | } 10 | -------------------------------------------------------------------------------- /create_dev_bot.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash -e 2 | 3 | # This aligns with the VSCode launch.json config and gitignore 4 | INSTANCE_NAME="RedBot_dev_homelab" 5 | DATA_PATH=".red_data" 6 | 7 | echo "This script assumes you have python3 and pip installed on your system. If you don't, please install them before continuing." 8 | echo 9 | 10 | read -p "Enter your bot token: " TOKEN 11 | if [ -z "$TOKEN" ]; then 12 | echo "You must provide a bot token." 13 | exit 1 14 | fi 15 | 16 | read -p "Enter your bot prefix [!]: " PREFIX 17 | PREFIX=${PREFIX:-"!"} 18 | 19 | is_venv_sourced=$(python3 -c 'import sys;print(sys.prefix != sys.base_prefix)') 20 | if [ "$is_venv_sourced" = "True" ]; then 21 | echo "You are already in a virtual environment. If you are not currently using the venv you wish to use for RedBot, please deactivate it." 22 | read -p "Do you want to continue? [y/N]: " CONTINUE 23 | if [ "$CONTINUE" != "y" ]; then 24 | exit 1 25 | fi 26 | fi 27 | 28 | echo "Creating and activating virtual environment..." 29 | python3 -m venv .venv 30 | source venv/bin/activate 31 | 32 | echo "Installing dependencies..." 33 | pip install -r requirements.txt 34 | 35 | echo "Creating RedBot instance..." 36 | redbot-setup --instance-name "$INSTANCE_NAME" --no-prompt --data-path $DATA_PATH 37 | redbot "$INSTANCE_NAME" --edit --no-prompt --token "$TOKEN" --prefix "$PREFIX" 38 | 39 | echo 40 | echo "Bot setup complete. You can now run the bot using the command 'redbot $INSTANCE_NAME' or by using the launch.json configuration in VSCode for debugging." 41 | echo 42 | echo "Run '${PREFIX}addpath $(dirname $(realpath "$0"))' in a chat with the bot to add this repository to the bot's list of cog paths." 43 | -------------------------------------------------------------------------------- /custom_msg/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .custom_msg import CustomMsgCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(CustomMsgCog()) 8 | -------------------------------------------------------------------------------- /custom_msg/custom_msg.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Optional 3 | 4 | import discord 5 | from redbot.core import checks, commands 6 | 7 | from .interactive_session import InteractiveSession, SessionCancelled, make_session 8 | 9 | 10 | class CustomMsgCog(commands.Cog): 11 | @checks.mod() 12 | @commands.guild_only() 13 | @commands.group(name="msg") 14 | async def msg_cmd(self, ctx: commands.Context): 15 | pass 16 | 17 | @msg_cmd.command(name="create", aliases=["send"]) # type: ignore 18 | async def msg_create(self, ctx: commands.GuildContext, specified_channel: Optional[discord.TextChannel] = None): 19 | channel = ctx.channel 20 | if specified_channel is not None: 21 | channel = specified_channel 22 | try: 23 | payload = await make_session(ctx) 24 | except asyncio.TimeoutError: 25 | return await ctx.send("Took too long to respond - exiting...") 26 | except SessionCancelled: 27 | return await ctx.send("Exiting...") 28 | 29 | if payload["embed"] is None: 30 | message = await channel.send(content=payload["content"]) 31 | else: 32 | message = await channel.send(content=payload["content"], embed=payload["embed"]) 33 | 34 | await ctx.send( 35 | "Message sent. " 36 | + "For future reference, the message is here: " 37 | + f"https://discord.com/channels/{ctx.guild.id}/{message.channel.id}/{message.id} (ID: {message.id})" 38 | ) 39 | 40 | @msg_cmd.command(name="edit") # type: ignore 41 | async def msg_edit(self, ctx: commands.Context, message: discord.Message): 42 | if message.author != ctx.me: 43 | return await ctx.send("You must specify a message that was sent by the bot.") 44 | 45 | try: 46 | payload = await make_session(ctx) 47 | except asyncio.TimeoutError: 48 | return await ctx.send("Took too long to respond - exiting...") 49 | except SessionCancelled: 50 | return await ctx.send("Exiting...") 51 | 52 | if not payload.get("content") and message.content: 53 | if not await InteractiveSession(ctx).get_boolean_answer( 54 | "The original message has message content, but you have not specified any. " 55 | + "Would you like to keep the original content?" 56 | ): 57 | payload.update({"content": ""}) 58 | 59 | if not payload.get("embed") and message.embeds: 60 | if not await InteractiveSession(ctx).get_boolean_answer( 61 | "The original message has an embed, but you have not specified one. " 62 | + "Would you like to keep the original embed?" 63 | ): 64 | payload.update({"embed": None}) 65 | 66 | await message.edit(**payload) 67 | await ctx.send("Message edited.") 68 | -------------------------------------------------------------------------------- /custom_msg/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Issy" 4 | ], 5 | "short": "Send messages as the bot", 6 | "description": "Allows moderators to send messages as the bot, also allowing modification of existing messages sent from the cog", 7 | "disabled": false, 8 | "name": "custom_msg", 9 | "tags": [ 10 | "utility", 11 | "admin" 12 | ], 13 | "install_msg": "Usage: `[p]msg create `", 14 | "min_bot_version": "3.5.1" 15 | } -------------------------------------------------------------------------------- /custom_msg/interactive_session.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import abstractmethod 4 | from typing import List, Optional, Self, TypedDict, Union 5 | 6 | import discord 7 | from redbot.core import commands 8 | 9 | 10 | class SessionCancelled(Exception): 11 | """Raised when an interactive session is closed by the user""" 12 | 13 | 14 | class Payload(TypedDict): 15 | content: Optional[str] 16 | embed: Optional[discord.Embed] 17 | 18 | 19 | class InteractiveSession: 20 | ctx: commands.Context 21 | payload: Payload 22 | 23 | def __init__(self, ctx: commands.Context): 24 | self.ctx = ctx 25 | self.payload = {"content": None, "embed": None} 26 | 27 | def predicate(self, message: discord.Message) -> bool: 28 | return message.channel == self.ctx.channel and message.author == self.ctx.author 29 | 30 | async def get_response(self, question: str) -> str: 31 | await self.ctx.send(question) 32 | response = (await self.ctx.bot.wait_for("message", check=self.predicate, timeout=60 * 10)).content 33 | if response == "exit()": 34 | raise SessionCancelled 35 | return response 36 | 37 | async def get_literal_answer(self, question: str, answers: List[str]): 38 | if not all(map(str.lower, answers)): 39 | raise ValueError("All values in the answer list must be lowercase") 40 | 41 | possible_answers = "/".join(f"`{a}`" for a in answers) 42 | while True: 43 | answer = (await self.get_response(f"{question} {possible_answers}")).lower() 44 | if answer not in answers: 45 | await self.ctx.send("Please send a valid answer (listed above)") 46 | else: 47 | return answer 48 | 49 | async def get_boolean_answer(self, question: str) -> bool: 50 | return (await self.get_literal_answer(question, ["y", "n"])) == "y" 51 | 52 | @classmethod 53 | def from_session(cls, session: InteractiveSession) -> Self: 54 | return cls(session.ctx) 55 | 56 | @abstractmethod 57 | async def confirm_sample(self) -> bool: 58 | """Sends the constructed payload and confirms the user is happy with it.""" 59 | return False 60 | 61 | 62 | class MessageBuilder(InteractiveSession): 63 | async def run(self) -> Payload: 64 | max_length = 2000 65 | while True: 66 | content = await self.get_response("Please enter the message you want to send.") 67 | content_length = len(content) 68 | if content_length > max_length: 69 | await self.ctx.send(f"Message must be {max_length} characters or less. Please try again.") 70 | continue 71 | break 72 | 73 | self.payload.update({"content": content}) 74 | if await self.confirm_sample(): 75 | return self.payload 76 | 77 | return await self.run() 78 | 79 | async def confirm_sample(self) -> bool: 80 | await self.ctx.send("Here is the message you have created.") 81 | await self.ctx.send(**self.payload) 82 | return await self.get_boolean_answer("Are you happy with this?") 83 | 84 | 85 | class EmbedBuilder(InteractiveSession): 86 | async def get_title(self) -> str: 87 | title = await self.get_response("What should the title be?") 88 | if len(title) > 256: # noqa: PLR2004 89 | await self.ctx.send("The title must be 256 characters or less.") 90 | return await self.get_title() 91 | 92 | return title 93 | 94 | async def get_description(self, *, send_tutorial: bool = True) -> str: 95 | # fixme: your function is rubbish 96 | max_length = 4096 97 | if send_tutorial: 98 | await self.ctx.send( 99 | f"The description can be up to {max_length} characters in length.\n" 100 | "For this section you may send multiple messages, and you can send" 101 | "`retry()` to clear the description and start again.\n" 102 | "Sending `finish()` will complete the description and move forward to the next stage." 103 | ) 104 | description: List[str] = [] 105 | while len("\n".join(description)) <= max_length: 106 | response = (await self.ctx.bot.wait_for("message", check=self.predicate, timeout=60 * 10)).content 107 | if response == "exit()": 108 | raise SessionCancelled 109 | elif response == "retry()": 110 | return await self.get_description(send_tutorial=False) 111 | elif response == "finish()": 112 | break 113 | 114 | if sum(map(len, [*description, response])) > max_length: 115 | remaining_chars = max_length - len("\n".join(description)) - 1 116 | if remaining_chars == 0: 117 | if not await self.get_boolean_answer("Max char limit reached. Do you want to submit this description?"): 118 | return await self.get_description(send_tutorial=False) 119 | else: 120 | break 121 | 122 | await self.ctx.send( 123 | "This segment of the description is too long, please retry this part.\n" 124 | f"You have {remaining_chars} characters remaining." 125 | ) 126 | continue 127 | 128 | description.append(response) 129 | 130 | return "\n".join(description) 131 | 132 | async def run(self) -> Payload: 133 | embed = discord.Embed(colour=await self.ctx.embed_colour()) 134 | if await self.get_boolean_answer("Do you want a title on this embed?"): 135 | embed.title = await self.get_title() 136 | await self.ctx.send("Title added.") 137 | 138 | if await self.get_boolean_answer("Do you want to add a description?"): 139 | embed.description = await self.get_description() 140 | await self.ctx.send("Description added.") 141 | 142 | self.payload.update({"embed": embed}) 143 | if not embed: 144 | await self.ctx.send("You can't use an empty embed.\nPlease go through the options again.") 145 | return await self.run() 146 | 147 | if await self.confirm_sample(): 148 | return self.payload 149 | else: 150 | return await self.run() 151 | 152 | async def confirm_sample(self) -> bool: 153 | await self.ctx.send("Here is the embed you have created.") 154 | await self.ctx.send(**self.payload) 155 | return await self.get_boolean_answer("Are you happy with this?") 156 | 157 | 158 | class MixedBuilder(InteractiveSession): 159 | async def run(self) -> Payload: 160 | message_payload = await MessageBuilder.from_session(self).run() 161 | await self.ctx.send("Message added.") 162 | embed_payload = await EmbedBuilder.from_session(self).run() 163 | await self.ctx.send("Embed added.") 164 | 165 | self.payload.update({"content": message_payload["content"], "embed": embed_payload["embed"]}) 166 | return self.payload 167 | 168 | async def confirm_sample(self) -> bool: 169 | return False 170 | 171 | 172 | async def make_session(ctx: commands.Context) -> Payload: 173 | await ctx.send( 174 | "Entering the interactive message builder. You can send `exit()` at any point to cancel the current builder." 175 | ) 176 | session = InteractiveSession(ctx) 177 | builders = {"embed": EmbedBuilder, "message": MessageBuilder, "both": MixedBuilder} 178 | builder: Union[MessageBuilder, EmbedBuilder] = builders[ 179 | await session.get_literal_answer("Do you want this to be an embed or regular message?", list(builders.keys())) 180 | ].from_session(session) 181 | return await builder.run() 182 | -------------------------------------------------------------------------------- /enforcer/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .enforcer import EnforcerCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(EnforcerCog(bot)) 8 | -------------------------------------------------------------------------------- /enforcer/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Issy", 4 | "Sneezey" 5 | ], 6 | "short": "Channel Enforcement", 7 | "description": "Allows you to enforce certain characteristics on a channel", 8 | "disabled": false, 9 | "name": "enforcer", 10 | "tags": [ 11 | "channel", 12 | "enforcement", 13 | "enforce" 14 | ], 15 | "install_msg": "Usage: `[p]enforcer`", 16 | "min_bot_version": "3.5.1" 17 | } -------------------------------------------------------------------------------- /feed/__init__.py: -------------------------------------------------------------------------------- 1 | from discord import AppCommandType 2 | from redbot.core.bot import Red 3 | 4 | from .feed import FeedCog, on_message, on_user 5 | 6 | 7 | async def setup(bot: Red): 8 | await bot.add_cog(FeedCog()) 9 | bot.tree.add_command(on_message) 10 | bot.tree.add_command(on_user) 11 | 12 | 13 | async def teardown(bot: Red): 14 | bot.tree.remove_command("Feed", type=AppCommandType.message) 15 | bot.tree.remove_command("Feed", type=AppCommandType.user) 16 | -------------------------------------------------------------------------------- /feed/feed.py: -------------------------------------------------------------------------------- 1 | """discord red-bot feed""" 2 | 3 | import random 4 | 5 | import discord 6 | from redbot.core import app_commands, commands 7 | 8 | food = ( 9 | "🍇", 10 | "🍈", 11 | "🍉", 12 | "🍊", 13 | "🍋", 14 | "🍌", 15 | "🍍", 16 | "🥭", 17 | "🍎", 18 | "🍏", 19 | "🍐", 20 | "🍑", 21 | "🍒", 22 | "🍓", 23 | "🥝", 24 | "🍅", 25 | "🥥", 26 | "🥑", 27 | "🍆", 28 | "🥔", 29 | "🥕", 30 | "🌽", 31 | "🌶️", 32 | "🥒", 33 | "🥬", 34 | "🥦", 35 | "🧄", 36 | "🧅", 37 | "🍄", 38 | "🥜", 39 | "🌰", 40 | "🍞", 41 | "🥐", 42 | "🥖", 43 | "🥨", 44 | "🥯", 45 | "🥞", 46 | "🧇", 47 | "🧀", 48 | "🍖", 49 | "🍗", 50 | "🥩", 51 | "🥓", 52 | "🍔", 53 | "🍟", 54 | "🍕", 55 | "🌭", 56 | "🥪", 57 | "🌮", 58 | "🌯", 59 | "🥙", 60 | "🧆", 61 | "🥚", 62 | "🍳", 63 | "🥘", 64 | "🍲", 65 | "🥣", 66 | "🥗", 67 | "🍿", 68 | "🧈", 69 | "🧂", 70 | "🥫", 71 | "🍱", 72 | "🍘", 73 | "🍙", 74 | "🍚", 75 | "🍛", 76 | "🍜", 77 | "🍝", 78 | "🍠", 79 | "🍢", 80 | "🍣", 81 | "🍤", 82 | "🍥", 83 | "🥮", 84 | "🍡", 85 | "🥟", 86 | "🥠", 87 | "🥡", 88 | "🦪", 89 | "🍦", 90 | "🍧", 91 | "🍨", 92 | "🍩", 93 | "🍪", 94 | "🎂", 95 | "🍰", 96 | "🧁", 97 | "🥧", 98 | "🍫", 99 | "🍬", 100 | "🍭", 101 | "🍮", 102 | "🍯", 103 | "🍼", 104 | "🥛", 105 | "☕", 106 | "🍵", 107 | "🍶", 108 | "🍾", 109 | "🍷", 110 | "🍸", 111 | "🍹", 112 | "🍺", 113 | "🍻", 114 | "🥂", 115 | "🥃", 116 | "🥤", 117 | "🧃", 118 | "🧉", 119 | "🧊", 120 | ) 121 | 122 | allowed_mentions = discord.AllowedMentions(everyone=False, users=True, roles=False) 123 | 124 | 125 | def get_fed(mention: str) -> str: 126 | return f"Forces {random.choice(food)} down {mention}'s throat" 127 | 128 | 129 | @app_commands.context_menu(name="Feed user") 130 | async def on_user(interaction: discord.Interaction, member: discord.User): 131 | """Feed user from user context""" 132 | await interaction.response.send_message(get_fed(member.mention), allowed_mentions=allowed_mentions) 133 | 134 | 135 | @app_commands.context_menu(name="Feed user") 136 | async def on_message(interaction: discord.Interaction, message: discord.Message): 137 | """Feed user from message context""" 138 | await interaction.response.send_message(get_fed(message.author.mention), allowed_mentions=allowed_mentions) 139 | 140 | 141 | class FeedCog(commands.Cog): 142 | """Feed Cog""" 143 | 144 | @commands.command(name="feed") 145 | async def feed(self, ctx, member: discord.Member): 146 | """Feed your friends 147 | Example: 148 | - `[p]feed ` 149 | """ 150 | await ctx.send(get_fed(member.mention), allowed_mentions=allowed_mentions) 151 | -------------------------------------------------------------------------------- /feed/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Issy", 4 | "tigattack" 5 | ], 6 | "short": "Feed your friends", 7 | "description": "Force food into people's mouths", 8 | "disabled": false, 9 | "name": "feed", 10 | "tags": [ 11 | "fun" 12 | ], 13 | "install_msg": "Usage: `[p]feed [member]`", 14 | "min_bot_version": "3.5.1" 15 | } -------------------------------------------------------------------------------- /google/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .google import Google 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(Google()) 8 | -------------------------------------------------------------------------------- /google/google.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | from redbot.core import commands 4 | 5 | 6 | class Google(commands.Cog): 7 | """Google Command""" 8 | 9 | @commands.command() 10 | async def google(self, ctx, *, query): 11 | """Send a google link with provided query""" 12 | if query.lower() == "google": 13 | await ctx.send("Great, nice one, thanks mate, now the internet's broken. Are you proud of yourself?") 14 | else: 15 | await ctx.send("https://google.com/search?q=" + urllib.parse.quote_plus(query)) 16 | -------------------------------------------------------------------------------- /google/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "TheDevFreak" 4 | ], 5 | "description": "Usage: [p]google ", 6 | "short": "Send a google link to someone", 7 | "name": "Google", 8 | "tags": [ 9 | "fun" 10 | ], 11 | "min_bot_version": "3.5.1" 12 | } -------------------------------------------------------------------------------- /guild_profiles/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .guild_profiles import GuildProfilesCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(GuildProfilesCog(bot)) 8 | -------------------------------------------------------------------------------- /guild_profiles/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "tigattack" 4 | ], 5 | "short": "Create profiles to manage a guild's icon and banner.", 6 | "description": "Create, read, update, delete, and apply guild profiles. Each profile consists of a guild icon and banner.", 7 | "name": "guild_profiles", 8 | "tags": [ 9 | "utility" 10 | ], 11 | "requirements": [ 12 | "aiofiles>=24.1.0" 13 | ], 14 | "install_msg": "Usage: `[p]guildprofile`", 15 | "min_bot_version": "3.5.1" 16 | } 17 | -------------------------------------------------------------------------------- /info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "tigattack", 4 | "Issy", 5 | "portalBlock", 6 | "Sneezey", 7 | "dantho", 8 | "BeryJu", 9 | "TheDevFreak", 10 | "McTwist", 11 | "ripple" 12 | ], 13 | "short": "r/Homelab's LabBot Cogs repository.", 14 | "description": "r/Homelab's LabBot Cogs repository, developed and maintained by the Homelab community.", 15 | "install_msg": "Thanks for adding r/Homelab's LabBot Cogs repository!" 16 | } 17 | -------------------------------------------------------------------------------- /isitreadonlyfriday/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .isitreadonlyfriday import IsItReadOnlyFriday 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(IsItReadOnlyFriday()) 8 | -------------------------------------------------------------------------------- /isitreadonlyfriday/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "tigattack" 4 | ], 5 | "description": "Tells you if it's Read-Only Friday.\nUsage: [p]isitreadonlyfriday [offset=0] or /isitreadonlyfriday [offset=0]\n`offset` is an optional UTC offset in hours.", 6 | "short": "Tells you if it's Read-Only Friday", 7 | "name": "isitreadonlyfriday", 8 | "tags": [ 9 | "fun" 10 | ], 11 | "min_bot_version": "3.5.1" 12 | } 13 | -------------------------------------------------------------------------------- /isitreadonlyfriday/isitreadonlyfriday.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import logging 4 | 5 | import aiohttp 6 | import discord 7 | from redbot.core import app_commands, commands 8 | 9 | log = logging.getLogger("red.rhomelab.isitreadonlyfriday") 10 | 11 | 12 | class IsItReadOnlyFriday(commands.Cog): 13 | """IsItReadOnlyFriday Cog""" 14 | 15 | def __init__(self): 16 | pass 17 | 18 | async def get_isitreadonlyfriday(self, offset: int) -> discord.Embed: 19 | # Get readonly data from isitreadonlyfriday api 20 | try: 21 | async with aiohttp.request("GET", f"https://isitreadonlyfriday.com/api/isitreadonlyfriday/{offset}") as response: 22 | response.raise_for_status() 23 | try: 24 | readonly = await response.json() 25 | except aiohttp.ContentTypeError: 26 | readonly = {"error": "Response content is not JSON"} 27 | except aiohttp.ClientError as e: 28 | readonly = {"error": f"Client error: {e!s}"} 29 | except asyncio.TimeoutError: 30 | readonly = {"error": "Request timed out"} 31 | except Exception as e: 32 | readonly = {"error": f"An unexpected error occurred: {e!s}"} 33 | 34 | if readonly.get("error"): 35 | log.error(f"Error fetching data from API: {readonly['error']}") 36 | return await self.make_error_embed() 37 | 38 | return await self.make_readonly_embed(readonly, "Friday") 39 | 40 | async def get_isitreadonlydecember(self, offset: int): 41 | # Check if it's December with a given (pre-checked) UTC offset 42 | utc_now = datetime.datetime.now(datetime.timezone.utc) 43 | offset_tz = datetime.timezone(datetime.timedelta(hours=offset)) 44 | local = utc_now.astimezone(offset_tz) 45 | data = {"offset": offset, "readonly": local.month == 12} # noqa: PLR2004 46 | return await self.make_readonly_embed(data, "December") 47 | 48 | @commands.command() 49 | async def isitreadonlyfriday(self, ctx: commands.Context, offset: int = 0) -> None: 50 | """Tells you if it's read-only Friday! 51 | 52 | Accepts optional UTC offset (default 0, range -12 to 12). 53 | """ 54 | 55 | if offset not in range(-12, 13): 56 | await ctx.send("Offset must be between -12 and 12.") 57 | return 58 | 59 | embed = await self.get_isitreadonlyfriday(offset) 60 | await ctx.send(embed=embed) 61 | 62 | @app_commands.command(name="isitreadonlyfriday") 63 | async def app_isitreadonlyfriday( 64 | self, 65 | interaction: discord.Interaction, 66 | offset: app_commands.Range[int, -12, 12] = 0, 67 | ): 68 | """Tells you if it's read-only Friday! 69 | 70 | Paramters 71 | ---------- 72 | offset: int 73 | UTC offset (default 0, range -12 to 12) 74 | """ 75 | 76 | embed = await self.get_isitreadonlyfriday(offset) 77 | await interaction.response.send_message(embed=embed) 78 | 79 | @commands.command() 80 | async def isitreadonlydecember(self, ctx: commands.Context, offset: int = 0) -> None: 81 | """Tells you if it's read-only December! 82 | 83 | Accepts optional UTC offset (default 0, range -12 to 12). 84 | """ 85 | 86 | if offset not in range(-12, 13): 87 | await ctx.send("Offset must be between -12 and 12.") 88 | return 89 | 90 | embed = await self.get_isitreadonlydecember(offset) 91 | await ctx.send(embed=embed) 92 | 93 | @app_commands.command(name="isitreadonlydecember") 94 | async def app_isitreadonlydecember( 95 | self, 96 | interaction: discord.Interaction, 97 | offset: app_commands.Range[int, -12, 12] = 0, 98 | ): 99 | """Tells you if it's read-only December! 100 | 101 | Paramters 102 | ---------- 103 | offset: int 104 | UTC offset (default 0, range -12 to 12) 105 | """ 106 | 107 | embed = await self.get_isitreadonlydecember(offset) 108 | await interaction.response.send_message(embed=embed) 109 | 110 | @staticmethod 111 | async def make_readonly_embed(data: dict, period: str) -> discord.Embed: 112 | """Generate embed for isitreadonlyfriday readonly""" 113 | if data["readonly"]: 114 | return discord.Embed( 115 | title=f"Is It Read-Only {period}?", 116 | description="Yes! Don't change anything!", 117 | colour=discord.Colour.red(), 118 | ) 119 | 120 | return discord.Embed( 121 | title=f"Is It Read-Only {period}?", 122 | description="No! Change away!", 123 | colour=discord.Colour.green(), 124 | ) 125 | 126 | @staticmethod 127 | async def make_error_embed() -> discord.Embed: 128 | """Generate error message embeds""" 129 | return discord.Embed( 130 | title="Error", 131 | description="An error occurred while fetching data from isitreadonlyfriday.com", 132 | colour=discord.Colour.brand_red(), 133 | ) 134 | -------------------------------------------------------------------------------- /jail/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .jail import JailCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(JailCog(bot)) 8 | -------------------------------------------------------------------------------- /jail/abstracts.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from abc import ABC, abstractmethod 3 | from typing import List 4 | 5 | import discord 6 | from redbot.core import Config, commands 7 | 8 | 9 | # fixme: please use data classes 10 | class JailABC(ABC): 11 | datetime: int 12 | channel_id: int 13 | role_id: int 14 | active: bool 15 | jailer: int 16 | user: int 17 | user_roles: List[int] 18 | archive_id: uuid.UUID 19 | 20 | def __init__(self, **kwargs): 21 | if kwargs.keys() != self.__annotations__.keys(): 22 | raise Exception("Invalid kwargs provided") 23 | 24 | for key, val in kwargs.items(): 25 | # expected_type: type = self.__annotations__[key] 26 | # if not isinstance(val, expected_type): 27 | # raise TypeError(f"Expected type {expected_type} for kwarg {key!r}, got type {type(val)} instead") 28 | 29 | setattr(self, key, val) 30 | 31 | @classmethod 32 | @abstractmethod 33 | def new( # noqa: PLR0913 34 | cls, 35 | ctx: commands.Context, 36 | datetime: int, 37 | channel_id: int, 38 | role_id: int, 39 | active: bool, 40 | jailer: int, 41 | user: int, 42 | user_roles: List[int], 43 | uuid: uuid.UUID, 44 | ): 45 | """Initialise the class in a command context""" 46 | pass 47 | 48 | @classmethod 49 | @abstractmethod 50 | def from_storage(cls, ctx: commands.Context, data: dict): 51 | """Initialise the class from a config record""" 52 | pass 53 | 54 | @abstractmethod 55 | def to_dict(self) -> dict: 56 | """Returns a dictionary representation of the class, suitable for storing in config""" 57 | pass 58 | 59 | 60 | # fixme: please use data classes 61 | 62 | 63 | class JailSetABC(ABC): 64 | jails: List[JailABC] 65 | 66 | def __init__(self, **kwargs): 67 | if kwargs.keys() != self.__annotations__.keys(): 68 | raise Exception("Invalid kwargs provided") 69 | 70 | for key, val in kwargs.items(): 71 | # expected_type: type = self.__annotations__[key] 72 | # if not isinstance(val, expected_type): 73 | # raise TypeError(f"Expected type {expected_type} for kwarg {key!r}, got type {type(val)} instead") 74 | 75 | setattr(self, key, val) 76 | 77 | @classmethod 78 | @abstractmethod 79 | def new(cls, ctx: commands.Context, jails: List[JailABC]): 80 | """Initialise the class in a command context""" 81 | pass 82 | 83 | @classmethod 84 | @abstractmethod 85 | def from_storage(cls, ctx: commands.Context, data: dict): 86 | """Initialise the class from a config record""" 87 | pass 88 | 89 | @abstractmethod 90 | def to_list(self) -> list: 91 | """Returns a list representation of the class, suitable for storing in config""" 92 | pass 93 | 94 | @abstractmethod 95 | def get_active_jail(self) -> JailABC: 96 | """Gets the current active jail in the set.""" 97 | pass 98 | 99 | @abstractmethod 100 | def add_jail(self, jail: JailABC): 101 | """Saves a jail to the set.""" 102 | pass 103 | 104 | 105 | class JailConfigHelperABC(ABC): 106 | config: Config 107 | 108 | async def create_jail(self, ctx: commands.Context, datetime: int, member: discord.Member) -> JailABC: 109 | """Creates and saves a new jail.""" 110 | pass 111 | 112 | async def get_jail_by_channel(self, ctx: commands.Context, channel: discord.TextChannel) -> JailABC: 113 | """Returns a jail if one exists for the specified channel""" 114 | pass 115 | -------------------------------------------------------------------------------- /jail/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "portalBlock" 4 | ], 5 | "short": "Utility to manage per-user timeouts (jails).", 6 | "description": "Create individual jails for users when multiple users need to be timed out.", 7 | "disabled": false, 8 | "name": "jail", 9 | "requirements": ["chat_exporter>=2.8.1"], 10 | "tags": [ 11 | "utility" 12 | ], 13 | "install_msg": "Usage: `[p]jail`. Please run `[p]jail setup `!! Refer to internal docs for bot permission requirements.", 14 | "min_bot_version": "3.5.1" 15 | } -------------------------------------------------------------------------------- /jail/jail.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | from io import BytesIO 4 | from os import path 5 | from typing import Optional 6 | 7 | import discord 8 | from discord import CategoryChannel 9 | from redbot.core import checks, commands, data_manager 10 | from redbot.core.bot import Red 11 | from redbot.core.utils.chat_formatting import pagify 12 | from redbot.core.utils.menus import close_menu, menu, next_page, prev_page 13 | 14 | from jail.utils import JailConfigHelper 15 | 16 | 17 | class JailCog(commands.Cog): 18 | """Jail cog""" 19 | 20 | def __init__(self, bot: Red, *args, **kwargs): 21 | super().__init__(*args, **kwargs) 22 | self.bot = bot 23 | 24 | self.config = JailConfigHelper() 25 | 26 | @checks.mod() 27 | @commands.guild_only() 28 | @commands.group("jail", pass_context=True, invoke_without_command=True) 29 | async def _jail(self, ctx: commands.Context, member: discord.Member): 30 | """Jails the specified user.""" 31 | jail = await self.config.create_jail(ctx, int(datetime.utcnow().timestamp()), member) 32 | if jail is None: 33 | await ctx.send("Sorry, there was an error with jail category. Make sure things are setup correctly!") 34 | return 35 | await self.config.jail_user(ctx, jail, member) 36 | await ctx.send("User has been jailed!") 37 | 38 | @checks.admin() 39 | @_jail.command("setup") 40 | async def _jail_setup(self, ctx: commands.Context, cat_id: int): 41 | """Sets the jail category channel.""" 42 | channel = ctx.guild.get_channel(cat_id) 43 | if not isinstance(channel, CategoryChannel): 44 | await ctx.send("Sorry, that's not a category channel.") 45 | return 46 | await self.config.set_category(ctx, channel) 47 | await ctx.send("Channel category set!") 48 | 49 | @_jail.command("free") 50 | async def _jail_free(self, ctx: commands.Context, user: discord.User): 51 | """Frees the specified user from the jail.""" 52 | jail = await self.config.get_jail_by_user(ctx, user) 53 | if jail is None or not jail.active: 54 | await ctx.send("That user isn't in jail!") 55 | return 56 | member = ctx.guild.get_member(user.id) 57 | if member is not None: 58 | await self.config.free_user(ctx, jail, member) 59 | else: 60 | await ctx.send("Error getting member! Cannot free them. I'll cleanup the jail and role though.") 61 | await self.config.cleanup_jail(ctx, jail) 62 | await ctx.send("User has been freed!") 63 | 64 | @_jail.group("archives", pass_context=True, invoke_without_command=True) 65 | async def _jail_archives(self, ctx: commands.Context, user: discord.User): 66 | """Lists all archives for a given user.""" 67 | # Code from the Notes cog, with applicable modifications 68 | jailset = await self.config.get_jailset_by_user(ctx, user) 69 | if not jailset: 70 | return await ctx.send("No jails found for that user.") 71 | jails = jailset.jails 72 | if not jails: 73 | return await ctx.send("Jail Set for user found, but no jails within.") 74 | num_jails = len(jails) 75 | pages = list(pagify("\n\n".join(str(j) for j in jails))) 76 | 77 | # Create embeds from pagified data 78 | jails_target: Optional[str] = getattr(user, "display_name", str(user.id)) if user is not None else None 79 | base_embed_options = { 80 | "title": ((f"Jail archives for {jails_target}" if jails_target else "All jails") + f" - ({num_jails} jails)"), 81 | "colour": await ctx.embed_colour(), 82 | } 83 | embeds = [ 84 | discord.Embed(**base_embed_options, description=page).set_footer(text=f"Page {index} of {len(pages)}") 85 | for index, page in enumerate(pages, start=1) 86 | ] 87 | 88 | if len(embeds) == 1: 89 | await ctx.send(embed=embeds[0]) 90 | else: 91 | ctx.bot.loop.create_task( 92 | menu(ctx=ctx, pages=embeds, controls={"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page}, timeout=180.0) 93 | ) 94 | 95 | @_jail_archives.command("fetch") 96 | async def _jail_archives_fetch(self, ctx: commands.Context, archive_id: uuid.UUID): 97 | """Fetches and sends the specified archive.""" 98 | data_path = data_manager.cog_data_path(self.config) 99 | archive_file = f"{archive_id}.html" 100 | archive_path = path.join(data_path, archive_file) 101 | 102 | async with ctx.typing(): 103 | try: 104 | # fixme: this is rly bad, dont use possibly blocking functions in async. 105 | with open(archive_path, "r") as file: # noqa: ASYNC230 106 | data = file.read() 107 | transmit = discord.File(BytesIO(initial_bytes=data.encode()), filename=archive_file) 108 | await ctx.send(file=transmit) 109 | except Exception as e: 110 | await ctx.send( 111 | "Error fetching archive. Likely file not found, maybe a permissions issue. Check the console for details." 112 | ) 113 | print(e) 114 | -------------------------------------------------------------------------------- /jail/utils.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from io import BytesIO 3 | from os import path 4 | from typing import List 5 | 6 | import discord 7 | from chat_exporter import chat_exporter 8 | from discord import CategoryChannel, NotFound 9 | from redbot.core import Config, commands, data_manager 10 | 11 | from jail.abstracts import JailABC, JailConfigHelperABC, JailSetABC 12 | 13 | 14 | class Jail(JailABC): 15 | @classmethod 16 | def new( # noqa: PLR0913 17 | cls, 18 | ctx: commands.Context, 19 | datetime: int, 20 | channel_id: int, 21 | role_id: int, 22 | active: bool, 23 | jailer: int, 24 | user: int, 25 | user_roles: List[int], 26 | archive_id: uuid.UUID, 27 | ): 28 | return cls( 29 | datetime=datetime, 30 | channel_id=channel_id, 31 | role_id=role_id, 32 | active=active, 33 | jailer=jailer, 34 | user=user, 35 | user_roles=user_roles, 36 | archive_id=archive_id, 37 | ) 38 | 39 | @classmethod 40 | def from_storage(cls, ctx: commands.Context, data: dict): 41 | return Jail.new( 42 | ctx, 43 | datetime=data["datetime"], 44 | channel_id=data["channel_id"], 45 | role_id=data["role_id"], 46 | active=data["active"], 47 | jailer=data["jailer"], 48 | user=data["user"], 49 | user_roles=data["user_roles"], 50 | archive_id=data["archive_id"], 51 | ) 52 | 53 | def to_dict(self) -> dict: 54 | return { 55 | "datetime": self.datetime, 56 | "channel_id": self.channel_id, 57 | "role_id": self.role_id, 58 | "active": self.active, 59 | "jailer": self.jailer, 60 | "user": self.user, 61 | "user_roles": self.user_roles, 62 | "archive_id": str(self.archive_id), 63 | } 64 | 65 | def __str__(self) -> str: 66 | return f"<@{self.user}> : {self.archive_id}" 67 | 68 | 69 | class JailSet(JailSetABC): 70 | @classmethod 71 | def new(cls, ctx: commands.Context, jails: List[JailABC]): 72 | return cls(jails=jails) 73 | 74 | @classmethod 75 | def from_storage(cls, ctx: commands.Context, data: list): 76 | return JailSet.new(ctx, [Jail.from_storage(ctx, j) for j in data]) 77 | 78 | def to_list(self) -> list: 79 | return [j.to_dict() for j in self.jails] 80 | 81 | def get_active_jail(self) -> JailABC: 82 | for jail in reversed(self.jails): 83 | if jail.active: 84 | return jail 85 | return None 86 | 87 | def add_jail(self, jail: JailABC): 88 | self.jails.append(jail) 89 | 90 | def deactivate_jail(self, archive_uuid: uuid.UUID): 91 | for jail in reversed(self.jails): 92 | if jail.active: 93 | jail.archive_id = archive_uuid 94 | jail.active = False 95 | 96 | 97 | class JailConfigHelper(JailConfigHelperABC): 98 | def __init__(self): 99 | self.config = Config.get_conf(self, identifier=1289862744207523842002, cog_name="JailCog") 100 | self.config.register_guild(jails={}) 101 | 102 | async def set_category(self, ctx: commands.Context, category: CategoryChannel): 103 | await self.config.guild(ctx.guild).category.set(category.id) 104 | 105 | async def get_category(self, guild: discord.Guild): 106 | channel = guild.get_channel(await self.config.guild(guild).category()) 107 | if channel is None: 108 | return None 109 | return channel 110 | 111 | async def create_jail(self, ctx: commands.Context, datetime: int, member: discord.Member) -> Jail: 112 | category = await self.get_category(ctx.guild) 113 | if category is None: 114 | return None 115 | reason = f"Jail: {ctx.author.name} created a jail for: {member.name}" 116 | 117 | role = await ctx.guild.create_role(name=f"Jail:{member.name}", mentionable=False, reason=reason) 118 | perms = discord.PermissionOverwrite( 119 | view_channel=True, read_message_history=True, read_messages=True, send_messages=True 120 | ) 121 | channel = await ctx.guild.create_text_channel( 122 | name=f"{member.name}-timeout", 123 | reason=reason, 124 | category=category, 125 | news=False, 126 | topic=f"{member.display_name} was bad and now we're here. DO NOT LEAVE! Leaving is evading and will " 127 | f"result in an immediate ban.", 128 | nsfw=False, 129 | ) 130 | await channel.set_permissions(role, overwrite=perms) 131 | async with self.config.guild(ctx.guild).jails() as jails: 132 | jail = Jail.new( 133 | ctx, datetime, channel.id, role.id, True, ctx.author.id, member.id, [r.id for r in member.roles], None 134 | ) 135 | if str(member.id) in jails.keys(): 136 | jailset = JailSet.from_storage(ctx, jails[str(member.id)]) 137 | else: 138 | jailset = JailSet.new(ctx, []) 139 | jailset.add_jail(jail) 140 | jails[str(member.id)] = jailset.to_list() 141 | 142 | return jail 143 | 144 | async def restore_user_roles(self, ctx: commands.Context, jail: JailABC, member: discord.Member): 145 | for rid in jail.user_roles: 146 | try: 147 | role = ctx.guild.get_role(rid) 148 | if role is not None: 149 | await member.add_roles(role, reason="Jail: Restore Roles") 150 | except NotFound: 151 | pass 152 | 153 | async def jail_user(self, ctx: commands.Context, jail: Jail, member: discord.Member): 154 | reason = "Jail: Timeout" 155 | for r in member.roles: 156 | if r.name != "@everyone": 157 | await member.remove_roles(r, reason=reason) 158 | 159 | role = ctx.guild.get_role(jail.role_id) 160 | if role is not None: 161 | await member.add_roles(role, reason=reason) 162 | 163 | async def free_user(self, ctx: commands.Context, jail: JailABC, member: discord.Member): 164 | await self.restore_user_roles(ctx, jail, member) 165 | 166 | role = ctx.guild.get_role(jail.role_id) 167 | if role is not None: 168 | await member.remove_roles(role, reason="Jail: Free User") 169 | 170 | async def cleanup_jail(self, ctx: commands.Context, jail: JailABC): 171 | try: 172 | role = ctx.guild.get_role(jail.role_id) 173 | if role is not None: 174 | await role.delete(reason="Jail: Jail deleted.") 175 | channel = ctx.guild.get_channel(jail.channel_id) 176 | if channel is not None: 177 | archive_uuid = uuid.uuid4() 178 | await self.archive_channel(ctx, channel, archive_uuid) 179 | await channel.delete(reason="Jail: Jail deleted.") 180 | async with self.config.guild(ctx.guild).jails() as jails: 181 | if str(jail.user) in jails: 182 | jailset = JailSet.from_storage(ctx, jails[str(jail.user)]) 183 | jailset.deactivate_jail(archive_uuid) 184 | jails[str(jail.user)] = jailset.to_list() 185 | except NotFound: 186 | pass 187 | 188 | async def archive_channel(self, ctx: commands.Context, channel: discord.TextChannel, archive_uuid: uuid.UUID): 189 | """Archive supplied channel to an HTML file""" 190 | # Copied this from tig because the work was already done :) 191 | if ctx.guild is None: 192 | raise TypeError("ctx.guild is None") 193 | data_path = data_manager.cog_data_path(self) 194 | transcript_file_name = f"{archive_uuid}.html" 195 | transcript_path = path.join(data_path, transcript_file_name) 196 | 197 | async with ctx.typing(): 198 | transcript = await chat_exporter.export( 199 | channel=channel, 200 | tz_info="UTC", # Original had this as tz_info= 201 | ) 202 | if transcript is None: 203 | await ctx.send("None transcript") 204 | return 205 | 206 | # Encode transcript to bytes object 207 | transcript_object = BytesIO(initial_bytes=transcript.encode()) 208 | 209 | # Write transcript to storage 210 | # fixme: this is rly bad, dont use possibly blocking functions in async. 211 | with open(transcript_path, "wb") as file: # noqa: ASYNC230 212 | file.write(transcript_object.getbuffer()) 213 | 214 | async def get_jail_by_user(self, ctx: commands.Context, user: discord.User) -> JailABC: 215 | async with self.config.guild(ctx.guild).jails() as jails: 216 | if str(user.id) in jails: 217 | return JailSet.from_storage(ctx, jails[str(user.id)]).get_active_jail() 218 | return None 219 | 220 | async def get_jailset_by_channel(self, ctx: commands.Context, channel: discord.TextChannel) -> JailSetABC: 221 | async with self.config.guild(ctx.guild).jails() as jails: 222 | for jailsetkey in jails: 223 | jailset = JailSet.from_storage(ctx, jails[jailsetkey]) 224 | for jail in jailset.jails: 225 | if jail.channel_id == channel.id: 226 | return jailset 227 | return None 228 | 229 | async def get_jailset_by_user(self, ctx: commands.Context, user: discord.User) -> JailSetABC: 230 | async with self.config.guild(ctx.guild).jails() as jails: 231 | if str(user.id) in jails: 232 | return JailSet.from_storage(ctx, jails[str(user.id)]) 233 | return None 234 | -------------------------------------------------------------------------------- /latex/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .latex import LatexCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(LatexCog()) 8 | -------------------------------------------------------------------------------- /latex/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "portalBlock" 4 | ], 5 | "short": "Render LaTeX", 6 | "description": "Render LaTeX data.", 7 | "disabled": false, 8 | "name": "latex", 9 | "tags": [ 10 | "reply", 11 | "utility" 12 | ], 13 | "install_msg": "Usage: `[p]latex `", 14 | "min_bot_version": "3.5.1" 15 | } -------------------------------------------------------------------------------- /latex/latex.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | import discord 4 | from redbot.core import commands 5 | 6 | 7 | class LatexCog(commands.Cog): 8 | """Latex Cog""" 9 | 10 | @commands.command() 11 | async def latex(self, ctx: commands.Context, *, latex: str): 12 | """Render a LaTeX statement 13 | 14 | Example: 15 | - `[p]latex ` 16 | """ 17 | embed = await self.make_latex_embed(ctx, latex) 18 | await ctx.send(embed=embed) 19 | await ctx.message.delete() 20 | 21 | async def make_latex_embed(self, ctx: commands.Context, latex) -> discord.Embed: 22 | image_options = urllib.parse.quote_plus("\\large&space;\\dpi{300}\\bg{black}") 23 | url = "https://latex.codecogs.com/png.image?" + image_options + urllib.parse.quote_plus(latex) 24 | latex_embed = discord.Embed(title="LaTeX Rendering", colour=await ctx.embed_colour()) 25 | latex_embed.add_field(name="Requested by:", value=ctx.author.mention) 26 | latex_embed.set_image(url=url) 27 | return latex_embed 28 | -------------------------------------------------------------------------------- /letters/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .letters import Letters 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(Letters()) 8 | -------------------------------------------------------------------------------- /letters/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "tigattack", 4 | "Issy" 5 | ], 6 | "short": "Outputs large emote letters from input text.", 7 | "description": "Convert a string of letters/numbers into large emote letters (\"regional indicators\")", 8 | "disabled": false, 9 | "name": "Letters", 10 | "tags": [ 11 | "fun", 12 | "reply", 13 | "utility" 14 | ], 15 | "install_msg": "Usage: `[p]letters abcxyz 123`", 16 | "end_user_data_statement": "No user data is stored by this cog.", 17 | "min_bot_version": "3.5.1" 18 | } 19 | -------------------------------------------------------------------------------- /letters/letters.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Optional 3 | 4 | from redbot.core import commands 5 | 6 | # Define numbers -> emotes tuple 7 | nums = (":zero:", ":one:", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:", ":nine:") 8 | 9 | # Define specials -> emotes dict 10 | specials = {"!": ":exclamation:", "?": ":question:", "#": ":hash:", "'": "'", ".": ".", ",": ","} 11 | 12 | allowed_chars = re.compile(r"[^a-z0-9!?\'.#, ]") 13 | 14 | 15 | def convert_char(char: str) -> str: 16 | """Convert character to discord emoji""" 17 | # Double space if char is space 18 | if char == " ": 19 | return " " 20 | 21 | # Convert to number emote if number 22 | elif char.isdigit(): 23 | return f"{nums[int(char)]} " 24 | 25 | # Convert to regional indicator emote if letter 26 | elif char.isalpha(): 27 | return f":regional_indicator_{char}: " 28 | 29 | # Convert to character emote 30 | else: 31 | return f"{specials[char]} " 32 | 33 | 34 | def correct_punctuation_spacing(input_str: str) -> str: 35 | return re.sub(r"([!?'.#,:]) ([!?'.#,])", r"\1\2", input_str) 36 | 37 | 38 | def string_converter(input_str: str) -> str: 39 | """Convert a string to discord emojis""" 40 | # NOTE In future it would be ideal to convert this function to an advanced converter (https://discordpy.readthedocs.io/en/latest/ext/commands/commands.html#advanced-converters) 41 | # So we can bootstrap the commands.clean_content converter and escape channel/user/role mentions 42 | # (currently there is no ping exploit; it just looks odd when converted) 43 | # However, the current version of the commands.clean_content converter doesn't actually work on an argument; 44 | # it scans the whole message content. 45 | # This has been fixed in discord.py 2.0 46 | 47 | # Make the whole string lowercase 48 | input_str = input_str.lower() 49 | # Strip unsupported characters 50 | if allowed_chars.search(input_str): 51 | input_str = allowed_chars.sub("", input_str) 52 | 53 | # Convert characters to Discord emojis 54 | letters = "".join(map(convert_char, input_str)) 55 | # Replace >= 3 spaces with two 56 | letters = re.sub(" {3,}", " ", letters) 57 | # Correct punctuation spacing 58 | letters = correct_punctuation_spacing(correct_punctuation_spacing(letters)) 59 | 60 | return letters 61 | 62 | 63 | def raw_flag(argument: str) -> bool: 64 | """Raw flag converter""" 65 | if argument.lower() == "-raw": 66 | return True 67 | else: 68 | raise commands.BadArgument 69 | 70 | 71 | class Letters(commands.Cog): 72 | """Letters cog""" 73 | 74 | @commands.command() 75 | async def letters(self, ctx: commands.Context, raw: Optional[raw_flag] = False, *, msg: string_converter): # type: ignore 76 | """Outputs large emote letters (\"regional indicators\") from input text. 77 | 78 | The result can be outputted as raw emote code using `-raw` flag. 79 | 80 | Example: 81 | - `[p]letters I would like this text as emotes 123` 82 | - `[p]letters -raw I would like this text as raw emote code 123` 83 | """ 84 | output = f"```{msg}```" if raw else msg 85 | 86 | # Ensure output isn't too long 87 | if len(output) > 2000: # noqa: PLR2004 88 | return await ctx.send("Input too large.") 89 | 90 | # Send message 91 | await ctx.send(output) 92 | -------------------------------------------------------------------------------- /markov/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | from redbot.core.utils import get_end_user_data_statement 3 | 4 | from .markov import Markov 5 | 6 | __red_end_user_data_statement__ = get_end_user_data_statement(__file__) 7 | 8 | 9 | async def setup(bot: Red): 10 | await bot.add_cog(Markov(bot)) 11 | -------------------------------------------------------------------------------- /markov/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author" : [ 3 | "CrunchBangDev", 4 | "tigattack" 5 | ], 6 | "install_msg" : "You've just installed the Markov cog! Please check the readme document to learn how to get started.", 7 | "name" : "Markov", 8 | "short" : "Generate markov chains for users", 9 | "description" : "Analyze user messages, generating markov chains that can be used to synthesize new text to mimic users", 10 | "end_user_data_statement": "When a user has explicitly opted in to message analysis, this cog stores words in messages sent by the user.", 11 | "required_cogs": {}, 12 | "requirements": [], 13 | "min_bot_version": "3.5.1", 14 | "tags": ["fun"] 15 | } 16 | -------------------------------------------------------------------------------- /markov/notice.txt: -------------------------------------------------------------------------------- 1 | This cog is based on https://gitlab.com/CrunchBangDev/cbd-cogs/-/tree/master/Markov 2 | 3 | Copyright CrunchBangDev - GNU General Public License v3.0 4 | 5 | Changes made 6 | * Cog info.json authors, description, tags. 7 | * General formatting/styling. 8 | * Add wanted functionality such as: 9 | * config output commands 10 | * mod management of enabled/disabled channels 11 | * optionally enable/disable channels by ID or mention 12 | 13 | See commit history for further detail. 14 | -------------------------------------------------------------------------------- /notes/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .notes import NotesCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(NotesCog()) 8 | -------------------------------------------------------------------------------- /notes/abstracts.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Callable, Iterable, List, Union 3 | 4 | import discord 5 | from redbot.core import Config, commands 6 | 7 | MAYBE_MEMBER = Union[discord.Member, discord.Object] 8 | 9 | # fixme: please use data classes 10 | 11 | 12 | class NoteABC(ABC): 13 | """Represents a note record""" 14 | 15 | note_id: int 16 | member_id: int 17 | message: str 18 | reporter_id: int 19 | reporter_name: str 20 | created_at: int 21 | deleted: bool 22 | is_warning: bool 23 | _guild: discord.Guild 24 | 25 | def __init__(self, **kwargs): 26 | if kwargs.keys() != self.__annotations__.keys(): 27 | raise Exception("Invalid kwargs provided") 28 | 29 | for key, val in kwargs.items(): 30 | expected_type: type = self.__annotations__[key] 31 | if not isinstance(val, expected_type): 32 | raise TypeError(f"Expected type {expected_type} for kwarg {key!r}, got type {type(val)} instead") 33 | 34 | setattr(self, key, val) 35 | 36 | @classmethod 37 | @abstractmethod 38 | def new(cls, ctx: commands.Context, note_id: int, member_id: int, message: str, *, is_warning: bool = False): 39 | """Initialise the class in a command context""" 40 | pass 41 | 42 | @classmethod 43 | @abstractmethod 44 | def from_storage(cls, ctx: commands.Context, data: dict, *, is_warning: bool = False): 45 | """Initialise the class from a config record""" 46 | pass 47 | 48 | @abstractmethod 49 | def __str__(self) -> str: 50 | """The string representation of the class. Used primarily in message embeds""" 51 | pass 52 | 53 | @abstractmethod 54 | def __lt__(self, other) -> bool: 55 | """Important for chronological sorting. Compares the created_at attribute of the instances""" 56 | pass 57 | 58 | @abstractmethod 59 | def delete(self): 60 | """Sets the deleted value to True""" 61 | pass 62 | 63 | @abstractmethod 64 | def undelete(self): 65 | """Sets the deleted value to False""" 66 | pass 67 | 68 | @abstractmethod 69 | def to_dict(self) -> dict: 70 | """Returns a dictionary representation of the class, suitable for storing in config""" 71 | pass 72 | 73 | 74 | class ConfigHelperABC(ABC): 75 | config: Config 76 | 77 | @staticmethod 78 | @abstractmethod 79 | def filter_not_deleted(note: NoteABC) -> bool: 80 | pass 81 | 82 | @staticmethod 83 | @abstractmethod 84 | def filter_match_user_id(user_id: int) -> Callable[[NoteABC], bool]: 85 | pass 86 | 87 | @abstractmethod 88 | def sorted_notes(self, notes: Iterable[NoteABC]) -> List[NoteABC]: 89 | """Sorts notes by creation date""" 90 | pass 91 | 92 | @abstractmethod 93 | async def add_note(self, ctx: commands.Context, member_id: int, message: str, *, is_warning: bool) -> NoteABC: 94 | pass 95 | 96 | @abstractmethod 97 | async def get_all_notes(self, ctx: commands.Context) -> List[NoteABC]: 98 | pass 99 | 100 | @abstractmethod 101 | async def get_notes_by_user(self, ctx: commands.Context, user: MAYBE_MEMBER) -> List[NoteABC]: 102 | pass 103 | 104 | @abstractmethod 105 | async def delete_note(self, ctx: commands.Context, note_id: int, *, is_warning: bool): 106 | pass 107 | 108 | @abstractmethod 109 | async def restore_note(self, ctx: commands.Context, note_id: int, *, is_warning: bool): 110 | pass 111 | -------------------------------------------------------------------------------- /notes/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Issy" 4 | ], 5 | "short": "Mod notes", 6 | "description": "Add notes about users", 7 | "disabled": false, 8 | "name": "notes", 9 | "tags": [ 10 | "notes", 11 | "mod", 12 | "log" 13 | ], 14 | "install_msg": "Usage: `[p]notes`", 15 | "min_bot_version": "3.5.1" 16 | } -------------------------------------------------------------------------------- /notes/notes.py: -------------------------------------------------------------------------------- 1 | """discord red-bot notes""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Optional 6 | 7 | import discord 8 | from redbot.core import checks, commands 9 | from redbot.core.utils.chat_formatting import escape, pagify 10 | from redbot.core.utils.menus import close_menu, menu, next_page, prev_page 11 | 12 | from .utils import MAYBE_MEMBER, ConfigHelper, NoteException 13 | 14 | 15 | def invoked_warning_cmd(ctx: commands.Context) -> bool: 16 | """Useful for finding which alias triggered the command. Checks 17 | against the invoked parents attribute. Can only be used in subcommands.""" 18 | return ctx.invoked_parents[0].startswith("warning") 19 | 20 | 21 | class NotesCog(commands.Cog): 22 | """Notes Cog""" 23 | 24 | config: ConfigHelper 25 | 26 | def __init__(self): 27 | super().__init__() 28 | self.config = ConfigHelper() 29 | 30 | # Command groups 31 | 32 | @commands.guild_only() 33 | @checks.mod() 34 | @commands.group(name="notes", aliases=["note"]) 35 | async def _notes(self, ctx: commands.Context): 36 | pass 37 | 38 | @commands.guild_only() 39 | @checks.mod() 40 | @commands.group(name="warnings", aliases=["warning"]) 41 | async def _warnings(self, ctx: commands.Context): 42 | pass 43 | 44 | # Note commands 45 | 46 | @_notes.command("add") 47 | async def notes_add( 48 | self, 49 | ctx: commands.Context, 50 | user: MAYBE_MEMBER, 51 | *, 52 | message: str, 53 | ): 54 | """Log a note against a user.""" 55 | note = await self.config.add_note(ctx, user, message, is_warning=False) 56 | await ctx.send(f"Note added (ID: {note.note_id}).") 57 | 58 | @_notes.command("delete") 59 | async def notes_delete(self, ctx: commands.Context, note_id: int): 60 | """Deletes a note.""" 61 | try: 62 | await self.config.delete_note(ctx, note_id, is_warning=False) 63 | await ctx.send("Note deleted.") 64 | except NoteException as error_message: 65 | await ctx.send(str(error_message)) 66 | 67 | @_notes.command("restore") 68 | async def notes_restore(self, ctx: commands.Context, note_id: int): 69 | """Restores a deleted note.""" 70 | try: 71 | await self.config.restore_note(ctx, note_id, is_warning=False) 72 | await ctx.send("Note restored.") 73 | except NoteException as error_message: 74 | await ctx.send(str(error_message)) 75 | 76 | # Warning commands 77 | 78 | @_warnings.command("add") 79 | async def warning_add( 80 | self, 81 | ctx: commands.Context, 82 | user: MAYBE_MEMBER, 83 | *, 84 | message: str, 85 | ): 86 | """Log a warning against a user.""" 87 | note = await self.config.add_note(ctx, user, message, is_warning=True) 88 | await ctx.send(f"Warning added (ID: {note.note_id}).") 89 | 90 | @_warnings.command("delete") 91 | async def warning_delete(self, ctx: commands.Context, note_id: int): 92 | """Deletes a warning.""" 93 | try: 94 | await self.config.delete_note(ctx, note_id, is_warning=True) 95 | await ctx.send("Warning deleted.") 96 | except NoteException as error_message: 97 | await ctx.send(str(error_message)) 98 | 99 | @_warnings.command("restore") 100 | async def warning_restore(self, ctx: commands.Context, note_id: int): 101 | """Restores a deleted warning.""" 102 | try: 103 | await self.config.restore_note(ctx, note_id, is_warning=True) 104 | await ctx.send("Warning restored.") 105 | except NoteException as error_message: 106 | await ctx.send(str(error_message)) 107 | 108 | # General commands 109 | 110 | @checks.bot_has_permissions(embed_links=True) 111 | @_notes.command("list") 112 | async def notes_list(self, ctx: commands.Context, *, user: Optional[MAYBE_MEMBER] = None): 113 | """Lists notes and warnings for everyone or a specific user.""" 114 | notes = await (self.config.get_notes_by_user(ctx, user) if user else self.config.get_all_notes(ctx)) 115 | if not notes: 116 | return await ctx.send("No notes to display.") 117 | 118 | # Get number of notes and warnings 119 | num_notes = len([n for n in notes if not n.is_warning]) 120 | num_warnings = len([n for n in notes if n.is_warning]) 121 | 122 | # Convert to strings and pagify 123 | pages = list(pagify("\n\n".join(str(n) for n in notes))) 124 | 125 | # Create embeds from pagified data 126 | notes_target: Optional[str] = ( 127 | escape(getattr(user, "display_name", str(user.id)), formatting=True) if user is not None else None 128 | ) 129 | base_embed_options = { 130 | "title": ( 131 | (f"Notes for {notes_target}" if notes_target else "All notes") 132 | + f" - ({num_warnings} warnings, {num_notes} notes)" 133 | ), 134 | "colour": await ctx.embed_colour(), 135 | } 136 | embeds = [ 137 | discord.Embed(**base_embed_options, description=page).set_footer(text=f"Page {index} of {len(pages)}") 138 | for index, page in enumerate(pages, start=1) 139 | ] 140 | 141 | if len(embeds) == 1: 142 | await ctx.send(embed=embeds[0]) 143 | else: 144 | ctx.bot.loop.create_task( 145 | menu(ctx=ctx, pages=embeds, controls={"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page}, timeout=180.0) 146 | ) 147 | 148 | @checks.bot_has_permissions(embed_links=True) 149 | @_notes.command("status") 150 | async def notes_status(self, ctx: commands.Context): 151 | """ 152 | Status of the cog. 153 | The bot will display how many notes it has recorded since it's inception. 154 | """ 155 | all_notes = await self.config.get_all_notes(ctx) 156 | await ctx.send( 157 | embed=( 158 | discord.Embed(title="Notes Status", colour=await ctx.embed_colour()) 159 | .add_field(name="Notes", value=str(len([n for n in all_notes if not n.is_warning]))) 160 | .add_field(name="Warnings", value=str(len([n for n in all_notes if n.is_warning]))) 161 | ) 162 | ) 163 | -------------------------------------------------------------------------------- /notes/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Callable, Iterable, List, Union 3 | 4 | import discord 5 | from redbot.core import Config, commands 6 | from redbot.core.utils.chat_formatting import escape 7 | 8 | from .abstracts import ConfigHelperABC, NoteABC 9 | 10 | MAYBE_MEMBER = Union[discord.Member, discord.Object] 11 | 12 | 13 | class NoteException(Exception): 14 | pass 15 | 16 | 17 | class Note(NoteABC): 18 | @classmethod 19 | def new(cls, ctx: commands.Context, note_id: int, member_id: int, message: str, *, is_warning: bool = False): 20 | return cls( 21 | note_id=note_id, 22 | member_id=member_id, 23 | message=message, 24 | reporter_id=ctx.author.id, 25 | reporter_name=ctx.author.name, 26 | created_at=int(datetime.utcnow().timestamp()), 27 | deleted=False, 28 | is_warning=is_warning, 29 | _guild=ctx.guild, 30 | ) 31 | 32 | @classmethod 33 | def from_storage(cls, ctx: commands.Context, data: dict, *, is_warning: bool = False): 34 | return cls( 35 | note_id=data["id"], 36 | member_id=int(data["member"]), # FIXME: Migrate all stored values to int 37 | message=data["message"], 38 | reporter_id=data["reporter"], 39 | reporter_name=data["reporterstr"], 40 | created_at=int(data["date"]), # FIXME: Migrate all stored values to int 41 | deleted=data["deleted"], 42 | is_warning=is_warning, 43 | _guild=ctx.guild, 44 | ) 45 | 46 | def __str__(self) -> str: 47 | icon = "\N{WARNING SIGN}" if self.is_warning else "\N{MEMO}" 48 | member_name = escape(str(self._guild.get_member(self.member_id) or self.member_id), formatting=True) 49 | reporter_name = self._guild.get_member(self.reporter_id) or self.reporter_name 50 | return ( 51 | f"{icon} #{self.note_id} **{member_name} - Added by {reporter_name}** " 52 | f"- \n{self.message}" 53 | ) 54 | 55 | def __lt__(self, other) -> bool: 56 | return self.created_at < other.created_at 57 | 58 | def delete(self): 59 | self.deleted = True 60 | return self 61 | 62 | def undelete(self): 63 | self.deleted = False 64 | return self 65 | 66 | def to_dict(self) -> dict: 67 | return { 68 | "id": self.note_id, 69 | "member": self.member_id, 70 | "message": self.message, 71 | "reporter": self.reporter_id, 72 | "reporterstr": self.reporter_name, 73 | "date": self.created_at, 74 | "deleted": self.deleted, 75 | } 76 | 77 | 78 | class ConfigHelper(ConfigHelperABC): 79 | def __init__(self): 80 | self.config = Config.get_conf(None, identifier=127318281, cog_name="NotesCog") 81 | self.config.register_guild(notes=[], warnings=[]) 82 | 83 | @staticmethod 84 | def filter_not_deleted(note: Note) -> bool: 85 | return not note.deleted 86 | 87 | @staticmethod 88 | def filter_match_user_id(user_id: int) -> Callable[[Note], bool]: 89 | def predicate(note: Note) -> bool: 90 | return note.member_id == user_id 91 | 92 | return predicate 93 | 94 | def sorted_notes(self, notes: Iterable[Note]) -> List[Note]: 95 | return sorted(filter(self.filter_not_deleted, notes), key=lambda note: note.created_at) 96 | 97 | async def add_note(self, ctx: commands.Context, user: MAYBE_MEMBER, message: str, *, is_warning: bool) -> Note: 98 | note = None 99 | async with getattr(self.config.guild(ctx.guild), "warnings" if is_warning else "notes")() as notes: 100 | note = Note.new(ctx, len(notes) + 1, user.id, message, is_warning=is_warning) 101 | notes.append(note.to_dict()) 102 | return note 103 | 104 | async def get_all_notes(self, ctx: commands.Context) -> List[Note]: 105 | config_group = self.config.guild(ctx.guild) 106 | notes = [Note.from_storage(ctx, data) for data in await config_group.notes()] 107 | warnings = [Note.from_storage(ctx, data, is_warning=True) for data in await config_group.warnings()] 108 | return self.sorted_notes(warnings + notes) 109 | 110 | async def get_notes_by_user(self, ctx: commands.Context, user: MAYBE_MEMBER) -> List[Note]: 111 | config_group = self.config.guild(ctx.guild) 112 | notes = [Note.from_storage(ctx, data) for data in await config_group.notes()] 113 | warnings = [Note.from_storage(ctx, data, is_warning=True) for data in await config_group.warnings()] 114 | return self.sorted_notes(filter(self.filter_match_user_id(user.id), notes + warnings)) 115 | 116 | async def delete_note(self, ctx: commands.Context, note_id: int, *, is_warning: bool): 117 | async with getattr(self.config.guild(ctx.guild), "warnings" if is_warning else "notes")() as notes: 118 | try: 119 | note = Note.from_storage(ctx, notes[note_id - 1], is_warning=is_warning) 120 | except IndexError: 121 | raise NoteException(f"Note with ID {note_id} could not be found.") 122 | 123 | if note.member_id == ctx.author.id: 124 | raise NoteException("You cannot manage this note.") 125 | 126 | if note.deleted: 127 | raise NoteException("Note already deleted.") 128 | 129 | notes[note.note_id - 1] = note.delete().to_dict() 130 | 131 | async def restore_note(self, ctx: commands.Context, note_id: int, *, is_warning: bool): 132 | async with getattr(self.config.guild(ctx.guild), "warnings" if is_warning else "notes")() as notes: 133 | try: 134 | note = Note.from_storage(ctx, notes[note_id - 1], is_warning=is_warning) 135 | except IndexError: 136 | raise NoteException(f"Note with ID {note_id} could not be found.") 137 | 138 | if note.member_id == ctx.author.id: 139 | raise NoteException("You cannot manage this note.") 140 | 141 | if not note.deleted: 142 | raise NoteException("Note not already deleted.") 143 | 144 | notes[note.note_id - 1] = note.undelete().to_dict() 145 | -------------------------------------------------------------------------------- /onboarding_role/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | from redbot.core.utils import get_end_user_data_statement_or_raise 3 | 4 | from .onboarding_role import OnboardingRole 5 | 6 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 7 | 8 | 9 | async def setup(bot: Red) -> None: 10 | await bot.add_cog(OnboardingRole(bot)) 11 | -------------------------------------------------------------------------------- /onboarding_role/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "onboarding_role", 3 | "short": "Apply a role to users who complete onboarding.", 4 | "description": "Apply a role to users who complete onboarding.", 5 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users.", 6 | "install_msg": "Usage: `[p]onboarding_role`", 7 | "author": [ 8 | "tigattack" 9 | ], 10 | "required_cogs": {}, 11 | "requirements": [], 12 | "tags": [ 13 | "utility", 14 | "mod" 15 | ], 16 | "min_bot_version": "3.5.0", 17 | "hidden": false, 18 | "disabled": false, 19 | "type": "COG" 20 | } 21 | -------------------------------------------------------------------------------- /onboarding_role/onboarding_role.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime, timedelta, timezone 3 | from typing import Literal 4 | 5 | import discord 6 | from redbot.core import checks, commands 7 | from redbot.core.bot import Red 8 | from redbot.core.config import Config 9 | 10 | RequestType = Literal["discord_deleted_user", "owner", "user", "user_strict"] 11 | log = logging.getLogger("red.rhomelab.onboarding_role") 12 | 13 | 14 | class OnboardingRole(commands.Cog): 15 | """ 16 | Apply a role to users who complete onboarding. 17 | """ 18 | 19 | def __init__(self, bot: Red) -> None: 20 | self.bot = bot 21 | self.config = Config.get_conf( 22 | self, 23 | identifier=251776415735873537, 24 | force_registration=True, 25 | ) 26 | 27 | default_guild_settings = { 28 | "role": None, 29 | "log_channel": None, 30 | "onboarded_users": [], 31 | } 32 | 33 | self.config.register_guild(**default_guild_settings) 34 | 35 | async def red_delete_data_for_user(self, *, requester: RequestType, user_id: int) -> None: # type: ignore 36 | await super().red_delete_data_for_user(requester=requester, user_id=user_id) 37 | 38 | # Listeners 39 | 40 | @commands.Cog.listener() 41 | async def on_member_update(self, before: discord.Member, after: discord.Member): 42 | """Listen for onboarding completed event""" 43 | if after.bot: 44 | # Member is a bot 45 | return 46 | 47 | if before.flags.completed_onboarding == after.flags.completed_onboarding or not after.flags.completed_onboarding: 48 | # Onboarding state is not changed or onboarding is not complete 49 | return 50 | 51 | await self.handle_onboarding(after) 52 | 53 | @commands.Cog.listener() 54 | async def on_ready(self): 55 | """ 56 | Listen for on_ready event. 57 | When event fires, add onboarded role to members in all guilds who meet the following criteria: 58 | - Not in `onboarded_users` list. 59 | - Has completed onboarding. 60 | - Does not have the onboarded role. 61 | """ 62 | for guild in self.bot.guilds: 63 | onboarded_role_id = await self.config.guild(guild).role() 64 | onboarded_role = guild.get_role(onboarded_role_id) 65 | if not onboarded_role: 66 | # Role not found 67 | log.warning(f"Role ID {onboarded_role_id} not found in guild {guild.name} (ID {guild.id}).") 68 | continue 69 | 70 | onboarded_users = await self.config.guild(guild).onboarded_users() 71 | for member in guild.members: 72 | if member not in onboarded_users and member.flags.completed_onboarding and onboarded_role not in member.roles: 73 | await self.handle_onboarding(member) 74 | 75 | # Commands 76 | 77 | @commands.group() # type: ignore 78 | @commands.guild_only() 79 | @checks.mod() 80 | async def onboarding_role(self, ctx: commands.GuildContext): 81 | pass 82 | 83 | @onboarding_role.command("status") 84 | async def get_status(self, ctx: commands.GuildContext): 85 | """Status of the cog.""" 86 | onboarded_role = "⚠️ Unset" 87 | log_channel = "⚠️ Unset" 88 | 89 | role_id = await self.config.guild(ctx.guild).role() 90 | log_channel_id = await self.config.guild(ctx.guild).log_channel() 91 | 92 | if role_id: 93 | onboarded_role = ctx.guild.get_role(role_id) 94 | if onboarded_role: 95 | onboarded_role = onboarded_role.name 96 | else: 97 | onboarded_role = f"Set to role with ID `{role_id}`, but could not find role!" 98 | 99 | if log_channel_id: 100 | log_channel = ctx.guild.get_channel(log_channel_id) 101 | if log_channel: 102 | log_channel = log_channel.mention 103 | else: 104 | log_channel = f"Set to channel with ID `{log_channel_id}`, but could not find channel!" 105 | 106 | num_onboarded_users = len(await self.config.guild(ctx.guild).onboarded_users()) 107 | 108 | embed = ( 109 | discord.Embed(colour=(await ctx.embed_colour())) 110 | .add_field(name="Onboarded Role", value=onboarded_role) 111 | .add_field(name="Log Channnel", value=log_channel) 112 | .add_field(name="Onboarded User Count", value=num_onboarded_users, inline=False) 113 | ) 114 | 115 | try: 116 | await ctx.send(embed=embed) 117 | except discord.Forbidden: 118 | await ctx.send("I need the `Embed links` permission to send status.") 119 | 120 | @onboarding_role.command("role") 121 | async def set_role(self, ctx: commands.GuildContext, role: discord.Role): 122 | """ 123 | Set the onboarding role. 124 | 125 | Examples: 126 | - `[p]onboarding_role role Users` 127 | - `[p]onboarding_role role 1253932390562590999` 128 | """ 129 | await self.config.guild(ctx.guild).role.set(role.id) 130 | log.debug(f"Onboarded role set to {role.name} (ID {role.id})") 131 | await ctx.tick() 132 | 133 | @onboarding_role.command("logchannel") 134 | async def set_log_channel(self, ctx: commands.GuildContext, channel: discord.TextChannel): 135 | """ 136 | Set the log channel for onboarding events. 137 | 138 | Examples: 139 | - `[p]onboarding_role logchannel #log` 140 | - `[p]onboarding_role logchannel 1262905457120583720` 141 | """ 142 | if channel.permissions_for(ctx.me).send_messages and channel.permissions_for(ctx.me).embed_links: 143 | await self.config.guild(ctx.guild).log_channel.set(channel.id) 144 | log.debug(f"Log channel set to {channel.name} (ID {channel.id})") 145 | await ctx.tick() 146 | else: 147 | await ctx.send(f"❌ I need the `Send Messages` and `Embed Links` permissions to send logs to {channel.mention}.") 148 | 149 | # Helpers 150 | 151 | async def handle_onboarding(self, member: discord.Member): 152 | """Handle onboarding completed event""" 153 | log.debug(f"User '{member.name}' (ID {member.id}) completed onboarding") 154 | guild = member.guild 155 | role_id = await self.config.guild(guild).role() 156 | 157 | if not role_id: 158 | # Welcome role is not set for this guild 159 | log.warning(f"Cannot grant onboarding role to '{member.name}' (ID {member.id}): Onboarding role not set.") 160 | return 161 | 162 | role = guild.get_role(role_id) 163 | if not role: 164 | # Welcome role is not found 165 | log.warning( 166 | f"Cannot grant onboarding role to '{member.name}' (ID {member.id}): " 167 | + f"Onboarding role set to ID {role_id} but could not found." 168 | ) 169 | return 170 | 171 | try: 172 | await member.add_roles(role) 173 | log.info(f"User '{member.name}' (ID {member.id}) completed onboarding and was added to the onboarding role.") 174 | 175 | async with self.config.guild(guild).onboarded_users() as onboarded_users: 176 | if member.id not in onboarded_users: 177 | onboarded_users.append(member.id) 178 | log.debug(f"User '{member.name}' (ID {member.id}) added to internal list of onboarded users.") 179 | 180 | await self.send_log_message(member) 181 | except discord.Forbidden: 182 | error_msg = f"Adding onboarding role to {member.name} (ID {member.id}) was forbidden." 183 | log.warning(error_msg) 184 | await self.send_log_message(member, error_msg) 185 | 186 | async def send_log_message(self, member: discord.Member, error_msg: str = ""): 187 | """Send success or failure message to configured log channel""" 188 | log_channel_id = await self.config.guild(member.guild).log_channel() 189 | if not log_channel_id: 190 | # Log channel not defined. 191 | # We won't log a warning here since we'll assume the user does not 192 | # wish for onboarding events to be logged to a channel. 193 | return 194 | log_channel = member.guild.get_channel(log_channel_id) 195 | if not log_channel or not isinstance(log_channel, discord.TextChannel): 196 | # Log channel not found or invalid 197 | log.warning( 198 | "Attempted to send log message for onboarding completion, " 199 | + f"but the configured log channel with ID {log_channel_id} could not be found." 200 | ) 201 | return 202 | 203 | if member.joined_at is None: 204 | onboarding_time = None 205 | log.debug("User's 'joined_at' attribute is None; could not calculate time to onboarding completion.") 206 | else: 207 | onboarding_time = humanise_timedelta(datetime.now(tz=timezone.utc) - member.joined_at) 208 | 209 | embed = discord.Embed(colour=(await self.bot.get_embed_colour(log_channel)), title="User Completed Onboarding") 210 | embed.add_field(name="Member", value=member.mention) 211 | if onboarding_time: 212 | embed.add_field(name="Time to Completion", value=str(onboarding_time)) 213 | 214 | if error_msg: 215 | embed.add_field(name="⚠️ Error", value=error_msg, inline=False) 216 | else: 217 | embed.description = "User added to onboarding role." 218 | 219 | try: 220 | await log_channel.send(embed=embed) 221 | except discord.Forbidden: 222 | log.warning(f"Sending onboarding log to {log_channel.name} (ID {log_channel_id}) was forbidden.") 223 | 224 | 225 | def humanise_timedelta(delta: timedelta) -> str: 226 | days = delta.days 227 | seconds = delta.seconds 228 | hours = seconds // 3600 229 | minutes = (seconds % 3600) // 60 230 | seconds = seconds % 60 231 | 232 | # Format the output 233 | parts = [] 234 | if days: 235 | parts.append(f"{days} day{'s' if days > 1 else ''}") 236 | if hours: 237 | parts.append(f"{hours} hour{'s' if hours > 1 else ''}") 238 | if minutes: 239 | parts.append(f"{minutes} minute{'s' if minutes > 1 else ''}") 240 | if seconds: 241 | parts.append(f"{seconds} second{'s' if seconds > 1 else ''}") 242 | 243 | return ", ".join(parts) 244 | -------------------------------------------------------------------------------- /penis/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .penis import Penis 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(Penis()) 8 | -------------------------------------------------------------------------------- /penis/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "baiumbg", 4 | "Twentysix", 5 | "TheDevFreak", 6 | "tigattack" 7 | ], 8 | "description": "Usage: [p]penis \nThis is 100% accurate.", 9 | "short": "Detects users' penis size with maximum accuracy.", 10 | "name": "Penis", 11 | "tags": [ 12 | "fun" 13 | ], 14 | "min_bot_version": "3.5.1" 15 | } -------------------------------------------------------------------------------- /penis/penis.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import discord 4 | from redbot.core import commands 5 | from redbot.core.utils.chat_formatting import pagify 6 | 7 | DONG_DISTRIBUTION_CONST = 30 8 | SMALL_DONG_CONST = 6 9 | BIG_DONG_CONST = DONG_DISTRIBUTION_CONST - SMALL_DONG_CONST 10 | 11 | class Penis(commands.Cog): 12 | """Penis related commands.""" 13 | 14 | def __init__(self): 15 | pass 16 | 17 | @commands.command() 18 | async def penis(self, ctx, *users: discord.Member): 19 | """Detects user's penis length 20 | 21 | This is 100% accurate. 22 | Enter multiple users for an accurate comparison!""" 23 | 24 | dongs = {} 25 | msg = "" 26 | state = random.getstate() 27 | 28 | if len(users) == 0: 29 | users = (ctx.author,) 30 | 31 | for user in users: 32 | random.seed(user.id) 33 | dongs[user] = "8{}D".format("=" * random.randint(0, DONG_DISTRIBUTION_CONST)) 34 | 35 | random.setstate(state) 36 | dongs = sorted(dongs.items(), key=lambda x: x[1]) 37 | 38 | for user, dong in dongs: 39 | if len(dong) <= SMALL_DONG_CONST: 40 | msg += "**{}'s size:**\n{}\nlol small\n".format(user.display_name, dong) 41 | elif len(dong) <= BIG_DONG_CONST: 42 | msg += "**{}'s size:**\n{}\n".format(user.display_name, dong) 43 | else: 44 | msg += "**{}'s size:**\n{}\nwow, now that's a dong!\n".format(user.display_name, dong) 45 | 46 | for page in pagify(msg): 47 | await ctx.send(page) 48 | -------------------------------------------------------------------------------- /phishingdetection/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .phishingdetection import PhishingDetectionCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(PhishingDetectionCog(bot)) 8 | -------------------------------------------------------------------------------- /phishingdetection/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Issy" 4 | ], 5 | "short": "Remove messages containing phishing links", 6 | "description": "Remove messages containing phishing links (data sourced from https://phish.sinking.yachts/)", 7 | "disabled": false, 8 | "name": "phishingdetection", 9 | "tags": [ 10 | "moderation", 11 | "utility", 12 | "scam", 13 | "prevention" 14 | ], 15 | "install_msg": "Thanks for installing", 16 | "min_bot_version": "3.5.1" 17 | } -------------------------------------------------------------------------------- /phishingdetection/phishingdetection.py: -------------------------------------------------------------------------------- 1 | """discord red-bot phishing link detection""" 2 | 3 | import re 4 | from typing import Callable, List, Literal, Optional, Set, TypedDict 5 | 6 | import aiohttp 7 | import discord 8 | from discord.ext import tasks 9 | from redbot.core import commands 10 | from redbot.core.bot import Red 11 | 12 | 13 | def api_endpoint(endpoint: str) -> str: 14 | return f"https://phish.sinking.yachts/v2{endpoint}" 15 | 16 | 17 | def generate_predicate_from_urls(urls: Set[str]) -> Callable[[str], bool]: 18 | urls_section = "|".join(re.escape(url) for url in urls) 19 | pattern = re.compile(f"(^| )(http[s]?://)?(www\\.)?({urls_section})(/|/[^ \n]+)?($| )") 20 | 21 | def predicate(content: str) -> bool: 22 | return bool(pattern.search(content)) 23 | 24 | return predicate 25 | 26 | 27 | class DomainUpdate(TypedDict): 28 | type: Literal["add", "delete"] 29 | domains: List[str] 30 | 31 | 32 | async def get_all_urls(session: aiohttp.ClientSession) -> Set[str]: 33 | async with session.get(api_endpoint("/all")) as response: 34 | urls: List[str] = await response.json() 35 | if not isinstance(urls, list) or not all([isinstance(i, str) for i in urls]): 36 | raise TypeError 37 | return set(urls) 38 | 39 | 40 | async def get_updates_from_timeframe(session: aiohttp.ClientSession, num_seconds: int) -> List[DomainUpdate]: 41 | async with session.get(api_endpoint(f"/recent/{num_seconds}")) as response: 42 | updates: List[DomainUpdate] = await response.json() 43 | if not isinstance(updates, list): 44 | raise TypeError 45 | return updates 46 | 47 | 48 | class PhishingDetectionCog(commands.Cog): 49 | """Phishing link detection cog""" 50 | 51 | predicate: Optional[Callable[[str], bool]] = None 52 | urls: Set[str] 53 | session: aiohttp.ClientSession 54 | 55 | def __init__(self, bot: Red): 56 | self.bot = bot 57 | self.session = aiohttp.ClientSession( 58 | headers={ 59 | "X-Identity": "A Red-DiscordBot instance using the phishingdetection cog from https://github.com/rhomelab/labbot-cogs" 60 | } 61 | ) 62 | self.initialise_url_set.start() 63 | 64 | async def cog_unload(self): 65 | self.initialise_url_set.cancel() 66 | self.update_urls.cancel() 67 | self.bot.loop.run_until_complete(self.session.close()) 68 | 69 | @tasks.loop(hours=1.0) 70 | async def initialise_url_set(self): 71 | """Fetch the initial list of URLs and set the regex pattern""" 72 | try: 73 | urls = await get_all_urls(self.session) 74 | except TypeError: 75 | return 76 | 77 | self.urls = urls 78 | self.predicate = generate_predicate_from_urls(self.urls) 79 | 80 | self.update_urls.start() 81 | self.initialise_url_set.cancel() # type: ignore 82 | 83 | @tasks.loop(hours=1.0) 84 | async def update_urls(self): 85 | """Fetch the list of phishing URLs and update the regex pattern""" 86 | # TODO: Use the websocket API to get live updates 87 | # Using 3660 (1 hour + 1 minute) instead of 3600 (1 hour) to prevent missing updates 88 | # This is fine, as we store the URLs in a set, 89 | # so duplicate add/remove operations do not result in missing/duplicate data 90 | updates = await get_updates_from_timeframe(self.session, 3600) 91 | for update in updates: 92 | if update["type"] == "add": 93 | for domain in update["domains"]: 94 | self.urls.add(domain) 95 | elif update["type"] == "delete": 96 | for domain in update["domains"]: 97 | try: 98 | self.urls.remove(domain) 99 | except KeyError: 100 | pass 101 | 102 | self.predicate = generate_predicate_from_urls(self.urls) 103 | 104 | @commands.Cog.listener() 105 | async def on_message(self, message: discord.Message): 106 | if self.predicate is None: 107 | # It's possible that the initialisation task has not completed yet 108 | return 109 | 110 | if not self.predicate(message.content): 111 | # No phishing links detected 112 | return 113 | 114 | # TODO: Maybe log this somewhere? 115 | await message.delete() 116 | -------------------------------------------------------------------------------- /prometheus_exporter/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .main import PromExporter 4 | 5 | 6 | async def setup(bot: Red): 7 | prom = PromExporter(bot) 8 | await prom.init() 9 | await bot.add_cog(prom) 10 | -------------------------------------------------------------------------------- /prometheus_exporter/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "ripplefcl", 4 | "tigattack" 5 | ], 6 | "description": "Exposes a HTTP endpoint for exporting guild metrics in Prometheus format", 7 | "short": "Exports guild metrics in Prometheus format", 8 | "name": "prometheus_exporter", 9 | "tags": [ 10 | "metrics", 11 | "prom" 12 | ], 13 | "min_bot_version": "3.5.1", 14 | "requirements": ["prometheus_client"], 15 | "install_msg": "Usage: `[p]prom_export`" 16 | } 17 | -------------------------------------------------------------------------------- /prometheus_exporter/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import discord 4 | from redbot.core import Config, checks, commands 5 | from redbot.core.bot import Red 6 | 7 | from .prom_server import PrometheusMetricsServer, promServer 8 | from .stats import Poller, statApi 9 | 10 | logger = logging.getLogger("red.rhomelab.prom") 11 | 12 | 13 | class PromExporter(commands.Cog): 14 | """commands for managing the prom exporter""" 15 | 16 | def __init__(self, bot: Red): 17 | self.bot = bot 18 | logger.info("initialising") 19 | self.address = "0.0.0.0" 20 | self.port = 9000 21 | self.poll_frequency = 1 22 | 23 | self.config = Config.get_conf(self, identifier=19283750192891838) 24 | 25 | default_global = {"address": "0.0.0.0", "port": 9900, "poll_interval": 1} 26 | self.config.register_global(**default_global) 27 | 28 | self.prom_server = None 29 | self.stat_api = None 30 | 31 | async def init(self): 32 | self.address = await self.config.address() 33 | self.port = await self.config.port() 34 | # we cast the interval to integer to avoid f25e678 from being a breaking change :3 35 | self.poll_frequency = int(await self.config.poll_interval()) 36 | self.start() 37 | 38 | @staticmethod 39 | def create_server(address: str, port: int): 40 | return promServer(address, port) 41 | 42 | @staticmethod 43 | def create_stat_api(prefix: str, poll_frequency: int, bot: Red, server: PrometheusMetricsServer) -> statApi: 44 | return Poller(prefix, poll_frequency, bot, server) 45 | 46 | @commands.group() 47 | async def prom_export(self, ctx: commands.Context): 48 | """Red Bot Prometheus Exporter""" 49 | 50 | @checks.is_owner() 51 | @prom_export.command() 52 | async def set_port(self, ctx: commands.Context, port: int): 53 | """Set the port the HTTP server should listen on""" 54 | logger.info(f"changing port to {port}") 55 | self.port = port 56 | await self.config.port.set(port) 57 | self.reload() 58 | await ctx.tick() 59 | 60 | @checks.is_owner() 61 | @prom_export.command() 62 | async def set_address(self, ctx: commands.Context, address: str): 63 | """Sets the bind address (IP) of the HTTP server""" 64 | 65 | logger.info(f"changing address to {address}") 66 | 67 | self.address = address 68 | await self.config.address.set(address) 69 | self.reload() 70 | await ctx.tick() 71 | 72 | @checks.is_owner() 73 | @prom_export.command() 74 | async def set_poll_interval(self, ctx: commands.Context, poll_interval: int): 75 | """Set the metrics poll interval (seconds)""" 76 | 77 | logger.info(f"changing poll interval to {poll_interval}") 78 | self.poll_frequency = poll_interval 79 | await self.config.poll_interval.set(poll_interval) 80 | self.reload() 81 | await ctx.tick() 82 | 83 | @checks.is_owner() 84 | @prom_export.command(name="config") 85 | async def show_config(self, ctx: commands.Context): 86 | """Show the current config""" 87 | conf_embed = ( 88 | discord.Embed(title="Role info", colour=await ctx.embed_colour()) 89 | .add_field(name="Address", value=self.address) 90 | .add_field(name="Port", value=self.port) 91 | .add_field(name="Poll Frequency", value=self.poll_frequency) 92 | ) 93 | await ctx.send(embed=conf_embed) 94 | 95 | def start(self): 96 | self.prom_server = self.create_server(self.address, self.port) 97 | self.stat_api = self.create_stat_api("discord_metrics", self.poll_frequency, self.bot, self.prom_server) 98 | 99 | self.prom_server.serve() 100 | self.stat_api.start() 101 | 102 | def stop(self): 103 | if self.prom_server: 104 | self.prom_server.stop() 105 | if self.stat_api: 106 | self.stat_api.stop() 107 | 108 | logger.info("stopped server process") 109 | 110 | def reload(self): 111 | logger.info("reloading") 112 | self.stop() 113 | self.start() 114 | logger.info("reloading complete") 115 | 116 | async def cog_unload(self): 117 | self.stop() 118 | logger.info("cog unloading") 119 | -------------------------------------------------------------------------------- /prometheus_exporter/prom_server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import socket 3 | import threading 4 | from functools import partial 5 | from typing import Protocol 6 | from wsgiref.simple_server import WSGIRequestHandler, make_server 7 | 8 | from prometheus_client import CollectorRegistry, make_wsgi_app 9 | 10 | logger = logging.getLogger("red.rhomelab.prom.server") 11 | 12 | 13 | class _SilentHandler(WSGIRequestHandler): 14 | """WSGI handler that does not log requests.""" 15 | 16 | def log_message(self, format, *args): 17 | """Log nothing.""" 18 | 19 | 20 | class MetricsServer(Protocol): 21 | def serve(self) -> None: ... 22 | 23 | def stop(self) -> None: ... 24 | 25 | 26 | class PrometheusMetricsServer(MetricsServer, Protocol): 27 | @property 28 | def registry(self) -> CollectorRegistry: ... 29 | 30 | 31 | class promServer(PrometheusMetricsServer): 32 | def __init__(self, addr: str, port: int): 33 | self.addr = addr 34 | self.port = port 35 | 36 | self.server_thread = None 37 | self.server = None 38 | self._registry = self._create_registry() 39 | 40 | def _create_registry(self) -> CollectorRegistry: 41 | return CollectorRegistry() 42 | 43 | def _get_best_family(self): 44 | """Automatically select address family depending on address""" 45 | infos = socket.getaddrinfo(self.addr, self.port) 46 | family, _, _, _, sockaddr = next(iter(infos)) 47 | return family, sockaddr[0] 48 | 49 | @property 50 | def registry(self) -> CollectorRegistry: 51 | return self._registry 52 | 53 | def serve(self) -> None: 54 | """Starts a WSGI server for prometheus metrics as a daemon thread.""" 55 | logger.debug("deploying prometheus server") 56 | app = make_wsgi_app(self._registry) 57 | logger.info(f"starting server on {self.addr}:{self.port}") 58 | self.server = make_server(self.addr, self.port, app, handler_class=_SilentHandler) 59 | self.server_thread = threading.Thread(target=partial(self.server.serve_forever, 0.5)) 60 | self.server_thread.daemon = True 61 | self.server_thread.start() 62 | logger.debug("server thread started") 63 | 64 | def stop(self) -> None: 65 | logger.debug("shutting down prom server") 66 | logger.debug("server thread exists: %s, server exists: %s", self.server_thread is not None, self.server is not None) 67 | if self.server_thread is not None and self.server is not None: 68 | logger.debug("prom server running, stopping...") 69 | self.server.shutdown() 70 | self.server.server_close() 71 | self.server_thread.join() 72 | logger.debug("prom server thread joined") 73 | -------------------------------------------------------------------------------- /prometheus_exporter/stats.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import typing 4 | from typing import Optional, Protocol 5 | 6 | import discord 7 | from prometheus_client import Gauge 8 | from redbot.core.bot import Red 9 | 10 | from .utils import timeout 11 | 12 | logger = logging.getLogger("red.rhomelab.prom.stats") 13 | 14 | if typing.TYPE_CHECKING: 15 | from .prom_server import PrometheusMetricsServer 16 | 17 | 18 | class statApi(Protocol): 19 | def __init__(self, prefix: str, poll_frequency: int, bot: Red, server: "PrometheusMetricsServer"): ... 20 | 21 | def start(self) -> None: ... 22 | 23 | def stop(self) -> None: ... 24 | 25 | 26 | class Poller(statApi): 27 | def __init__(self, prefix: str, poll_frequency: int, bot: Red, server: "PrometheusMetricsServer"): 28 | self.bot = bot 29 | self.registry = server.registry 30 | self.poll_frequency = poll_frequency 31 | self.poll_task: Optional[asyncio.Task] = None 32 | 33 | self.bot_latency_gauge = Gauge(f"{prefix}_bot_latency_seconds", "the latency to discord", registry=self.registry) 34 | 35 | self.total_guild_gauge = Gauge( 36 | f"{prefix}_total_guilds_count", "the total number of guilds this bot is in", registry=self.registry 37 | ) 38 | 39 | self.guild_stats_gauge = Gauge( 40 | f"{prefix}_guild_stats_count", "counter stats for each guild", ["server_id", "stat_type"], registry=self.registry 41 | ) 42 | 43 | self.guild_user_status_gauge = Gauge( 44 | f"{prefix}_guild_user_status_count", 45 | "count of each user status in a guild", 46 | ["server_id", "client_type", "status"], 47 | registry=self.registry, 48 | ) 49 | 50 | self.guild_user_activity_gauge = Gauge( 51 | f"{prefix}_guild_user_activity_count", 52 | "count of each user activity in a guild", 53 | ["server_id", "activity"], 54 | registry=self.registry, 55 | ) 56 | 57 | self.guild_voice_stats_gauge = Gauge( 58 | f"{prefix}_guild_voice_stats_count", 59 | "count of voice stats in a guild", 60 | ["server_id", "channel_id", "stat_type"], 61 | registry=self.registry, 62 | ) 63 | 64 | @timeout 65 | async def gather_guild_count_stats(self, guild: discord.Guild): 66 | logger.debug("gathering guild count stats") 67 | emoji_types = [emote.animated for emote in guild.emojis] 68 | data_types = { 69 | "members": len(guild.members), 70 | "voice_channels": len(guild.voice_channels), 71 | "text_channels": len(guild.text_channels), 72 | "categories": len(guild.categories), 73 | "stage_channels": len(guild.stage_channels), 74 | "forums": len(guild.forums), 75 | "roles": len(guild.roles), 76 | "emojis": len(guild.emojis), 77 | "animated_emojis": emoji_types.count(True), 78 | "static_emojis": emoji_types.count(False), 79 | } 80 | for data_type, data in data_types.items(): 81 | logger.debug("setting guild stats gauge server_id:%d, stat_type:%s, data:%s", guild.id, data_type, data) 82 | self.guild_stats_gauge.labels(server_id=guild.id, stat_type=data_type).set(data) 83 | 84 | @timeout 85 | async def gather_user_status_stats(self, guild: discord.Guild): 86 | logger.debug("gathering user status count") 87 | data_types = { 88 | "web": {value: 0 for value in discord.Status}, 89 | "mobile": {value: 0 for value in discord.Status}, 90 | "desktop": {value: 0 for value in discord.Status}, 91 | "total": {value: 0 for value in discord.Status}, 92 | } 93 | 94 | for member in guild.members: 95 | data_types["web"][member.web_status] += 1 96 | data_types["mobile"][member.mobile_status] += 1 97 | data_types["desktop"][member.desktop_status] += 1 98 | data_types["total"][member.status] += 1 99 | 100 | for client_type, statuses in data_types.items(): 101 | for status, count in statuses.items(): 102 | logger.debug( 103 | "setting user status gauge server_id:%d, client_type:%s, status:%s, data:%d", 104 | guild.id, 105 | client_type, 106 | status, 107 | count, 108 | ) 109 | self.guild_user_status_gauge.labels(server_id=guild.id, client_type=client_type, status=status).set(count) 110 | 111 | @timeout 112 | async def gather_user_activity_stats(self, guild: discord.Guild): 113 | logger.debug("gathering user activity stats") 114 | data_types = {value.name: 0 for value in discord.ActivityType if "unknown" not in value.name} 115 | 116 | for member in guild.members: 117 | if member.activity is not None and member.activity.type.name in data_types: 118 | data_types[member.activity.type.name] += 1 119 | logger.debug("post user activity stats collection") 120 | 121 | for data_type, data in data_types.items(): 122 | logger.debug( 123 | "setting user activity gauge server_id:%d, activity:%s, data:%d", 124 | guild.id, 125 | data_type, 126 | data, 127 | ) 128 | self.guild_user_activity_gauge.labels(server_id=guild.id, activity=data_type).set(data) 129 | 130 | @timeout 131 | async def gather_voice_stats(self, guild: discord.Guild): 132 | logger.debug("gathering voice stats") 133 | logger.debug("voice channel count: %d", len(guild.voice_channels)) 134 | 135 | for vc in guild.voice_channels: 136 | data_types = {"capacity": len(vc.members)} 137 | 138 | for data_type, data in data_types.items(): 139 | logger.debug( 140 | "setting voice stats gauge server_id:%d, channel_id:%s, stat_type:%s, data:%d", 141 | guild.id, 142 | vc.id, 143 | data_type, 144 | data, 145 | ) 146 | self.guild_voice_stats_gauge.labels(server_id=guild.id, channel_id=vc.id, stat_type=data_type).set(data) 147 | 148 | async def poll_per_guild_stats(self): 149 | for guild in self.bot.guilds: 150 | await self.gather_guild_count_stats(guild) 151 | await self.gather_user_status_stats(guild) 152 | await self.gather_user_activity_stats(guild) 153 | await self.gather_voice_stats(guild) 154 | 155 | @timeout 156 | async def poll_latency(self): 157 | logger.debug("setting bot latency guage: %d", self.bot.latency) 158 | self.bot_latency_gauge.set(self.bot.latency) 159 | 160 | @timeout 161 | async def poll_total_guilds(self): 162 | logger.debug("setting total guild guage: %d", len(self.bot.guilds)) 163 | 164 | self.total_guild_gauge.set(len(self.bot.guilds)) 165 | 166 | async def poll(self): 167 | logger.debug("running polling run") 168 | await self.poll_latency() 169 | await self.poll_total_guilds() 170 | await self.poll_per_guild_stats() 171 | 172 | def start(self): 173 | async def poll_loop(): 174 | while True: 175 | await self.poll() 176 | await asyncio.sleep(self.poll_frequency) 177 | 178 | logger.debug("creating polling loop") 179 | 180 | self.poll_task = self.bot.loop.create_task(poll_loop()) 181 | 182 | def stop(self): 183 | logger.debug("tearing down polling loop") 184 | if self.poll_task is not None: 185 | logger.debug("cancelling polling loop") 186 | 187 | self.poll_task.cancel() 188 | -------------------------------------------------------------------------------- /prometheus_exporter/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from functools import wraps 4 | from typing import TYPE_CHECKING, Awaitable 5 | 6 | if TYPE_CHECKING: 7 | pass 8 | 9 | 10 | logger = logging.getLogger("red.rhomelab.prom.utils") 11 | 12 | 13 | def timeout(f: Awaitable): 14 | @wraps(f) 15 | async def inner(self, *args, **kwargs) -> None: 16 | async with asyncio.timeout(self.poll_frequency): 17 | try: 18 | return await f(self, *args, **kwargs) 19 | except Exception as e: 20 | logger.exception(e) 21 | 22 | return inner 23 | -------------------------------------------------------------------------------- /purge/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .purge import PurgeCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(PurgeCog(bot)) 8 | -------------------------------------------------------------------------------- /purge/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Sneezey" 4 | ], 5 | "short": "Purge inactive, non-roled users", 6 | "description": "Remove users without a role after a certain amount of time", 7 | "disabled": false, 8 | "name": "purge", 9 | "tags": [ 10 | "purge", 11 | "inactive" 12 | ], 13 | "requirements": [ 14 | "croniter" 15 | ], 16 | "install_msg": "Usage: `[p]purge`", 17 | "min_bot_version": "3.5.1" 18 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | asyncio_mode = "auto" 3 | 4 | [tool.ruff.lint] 5 | select = ["F", "E", "W", "I", "ASYNC", "PL", "RUF"] 6 | 7 | [tool.ruff] 8 | line-length = 127 9 | 10 | [tool.pyright] 11 | venvPath = "." 12 | venv = ".venv" 13 | exclude = [".venv", "jail", "notes", "tags", "prometheus_exporter"] 14 | -------------------------------------------------------------------------------- /quotes/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .quotes import QuotesCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(QuotesCog(bot)) 8 | -------------------------------------------------------------------------------- /quotes/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Issy" 4 | ], 5 | "short": "Quote funny moments", 6 | "description": "Add quotes to the quotes channel automagically", 7 | "disabled": false, 8 | "name": "quotes", 9 | "tags": [ 10 | "quotes", 11 | "fun", 12 | "utility" 13 | ], 14 | "install_msg": "Usage: `[p]quote add [channel] [message IDs]`", 15 | "min_bot_version": "3.5.1" 16 | } -------------------------------------------------------------------------------- /quotes/quotes.py: -------------------------------------------------------------------------------- 1 | """discord red-bot quotes""" 2 | 3 | import asyncio 4 | from typing import List, Optional, Sequence 5 | 6 | import discord 7 | from redbot.core import Config, checks, commands 8 | from redbot.core.bot import Red 9 | from redbot.core.utils.menus import start_adding_reactions 10 | from redbot.core.utils.predicates import ReactionPredicate 11 | 12 | 13 | class QuotesCog(commands.Cog): 14 | """Quotes Cog""" 15 | 16 | bot: Red 17 | config: Config 18 | 19 | def __init__(self, bot: Red): 20 | self.bot = bot 21 | self.config = Config.get_conf(self, identifier=377212919068229633002) 22 | 23 | default_guild_config = { 24 | "quote_channel": None, # int 25 | } 26 | 27 | self.config.register_guild(**default_guild_config) 28 | 29 | @commands.group(name="quote") 30 | async def _quotes(self, ctx: commands.Context): 31 | pass 32 | 33 | @commands.guild_only() 34 | @checks.mod() 35 | @_quotes.command(name="setchannel") 36 | async def set_quotes_channel(self, ctx: commands.GuildContext, channel: discord.TextChannel): 37 | """Set the quotes channel for this server 38 | 39 | Usage: 40 | - `[p]quote setchannel ` 41 | """ 42 | await self.config.guild(ctx.guild).quote_channel.set(channel.id) 43 | check_value = await self.config.guild(ctx.guild).quote_channel() 44 | success_embed = discord.Embed( 45 | title="Quotes channel set", 46 | description=f"Quotes channel set to <#{check_value}>", 47 | colour=await ctx.embed_colour(), 48 | ) 49 | await ctx.send(embed=success_embed) 50 | 51 | # fixme: too many branches, i cba to refactor this now 52 | @commands.guild_only() 53 | @_quotes.command(name="add") 54 | async def add_quote(self, ctx: commands.GuildContext, *message_ids: str): 55 | """Add a message or set of messages to the quotes channel 56 | 57 | Usage: 58 | - `[p]quote add ` 59 | 60 | For multiple messages in a single quote: 61 | - `[p]quote add ` 62 | """ 63 | if not message_ids: 64 | return await self.send_error(ctx, error_type="NoArgs") 65 | 66 | # Collect the messages 67 | async with ctx.channel.typing(): 68 | messages = await self._get_messages(ctx, message_ids) 69 | 70 | if not messages: 71 | return 72 | 73 | quote_fragments = [] 74 | for message in messages: 75 | quote_fragments.append(f"**{self._get_author_name(message)}:** {message.content}") 76 | 77 | formatted_quote = "\n".join(quote_fragments) 78 | 79 | quote_embed = await self.make_quote_embed(ctx, formatted_quote, messages) 80 | quote_channel = await self.config.guild(ctx.guild).quote_channel() 81 | 82 | if not quote_channel: 83 | return await self.send_error(ctx, error_type="NoChannelSet") 84 | 85 | try: 86 | quote_channel = await self.bot.fetch_channel(quote_channel) 87 | except Exception: 88 | return await self.send_error(ctx, error_type="ChannelNotFound") 89 | 90 | try: 91 | msg = await ctx.send(embed=quote_embed, content="Are you sure you want to send this quote?") 92 | # If sending the quote failed for any reason. For example, quote exceeded the character limit 93 | except Exception as err: 94 | return await self.send_error(ctx, custom_msg=str(err)) 95 | 96 | confirmation = await self.get_confirmation(ctx, msg) 97 | if confirmation: 98 | await quote_channel.send(embed=quote_embed) 99 | success_embed = discord.Embed(description="Your quote has been sent", colour=await ctx.embed_colour()) 100 | await ctx.send(embed=success_embed) 101 | 102 | # Helper functions 103 | 104 | async def _get_author_name(self, message: discord.Message) -> str: 105 | author = message.author 106 | if isinstance(author, discord.Member): 107 | return f"{author.nick if author.nick else author.name}" 108 | return f"{author.name}" 109 | 110 | async def _get_messages(self, ctx: commands.GuildContext, message_ids: Sequence[str]) -> List[discord.Message]: 111 | messages: list[discord.Message] = [] 112 | errored_mids: list[str] = [] 113 | # FIXME: dont do it like this 114 | for elem in message_ids: 115 | for _channel in ctx.guild.channels: 116 | if channel := self._is_valid_channel(_channel): 117 | try: 118 | message = await channel.fetch_message(int(elem)) 119 | messages.append(message) 120 | break 121 | # Could be ValueError if the ID isn't int convertible or NotFound if it's not a valid ID 122 | except (ValueError, discord.NotFound): 123 | pass 124 | else: 125 | # message not found in any channel 126 | errored_mids.append(elem) 127 | 128 | if errored_mids and len(errored_mids) < len(message_ids): 129 | error_msg = f"The following message IDs were not found: {', '.join(errored_mids)}" 130 | await self.send_error(ctx, custom_msg=error_msg) 131 | elif errored_mids and len(errored_mids) == len(message_ids): 132 | await self.send_error(ctx, custom_msg="None of the provided message IDs were found!") 133 | return messages 134 | 135 | async def make_quote_embed( 136 | self, 137 | ctx: commands.Context, 138 | formatted_quote: str, 139 | messages: List[discord.Message], 140 | ) -> discord.Embed: 141 | """Generate the quote embed to be sent""" 142 | authors = [message.author for message in messages] 143 | author_list = " ".join([i.mention for i in authors]) 144 | # List of channel mentions 145 | channels: List[str] = [] 146 | 147 | for _channel in [i.channel for i in messages]: 148 | if channel := self._is_valid_channel(_channel): 149 | channels.append(channel.mention) 150 | unique_channels = set(channels) 151 | 152 | return ( 153 | discord.Embed( 154 | description=formatted_quote, 155 | colour=await ctx.embed_colour(), 156 | ) 157 | .add_field(name="Authors", value=author_list, inline=False) 158 | .add_field(name="Submitted by", value=ctx.author.mention) 159 | .add_field(name="Channels", value="\n".join(unique_channels)) 160 | .add_field(name="Link", value=f"[Jump to quote]({messages[0].jump_url})") 161 | .add_field(name="Timestamp", value=f"") 162 | ) 163 | 164 | async def send_error(self, ctx, error_type: str = "", custom_msg: str = "") -> None: 165 | """Generate error message embeds""" 166 | error_msgs = { 167 | "NoChannelSet": ( 168 | "There is no quotes channel configured for this server. " 169 | "A moderator must set a quotes channel for this server using the " 170 | f"command `{ctx.prefix}quote set_quotes_channel `" 171 | ), 172 | "ChannelNotFound": ( 173 | "Unable to find the quotes channel for this server. This could " 174 | "be due to a permissions issue or because the channel no longer exists." 175 | "A moderator must set a valid quotes channel for this server using the command " 176 | f"`{ctx.prefix}quote set_quotes_channel `" 177 | ), 178 | "NoArgs": "You must provide 1 or more message IDs for this command!", 179 | } 180 | 181 | if error_type: 182 | error_msg = error_msgs[error_type] 183 | elif custom_msg: 184 | error_msg = custom_msg 185 | else: 186 | error_msg = "An unknown error has occurred" 187 | error_embed = discord.Embed(title="Error", description=error_msg, colour=await ctx.embed_colour()) 188 | await ctx.send(embed=error_embed) 189 | 190 | def _is_valid_channel(self, channel: "discord.guild.GuildChannel | discord.abc.MessageableChannel | None"): 191 | if channel is not None and not isinstance( 192 | channel, 193 | ( 194 | discord.ForumChannel, 195 | discord.CategoryChannel, 196 | discord.DMChannel, 197 | discord.ForumChannel, 198 | discord.PartialMessageable, 199 | discord.GroupChannel, 200 | ), 201 | ): 202 | return channel 203 | return False 204 | 205 | async def get_confirmation(self, ctx: commands.Context, msg: discord.Message) -> Optional[bool]: 206 | """Get confirmation from user with reactions""" 207 | emojis = ["❌", "✅"] 208 | start_adding_reactions(msg, emojis) 209 | 210 | try: 211 | reaction, _ = await self.bot.wait_for( 212 | "reaction_add", timeout=180.0, check=ReactionPredicate.with_emojis(emojis, msg, ctx.author) 213 | ) 214 | except asyncio.TimeoutError: 215 | await msg.clear_reactions() 216 | return 217 | else: 218 | await msg.clear_reactions() 219 | return bool(emojis.index(reaction.emoji)) 220 | -------------------------------------------------------------------------------- /reactrole/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .reactrole import ReactRoleCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(ReactRoleCog(bot)) 8 | -------------------------------------------------------------------------------- /reactrole/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Issy", 4 | "Sneezey" 5 | ], 6 | "short": "React Role cog", 7 | "description": "Allows roles to be applied and removed by reactions", 8 | "disabled": false, 9 | "name": "reactrole", 10 | "tags": [ 11 | "roles", 12 | "react" 13 | ], 14 | "install_msg": "Usage: `[p]reactrole`", 15 | "min_bot_version": "3.5.1" 16 | } -------------------------------------------------------------------------------- /reactrole/reactrole.py: -------------------------------------------------------------------------------- 1 | """discord red-bot reactrole cog""" 2 | 3 | import logging 4 | 5 | import discord 6 | from redbot.core import Config, checks, commands 7 | from redbot.core.bot import Red 8 | from redbot.core.utils.chat_formatting import pagify 9 | from redbot.core.utils.menus import close_menu, menu, next_page, prev_page 10 | 11 | CUSTOM_CONTROLS = {"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page} 12 | 13 | log = logging.getLogger("red.rhomelab.reactrole") 14 | 15 | 16 | class ReactRoleCog(commands.Cog): 17 | """ReactRole Cog""" 18 | 19 | bot: Red 20 | config: Config 21 | 22 | def __init__(self, bot: Red): 23 | self.bot = bot 24 | self.config = Config.get_conf(self, identifier=124123498) 25 | 26 | default_guild_settings = {"roles": [], "enabled": True} 27 | 28 | self.config.register_guild(**default_guild_settings) 29 | 30 | def _is_valid_channel(self, channel: "discord.guild.GuildChannel | None"): 31 | if channel is not None and not isinstance(channel, (discord.ForumChannel, discord.CategoryChannel)): 32 | return channel 33 | return False 34 | 35 | @commands.Cog.listener() 36 | async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): 37 | """ 38 | Member adds reaction to a message 39 | """ 40 | 41 | if not payload.member: 42 | # TODO Log error 43 | return 44 | 45 | if payload.member.bot: 46 | # Go no further if member is a bot 47 | return 48 | 49 | guild = self.bot.get_guild(payload.guild_id) 50 | if guild is None: 51 | # Guild shouldn't be none 52 | return 53 | 54 | if not await self.config.guild(guild).enabled(): 55 | # Go no further if disabled 56 | return 57 | 58 | async with self.config.guild(guild).roles() as roles: 59 | for item in roles: 60 | if item["message"] == payload.message_id and item["reaction"] == str(payload.emoji): 61 | role = guild.get_role(item["role"]) 62 | await payload.member.add_roles(role) 63 | 64 | @commands.Cog.listener() 65 | async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent): 66 | """ 67 | Member removes reaction from a message 68 | """ 69 | guild = self.bot.get_guild(payload.guild_id) 70 | if not guild: 71 | # Guild shouldn't be none 72 | return 73 | 74 | member = guild.get_member(payload.user_id) 75 | 76 | if member.bot: 77 | # Go no further if member is a bot 78 | return 79 | 80 | if not await self.config.guild(guild).enabled(): 81 | # Go no further if disabled 82 | return 83 | 84 | async with self.config.guild(guild).roles() as roles: 85 | for item in roles: 86 | if item["message"] == payload.message_id and item["reaction"] == str(payload.emoji): 87 | role = guild.get_role(item["role"]) 88 | await member.remove_roles(role) 89 | 90 | @commands.group(name="reactrole") # type: ignore 91 | @commands.guild_only() 92 | @checks.mod() 93 | async def _reactrole(self, ctx: commands.Context): 94 | pass 95 | 96 | @_reactrole.command("add") 97 | @checks.admin() 98 | async def add_reactrole( 99 | self, 100 | ctx: commands.GuildContext, 101 | message: discord.Message, 102 | reaction: str, 103 | role: discord.Role, 104 | ): 105 | """Creates a new react role 106 | 107 | Example: 108 | - `[p]reactrole add ` 109 | """ 110 | data = {"message": message.id, "reaction": reaction, "role": role.id, "channel": message.channel.id} 111 | async with self.config.guild(ctx.guild).roles() as roles: 112 | # This should only return 1 item at max because items are checked for uniqueness before adding them 113 | exists = [item for item in roles if item == data] 114 | if exists: 115 | return await ctx.send("React role already exists.") 116 | 117 | try: 118 | await message.add_reaction(reaction) 119 | except Exception: 120 | return await ctx.send("Unable to add emoji message to message") 121 | 122 | roles.append(data) 123 | await ctx.send("Configured react role.") 124 | 125 | @_reactrole.command("remove") 126 | async def remove_reactrole( 127 | self, 128 | ctx: commands.GuildContext, 129 | message: discord.Message, 130 | reaction: str, 131 | role: discord.Role, 132 | ): 133 | """Removes a configured react role 134 | 135 | Example: 136 | - `[p]reactrole remove 360678601227763712-893601663435276318 :kek: @moderator` 137 | """ 138 | data = {"message": message.id, "reaction": reaction, "role": role.id, "channel": message.channel.id} 139 | async with self.config.guild(ctx.guild).roles() as roles: 140 | # This should only return 1 item at max because items are checked for uniqueness before adding them 141 | exists = [item for item in roles if item == data] 142 | if exists: 143 | roles.remove(data) 144 | try: 145 | await message.clear_reaction(reaction) 146 | except discord.NotFound: 147 | pass 148 | await ctx.send("React role removed.") 149 | else: 150 | return await ctx.send("React role doesn't exist.") 151 | 152 | @_reactrole.command("list") 153 | async def reactrole_list(self, ctx: commands.GuildContext): 154 | """Shows a list of react roles configured 155 | 156 | Example: 157 | - `[p]reactrole list` 158 | """ 159 | messages = [] 160 | enabled = await self.config.guild(ctx.guild).enabled() 161 | messages.append(f"Enabled: {enabled}") 162 | 163 | async with self.config.guild(ctx.guild).roles() as roles: 164 | for item in roles: 165 | role = ctx.guild.get_role(item["role"]) 166 | if not role: 167 | messages.append(f"Role {item['role']} not found.") 168 | 169 | _channel = ctx.guild.get_channel(item["channel"]) 170 | if (channel := self._is_valid_channel(_channel)) and role: 171 | message = await channel.fetch_message(item["message"]) 172 | messages.append(f"📝 {message.jump_url} - {role.name} - {item['reaction']}\n") 173 | else: 174 | messages.append( 175 | f"Channel {_channel.mention if _channel else item['channel']} is not a searchable channel." 176 | ) 177 | 178 | # Pagify implementation 179 | # https://github.com/Cog-Creators/Red-DiscordBot/blob/9698baf6e74f6b34f946189f05e2559a60e83706/redbot/core/utils/chat_formatting.py#L208 180 | pages = list(pagify("\n\n".join(messages), shorten_by=58)) 181 | 182 | embeds = [ 183 | discord.Embed( 184 | title=f"React Roles - Page {index + 1}/{len(pages)}", 185 | description=page, 186 | colour=(await ctx.embed_colour()), 187 | ) 188 | for index, page in enumerate(pages) 189 | ] 190 | 191 | await menu( 192 | ctx, 193 | pages=embeds, 194 | controls=CUSTOM_CONTROLS, 195 | timeout=30.0, 196 | ) 197 | 198 | @_reactrole.command("enable") 199 | async def reactrole_enable(self, ctx: commands.GuildContext): 200 | """Enables the ReactRole's functionality 201 | 202 | Example: 203 | - `[p]reactrole enable` 204 | """ 205 | await self.config.guild(ctx.guild).enabled.set(True) 206 | await ctx.send("Enabled ReactRole.") 207 | 208 | @_reactrole.command("disable") 209 | async def reactrole_disable(self, ctx: commands.GuildContext): 210 | """Disables the ReactRole's functionality 211 | 212 | Example: 213 | - `[p]reactrole disable` 214 | """ 215 | await self.config.guild(ctx.guild).enabled.set(False) 216 | await ctx.send("Disabled ReactRole.") 217 | -------------------------------------------------------------------------------- /report/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .report import ReportCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(ReportCog(bot)) 8 | -------------------------------------------------------------------------------- /report/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "tigattack", 4 | "Issy", 5 | "Sneezey" 6 | ], 7 | "short": "Report cog", 8 | "description": "Allows users to report an issue to mods", 9 | "disabled": false, 10 | "name": "report", 11 | "tags": [ 12 | "report", 13 | "reports" 14 | ], 15 | "install_msg": "Usage: `[p]report`", 16 | "min_bot_version": "3.5.1" 17 | } -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | aiofiles>=24.1.0 2 | chat_exporter>=2.8.1 3 | fastjsonschema==2.19.1 4 | pre-commit==3.7.0 5 | prometheus-client 6 | pyright==1.1.394 7 | pytest-aiohttp==1.0.5 8 | pytest-asyncio==0.23.6 9 | pytest==8.2.0 10 | ruff==0.9.6 11 | sentry-sdk>=2.22.0 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pip 2 | wheel 3 | Red-DiscordBot 4 | -------------------------------------------------------------------------------- /role_welcome/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .role_welcome import RoleWelcome 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(RoleWelcome(bot)) 8 | -------------------------------------------------------------------------------- /role_welcome/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["tigattack"], 3 | "short": "Welcome members when they are first given a role", 4 | "description": "Send a welcome message when a user is added to a role.", 5 | "name": "role_welcome", 6 | "install_msg": "Usage: `[p]rolewelcome`", 7 | "min_bot_version": "3.5.1" 8 | } 9 | -------------------------------------------------------------------------------- /roleinfo/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .roleinfo import RoleInfoCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(RoleInfoCog(bot)) 8 | -------------------------------------------------------------------------------- /roleinfo/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Issy" 4 | ], 5 | "short": "Role info cog", 6 | "description": "Allows users to view info on a role by ID, mention or role name", 7 | "disabled": false, 8 | "name": "roleinfo", 9 | "tags": [ 10 | "info", 11 | "moderation" 12 | ], 13 | "install_msg": "Usage: `[p]roleinfo`", 14 | "min_bot_version": "3.5.1" 15 | } -------------------------------------------------------------------------------- /roleinfo/roleinfo.py: -------------------------------------------------------------------------------- 1 | """discord red-bot roleinfo cog""" 2 | 3 | import discord 4 | from redbot.core import commands 5 | from redbot.core.bot import Red 6 | from redbot.core.utils.mod import is_mod_or_superior as is_mod 7 | 8 | 9 | class RoleInfoCog(commands.Cog): 10 | """Roleinfo cog""" 11 | 12 | bot: Red 13 | 14 | def __init__(self, bot: Red): 15 | self.bot = bot 16 | 17 | @commands.command("roleinfo") 18 | async def role_info_cmd(self, ctx: commands.Context, role: discord.Role): 19 | """Displays info about a role in the server 20 | 21 | Example: 22 | - `[p]roleinfo ` 23 | - `[p]roleinfo 266858186336632831` 24 | - `[p]roleinfo verified` 25 | - `[p]roleinfo @verified` 26 | """ 27 | if isinstance(ctx.author, discord.Member): 28 | role_check = await is_mod(self.bot, ctx.author) or role <= max(ctx.author.roles) 29 | if role_check: 30 | embed = await self.make_role_embed(role) 31 | await ctx.send(embed=embed) 32 | else: 33 | await ctx.send("You can only use this command in a server.") 34 | 35 | async def make_role_embed(self, role: discord.Role) -> discord.Embed: 36 | """Generate the role info embed""" 37 | return ( 38 | discord.Embed(title="Role info", colour=role.colour) 39 | .add_field(name="Name", value=role.name) 40 | .add_field(name="Members", value=len(role.members)) 41 | .add_field(name="Hoist", value="Yes" if role.hoist else "No") 42 | .add_field(name="Mentionable", value="Yes" if role.mentionable else "No") 43 | .add_field(name="Position", value=len(role.guild.roles) - role.position) 44 | .add_field(name="ID", value=role.id) 45 | .add_field(name="Created at", value=f"") 46 | ) 47 | -------------------------------------------------------------------------------- /sentry/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .sentry import SentryCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(SentryCog(bot)) 8 | -------------------------------------------------------------------------------- /sentry/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "BeryJu" 4 | ], 5 | "short": "Sentry cog", 6 | "description": "Sends exceptions and stacktraces to sentry.", 7 | "disabled": false, 8 | "name": "sentry", 9 | "tags": [ 10 | "sentry" 11 | ], 12 | "requirements": [ 13 | "sentry-sdk>=2.22.0" 14 | ], 15 | "install_msg": "Install: `[p]set api sentry dsn,https://fooo@bar.baz/9`", 16 | "min_bot_version": "3.5.1" 17 | } 18 | -------------------------------------------------------------------------------- /sentry/sentry.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from logging import Logger, getLogger 3 | from typing import Optional 4 | 5 | from discord import Message 6 | from discord.channel import TextChannel 7 | from discord.ext.commands.errors import CommandInvokeError 8 | from redbot.core import checks, commands 9 | from redbot.core.bot import Config, Red 10 | from sentry_sdk.client import Client 11 | from sentry_sdk.scope import Scope, use_isolation_scope 12 | from sentry_sdk.tracing import Transaction 13 | from sentry_sdk.utils import BadDsn 14 | 15 | 16 | class SentryCog(commands.Cog): 17 | """Sentry error reporting cog.""" 18 | 19 | logger: Logger 20 | bot: Red 21 | _is_initialized: bool 22 | client: Optional[Client] 23 | 24 | def __init__(self, bot: Red): 25 | super().__init__() 26 | self.logger = getLogger("red.rhomelab.sentry") 27 | 28 | self.bot = bot 29 | self.client = None 30 | 31 | self.config = Config.get_conf(self, identifier=34848138412384) 32 | default_global = { 33 | "environment": "", 34 | "log_level": "WARNING", 35 | } 36 | self.config.register_global(**default_global) 37 | 38 | bot.before_invoke(self.before_invoke) 39 | bot.after_invoke(self.after_invoke) 40 | self.logger.debug("Registered before/after hooks") 41 | 42 | async def ensure_client_init(self, context: commands.context.Context): 43 | """Ensure client is initialised""" 44 | if self.client: 45 | return 46 | log_level = await self.config.log_level() 47 | try: 48 | self.logger.setLevel(log_level) 49 | except ValueError: 50 | self.logger.error("Failed to set log level to '%s'", log_level) 51 | 52 | self.logger.debug("Initialising sentry client") 53 | environment = await self.config.environment() 54 | keys = await self.bot.get_shared_api_tokens("sentry") 55 | dsn = keys.get("dsn", None) 56 | try: 57 | self.client = Client( 58 | dsn=dsn, 59 | environment=environment, 60 | traces_sample_rate=1, 61 | integrations=[], 62 | default_integrations=False, 63 | ) 64 | self.client.options["debug"] = log_level.upper() == "DEBUG" 65 | except BadDsn: 66 | self.logger.error("Failed to initialise sentry client with DSN '%s'", dsn) 67 | else: 68 | self.logger.debug("Initialised sentry client with %s env=%s", dsn, environment) 69 | 70 | def cog_unload(self): 71 | self.bot.remove_before_invoke_hook(self.before_invoke) 72 | return super().cog_unload() 73 | 74 | @checks.mod() 75 | @commands.group(name="sentry", pass_context=True) 76 | async def _sentry(self, ctx: commands.context.Context): 77 | """Command group for sentry settings""" 78 | 79 | @_sentry.command(name="set_env") # type: ignore 80 | async def sentry_set_env(self, context: commands.context.Context, new_value: str): 81 | """Set sentry environment""" 82 | await self.config.environment.set(new_value) 83 | await context.send(f"Sentry environment has been changed to '{new_value}'") 84 | 85 | @_sentry.command(name="get_env") # type: ignore 86 | async def sentry_get_env(self, context: commands.context.Context): 87 | """Get sentry environment""" 88 | environment_val = await self.config.environment() 89 | if environment_val: 90 | message = f"The Sentry environment is '{environment_val}'" 91 | else: 92 | message = f"The Sentry environment is unset. See `{context.prefix}sentry set_env`." 93 | await context.send(message) 94 | 95 | @_sentry.command(name="set_log_level") # type: ignore 96 | async def sentry_set_log_level(self, context: commands.context.Context, new_value: str): 97 | """Set sentry log_level""" 98 | new_value = new_value.upper() 99 | if self.client: 100 | self.client.options["debug"] = new_value == "DEBUG" 101 | else: 102 | self.logger.warning("Sentry client not initialised yet") 103 | await context.send("Sentry client not initialised yet") 104 | try: 105 | self.logger.setLevel(new_value) 106 | await self.config.log_level.set(new_value) 107 | await context.send(f"Sentry log_level has been changed to '{new_value}'") 108 | except ValueError as error: 109 | self.logger.warning(f"Could not change log level to '{new_value}': ", exc_info=error) 110 | await context.send("Sentry log_level could not be changed.\n" + f"{new_value} is not a valid logging level.") 111 | 112 | @_sentry.command(name="get_log_level") # type: ignore 113 | async def sentry_get_log_level(self, context: commands.context.Context): 114 | """Get sentry log_level""" 115 | log_level_val = await self.config.log_level() 116 | await context.send(f"The Sentry log_level is '{log_level_val}'") 117 | 118 | @_sentry.command(name="test") # type: ignore 119 | async def sentry_test(self, context: commands.context.Context): 120 | """Test sentry""" 121 | await context.send("An exception will now be raised. Check Sentry to confirm.") 122 | raise ValueError("test error") 123 | 124 | async def before_invoke(self, context: commands.context.Context): 125 | """Method invoked before any red command. Start a transaction.""" 126 | await self.ensure_client_init(context) 127 | msg: Message = context.message 128 | client_scope = Scope(client=self.client) 129 | with use_isolation_scope(client_scope) as scope: 130 | # set_user applies to the current scope, so it also applies to the transaction 131 | scope.set_user( 132 | { 133 | "id": msg.author.id, 134 | "username": msg.author.display_name, 135 | } 136 | ) 137 | transaction = scope.start_transaction(op="command", name=f"Command {context.command.name}") 138 | transaction.set_tag("discord_message", msg.content) 139 | if context.command: 140 | transaction.set_tag("discord_command", context.command.name) 141 | if msg.guild: 142 | transaction.set_tag("discord_guild", msg.guild.name) 143 | if isinstance(msg.channel, TextChannel): 144 | transaction.set_tag("discord_channel", msg.channel.name) 145 | transaction.set_tag("discord_channel_id", msg.channel.id) 146 | setattr(context, "__sentry_transaction", transaction) 147 | 148 | async def after_invoke(self, context: commands.context.Context): 149 | """Method invoked after any red command. Checks if the command failed, and 150 | then tries to send the last exception to sentry.""" 151 | await self.ensure_client_init(context) 152 | transaction: Optional[Transaction] = getattr(context, "__sentry_transaction", None) 153 | if not transaction: 154 | self.logger.debug("post-command: no transaction, discarding") 155 | return 156 | client_scope = Scope(client=self.client) 157 | with use_isolation_scope(client_scope) as scope: 158 | transaction.set_status("ok") 159 | msg: Message = context.message 160 | scope.set_user( 161 | { 162 | "id": msg.author.id, 163 | "username": msg.author.display_name, 164 | } 165 | ) 166 | scope.set_tag("discord_message", msg.content) 167 | if context.command: 168 | scope.set_tag("discord_command", context.command.name) 169 | if msg.guild: 170 | scope.set_tag("discord_guild", msg.guild.name) 171 | if isinstance(msg.channel, TextChannel): 172 | scope.set_tag("discord_channel", msg.channel.name) 173 | scope.set_tag("discord_channel_id", msg.channel.id) 174 | 175 | if not context.command_failed: 176 | self.logger.debug("post-command: sending successful transaction") 177 | transaction.finish(scope) 178 | return 179 | exc_type, value, _ = sys.exc_info() 180 | if not exc_type: 181 | transaction.finish(scope) 182 | return 183 | if isinstance(value, CommandInvokeError): 184 | value = value.original 185 | 186 | transaction.set_status("unknown_error") 187 | self.logger.debug("post-command: capturing error") 188 | scope.capture_exception(value) 189 | 190 | transaction.finish(scope) 191 | -------------------------------------------------------------------------------- /tags/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .tags import TagCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(TagCog(bot)) 8 | -------------------------------------------------------------------------------- /tags/abstracts.py: -------------------------------------------------------------------------------- 1 | # Credit to the Notes cog author(s) for this structure 2 | 3 | from abc import ABC, abstractmethod 4 | from typing import List, Optional 5 | 6 | import discord 7 | from redbot.core import Config, commands 8 | 9 | 10 | # fixme: please use data classes 11 | class BaseABC(ABC): 12 | def __init__(self, **kwargs): 13 | if kwargs.keys() != self.__annotations__.keys(): 14 | raise Exception("Invalid kwargs provided") 15 | 16 | for key, val in kwargs.items(): 17 | # expected_type: type = self.__annotations__[key] 18 | # if not isinstance(val, expected_type): 19 | # raise TypeError(f"Expected type {expected_type} for kwarg {key!r}, got type {type(val)} instead") 20 | 21 | setattr(self, key, val) 22 | 23 | 24 | class TransferABC(BaseABC): 25 | prior: int 26 | reason: str 27 | to: int 28 | time: int 29 | 30 | def __init__(self, **kwargs): 31 | super().__init__(**kwargs) 32 | 33 | @classmethod 34 | @abstractmethod 35 | def new(cls, ctx: commands.Context, prior: int, reason: str, to: int, time: int): 36 | """Initialise the class in a command context""" 37 | pass 38 | 39 | @classmethod 40 | @abstractmethod 41 | def from_storage(cls, ctx: commands.Context, data: dict): 42 | """Initialise the class from a config record""" 43 | pass 44 | 45 | @abstractmethod 46 | def to_dict(self) -> dict: 47 | """Returns a dictionary representation of the class, suitable for storing in config""" 48 | pass 49 | 50 | 51 | class UseABC(BaseABC): 52 | user: int 53 | time: int 54 | 55 | def __init__(self, **kwargs): 56 | super().__init__(**kwargs) 57 | 58 | @classmethod 59 | @abstractmethod 60 | def new(cls, ctx: commands.Context, user: int, time: int): 61 | """Initialise the class in a command context""" 62 | pass 63 | 64 | @classmethod 65 | @abstractmethod 66 | def from_storage(cls, ctx: commands.Context, data: dict): 67 | """Initialise the class from a config record""" 68 | pass 69 | 70 | @abstractmethod 71 | def to_dict(self) -> dict: 72 | """Returns a dictionary representation of the class, suitable for storing in config""" 73 | pass 74 | 75 | 76 | class AliasABC(BaseABC): 77 | alias: str 78 | creator: int 79 | created: int 80 | tag: str 81 | uses: List[UseABC] 82 | 83 | def __init__(self, **kwargs): 84 | super().__init__(**kwargs) 85 | 86 | @classmethod 87 | @abstractmethod 88 | def new(cls, ctx: commands.Context, alias: str, creator: int, created: int, tag: str, uses: List[UseABC]): # noqa: PLR0913 89 | """Initialise the class in a command context""" 90 | pass 91 | 92 | @classmethod 93 | @abstractmethod 94 | def from_storage(cls, ctx: commands.Context, data: dict): 95 | """Initialise the class from a config record""" 96 | pass 97 | 98 | @abstractmethod 99 | def to_dict(self) -> dict: 100 | """Returns a dictionary representation of the class, suitable for storing in config""" 101 | pass 102 | 103 | 104 | class TagABC(BaseABC): 105 | tag: str 106 | creator: int 107 | owner: int 108 | created: int 109 | content: str 110 | transfers: List[TransferABC] 111 | uses: List[UseABC] 112 | 113 | def __init__(self, **kwargs): 114 | super().__init__(**kwargs) 115 | 116 | @classmethod 117 | @abstractmethod 118 | def new(cls, ctx: commands.Context, creator: int, owner: int, created: int, tag: str, content: str): # noqa: PLR0913 119 | """Initialise the class in a command context""" 120 | pass 121 | 122 | @classmethod 123 | @abstractmethod 124 | def from_storage(cls, ctx: commands.Context, data: dict): 125 | """Initialise the class from a config record""" 126 | pass 127 | 128 | @abstractmethod 129 | def to_dict(self) -> dict: 130 | """Returns a dictionary representation of the class, suitable for storing in config""" 131 | pass 132 | 133 | 134 | class TagConfigHelperABC(ABC): 135 | config: Config 136 | 137 | async def log_uses(self, ctx: commands.Context) -> bool: 138 | """Returns whether to log tag/alias use.""" 139 | pass 140 | 141 | async def set_log_uses(self, ctx: commands.Context, log: bool): 142 | """Sets whether to log tag/alias use.""" 143 | pass 144 | 145 | async def log_transfers(self, ctx: commands.Context) -> bool: 146 | """Returns whether to log transfers.""" 147 | pass 148 | 149 | async def set_log_transfers(self, ctx: commands.Context, log: bool): 150 | """Sets whether to log transfers.""" 151 | pass 152 | 153 | async def create_tag(self, ctx: commands.Context, tag: str, content: str) -> TagABC: 154 | """Creates, saves, and returns a new Tag.""" 155 | pass 156 | 157 | async def edit_tag(self, ctx: commands.Context, tag: str, content: str) -> TagABC: 158 | """Updates and saves the content of an existing tag.""" 159 | pass 160 | 161 | async def transfer_tag(self, ctx: commands.Context, trigger: str, to: int, reason: str, time: int): 162 | """Transfers the specified tag ownership to the new specified owner and makes the transfer entry.""" 163 | pass 164 | 165 | async def get_tag(self, ctx: commands.Context, tag: str) -> TagABC: 166 | """Returns the tag, if any, for the given key.""" 167 | pass 168 | 169 | async def get_tag_by_alias(self, ctx: commands.Context, alias: AliasABC) -> TagABC: 170 | """Returns the associated tag for the given alias.""" 171 | pass 172 | 173 | async def get_tags(self, ctx: commands.Context, creator: Optional[discord.User]) -> List[TagABC]: 174 | """Returns a list of all tags, or those created by the specified user if provided.""" 175 | pass 176 | 177 | async def get_tags_by_owner(self, ctx: commands.Context, owner_id: int) -> List[TagABC]: 178 | """Returns a list of tags owned by the provided owner.""" 179 | pass 180 | 181 | async def get_tag_or_alias(self, ctx: commands.Context, trigger: str) -> (TagABC, AliasABC): 182 | """For the given trigger: returns the tag and no alias if a tag, the resolved tag plus alias if an alias, and 183 | none if neither.""" 184 | pass 185 | 186 | async def add_tag_use(self, ctx: commands.Context, tag: TagABC, user: int, time: int): 187 | """Adds and saves a usage entry for the specified tag.""" 188 | pass 189 | 190 | async def create_alias(self, ctx: commands.Context, alias: str, tag: str, creator: int, created: int): 191 | """Creates and saves the specified alias for the tag.""" 192 | pass 193 | 194 | async def delete_alias(self, ctx: commands.Context, alias: str): 195 | """Deletes the specified alias and related data.""" 196 | pass 197 | 198 | async def get_alias(self, ctx: commands.Context, alias: str) -> AliasABC: 199 | """Returns the alias, if any, for the given key.""" 200 | pass 201 | 202 | async def get_aliases(self, ctx: commands.Context, creator: Optional[discord.User]) -> List[AliasABC]: 203 | """Returns a list of all aliases, or those created by the specified user if provided.""" 204 | pass 205 | 206 | async def get_aliases_by_tag(self, ctx: commands.Context, tag: TagABC) -> List[AliasABC]: 207 | """Returns a list of aliases for the given tag.""" 208 | pass 209 | 210 | async def get_aliases_by_owner(self, ctx: commands.Context, owner_id: int) -> List[AliasABC]: 211 | """Returns a list of aliases owned by the provided owner.""" 212 | pass 213 | 214 | async def add_alias_use(self, ctx: commands.Context, alias: AliasABC, user: int, time: int): 215 | """Adds and saves a usage entry for the specified alias and its associated tag.""" 216 | pass 217 | -------------------------------------------------------------------------------- /tags/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "portalBlock" 4 | ], 5 | "short": "User-generated tags for re-posting commonly used messages.", 6 | "description": "User-generated tags and aliases with statistics tracking for re-posting commonly used messages.", 7 | "disabled": false, 8 | "name": "tags", 9 | "tags": [ 10 | "utility", 11 | "fun", 12 | "reply" 13 | ], 14 | "install_msg": "Usage: `[p]tags`", 15 | "min_bot_version": "3.5.1" 16 | } -------------------------------------------------------------------------------- /tags/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Optional 3 | 4 | import discord 5 | from redbot.core import Config, commands 6 | 7 | from tags.abstracts import AliasABC, TagABC, TagConfigHelperABC, TransferABC, UseABC 8 | 9 | 10 | class Transfer(TransferABC): 11 | @classmethod 12 | def new(cls, ctx: commands.Context, prior: int, reason: str, to: int, time: int): 13 | return cls(prior=prior, reason=reason, to=to, time=time) 14 | 15 | @classmethod 16 | def from_storage(cls, ctx: commands.Context, data: dict): 17 | return cls(prior=data["prior"], reason=data["reason"], to=data["to"], time=data["time"]) 18 | 19 | def to_dict(self) -> dict: 20 | return {"prior": self.prior, "reason": self.reason, "to": self.to, "time": self.time} 21 | 22 | 23 | class Use(UseABC): 24 | @classmethod 25 | def new(cls, ctx: commands.Context, user: int, time: int): 26 | return cls(user=user, time=time) 27 | 28 | @classmethod 29 | def from_storage(cls, ctx: commands.Context, data: dict): 30 | return cls(user=data["user"], time=data["time"]) 31 | 32 | def to_dict(self) -> dict: 33 | return {"user": self.user, "time": self.time} 34 | 35 | 36 | class Alias(AliasABC): 37 | @classmethod 38 | def new(cls, ctx: commands.Context, alias: str, creator: int, created: int, tag: str, uses: List[UseABC]): # noqa: PLR0913 39 | return cls(alias=alias, creator=creator, created=created, tag=tag, uses=uses) 40 | 41 | @classmethod 42 | def from_storage(cls, ctx: commands.Context, data: dict): 43 | return cls(alias=data["alias"], creator=data["creator"], created=data["created"], tag=data["tag"], uses=data["uses"]) 44 | 45 | def to_dict(self) -> dict: 46 | return {"alias": self.alias, "creator": self.creator, "created": self.created, "tag": self.tag, "uses": self.uses} 47 | 48 | 49 | class Tag(TagABC): 50 | @classmethod 51 | def new(cls, ctx: commands.Context, creator: int, owner: int, created: int, tag: str, content: str): # noqa: PLR0913 52 | return cls(tag=tag, creator=creator, owner=owner, created=created, content=content, transfers=[], uses=[]) 53 | 54 | @classmethod 55 | def from_storage(cls, ctx: commands.Context, data: dict): 56 | return cls( 57 | tag=data["tag"], 58 | creator=data["creator"], 59 | owner=data["owner"], 60 | created=data["created"], 61 | content=data["content"], 62 | transfers=data["transfers"], 63 | uses=["uses"], 64 | ) 65 | 66 | def to_dict(self) -> dict: 67 | return { 68 | "tag": self.tag, 69 | "creator": self.creator, 70 | "owner": self.owner, 71 | "created": self.created, 72 | "content": self.content, 73 | "transfers": [], 74 | "uses": [], 75 | } 76 | 77 | 78 | class TagConfigHelper(TagConfigHelperABC): 79 | def __init__(self): 80 | self.config = Config.get_conf(None, identifier=128986274420752384002, cog_name="TagCog") 81 | self.config.register_guild(log={}, tags={}, aliases={}) 82 | 83 | async def log_uses(self, ctx: commands.Context) -> bool: 84 | return self.config.guild(ctx.guild).log().uses() 85 | 86 | async def set_log_uses(self, ctx: commands.Context, log: bool): 87 | return self.config.guild(ctx.guild).log().uses.set(log) 88 | 89 | async def log_transfers(self, ctx: commands.Context) -> bool: 90 | return self.config.guild(ctx.guild).log().transfers() 91 | 92 | async def set_log_transfers(self, ctx: commands.Context, log: bool): 93 | return self.config.guild(ctx.guild).log().uses.transfers(log) 94 | 95 | async def create_tag(self, ctx: commands.Context, trigger: str, content: str) -> Tag: 96 | time = int(datetime.utcnow().timestamp()) 97 | tag = Tag.new(ctx, ctx.author.id, ctx.author.id, time, trigger, content) 98 | async with self.config.guild(ctx.guild).tags() as tags: 99 | tags[trigger] = tag.to_dict() 100 | return tag 101 | 102 | async def edit_tag(self, ctx: commands.Context, trigger: str, content: str) -> Tag: 103 | tag = await self.get_tag(ctx, trigger) 104 | if tag is not None: 105 | tag.content = content 106 | async with self.config.guild(ctx.guild).tags() as tags: 107 | tags[trigger] = tag.to_dict() 108 | return tag 109 | 110 | async def transfer_tag(self, ctx: commands.Context, trigger: str, to: int, reason: str, time: int): 111 | tag = await self.get_tag(ctx, trigger) 112 | if tag is not None: 113 | transfers = tag.transfers 114 | transfers.append(Transfer.new(ctx, tag.owner, reason, to, time)) 115 | tag.transfers = transfers 116 | tag.owner = to 117 | async with self.config.guild(ctx.guild).tags() as tags: 118 | tags[trigger] = tag.to_dict() 119 | 120 | async def delete_tag(self, ctx: commands.Context, tag: str): 121 | async with self.config.guild(ctx.guild).tags() as tags: 122 | del tags[tag] 123 | 124 | async def get_tag(self, ctx: commands.Context, trigger: str) -> Tag: 125 | tag = None 126 | async with self.config.guild(ctx.guild).tags() as tags: 127 | if trigger in tags: 128 | tag = Tag.from_storage(ctx, tags[trigger]) 129 | return tag 130 | 131 | async def get_tag_by_alias(self, ctx: commands.Context, alias: Alias) -> Tag: 132 | tag = None 133 | if alias is not None and alias.tag is not None: 134 | search = alias.tag 135 | async with self.config.guild(ctx.guild).tags() as tags: 136 | if search in tags: 137 | tag = Tag.from_storage(ctx, tags[search]) 138 | return tag 139 | 140 | async def get_tags(self, ctx: commands.Context, owner: Optional[discord.User]) -> List[TagABC]: 141 | tag_list = [] 142 | async with self.config.guild(ctx.guild).tags() as tags: 143 | for tag_key in tags.keys(): 144 | tag = Tag.from_storage(ctx, tags[tag_key]) 145 | if owner is not None: 146 | if not tag.owner == owner.id: 147 | continue 148 | tag_list.append(tag) 149 | return tag_list 150 | 151 | async def get_tags_by_owner(self, ctx: commands.Context, owner_id: int) -> List[Tag]: 152 | filtered_tags = [] 153 | async with self.config.guild(ctx.guild).tags() as tags: 154 | for tag in tags: 155 | if tag.owner == owner_id: 156 | filtered_tags.append(Tag.from_storage(ctx, tag)) 157 | return filtered_tags 158 | 159 | async def get_tag_or_alias(self, ctx: commands.Context, trigger: str) -> (Tag, Alias): 160 | return await self.get_tag(ctx, trigger), await self.get_alias(ctx, trigger) 161 | 162 | async def add_tag_use(self, ctx: commands.Context, tag: Tag, user: int, time: int): 163 | use = Use.new(ctx, user, time) 164 | async with self.config.guild(ctx.guild).tags() as tags: 165 | if tag.tag in tags: 166 | tags[tag.tag]["uses"].append(use.to_dict()) 167 | 168 | async def create_alias(self, ctx: commands.Context, alias: str, tag: str, creator: int, created: int): 169 | new_alias = Alias.new(ctx, alias, creator, created, tag, []) 170 | async with self.config.guild(ctx.guild).aliases() as aliases: 171 | aliases[alias] = new_alias.to_dict() 172 | 173 | async def delete_alias(self, ctx: commands.Context, alias: str): 174 | async with self.config.guild(ctx.guild).aliases() as aliases: 175 | del aliases[alias] 176 | 177 | async def get_alias(self, ctx: commands.Context, trigger: str) -> Alias: 178 | alias = None 179 | async with self.config.guild(ctx.guild).aliases() as aliases: 180 | if trigger in aliases: 181 | alias = Alias.from_storage(ctx, aliases[trigger]) 182 | return alias 183 | 184 | async def get_aliases(self, ctx: commands.Context, creator: Optional[discord.User]) -> List[Alias]: 185 | alias_list = [] 186 | async with self.config.guild(ctx.guild).aliases() as aliases: 187 | for alias_key in aliases.keys(): 188 | alias = Alias.from_storage(ctx, aliases[alias_key]) 189 | if creator is not None: 190 | if not alias.creator == creator.id: 191 | continue 192 | alias_list.append(alias) 193 | return alias_list 194 | 195 | async def get_aliases_by_tag(self, ctx: commands.Context, tag: Tag) -> List[Alias]: 196 | alias_list = [] 197 | async with self.config.guild(ctx.guild).aliases() as aliases: 198 | for alias_key in aliases.keys(): 199 | alias = Alias.from_storage(ctx, aliases[alias_key]) 200 | if alias.tag == tag.tag: 201 | alias_list.append(alias) 202 | return alias_list 203 | 204 | async def get_aliases_by_owner(self, ctx: commands.Context, owner_id: int) -> List[Alias]: 205 | filtered_aliases = [] 206 | async with self.config.guild(ctx.guild).aliases() as aliases: 207 | for alias_key in aliases.keys(): 208 | alias = Alias.from_storage(ctx, aliases[alias_key]) 209 | if alias.creator == owner_id: 210 | filtered_aliases.append(alias) 211 | return filtered_aliases 212 | 213 | async def add_alias_use(self, ctx: commands.Context, alias: Alias, user: int, time: int): 214 | use = Use.new(ctx, user, time) 215 | async with self.config.guild(ctx.guild).aliases() as aliases: 216 | if alias.alias in aliases: 217 | aliases[alias.alias]["uses"].append(use.to_dict()) 218 | tag = await self.get_tag_by_alias(ctx, alias) 219 | if tag is not None: 220 | await self.add_tag_use(ctx, tag, user, time) 221 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rHomelab/LabBot-Cogs/5423048c5975ec808ee1f7ea10c372c7920917d6/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_phishingdetection.py: -------------------------------------------------------------------------------- 1 | from typing import Any, AsyncGenerator, List, Set 2 | 3 | import aiohttp 4 | import pytest 5 | 6 | from phishingdetection import phishingdetection 7 | 8 | 9 | def mutate_url(url: str) -> List[str]: 10 | return [url, f"http://{url}", f"https://{url}", f"https://www.{url}", f"https://www.{url}/foobar", f"https://{url}/foobar"] 11 | 12 | 13 | @pytest.fixture 14 | async def session() -> AsyncGenerator[aiohttp.ClientSession, Any]: 15 | client_session: aiohttp.ClientSession = aiohttp.ClientSession(headers={"X-Identity": "Test client"}) 16 | yield client_session 17 | await client_session.close() 18 | 19 | 20 | @pytest.fixture 21 | async def urls(session: aiohttp.ClientSession) -> Set[str]: 22 | return await phishingdetection.get_all_urls(session) 23 | 24 | 25 | @pytest.fixture 26 | def legitimate_urls() -> Set[str]: 27 | return {"discord.com", "discordapp.com", "twitch.tv", "twitter.com", "tenor.com", "giphy.com"} 28 | 29 | 30 | async def test_fetch_urls(session: aiohttp.ClientSession): 31 | urls = await phishingdetection.get_all_urls(session) 32 | assert len(urls) > 0 33 | 34 | 35 | async def test_can_match(urls: Set[str]): 36 | predicate = phishingdetection.generate_predicate_from_urls(urls) 37 | for url in urls: 38 | for mutation in mutate_url(url): 39 | assert predicate(mutation) is True 40 | 41 | 42 | async def test_no_false_match(urls: Set[str], legitimate_urls: Set[str]): 43 | predicate = phishingdetection.generate_predicate_from_urls(urls) 44 | for url in legitimate_urls: 45 | for mutation in mutate_url(url): 46 | assert predicate(mutation) is False 47 | -------------------------------------------------------------------------------- /timeout/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | from redbot.core.utils import get_end_user_data_statement 3 | 4 | from .timeout import Timeout 5 | 6 | __red_end_user_data_statement__ = get_end_user_data_statement(__file__) 7 | 8 | 9 | async def setup(bot: Red): 10 | await bot.add_cog(Timeout()) 11 | -------------------------------------------------------------------------------- /timeout/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author" : ["coffeebank", "tigattack"], 3 | "install_msg" : "Usage: `[p]timeout `. Configure with `[p]timeoutset`.", 4 | "name" : "timeout", 5 | "short" : "Manage timeout state of users.", 6 | "requirements" : [], 7 | "description" : "Manage the timeout state of a user easily. Set a timeout role and mod log channel, and any users specified will have their roles removed and replaced with the timeout role. Roles will be restored when removed from timeout.", 8 | "end_user_data_statement" : "This cog temporarily stores a list the roles of the given user's roles they are in timeout. No other data is stored.", 9 | "tags": [ 10 | "utility", 11 | "admin" 12 | ], 13 | "min_bot_version": "3.5.1" 14 | } 15 | -------------------------------------------------------------------------------- /timeout/notice.txt: -------------------------------------------------------------------------------- 1 | This cog is based on https://github.com/coffeebank/coffee-cogs 2 | 3 | Copyright coffeebank - GNU General Public License v3.0 4 | 5 | Changes made 6 | * Cog and command names, description, and install message. 7 | * General formatting/styling/terminology. 8 | * Removed unwanted functions. 9 | * Add functionality to save and restore the user's roles before and after timeout respectively. 10 | * Appended tigattack to authors. 11 | * General refactoring to fit preferences and requirements. 12 | -------------------------------------------------------------------------------- /topic/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .topic import Topic 4 | 5 | 6 | async def setup(bot: Red): 7 | """Base setup function""" 8 | await bot.add_cog(Topic()) 9 | -------------------------------------------------------------------------------- /topic/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Hugh Mungus (the1337g33k)" 4 | ], 5 | "short": "Topic cog", 6 | "description": "Allows users to request the bot regurgitate the current channel topic as a message response.", 7 | "disabled": false, 8 | "name": "topic", 9 | "tags": [ 10 | "info" 11 | ], 12 | "install_msg": "Usage: `[p]topic`", 13 | "min_bot_version": "3.5.1" 14 | } -------------------------------------------------------------------------------- /topic/topic.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from redbot.core import app_commands, commands 3 | 4 | 5 | class Topic(commands.Cog): 6 | """Thus beginith the topic command""" 7 | 8 | def __init__(self): 9 | pass 10 | 11 | def _is_valid_channel(self, channel: "discord.abc.MessageableChannel | discord.interactions.InteractionChannel | None"): 12 | if channel is not None and not isinstance( 13 | channel, 14 | ( 15 | discord.VoiceChannel, 16 | discord.Thread, 17 | discord.DMChannel, 18 | discord.PartialMessageable, 19 | discord.GroupChannel, 20 | discord.VoiceChannel, 21 | discord.CategoryChannel, 22 | ), 23 | ): 24 | return channel 25 | return False 26 | 27 | @commands.command() 28 | @commands.guild_only() 29 | async def topic(self, ctx: commands.GuildContext): 30 | """Repeats the current channel's topic as a message in the channel.""" 31 | if channel := self._is_valid_channel(ctx.channel): 32 | topic = channel.topic 33 | if topic: 34 | await ctx.send(f"{ctx.channel.mention}: {topic}") 35 | return 36 | await ctx.send("This channel does not have a topic.") 37 | 38 | @app_commands.command(name="topic") 39 | @app_commands.guild_only() 40 | async def app_topic(self, interaction: discord.Interaction): 41 | """Repeats the current channel's topic as a message in the channel.""" 42 | if channel := self._is_valid_channel(interaction.channel): 43 | topic = channel.topic 44 | if topic: 45 | await interaction.response.send_message(f"{channel.mention}: {topic}") 46 | return 47 | await interaction.response.send_message("This channel does not have a topic.") 48 | -------------------------------------------------------------------------------- /verify/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .verify import VerifyCog 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(VerifyCog(bot)) 8 | -------------------------------------------------------------------------------- /verify/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Issy", 4 | "Sneezey", 5 | "tigattack" 6 | ], 7 | "short": "Give a role to verified users", 8 | "description": "Verify that a user is less-of-a-bot that what could be", 9 | "disabled": false, 10 | "name": "verify", 11 | "tags": [ 12 | "verify", 13 | "verification", 14 | "check" 15 | ], 16 | "install_msg": "Usage: `[p]verify`", 17 | "requirements": [ 18 | "python-Levenshtein" 19 | ], 20 | "min_bot_version": "3.5.1" 21 | } -------------------------------------------------------------------------------- /xkcd/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .xkcd import Xkcd 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(Xkcd()) 8 | -------------------------------------------------------------------------------- /xkcd/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "TheDevFreak" 4 | ], 5 | "description": "Usage: [p]xkcd ", 6 | "short": "Gives xkcd comic of number specified, otherwise latest.", 7 | "name": "xkcd", 8 | "tags": [ 9 | "fun" 10 | ], 11 | "min_bot_version": "3.5.1" 12 | } -------------------------------------------------------------------------------- /xkcd/xkcd.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import discord 3 | from redbot.core import commands 4 | 5 | 6 | async def fetch_get(url_in: str) -> dict: 7 | """Make web requests""" 8 | async with aiohttp.request("GET", url_in) as response: 9 | if response.status != 200: # noqa: PLR2004 10 | return {} 11 | return await response.json() 12 | 13 | 14 | class Xkcd(commands.Cog): 15 | """xkcd Cog""" 16 | 17 | def __init__(self): 18 | pass 19 | 20 | @commands.command() 21 | async def xkcd(self, ctx: commands.Context, comic_number: int = 0): 22 | """Returns xkcd comic of given number, otherwise return latest comic.""" 23 | 24 | if not comic_number: 25 | # No comic specified, get latest 26 | url = "https://xkcd.com/info.0.json" 27 | else: 28 | url = f"https://xkcd.com/{comic_number}/info.0.json" 29 | 30 | # Get comic data from xkcd api 31 | comic_json = await fetch_get(url) 32 | 33 | # If the response isn't 200 throw an error 34 | if not comic_json: 35 | embed = await self.make_error_embed(ctx, "404") 36 | await ctx.send(embed=embed) 37 | return 38 | 39 | embed = await self.make_comic_embed(ctx, comic_json) 40 | await ctx.send(embed=embed) 41 | 42 | async def make_comic_embed(self, ctx: commands.Context, data: dict) -> discord.Embed: 43 | """Generate embed for xkcd comic""" 44 | xkcd_embed = discord.Embed( 45 | title=f"xkcd Comic: #{data['num']}", url=f"https://xkcd.com/{data['num']}", colour=await ctx.embed_colour() 46 | ) 47 | xkcd_embed.add_field(name="Comic Title", value=data["safe_title"]) 48 | xkcd_embed.add_field(name="Publish Date", value=f"{data['year']}-{data['month']}-{data['day']}") 49 | # If there is alt text add it to the embed, otherwise don't 50 | if data["alt"]: 51 | xkcd_embed.add_field(name="Comic Alt Text", value=data["alt"]) 52 | else: 53 | pass 54 | xkcd_embed.set_image(url=data["img"]) 55 | return xkcd_embed 56 | 57 | async def make_error_embed(self, ctx: commands.Context, error_type: str) -> discord.Embed: 58 | "Generate error message embeds" 59 | error_msgs = {"404": "Comic not found"} 60 | return discord.Embed( 61 | title="Error", 62 | description=error_msgs[error_type], 63 | colour=await ctx.embed_colour(), 64 | ) 65 | --------------------------------------------------------------------------------