├── .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 |
--------------------------------------------------------------------------------