├── .git-blame-ignore-revs ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── LICENSE ├── README.md ├── defender ├── __init__.py ├── abc.py ├── commands │ ├── __init__.py │ ├── manualmodules.py │ ├── settings.py │ └── stafftools.py ├── core │ ├── announcements.py │ ├── automodules.py │ ├── cache.py │ ├── events.py │ ├── menus.py │ ├── status.py │ ├── utils.py │ └── warden │ │ ├── __init__.py │ │ ├── api.py │ │ ├── enums.py │ │ ├── heat.py │ │ ├── rule.py │ │ ├── utils.py │ │ └── validation.py ├── defender.py ├── enums.py ├── exceptions.py ├── info.json └── tests │ ├── __init__.py │ ├── test_warden.py │ └── wd_sample_rules.py ├── index ├── NOTICE ├── __init__.py ├── exceptions.py ├── index.py ├── info.json ├── parser.py └── views.py ├── info.json ├── pyproject.toml └── sbansync ├── __init__.py ├── info.json └── sbansync.py /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Since version 2.23 (released in August 2019), git-blame has a feature 2 | # to ignore or bypass certain commits. 3 | # 4 | # This file contains a list of commits that are not likely what you 5 | # are looking for in a blame, such as mass reformatting or renaming. 6 | # You can set this file as a default ignore file for blame by running 7 | # the following command. 8 | # 9 | # $ git config blame.ignoreRevsFile .git-blame-ignore-revs 10 | 11 | # Black reformat 12 | 320f7561e8899b784721da2d6d78d65617bdfc7a -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: "Let's squash it \U0001F41B" 4 | title: "[Bug] " 5 | labels: bug 6 | assignees: Twentysix26 7 | 8 | --- 9 | 10 | **Cog** 11 | - [ ] Defender 12 | - [ ] Index 13 | - [ ] Sbansync 14 | 15 | **Describe the bug** 16 | A clear and concise description of what the bug is. 17 | 18 | **To Reproduce** 19 | Steps to reproduce the behavior: 20 | 1. Issue command '...' 21 | 2. Do this '....' 22 | 3. Do that '....' 23 | 4. See error 24 | 25 | **Expected behavior** 26 | A clear and concise description of what you expected to happen. 27 | 28 | **Screenshots** 29 | If applicable, add screenshots to help explain your problem. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Have a cool idea for any of my cogs? Let's hear it 4 | title: "[Feature request] " 5 | labels: enhancement 6 | assignees: Twentysix26 7 | 8 | --- 9 | 10 | **Cog** 11 | - [ ] Defender 12 | - [ ] Index 13 | - [ ] Sbansync 14 | 15 | **Describe the feature you'd like to see** 16 | A clear and concise description of what you'd like to see 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .vscode 132 | .idea -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # x26-Cogs 2 | Cogs made by Twentysix for the Red community 3 | 4 | ## Cogs 5 | ### Index 6 | Allows you to browse a [Red-Index](https://github.com/Cog-Creators/Red-Index/) repo/cog index directly from your Red instance, helping you discover new repos and cogs. This is integrated with the core Downloader, so you can easily install a new repo or a new cog by clicking a reaction. Yay technology! 7 | ### Defender 8 | Protects your community with automod features and empowers the staff and users you trust with advanced moderation tools. 9 | For more information about this cog you can read [the docs](https://github.com/Twentysix26/x26-Cogs/wiki/Defender) and [my write-up](https://x26.it/2020/09/06/project-showcase-defender.html). 10 | This cog offers: 11 | - A wide range of modules, automatic and manual, designed to counter many common patterns of bad actors and thwart large scale attacks 12 | - A very customizable experience: every single module can be fine tuned to better fit your community 13 | - A smart way to subdivide your userbase in ranks, so that your regulars will never be caught in the crossfire 14 | - An extremely versatile module, [Warden](https://github.com/Twentysix26/x26-Cogs/wiki/Warden), that allows you to define complex custom rules to better moderate, monitor and manage your community 15 | - A unique (and optional) way to let your community help *you* when you can't 16 | - The peace of mind that only a smart automod can bring :-) 17 | ## Contributions 18 | Bugfixes are welcome in PR form. 19 | For anything else, such as adding new features, let's discuss about it first! Feel free to [open an issue](https://github.com/Twentysix26/x26-Cogs/issues/new/choose). 20 | -------------------------------------------------------------------------------- /defender/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from .defender import Defender 3 | from redbot.core import VersionInfo, version_info 4 | from pathlib import Path 5 | 6 | with open(Path(__file__).parent / "info.json") as fp: 7 | __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] 8 | 9 | 10 | async def setup(bot): 11 | await bot.add_cog(Defender(bot)) 12 | -------------------------------------------------------------------------------- /defender/abc.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defender - Protects your community with automod features and 3 | empowers the staff and users you trust with 4 | advanced moderation tools 5 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from abc import ABC, abstractmethod 19 | from typing import Optional 20 | from redbot.core import Config, commands 21 | from redbot.core.bot import Red 22 | from .enums import Rank, EmergencyModules 23 | from .core.warden.enums import Event as WardenEvent 24 | from .core.warden.rule import WardenRule 25 | from .core.utils import QuickAction 26 | from typing import List, Dict 27 | import datetime 28 | import discord 29 | import asyncio 30 | 31 | 32 | class CompositeMetaClass(type(commands.Cog), type(ABC)): 33 | """ 34 | This allows the metaclass used for proper type detection to 35 | coexist with discord.py's metaclass 36 | """ 37 | 38 | pass 39 | 40 | 41 | class MixinMeta(ABC): 42 | """ 43 | Base class for well behaved type hint detection with composite class. 44 | Basically, to keep developers sane when not all attributes are defined in each mixin. 45 | """ 46 | 47 | def __init__(self, *_args): 48 | self.config: Config 49 | self.bot: Red 50 | self.emergency_mode: dict 51 | self.active_warden_rules: dict 52 | self.invalid_warden_rules: dict 53 | self.warden_checks: dict 54 | self.joined_users: dict 55 | self.monitor: dict 56 | self.loop: asyncio.AbstractEventLoop 57 | self.quick_actions: Dict[int, Dict[int, QuickAction]] 58 | 59 | @abstractmethod 60 | async def rank_user(self, member: discord.Member) -> Rank: 61 | raise NotImplementedError() 62 | 63 | @abstractmethod 64 | async def is_rank_4(self, member: discord.Member) -> bool: 65 | raise NotImplementedError() 66 | 67 | @abstractmethod 68 | def is_role_privileged(self, role: discord.Role, issuers_top_role: discord.Role = None) -> bool: 69 | raise NotImplementedError() 70 | 71 | @abstractmethod 72 | async def make_message_log( 73 | self, obj, *, guild: discord.Guild, requester: discord.Member = None, replace_backtick=False, pagify_log=False 74 | ): 75 | raise NotImplementedError() 76 | 77 | @abstractmethod 78 | def has_staff_been_active(self, guild: discord.Guild, minutes: int) -> bool: 79 | raise NotImplementedError() 80 | 81 | @abstractmethod 82 | async def refresh_staff_activity(self, guild: discord.Guild, timestamp=None): 83 | raise NotImplementedError() 84 | 85 | @abstractmethod 86 | async def refresh_with_audit_logs_activity(self, guild: discord.Guild): 87 | raise NotImplementedError() 88 | 89 | @abstractmethod 90 | def is_in_emergency_mode(self, guild: discord.Guild) -> bool: 91 | raise NotImplementedError() 92 | 93 | @abstractmethod 94 | def send_to_monitor(self, guild: discord.Guild, entry: str): 95 | raise NotImplementedError() 96 | 97 | @abstractmethod 98 | async def send_announcements(self): 99 | raise NotImplementedError() 100 | 101 | @abstractmethod 102 | async def inc_message_count(self, member: discord.Member): 103 | raise NotImplementedError() 104 | 105 | @abstractmethod 106 | async def get_total_recorded_messages(self, member: discord.Member) -> int: 107 | raise NotImplementedError() 108 | 109 | @abstractmethod 110 | async def is_helper(self, member: discord.Member) -> bool: 111 | raise NotImplementedError() 112 | 113 | @abstractmethod 114 | async def is_emergency_module(self, guild, module: EmergencyModules): 115 | raise NotImplementedError() 116 | 117 | @abstractmethod 118 | async def create_modlog_case( 119 | self, 120 | bot, 121 | guild, 122 | created_at, 123 | action_type, 124 | user, 125 | moderator=None, 126 | reason=None, 127 | until=None, 128 | channel=None, 129 | last_known_username=None, 130 | ): 131 | raise NotImplementedError() 132 | 133 | @abstractmethod 134 | async def send_notification( 135 | self, 136 | destination: discord.abc.Messageable, 137 | description: str, 138 | *, 139 | title: str = None, 140 | fields: list = [], 141 | footer: str = None, 142 | thumbnail: str = None, 143 | ping=False, 144 | file: discord.File = None, 145 | react: str = None, 146 | jump_to: discord.Message = None, 147 | allow_everyone_ping=False, 148 | force_text_only=False, 149 | heat_key: str = None, 150 | no_repeat_for: datetime.timedelta = None, 151 | quick_action: QuickAction = None, 152 | view: discord.ui.View = None 153 | ) -> Optional[discord.Message]: 154 | raise NotImplementedError() 155 | 156 | @abstractmethod 157 | async def join_monitor_flood(self, member: discord.Member): 158 | raise NotImplementedError() 159 | 160 | @abstractmethod 161 | async def join_monitor_suspicious(self, member: discord.Member): 162 | raise NotImplementedError() 163 | 164 | @abstractmethod 165 | async def invite_filter(self, message: discord.Message): 166 | raise NotImplementedError() 167 | 168 | @abstractmethod 169 | async def detect_raider(self, message: discord.Message): 170 | raise NotImplementedError() 171 | 172 | @abstractmethod 173 | async def comment_analysis(self, message: discord.Message): 174 | raise NotImplementedError() 175 | 176 | @abstractmethod 177 | async def make_identify_embed(self, message, user, rank=True, link=True): 178 | raise NotImplementedError() 179 | 180 | @abstractmethod 181 | async def callout_if_fake_admin(self, ctx: commands.Context): 182 | raise NotImplementedError() 183 | 184 | @abstractmethod 185 | def get_warden_rules_by_event(self, guild: discord.Guild, event: WardenEvent) -> List[WardenRule]: 186 | raise NotImplementedError() 187 | 188 | @abstractmethod 189 | def dispatch_event(self, event_name, *args): 190 | raise NotImplementedError() 191 | 192 | @abstractmethod 193 | async def format_punish_message(self, member: discord.Member) -> str: 194 | raise NotImplementedError() 195 | -------------------------------------------------------------------------------- /defender/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from .manualmodules import ManualModules 2 | from .settings import Settings 3 | from .stafftools import StaffTools 4 | from ..abc import CompositeMetaClass 5 | 6 | 7 | class Commands(ManualModules, StaffTools, Settings, metaclass=CompositeMetaClass): # type: ignore 8 | """Class joining all command subclasses""" 9 | -------------------------------------------------------------------------------- /defender/commands/manualmodules.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defender - Protects your community with automod features and 3 | empowers the staff and users you trust with 4 | advanced moderation tools 5 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from ..enums import EmergencyMode 19 | from ..abc import MixinMeta, CompositeMetaClass 20 | from ..enums import EmergencyModules, Action, Rank 21 | from ..core.menus import EmergencyView 22 | from redbot.core import commands 23 | from redbot.core.utils.chat_formatting import box 24 | import discord 25 | import asyncio 26 | 27 | 28 | class ManualModules(MixinMeta, metaclass=CompositeMetaClass): # type: ignore 29 | @commands.cooldown(1, 120, commands.BucketType.channel) 30 | @commands.command(aliases=["staff"]) 31 | @commands.guild_only() 32 | async def alert(self, ctx): 33 | """Alert the staff members""" 34 | guild = ctx.guild 35 | author = ctx.author 36 | message = ctx.message 37 | EMBED_TITLE = "🚨 • Alert" 38 | EMBED_FIELDS = [ 39 | {"name": "Issuer", "value": f"`{author}`"}, 40 | {"name": "ID", "value": f"`{author.id}`"}, 41 | {"name": "Channel", "value": message.channel.mention}, 42 | ] 43 | d_enabled = await self.config.guild(guild).enabled() 44 | enabled = await self.config.guild(guild).alert_enabled() 45 | if not enabled or not d_enabled: 46 | ctx.command.reset_cooldown(ctx) 47 | return await ctx.send("This feature is currently not enabled.") 48 | 49 | if not await self.is_helper(ctx.author) and not await self.bot.is_mod(ctx.author): 50 | ctx.command.reset_cooldown(ctx) 51 | return await ctx.send("You are not authorized to issue this command.") 52 | 53 | notify_channel_id = await self.config.guild(guild).notify_channel() 54 | notify_channel = ctx.guild.get_channel(notify_channel_id) 55 | if not notify_channel_id or not notify_channel: 56 | ctx.command.reset_cooldown(ctx) 57 | return await ctx.send("I don't have a notify channel set or I could not find it.") 58 | 59 | emergency_modules = await self.config.guild(guild).emergency_modules() 60 | 61 | react_text = "" 62 | if emergency_modules: 63 | react_text = " Press the button or take some actions in this server to disable the emergency timer." 64 | 65 | await self.send_notification( 66 | guild, 67 | f"An alert has been issued!{react_text}", 68 | title=EMBED_TITLE, 69 | fields=EMBED_FIELDS, 70 | ping=True, 71 | jump_to=ctx.message, 72 | view=EmergencyView(self), 73 | ) 74 | await ctx.send("The staff has been notified. Please keep calm, I'm sure everything is fine. 🔥") 75 | 76 | ### Emergency mode 77 | 78 | if not emergency_modules: 79 | return 80 | 81 | if self.is_in_emergency_mode(guild): 82 | return 83 | 84 | async def check_audit_log(): 85 | try: 86 | await self.refresh_with_audit_logs_activity(guild) 87 | except discord.Forbidden: # No access to the audit log, welp 88 | pass 89 | 90 | async def maybe_delete(message): 91 | if not message: 92 | return 93 | try: 94 | await message.delete() 95 | except: 96 | pass 97 | 98 | await asyncio.sleep(60) 99 | await check_audit_log() 100 | active = self.has_staff_been_active(guild, minutes=1) 101 | if active: # Someone was active very recently 102 | return 103 | 104 | minutes = await self.config.guild(guild).emergency_minutes() 105 | minutes -= 1 106 | last_msg = None 107 | 108 | if minutes: # This whole countdown thing is skipped if the max inactivity is a single minute 109 | text = ( 110 | "⚠️ No staff activity detected in the past minute. " 111 | "Emergency mode will be engaged in {} minutes. " 112 | "Please stand by. ⚠️" 113 | ) 114 | 115 | last_msg = await ctx.send(f"{ctx.author.mention} " + text.format(minutes)) 116 | await self.send_notification( 117 | guild, 118 | "⚠️ Looks like you're not around. I will automatically engage " 119 | f"emergency mode in {minutes} minutes if you don't show up.", 120 | force_text_only=True, 121 | ) 122 | while minutes != 0: 123 | await asyncio.sleep(60) 124 | await check_audit_log() 125 | if self.has_staff_been_active(guild, minutes=1): 126 | await maybe_delete(last_msg) 127 | ctx.command.reset_cooldown(ctx) 128 | await ctx.send( 129 | "Staff activity detected. Alert deactivated. " "Thanks for helping keep the community safe." 130 | ) 131 | return 132 | minutes -= 1 133 | if minutes % 2: # Halves the # of messages 134 | await maybe_delete(last_msg) 135 | last_msg = await ctx.send(text.format(minutes)) 136 | 137 | guide = { 138 | EmergencyModules.Voteout: "voteout ` - Start a vote to expel a user from the server", 139 | EmergencyModules.Vaporize: ("vaporize ` - Allows you to mass ban users from " "the server"), 140 | EmergencyModules.Silence: ( 141 | "silence (2-4)` - Enables auto-deletion of messages for " "the specified rank (and below)" 142 | ), 143 | } 144 | 145 | text = ( 146 | "⚠️ Emergency mode engaged. Helpers, you are now authorized to use the modules listed below.\n" 147 | "Please be responsible and only use these in case of true necessity, every action you take " 148 | "will be logged and reviewed at a later time.\n" 149 | ) 150 | 151 | for module in emergency_modules: 152 | text += f"`{ctx.prefix}{guide[EmergencyModules(module)]}\n" 153 | 154 | self.emergency_mode[guild.id] = EmergencyMode(manual=False) 155 | 156 | await self.send_notification( 157 | guild, 158 | "⚠️ Emergency mode engaged. Our helpers are now able to use the " 159 | f"**{', '.join(emergency_modules)}** modules.", 160 | force_text_only=True, 161 | ) 162 | 163 | await ctx.send(text) 164 | self.dispatch_event("emergency", guild) 165 | await maybe_delete(last_msg) 166 | 167 | @commands.command() 168 | @commands.guild_only() 169 | async def vaporize(self, ctx, *members: discord.Member): 170 | """Gets rid of bad actors in a quick and silent way 171 | 172 | Works only on Rank 3 and under""" 173 | guild = ctx.guild 174 | channel = ctx.channel 175 | EMBED_TITLE = "☁️ • Vaporize" 176 | EMBED_FIELDS = [ 177 | {"name": "Issuer", "value": f"`{ctx.author}`"}, 178 | {"name": "ID", "value": f"`{ctx.author.id}`"}, 179 | {"name": "Channel", "value": channel.mention}, 180 | ] 181 | has_ban_perms = channel.permissions_for(ctx.author).ban_members 182 | d_enabled = await self.config.guild(guild).enabled() 183 | enabled = await self.config.guild(guild).vaporize_enabled() 184 | em_enabled = await self.is_emergency_module(guild, EmergencyModules.Vaporize) 185 | emergency_mode = self.is_in_emergency_mode(guild) 186 | override = em_enabled and emergency_mode 187 | is_staff = await self.bot.is_mod(ctx.author) 188 | if not is_staff: # Prevents weird edge cases where staff is also helper 189 | is_helper = await self.is_helper(ctx.author) 190 | else: 191 | is_helper = False 192 | 193 | if not d_enabled: 194 | ctx.command.reset_cooldown(ctx) 195 | return await ctx.send("Defender is currently not operational.") 196 | if not is_staff and not is_helper: 197 | ctx.command.reset_cooldown(ctx) 198 | return await ctx.send("You are not authorized to issue this command.") 199 | if not override: 200 | if is_helper: 201 | ctx.command.reset_cooldown(ctx) 202 | if em_enabled: 203 | return await ctx.send( 204 | "This command is only available during emergency mode. " "No such thing right now." 205 | ) 206 | else: 207 | return await ctx.send("You are not authorized to issue this command.") 208 | if is_staff and not enabled: 209 | ctx.command.reset_cooldown(ctx) 210 | return await ctx.send("This command is not available right now.") 211 | if is_staff and not has_ban_perms: 212 | ctx.command.reset_cooldown(ctx) 213 | if em_enabled: 214 | return await ctx.send("You need ban permissions to use this module outside of emergency mode.") 215 | else: 216 | return await ctx.send("You need ban permissions to use this module.") 217 | 218 | guild = ctx.guild 219 | if not members: 220 | await ctx.send_help() 221 | return 222 | max_targets = await self.config.guild(guild).vaporize_max_targets() 223 | if len(members) > max_targets: 224 | await ctx.send(f"No more than {max_targets} users at once. Please try again.") 225 | return 226 | for m in members: 227 | rank = await self.rank_user(m) 228 | if rank < Rank.Rank3: 229 | await ctx.send( 230 | "This command can only be used on Rank 3 and under. " f"`{m}` ({m.id}) is Rank {rank.value}." 231 | ) 232 | return 233 | 234 | errored = [] 235 | 236 | for m in members: 237 | try: 238 | await guild.ban(m, reason=f"Vaporized by {ctx.author} ({ctx.author.id})", delete_message_days=0) 239 | except: 240 | errored.append(str(m.id)) 241 | 242 | if not errored: 243 | await ctx.tick() 244 | else: 245 | await ctx.send("I could not ban the following IDs: " + ", ".join(errored)) 246 | 247 | if len(errored) == len(members): 248 | return 249 | 250 | total = len(members) - len(errored) 251 | await self.send_notification( 252 | guild, f"{total} users have been vaporized.", title=EMBED_TITLE, fields=EMBED_FIELDS, jump_to=ctx.message 253 | ) 254 | 255 | @commands.cooldown(1, 22, commands.BucketType.guild) # More useful as a lock of sorts in this case 256 | @commands.command(cooldown_after_parsing=True) # Only one concurrent session per guild 257 | @commands.guild_only() 258 | async def voteout(self, ctx, *, user: discord.Member): 259 | """Initiates a vote to expel a user from the server 260 | 261 | Can be used by members with helper roles during emergency mode""" 262 | EMOJI = "👢" 263 | guild = ctx.guild 264 | channel = ctx.channel 265 | EMBED_TITLE = "👍 👎 • Voteout" 266 | EMBED_FIELDS = [ 267 | {"name": "Username", "value": f"`{user}`"}, 268 | {"name": "ID", "value": f"`{user.id}`"}, 269 | {"name": "Channel", "value": channel.mention}, 270 | ] 271 | action = await self.config.guild(guild).voteout_action() 272 | user_perms = channel.permissions_for(ctx.author) 273 | if Action(action) == Action.Ban: 274 | perm_text = "ban" 275 | has_action_perms = user_perms.ban_members 276 | else: # Kick / Softban 277 | perm_text = "kick" 278 | has_action_perms = user_perms.kick_members 279 | 280 | d_enabled = await self.config.guild(guild).enabled() 281 | enabled = await self.config.guild(guild).voteout_enabled() 282 | em_enabled = await self.is_emergency_module(guild, EmergencyModules.Voteout) 283 | emergency_mode = self.is_in_emergency_mode(guild) 284 | override = em_enabled and emergency_mode 285 | is_staff = await self.bot.is_mod(ctx.author) 286 | if not is_staff: # Prevents weird edge cases where staff is also helper 287 | is_helper = await self.is_helper(ctx.author) 288 | else: 289 | is_helper = False 290 | 291 | if not d_enabled: 292 | ctx.command.reset_cooldown(ctx) 293 | return await ctx.send("Defender is currently not operational.") 294 | if not is_staff and not is_helper: 295 | ctx.command.reset_cooldown(ctx) 296 | return await ctx.send("You are not authorized to issue this command.") 297 | if not override: 298 | if is_helper: 299 | ctx.command.reset_cooldown(ctx) 300 | if em_enabled: 301 | return await ctx.send( 302 | "This command is only available during emergency mode. " "No such thing right now." 303 | ) 304 | else: 305 | return await ctx.send("You are not authorized to issue this command.") 306 | if is_staff and not enabled: 307 | ctx.command.reset_cooldown(ctx) 308 | return await ctx.send("This command is not available right now.") 309 | if is_staff and not has_action_perms: 310 | ctx.command.reset_cooldown(ctx) 311 | if em_enabled: 312 | return await ctx.send( 313 | f"You need {perm_text} permissions to use this module outside of " "emergency mode." 314 | ) 315 | else: 316 | return await ctx.send(f"You need {perm_text} permissions to use this module.") 317 | 318 | required_rank = await self.config.guild(guild).voteout_rank() 319 | target_rank = await self.rank_user(user) 320 | if target_rank < required_rank: 321 | ctx.command.reset_cooldown(ctx) 322 | await ctx.send( 323 | "You cannot vote to expel that user. " 324 | f"User rank: {target_rank.value} (Must be rank {required_rank} or below)" 325 | ) 326 | return 327 | 328 | required_votes = await self.config.guild(guild).voteout_votes() 329 | 330 | msg = await ctx.send( 331 | f"A voting session to {action} user `{user}` has been initiated.\n" 332 | f"Required votes: **{required_votes}**. Only helper roles and staff " 333 | f"are allowed to vote.\nReact with {EMOJI} to vote." 334 | ) 335 | await msg.add_reaction(EMOJI) 336 | 337 | allowed_roles = await self.config.guild(guild).helper_roles() 338 | allowed_roles.extend(await ctx.bot._config.guild(guild).admin_role()) 339 | allowed_roles.extend(await ctx.bot._config.guild(guild).mod_role()) 340 | voters = [ctx.author] 341 | 342 | def is_allowed(user): 343 | for r in user.roles: 344 | if r.id in allowed_roles: 345 | return True 346 | return False 347 | 348 | def add_vote(r, user): 349 | if r.message.id != msg.id: 350 | return False 351 | elif str(r.emoji) != EMOJI: 352 | return False 353 | elif user.bot: 354 | return False 355 | if user not in voters: 356 | if is_allowed(user): 357 | voters.append(user) 358 | 359 | return len(voters) >= required_votes 360 | 361 | try: 362 | r = await ctx.bot.wait_for("reaction_add", check=add_vote, timeout=20) 363 | except asyncio.TimeoutError: 364 | ctx.command.reset_cooldown(ctx) 365 | return await ctx.send("Vote aborted: insufficient votes.") 366 | 367 | voters_list = "\n".join([f"{v} ({v.id})" for v in voters]) 368 | if Action(action) == Action.Ban: 369 | action_text = "Votebanned with Defender." 370 | days = await self.config.guild(guild).voteout_wipe() 371 | reason = f"{action_text} Voters: {voters_list}" 372 | await guild.ban(user, reason=reason, delete_message_days=days) 373 | self.dispatch_event("member_remove", user, Action.Ban.value, reason) 374 | elif Action(action) == Action.Softban: 375 | action_text = "Votekicked with Defender." # Softban can be considered a kick 376 | reason = f"{action_text} Voters: {voters_list}" 377 | await guild.ban(user, reason=reason, delete_message_days=1) 378 | await guild.unban(user) 379 | self.dispatch_event("member_remove", user, Action.Softban.value, reason) 380 | elif Action(action) == Action.Kick: 381 | action_text = "Votekicked with Defender." 382 | reason = f"{action_text} Voters: {voters_list}" 383 | await guild.kick(user, reason=reason) 384 | self.dispatch_event("member_remove", user, Action.Kick.value, reason) 385 | elif Action(action) == Action.Punish: 386 | action_text = "" 387 | punish_role = guild.get_role(await self.config.guild(guild).punish_role()) 388 | punish_message = await self.format_punish_message(user) 389 | if punish_role and not self.is_role_privileged(punish_role): 390 | await user.add_roles(punish_role, reason="Defender: punish role assignation") 391 | if punish_message: 392 | await ctx.channel.send(punish_message) 393 | else: 394 | self.send_to_monitor( 395 | guild, 396 | "[Voteout] Failed to punish user. Is the punish role " "still present and with *no* privileges?", 397 | ) 398 | await ctx.channel.send( 399 | "The voting session passed but I was not able to punish the " "user due to a misconfiguration." 400 | ) 401 | return 402 | else: 403 | raise ValueError("Invalid action set for voteout.") 404 | 405 | await self.send_notification( 406 | guild, 407 | f"A user has been expelled with " f"a vote.\nVoters:\n{box(voters_list)}", 408 | title=EMBED_TITLE, 409 | fields=EMBED_FIELDS, 410 | jump_to=msg, 411 | ) 412 | 413 | await self.create_modlog_case( 414 | self.bot, guild, ctx.message.created_at, action, user, guild.me, action_text, until=None, channel=None 415 | ) 416 | 417 | ctx.command.reset_cooldown(ctx) 418 | await ctx.send(f"Vote successful. `{user}` has been expelled.") 419 | 420 | @commands.command() 421 | @commands.guild_only() 422 | async def silence(self, ctx: commands.Context, rank: int): 423 | """Enables server wide message autodeletion for the specified rank (and below) 424 | 425 | Passing 0 will disable this.""" 426 | guild = ctx.guild 427 | channel = ctx.channel 428 | EMBED_TITLE = "🔇 • Silence" 429 | EMBED_FIELDS = [{"name": "Issuer", "value": f"`{ctx.author}`"}, {"name": "ID", "value": f"`{ctx.author.id}`"}] 430 | has_mm_perms = channel.permissions_for(ctx.author).manage_messages 431 | d_enabled = await self.config.guild(guild).enabled() 432 | enabled = await self.config.guild(guild).silence_enabled() 433 | em_enabled = await self.is_emergency_module(guild, EmergencyModules.Silence) 434 | emergency_mode = self.is_in_emergency_mode(guild) 435 | override = em_enabled and emergency_mode 436 | is_staff = await self.bot.is_mod(ctx.author) 437 | if not is_staff: # Prevents weird edge cases where staff is also helper 438 | is_helper = await self.is_helper(ctx.author) 439 | else: 440 | is_helper = False 441 | 442 | if not d_enabled: 443 | ctx.command.reset_cooldown(ctx) 444 | return await ctx.send("Defender is currently not operational.") 445 | if not is_staff and not is_helper: 446 | ctx.command.reset_cooldown(ctx) 447 | return await ctx.send("You are not authorized to issue this command.") 448 | if not override: 449 | if is_helper: 450 | ctx.command.reset_cooldown(ctx) 451 | if em_enabled: 452 | return await ctx.send( 453 | "This command is only available during emergency mode. " "No such thing right now." 454 | ) 455 | else: 456 | return await ctx.send("You are not authorized to issue this command.") 457 | if is_staff and not enabled: 458 | ctx.command.reset_cooldown(ctx) 459 | return await ctx.send("This command is not available right now.") 460 | if is_staff and not has_mm_perms: 461 | ctx.command.reset_cooldown(ctx) 462 | if em_enabled: 463 | return await ctx.send( 464 | "You need manage messages permissions to use this " "module outside of emergency mode." 465 | ) 466 | else: 467 | return await ctx.send("You need manage messages permissions to use this module.") 468 | 469 | if rank != 0: 470 | try: 471 | Rank(rank) 472 | except: 473 | return await ctx.send("Not a valid rank. Must be 1-4.") 474 | await self.config.guild(ctx.guild).silence_rank.set(rank) 475 | if rank: 476 | await self.send_notification( 477 | guild, 478 | "This module has been enabled. " 479 | f"Message from users belonging to rank {rank} or below will be deleted.", 480 | title=EMBED_TITLE, 481 | fields=EMBED_FIELDS, 482 | jump_to=ctx.message, 483 | ) 484 | await ctx.send(f"Any message from Rank {rank} and below will be deleted. " "Set 0 to disable silence mode.") 485 | else: 486 | await self.send_notification( 487 | guild, 488 | "This module has been disabled. Messages will no longer be deleted.", 489 | title=EMBED_TITLE, 490 | fields=EMBED_FIELDS, 491 | jump_to=ctx.message, 492 | ) 493 | await ctx.send("Silence mode disabled.") 494 | -------------------------------------------------------------------------------- /defender/core/announcements.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defender - Protects your community with automod features and 3 | empowers the staff and users you trust with 4 | advanced moderation tools 5 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import discord 19 | from datetime import datetime 20 | 21 | TITLE_TEXT = "Defender update" 22 | FOOTER_TEXT = "\n\n*- Twentysix, creator of Defender*" 23 | REPO_LINK = "https://github.com/Twentysix26/x26-Cogs" 24 | WARDEN_URL = "https://github.com/Twentysix26/x26-Cogs/wiki/Warden" 25 | SOCIALS = "\n\n[`x26.it`](https://x26.it) - [`Support me!`](https://www.patreon.com/Twentysix26)" 26 | TAG = FOOTER_TEXT + SOCIALS 27 | WARDEN_ANNOUNCEMENT = ( 28 | "Hello. There is a new auto-module available: **Warden**.\nThis auto-module allows you to define " 29 | "complex rules to better monitor, manage and moderate your community.\nIt is now the most " 30 | f"versatile module that Defender features and by following the [guide]({WARDEN_URL}) " 31 | "you will learn how to leverage its full potential in no time. For any suggestion feel free to " 32 | f"open an issue in my [repository]({REPO_LINK}).\n\n" 33 | "Also, as a small quality of life improvement, the `[p]defender` command has been aliased to " 34 | "`[p]def` (using the standard alias cog would cause some issues).\n\n" 35 | "I hope you're enjoying Defender as much as I enjoyed creating it." 36 | ) 37 | CA_ANNOUNCEMENT = ( 38 | "Hello, a new auto-module is available: **Comment analysis**.\nThis auto-module leverages Google's " 39 | "[Perspective API](https://www.perspectiveapi.com/) to detect all kinds of abusive messages, turning Defender " 40 | "in an even smarter tool for monitoring and prevention.\n\nThis update also brings you some new " 41 | "debugging tools for Warden (check `[p]def warden`) and more consistent notifications for every module.\n" 42 | "To finish up there is now the possibility to assign a *punishing role* through the automodules: " 43 | "this is convenient if you want to prevent an offending user from sending messages instead of just expelling " 44 | "them. As usual, `[p]def status` will guide you through the setup.\nEnjoy!" 45 | ) 46 | 47 | ANNOUNCEMENTS = {1_601_078_400: WARDEN_ANNOUNCEMENT, 1_625_135_507: CA_ANNOUNCEMENT} 48 | 49 | 50 | def _make_announcement_embed(content): 51 | return discord.Embed(color=discord.Colour.red(), title=TITLE_TEXT, description=content) 52 | 53 | 54 | def get_announcements_text(*, only_recent=True): 55 | to_send = {} 56 | now = datetime.utcnow() 57 | 58 | for k, v in ANNOUNCEMENTS.items(): 59 | ts = datetime.utcfromtimestamp(k) 60 | if only_recent is True and now > ts: # The announcement is old 61 | continue 62 | to_send[k] = {"title": TITLE_TEXT, "description": v + TAG} 63 | 64 | return to_send 65 | 66 | 67 | def get_announcements_embed(*, only_recent=True): 68 | to_send = {} 69 | now = datetime.utcnow() 70 | 71 | for k, v in ANNOUNCEMENTS.items(): 72 | ts = datetime.utcfromtimestamp(k) 73 | if only_recent is True and now > ts: # The announcement is old 74 | continue 75 | to_send[k] = _make_announcement_embed(v + TAG) 76 | 77 | return to_send 78 | -------------------------------------------------------------------------------- /defender/core/automodules.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defender - Protects your community with automod features and 3 | empowers the staff and users you trust with 4 | advanced moderation tools 5 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | # Most automodules are too small to have their own files 19 | 20 | from ..abc import MixinMeta, CompositeMetaClass 21 | from redbot.core.utils.chat_formatting import box, humanize_list 22 | from redbot.core.utils.common_filters import INVITE_URL_RE 23 | from ..abc import CompositeMetaClass 24 | from ..enums import Action 25 | from ..core.menus import QAView 26 | from ..core import cache as df_cache 27 | from ..core.utils import get_external_invite, ACTIONS_VERBS, utcnow, timestamp 28 | from ..core.warden import heat 29 | from .utils import timestamp 30 | from io import BytesIO 31 | from collections import namedtuple, OrderedDict 32 | from datetime import timedelta 33 | import contextlib 34 | import discord 35 | import logging 36 | import aiohttp 37 | 38 | PERSPECTIVE_API_URL = "https://commentanalyzer.googleapis.com/v1alpha1/comments:analyze?key={}" 39 | AIOHTTP_TIMEOUT = aiohttp.ClientTimeout(total=5) 40 | log = logging.getLogger("red.x26cogs.defender") 41 | LiteUser = namedtuple("LiteUser", ("id", "name", "joined_at")) 42 | 43 | 44 | class AutoModules(MixinMeta, metaclass=CompositeMetaClass): # type: ignore 45 | async def invite_filter(self, message): 46 | author = message.author 47 | guild = author.guild 48 | EMBED_TITLE = "🔥📧 • Invite filter" 49 | EMBED_FIELDS = [ 50 | {"name": "Username", "value": f"`{author}`"}, 51 | {"name": "ID", "value": f"`{author.id}`"}, 52 | {"name": "Channel", "value": message.channel.mention}, 53 | ] 54 | 55 | result = INVITE_URL_RE.findall(message.content) 56 | 57 | if not result: 58 | return 59 | 60 | exclude_own_invites = await self.config.guild(guild).invite_filter_exclude_own_invites() 61 | 62 | if exclude_own_invites: 63 | external_invite = await get_external_invite(guild, result) 64 | if external_invite is None: 65 | return False 66 | else: 67 | external_invite = result[0][1] 68 | 69 | if len(message.content) > 1000: 70 | content = box(f"{message.content[:1000]}(...)") 71 | else: 72 | content = box(message.content) 73 | 74 | action = Action(await self.config.guild(guild).invite_filter_action()) 75 | 76 | if action == Action.Ban: 77 | reason = "Posting an invite link (Defender autoban)" 78 | await guild.ban(author, reason=reason, delete_message_days=0) 79 | self.dispatch_event("member_remove", author, Action.Ban.value, reason) 80 | elif action == Action.Kick: 81 | reason = "Posting an invite link (Defender autokick)" 82 | await guild.kick(author, reason=reason) 83 | self.dispatch_event("member_remove", author, Action.Kick.value, reason) 84 | elif action == Action.Softban: 85 | reason = "Posting an invite link (Defender autokick)" 86 | await guild.ban(author, reason=reason, delete_message_days=1) 87 | await guild.unban(author) 88 | self.dispatch_event("member_remove", author, Action.Softban.value, reason) 89 | elif action == Action.Punish: 90 | punish_role = guild.get_role(await self.config.guild(guild).punish_role()) 91 | punish_message = await self.format_punish_message(author) 92 | if punish_role and not self.is_role_privileged(punish_role): 93 | await author.add_roles(punish_role, reason="Defender: punish role assignation") 94 | if punish_message: 95 | await message.channel.send(punish_message) 96 | else: 97 | self.send_to_monitor( 98 | guild, 99 | "[InviteFilter] Failed to punish user. Is the punish role " 100 | "still present and with *no* privileges?", 101 | ) 102 | return 103 | 104 | msg_action = "detected" 105 | if await self.config.guild(guild).invite_filter_delete_message(): 106 | msg_action = "attempted to delete" 107 | try: 108 | await message.delete() 109 | except discord.Forbidden: 110 | self.send_to_monitor( 111 | guild, "[InviteFilter] Failed to delete message: " f"no permissions in #{message.channel}" 112 | ) 113 | except discord.NotFound: 114 | pass 115 | except Exception as e: 116 | log.error("Unexpected error in invite filter's message deletion", exc_info=e) 117 | else: 118 | msg_action = "deleted" 119 | 120 | invite_data = f"**About [discord.gg/{external_invite}](https://discord.gg/{external_invite})**\n" 121 | 122 | try: 123 | invite = await self.bot.fetch_invite(external_invite) 124 | except (discord.NotFound, discord.HTTPException): 125 | invite_data += f"I could not gather more information about the invite." 126 | else: 127 | if invite.guild: 128 | invite_data += f"This invite leads to the server `{invite.guild.name}` (`{invite.guild.id}`)\n" 129 | if invite.approximate_presence_count is not None and invite.approximate_member_count is not None: 130 | invite_data += ( 131 | f"It has **{invite.approximate_member_count}** members " 132 | f"({invite.approximate_presence_count} online)\n" 133 | ) 134 | is_partner = "PARTNERED" in invite.guild.features 135 | is_verified = "VERIFIED" in invite.guild.features 136 | chars = [] 137 | chars.append(f"It was created {timestamp(invite.guild.created_at, relative=True)}") 138 | if is_partner: 139 | chars.append("it is a **partner** server") 140 | if is_verified: 141 | chars.append("it is **verified**") 142 | if invite.guild.icon: 143 | chars.append(f"it has an [icon set]({invite.guild.icon})") 144 | if invite.guild.banner: 145 | chars.append(f"it has a [banner set]({invite.guild.banner})") 146 | if invite.guild.description: 147 | chars.append(f"the following is its description:\n{box(invite.guild.description)}") 148 | invite_data += f"{humanize_list(chars)}" 149 | else: 150 | invite_data += f"I have failed to retrieve the server's data. Possibly a group DM invite?\n" 151 | 152 | if action == Action.NoAction: 153 | notif_text = f"I have {msg_action} a message with this content:\n{content}\n{invite_data}" 154 | else: 155 | notif_text = f"I have {ACTIONS_VERBS[action]} a user for posting this message:\n{content}\n{invite_data}" 156 | 157 | quick_action = QAView(self, author.id, "Posting an invite link") 158 | heat_key = f"core-if-{author.id}-{message.channel.id}" 159 | await self.send_notification( 160 | guild, 161 | notif_text, 162 | title=EMBED_TITLE, 163 | fields=EMBED_FIELDS, 164 | jump_to=message, 165 | no_repeat_for=timedelta(minutes=1), 166 | heat_key=heat_key, 167 | view=quick_action, 168 | ) 169 | 170 | await self.create_modlog_case( 171 | self.bot, 172 | guild, 173 | message.created_at, 174 | action.value, 175 | author, 176 | guild.me, 177 | "Posting an invite link", 178 | until=None, 179 | channel=None, 180 | ) 181 | 182 | return True 183 | 184 | async def detect_raider(self, message): 185 | author = message.author 186 | guild = author.guild 187 | EMBED_TITLE = "🦹 • Raider detection" 188 | EMBED_FIELDS = [ 189 | {"name": "Username", "value": f"`{author}`"}, 190 | {"name": "ID", "value": f"`{author.id}`"}, 191 | {"name": "Channel", "value": message.channel.mention}, 192 | ] 193 | 194 | cache = df_cache.get_user_messages(author) 195 | 196 | max_messages = await self.config.guild(guild).raider_detection_messages() 197 | minutes = await self.config.guild(guild).raider_detection_minutes() 198 | x_minutes_ago = message.created_at - timedelta(minutes=minutes) 199 | recent = 0 200 | 201 | for i, m in enumerate(cache): 202 | if m.created_at > x_minutes_ago: 203 | recent += 1 204 | # We only care about the X most recent ones 205 | if i == max_messages - 1: 206 | break 207 | 208 | if recent != max_messages: 209 | return 210 | 211 | quick_action = QAView(self, author.id, "Message spammer") 212 | action = Action(await self.config.guild(guild).raider_detection_action()) 213 | 214 | if action == Action.Ban: 215 | delete_days = await self.config.guild(guild).raider_detection_wipe() 216 | reason = "Message spammer (Defender autoban)" 217 | await guild.ban(author, reason=reason, delete_message_days=delete_days) 218 | self.dispatch_event("member_remove", author, Action.Ban.value, reason) 219 | elif action == Action.Kick: 220 | reason = "Message spammer (Defender autokick)" 221 | await guild.kick(author, reason=reason) 222 | self.dispatch_event("member_remove", author, Action.Kick.value, reason) 223 | elif action == Action.Softban: 224 | reason = "Message spammer (Defender autokick)" 225 | await guild.ban(author, reason=reason, delete_message_days=1) 226 | await guild.unban(author) 227 | self.dispatch_event("member_remove", author, Action.Softban.value, reason) 228 | elif action == Action.NoAction: 229 | await self.send_notification( 230 | guild, 231 | f"User is spamming messages ({recent} " f"messages in {minutes} minutes).", 232 | title=EMBED_TITLE, 233 | fields=EMBED_FIELDS, 234 | jump_to=message, 235 | no_repeat_for=timedelta(minutes=15), 236 | ping=True, 237 | view=quick_action, 238 | ) 239 | return 240 | elif action == Action.Punish: 241 | punish_role = guild.get_role(await self.config.guild(guild).punish_role()) 242 | punish_message = await self.format_punish_message(author) 243 | if punish_role and not self.is_role_privileged(punish_role): 244 | await author.add_roles(punish_role, reason="Defender: punish role assignation") 245 | if punish_message: 246 | await message.channel.send(punish_message) 247 | else: 248 | self.send_to_monitor( 249 | guild, 250 | "[RaiderDetection] Failed to punish user. Is the punish role " 251 | "still present and with *no* privileges?", 252 | ) 253 | return 254 | else: 255 | raise ValueError("Invalid action for raider detection") 256 | 257 | await self.create_modlog_case( 258 | self.bot, 259 | guild, 260 | message.created_at, 261 | action.value, 262 | author, 263 | guild.me, 264 | "Message spammer", 265 | until=None, 266 | channel=None, 267 | ) 268 | past_messages = await self.make_message_log(author, guild=author.guild) 269 | log = "\n".join(past_messages[:40]) 270 | f = discord.File(BytesIO(log.encode("utf-8")), f"{author.id}-log.txt") 271 | await self.send_notification( 272 | guild, 273 | f"I have {ACTIONS_VERBS[action]} a user for posting {recent} " 274 | f"messages in {minutes} minutes. Attached their last stored messages.", 275 | file=f, 276 | title=EMBED_TITLE, 277 | fields=EMBED_FIELDS, 278 | jump_to=message, 279 | no_repeat_for=timedelta(minutes=1), 280 | view=quick_action, 281 | ) 282 | return True 283 | 284 | async def join_monitor_flood(self, member): 285 | EMBED_TITLE = "🔎🕵️ • Join monitor" 286 | guild = member.guild 287 | 288 | if guild.id not in self.joined_users: 289 | self.joined_users[guild.id] = OrderedDict() 290 | 291 | cache = self.joined_users[guild.id] 292 | cache[member.id] = LiteUser(id=member.id, name=str(member), joined_at=member.joined_at) 293 | cache.move_to_end(member.id) # If it's a rejoin we want it last 294 | if len(cache) > 100: 295 | cache.popitem(last=False) 296 | 297 | users = await self.config.guild(guild).join_monitor_n_users() 298 | minutes = await self.config.guild(guild).join_monitor_minutes() 299 | x_minutes_ago = utcnow() - timedelta(minutes=minutes) 300 | 301 | recent_users = [] 302 | for m in reversed(cache.values()): 303 | if m.joined_at > x_minutes_ago: 304 | recent_users.append(m) 305 | else: 306 | break 307 | 308 | if len(recent_users) < users: 309 | return False 310 | 311 | lvl_msg = "" 312 | lvl = await self.config.guild(guild).join_monitor_v_level() 313 | if lvl > guild.verification_level.value: 314 | if not heat.get_custom_heat(guild, "core-jm-lvl") == 0: 315 | return False 316 | heat.increase_custom_heat(guild, "core-jm-lvl", timedelta(minutes=1)) 317 | try: 318 | lvl = discord.VerificationLevel(lvl) 319 | await guild.edit(verification_level=lvl) 320 | lvl_msg = "\nI have raised the server's verification level " f"to `{lvl}`." 321 | except discord.Forbidden: 322 | lvl_msg = "\nI tried to raise the server's verification level " "but I lack the permissions to do so." 323 | except: 324 | lvl_msg = "\nI tried to raise the server's verification level " "but I failed to do so." 325 | 326 | most_recent_txt = "\n".join([f"{m.id} - {m.name}" for m in recent_users[:10]]) 327 | 328 | await self.send_notification( 329 | guild, 330 | f"Abnormal influx of new users ({len(recent_users)} in the past " 331 | f"{minutes} minutes). Possible raid ongoing or about to start.{lvl_msg}" 332 | f"\nMost recent joins: {box(most_recent_txt)}", 333 | title=EMBED_TITLE, 334 | ping=True, 335 | heat_key="core-jm-flood", 336 | no_repeat_for=timedelta(minutes=15), 337 | ) 338 | return True 339 | 340 | async def join_monitor_suspicious(self, member): 341 | EMBED_TITLE = "🔎🕵️ • Join monitor" 342 | EMBED_FIELDS = [ 343 | {"name": "Username", "value": f"`{member}`"}, 344 | {"name": "ID", "value": f"`{member.id}`"}, 345 | {"name": "Account created", "value": timestamp(member.created_at)}, 346 | {"name": "Joined this server", "value": timestamp(member.joined_at)}, 347 | ] 348 | guild = member.guild 349 | hours = await self.config.guild(guild).join_monitor_susp_hours() 350 | 351 | description = f"A user created {timestamp(member.created_at, relative=True)} just joined the server." 352 | heat_key = f"core-jm-{member.id}" 353 | 354 | if hours: 355 | x_hours_ago = member.joined_at - timedelta(hours=hours) 356 | if member.created_at > x_hours_ago: 357 | footer = "To turn off these notifications do `[p]dset joinmonitor notifynew 0` (admin only)" 358 | quick_action = QAView(self, member.id, "New account") 359 | try: 360 | await self.send_notification( 361 | guild, 362 | description, 363 | title=EMBED_TITLE, 364 | fields=EMBED_FIELDS, 365 | thumbnail=member.avatar, 366 | footer=footer, 367 | no_repeat_for=timedelta(minutes=1), 368 | heat_key=heat_key, 369 | view=quick_action, 370 | ) 371 | except (discord.Forbidden, discord.HTTPException): 372 | pass 373 | 374 | description = ( 375 | f"A user created {timestamp(member.created_at, relative=True)} just joined the server {guild.name}." 376 | ) 377 | subs = await self.config.guild(guild).join_monitor_susp_subs() 378 | 379 | for _id in subs: 380 | user = guild.get_member(_id) 381 | if not user: 382 | continue 383 | 384 | hours = await self.config.member(user).join_monitor_susp_hours() 385 | if not hours: 386 | continue 387 | 388 | x_hours_ago = member.joined_at - timedelta(hours=hours) 389 | if member.created_at > x_hours_ago: 390 | footer = "To turn off these notifications do `[p]def notifynew 0` in the server." 391 | try: 392 | await self.send_notification( 393 | user, 394 | description, 395 | title=EMBED_TITLE, 396 | fields=EMBED_FIELDS, 397 | thumbnail=member.avatar, 398 | footer=footer, 399 | no_repeat_for=timedelta(minutes=1), 400 | heat_key=f"{heat_key}-{user.id}", 401 | ) 402 | except (discord.Forbidden, discord.HTTPException): 403 | pass 404 | 405 | async def comment_analysis(self, message): 406 | guild = message.guild 407 | author = message.author 408 | EMBED_TITLE = "💬 • Comment analysis" 409 | EMBED_FIELDS = [ 410 | {"name": "Username", "value": f"`{author}`"}, 411 | {"name": "ID", "value": f"`{author.id}`"}, 412 | {"name": "Channel", "value": message.channel.mention}, 413 | ] 414 | 415 | body = {"comment": {"text": message.content}, "requestedAttributes": {}, "doNotStore": True} 416 | 417 | token = await self.config.guild(guild).ca_token() 418 | attributes = await self.config.guild(guild).ca_attributes() 419 | threshold = await self.config.guild(guild).ca_threshold() 420 | 421 | for attribute in attributes: 422 | body["requestedAttributes"][attribute] = {} 423 | 424 | async with aiohttp.ClientSession() as session: 425 | async with session.post(PERSPECTIVE_API_URL.format(token), json=body, timeout=AIOHTTP_TIMEOUT) as r: 426 | if r.status == 200: 427 | results = await r.json() 428 | else: 429 | if r.status != 400: 430 | # Not explicitly documented but if the API doesn't recognize the language error 400 is returned 431 | # We can safely ignore those cases 432 | log.error("Error querying Perspective API") 433 | log.debug(f"Sent: '{message.content}', received {r.status}") 434 | return 435 | 436 | scores = results["attributeScores"] 437 | for attribute in scores: 438 | attribute_score = scores[attribute]["summaryScore"]["value"] * 100 439 | if attribute_score >= threshold: 440 | triggered_attribute = attribute 441 | break 442 | else: 443 | return 444 | 445 | action = Action(await self.config.guild(guild).ca_action()) 446 | 447 | sanitized_content = message.content.replace("`", "'") 448 | exp_text = f"I have {ACTIONS_VERBS[action]} the user for this message.\n" if action != Action.NoAction else "" 449 | text = ( 450 | f"Possible rule breaking message detected. {exp_text}" 451 | f"The following message scored {round(attribute_score, 2)}% in the **{triggered_attribute}** category:\n" 452 | f"{box(sanitized_content)}" 453 | ) 454 | 455 | delete_days = 0 456 | reason = await self.config.guild(guild).ca_reason() 457 | heat_key = f"core-ca-{author.id}-{message.channel.id}" 458 | 459 | if action == Action.Ban: 460 | delete_days = await self.config.guild(guild).ca_wipe() 461 | await guild.ban(author, reason=reason, delete_message_days=delete_days) 462 | self.dispatch_event("member_remove", author, Action.Ban.value, reason) 463 | elif action == Action.Kick: 464 | await guild.kick(author, reason=reason) 465 | self.dispatch_event("member_remove", author, Action.Kick.value, reason) 466 | elif action == Action.Softban: 467 | delete_days = 1 468 | await guild.ban(author, reason=reason, delete_message_days=delete_days) 469 | await guild.unban(author) 470 | self.dispatch_event("member_remove", author, Action.Softban.value, reason) 471 | elif action == Action.Punish: 472 | punish_role = guild.get_role(await self.config.guild(guild).punish_role()) 473 | punish_message = await self.format_punish_message(author) 474 | if punish_role and not self.is_role_privileged(punish_role): 475 | await author.add_roles(punish_role, reason="Defender: punish role assignation") 476 | if punish_message: 477 | await message.channel.send(punish_message) 478 | else: 479 | self.send_to_monitor( 480 | guild, 481 | "[CommentAnalysis] Failed to punish user. Is the punish role " 482 | "still present and with *no* privileges?", 483 | ) 484 | return 485 | elif action == Action.NoAction: 486 | heat_key = f"core-ca-{author.id}-{message.channel.id}-{len(message.content)}" 487 | 488 | quick_action = QAView(self, author.id, reason) 489 | await self.send_notification( 490 | guild, 491 | text, 492 | title=EMBED_TITLE, 493 | fields=EMBED_FIELDS, 494 | jump_to=message, 495 | heat_key=heat_key, 496 | no_repeat_for=timedelta(minutes=1), 497 | view=quick_action, 498 | ) 499 | 500 | if await self.config.guild(guild).ca_delete_message() and delete_days == 0: 501 | with contextlib.suppress(discord.HTTPException, discord.Forbidden): 502 | await message.delete() 503 | 504 | await self.create_modlog_case( 505 | self.bot, guild, message.created_at, action.value, author, guild.me, reason, until=None, channel=None 506 | ) 507 | -------------------------------------------------------------------------------- /defender/core/cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defender - Protects your community with automod features and 3 | empowers the staff and users you trust with 4 | advanced moderation tools 5 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from collections import deque, defaultdict, namedtuple 19 | from datetime import timedelta 20 | from copy import deepcopy, copy 21 | from typing import Optional 22 | from discord.ext.commands.errors import BadArgument 23 | from discord.ext.commands import IDConverter 24 | from discord.utils import time_snowflake 25 | from redbot.core.utils import AsyncIter 26 | from ..core.utils import utcnow 27 | import regex as re 28 | import discord 29 | import logging 30 | import asyncio 31 | 32 | log = logging.getLogger("red.x26cogs.defender") 33 | 34 | MessageEdit = namedtuple("MessageEdit", ("content", "edited_at")) 35 | # These values are overriden at runtime with the owner's settings 36 | MSG_EXPIRATION_TIME = 48 # Hours 37 | MSG_STORE_CAP = 3000 38 | _guild_dict = {"users": {}, "channels": {}} 39 | _message_cache = defaultdict(lambda: deepcopy(_guild_dict)) 40 | _msg_obj = None # Warden use 41 | 42 | # We're gonna store *a lot* of messages in memory and we're gonna improve 43 | # performances by storing only a lite version of them 44 | 45 | 46 | class LiteMessage: 47 | __slots__ = ("id", "created_at", "content", "channel_id", "author_id", "edits") 48 | 49 | def __init__(self, message: discord.Message): 50 | self.id = message.id 51 | self.created_at = message.created_at 52 | self.content = message.content 53 | self.author_id = message.author.id 54 | self.channel_id = message.channel.id 55 | self.edits = deque(maxlen=20) 56 | if message.attachments: 57 | filename = message.attachments[0].filename 58 | self.content = f"(Attachment: {filename}) {self.content}" 59 | 60 | 61 | class CacheUser: 62 | def __init__(self, _id, guild): 63 | self.id = _id 64 | self.guild = guild 65 | 66 | def __str__(self): 67 | return "Unknown" 68 | 69 | 70 | class UserCacheConverter(IDConverter): 71 | """ 72 | This is a modified version of discord.py's Member converter 73 | https://github.com/Rapptz/discord.py/blob/master/discord/ext/commands/converter.py 74 | 1. Lookup by ID. If found, return a Member 75 | 2. Lookup by name. If found, return a Member 76 | 3. Lookup by ID in cache. If found, return a CacheUser object that will allow to access the cache 77 | """ 78 | 79 | async def convert(self, ctx, argument): 80 | match = self._get_id_match(argument) or re.match(r"<@!?([0-9]+)>$", argument) 81 | guild = ctx.guild 82 | result = None 83 | user_id = None 84 | if match is None: 85 | # not a mention... 86 | if guild: 87 | result = guild.get_member_named(argument) 88 | else: 89 | user_id = int(match.group(1)) 90 | if guild: 91 | result = guild.get_member(user_id) or discord.utils.get(ctx.message.mentions, id=user_id) 92 | 93 | if result is None and guild and user_id: 94 | try: 95 | _message_cache[guild.id]["users"][user_id] 96 | except KeyError: 97 | pass 98 | else: 99 | result = CacheUser(_id=user_id, guild=guild) 100 | 101 | if result is None: 102 | raise BadArgument("User not found in the guild nor in the recorded messages.") 103 | 104 | return result 105 | 106 | 107 | def add_message(message): 108 | author = message.author 109 | guild = message.guild 110 | channel = message.channel 111 | if author.id not in _message_cache[guild.id]["users"]: 112 | _message_cache[guild.id]["users"][author.id] = deque(maxlen=MSG_STORE_CAP) 113 | 114 | lite_message = LiteMessage(message) 115 | 116 | _message_cache[guild.id]["users"][author.id].appendleft(lite_message) 117 | 118 | if channel.id not in _message_cache[guild.id]["channels"]: 119 | _message_cache[guild.id]["channels"][channel.id] = deque(maxlen=MSG_STORE_CAP) 120 | 121 | _message_cache[guild.id]["channels"][channel.id].appendleft(lite_message) 122 | 123 | 124 | async def add_message_edit(message): 125 | author = message.author 126 | guild = message.guild 127 | channel = message.channel 128 | 129 | # .edits will contain past edits 130 | # .content will always be current 131 | async for m in AsyncIter(_message_cache[guild.id]["users"].get(author.id, []).copy(), steps=10): 132 | if m.id == message.id: 133 | m.edits.appendleft(MessageEdit(content=m.content, edited_at=message.edited_at)) 134 | m.content = message.content 135 | break 136 | else: 137 | async for m in AsyncIter(_message_cache[guild.id]["channels"].get(channel.id, []).copy(), steps=10): 138 | if m.id == message.id: 139 | m.edits.appendleft(MessageEdit(content=m.content, edited_at=message.edited_at)) 140 | m.content = message.content 141 | break 142 | 143 | 144 | def get_user_messages(user): 145 | guild = user.guild 146 | if user.id not in _message_cache[guild.id]["users"]: 147 | return [] 148 | 149 | return _message_cache[guild.id]["users"][user.id].copy() 150 | 151 | 152 | def get_channel_messages(channel): 153 | guild = channel.guild 154 | if channel.id not in _message_cache[guild.id]["channels"]: 155 | return [] 156 | 157 | return _message_cache[guild.id]["channels"][channel.id].copy() 158 | 159 | 160 | async def discard_stale(): 161 | x_hours_ago = utcnow() - timedelta(hours=MSG_EXPIRATION_TIME) 162 | for guid, _cache in _message_cache.items(): 163 | for uid, store in _cache["users"].items(): 164 | _message_cache[guid]["users"][uid] = deque( 165 | [m for m in store if m.created_at > x_hours_ago], maxlen=MSG_STORE_CAP 166 | ) 167 | await asyncio.sleep(0) 168 | 169 | for guid, _cache in _message_cache.items(): 170 | for cid, store in _cache["channels"].items(): 171 | _message_cache[guid]["channels"][cid] = deque( 172 | [m for m in store if m.created_at > x_hours_ago], maxlen=MSG_STORE_CAP 173 | ) 174 | await asyncio.sleep(0) 175 | 176 | 177 | async def discard_messages_from_user(_id): 178 | for guid, _cache in _message_cache.items(): 179 | for uid, store in _cache["users"].items(): 180 | _message_cache[guid]["users"][uid] = deque([m for m in store if m.author_id != _id], maxlen=MSG_STORE_CAP) 181 | await asyncio.sleep(0) 182 | 183 | for guid, _cache in _message_cache.items(): 184 | for cid, store in _cache["channels"].items(): 185 | _message_cache[guid]["channels"][cid] = deque( 186 | [m for m in store if m.author_id != _id], maxlen=MSG_STORE_CAP 187 | ) 188 | await asyncio.sleep(0) 189 | 190 | 191 | # This is a single message object that we store to mock commands in Warden 192 | def maybe_store_msg_obj(message: discord.Message): 193 | global _msg_obj 194 | 195 | if _msg_obj is not None: 196 | return 197 | msg = copy(message) 198 | msg.nonce = "262626" 199 | msg.author = None 200 | msg.channel = None 201 | msg.content = "" 202 | msg.mentions = msg.role_mentions = msg.reactions = msg.embeds = msg.attachments = [] 203 | # We're wiping the cached properties here 204 | # High breakage chance if d.py ever changes its internals 205 | try: 206 | for attr in msg._CACHED_SLOTS: 207 | try: 208 | delattr(msg, attr) 209 | except AttributeError: 210 | pass 211 | except Exception as e: 212 | return log.error("Failed to store the message object for issue-command use", exc_info=e) 213 | 214 | _msg_obj = msg 215 | 216 | 217 | def get_msg_obj() -> Optional[discord.Message]: 218 | msg = copy(_msg_obj) 219 | msg.id = time_snowflake(utcnow()) 220 | return msg 221 | -------------------------------------------------------------------------------- /defender/core/menus.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defender - Protects your community with automod features and 3 | empowers the staff and users you trust with 4 | advanced moderation tools 5 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from discord import ui 19 | from discord import SelectOption 20 | from typing import List, Tuple, Union 21 | from ..enums import QAInteractions 22 | from .utils import utcnow 23 | from collections.abc import Iterable 24 | import discord 25 | import logging 26 | 27 | log = logging.getLogger("red.x26cogs.defender") 28 | 29 | 30 | class SettingSetSelect(ui.Select): 31 | def __init__( 32 | self, 33 | config_value, 34 | current_settings: Union[int, str, List[Union[str, int]]], 35 | select_options: Tuple[SelectOption, ...], 36 | max_values=None, 37 | cast_to=None, 38 | **kwargs, 39 | ): 40 | self.cast_to = cast_to 41 | self.config_value = config_value 42 | iterable = isinstance(current_settings, Iterable) 43 | if max_values is None: 44 | max_values = len(select_options) 45 | if not iterable: 46 | current_settings = [str(current_settings)] 47 | else: 48 | current_settings = [str(s) for s in current_settings] 49 | super().__init__(max_values=max_values, **kwargs) 50 | for s in select_options: 51 | default = s.value if s.value is not None else s.label 52 | self.add_option( 53 | value=str(s.value) if s.value is not None else discord.utils.MISSING, 54 | label=s.label, 55 | description=s.description, 56 | emoji=s.emoji, 57 | default=True if default in current_settings else False, 58 | ) 59 | 60 | async def callback(self, inter: discord.Interaction): 61 | values = self.values 62 | if self.cast_to: 63 | values = [self.cast_to(v) for v in values] 64 | if self.max_values == 1: 65 | log.debug(f"Setting {values[0]}, type {type(values[0])}") 66 | await self.config_value.set(values[0]) 67 | else: 68 | log.debug(f"Setting {values}") 69 | await self.config_value.set(values) 70 | 71 | await inter.response.defer() 72 | 73 | 74 | class RestrictedView(ui.View): 75 | def __init__(self, cog, issuer_id, timeout=180, **kwargs): 76 | super().__init__(timeout=timeout, **kwargs) 77 | self.cog = cog 78 | self.issuer_id = issuer_id 79 | 80 | async def interaction_check(self, inter: discord.Interaction): 81 | if inter.user.id != self.issuer_id: 82 | await inter.response.send_message( 83 | "Only the issuer of the command can change these options.", ephemeral=True 84 | ) 85 | return False 86 | return True 87 | 88 | 89 | class QASelect(discord.ui.Select): 90 | def __init__(self, target_id: int): 91 | super().__init__(custom_id=str(target_id), placeholder="Quick action") 92 | self.options = [ 93 | SelectOption(value=QAInteractions.Ban.value, label="Ban", emoji="🔨"), 94 | SelectOption(value=QAInteractions.Kick.value, label="Kick", emoji="👢"), 95 | SelectOption(value=QAInteractions.Softban.value, label="Softban", emoji="💨"), 96 | SelectOption(value=QAInteractions.Punish.value, label="Punish", emoji="👊"), 97 | SelectOption(value=QAInteractions.BanAndDelete24.value, label="Ban + 24h deletion", emoji="🔂"), 98 | ] 99 | 100 | async def callback(self, inter: discord.Interaction): 101 | guild: discord.Guild = inter.guild 102 | user: discord.Member = inter.user 103 | view: QAView = self.view 104 | cog = view.cog 105 | bot = view.bot 106 | reason = view.reason 107 | action = QAInteractions(self.values[0]) 108 | 109 | target = guild.get_member(int(self.custom_id)) 110 | if target is None: 111 | await inter.response.send_message( 112 | "I have tried to take action but the user seems to be gone.", ephemeral=True 113 | ) 114 | return 115 | elif target.top_role >= user.top_role: 116 | cog.send_to_monitor( 117 | guild, f"[QuickAction] Prevented user {user} from taking action on {target}: " "hierarchy check failed." 118 | ) 119 | await inter.response.send_message( 120 | "Denied. Your top role must be higher than the target's to take action on them.", ephemeral=True 121 | ) 122 | return 123 | 124 | # if action in (QAInteractions.Ban, QAInteractions.Softban, QAInteractions.Kick): # Expel = no more actions 125 | # self.quick_actions[guild.id].pop(payload.message_id, None) 126 | 127 | if await bot.is_mod(target): 128 | cog.send_to_monitor(guild, f"[QuickAction] Target user {target} is a staff member. I cannot do that.") 129 | await inter.response.send_message("Denied. You're trying to take action on a staff member.", ephemeral=True) 130 | return 131 | 132 | check1 = user.guild_permissions.ban_members is False and action in ( 133 | QAInteractions.Ban, 134 | QAInteractions.Softban, 135 | QAInteractions.BanAndDelete24, 136 | ) 137 | check2 = user.guild_permissions.kick_members is False and action == QAInteractions.Kick 138 | 139 | if any((check1, check2)): 140 | cog.send_to_monitor(guild, f"[QuickAction] Mod {user} lacks permissions to {action.value}.") 141 | await inter.response.send_message( 142 | "Denied. You lack appropriate permissions for this action.", ephemeral=True 143 | ) 144 | return 145 | 146 | auditlog_reason = f"Defender QuickAction issued by {user} ({user.id})" 147 | 148 | if action == QAInteractions.Ban: 149 | await guild.ban(target, reason=auditlog_reason, delete_message_days=0) 150 | cog.dispatch_event("member_remove", target, action.value, reason) 151 | elif action == QAInteractions.Softban: 152 | await guild.ban(target, reason=auditlog_reason, delete_message_days=1) 153 | await guild.unban(target) 154 | cog.dispatch_event("member_remove", target, action.value, reason) 155 | elif action == QAInteractions.Kick: 156 | await guild.kick(target, reason=auditlog_reason) 157 | cog.dispatch_event("member_remove", target, action.value, reason) 158 | elif action == QAInteractions.Punish: 159 | punish_role = guild.get_role(await cog.config.guild(guild).punish_role()) 160 | if punish_role and not cog.is_role_privileged(punish_role): 161 | await target.add_roles(punish_role, reason=auditlog_reason) 162 | else: 163 | cog.send_to_monitor( 164 | guild, 165 | "[QuickAction] Failed to punish user. Is the punish role " 166 | "still present and with *no* privileges?", 167 | ) 168 | await inter.response.defer() 169 | return 170 | elif action == QAInteractions.BanAndDelete24: 171 | await guild.ban(target, reason=auditlog_reason, delete_message_days=1) 172 | cog.dispatch_event("member_remove", target, action.value, reason) 173 | 174 | if action == QAInteractions.BanAndDelete24: 175 | action = QAInteractions.Ban 176 | 177 | await inter.response.defer() 178 | 179 | await cog.create_modlog_case( 180 | bot, guild, utcnow(), action.value, target, user, reason if reason else None, until=None, channel=None 181 | ) 182 | 183 | 184 | class QAView(discord.ui.View): 185 | def __init__(self, cog, target_id: int, reason: str): 186 | self.cog = cog 187 | self.bot = cog.bot 188 | self.reason = reason 189 | super().__init__(timeout=0) 190 | self.add_item(QASelect(target_id)) 191 | 192 | async def interaction_check(self, inter: discord.Interaction): 193 | if not await self.bot.is_mod(inter.user): 194 | await inter.response.send_message( 195 | "Only staff members are allowed to take action. You sure don't look like one.", ephemeral=True 196 | ) 197 | return False 198 | return True 199 | 200 | 201 | class StopAlertButton(discord.ui.Button): 202 | async def callback(self, inter: discord.Interaction): 203 | self.view.stop() 204 | await self.view.cog.refresh_staff_activity(inter.guild) 205 | self.disabled = True 206 | await inter.response.edit_message(view=self.view) 207 | 208 | 209 | class EmergencyView(discord.ui.View): 210 | def __init__(self, cog): 211 | super().__init__(timeout=0) 212 | self.cog = cog 213 | self.add_item(StopAlertButton(style=discord.ButtonStyle.danger, emoji="⚠️", label="Stop timer")) 214 | 215 | async def interaction_check(self, inter: discord.Interaction): 216 | if not await self.cog.bot.is_mod(inter.user): 217 | await inter.response.send_message( 218 | "Only staff members are allowed to press this button. You sure don't look like one.", ephemeral=True 219 | ) 220 | return False 221 | return True 222 | -------------------------------------------------------------------------------- /defender/core/status.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defender - Protects your community with automod features and 3 | empowers the staff and users you trust with 4 | advanced moderation tools 5 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import discord 19 | from ..enums import Action, EmergencyModules 20 | 21 | DOCS_BASE_URL = "https://twentysix26.github.io/defender-docs" 22 | WD_CHECKS = f"[Warden checks]({DOCS_BASE_URL}/#warden-checks): " "**{}**" 23 | 24 | 25 | async def make_status(ctx, cog): 26 | def is_active(arg): 27 | return "active" if bool(arg) else "not active" 28 | 29 | pages = [] 30 | guild = ctx.guild 31 | d_enabled = await cog.config.guild(guild).enabled() 32 | n_channel = guild.get_channel(await cog.config.guild(guild).notify_channel()) 33 | can_sm_in_n_channel = None 34 | can_rm_in_n_channel = None 35 | if n_channel: 36 | can_sm_in_n_channel = n_channel.permissions_for(guild.me).send_messages 37 | can_rm_in_n_channel = n_channel.permissions_for(guild.me).read_messages 38 | 39 | n_role = guild.get_role(await cog.config.guild(guild).notify_role()) 40 | can_ban = ctx.channel.permissions_for(guild.me).ban_members 41 | can_kick = ctx.channel.permissions_for(guild.me).kick_members 42 | can_read_al = ctx.channel.permissions_for(guild.me).view_audit_log 43 | can_see_own_invites = True 44 | punish_role = guild.get_role(await cog.config.guild(guild).punish_role()) 45 | if not guild.me.guild_permissions.manage_guild: 46 | if await cog.config.guild(guild).invite_filter_enabled(): 47 | exclude_own = await cog.config.guild(guild).invite_filter_exclude_own_invites() 48 | if exclude_own: 49 | can_see_own_invites = False 50 | 51 | msg = ( 52 | "This is an overview of the status and the general settings.\n*Notify role* is the " 53 | "role that gets pinged in case of urgent matters.\n*Notify channel* is where I send " 54 | "notifications about reports and actions I take.\n*Punish role* is the role that I " 55 | 'will assign to misbehaving users if the "action" of a Defender module is ' 56 | 'set to "punish".\n\n' 57 | ) 58 | 59 | admin_roles = await ctx.bot._config.guild(ctx.guild).admin_role() 60 | mod_roles = await ctx.bot._config.guild(ctx.guild).mod_role() 61 | has_core_roles_set = bool(admin_roles) or bool(mod_roles) 62 | 63 | _not = "NOT " if not d_enabled else "" 64 | msg += f"[Defender]({DOCS_BASE_URL}) is **{_not}operational**.\n\n" 65 | 66 | p = ctx.prefix 67 | 68 | if not has_core_roles_set: 69 | msg += ( 70 | f"**Configuration issue:** Core admin / mod roles are not set: see {p}set showsettings / " f"{p}help set\n" 71 | ) 72 | if not n_channel: 73 | msg += f"**Configuration issue:** Notify channel not set ({p}dset general notifychannel)\n" 74 | if can_sm_in_n_channel is False or can_rm_in_n_channel is False: 75 | msg += "**Configuration issue:** I cannot read and/or send messages in the notify channel.\n" 76 | if not n_role: 77 | msg += f"**Configuration issue:** Notify role not set ({p}dset general notifyrole)\n" 78 | if not can_see_own_invites: 79 | msg += "**Configuration issue:** I need 'Manage server' permissions to see our own invites.\n" 80 | if not can_ban: 81 | msg += "**Possible configuration issue:** I'm not able to ban in this server.\n" 82 | if not can_kick: 83 | msg += "**Possible configuration issue:** I'm not able to kick in this server.\n" 84 | if not can_read_al: 85 | msg += ( 86 | "**Possible configuration issue:** I'm not able to see the audit log in this server. " 87 | "I may need this to detect staff activity.\n" 88 | ) 89 | if not d_enabled: 90 | msg += ( 91 | "**Warning:** Since the Defender system is **off** every module will be shown as " 92 | "disabled, regardless of individual settings.\n" 93 | ) 94 | 95 | em = discord.Embed(color=discord.Colour.red(), description=msg) 96 | em.set_footer(text=f"`{p}dset general` to configure") 97 | em.set_author(name=f"Defender system v{cog.__version__}", url=DOCS_BASE_URL) 98 | em.add_field(name="Notify role", value=n_role.mention if n_role else "None set", inline=True) 99 | em.add_field(name="Notify channel", value=n_channel.mention if n_channel else "None set", inline=True) 100 | em.add_field(name="Punish role", value=punish_role.mention if punish_role else "None set", inline=True) 101 | 102 | pages.append(em) 103 | 104 | days = await cog.config.guild(guild).rank3_joined_days() 105 | 106 | msg = ( 107 | "To grant you more granular control on *who* I should target " 108 | "and monitor I categorize the userbase in **ranks**.\n\n" 109 | "**Rank 1** are staff, trusted roles and helper roles\n**Rank 2** are " 110 | "regular users.\n**Rank 3** are users who joined this server " 111 | f"less than *{days} days ago*.\n" 112 | ) 113 | 114 | is_counting = await cog.config.guild(guild).count_messages() 115 | if is_counting: 116 | messages = await cog.config.guild(guild).rank3_min_messages() 117 | rank4_text = ( 118 | f"**Rank 4** are users who joined less than *{days} days ago* " 119 | f"and also have sent less than *{messages}* messages in this " 120 | "server.\n\n" 121 | ) 122 | else: 123 | rank4_text = "Currently there is no **Rank 4** because *message counting* " "in this server is disabled.\n\n" 124 | 125 | msg += rank4_text 126 | 127 | msg += ( 128 | "When setting the target rank of a module, that rank and anything below that will be " 129 | "targeted. Setting Rank 3 as a target, for example, means that Rank 3 and Rank 4 will be " 130 | "considered valid targets.\n\n" 131 | ) 132 | 133 | helpers = ( 134 | f"**Helper roles** are users who are able to use `{p}alert` to report " 135 | "problems that need your attention.\nIf you wish, you can also enable " 136 | "*emergency mode*: if no staff activity is detected in a set time window " 137 | "after an *alert* is issued, helper roles will be granted access to modules " 138 | "that may help them in taking care of bad actors by themselves.\n" 139 | ) 140 | 141 | em_modules = await cog.config.guild(guild).emergency_modules() 142 | minutes = await cog.config.guild(guild).emergency_minutes() 143 | 144 | helpers += "Currently " 145 | if not em_modules: 146 | helpers += ( 147 | "no modules are set to be available in *emergency mode* and as such it is disabled. " 148 | "Some manual modules can be set to be used in *emergency mode* if you wish.\n\n" 149 | ) 150 | else: 151 | em_modules = [f"**{m}**" for m in em_modules] 152 | helpers += "the modules " + ", ".join(em_modules) 153 | helpers += ( 154 | f" will be available to helper roles after **{minutes} minutes** of staff inactivity " 155 | "following an alert.\n\n" 156 | ) 157 | 158 | msg += helpers 159 | 160 | trusted = await cog.config.guild(guild).trusted_roles() 161 | helper = await cog.config.guild(guild).helper_roles() 162 | trusted_roles = [] 163 | helper_roles = [] 164 | 165 | for r in guild.roles: 166 | if r.id in trusted: 167 | trusted_roles.append(r.mention) 168 | if r.id in helper: 169 | helper_roles.append(r.mention) 170 | 171 | if not trusted_roles: 172 | trusted_roles = ["None set."] 173 | if not helper_roles: 174 | helper_roles = ["None set."] 175 | 176 | msg += "Trusted roles: " + " ".join(trusted_roles) + "\n" 177 | msg += "Helper roles: " + " ".join(helper_roles) 178 | 179 | em = discord.Embed(color=discord.Colour.red(), description=msg) 180 | em.set_footer(text=f"See `{p}dset rank3` `{p}dset general` `{p}dset emergency`") 181 | em.set_author(name="Ranks and helper roles") 182 | 183 | pages.append(em) 184 | 185 | enabled = False 186 | if d_enabled: 187 | enabled = await cog.config.guild(guild).raider_detection_enabled() 188 | 189 | rank = await cog.config.guild(guild).raider_detection_rank() 190 | messages = await cog.config.guild(guild).raider_detection_messages() 191 | minutes = await cog.config.guild(guild).raider_detection_minutes() 192 | action = Action(await cog.config.guild(guild).raider_detection_action()) 193 | wipe = await cog.config.guild(guild).raider_detection_wipe() 194 | if action == Action.NoAction: 195 | action = "**notify** the staff about it" 196 | else: 197 | action = f"**{action.value}** them" 198 | 199 | msg = ( 200 | "**Raider detection 🦹**\nThis auto-module is designed to counter raiders. It can detect large " 201 | "amounts of messages in a set time window and take action on the user.\n" 202 | ) 203 | msg += ( 204 | f"It is set so that if a **Rank {rank}** user (or below) sends **{messages} messages** in " 205 | f"**{minutes} minutes** I will {action}.\n" 206 | ) 207 | if action == Action.Ban and wipe: 208 | msg += f"The **ban** will also delete **{wipe} days** worth of messages.\n" 209 | msg += f"{WD_CHECKS.format(is_active(await cog.config.guild(guild).raider_detection_wdchecks()))}\n" 210 | msg += "This module is currently " 211 | msg += "**enabled**.\n\n" if enabled else "**disabled**.\n\n" 212 | 213 | if d_enabled: 214 | enabled = await cog.config.guild(guild).invite_filter_enabled() 215 | rank = await cog.config.guild(guild).invite_filter_rank() 216 | action = await cog.config.guild(guild).invite_filter_action() 217 | own_invites = await cog.config.guild(guild).invite_filter_exclude_own_invites() 218 | if own_invites: 219 | oi_text = "Users are **allowed** to post invites that belong to this server." 220 | if not guild.me.guild_permissions.manage_guild: 221 | oi_text += " However I lack the 'Manage guild' permission. I need that to see our own invites." 222 | else: 223 | oi_text = "I will take action on **any invite**, even when they belong to this server." 224 | 225 | if action == "none": 226 | action = "**target** any user" 227 | else: 228 | action = f"**{action}** any user" 229 | 230 | if await cog.config.guild(guild).invite_filter_delete_message(): 231 | action_msg = "I will also **delete** the invite's message." 232 | else: 233 | action_msg = "I will **not delete** the invite's message." 234 | 235 | msg += ( 236 | "**Invite filter 🔥📧**\nThis auto-module is designed to take care of advertisers. It can detect " 237 | f"a standard Discord invite and take action on the user.\nIt is set so that I will {action} " 238 | f"who is **Rank {rank}** or below. {action_msg} {oi_text}\n" 239 | ) 240 | msg += f"{WD_CHECKS.format(is_active(await cog.config.guild(guild).invite_filter_wdchecks()))}\n" 241 | msg += "This module is currently " 242 | msg += "**enabled**.\n\n" if enabled else "**disabled**.\n\n" 243 | 244 | if d_enabled: 245 | enabled = await cog.config.guild(guild).join_monitor_enabled() 246 | users = await cog.config.guild(guild).join_monitor_n_users() 247 | minutes = await cog.config.guild(guild).join_monitor_minutes() 248 | newhours = await cog.config.guild(guild).join_monitor_susp_hours() 249 | v_level = await cog.config.guild(guild).join_monitor_v_level() 250 | 251 | msg += ( 252 | "**Join monitor 🔎🕵️**\nThis auto-module is designed to report suspicious user joins. It is able " 253 | "to detect an abnormal influx of new users and report any account that has been recently " 254 | "created.\n" 255 | ) 256 | msg += ( 257 | f"It is set so that if **{users} users** join in the span of **{minutes} minutes** I will notify " 258 | "the staff with a ping.\n" 259 | ) 260 | if v_level: 261 | msg += ( 262 | "Additionally I will raise the server's verification level to " 263 | f"**{discord.VerificationLevel(v_level)}**.\n" 264 | ) 265 | else: 266 | msg += "I will **not** raise the server's verification level.\n" 267 | if newhours: 268 | msg += f"I will also report any new user whose account is less than **{newhours} hours old**.\n" 269 | else: 270 | msg += "Newly created accounts notifications are **off**.\n" 271 | msg += f"{WD_CHECKS.format(is_active(await cog.config.guild(guild).join_monitor_wdchecks()))}\n" 272 | msg += "This module is currently " 273 | msg += "**enabled**.\n\n" if enabled else "**disabled**.\n\n" 274 | 275 | em = discord.Embed(color=discord.Colour.red(), description=msg) 276 | em.set_footer(text=f"`{p}dset raiderdetection` `{p}dset invitefilter` `{p}dset joinmonitor` to configure.") 277 | em.set_author(name="Auto modules (1/2)") 278 | 279 | pages.append(em) 280 | 281 | if d_enabled: 282 | enabled = await cog.config.guild(guild).warden_enabled() 283 | active_rules = len(cog.active_warden_rules[guild.id]) 284 | invalid_rules = len(cog.invalid_warden_rules[guild.id]) 285 | total_rules = active_rules + invalid_rules 286 | warden_guide = f"{DOCS_BASE_URL}/warden/overview/" 287 | invalid_text = "" 288 | if invalid_rules: 289 | invalid_text = f", **{invalid_rules}** of which are invalid" 290 | 291 | wd_periodic = "allowed" if await cog.config.wd_periodic_allowed() else "not allowed" 292 | wd_regex = "allowed" if await cog.config.wd_regex_allowed() else "not allowed" 293 | 294 | msg = ( 295 | "**Warden 👮**\nThis auto-module is extremely versatile. Thanks to a rich set of " 296 | "*events*, *conditions* and *actions* that you can combine Warden allows you to define " 297 | "custom rules to counter any common pattern of bad behaviour that you notice in your " 298 | "community.\nMessage filtering, assignation of roles to misbehaving users, " 299 | "custom staff alerts are only a few examples of what you can accomplish " 300 | f"with this powerful module.\nYou can learn more [here]({warden_guide}).\n" 301 | ) 302 | msg += f"The creation of periodic Warden rules is **{wd_periodic}**.\n" 303 | msg += f"The use of regex in Warden rules is **{wd_regex}**.\n" 304 | msg += f"There are a total of **{total_rules}** rules defined{invalid_text}.\n" 305 | msg += "This module is currently " 306 | msg += "**enabled**.\n\n" if enabled else "**disabled**.\n\n" 307 | 308 | PERSPECTIVE_URL = "https://www.perspectiveapi.com/" 309 | PERSPECTIVE_API_URL = "https://developers.perspectiveapi.com/s/docs-get-started" 310 | ca_token = await cog.config.guild(guild).ca_token() 311 | if ca_token: 312 | ca_token = f"The API key is currently set: **{ca_token[:3]}...{ca_token[len(ca_token)-3:]}**" 313 | else: 314 | ca_token = f"The API key is **NOT** set. Get one [here]({PERSPECTIVE_API_URL})" 315 | 316 | ca_action = Action(await cog.config.guild(guild).ca_action()) 317 | ca_wipe = await cog.config.guild(guild).ca_wipe() 318 | ca_show_single_deletion = True 319 | if ca_action == Action.Ban: 320 | if ca_wipe: 321 | ca_show_single_deletion = False 322 | ca_action = f"**ban** the author and **delete {ca_wipe} days** worth of messages" 323 | else: 324 | ca_action = f"**ban** the author" 325 | elif ca_action == Action.Softban: 326 | ca_show_single_deletion = False 327 | ca_action = f"**softban** the author" 328 | elif ca_action == Action.NoAction: 329 | ca_action = "**notify** the staff" 330 | else: 331 | ca_action = f"**{ca_action.value}** the author" 332 | 333 | ca_message_delete = await cog.config.guild(guild).ca_delete_message() 334 | ca_del = "" 335 | if ca_show_single_deletion: 336 | if ca_message_delete: 337 | ca_del = " and **delete** it" 338 | else: 339 | ca_del = " and **not delete** it" 340 | 341 | ca_rank = await cog.config.guild(guild).ca_rank() 342 | ca_attributes = len(await cog.config.guild(guild).ca_attributes()) 343 | ca_threshold = await cog.config.guild(guild).ca_threshold() 344 | enabled = await cog.config.guild(guild).ca_enabled() 345 | 346 | msg += ( 347 | "**Comment analysis 💬**\nThis automodule interfaces with Google's " 348 | f"[Perspective API]({PERSPECTIVE_URL}) to analyze the messages in your server and " 349 | "detect abusive content.\nIt supports a variety of languages and it is a powerful tool " 350 | "for monitoring and prevention. Be mindful of *false positives*: context is not taken " 351 | f"in consideration.\n{ca_token}.\nIt is set so that if I detect an abusive message I will " 352 | f"{ca_action}{ca_del}. The offending user must be **Rank {ca_rank}** or below.\nI will take action " 353 | f"only if the **{ca_threshold}%** threshold is reached for any of the **{ca_attributes}** " 354 | f"attribute(s) that have been set.\n" 355 | ) 356 | msg += f"{WD_CHECKS.format(is_active(await cog.config.guild(guild).ca_wdchecks()))}\n" 357 | msg += "This module is currently " 358 | msg += "**enabled**.\n\n" if enabled else "**disabled**.\n\n" 359 | 360 | em = discord.Embed(color=discord.Colour.red(), description=msg) 361 | em.set_footer(text=f"`{p}dset warden` `{p}def warden` `{p}dset commentanalysis` to configure.") 362 | em.set_author(name="Auto modules (2/2)") 363 | 364 | pages.append(em) 365 | 366 | if d_enabled: 367 | enabled = await cog.config.guild(guild).alert_enabled() 368 | em_modules = await cog.config.guild(guild).emergency_modules() 369 | minutes = await cog.config.guild(guild).emergency_minutes() 370 | 371 | msg = ( 372 | "**Alert 🚨**\nThis manual module is designed to aid helper roles in reporting bad actors to " 373 | f"the staff. Upon issuing the `{p}alert` command the staff will get pinged in the set notification " 374 | "channel and will be given context from where the alert was issued.\nFurther, if any manual module is " 375 | "set to be used in case of staff inactivity (*emergency mode*), they will be rendered available to " 376 | "helper roles after the set time window.\n" 377 | ) 378 | if em_modules: 379 | msg += ( 380 | f"It is set so that the modules **{', '.join(em_modules)}** will be rendered available to helper roles " 381 | f"after the staff has been inactive for **{minutes} minutes** following an alert.\n" 382 | ) 383 | else: 384 | msg += f"No module is set to be used in *emergency mode*, therefore it cannot currently be triggered.\n" 385 | msg += "This module is currently " 386 | msg += "**enabled**.\n\n" if enabled else "**disabled**.\n\n" 387 | 388 | if d_enabled: 389 | enabled = await cog.config.guild(guild).vaporize_enabled() 390 | 391 | v_max_targets = await cog.config.guild(guild).vaporize_max_targets() 392 | msg += ( 393 | "**Vaporize ☁️**\nThis manual module is designed to get rid of vast amounts of bad actors in a quick way " 394 | "without creating a mod-log entry. To prevent misuse only **Rank 3** and below are targetable by this " 395 | f"module. A maximum of **{v_max_targets}** users can be vaporized at once. This module can be rendered available " 396 | "to helper roles in *emergency mode*.\n" 397 | ) 398 | if EmergencyModules.Vaporize.value in em_modules: 399 | msg += "It is set to be rendered available to helper roles in *emergency mode*.\n" 400 | else: 401 | msg += "It is not set to be available in *emergency mode*.\n" 402 | msg += "This module is currently " 403 | msg += "**enabled**.\n\n" if enabled else "**disabled**.\n\n" 404 | 405 | if d_enabled: 406 | enabled = await cog.config.guild(guild).silence_enabled() 407 | 408 | rank_silenced = await cog.config.guild(guild).silence_rank() 409 | 410 | msg += ( 411 | "**Silence 🔇**\nThis manual module allows to enable auto-deletion of messages for the selected ranks.\n" 412 | "It can be rendered available to helper roles in *emergency mode*.\n" 413 | ) 414 | if rank_silenced: 415 | msg += f"It is set to silence **Rank {rank_silenced}** and below.\n" 416 | else: 417 | msg += "No rank is set to be silenced.\n" 418 | if EmergencyModules.Silence.value in em_modules: 419 | msg += "It is set to be rendered available to helper roles in *emergency mode*.\n" 420 | else: 421 | msg += "It is not set to be available in *emergency mode*.\n" 422 | msg += "This module is currently " 423 | msg += "**enabled**.\n\n" if enabled else "**disabled**.\n\n" 424 | 425 | em = discord.Embed(color=discord.Colour.red(), description=msg) 426 | em.set_footer(text=f"`{p}dset alert` `{p}dset vaporize` `{p}dset silence` `{p}dset emergency` to configure.") 427 | em.set_author(name="Manual modules (1/2)") 428 | 429 | pages.append(em) 430 | 431 | if d_enabled: 432 | enabled = await cog.config.guild(guild).voteout_enabled() 433 | 434 | votes = await cog.config.guild(guild).voteout_votes() 435 | rank = await cog.config.guild(guild).voteout_rank() 436 | action = await cog.config.guild(guild).voteout_action() 437 | wipe = await cog.config.guild(guild).voteout_wipe() 438 | 439 | msg = ( 440 | "**Voteout 👍 👎**\nThis manual module allows to start a voting session to expel a user from the " 441 | "server. It is most useful to helper roles, however staff can also use this.\n" 442 | "It can be rendered available to helper roles in *emergency mode*.\n" 443 | ) 444 | msg += ( 445 | f"It is set so that **{votes} votes** (including the issuer) are required to **{action}** " 446 | f"the target user, which must be **Rank {rank}** or below." 447 | ) 448 | if Action(action) == Action.Ban and wipe: 449 | msg += f"\nThe **ban** will also delete **{wipe} days** worth of messages." 450 | msg += "\n" 451 | if EmergencyModules.Voteout.value in em_modules: 452 | msg += "It is set to be rendered available to helper roles in *emergency mode*.\n" 453 | else: 454 | msg += "It is not set to be available in *emergency mode*.\n" 455 | msg += "This module is currently " 456 | msg += "**enabled**.\n\n" if enabled else "**disabled**.\n\n" 457 | 458 | em = discord.Embed(color=discord.Colour.red(), description=msg) 459 | em.set_footer(text=f"`{p}dset voteout` `{p}dset emergency` to configure.") 460 | em.set_author(name="Manual modules (2/2)") 461 | 462 | pages.append(em) 463 | 464 | return pages 465 | -------------------------------------------------------------------------------- /defender/core/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defender - Protects your community with automod features and 3 | empowers the staff and users you trust with 4 | advanced moderation tools 5 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from typing import Tuple, List 19 | from ..enums import Action, QAAction 20 | from ..exceptions import MisconfigurationError 21 | from collections import namedtuple 22 | import datetime 23 | import discord 24 | 25 | ACTIONS_VERBS = { 26 | Action.Ban: "banned", 27 | Action.Softban: "softbanned", 28 | Action.Kick: "kicked", 29 | Action.Punish: "punished", 30 | Action.NoAction: "", 31 | } 32 | 33 | QUICK_ACTION_EMOJIS = { 34 | "👢": Action.Kick, 35 | "🔨": Action.Ban, 36 | "💨": Action.Softban, 37 | "👊": Action.Punish, 38 | "👊🏻": Action.Punish, 39 | "👊🏼": Action.Punish, 40 | "👊🏾": Action.Punish, 41 | "👊🏿": Action.Punish, 42 | "🔂": QAAction.BanDeleteOneDay, 43 | } 44 | 45 | QuickAction = namedtuple("QuickAction", ("target", "reason")) 46 | 47 | 48 | async def get_external_invite(guild: discord.Guild, invites: List[Tuple]): 49 | if not guild.me.guild_permissions.manage_guild: 50 | raise MisconfigurationError("I need 'manage guild' permissions to fetch this server's invites.") 51 | 52 | has_vanity_url = "VANITY_URL" in guild.features 53 | vanity_url = await guild.vanity_invite() if has_vanity_url else "" 54 | if vanity_url: 55 | vanity_url = vanity_url.code 56 | 57 | own_invites = [] 58 | for invite in await guild.invites(): 59 | own_invites.append(invite.code) 60 | 61 | for invite in invites: 62 | if invite[1] == vanity_url: 63 | continue 64 | for own_invite in own_invites: 65 | if invite[1] == own_invite: 66 | break 67 | else: 68 | return invite[1] 69 | 70 | return None 71 | 72 | 73 | def utcnow(): 74 | if discord.version_info.major >= 2: 75 | return datetime.datetime.now(datetime.timezone.utc) 76 | else: 77 | return datetime.datetime.utcnow() 78 | 79 | 80 | def timestamp(ts: datetime.datetime, relative=False): 81 | # Discord assumes UTC timestamps 82 | timestamp = int(ts.replace(tzinfo=datetime.timezone.utc).timestamp()) 83 | 84 | if relative: 85 | return f"" 86 | else: 87 | return f"" 88 | -------------------------------------------------------------------------------- /defender/core/warden/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Twentysix26/x26-Cogs/3a950b780eb88aa4abbfe16d7d56a0cfa8197056/defender/core/warden/__init__.py -------------------------------------------------------------------------------- /defender/core/warden/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defender - Protects your community with automod features and 3 | empowers the staff and users you trust with 4 | advanced moderation tools 5 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from ...abc import MixinMeta 19 | from ...enums import Rank 20 | from .utils import strip_yaml_codeblock 21 | from .rule import WardenCheck 22 | from .enums import Event as WDEvent, ChecksKeys 23 | from typing import Optional 24 | import logging 25 | import discord 26 | import asyncio 27 | 28 | log = logging.getLogger("red.x26cogs.defender") 29 | cog: Optional[MixinMeta] = None 30 | 31 | 32 | def init_api(_cog: MixinMeta): 33 | global cog 34 | cog = _cog 35 | 36 | 37 | async def get_check(guild, module: ChecksKeys): 38 | if cog is None: 39 | raise RuntimeError("Warden API was not initialized.") 40 | 41 | check = cog.warden_checks[guild.id].get(module, None) 42 | 43 | if check: 44 | return check.raw_rule 45 | 46 | return None 47 | 48 | 49 | async def set_check(guild, module: ChecksKeys, conditions: str, author: discord.Member): 50 | if cog is None: 51 | raise RuntimeError("Warden API was not initialized.") 52 | 53 | wd_cond = strip_yaml_codeblock(conditions) 54 | 55 | wd_check = WardenCheck() 56 | await wd_check.parse(wd_cond, cog=cog, author=author, module=module) 57 | 58 | cog.warden_checks[guild.id][module] = wd_check 59 | await cog.config.guild(guild).set_raw(f"{module.value}_wdchecks", value=wd_cond) 60 | 61 | 62 | async def remove_check(guild, module: ChecksKeys): 63 | if cog is None: 64 | raise RuntimeError("Warden API was not initialized.") 65 | 66 | try: 67 | del cog.warden_checks[guild.id][module] 68 | except KeyError: 69 | pass 70 | 71 | await cog.config.guild(guild).clear_raw(f"{module.value}_wdchecks") 72 | 73 | 74 | async def eval_check( 75 | guild, module: ChecksKeys, user: Optional[discord.Member] = None, message: Optional[discord.Message] = None 76 | ): 77 | if cog is None: 78 | raise RuntimeError("Warden API was not initialized.") 79 | 80 | wd_check: WardenCheck = cog.warden_checks[guild.id].get(module, None) 81 | if wd_check is None: # No check = Passed 82 | return True 83 | 84 | return bool(await wd_check.satisfies_conditions(rank=Rank.Rank4, cog=cog, guild=guild, user=user, message=message)) 85 | 86 | 87 | async def load_modules_checks(): 88 | if cog is None: 89 | raise RuntimeError("Warden API was not initialized.") 90 | 91 | n = 0 92 | 93 | guilds = cog.config._get_base_group(cog.config.GUILD) 94 | async with guilds.all() as all_guilds: 95 | for guid, guild_data in all_guilds.items(): 96 | for key in ChecksKeys: 97 | raw_check = guild_data.get(f"{key.value}_wdchecks", None) 98 | if raw_check is None: 99 | continue 100 | n += 1 101 | wd_check = WardenCheck() 102 | await wd_check.parse(raw_check, cog=cog, module=key) 103 | cog.warden_checks[int(guid)][key] = wd_check 104 | 105 | await asyncio.sleep(0) 106 | 107 | log.debug(f"Warden: Loaded {n} checks") 108 | -------------------------------------------------------------------------------- /defender/core/warden/enums.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defender - Protects your community with automod features and 3 | empowers the staff and users you trust with 4 | advanced moderation tools 5 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import enum 19 | 20 | 21 | class Event(enum.Enum): 22 | OnMessage = "on-message" 23 | OnMessageEdit = "on-message-edit" 24 | OnMessageDelete = "on-message-delete" 25 | OnReactionAdd = "on-reaction-add" 26 | OnReactionRemove = "on-reaction-remove" 27 | OnUserJoin = "on-user-join" 28 | OnUserLeave = "on-user-leave" 29 | OnRoleAdd = "on-role-add" 30 | OnRoleRemove = "on-role-remove" 31 | OnEmergency = "on-emergency" 32 | Manual = "manual" 33 | Periodic = "periodic" 34 | 35 | 36 | class Action(enum.Enum): 37 | NotifyStaff = "notify-staff" 38 | BanAndDelete = "ban-user-and-delete" # Ban user in context and delete X days 39 | Kick = "kick-user" # Kick user in context 40 | Softban = "softban-user" # Softban user in context 41 | PunishUser = "punish-user" # Assign the punish role to the user 42 | PunishUserWithMessage = "punish-user-with-message" # Assign the punish role to the user and send the set message 43 | Timeout = "timeout-user" # Timeout user in context 44 | Modlog = "send-mod-log" # Send modlog case of last expel action + reason 45 | DeleteUserMessage = "delete-user-message" # Delete message in context 46 | SetChannelSlowmode = "set-channel-slowmode" # 0 - 6h 47 | AddRolesToUser = "add-roles-to-user" # Adds roles to user in context 48 | RemoveRolesFromUser = "remove-roles-from-user" # Remove roles from user in context 49 | EnableEmergencyMode = "enable-emergency-mode" 50 | SetUserNickname = "set-user-nickname" # Changes nickname of user in context 51 | NoOp = "no-op" # Does nothing. For testing purpose. 52 | SendToMonitor = "send-to-monitor" # Posts a message to [p]defender monitor 53 | AddUserHeatpoint = "add-user-heatpoint" 54 | AddUserHeatpoints = "add-user-heatpoints" 55 | AddChannelHeatpoint = "add-channel-heatpoint" 56 | AddChannelHeatpoints = "add-channel-heatpoints" 57 | AddCustomHeatpoint = "add-custom-heatpoint" 58 | AddCustomHeatpoints = "add-custom-heatpoints" 59 | EmptyUserHeat = "empty-user-heat" 60 | EmptyChannelHeat = "empty-channel-heat" 61 | EmptyCustomHeat = "empty-custom-heat" 62 | IssueCommand = "issue-command" 63 | DeleteLastMessageSentAfter = "delete-last-message-sent-after" 64 | SendMessage = "send-message" # Send a message to an arbitrary destination with an optional embed 65 | GetUserInfo = "get-user-info" # Get info of an arbitrary user 66 | ArchiveThread = "archive-thread" # Archive the thread in context 67 | LockThread = "lock-thread" # Lock the thread in context 68 | ArchiveAndLockThread = "archive-and-lock-thread" # Archive and lock the thread in context 69 | DeleteThread = "delete-thread" # Delete the thread in context 70 | Exit = "exit" # Stops processing the rule 71 | WarnSystemWarn = "warnsystem-warn" ## Warnsystem integration 72 | VarAssign = "var-assign" # Assigns a string to a variable 73 | VarAssignRandom = "var-assign-random" # Assigns a random string to a variable 74 | VarAssignHeat = "var-assign-heat" # Assign heat values to a variable 75 | VarMath = "var-math" 76 | VarReplace = "var-replace" # Replace var's str with substr 77 | VarSlice = "var-slice" # Slice a var 78 | VarSplit = "var-split" # Splits a string into substrings 79 | VarTransform = "var-transform" # Perform a variety of operations on the var 80 | 81 | 82 | class Condition(enum.Enum): 83 | UserIdMatchesAny = "user-id-matches-any" 84 | UsernameMatchesAny = "username-matches-any" 85 | UsernameMatchesRegex = "username-matches-regex" 86 | NicknameMatchesAny = "nickname-matches-any" 87 | NicknameMatchesRegex = "nickname-matches-regex" 88 | DisplayNameMatchesAny = "display-name-matches-any" 89 | DisplayNameMatchesRegex = "display-name-matches-regex" 90 | MessageMatchesAny = "message-matches-any" 91 | MessageMatchesRegex = "message-matches-regex" 92 | MessageContainsWord = "message-contains-word" 93 | UserCreatedLessThan = "user-created-less-than" 94 | UserJoinedLessThan = "user-joined-less-than" 95 | UserActivityMatchesAny = "user-activity-matches-any" 96 | UserStatusMatchesAny = "user-status-matches-any" 97 | UserHasDefaultAvatar = "user-has-default-avatar" 98 | UserHasSentLessThanMessages = "user-has-sent-less-than-messages" 99 | ChannelMatchesAny = "channel-matches-any" 100 | CategoryMatchesAny = "category-matches-any" 101 | ChannelIsPublic = "channel-is-public" 102 | MessageHasAttachment = "message-has-attachment" 103 | InEmergencyMode = "in-emergency-mode" 104 | UserHasAnyRoleIn = "user-has-any-role-in" 105 | MessageContainsInvite = "message-contains-invite" 106 | MessageContainsMedia = "message-contains-media" 107 | MessageContainsUrl = "message-contains-url" 108 | MessageContainsMTMentions = "message-contains-more-than-mentions" 109 | MessageContainsMTUniqueMentions = "message-contains-more-than-unique-mentions" 110 | MessageContainsMTRolePings = "message-contains-more-than-role-pings" 111 | MessageContainsMTEmojis = "message-contains-more-than-emojis" 112 | MessageHasMTCharacters = "message-has-more-than-characters" 113 | UserIsRank = "user-is-rank" 114 | IsStaff = "is-staff" 115 | IsHelper = "is-helper" 116 | UserHeatIs = "user-heat-is" 117 | ChannelHeatIs = "channel-heat-is" 118 | UserHeatMoreThan = "user-heat-more-than" 119 | ChannelHeatMoreThan = "channel-heat-more-than" 120 | CustomHeatIs = "custom-heat-is" 121 | CustomHeatMoreThan = "custom-heat-more-than" 122 | Compare = "compare" 123 | 124 | 125 | class ConditionBlock(enum.Enum): 126 | IfAll = "if-all" 127 | IfAny = "if-any" 128 | IfNot = "if-not" 129 | 130 | 131 | class ConditionalActionBlock(enum.Enum): 132 | IfTrue = "if-true" 133 | IfFalse = "if-false" 134 | 135 | 136 | class ChecksKeys(enum.Enum): 137 | CommentAnalysis = "ca" 138 | RaiderDetection = "raider_detection" 139 | InviteFilter = "invite_filter" 140 | JoinMonitor = "join_monitor" 141 | -------------------------------------------------------------------------------- /defender/core/warden/heat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defender - Protects your community with automod features and 3 | empowers the staff and users you trust with 4 | advanced moderation tools 5 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import discord 19 | import logging 20 | import asyncio 21 | from ...core.utils import utcnow 22 | from copy import deepcopy 23 | from datetime import timedelta 24 | from collections import defaultdict, deque 25 | from typing import Union 26 | 27 | """ 28 | This system is meant to enhance Warden in a way that allows to track (and act on) recurring events 29 | Thanks to this system, for example, it's possible to make rules that track how many messages a user 30 | has sent in a set window of time and act after an arbitrary threshold is reached. 31 | This system works by attaching "heat" points to members (or even channels) that expire after a set 32 | amount of time and are shared between different Warden rules. 33 | """ 34 | 35 | MAX_HEATPOINTS = 100 36 | log = logging.getLogger("red.x26cogs.defender") 37 | 38 | _guild_heat = {"channels": {}, "users": {}, "custom": {}} 39 | _heat_store = defaultdict(lambda: deepcopy(_guild_heat)) 40 | _sandbox_heat_store = defaultdict(lambda: deepcopy(_guild_heat)) 41 | 42 | 43 | class HeatLevel: 44 | __slots__ = ("guild", "id", "type", "_heat_points") 45 | 46 | def __init__(self, guild: int, _id: Union[str, int], _type: str): 47 | self.guild = guild 48 | self.id = _id 49 | self.type = _type 50 | self._heat_points = deque(maxlen=MAX_HEATPOINTS) 51 | 52 | def increase_heat(self, td: timedelta): 53 | ts = utcnow() 54 | ts += td 55 | self._heat_points.append(ts) 56 | 57 | def _expire_heat(self): 58 | now = utcnow() 59 | self._heat_points = deque([h for h in self._heat_points if h > now], maxlen=MAX_HEATPOINTS) 60 | 61 | def __len__(self): 62 | self._expire_heat() 63 | q = len(self._heat_points) 64 | if q == 0: 65 | discard_heatlevel(self) 66 | return q 67 | 68 | def __repr__(self): 69 | return f"" 70 | 71 | 72 | def get_heat_store(guild_id, debug=False): 73 | if debug is False: 74 | return _heat_store[guild_id] 75 | else: 76 | return _sandbox_heat_store[guild_id] 77 | 78 | 79 | def get_user_heat(user: discord.Member, *, debug=False): 80 | heat = get_heat_store(user.guild.id, debug)["users"].get(user.id) 81 | if heat: 82 | return len(heat) 83 | else: 84 | return 0 85 | 86 | 87 | def get_channel_heat(channel: discord.TextChannel, *, debug=False): 88 | heat = get_heat_store(channel.guild.id, debug)["channels"].get(channel.id) 89 | if heat: 90 | return len(heat) 91 | else: 92 | return 0 93 | 94 | 95 | def get_custom_heat(guild: discord.Guild, key: str, *, debug=False): 96 | key = key.lower() 97 | heat = get_heat_store(guild.id, debug)["custom"].get(key) 98 | if heat: 99 | return len(heat) 100 | else: 101 | return 0 102 | 103 | 104 | def empty_user_heat(user: discord.Member, *, debug=False): 105 | heat = get_heat_store(user.guild.id, debug)["users"].get(user.id) 106 | if heat: 107 | discard_heatlevel(heat, debug=debug) 108 | 109 | 110 | def empty_channel_heat(channel: discord.TextChannel, *, debug=False): 111 | heat = get_heat_store(channel.guild.id, debug)["channels"].get(channel.id) 112 | if heat: 113 | discard_heatlevel(heat, debug=debug) 114 | 115 | 116 | def empty_custom_heat(guild: discord.Guild, key: str, *, debug=False): 117 | key = key.lower() 118 | heat = get_heat_store(guild.id, debug)["custom"].get(key) 119 | if heat: 120 | discard_heatlevel(heat, debug=debug) 121 | 122 | 123 | def increase_user_heat(user: discord.Member, td: timedelta, *, debug=False): 124 | heat = get_heat_store(user.guild.id, debug)["users"].get(user.id) 125 | if heat: 126 | heat.increase_heat(td) 127 | else: 128 | get_heat_store(user.guild.id, debug)["users"][user.id] = HeatLevel(user.guild.id, user.id, "users") 129 | get_heat_store(user.guild.id, debug)["users"][user.id].increase_heat(td) 130 | 131 | 132 | def increase_channel_heat(channel: discord.TextChannel, td: timedelta, *, debug=False): 133 | heat = get_heat_store(channel.guild.id, debug)["channels"].get(channel.id) 134 | if heat: 135 | heat.increase_heat(td) 136 | else: 137 | get_heat_store(channel.guild.id, debug)["channels"][channel.id] = HeatLevel( 138 | channel.guild.id, channel.id, "channels" 139 | ) 140 | get_heat_store(channel.guild.id, debug)["channels"][channel.id].increase_heat(td) 141 | 142 | 143 | def increase_custom_heat(guild: discord.Guild, key: str, td: timedelta, *, debug=False): 144 | key = key.lower() 145 | heat = get_heat_store(guild.id, debug)["custom"].get(key) 146 | if heat: 147 | heat.increase_heat(td) 148 | else: 149 | get_heat_store(guild.id, debug)["custom"][key] = HeatLevel(guild.id, key, "custom") 150 | get_heat_store(guild.id, debug)["custom"][key].increase_heat(td) 151 | 152 | 153 | def discard_heatlevel(heatlevel: HeatLevel, *, debug=False): 154 | try: 155 | del get_heat_store(heatlevel.guild, debug)[heatlevel.type][heatlevel.id] 156 | except Exception as e: 157 | pass 158 | 159 | 160 | async def remove_stale_heat(): 161 | # In case you're wondering wtf am I doing here: 162 | # I'm calling len on each HeatLevel object to trigger 163 | # its auto removal logic, so they don't linger indefinitely 164 | # in the cache after the heatpoints are expired and the user is long gone 165 | for store in (_heat_store, _sandbox_heat_store): 166 | for c in store.values(): 167 | for cc in c.values(): 168 | for heat_level in list(cc.values()): 169 | len(heat_level) 170 | await asyncio.sleep(0) 171 | 172 | 173 | def get_state(guild, debug=False): 174 | if not debug: 175 | return _heat_store[guild.id].copy() 176 | else: 177 | return _sandbox_heat_store[guild.id].copy() 178 | 179 | 180 | def empty_state(guild, debug=False): 181 | try: 182 | if not debug: 183 | del _heat_store[guild.id] 184 | else: 185 | del _sandbox_heat_store[guild.id] 186 | except KeyError: 187 | pass 188 | 189 | 190 | def get_custom_heat_keys(guild: discord.Guild): 191 | return list(_heat_store[guild.id]["custom"].keys()) 192 | -------------------------------------------------------------------------------- /defender/core/warden/utils.py: -------------------------------------------------------------------------------- 1 | from rapidfuzz import fuzz, process 2 | from redbot.core.utils import AsyncIter 3 | import emoji 4 | import regex as re 5 | import discord 6 | import logging 7 | import functools 8 | import asyncio 9 | import multiprocessing 10 | 11 | EMOJI_RE = re.compile(r"") 12 | REMOVE_C_EMOJIS_RE = re.compile(r"") 13 | 14 | log = logging.getLogger("red.x26cogs.defender") 15 | 16 | # Based on d.py's EmojiConverter 17 | # https://github.com/Rapptz/discord.py/blob/master/discord/ext/commands/converter.py 18 | 19 | 20 | def has_x_or_more_emojis(bot: discord.Client, guild: discord.Guild, text: str, limit: int): 21 | n = emoji.emoji_count(text) 22 | 23 | if n >= limit: 24 | return True 25 | 26 | if "<" in text: # No need to run a regex if no custom emoji can be present 27 | n += len(list(re.finditer(EMOJI_RE, text))) 28 | 29 | return n >= limit 30 | 31 | 32 | async def run_user_regex(*, rule_obj, cog, guild: discord.Guild, regex: str, text: str): 33 | # This implementation is similar to what reTrigger does for safe-ish user regex. Thanks Trusty! 34 | # https://github.com/TrustyJAID/Trusty-cogs/blob/4d690f6ce51c1c5ebf98a2e05ff504ea26eac30b/retrigger/triggerhandler.py 35 | allowed = await cog.config.wd_regex_allowed() 36 | safety_checks_enabled = await cog.config.wd_regex_safety_checks() 37 | 38 | if not allowed: 39 | return False 40 | 41 | # TODO This section might benefit from locks in case of faulty rules? 42 | 43 | if safety_checks_enabled: 44 | try: 45 | regex_obj = re.compile(regex) # type: ignore 46 | process = cog.wd_pool.apply_async(regex_obj.findall, (text,)) 47 | task = functools.partial(process.get, timeout=3) 48 | new_task = cog.bot.loop.run_in_executor(None, task) 49 | result = await asyncio.wait_for(new_task, timeout=5) 50 | except (multiprocessing.TimeoutError, asyncio.TimeoutError): 51 | log.warning( 52 | f"Warden - User defined regex timed out. This rule has been disabled." 53 | f"\nGuild: {guild.id}\nRegex: {regex}" 54 | ) 55 | cog.active_warden_rules[guild.id].pop(rule_obj.name, None) 56 | cog.invalid_warden_rules[guild.id][rule_obj.name] = rule_obj 57 | async with cog.config.guild(guild).wd_rules() as warden_rules: 58 | # There's no way to disable rules for now. So, let's just break it :D 59 | rule_obj.raw_rule = ( 60 | ":!!! Regex in this rule perform poorly. Fix the issue and remove this line !!!:\n" 61 | + rule_obj.raw_rule 62 | ) 63 | warden_rules[rule_obj.name] = rule_obj.raw_rule 64 | await cog.send_notification( 65 | guild, 66 | f"The Warden rule `{rule_obj.name}` has been disabled for poor regex performances. " 67 | f"Please fix it to prevent this from happening again in the future.", 68 | title="👮 • Warden", 69 | ) 70 | return False 71 | except Exception as e: 72 | log.error("Warden - Unexpected error while running user defined regex", exc_info=e) 73 | return False 74 | else: 75 | return bool(result) 76 | else: 77 | try: 78 | return bool(re.search(regex, text)) 79 | except Exception as e: 80 | log.error(f"Warden - Unexpected error while running user defined regex with no safety checks", exc_info=e) 81 | return False 82 | 83 | 84 | def make_fuzzy_suggestion(term, _list): 85 | result = process.extract(term, _list, limit=1, scorer=fuzz.QRatio) 86 | result = [r for r in result if r[1] > 10] 87 | if result: 88 | return f" Did you mean `{result[0][0]}`?" 89 | else: 90 | return "" 91 | 92 | 93 | async def delete_message_after(message: discord.Message, sleep_for: int): 94 | await asyncio.sleep(sleep_for) 95 | try: 96 | await message.delete() 97 | except: 98 | pass 99 | 100 | 101 | async def rule_add_periodic_prompt(*, cog, message: discord.Message, new_rule): 102 | confirm_emoji = "✅" 103 | guild = message.guild 104 | affected = 0 105 | channel = message.channel 106 | async with channel.typing(): 107 | msg: discord.Message = await channel.send( 108 | "Checking your new rule... Please wait and watch this message for updates." 109 | ) 110 | 111 | def confirm(r, user): 112 | return user == message.author and str(r.emoji) == confirm_emoji and r.message.id == msg.id 113 | 114 | async for m in AsyncIter(guild.members, steps=2): 115 | if m.bot: 116 | continue 117 | if m.joined_at is None: 118 | continue 119 | rank = await cog.rank_user(m) 120 | if await new_rule.satisfies_conditions(rank=rank, user=m, guild=guild, cog=cog): 121 | affected += 1 122 | 123 | if affected >= 10 or affected >= len(guild.members) / 2: 124 | await msg.edit( 125 | content=f"You're adding a periodic rule. At the first run {affected} users will be affected. " 126 | "Are you sure you want to continue?" 127 | ) 128 | await msg.add_reaction(confirm_emoji) 129 | try: 130 | await cog.bot.wait_for("reaction_add", check=confirm, timeout=15) 131 | except asyncio.TimeoutError: 132 | await channel.send("Not adding the rule.") 133 | return False 134 | else: 135 | return True 136 | else: 137 | await msg.edit(content="Safety checks passed.") 138 | return True 139 | 140 | 141 | async def rule_add_overwrite_prompt(*, cog, message: discord.Message): 142 | save_emoji = "💾" 143 | channel = message.channel 144 | msg = await channel.send( 145 | "There is a rule with the same name already. Do you want to " "overwrite it? React to confirm." 146 | ) 147 | 148 | def confirm(r, user): 149 | return user == message.author and str(r.emoji) == save_emoji and r.message.id == msg.id 150 | 151 | await msg.add_reaction(save_emoji) 152 | try: 153 | r = await cog.bot.wait_for("reaction_add", check=confirm, timeout=15) 154 | except asyncio.TimeoutError: 155 | await channel.send("Not proceeding with overwrite.") 156 | return False 157 | else: 158 | return True 159 | 160 | 161 | def strip_yaml_codeblock(code: str): 162 | code = code.strip("\n") 163 | if code.startswith(("```yaml", "```YAML")): 164 | code = code.lstrip("`yamlYAML") 165 | if code.startswith(("```yml", "```YML")): 166 | code = code.lstrip("`ymlYML") 167 | if code.startswith("```") or code.endswith("```"): 168 | code = code.strip("`") 169 | 170 | return code 171 | -------------------------------------------------------------------------------- /defender/enums.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defender - Protects your community with automod features and 3 | empowers the staff and users you trust with 4 | advanced moderation tools 5 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import enum 19 | 20 | 21 | class EmergencyMode: 22 | def __init__(self, *, manual): 23 | self.is_manual = manual # Manual mode won't automatically be disabled by staff activity 24 | 25 | 26 | class Rank(enum.IntEnum): 27 | """Ranks of trust""" 28 | 29 | Rank1 = 1 # Trusted user. Has at least one of the roles defined in "trusted_roles" or is staff/admin 30 | Rank2 = 2 # User that satisfies all the requirements below 31 | Rank3 = 3 # User that joined . 16 | """ 17 | 18 | 19 | class CoreException(Exception): 20 | pass 21 | 22 | 23 | class MisconfigurationError(CoreException): 24 | pass 25 | 26 | 27 | class WardenException(Exception): 28 | pass 29 | 30 | 31 | class InvalidRule(WardenException): 32 | pass 33 | 34 | 35 | class ExecutionError(WardenException): 36 | pass 37 | 38 | 39 | class StopExecution(WardenException): 40 | pass 41 | -------------------------------------------------------------------------------- /defender/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Twentysix (Twentysix#5252)" 4 | ], 5 | "description": "Protects your community with automod features and empowers the staff and users you trust with advanced moderation tools", 6 | "install_msg": "Thank you for installing Defender. This cog is very feature packed and customizable: `[p]def status` will give you a very good overview of how this cog works and will also direct you to the individual settings commands. See `[p]help Defender` too for a list of commands.\nFor further information you can read the docs and my blog post at ", 7 | "short": "Protects your community with automod features and empowers the staff and users you trust with advanced moderation tools", 8 | "tags": [ 9 | "automod", 10 | "antiraid", 11 | "moderation", 12 | "monitoring" 13 | ], 14 | "requirements": ["emoji~=1.6.3", "pydantic~=2.7.2", "regex==2022.4.24"], 15 | "min_bot_version": "3.5.0.dev317", 16 | "type": "COG", 17 | "end_user_data_statement": "This cog stores user IDs for the purpose of counting the messages a user sends and/or send the DM notifications the user has subscribed to." 18 | } 19 | -------------------------------------------------------------------------------- /defender/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Twentysix26/x26-Cogs/3a950b780eb88aa4abbfe16d7d56a0cfa8197056/defender/tests/__init__.py -------------------------------------------------------------------------------- /defender/tests/test_warden.py: -------------------------------------------------------------------------------- 1 | from ..core.warden.enums import Action, Condition, ChecksKeys 2 | from ..enums import Rank 3 | from ..core.warden.validation import CONDITIONS_VALIDATORS, ACTIONS_VALIDATORS 4 | from ..core.warden.validation import CONDITIONS_ANY_CONTEXT, CONDITIONS_USER_CONTEXT, CONDITIONS_MESSAGE_CONTEXT 5 | from ..core.warden.validation import ACTIONS_ANY_CONTEXT, ACTIONS_USER_CONTEXT, ACTIONS_MESSAGE_CONTEXT, BaseModel 6 | from ..core.warden.rule import WardenRule, WardenCheck 7 | from ..core.warden import heat 8 | from ..core.warden.rule import WardenRule 9 | from ..core.utils import utcnow 10 | from ..exceptions import InvalidRule 11 | from . import wd_sample_rules as rl 12 | from datetime import timedelta 13 | from discord import Activity 14 | import pytest 15 | 16 | 17 | class FakeGuildPerms: 18 | manage_guild = False 19 | 20 | 21 | class FakeMe: 22 | guild_permissions = FakeGuildPerms 23 | 24 | 25 | class FakeRole: 26 | def __init__(self, _id, name): 27 | self.id = _id 28 | self.name = name 29 | 30 | 31 | class FakeGuild: 32 | id = 852_499_907_842_801_727 33 | me = FakeMe 34 | text_channels = {} 35 | roles = {} 36 | icon = None 37 | banner = None 38 | 39 | def get_role(self, _id): 40 | for role in self.roles: 41 | if _id == role.id: 42 | return role 43 | 44 | 45 | FAKE_GUILD = FakeGuild() 46 | 47 | 48 | class FakeChannel: 49 | id = 852_499_907_842_801_728 50 | name = "fake" 51 | guild = FAKE_GUILD 52 | category = None 53 | mention = "<@852499907842801728>" 54 | 55 | 56 | FAKE_CHANNEL = FakeChannel() 57 | 58 | 59 | class FakeAsset: 60 | filename = "26.jpg" 61 | url = "https://blabla" 62 | 63 | 64 | class FakeUser: 65 | nick = None 66 | display_name = "Twentysix" 67 | name = "Twentysix" 68 | id = 852_499_907_842_801_726 69 | guild = FAKE_GUILD 70 | mention = "<@852499907842801726>" 71 | created_at = utcnow() 72 | joined_at = utcnow() 73 | avatar = FakeAsset() 74 | roles = {} 75 | activities = [Activity(name="fake activity"), Activity(name="spam")] 76 | 77 | 78 | FAKE_USER = FakeUser() 79 | 80 | 81 | class FakeMessage: 82 | id = 852_499_907_842_801_729 83 | guild = FAKE_GUILD 84 | channel = FAKE_CHANNEL 85 | author = FAKE_USER 86 | content = clean_content = "increase" 87 | created_at = utcnow() 88 | jump_url = "" 89 | attachments = [] 90 | raw_mentions = [] 91 | mentions = [] 92 | role_mentions = [] 93 | 94 | 95 | FAKE_MESSAGE = FakeMessage() 96 | 97 | 98 | def test_inheritance(): 99 | for c in CONDITIONS_VALIDATORS.values(): 100 | assert issubclass(c, BaseModel) 101 | 102 | for c in ACTIONS_VALIDATORS.values(): 103 | assert issubclass(c, BaseModel) 104 | 105 | 106 | def test_check_validators_consistency(): 107 | def x_contains_only_y(x, y): 108 | for element in x: 109 | if not isinstance(element, y): 110 | return False 111 | return True 112 | 113 | for condition in Condition: 114 | assert condition in CONDITIONS_VALIDATORS 115 | 116 | for action in Action: 117 | assert action in ACTIONS_VALIDATORS 118 | 119 | i = 0 120 | print("Checking if conditions are in one and only one context...") 121 | for condition in Condition: 122 | print(f"Checking {condition.value}...") 123 | if condition in CONDITIONS_ANY_CONTEXT: 124 | i += 1 125 | 126 | if condition in CONDITIONS_USER_CONTEXT: 127 | i += 1 128 | 129 | if condition in CONDITIONS_MESSAGE_CONTEXT: 130 | i += 1 131 | 132 | assert i == 1 133 | i = 0 134 | 135 | i = 0 136 | print("Checking if actions are in one and only one context...") 137 | for action in Action: 138 | print(f"Checking {action.value}...") 139 | if action in ACTIONS_ANY_CONTEXT: 140 | i += 1 141 | 142 | if action in ACTIONS_USER_CONTEXT: 143 | i += 1 144 | 145 | if action in ACTIONS_MESSAGE_CONTEXT: 146 | i += 1 147 | 148 | assert i == 1 149 | i = 0 150 | 151 | assert x_contains_only_y(CONDITIONS_ANY_CONTEXT, Condition) 152 | assert x_contains_only_y(CONDITIONS_USER_CONTEXT, Condition) 153 | assert x_contains_only_y(CONDITIONS_MESSAGE_CONTEXT, Condition) 154 | assert x_contains_only_y(ACTIONS_ANY_CONTEXT, Action) 155 | assert x_contains_only_y(ACTIONS_USER_CONTEXT, Action) 156 | assert x_contains_only_y(ACTIONS_MESSAGE_CONTEXT, Action) 157 | 158 | 159 | @pytest.mark.asyncio 160 | async def test_rule_parsing(): 161 | with pytest.raises(InvalidRule, match=r".*rank.*"): 162 | await WardenRule().parse(rl.INVALID_RANK, cog=None) 163 | with pytest.raises(InvalidRule, match=r".*event.*"): 164 | await WardenRule().parse(rl.INVALID_EVENT, cog=None) 165 | with pytest.raises(InvalidRule, match=r".*number.*"): 166 | await WardenRule().parse(rl.INVALID_PRIORITY, cog=None) 167 | with pytest.raises(InvalidRule, match=r".*'run-every' parameter is mandatory.*"): 168 | await WardenRule().parse(rl.INVALID_PERIODIC_MISSING_RUN_EVERY, cog=None) 169 | with pytest.raises(InvalidRule, match=r".*'periodic' event must be specified.*"): 170 | await WardenRule().parse(rl.INVALID_PERIODIC_MISSING_EVENT, cog=None) 171 | with pytest.raises(InvalidRule, match=r".*Statement `message-matches-any` not allowed*"): 172 | await WardenRule().parse(rl.INVALID_MIXED_RULE_CONDITION, cog=None) 173 | with pytest.raises(InvalidRule, match=r".*Statement `delete-user-message` not allowed*"): 174 | await WardenRule().parse(rl.INVALID_MIXED_RULE_ACTION, cog=None) 175 | with pytest.raises(InvalidRule, match=r".*too many arguments*"): 176 | await WardenRule().parse(rl.INVALID_TOO_MANY_ARGS, cog=None) 177 | with pytest.raises(InvalidRule, match=r".*Input should be less than*"): 178 | await WardenRule().parse(rl.OOB_USER_HEATPOINTS, cog=None) 179 | with pytest.raises(InvalidRule, match=r".*This amount of time is too large*"): 180 | await WardenRule().parse(rl.OOB_USER_HEATPOINTS2, cog=None) 181 | with pytest.raises(InvalidRule, match=r".*Input should be less than*"): 182 | await WardenRule().parse(rl.OOB_CUSTOM_HEATPOINTS, cog=None) 183 | with pytest.raises(InvalidRule, match=r".*This amount of time is too large*"): 184 | await WardenRule().parse(rl.OOB_CUSTOM_HEATPOINTS2, cog=None) 185 | with pytest.raises(InvalidRule, match=r".*cannot start with*"): 186 | await WardenRule().parse(rl.RESERVED_KEY_CUSTOM_HEATPOINTS, cog=None) 187 | with pytest.raises(InvalidRule, match=r".*Invalid variable name*"): 188 | await WardenRule().parse(rl.INVALID_VAR_NAME, cog=None) 189 | with pytest.raises(InvalidRule, match=r".*less than or equal to 4*"): 190 | await WardenRule().parse(rl.INVALID_RANK, cog=None) 191 | with pytest.raises(InvalidRule, match=r".*This amount of time is too large*"): 192 | await WardenRule().parse(rl.OOB_DELETE_AFTER, cog=None) 193 | with pytest.raises( 194 | InvalidRule, match=r".*conditional action blocks are not allowed in the condition section of a rule.*" 195 | ): 196 | await WardenRule().parse(rl.INVALID_COND_ACTION_BLOCK_IN_CONDITION_SECTION, cog=None) 197 | with pytest.raises(InvalidRule, match=r".*Actions .* are not allowed in the condition section of a rule*"): 198 | await WardenRule().parse(rl.INVALID_ACTION_IN_CONDITION_SECTION, cog=None) 199 | with pytest.raises(InvalidRule, match=r".*Actions are not allowed inside condition blocks*"): 200 | await WardenRule().parse(rl.INVALID_NESTING_ACTION_IN_COND_BLOCK, cog=None) 201 | with pytest.raises(InvalidRule, match=r".*Conditional action blocks are not allowed inside condition blocks*"): 202 | await WardenRule().parse(rl.INVALID_NESTING_COND_ACTION_BLOCK_IN_COND_BLOCK, cog=None) 203 | 204 | await WardenRule().parse(rl.TUTORIAL_SIMPLE_RULE, cog=None) 205 | await WardenRule().parse(rl.TUTORIAL_PRIORITY_RULE, cog=None) 206 | await WardenRule().parse(rl.VALID_MIXED_RULE, cog=None) 207 | await WardenRule().parse(rl.NESTED_COMPLEX_RULE, cog=None) 208 | 209 | rule = WardenRule() 210 | await rule.parse(rl.TUTORIAL_COMPLEX_RULE, cog=None) 211 | assert isinstance(rule.rank, Rank) 212 | assert rule.name and isinstance(rule.name, str) 213 | assert rule.raw_rule and isinstance(rule.raw_rule, str) 214 | assert rule.events and isinstance(rule.events, list) 215 | assert rule.cond_tree and isinstance(rule.cond_tree, dict) 216 | assert rule.action_tree and isinstance(rule.action_tree, dict) 217 | 218 | # TODO Add rules to check for invalid types, non-empty lists, etc 219 | # Restore allowed events tests 220 | 221 | 222 | @pytest.mark.asyncio 223 | async def test_rule_cond_eval(): 224 | rule = WardenRule() 225 | await rule.parse(rl.CHECK_RANK_SAFEGUARD, cog=None) 226 | assert bool(await rule.satisfies_conditions(cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, user=FAKE_USER)) is False 227 | 228 | rule = WardenRule() 229 | await rule.parse(rl.CHECK_RANK_SAFEGUARD, cog=None) 230 | assert bool(await rule.satisfies_conditions(cog=None, rank=Rank.Rank3, guild=FAKE_GUILD, user=FAKE_USER)) is True 231 | 232 | rule = WardenRule() 233 | await rule.parse(rl.CONDITION_TEST_POSITIVE, cog=None) 234 | assert bool(await rule.satisfies_conditions(cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, user=FAKE_USER)) is True 235 | 236 | rule = WardenRule() 237 | await rule.parse(rl.CONDITION_TEST_NEGATIVE, cog=None) 238 | assert bool(await rule.satisfies_conditions(cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, user=FAKE_USER)) is False 239 | 240 | rule = WardenRule() 241 | await rule.parse(rl.DISPLAY_NAME_MATCHES_ANY_OK, cog=None) 242 | assert bool(await rule.satisfies_conditions(cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, user=FAKE_USER)) is True 243 | 244 | rule = WardenRule() 245 | await rule.parse(rl.DISPLAY_NAME_MATCHES_ANY_KO, cog=None) 246 | assert bool(await rule.satisfies_conditions(cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, user=FAKE_USER)) is False 247 | 248 | positive_comparisons = ( 249 | '[1, "==", 1]', 250 | '[1, "!=", 2]', 251 | '[2, ">", 1]', 252 | '[1, "<", 2]', 253 | '[3, ">=", 3]', 254 | '[4, ">=", 3]', 255 | '[3, "<=", 3]', 256 | '[3, "<=", 5]', 257 | "[hello, contains, ll]", 258 | '[hello, contains-pattern, "H?ll*"]', # should NOT be case sensitive 259 | ) 260 | 261 | negative_comparisons = ( 262 | '[2, "==", 1]', 263 | '[1, "!=", 1]', 264 | '[2, ">", 4]', 265 | '[4, "<", 2]', 266 | '[3, ">=", 5]', 267 | '[5, "<=", 3]', 268 | "[hello, contains, xx]", 269 | '[hello, contains-pattern, "h?xx*"]', 270 | ) 271 | 272 | expected_result = (True, False) 273 | for i, comparison_list in enumerate((positive_comparisons, negative_comparisons)): 274 | for comp in comparison_list: 275 | rule = WardenRule() 276 | await rule.parse( 277 | rl.DYNAMIC_RULE.format( 278 | rank="1", event="on-user-join", conditions=f" - compare: {comp}", actions=" - no-op:" 279 | ), 280 | cog=None, 281 | ) 282 | assert ( 283 | bool(await rule.satisfies_conditions(cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, user=FAKE_USER)) 284 | is expected_result[i] 285 | ) 286 | 287 | operations = ( 288 | ('[result, 1, "+", 1]', 2), 289 | ('[result, 10, "-", 5]', 5), 290 | ('[result, 2, "*", 2]', 4), 291 | ('[result, 4, "/", 2]', 2.0), 292 | ('[result, $test_var1, "+", $test_var2]', 26), 293 | ('[result, -15, "abs"]', 15), 294 | ('[result, 4, "pow", 2]', 16.0), 295 | ('[result, 4.2, "floor"]', 4), 296 | ('[result, 4.2, "ceil"]', 5), 297 | ('[result, 26.5, "trunc"]', 26), 298 | ) 299 | 300 | test_math_rule = WardenRule() 301 | await test_math_rule.parse(rl.TEST_MATH_HEAT, cog=None) 302 | 303 | for op in operations: 304 | heat.empty_custom_heat(FAKE_GUILD, "test-passed") 305 | rule = WardenRule() 306 | await rule.parse(rl.TEST_MATH.format(operation=op[0], result=op[1]), cog=None) 307 | await rule.do_actions(cog=None, guild=FAKE_GUILD) 308 | 309 | assert bool(await test_math_rule.satisfies_conditions(guild=FAKE_GUILD, rank=Rank.Rank1, cog=None)) is True 310 | 311 | ##### Prod store 312 | rule = WardenRule() 313 | await rule.parse(rl.CHECK_HEATPOINTS, cog=None) 314 | assert ( 315 | bool(await rule.satisfies_conditions(cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE)) 316 | is False 317 | ) 318 | 319 | rule = WardenRule() 320 | await rule.parse(rl.INCREASE_HEATPOINTS, cog=None) 321 | assert ( 322 | bool(await rule.satisfies_conditions(cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE)) is True 323 | ) 324 | await rule.do_actions(cog=None, guild=FAKE_GUILD, message=FAKE_MESSAGE) 325 | 326 | rule = WardenRule() 327 | await rule.parse(rl.CHECK_HEATPOINTS, cog=None) 328 | assert ( 329 | bool(await rule.satisfies_conditions(cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE)) is True 330 | ) 331 | ############## 332 | 333 | ##### Sandbox store 334 | rule = WardenRule() 335 | await rule.parse(rl.CHECK_HEATPOINTS, cog=None) 336 | assert ( 337 | bool( 338 | await rule.satisfies_conditions( 339 | cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE, debug=True 340 | ) 341 | ) 342 | is False 343 | ) 344 | 345 | rule = WardenRule() 346 | await rule.parse(rl.INCREASE_HEATPOINTS, cog=None) 347 | assert ( 348 | bool( 349 | await rule.satisfies_conditions( 350 | cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE, debug=True 351 | ) 352 | ) 353 | is True 354 | ) 355 | await rule.do_actions(cog=None, guild=FAKE_GUILD, message=FAKE_MESSAGE, debug=True) 356 | 357 | rule = WardenRule() 358 | await rule.parse(rl.CHECK_HEATPOINTS, cog=None) 359 | assert ( 360 | bool( 361 | await rule.satisfies_conditions( 362 | cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE, debug=True 363 | ) 364 | ) 365 | is True 366 | ) 367 | ############## 368 | 369 | rule = WardenRule() 370 | await rule.parse(rl.EMPTY_HEATPOINTS, cog=None) 371 | assert ( 372 | bool( 373 | await rule.satisfies_conditions( 374 | cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE, debug=True 375 | ) 376 | ) 377 | is True 378 | ) 379 | await rule.do_actions(cog=None, guild=FAKE_GUILD, message=FAKE_MESSAGE, debug=True) 380 | 381 | rule = WardenRule() 382 | await rule.parse(rl.CHECK_EMPTY_HEATPOINTS, cog=None) 383 | assert ( 384 | bool( 385 | await rule.satisfies_conditions( 386 | cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE, debug=True 387 | ) 388 | ) 389 | is True 390 | ) 391 | 392 | rule = WardenRule() 393 | await rule.parse(rl.CHECK_EMPTY_HEATPOINTS, cog=None) 394 | assert ( 395 | bool(await rule.satisfies_conditions(cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE)) 396 | is False 397 | ) 398 | 399 | rule = WardenRule() 400 | await rule.parse(rl.EMPTY_HEATPOINTS, cog=None) 401 | assert ( 402 | bool(await rule.satisfies_conditions(cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE)) is True 403 | ) 404 | await rule.do_actions(cog=None, guild=FAKE_GUILD, message=FAKE_MESSAGE) 405 | 406 | rule = WardenRule() 407 | await rule.parse(rl.CHECK_EMPTY_HEATPOINTS, cog=None) 408 | assert ( 409 | bool(await rule.satisfies_conditions(cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE)) is True 410 | ) 411 | 412 | ## Testing .last_result passing between stacks 413 | rule = WardenRule() 414 | await rule.parse(rl.NESTED_HEATPOINTS, cog=None) 415 | assert ( 416 | bool( 417 | await rule.satisfies_conditions( 418 | cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE, debug=True 419 | ) 420 | ) 421 | is True 422 | ) 423 | await rule.do_actions(cog=None, guild=FAKE_GUILD, message=FAKE_MESSAGE, debug=True) 424 | 425 | rule = WardenRule() 426 | await rule.parse(rl.NESTED_HEATPOINTS2, cog=None) 427 | assert ( 428 | bool( 429 | await rule.satisfies_conditions( 430 | cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE, debug=True 431 | ) 432 | ) 433 | is True 434 | ) 435 | await rule.do_actions(cog=None, guild=FAKE_GUILD, message=FAKE_MESSAGE, debug=True) 436 | 437 | rule = WardenRule() 438 | await rule.parse(rl.NESTED_HEATPOINTS_CHECK, cog=None) 439 | assert ( 440 | bool( 441 | await rule.satisfies_conditions( 442 | cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE, debug=True 443 | ) 444 | ) 445 | is True 446 | ) 447 | 448 | ###################### 449 | 450 | rule = WardenRule() 451 | await rule.parse(rl.CONDITIONAL_ACTION_TEST_ASSIGN, cog=None) 452 | await rule.do_actions(cog=None, guild=FAKE_GUILD, message=FAKE_MESSAGE) 453 | 454 | rule = WardenRule() 455 | await rule.parse(rl.CONDITIONAL_ACTION_TEST_CHECK, cog=None) 456 | assert ( 457 | bool(await rule.satisfies_conditions(cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE)) is True 458 | ) 459 | 460 | 461 | @pytest.mark.asyncio 462 | async def test_conditions(): 463 | async def eval_cond(condition: Condition, params, expected_result: bool): 464 | rule = WardenRule() 465 | await rule.parse(rl.CONDITION_TEST.format(condition.value, params), cog=None) 466 | 467 | assert ( 468 | bool(await rule.satisfies_conditions(cog=None, rank=Rank.Rank1, guild=FAKE_GUILD, message=FAKE_MESSAGE)) 469 | is expected_result 470 | ) 471 | 472 | FAKE_MESSAGE.content = "aaa 2626 aaa I like cats" 473 | await eval_cond(Condition.MessageMatchesAny, ["abcd", "*hello*"], False) 474 | await eval_cond(Condition.MessageMatchesAny, ["*2626*", "hi", 12345], True) 475 | 476 | await eval_cond(Condition.MessageContainsWord, [6, "aa", "2"], False) 477 | await eval_cond(Condition.MessageContainsWord, [111, "111", "AAA"], True) 478 | await eval_cond(Condition.MessageContainsWord, ["c?ts"], True) 479 | await eval_cond(Condition.MessageContainsWord, ["c?t"], False) 480 | 481 | FAKE_MESSAGE.attachments = [] 482 | await eval_cond(Condition.MessageHasAttachment, "true", False) 483 | await eval_cond(Condition.MessageHasAttachment, "false", True) 484 | FAKE_MESSAGE.attachments = [FakeAsset()] 485 | await eval_cond(Condition.MessageHasAttachment, "true", True) 486 | await eval_cond(Condition.MessageHasAttachment, "false", False) 487 | 488 | FAKE_MESSAGE.content = "aaa 2626 aaa" 489 | await eval_cond(Condition.MessageContainsUrl, "true", False) 490 | await eval_cond(Condition.MessageContainsUrl, "false", True) 491 | FAKE_MESSAGE.content = "aaa https://discord.red aaa" 492 | await eval_cond(Condition.MessageContainsUrl, "true", True) 493 | await eval_cond(Condition.MessageContainsUrl, "false", False) 494 | 495 | FAKE_MESSAGE.content = "aaa 2626 aaa" 496 | await eval_cond(Condition.MessageContainsInvite, "true", False) 497 | await eval_cond(Condition.MessageContainsInvite, "false", True) 498 | FAKE_MESSAGE.content = "aaa https://discord.gg/red aaa" 499 | await eval_cond( 500 | Condition.MessageContainsInvite, "true", False 501 | ) # Can't be True: will always raise due to missing perms 502 | await eval_cond(Condition.MessageContainsInvite, "false", False) 503 | 504 | FAKE_MESSAGE.content = "aaa 2626 https://discord.gg/file.txt aaa" 505 | await eval_cond(Condition.MessageContainsMedia, "true", False) 506 | await eval_cond(Condition.MessageContainsMedia, "false", True) 507 | FAKE_MESSAGE.content = "aaa https://discord.gg/file.jpg aaa" 508 | await eval_cond(Condition.MessageContainsMedia, "true", True) 509 | await eval_cond(Condition.MessageContainsMedia, "false", False) 510 | 511 | FAKE_MESSAGE.raw_mentions = ["<@26262626262626>"] 512 | await eval_cond(Condition.MessageContainsMTMentions, 1, False) 513 | FAKE_MESSAGE.raw_mentions = ["<@26262626262626>", "<@26262626262626>"] 514 | await eval_cond(Condition.MessageContainsMTMentions, 1, True) 515 | 516 | FAKE_MESSAGE.mentions = ["<@26262626262626>", "<@26262626262626>"] 517 | await eval_cond(Condition.MessageContainsMTUniqueMentions, 1, False) 518 | FAKE_MESSAGE.mentions = ["<@26262626262626>", "<@123456789033221>"] 519 | await eval_cond(Condition.MessageContainsMTUniqueMentions, 1, True) 520 | 521 | FAKE_MESSAGE.role_mentions = ["<@26262626262626>"] 522 | await eval_cond(Condition.MessageContainsMTRolePings, 1, False) 523 | FAKE_MESSAGE.role_mentions = ["<@26262626262626>", "<@26262626262626>"] 524 | await eval_cond(Condition.MessageContainsMTRolePings, 1, True) 525 | 526 | FAKE_MESSAGE.clean_content = "2626" 527 | await eval_cond(Condition.MessageHasMTCharacters, 3, True) 528 | await eval_cond(Condition.MessageHasMTCharacters, 4, False) 529 | 530 | FAKE_USER.id = 262_626 531 | await eval_cond(Condition.UserIdMatchesAny, [123_456, "123424234"], False) 532 | await eval_cond(Condition.UserIdMatchesAny, [12, "262626"], True) 533 | 534 | FAKE_USER.name = "Twentysix" 535 | await eval_cond(Condition.UsernameMatchesAny, ["dsaasdasd", "Twentysix"], True) 536 | await eval_cond(Condition.UsernameMatchesAny, ["dsaasd", "dsadss"], False) 537 | 538 | FAKE_USER.nick = None 539 | await eval_cond(Condition.NicknameMatchesAny, ["dsaasdasd", "Twentysix"], False) 540 | FAKE_USER.nick = "Twentysix" 541 | await eval_cond(Condition.NicknameMatchesAny, ["dsaasdasd", "Twentysix"], True) 542 | await eval_cond(Condition.NicknameMatchesAny, ["dsaasd", "dsadss"], False) 543 | 544 | FAKE_USER.joined_at = utcnow() 545 | await eval_cond(Condition.UserJoinedLessThan, 1, True) 546 | await eval_cond(Condition.UserJoinedLessThan, "1 hour", True) 547 | FAKE_USER.joined_at = utcnow() - timedelta(hours=2) 548 | await eval_cond(Condition.UserJoinedLessThan, 1, False) 549 | await eval_cond(Condition.UserJoinedLessThan, "1 hour", False) 550 | 551 | FAKE_USER.created_at = utcnow() 552 | await eval_cond(Condition.UserCreatedLessThan, 1, True) 553 | await eval_cond(Condition.UserCreatedLessThan, "1 hour", True) 554 | FAKE_USER.created_at = utcnow() - timedelta(hours=2) 555 | await eval_cond(Condition.UserCreatedLessThan, 1, False) 556 | await eval_cond(Condition.UserCreatedLessThan, "1 hour", False) 557 | 558 | FAKE_USER.avatar.url = "discord.gg/ad/sda/s/ads.png" 559 | await eval_cond(Condition.UserHasDefaultAvatar, "true", False) 560 | await eval_cond(Condition.UserHasDefaultAvatar, "false", True) 561 | FAKE_USER.avatar.url = "discord.gg/asddasad/embed/avatars/2.png" 562 | await eval_cond(Condition.UserHasDefaultAvatar, "true", True) 563 | await eval_cond(Condition.UserHasDefaultAvatar, "false", False) 564 | 565 | FAKE_CHANNEL.id = 262_626 566 | FAKE_CHANNEL.name = "my-ch" 567 | FAKE_GUILD.text_channels[FAKE_CHANNEL] = FAKE_CHANNEL 568 | await eval_cond(Condition.ChannelMatchesAny, [12345, "asdas"], False) 569 | await eval_cond(Condition.ChannelMatchesAny, [12345, "262626"], True) 570 | await eval_cond(Condition.ChannelMatchesAny, ["my-ch", "1111111"], True) 571 | 572 | role1 = FakeRole(12345, "my_role") 573 | role2 = FakeRole(67890, "my_role2") 574 | FAKE_GUILD.roles[role1] = role1 575 | FAKE_GUILD.roles[role2] = role2 576 | await eval_cond(Condition.UserHasAnyRoleIn, [12345, "1111111"], False) 577 | await eval_cond(Condition.UserHasAnyRoleIn, ["dassdads", "my_role"], False) 578 | FAKE_USER.roles[role1] = role1 579 | await eval_cond(Condition.UserHasAnyRoleIn, [12345, "1111111"], True) 580 | await eval_cond(Condition.UserHasAnyRoleIn, ["dassdads", "my_role"], True) 581 | 582 | await eval_cond(Condition.UserActivityMatchesAny, ["xx", "*spam*"], True) 583 | await eval_cond(Condition.UserActivityMatchesAny, ["xx", "*bla*"], False) 584 | 585 | # Missing tests for category, public channels, regex related and emojis 586 | 587 | # This tests the "_single_value" changes. The condition should only accept a single value, 588 | # not a list to unpack and not a dict 589 | with pytest.raises(InvalidRule, match=r".*Input should be a valid boolean*"): 590 | await eval_cond(Condition.MessageHasAttachment, ["true"], True) 591 | 592 | with pytest.raises(InvalidRule, match=r".*Input should be a valid boolean*"): 593 | await eval_cond(Condition.MessageHasAttachment, {"value": True}, True) 594 | 595 | 596 | @pytest.mark.asyncio 597 | async def test_warden_checks(): 598 | wd_check = WardenCheck() 599 | 600 | await wd_check.parse(rl.TEST_CHECK_MESSAGE, cog=None, author=None, module=ChecksKeys.CommentAnalysis) 601 | FAKE_MESSAGE.content = "123" 602 | assert ( 603 | bool( 604 | await wd_check.satisfies_conditions( 605 | rank=Rank.Rank4, cog=None, guild=FAKE_GUILD, user=FAKE_USER, message=FAKE_MESSAGE 606 | ) 607 | ) 608 | is True 609 | ) 610 | 611 | with pytest.raises(InvalidRule, match=r".*is not allowed in the checks for this module*"): 612 | await wd_check.parse(rl.TEST_CHECK_MESSAGE, cog=None, author=None, module=ChecksKeys.JoinMonitor) 613 | 614 | with pytest.raises(InvalidRule, match=r".*Only conditions are allowed to be used in Warden checks*"): 615 | await wd_check.parse(rl.TEST_CHECK_ACTIONS, cog=None, author=None, module=ChecksKeys.CommentAnalysis) 616 | 617 | with pytest.raises(InvalidRule, match=r".*checks should be a list of conditions*"): 618 | await wd_check.parse(rl.TEST_MATH, cog=None, author=None, module=ChecksKeys.CommentAnalysis) 619 | -------------------------------------------------------------------------------- /defender/tests/wd_sample_rules.py: -------------------------------------------------------------------------------- 1 | TUTORIAL_SIMPLE_RULE = """ 2 | name: spiders-are-spooky 3 | rank: 1 4 | event: on-message 5 | if: 6 | - message-matches-any: ["*spider*"] 7 | do: 8 | - delete-user-message: 9 | """ 10 | 11 | TUTORIAL_COMPLEX_RULE = """ 12 | name: spiders-are-spooky 13 | rank: 1 14 | event: on-message 15 | if: 16 | - if-any: 17 | - username-matches-any: ["*spider*"] 18 | - message-matches-any: ["*spider*"] 19 | - nickname-matches-any: ["*spider*"] 20 | - if-not: 21 | - user-joined-less-than: 2 22 | - is-staff: true 23 | do: 24 | - ban-user-and-delete: 1 25 | - send-mod-log: "Usage of the S word is not welcome in this community. Begone, $user." 26 | """ 27 | 28 | TUTORIAL_PRIORITY_RULE = """ 29 | name: always-first 30 | rank: 1 31 | priority: 1 32 | event: on-message 33 | if: 34 | - message-matches-any: ["*"] 35 | do: 36 | - send-to-monitor: "I'm 1st!" 37 | """ 38 | 39 | INVALID_PRIORITY = """ 40 | name: always-first 41 | rank: 1 42 | priority: first 43 | event: on-message 44 | if: 45 | - message-matches-any: ["*"] 46 | do: 47 | - send-to-monitor: "I'm 1st!" 48 | """ 49 | 50 | INVALID_RANK = """ 51 | name: test 52 | rank: 8 53 | event: on-message 54 | if: 55 | - messages-matches-any: ["*"] 56 | do: 57 | - no-op: 58 | """ 59 | 60 | INVALID_EVENT = """ 61 | name: test 62 | rank: 4 63 | event: xxxx 64 | if: 65 | - messages-matches-any: ["*"] 66 | do: 67 | - no-op: 68 | """ 69 | 70 | INVALID_PERIODIC_MISSING_RUN_EVERY = """ 71 | name: per 72 | rank: 2 73 | event: periodic 74 | if: 75 | - username-matches-any: ["abcd"] 76 | do: 77 | - no-op: 78 | """ 79 | 80 | INVALID_PERIODIC_MISSING_EVENT = """ 81 | name: per 82 | rank: 2 83 | run-every: 2 hours 84 | event: on-user-join 85 | if: 86 | - username-matches-any: ["abcd"] 87 | do: 88 | - no-op: 89 | """ 90 | 91 | INVALID_ACTION_IN_CONDITION_SECTION = """ 92 | name: cond-section 93 | rank: 1 94 | event: on-user-join 95 | if: 96 | - send-to-monitor: "hi" 97 | do: 98 | - no-op: 99 | """ 100 | 101 | INVALID_COND_ACTION_BLOCK_IN_CONDITION_SECTION = """ 102 | name: cond-section2 103 | rank: 1 104 | event: on-user-join 105 | if: 106 | - compare: [1, ==, 1] 107 | - if-true: 108 | - compare: [1, ==, 1] 109 | do: 110 | - no-op: 111 | """ 112 | 113 | INVALID_NESTING_ACTION_IN_COND_BLOCK = """ 114 | name: nesting 115 | rank: 1 116 | event: on-user-join 117 | if: 118 | - compare: [1, ==, 1] 119 | do: 120 | - if-all: 121 | - compare: [1, ==, 1] 122 | - if-any: 123 | - send-message: [hi, hi] 124 | - if-not: 125 | - compare: [1, ==, 1] 126 | - if-not: 127 | - compare: [1, ==, 1] 128 | """ 129 | 130 | INVALID_NESTING_COND_ACTION_BLOCK_IN_COND_BLOCK = """ 131 | name: nesting2 132 | rank: 1 133 | event: on-user-join 134 | if: 135 | - compare: [1, ==, 1] 136 | do: 137 | - if-all: 138 | - compare: [1, ==, 1] 139 | - if-true: 140 | - compare: [1, ==, 1] 141 | - if-not: 142 | - compare: [1, ==, 1] 143 | - if-not: 144 | - compare: [1, ==, 1] 145 | """ 146 | 147 | INVALID_TOO_MANY_ARGS = """ 148 | name: nesting2 149 | rank: 1 150 | event: on-user-join 151 | if: 152 | - compare: [1, ==, 1, 1] 153 | do: 154 | - no-op: 155 | 156 | """ 157 | 158 | CHECK_RANK_SAFEGUARD = """ 159 | name: rank-check 160 | rank: 3 161 | event: on-message 162 | if: 163 | - compare: [1, ==, 1] 164 | do: 165 | - no-op: 166 | """ 167 | 168 | NESTED_COMPLEX_RULE = """ 169 | name: nesting2 170 | rank: 1 171 | event: on-user-join 172 | if: 173 | - compare: [1, ==, 1] 174 | - if-all: 175 | - if-not: 176 | - compare: [1, ==, 1] 177 | - if-not: 178 | - compare: [1, ==, 1] 179 | - if-all: 180 | - compare: [1, ==, 1] 181 | - compare: [1, ==, 1] 182 | - compare: [1, ==, 1] 183 | do: 184 | - no-op: 185 | - compare: [1, ==, 1] 186 | - if-any: 187 | - compare: [1, ==, 1] 188 | - compare: [1, ==, 1] 189 | - if-true: 190 | - compare: [1, ==, 2] 191 | - if-true: 192 | - send-message: [hello, there] 193 | - no-op: 194 | - send-to-monitor: "." 195 | - if-false: 196 | - no-op: 197 | """ 198 | 199 | OOB_USER_HEATPOINTS = """ 200 | name: heat 201 | rank: 2 202 | event: on-user-join 203 | if: 204 | - username-matches-any: ["abcd"] 205 | do: 206 | - add-user-heatpoints: [500, 4 hours] 207 | """ 208 | 209 | OOB_USER_HEATPOINTS2 = """ 210 | name: heat 211 | rank: 2 212 | event: on-user-join 213 | if: 214 | - username-matches-any: ["abcd"] 215 | do: 216 | - add-user-heatpoints: [5, 400 hours] 217 | """ 218 | 219 | OOB_CUSTOM_HEATPOINTS = """ 220 | name: heat 221 | rank: 2 222 | event: on-user-join 223 | if: 224 | - username-matches-any: ["abcd"] 225 | do: 226 | - add-custom-heatpoints: ["boop", 500, 4 hours] 227 | """ 228 | 229 | OOB_CUSTOM_HEATPOINTS2 = """ 230 | name: heat 231 | rank: 2 232 | event: on-user-join 233 | if: 234 | - username-matches-any: ["abcd"] 235 | do: 236 | - add-custom-heatpoints: ["boop", 500, 4 hours] 237 | """ 238 | 239 | OOB_CUSTOM_HEATPOINTS2 = """ 240 | name: heat 241 | rank: 2 242 | event: on-user-join 243 | if: 244 | - username-matches-any: ["abcd"] 245 | do: 246 | - add-custom-heatpoints: ["boop", 5, 400 hours] 247 | """ 248 | 249 | RESERVED_KEY_CUSTOM_HEATPOINTS = """ 250 | name: heat 251 | rank: 2 252 | event: on-user-join 253 | if: 254 | - username-matches-any: ["abcd"] 255 | do: 256 | - add-custom-heatpoints: ["core-boop", 5, 4 hours] 257 | """ 258 | 259 | INVALID_VAR_NAME = """ 260 | name: var 261 | rank: 2 262 | event: on-user-join 263 | if: 264 | - username-matches-any: ["abcd"] 265 | do: 266 | - var-assign: ["aa-aa", 123] 267 | """ 268 | 269 | INVALID_RANK = """ 270 | name: var 271 | rank: 2 272 | event: on-user-join 273 | if: 274 | - user-is-rank: 10 275 | do: 276 | - no-op: 277 | """ 278 | 279 | OOB_DELETE_AFTER = """ 280 | name: var 281 | rank: 2 282 | event: on-user-join 283 | if: 284 | - username-matches-any: ["abcd"] 285 | do: 286 | - delete-last-message-sent-after: 5 days 287 | """ 288 | 289 | VALID_MIXED_RULE = """ 290 | name: spiders-are-spooky 291 | rank: 1 292 | event: [on-message, on-user-join] 293 | if: 294 | - username-matches-any: ["*spider*"] 295 | do: 296 | - set-user-nickname: bunny 297 | """ 298 | 299 | INVALID_MIXED_RULE_CONDITION = """ 300 | name: spiders-are-spooky 301 | rank: 1 302 | event: [on-message, on-user-join] 303 | if: 304 | - message-matches-any: ["*spider*"] 305 | do: 306 | - set-user-nickname: bunny 307 | """ 308 | 309 | INVALID_MIXED_RULE_ACTION = """ 310 | name: spiders-are-spooky 311 | rank: 1 312 | event: [on-message, on-user-join] 313 | if: 314 | - username-matches-any: ["*spider*"] 315 | do: 316 | - delete-user-message: 317 | """ 318 | 319 | DYNAMIC_RULE = """ 320 | name: test 321 | rank: {rank} 322 | event: {event} 323 | if: 324 | {conditions} 325 | do: 326 | {actions} 327 | """ 328 | 329 | DYNAMIC_RULE_PERIODIC = """ 330 | name: test 331 | rank: 3 332 | run-every: 15 minutes 333 | event: {event} 334 | if: 335 | {conditions} 336 | do: 337 | {actions} 338 | """ 339 | 340 | CONDITION_TEST_POSITIVE = """ 341 | name: positive 342 | rank: 1 343 | event: on-user-join 344 | if: 345 | - if-any: 346 | - username-matches-any: ["Twentysix"] 347 | - nickname-matches-any: ["*spider*"] 348 | - if-not: 349 | - username-matches-any: ["xxxxxxxxx"] 350 | do: 351 | - no-op: 352 | """ 353 | 354 | CONDITION_TEST_NEGATIVE = """ 355 | name: negative 356 | rank: 1 357 | event: on-user-join 358 | if: 359 | - if-all: 360 | - username-matches-any: ["Twentysix"] 361 | - if-not: 362 | - user-id-matches-any: [852499907842801726] 363 | do: 364 | - no-op: 365 | """ 366 | 367 | DISPLAY_NAME_MATCHES_ANY_OK = """ 368 | name: positive 369 | rank: 1 370 | event: on-user-join 371 | if: 372 | - display-name-matches-any: ["Twentysix"] 373 | do: 374 | - no-op: 375 | """ 376 | 377 | DISPLAY_NAME_MATCHES_ANY_KO = """ 378 | name: negative 379 | rank: 1 380 | event: on-user-join 381 | if: 382 | - display-name-matches-any: ["26"] 383 | do: 384 | - no-op: 385 | """ 386 | 387 | INCREASE_HEATPOINTS = """ 388 | name: increase 389 | rank: 1 390 | event: on-message 391 | if: 392 | - if-any: 393 | - message-matches-any: ["increase"] 394 | do: 395 | - add-user-heatpoint: 9m 396 | - add-user-heatpoints: [1, 9m] 397 | - add-channel-heatpoint: 9m 398 | - add-channel-heatpoints: [5, 4m] 399 | - add-custom-heatpoint: ["test-852499907842801728", 50m] 400 | - add-custom-heatpoints: ["test-$channel_id", 12, 50m] 401 | """ 402 | 403 | CHECK_HEATPOINTS = """ 404 | name: check 405 | rank: 1 406 | event: on-message 407 | if: 408 | - user-heat-is: 2 409 | - channel-heat-is: 6 410 | - user-heat-more-than: 0 411 | - channel-heat-more-than: 0 412 | - custom-heat-is: ["test-852499907842801728", 13] 413 | - custom-heat-more-than: ["test-$channel_id", 5] 414 | do: 415 | - no-op: 416 | """ 417 | 418 | EMPTY_HEATPOINTS = """ 419 | name: empty 420 | rank: 1 421 | event: on-message 422 | if: 423 | - message-matches-any: ["*"] 424 | do: 425 | - empty-user-heat: 426 | - empty-channel-heat: 427 | - empty-custom-heat: "test" 428 | """ 429 | 430 | CHECK_EMPTY_HEATPOINTS = """ 431 | name: check-empty 432 | rank: 1 433 | event: on-message 434 | if: 435 | - user-heat-is: 0 436 | - channel-heat-is: 0 437 | - custom-heat-is: ["test", 0] 438 | do: 439 | - no-op: 440 | """ 441 | 442 | NESTED_HEATPOINTS = """ 443 | name: nested-heat 444 | rank: 1 445 | event: on-user-join 446 | if: 447 | - compare: [1, ==, 1] 448 | do: 449 | - no-op: 450 | - compare: [1, ==, 1] 451 | - if-any: 452 | - compare: [1, ==, 1] 453 | - compare: [1, ==, 1] 454 | - if-true: 455 | - add-custom-heatpoint: ["this-should-be-two", 1 minute] 456 | - compare: [1, ==, 2] 457 | - if-false: # This should NOT happen! 458 | - add-custom-heatpoint: ["this-should-be-two", 1 minute] 459 | """ 460 | 461 | NESTED_HEATPOINTS2 = """ 462 | name: nested-heat2 463 | rank: 1 464 | event: on-user-join 465 | if: 466 | - compare: [1, ==, 1] 467 | do: 468 | - no-op: 469 | - compare: [1, ==, 1] 470 | - if-any: 471 | - compare: [1, ==, 2] 472 | - compare: [1, ==, 2] 473 | - if-false: 474 | - add-custom-heatpoint: ["this-should-be-two", 1 minute] 475 | - compare: [1, ==, 1] 476 | - if-true: # This should NOT happen! 477 | - add-custom-heatpoint: ["this-should-be-two", 1 minute] 478 | - compare: [1, ==, 2] 479 | """ 480 | 481 | NESTED_HEATPOINTS_CHECK = """ 482 | name: nested-heat-check 483 | rank: 1 484 | event: on-user-join 485 | if: 486 | - custom-heat-is: ["this-should-be-two", 2] 487 | do: 488 | - no-op: 489 | """ 490 | 491 | CONDITION_TEST = """ 492 | name: condition-test 493 | rank: 1 494 | event: on-message 495 | if: 496 | - {}: {} 497 | do: 498 | - no-op: 499 | """ 500 | 501 | CONDITIONAL_ACTION_TEST_ASSIGN = """ 502 | name: condition-test 503 | rank: 1 504 | event: on-message 505 | if: 506 | - message-matches-any: ["*"] 507 | do: 508 | - if-false: # This should not happen: nothing has been evaluated yet 509 | - add-custom-heatpoint: ["thisshouldbezero-1", 1m] 510 | 511 | - if-true: # This should not happen: nothing has been evaluated yet 512 | - add-custom-heatpoint: ["thisshouldbezero-1", 1m] 513 | 514 | - add-custom-heatpoint: ["thisshouldbetwo", 1m] 515 | - custom-heat-is: ["thisshouldbetwo", 1] 516 | - if-true: 517 | - add-custom-heatpoint: ["thisshouldbetwo", 1m] 518 | - if-false: 519 | - add-custom-heatpoint: ["thisshouldbezero", 1m] 520 | 521 | - add-custom-heatpoint: ["thisshouldbeone", 1m] 522 | 523 | - compare: [1, "!=", 1] 524 | - if-false: 525 | - add-custom-heatpoint: ["compare-ok", 1m] 526 | 527 | - var-assign: [one, 1] 528 | - compare: [$one, "==", 1] 529 | - if-true: 530 | - add-custom-heatpoint: ["compare-ok2", 1m] 531 | 532 | - exit: # This should interrupt the rule 533 | - add-custom-heatpoint: ["thisshouldbezero-1", 1m] 534 | """ 535 | 536 | CONDITIONAL_ACTION_TEST_CHECK = """ 537 | name: condition-test-check 538 | rank: 1 539 | event: on-message 540 | if: 541 | - custom-heat-is: ["thisshouldbetwo", 2] 542 | - custom-heat-is: ["thisshouldbeone", 1] 543 | - custom-heat-is: ["thisshouldbezero", 0] 544 | - custom-heat-is: ["thisshouldbezero-1", 0] 545 | - custom-heat-is: ["compare-ok", 1] 546 | - custom-heat-is: ["compare-ok2", 1] 547 | do: 548 | - no-op: 549 | """ 550 | 551 | TEST_MATH = """ 552 | name: math-test1 553 | rank: 1 554 | event: on-emergency 555 | if: 556 | - compare: [1, ==, 1] 557 | do: 558 | - var-assign: [test_var1, 20] 559 | - var-assign: [test_var2, 6] 560 | - var-math: {operation} 561 | - compare: [$result, ==, {result}] 562 | - if-true: 563 | - add-custom-heatpoint: ["test-passed", 1m] 564 | """ 565 | 566 | TEST_MATH_HEAT = """ 567 | name: math-test2 568 | rank: 1 569 | event: on-emergency 570 | if: 571 | - custom-heat-is: ["test-passed", 1] 572 | do: 573 | - no-op: 574 | """ 575 | 576 | TEST_CHECK_ACTIONS = """ 577 | - if-any: 578 | - username-matches-any: [123] 579 | - message-matches-any: [123] 580 | - send-message: [123, "abc"] 581 | """ 582 | 583 | TEST_CHECK_MESSAGE = """ 584 | - if-any: 585 | - username-matches-any: [123] 586 | - message-matches-any: [123] 587 | """ 588 | -------------------------------------------------------------------------------- /index/NOTICE: -------------------------------------------------------------------------------- 1 | =========================================================================== 2 | == NOTICE file corresponding to the section 4(d) of the Apache License, == 3 | == Version 2.0, in this case for the JackCogs code. == 4 | =========================================================================== 5 | 6 | JackCogs 7 | Copyright 2018-2020 Jakub Kuczys (https://github.com/jack1142) 8 | 9 | This product includes software developed by Jakub Kuczys (https://github.com/jack1142). 10 | -------------------------------------------------------------------------------- /index/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Index - Browse and install Red repos and cogs using the Red-Index system 3 | Copyright (C) 2020 Twentysix (https://github.com/Twentysix26/) 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import json 20 | from pathlib import Path 21 | 22 | from redbot.core.bot import Red 23 | 24 | from .index import Index 25 | 26 | with open(Path(__file__).parent / "info.json") as fp: 27 | __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] 28 | 29 | 30 | async def setup(bot: Red) -> None: 31 | await bot.add_cog(Index(bot)) 32 | -------------------------------------------------------------------------------- /index/exceptions.py: -------------------------------------------------------------------------------- 1 | class IndexException(Exception): 2 | pass 3 | 4 | 5 | class NoCogs(IndexException): 6 | pass 7 | -------------------------------------------------------------------------------- /index/index.py: -------------------------------------------------------------------------------- 1 | """ 2 | Index - Browse and install Red repos and cogs using the Red-Index system 3 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | from typing import Any, Dict 20 | 21 | import aiohttp 22 | import logging 23 | from copy import copy 24 | from datetime import datetime 25 | from redbot.core import commands 26 | from redbot.core.bot import Red 27 | from redbot.core.config import Config 28 | 29 | from .parser import Cog, Repo 30 | from .views import IndexCogsView, IndexReposView 31 | from .exceptions import NoCogs 32 | 33 | IX_PROTOCOL = 1 34 | CC_INDEX_LINK = f"https://raw.githubusercontent.com/Cog-Creators/Red-Index/master/index/{IX_PROTOCOL}-min.json" 35 | RED_INDEX_REPO = "https://github.com/Cog-Creators/Red-Index/" 36 | 37 | log = logging.getLogger("red.x26cogs.index") 38 | 39 | 40 | class Index(commands.Cog): 41 | """Browse and install repos / cogs from a Red-Index""" 42 | 43 | def __init__(self, bot: Red): 44 | self.bot = bot 45 | self.config = Config.get_conf(self, identifier=262_626, force_registration=True) 46 | self.config.register_global( 47 | red_index_link=CC_INDEX_LINK, 48 | red_index_max_age=10, 49 | red_index_cache={}, 50 | red_index_show_unapproved=False, # minutes 51 | ) 52 | self.session = aiohttp.ClientSession() 53 | self.cache = [] 54 | self.last_fetched = None 55 | 56 | async def cog_unload(self): 57 | await self.session.close() 58 | 59 | async def red_get_data_for_user(self, *, user_id: int) -> Dict[str, Any]: 60 | return {} 61 | 62 | async def red_delete_data_for_user(self, *, requester, user_id: int): 63 | pass 64 | 65 | @commands.group(name="index") 66 | async def indexgroup(self, ctx: commands.Context): 67 | """Red-Index cog discoverability commands""" 68 | 69 | @indexgroup.command(name="browse") 70 | @commands.bot_has_permissions(embed_links=True, add_reactions=True) 71 | async def index_browse(self, ctx: commands.Context, repo_name=""): 72 | """Browses repos / cogs""" 73 | try: 74 | await self.fetch_index() 75 | except Exception as e: 76 | await ctx.send( 77 | "Something went wrong. Index service may be not " 78 | "available or a not working link may have been set.\n" 79 | f"Error: {e}" 80 | ) 81 | return 82 | if not repo_name: 83 | cache = self.cache.copy() 84 | await IndexReposView(ctx, repos=cache).show_repos() 85 | else: 86 | for r in self.cache: 87 | if not r.name.lower() == repo_name.lower(): 88 | continue 89 | try: 90 | await IndexCogsView(ctx, repo=r).show_cogs() 91 | except NoCogs: 92 | await ctx.send("This repository is empty: no cogs to show.") 93 | break 94 | else: 95 | await ctx.send("I could not find any repo with that name.") 96 | 97 | def get_all_cogs(self): 98 | cogs = [] 99 | for r in self.cache: 100 | cogs.extend(r.cogs.values()) 101 | return cogs 102 | 103 | @indexgroup.command(name="search") 104 | @commands.bot_has_permissions(embed_links=True, add_reactions=True) 105 | async def index_search(self, ctx: commands.Context, *, search_term: str): 106 | """Search for cogs""" 107 | try: 108 | await self.fetch_index() 109 | except Exception as e: 110 | await ctx.send( 111 | "Something went wrong. Index service may be not " 112 | "available or a not working link may have been set.\n" 113 | f"Error: {e}" 114 | ) 115 | return 116 | cogs_cache = self.get_all_cogs() 117 | results = [] 118 | search_term = search_term.lower() 119 | # First search by name 120 | for c in cogs_cache: 121 | if search_term in c.name.lower(): 122 | results.append(c) 123 | # Then search by tags 124 | for c in cogs_cache: 125 | for tag in c.tags: 126 | if search_term in tag.lower(): 127 | if c not in results: 128 | results.append(c) 129 | # If still nothing comes up search by description 130 | if not results: 131 | for c in cogs_cache: 132 | if search_term in c.description.lower(): 133 | results.append(c) 134 | # Maybe the user is looking for a particular repo...? 135 | if not results: 136 | for c in cogs_cache: 137 | if search_term in c.repo.name.lower(): 138 | results.append(c) 139 | # Ok maybe... authors? 140 | if not results: 141 | for c in cogs_cache: 142 | if search_term in " ".join(c.author).lower(): 143 | results.append(c) 144 | 145 | if results: 146 | await IndexCogsView(ctx, cogs=results).show_cogs() 147 | else: 148 | # Well, fuck it then 149 | await ctx.send("I could not find anything with those search terms.") 150 | 151 | @commands.is_owner() 152 | @commands.group() 153 | async def indexset(self, ctx: commands.Context): 154 | """Red-Index configuration""" 155 | 156 | @indexset.command(name="refresh") 157 | async def indexset_refresh(self, ctx: commands.Context): 158 | """Manually refresh the Red-Index cache.""" 159 | async with ctx.typing(): 160 | try: 161 | await self.fetch_index(force=True) 162 | except Exception as e: 163 | await ctx.send( 164 | "Something went wrong. Index service may be not " 165 | "available or a not working link may have been set.\n" 166 | f"Error: {e}" 167 | ) 168 | else: 169 | await ctx.send("Index refreshed successfully.") 170 | 171 | @indexset.command(name="maxminutes") 172 | async def indexset_maxminutes(self, ctx: commands.Context, minutes: int): 173 | """Minutes elapsed before the cache is considered stale 174 | 175 | Set 0 if you want the cache refresh to be manual only""" 176 | if minutes < 0: 177 | await ctx.send("Invalid minutes value.") 178 | return 179 | await self.config.red_index_max_age.set(minutes) 180 | if minutes: 181 | await ctx.send(f"After {minutes} minutes the cache will be automatically " "refreshed when used.") 182 | else: 183 | await ctx.send("Cache auto-refresh disabled. Do " f"{ctx.prefix}index refresh to refresh it.") 184 | 185 | @indexset.command(name="link") 186 | async def indexset_link(self, ctx: commands.Context, link: str = ""): 187 | """Set a custom Red-Index link""" 188 | if not link: 189 | await ctx.send( 190 | "With this command you can set a custom Red-Index link. " 191 | "This gives you the freedom to run your own Red-Index: just fork the repo " 192 | f"and it's ready to go!\n<{RED_INDEX_REPO}>\nTo keep using our curated " 193 | f"index do `{ctx.prefix}indexset link default`" 194 | ) 195 | return 196 | if link.lower() == "default": 197 | await self.config.red_index_link.clear() 198 | await ctx.send(f"Link has been set to the default one:\n<{CC_INDEX_LINK}>") 199 | await self.fetch_index(force=True) 200 | else: 201 | await self.config.red_index_link.set(link) 202 | try: 203 | await self.fetch_index(force=True) 204 | except Exception as e: 205 | log.error("Error fetching the index file", exc_info=e) 206 | await ctx.send( 207 | "Something went wrong while trying to reach the new link you have set. " 208 | "I'll revert to the default one.\nA custom Red-Index link format must be " 209 | f"similar to this: <{CC_INDEX_LINK}>.\nIt has to be static and point to a " 210 | "valid json source." 211 | ) 212 | await self.config.red_index_link.clear() 213 | await self.fetch_index(force=True) 214 | else: 215 | await ctx.send( 216 | "New link successfully set. Remember that you can go back " 217 | f"to the standard link with `{ctx.prefix}indexset link default.`" 218 | ) 219 | 220 | @indexset.command(name="showunapproved") 221 | async def indexset_showunapproved(self, ctx: commands.Context, yes_or_no: bool): 222 | """Toggle unapproved cogs display""" 223 | await self.config.red_index_show_unapproved.set(yes_or_no) 224 | try: 225 | await self.fetch_index(force=True) 226 | except Exception as e: 227 | await ctx.send( 228 | "Something went wrong. Index service may be not " 229 | "available or a not working link may have been set.\n" 230 | f"Error: {e}" 231 | ) 232 | return 233 | if yes_or_no: 234 | await ctx.send( 235 | "Done. Remember that unapproved cogs haven't been vetted " 236 | "by anyone. Make sure you trust what you install!" 237 | ) 238 | else: 239 | await ctx.send("Done. I won't show any unapproved cog.") 240 | 241 | async def fetch_index(self, force=False): 242 | if force or await self.is_cache_stale(): 243 | link = await self.config.red_index_link() 244 | async with self.session.get(link) as data: 245 | if data.status != 200: 246 | raise RuntimeError(f"Could not fetch index. HTTP code: {data.status}") 247 | raw = await data.json(content_type=None) 248 | 249 | show_unapproved = await self.config.red_index_show_unapproved() 250 | cache = [] 251 | 252 | for k, v in raw.items(): 253 | cache.append(Repo(k, v)) 254 | 255 | if not show_unapproved: 256 | cache = [r for r in cache if r.rx_category != "unapproved"] 257 | 258 | self.cache = cache 259 | self.last_fetched = datetime.utcnow() 260 | 261 | async def is_cache_stale(self): 262 | max_age = await self.config.red_index_max_age() 263 | if not max_age: # 0 = no auto-refresh 264 | return False 265 | elif not self.last_fetched: # no fetch yet 266 | return True 267 | 268 | minutes_since = (datetime.utcnow() - self.last_fetched).seconds / 60 269 | return minutes_since > max_age 270 | 271 | async def install_repo_cog(self, ctx, repo: Repo, cog: Cog = None): 272 | """ 273 | Following Jackenmen's Cogboard logic made my life easier here. Thanks Jack! 274 | https://github.com/jack1142/JackCogs/blob/91f39e1f4cb97491a70103cce90f0aa99fa2efc5/cogboard/menus.py#L30 275 | """ 276 | 277 | async def get_fake_context(ctx, command): 278 | fake_message = copy(ctx.message) 279 | fake_message.content = f"{ctx.prefix}{command.qualified_name}" 280 | return await ctx.bot.get_context(fake_message) 281 | 282 | def get_repo_by_url(url): 283 | for r in downloader._repo_manager.repos: 284 | if url == r.clean_url: 285 | return r 286 | 287 | def get_clean_url(url): 288 | if "@" in url: 289 | url, branch = url.split("@") 290 | return url, None 291 | 292 | downloader = self.bot.get_cog("Downloader") 293 | if downloader is None: 294 | raise RuntimeError("Downloader is not loaded.") 295 | 296 | url, branch = get_clean_url(repo.url) 297 | downloader_repo = get_repo_by_url(url) 298 | 299 | if not downloader_repo: 300 | command = downloader._repo_add 301 | fake_context = await get_fake_context(ctx, command) 302 | 303 | branch = repo.rx_branch if repo.rx_branch else None 304 | await command(fake_context, repo.name.lower(), url, branch) 305 | downloader_repo = get_repo_by_url(url) 306 | if not downloader_repo: 307 | raise RuntimeError("I could not find the repo after adding it through Downloader.") 308 | 309 | if cog: 310 | if downloader_repo is None: 311 | raise RuntimeError("No valid downloader repo.") 312 | await downloader._cog_install(ctx, downloader_repo, cog.name) 313 | -------------------------------------------------------------------------------- /index/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Twentysix (Twentysix#5252)", 4 | "jack1142 (Jackenmen#6607)" 5 | ], 6 | "description": "Browse, search and install repos / cogs from a Red-Index", 7 | "install_msg": "Thanks for installing Index. This cog allows you to browse a Red-Index repo/cog index, helping you easily discover new repos and cogs.\nThe default Red-Index link points to the official one, and while we try to vet all its content remember that this is all content made by 3rd parties, you are ultimately responsible for what you install: **don't trust the source? Don't install it**. Have fun!\n", 8 | "short": "Easy cog discoverability with Red-Index", 9 | "tags": [ 10 | "index", 11 | "discoverability", 12 | "cogs", 13 | "finder" 14 | ], 15 | "min_bot_version": "3.5.0.dev317", 16 | "type": "COG", 17 | "end_user_data_statement": "This cog does not store end user data." 18 | } -------------------------------------------------------------------------------- /index/parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | Index - Browse and install Red repos and cogs using the Red-Index system 3 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import discord 20 | import logging 21 | 22 | FLOPPY_DISK = "\N{FLOPPY DISK}" 23 | ARROW_UP = "\N{UPWARDS BLACK ARROW}\N{VARIATION SELECTOR-16}" 24 | ARROW_DOWN = "\N{DOWNWARDS BLACK ARROW}\N{VARIATION SELECTOR-16}" 25 | 26 | log = logging.getLogger("red.x26cogs.index") 27 | 28 | 29 | class Repo: 30 | def __init__(self, url: str, raw_data: dict): 31 | self.url = url 32 | self.rx_category = raw_data.get("rx_category", "unapproved") 33 | self.rx_cogs = raw_data.get("rx_cogs", []) 34 | self.author = raw_data.get("author", ["Unknown"]) 35 | self.description = raw_data.get("description", "") 36 | self.short = raw_data.get("short", "") 37 | self.name = raw_data.get("name", "Unknown") 38 | self.rx_branch = raw_data.get("rx_branch", "") 39 | self.cogs = {} 40 | for cog_name, cog_raw in raw_data["rx_cogs"].items(): 41 | if cog_raw.get("hidden", False) or cog_raw.get("disabled", False): 42 | continue 43 | self.cogs[cog_name] = Cog(cog_name, self, cog_raw) 44 | 45 | def build_embed(self, *, prefix="[p]", is_owner=False): 46 | em = discord.Embed(url=self.url, description=self.description, colour=discord.Colour.red()) 47 | em.set_author(name=f"{self.name} by {', '.join(self.author)}") 48 | em.add_field(name="Type", value=self.rx_category, inline=True) 49 | if self.rx_branch: 50 | em.add_field(name="Branch", value=self.rx_branch, inline=True) 51 | url, _ = self.url.split("@", 1) 52 | else: 53 | url = self.url 54 | em.add_field( 55 | name="Command to add repo", 56 | value=f"{prefix}repo add {self.name.lower()} {url} {self.rx_branch}", 57 | inline=False, 58 | ) 59 | return em 60 | 61 | 62 | class Cog: 63 | def __init__(self, name: str, repo: Repo, raw_data: dict): 64 | self.name = name 65 | self.author = raw_data.get("author", ["Unknown"]) 66 | self.description = raw_data.get("description", "") 67 | self.end_user_data_statement = raw_data.get("end_user_data_statement", "") 68 | self.permissions = raw_data.get("permissions", []) 69 | self.short = raw_data.get("short", "") 70 | self.min_bot_version = raw_data.get("min_bot_version", "") 71 | self.max_bot_version = raw_data.get("max_bot_version", "") 72 | self.min_python_version = raw_data.get("min_python_version", "") 73 | self.hidden = False 74 | self.disabled = False 75 | self.required_cogs = raw_data.get("required_cogs", {}) 76 | self.requirements = raw_data.get("requirements", []) 77 | self.tags = raw_data.get("tags", []) 78 | self.type = raw_data.get("type", "") 79 | self.repo = repo 80 | 81 | def build_embed(self, *, prefix="[p]", is_owner=False): 82 | url = f"{self.repo.url}/{self.name}" 83 | 84 | if self.description: 85 | description = self.description 86 | else: 87 | description = self.short 88 | if self.author: 89 | author = ", ".join(self.author) 90 | else: 91 | author = self.repo.name 92 | em = discord.Embed(url=url, description=description, colour=discord.Colour.red()) 93 | em.set_author(name=f"{self.name} from {self.repo.name}") 94 | em.add_field(name="Type", value=self.repo.rx_category, inline=True) 95 | em.add_field(name="Author", value=author, inline=True) 96 | if self.requirements: 97 | em.add_field(name="External libraries", value=f"{', '.join(self.requirements)}", inline=True) 98 | if self.required_cogs: 99 | em.add_field(name="Required cogs", value=f"{', '.join(self.required_cogs.keys())}", inline=True) 100 | if self.repo.rx_branch: 101 | repo_url, _ = self.repo.url.split("@", 1) 102 | else: 103 | repo_url = self.repo.url 104 | em.add_field( 105 | name="Command to add repo", 106 | value=f"{prefix}repo add {self.repo.name.lower()} {repo_url} {self.repo.rx_branch}", 107 | inline=False, 108 | ) 109 | em.add_field( 110 | name="Command to add cog", value=f"{prefix}cog install {self.repo.name.lower()} {self.name}", inline=False 111 | ) 112 | tags = "" 113 | if self.tags: 114 | tags = "\nTags: " + ", ".join(self.tags) 115 | em.set_footer(text=f"{tags}") 116 | return em 117 | 118 | 119 | def build_embeds(repos_cogs, prefix="[p]", is_owner=False): 120 | embeds = [] 121 | 122 | for rc in repos_cogs: 123 | if isinstance(rc, (Repo, Cog)): 124 | em = rc.build_embed(prefix=prefix, is_owner=is_owner) 125 | else: 126 | raise TypeError("Unhandled type.") 127 | embeds.append(em) 128 | 129 | return embeds 130 | -------------------------------------------------------------------------------- /index/views.py: -------------------------------------------------------------------------------- 1 | """ 2 | Index - Browse and install Red repos and cogs using the Red-Index system 3 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | from typing import List, Optional 20 | 21 | import discord 22 | from redbot.core import commands 23 | 24 | from .parser import Repo, Cog, build_embeds, FLOPPY_DISK, ARROW_DOWN 25 | from .exceptions import NoCogs 26 | 27 | PREV_ARROW = "\N{LEFTWARDS BLACK ARROW}\N{VARIATION SELECTOR-16}" 28 | CROSS_MARK = "\N{HEAVY MULTIPLICATION X}\N{VARIATION SELECTOR-16}" 29 | NEXT_ARROW = "\N{BLACK RIGHTWARDS ARROW}\N{VARIATION SELECTOR-16}" 30 | MAG_GLASS = "\N{LEFT-POINTING MAGNIFYING GLASS}" 31 | 32 | 33 | class IndexView(discord.ui.View): 34 | def __init__(self, ctx: commands.Context, *args, **kwargs): 35 | self.ctx: commands.Context = ctx 36 | self.cog = ctx.cog 37 | 38 | self._message: Optional[discord.Message] = None 39 | self._embeds: Optional[List[discord.Embed]] = None 40 | self._selected = 0 41 | 42 | super().__init__(*args, timeout=60 * 3, **kwargs) 43 | 44 | async def interaction_check(self, interaction: discord.Interaction): 45 | if not interaction.user.id == self.ctx.author.id: 46 | await interaction.response.send_message( 47 | "You are not allowed to interact with this menu. " 48 | f"You can open your own with `{self.ctx.prefix}index browse`.", 49 | ephemeral=True, 50 | ) 51 | return False 52 | return True 53 | 54 | async def on_timeout(self): 55 | for child in self.children: 56 | if not child.style == discord.ButtonStyle.url: 57 | child.disabled = True 58 | try: 59 | await self._message.edit(view=self) 60 | except discord.HTTPException: 61 | pass 62 | 63 | 64 | class IndexReposView(IndexView): 65 | def __init__(self, ctx: commands.Context, repos: List[Repo]): 66 | super().__init__(ctx) 67 | self.repos: List[Repo] = repos 68 | 69 | async def show_repos(self): 70 | is_owner = await self.ctx.bot.is_owner(self.ctx.author) and self.ctx.bot.get_cog("Downloader") 71 | self._embeds = build_embeds(self.repos, prefix=self.ctx.prefix, is_owner=is_owner) 72 | if not is_owner: 73 | self.remove_item(self.install_repo) 74 | self._message = await self.ctx.send(embed=self._embeds[self._selected], view=self) 75 | 76 | @discord.ui.button(emoji=PREV_ARROW) 77 | async def prev_page(self, interaction: discord.Interaction, button: discord.ui.Button): 78 | if self._selected == (len(self.repos) - 1): 79 | self._selected = 0 80 | else: 81 | self._selected += 1 82 | await interaction.response.edit_message(embed=self._embeds[self._selected], view=self) 83 | 84 | @discord.ui.button(emoji=MAG_GLASS) 85 | async def enter_repo(self, interaction: discord.Interaction, button: discord.ui.Button): 86 | try: 87 | await IndexCogsView(self.ctx, repo=self.repos[self._selected]).show_cogs() 88 | except NoCogs: 89 | await interaction.response.send_message("This repository is empty: no cogs to show.", ephemeral=True) 90 | return 91 | await interaction.response.defer() 92 | await self._message.delete() 93 | 94 | @discord.ui.button(emoji=NEXT_ARROW) 95 | async def next_page(self, interaction: discord.Interaction, button: discord.ui.Button): 96 | if self._selected == 0: 97 | self._selected = len(self.repos) - 1 98 | else: 99 | self._selected -= 1 100 | await interaction.response.edit_message(embed=self._embeds[self._selected], view=self) 101 | 102 | @discord.ui.button(style=discord.ButtonStyle.danger, emoji=CROSS_MARK) 103 | async def close_page(self, interaction: discord.Interaction, button: discord.ui.Button): 104 | await interaction.response.defer() 105 | try: 106 | await self._message.delete() 107 | except discord.HTTPException: 108 | pass 109 | self.stop() 110 | 111 | @discord.ui.button(style=discord.ButtonStyle.success, label="Install repo", emoji=FLOPPY_DISK) 112 | async def install_repo(self, interaction: discord.Interaction, button: discord.ui.Button): 113 | await interaction.response.defer() 114 | try: 115 | await self.cog.install_repo_cog(self.ctx, self.repos[self._selected]) 116 | except RuntimeError as e: 117 | await self.ctx.send(f"I could not install the repository: {e}") 118 | 119 | 120 | class IndexCogsView(IndexView): 121 | def __init__(self, ctx: commands.Context, repo: Optional[Repo] = None, cogs: Optional[List[Cog]] = None): 122 | super().__init__(ctx) 123 | self.repo: Optional[Repo] = repo 124 | self.cogs: Optional[List[Cog]] = cogs 125 | 126 | async def show_cogs(self): 127 | is_owner = await self.ctx.bot.is_owner(self.ctx.author) and self.ctx.bot.get_cog("Downloader") 128 | if self.repo and not self.cogs: 129 | self.cogs = list(self.repo.cogs.values()) 130 | elif self.cogs: 131 | pass 132 | else: 133 | raise ValueError() 134 | self._embeds = build_embeds(self.cogs, prefix=self.ctx.prefix, is_owner=is_owner) 135 | if not is_owner: 136 | self.remove_item(self.install_cog) 137 | if len(self._embeds) == 0: 138 | raise NoCogs() 139 | self._message = await self.ctx.send(embed=self._embeds[self._selected], view=self) 140 | 141 | @discord.ui.button(emoji=PREV_ARROW) 142 | async def prev_page(self, interaction: discord.Interaction, button: discord.ui.Button): 143 | if self._selected == (len(self.cogs) - 1): 144 | self._selected = 0 145 | else: 146 | self._selected += 1 147 | await interaction.response.edit_message(embed=self._embeds[self._selected], view=self) 148 | 149 | @discord.ui.button(emoji=ARROW_DOWN) 150 | async def browse_repos(self, interaction: discord.Interaction, button: discord.ui.Button): 151 | await interaction.response.defer() 152 | await self._message.delete() 153 | await IndexReposView(self.ctx, repos=self.cog.cache.copy()).show_repos() 154 | 155 | @discord.ui.button(emoji=NEXT_ARROW) 156 | async def next_page(self, interaction: discord.Interaction, button: discord.ui.Button): 157 | if self._selected == 0: 158 | self._selected = len(self.cogs) - 1 159 | else: 160 | self._selected -= 1 161 | await interaction.response.edit_message(embed=self._embeds[self._selected], view=self) 162 | 163 | @discord.ui.button(style=discord.ButtonStyle.danger, emoji=CROSS_MARK) 164 | async def close_page(self, interaction: discord.Interaction, button: discord.ui.Button): 165 | await interaction.response.defer() 166 | try: 167 | await self._message.delete() 168 | except discord.HTTPException: 169 | pass 170 | self.stop() 171 | 172 | @discord.ui.button(style=discord.ButtonStyle.success, label="Install cog", emoji=FLOPPY_DISK) 173 | async def install_cog(self, interaction: discord.Interaction, button: discord.ui.Button): 174 | await interaction.response.defer() 175 | try: 176 | await self.cog.install_repo_cog(self.ctx, self.cogs[self._selected].repo, self.cogs[self._selected]) 177 | except RuntimeError as e: 178 | await self.ctx.send(f"I could not install the repository: {e}") 179 | -------------------------------------------------------------------------------- /info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "x26-Cogs", 3 | "short": "General purpose cogs by Twentysix (Twentysix#5252)", 4 | "description": "General purpose cogs by Twentysix (Twentysix#5252)", 5 | "install_msg": "Thanks for installing this repo. Issues or questions? Have fun!", 6 | "author": [ 7 | "Twentysix (Twentysix#5252)" 8 | ] 9 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 -------------------------------------------------------------------------------- /sbansync/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from .sbansync import Sbansync 3 | from pathlib import Path 4 | 5 | with open(Path(__file__).parent / "info.json") as fp: 6 | __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] 7 | 8 | 9 | async def setup(bot): 10 | await bot.add_cog(Sbansync(bot)) 11 | -------------------------------------------------------------------------------- /sbansync/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Twentysix (Twentysix#5252)" 4 | ], 5 | "description": "A simple, no frills bansync cog.", 6 | "install_msg": "Thanks for installing this cog. TL;DR:\nIf you're admin on server A and server B you can sync bans between them no problem.\nIf you're only admin on server A, server B will need to whitelist your server for you to pull (and push) bans from/to it.\nThat's it. See `[p]help Sbansync` and enjoy.", 7 | "short": "A simple, no frills bansync cog.", 8 | "tags": [ 9 | "bansync", 10 | "moderation", 11 | "admin" 12 | ], 13 | "min_bot_version": "3.5.0.dev317", 14 | "type": "COG", 15 | "end_user_data_statement": "This cog does not store end user data." 16 | } -------------------------------------------------------------------------------- /sbansync/sbansync.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simplebansync - A simple, no frills bansync cog 3 | Copyright (C) 2020-present Twentysix (https://github.com/Twentysix26/) 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | from redbot.core import commands 20 | from redbot.core.bot import Red 21 | from redbot.core.commands import GuildConverter 22 | from redbot.core.config import Config 23 | from redbot.core.utils.chat_formatting import inline 24 | from enum import Enum 25 | from collections import Counter 26 | import discord 27 | import logging 28 | 29 | log = logging.getLogger("red.x26cogs.simplebansync") 30 | 31 | 32 | class Operation(Enum): 33 | Pull = 1 34 | Push = 2 35 | Sync = 3 36 | 37 | 38 | class Sbansync(commands.Cog): 39 | """Pull, push and sync bans between servers""" 40 | 41 | def __init__(self, bot: Red): 42 | self.bot = bot 43 | self.config = Config.get_conf(self, identifier=262_626, force_registration=True) 44 | self.config.register_guild(allow_pull_from=[], allow_push_to=[], silently=False) 45 | 46 | @commands.group() 47 | @commands.guild_only() 48 | @commands.admin() 49 | async def sbansync(self, ctx: commands.Context): 50 | """Pull, push and sync bans between servers""" 51 | if await self.callout_if_fake_admin(ctx): 52 | ctx.invoked_subcommand = None 53 | 54 | @sbansync.command(name="pull") 55 | @commands.bot_has_permissions(ban_members=True) 56 | async def sbansyncpullfrom(self, ctx: commands.Context, *, server: GuildConverter): 57 | """Pulls bans from a server 58 | 59 | The command issuer must be an admin on that server OR the server 60 | needs to whitelist this one for pull operations""" 61 | author = ctx.author 62 | if not await self.is_member_allowed(Operation.Pull, author, server): 63 | return await ctx.send("This server is not in that server's pull list.") 64 | 65 | async with ctx.typing(): 66 | try: 67 | stats = await self.do_operation(Operation.Pull, author, server) 68 | except RuntimeError as e: 69 | return await ctx.send(str(e)) 70 | 71 | text = "" 72 | 73 | if stats: 74 | for k, v in stats.items(): 75 | text += f"{k} {v}\n" 76 | else: 77 | text = "No bans to pull." 78 | 79 | silently = await self.config.guild(ctx.guild).silently() 80 | 81 | if silently: 82 | await ctx.tick() 83 | else: 84 | await ctx.send(text) 85 | 86 | @sbansync.command(name="push") 87 | @commands.bot_has_permissions(ban_members=True) 88 | async def sbansyncpushto(self, ctx: commands.Context, *, server: GuildConverter): 89 | """Pushes bans to a server 90 | 91 | The command issuer must be an admin on that server OR the server 92 | needs to whitelist this one for push operations""" 93 | author = ctx.author 94 | if not await self.is_member_allowed(Operation.Push, author, server): 95 | return await ctx.send("This server is not in that server's push list.") 96 | 97 | async with ctx.typing(): 98 | try: 99 | stats = await self.do_operation(Operation.Push, author, server) 100 | except RuntimeError as e: 101 | return await ctx.send(str(e)) 102 | 103 | text = "" 104 | 105 | if stats: 106 | for k, v in stats.items(): 107 | text += f"{k} {v}\n" 108 | else: 109 | text = "No bans to push." 110 | 111 | silently = await self.config.guild(ctx.guild).silently() 112 | 113 | if silently: 114 | await ctx.tick() 115 | else: 116 | await ctx.send(text) 117 | 118 | @sbansync.command(name="sync") 119 | @commands.bot_has_permissions(ban_members=True) 120 | async def sbansyncsyncwith(self, ctx: commands.Context, *, server: GuildConverter): 121 | """Syncs bans with a server 122 | 123 | The command issuer must be an admin on that server OR the server 124 | needs to whitelist this one for push and pull operations""" 125 | author = ctx.author 126 | if not await self.is_member_allowed(Operation.Sync, author, server): 127 | return await ctx.send("This server is not in that server's push and/or pull list.") 128 | 129 | async with ctx.typing(): 130 | try: 131 | stats = await self.do_operation(Operation.Sync, author, server) 132 | except RuntimeError as e: 133 | return await ctx.send(str(e)) 134 | 135 | text = "" 136 | 137 | if stats: 138 | for k, v in stats.items(): 139 | text += f"{k} {v}\n" 140 | else: 141 | text = "No bans to sync." 142 | 143 | silently = await self.config.guild(ctx.guild).silently() 144 | 145 | if silently: 146 | await ctx.tick() 147 | else: 148 | await ctx.send(text) 149 | 150 | @commands.group() 151 | @commands.guild_only() 152 | @commands.admin() 153 | async def sbansyncset(self, ctx: commands.Context): 154 | """SimpleBansync settings""" 155 | if await self.callout_if_fake_admin(ctx): 156 | ctx.invoked_subcommand = None 157 | 158 | @sbansyncset.command(name="addpush") 159 | async def sbansyncsaddpush(self, ctx: commands.Context, *, server: GuildConverter): 160 | """Allows a server to push bans to this one""" 161 | async with self.config.guild(ctx.guild).allow_push_to() as allowed_push: 162 | if server.id not in allowed_push: 163 | allowed_push.append(server.id) 164 | await ctx.send(f"`{server.name}` will now be allowed to **push** bans to this server.") 165 | 166 | @sbansyncset.command(name="addpull") 167 | async def sbansyncsaddpull(self, ctx: commands.Context, *, server: GuildConverter): 168 | """Allows a server to pull bans from this one""" 169 | async with self.config.guild(ctx.guild).allow_pull_from() as allowed_pull: 170 | if server.id not in allowed_pull: 171 | allowed_pull.append(server.id) 172 | await ctx.send(f"`{server.name}` will now be allowed to **pull** bans from this server.") 173 | 174 | @sbansyncset.command(name="removepush") 175 | async def sbansyncsremovepush(self, ctx: commands.Context, *, server: GuildConverter): 176 | """Disallows a server to push bans to this one""" 177 | async with self.config.guild(ctx.guild).allow_push_to() as allowed_push: 178 | if server.id in allowed_push: 179 | allowed_push.remove(server.id) 180 | await ctx.send( 181 | f"`{server.name}` has been removed from the list of servers allowed to " "**push** bans to this server." 182 | ) 183 | 184 | @sbansyncset.command(name="removepull") 185 | async def sbansyncsremovepull(self, ctx: commands.Context, *, server: GuildConverter): 186 | """Disallows a server to pull bans from this one""" 187 | async with self.config.guild(ctx.guild).allow_pull_from() as allowed_pull: 188 | if server.id in allowed_pull: 189 | allowed_pull.remove(server.id) 190 | await ctx.send( 191 | f"`{server.name}` has been removed from the list of servers allowed to " "**pull** bans from this server." 192 | ) 193 | 194 | @sbansyncset.command(name="clearpush") 195 | async def sbansyncsaclearpush(self, ctx: commands.Context): 196 | """Clears the list of servers allowed to push bans to this one""" 197 | await self.config.guild(ctx.guild).allow_push_to.clear() 198 | await ctx.send( 199 | "Push list cleared. Only local admins are now allowed to push bans to this " "server from elsewhere." 200 | ) 201 | 202 | @sbansyncset.command(name="clearpull") 203 | async def sbansyncsclearpull(self, ctx: commands.Context): 204 | """Clears the list of servers allowed to pull bans from this one""" 205 | await self.config.guild(ctx.guild).allow_pull_from.clear() 206 | await ctx.send( 207 | "Pull list cleared. Only local admins are now allowed to pull bans from this " "server from elsewhere." 208 | ) 209 | 210 | @sbansyncset.command(name="showlists", aliases=["showsettings"]) 211 | async def sbansyncsshowlists(self, ctx: commands.Context): 212 | """Shows the current pull and push lists""" 213 | b = self.bot 214 | pull = await self.config.guild(ctx.guild).allow_pull_from() 215 | push = await self.config.guild(ctx.guild).allow_push_to() 216 | pull = [inline(b.get_guild(s).name) for s in pull if b.get_guild(s)] or ["None"] 217 | push = [inline(b.get_guild(s).name) for s in push if b.get_guild(s)] or ["None"] 218 | 219 | await ctx.send(f"Pull: {', '.join(pull)}\nPush: {', '.join(push)}") 220 | 221 | @sbansyncset.command(name="silently") 222 | async def sbansyncssilently(self, ctx: commands.Context, on_or_off: bool): 223 | """Toggle whether to perform operations silently 224 | 225 | This is is useful in case pull, push and syncs are done by tasks 226 | instead of manually""" 227 | await self.config.guild(ctx.guild).silently.set(on_or_off) 228 | 229 | if on_or_off: 230 | await ctx.send("I will perform pull, push and syncs silently.") 231 | else: 232 | await ctx.send("I will report the number of users affected for each operation.") 233 | 234 | async def is_member_allowed(self, operation: Operation, member: discord.Member, target: discord.Guild): 235 | """A member is allowed to pull, push or sync to a guild if: 236 | A) Has an admin role in the target server WITH ban permissions 237 | B) The target server has whitelisted our server for this operation 238 | """ 239 | target_member = target.get_member(member.id) 240 | if target_member: 241 | is_admin_in_target = await self.bot.is_admin(target_member) 242 | has_ban_perms = target_member.guild_permissions.ban_members 243 | if is_admin_in_target and has_ban_perms: 244 | return True 245 | 246 | allow_pull = member.guild.id in await self.config.guild(target).allow_pull_from() 247 | allow_push = member.guild.id in await self.config.guild(target).allow_push_to() 248 | 249 | if operation == Operation.Pull: 250 | return allow_pull 251 | elif operation == Operation.Push: 252 | return allow_push 253 | elif operation == Operation.Sync: 254 | return allow_pull and allow_push 255 | else: 256 | raise ValueError("Invalid operation") 257 | 258 | async def do_operation(self, operation: Operation, member: discord.Member, target_guild: discord.Guild): 259 | guild = member.guild 260 | if not target_guild.me.guild_permissions.ban_members: 261 | raise RuntimeError("I do not have ban members permissions in the target server.") 262 | 263 | stats = Counter() 264 | 265 | guild_bans = [m.user async for m in guild.bans(limit=None)] 266 | target_bans = [m.user async for m in target_guild.bans(limit=None)] 267 | 268 | if operation in (Operation.Pull, Operation.Sync): 269 | for m in target_bans: 270 | if m not in guild_bans: 271 | try: 272 | await guild.ban(m, delete_message_seconds=0, reason=f"Syncban issued by {member} ({member.id})") 273 | except (discord.Forbidden, discord.HTTPException): 274 | stats["Failed pulls: "] += 1 275 | else: 276 | stats["Pulled bans: "] += 1 277 | 278 | if operation in (Operation.Push, Operation.Sync): 279 | for m in guild_bans: 280 | if m not in target_bans: 281 | try: 282 | await target_guild.ban( 283 | m, delete_message_seconds=0, reason=f"Syncban issued by {member} ({member.id})" 284 | ) 285 | except (discord.Forbidden, discord.HTTPException): 286 | stats["Failed pushes: "] += 1 287 | else: 288 | stats["Pushed bans: "] += 1 289 | 290 | return stats 291 | 292 | async def callout_if_fake_admin(self, ctx): 293 | if ctx.invoked_subcommand is None: 294 | # User is just checking out the help 295 | return False 296 | error_msg = ( 297 | "It seems that you have a role that is considered admin at bot level but " 298 | "not the basic permissions that one would reasonably expect an admin to have.\n" 299 | "To use these commands, other than the admin role, you need `administrator` " 300 | "permissions OR `ban members`.\n" 301 | "I cannot let you proceed until you properly configure permissions in this server." 302 | ) 303 | channel = ctx.channel 304 | has_ban_perms = channel.permissions_for(ctx.author).ban_members 305 | 306 | if not has_ban_perms: 307 | await ctx.send(error_msg) 308 | return True 309 | return False 310 | --------------------------------------------------------------------------------