├── models ├── __init__.py ├── mutes.py ├── mail.py ├── moderations.py └── base.py ├── utils ├── __init__.py ├── enums.py ├── patch.py ├── paginator.py └── base.py ├── README.md ├── configs ├── poll.json ├── mod.json ├── logging.json ├── mail.json ├── infraction.json └── filter.json ├── config.json ├── LICENSE ├── plugins ├── help.py ├── filter.py ├── poll.py ├── basic.py ├── admin.py ├── mail.py ├── logging.py └── infract.py └── .gitignore /models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HootHoot 2 | Justice 2.0, a moderation bot for the repl.it discord 3 | -------------------------------------------------------------------------------- /utils/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Status(Enum): 5 | ENABLED = 0 6 | WARNING = 1 7 | DISABLED = 2 8 | -------------------------------------------------------------------------------- /configs/poll.json: -------------------------------------------------------------------------------- 1 | { 2 | "poll_channel": 610930206747918336, 3 | "question_timeout": 30, 4 | "subscribe_role": 616299568799285278 5 | } -------------------------------------------------------------------------------- /models/mutes.py: -------------------------------------------------------------------------------- 1 | from models.base import Base, Column 2 | 3 | 4 | class Mute(Base): 5 | target = Column("INTEGER") 6 | end_time = Column("INTEGER") 7 | -------------------------------------------------------------------------------- /models/mail.py: -------------------------------------------------------------------------------- 1 | from models.base import Base, Column 2 | 3 | 4 | class MailRoom(Base): 5 | user = Column("INTEGER", unique=True) 6 | channel = Column("INTEGER") 7 | date = Column("INTEGER") 8 | message = Column("TEXT") 9 | -------------------------------------------------------------------------------- /configs/mod.json: -------------------------------------------------------------------------------- 1 | { 2 | "avatar_timeout": 3600, 3 | "avatar_notification": "You've been muted for having an inappropriate avatar. You'll be muted until you change it for 1 hour. If you do not change it by that time, you'll be indefinitely muted and will have to contact staff to get unmuted.", 4 | "avatar_release": "Thank you for complying, you've been unmuted." 5 | } -------------------------------------------------------------------------------- /models/moderations.py: -------------------------------------------------------------------------------- 1 | from models.base import Base, Column 2 | 3 | 4 | class Infraction(Base): 5 | user = Column("INTEGER") 6 | type = Column("TEXT") 7 | reason = Column("TEXT", optional=True) 8 | moderator = Column("INTEGER") 9 | date = Column("INTEGER") 10 | 11 | 12 | class Note(Base): 13 | user = Column("INTEGER") 14 | content = Column("TEXT") 15 | moderator = Column("INTEGER") 16 | date = Column("INTEGER") 17 | -------------------------------------------------------------------------------- /configs/logging.json: -------------------------------------------------------------------------------- 1 | { 2 | "logging_channel": 440221190875774976, 3 | "max_message_cache": 100, 4 | "max_channel_cache": 50, 5 | "max_voice_cache": 75, 6 | "enabled": { 7 | "MessageDelete": true, 8 | "MessageUpdate": true, 9 | "ChannelUpdate": true, 10 | "ChannelDelete": true, 11 | "ChannelCreate": false, 12 | "GuildBanAdd": true, 13 | "GuildBanRemove": true, 14 | "GuildMemberAdd": true, 15 | "GuildMemberRemove": true, 16 | "GuildMemberUpdate": true, 17 | "VoiceStateUpdate": true 18 | } 19 | } -------------------------------------------------------------------------------- /configs/mail.json: -------------------------------------------------------------------------------- 1 | { 2 | "expiration": 43200, 3 | "confirmation_message": "You're about to open a new conversation with the repl.it discord moderators. Are you sure you'd like to do this?", 4 | "confirm_patience": 300, 5 | "ending_conv": "Ending conversation", 6 | "bad_reaction": "Unknown reaction, ending conversation", 7 | "closing_message": "This conversation has been closed or expired. Sending a new message with start a new conversation.", 8 | "unknown_room": "Unable to find that mail.", 9 | "max_cache": 20, 10 | "confirm_expired": "No reaction provided, ending query." 11 | } -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "bot": { 3 | "levels": { 4 | "437061464990285824": 100, 5 | "438855279363489812": 50, 6 | "503653160955674636": 50, 7 | "481953084726312977": 10 8 | }, 9 | "plugins": [ 10 | "plugins.infract", 11 | "plugins.basic", 12 | "plugins.logging", 13 | "plugins.poll", 14 | "plugins.help", 15 | "plugins.admin" 16 | ], 17 | "commands_prefix": ".", 18 | "commands_require_mention": false, 19 | "plugin_config_dir": "configs", 20 | "commands_level_getter": "utils.patch.get_correct_level", 21 | "shared_config": { 22 | "MUTE_ROLE": 439823181046611970, 23 | "GUILD_ID": 437048931827056642, 24 | "BOT_LOGGING_CHANNEL": 549745775899312138, 25 | "mail_parent": 604857699439738934, 26 | "PAGINATOR_TIMEOUT": 1200 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /configs/infraction.json: -------------------------------------------------------------------------------- 1 | { 2 | "warns_to_strike": 3, 3 | "strike_to_ban": 5, 4 | "auto_actions": { 5 | "warn": {"mute": 600}, 6 | "strike": {"mute": 86400} 7 | }, 8 | "msgs": { 9 | "warn": "You've been warned for '{reason}'. Don't worry, this a minor infraction, just don't do it again. You've been muted for {length} minutes.", 10 | "warn_no_reason": "You've been warned. Don't worry, this a minor infraction, just don't do it again. You've been muted for {length} minutes.", 11 | "strike_auto": "You've been auto-striked for receiving 3 warnings. 5 strikes is a ban, so think about what you do before you do it. You've been muted for {length} minutes.", 12 | "strike_manual_no_reason": "You've been striked. This is a major infraction, think about what you do before you do it. You've been muted for {length} minutes.", 13 | "strike_manual": "You've been striked for '{reason}'. This is a major infraction, think about what you do before you do it. You've been muted for {length} minutes." 14 | } 15 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Repl.it Discord 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /plugins/help.py: -------------------------------------------------------------------------------- 1 | from utils.base import HootPlugin 2 | 3 | from disco.types.message import MessageEmbed 4 | from disco.util.sanitize import S 5 | 6 | 7 | class HelpPlugin(HootPlugin): 8 | 9 | @HootPlugin.command("help", "[name:str]") 10 | def help_command(self, event, name: str = None): # TODO: Fix issue with commands with same name different group 11 | """ 12 | ***The Help Command*** 13 | 14 | This command will provide information on a certain command, or list all commands if no command is specified. 15 | 16 | ***Optional Values*** 17 | > __name__ **The name of the target command** 18 | """ 19 | if name is None: 20 | collections = [plugin.command_list for plugin in self.bot.plugins.values()] 21 | complete = [] 22 | for collection in collections: 23 | complete.extend(collection) 24 | 25 | embed = MessageEmbed() 26 | embed.title = 'List of Commands' 27 | embed.color = 0x6832E3 28 | embed.description = ', '.join(complete) 29 | else: 30 | for plugin in self.bot.plugins.values(): 31 | desc = plugin.get_help(name.lower()) 32 | if desc: 33 | break 34 | else: 35 | return event.msg.reply("Could not find command '{}'".format(S(name))) 36 | embed = MessageEmbed() 37 | embed.title = '**{}**'.format(name) 38 | embed.color = 0x6832E3 39 | embed.description = desc 40 | 41 | event.msg.reply(" ", embed=embed) 42 | -------------------------------------------------------------------------------- /utils/patch.py: -------------------------------------------------------------------------------- 1 | from disco.types.guild import GuildMember 2 | from disco.bot.parser import TYPE_MAP 3 | 4 | 5 | TIME_MAP = { 6 | "s": 1, 7 | "m": 60, 8 | "h": 60 * 60, 9 | "d": 60 * 60 * 24 10 | } 11 | 12 | 13 | def get_correct_level(self, actor): 14 | level = 0 15 | 16 | if actor.id in self.config.levels: 17 | level = self.config.levels[str(actor.id)] 18 | 19 | if isinstance(actor, GuildMember): 20 | for rid in actor.roles: 21 | rid = str(rid) 22 | if rid in self.config.levels and self.config.levels[rid] > level: 23 | level = self.config.levels[rid] 24 | 25 | return level 26 | 27 | 28 | def get_member(ctx, data): 29 | if data.isdigit(): 30 | target_id = data 31 | elif data.startswith("<") and data.endswith(">"): 32 | target_id = data.strip("<@!>") 33 | else: 34 | raise ValueError("Invalid member id / mention") 35 | return ctx.guild.get_member(target_id) 36 | 37 | 38 | def get_time(_, data): 39 | dates = data.lower().split(" ") 40 | total_seconds = 0 41 | for date in dates: 42 | total_seconds += TIME_MAP[date[-1]] * int(date[:-1]) 43 | return total_seconds 44 | 45 | 46 | def get_channel_id(_, data): 47 | if data.isdigit(): 48 | data = int(data) 49 | elif data.startswith("<") and data.endswith(">"): 50 | data = int(data.strip("<#>")) 51 | else: 52 | raise ValueError("Invalid channel id / mention") 53 | return data 54 | 55 | 56 | TYPE_MAP['member'] = get_member 57 | TYPE_MAP['time'] = get_time 58 | TYPE_MAP['channel_id'] = get_channel_id 59 | -------------------------------------------------------------------------------- /configs/filter.json: -------------------------------------------------------------------------------- 1 | { 2 | "regex": [ 3 | "^[a@][s\\$][s\\$]$", 4 | "[a@][s\\$][s\\$]h[o0][l1][e3][s\\$]?", 5 | "b[a@][s\\$][t\\+][a@]rd", 6 | "b[e3][a@]?[s\\$][t\\+][i1][a@]?[l1]([i1][t\\+]y)?", 7 | "b[i1][t\\+]c[h#]([e3]?r?[s\\$]?|[i1]ng?)?", 8 | "b[l1][o0]wj[o0]b[s\\$]?", 9 | "c[l1][i1][t\\+]", 10 | "^(c|k|ck|q)[o0](c|k|ck|q)[s\\$]?$", 11 | "(c|k|ck|q)[o0](c|k|ck|q)[s\\$]u((c|k|ck|q)([e3][rd]|[i1]ng|[s\\$])?)?", 12 | "^cum[s\\$]?$", 13 | "cum+([e3]r|[i1]ngc[o0]ck)", 14 | "(c|k|ck|q)um[s\\$]h[o0][t\\+]", 15 | "(c|k|ck|q)un+([i1][l1]+[i1]ngu[s\\$]|[t\\+]([s\\$]|[l1][i1](c|k|ck|q)([e3]r|[i1]ng)?)?)", 16 | "cyb[e3]r(ph|f)u*(c|k|ck|q)", 17 | "d[i1]ck", 18 | "d[i1][l1]d[o0][s\\$]*", 19 | "d[i1]n(c|k|ck|q)[s\\$]*", 20 | "[e3]j[a@]cu[l1]", 21 | "(ph|f)[a@]g+([s\\$]|[i1]ng|[o0]+[t\\+]+[s\\$]*)?", 22 | "(ph|f)[e3][l1]+[a@][t\\+][i1][o0]", 23 | "(ph|f)u*(c|k|ck|q)[s\\$]?", 24 | "g[a@]ngb[a@]ng([s\\$]|[e3]d)?", 25 | "h[o0]rny", 26 | "j[a@](c|k|ck|q)\\-?[o0](ph|f)(ph|f)?", 27 | "j[e3]rk\\-?[o0](ph|f)(ph|f)?", 28 | "j[i1][s\\$z][s\\$z]?m?", 29 | "[ck][o0]ndum[s\\$]?", 30 | "ma[s\\$][t\\+]([e3]|ur)b(8|a[i1][t\\+]|a[t\\+][e3])", 31 | "n[i1]g+[e3]r[s\\$]?", 32 | "[o0]rg[a@][s\\$][i1]?m[s\\$]?", 33 | "p[e3]n+[i1][s\\$]+", 34 | "p[o0]rn([o0][s\\$]?|[o0]gr[a@]p[h#]y)?", 35 | "pu[s\\$]{2,}(y|[i1][e3])[s\\$]*", 36 | "[s\\$][e3]x", 37 | "[s\\$]+[h#]+[i1]+[t\\+]+[s\\$]*", 38 | "[s\\$][l1]u[t\\+][s\\$]?", 39 | "[s\\$]mu[t\\+][s\\$]?", 40 | "[s\\$]punk[s\\$]?", 41 | "[t\\+]w[a@][t\\+][s\\$]?" 42 | ], 43 | "max_mentions": 5, 44 | "max_word_count": 12, 45 | "discord_syntax": "*_~|`", 46 | "extra_text": ".,?!-'\";:()[]<>" 47 | } 48 | -------------------------------------------------------------------------------- /utils/paginator.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from typing import List 3 | 4 | from disco.types.message import MessageEmbed 5 | from gevent.timeout import Timeout 6 | 7 | 8 | class PaginatorEmbed: 9 | 10 | def __init__(self, event, contents: List[str], **kwargs): 11 | self.event = event 12 | self.contents = contents 13 | self.embed = MessageEmbed(**kwargs) 14 | self.index = 0 15 | self.update() 16 | self.msg = event.msg.reply("", embed=self.embed) 17 | if len(contents) != 1: 18 | self.msg.add_reaction("⬅") 19 | sleep(.2) 20 | self.msg.add_reaction("➡") 21 | sleep(.2) # Or the bot could check to make sure it's not reacting to it's own reaction 22 | self.watch() 23 | 24 | def update(self): 25 | page = self.index % len(self.contents) 26 | self.embed.description = self.contents[page] 27 | self.embed.set_footer(text="Page {} / {}".format(page + 1, len(self.contents))) 28 | 29 | def watch(self): 30 | while True: 31 | reaction = self.event.command.plugin.wait_for_event( 32 | "MessageReactionAdd", 33 | lambda e: e.emoji.name in ("⬅", "➡"), 34 | message_id=self.msg.id, 35 | channel_id=self.msg.channel_id 36 | ) 37 | try: 38 | event = reaction.get(timeout=self.event.command.plugin.config["PAGINATOR_TIMEOUT"]) 39 | except Timeout: 40 | break 41 | 42 | if event.emoji.name == "➡": 43 | self.index += 1 44 | else: 45 | self.index -= 1 46 | 47 | self.update() 48 | self.msg.edit(embed=self.embed) 49 | event.delete() 50 | -------------------------------------------------------------------------------- /.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 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # PyCharm 107 | .idea/ 108 | 109 | # Database 110 | jester.db -------------------------------------------------------------------------------- /plugins/filter.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | import re 3 | 4 | from utils.base import HootPlugin, CommandLevels 5 | 6 | 7 | from disco.types import Message 8 | 9 | 10 | class FilterPlugin(HootPlugin): 11 | 12 | def load(self, ctx): 13 | for i, match in enumerate(self.config["regex"]): 14 | self.config["regex"][i] = re.compile(match) 15 | 16 | self.table = {} 17 | for char in self.config['discord_syntax'] + self.config['extra_text']: 18 | self.table[ord(char)] = None 19 | 20 | def get_words(self, sentence: str): 21 | content = sentence.lower().translate(self.table) 22 | return [s for s in content.split(" ") if s] 23 | 24 | def do_checks(self, msg: Message): 25 | if msg.channel.parent_id == self.config["mail_parent"] or msg.channel.is_dm: 26 | return True, None 27 | try: 28 | for attr in dir(self): 29 | if attr.startswith("check"): 30 | getattr(self, attr)(msg) 31 | except AssertionError as reason: 32 | return False, str(reason) 33 | return True, None 34 | 35 | def check_bad_words(self, msg: Message): 36 | for word in self.get_words(msg.content): 37 | for reg in self.config['regex']: 38 | assert reg.match(word) is None, "Watch your profanity {mention}" 39 | 40 | def check_mentions(self, msg: Message): 41 | assert len(msg.mentions) <= self.config['max_mentions'], "Calm down, don't spam mentions {mention} {mention}" 42 | 43 | def check_repeats(self, msg: Message): 44 | words = Counter(self.get_words(msg.content)) 45 | if words: 46 | assert words.most_common(1)[0][1] <= self.config['max_word_count'], "Don't repeat messages {mention}" 47 | 48 | @HootPlugin.listen("MessageCreate") 49 | def on_message(self, event: Message): 50 | good, reason = self.do_checks(event) 51 | if not good: 52 | event.delete() 53 | r = reason.format(mention=event.author.mention) 54 | event.reply(r) 55 | self.log_action("Blocked Message", "({n} {r}): {c}", event.member, r=r, 56 | c=event.content, n=event.channel.m) 57 | -------------------------------------------------------------------------------- /utils/base.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from time import time 3 | 4 | from models.mutes import Mute 5 | 6 | from disco.bot.plugin import Plugin, CommandError 7 | from disco.bot import CommandLevels 8 | from disco.api.http import APIException 9 | from disco.types.message import MessageEmbed 10 | import gevent 11 | 12 | 13 | class HootPlugin(Plugin): 14 | _shallow = True 15 | 16 | @property 17 | def command_list(self): 18 | return map(lambda x: x.name, self.commands) 19 | 20 | def execute(self, event): 21 | """ 22 | Executes a CommandEvent this plugin owns. 23 | """ 24 | if not event.command.oob: 25 | self.greenlets.add(gevent.getcurrent()) 26 | try: 27 | return event.command.execute(event) 28 | except CommandError as e: 29 | msg = e.args[0] 30 | if msg.startswith("cannot convert"): 31 | event.msg.reply("Invalid arguments for the given command, try .help to see how to use it.") 32 | else: 33 | event.msg.reply(e.args[0]) 34 | return False 35 | finally: 36 | self.ctx.drop() 37 | 38 | def get_help(self, name: str): 39 | try: 40 | cmd = next(c for c in self.commands if c.name == name) 41 | except StopIteration: 42 | return None 43 | return cmd.get_docstring() 44 | 45 | def log_action(self, action: str, content: str, target=None, **kwargs): 46 | target = target.user if hasattr(target, "user") else target 47 | embed = MessageEmbed() 48 | embed.title = action + " | " + str(target) if target is not None else "" 49 | embed.color = 0x6832E3 50 | if target is not None: 51 | embed.description = content.format(t=target, **kwargs) 52 | embed.set_thumbnail(url=target.avatar_url) 53 | else: 54 | embed.description = content.format(**kwargs) 55 | embed.timestamp = datetime.utcnow().isoformat() 56 | self.client.api.channels_messages_create(self.config["BOT_LOGGING_CHANNEL"], " ", embed=embed) 57 | 58 | def dm(self, channel, *args, **kwargs): 59 | try: 60 | channel.send_message(*args, **kwargs) 61 | except APIException: 62 | pass 63 | 64 | def unmute(self, member, force=False): 65 | unmute = True 66 | for mute in Mute.find(Mute.target == member.id): 67 | if mute.end_time <= time() or force: 68 | mute.delete_self() 69 | else: 70 | unmute = False 71 | if unmute: 72 | member.remove_role(self.config["MUTE_ROLE"]) 73 | self.log_action("Unmute", "Unmuted {t.mention}", member.user) 74 | -------------------------------------------------------------------------------- /plugins/poll.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from utils.base import HootPlugin, CommandLevels 4 | 5 | from disco.types.message import MessageEmbed 6 | from gevent import sleep 7 | from gevent.timeout import Timeout 8 | 9 | 10 | class PollPlugin(HootPlugin): 11 | 12 | LETTERS = [chr(127462 + i) for i in range(26)] # Emoji alphabet 13 | 14 | @HootPlugin.listen("Ready") 15 | def on_ready(self, _): 16 | self.poll_channel = self.client.api.channels_get(self.config['poll_channel']) 17 | for msg in self.poll_channel.get_pins(): 18 | if msg.author.id == self.client.state.me.id: 19 | self.poll_msg = msg 20 | break 21 | else: 22 | self.poll_msg = None 23 | 24 | self.sub_role = next(r for r in 25 | self.client.api.guilds_roles_list(self.config['GUILD_ID']) if 26 | r.id == self.config['subscribe_role']) 27 | 28 | 29 | def get_msg(self, event): 30 | masync = self.wait_for_event("MessageCreate", channel_id=event.msg.channel_id, author__id=event.msg.author.id) 31 | try: 32 | return masync.get(timeout=self.config['question_timeout']) 33 | except Timeout: 34 | event.msg.reply("No answer provided, canceling") 35 | return None 36 | 37 | @HootPlugin.command("poll", "", level=CommandLevels.MOD) 38 | def create_poll(self, event, question: str): 39 | """ 40 | ***The Poll Command*** 41 | 42 | This command will create a new poll and post it. 43 | 44 | ***Required Values*** 45 | > __question__ **The poll question** 46 | """ 47 | responses = {} 48 | for letter in self.LETTERS: 49 | event.msg.reply("Response {}: Send 'exit' to post, 'cancel' to cancel the poll".format(letter)) 50 | msg = self.get_msg(event) 51 | if msg is None: 52 | return 53 | if msg.content == 'exit': 54 | break 55 | if msg.content == 'cancel': 56 | return 57 | responses[letter] = msg.content 58 | 59 | if self.poll_msg: 60 | self.poll_msg.unpin() 61 | 62 | embed = MessageEmbed() 63 | embed.title = "New poll question!" 64 | embed.color = 0x6832E3 65 | embed.timestamp = datetime.utcnow().isoformat() 66 | embed.description = "\n".join([question + "\n"] + [l + " **" + q + "**" for l, q in responses.items()]) 67 | self.sub_role.update(mentionable=True) 68 | sleep(.5) 69 | poll_msg = self.poll_channel.send_message(self.sub_role.mention, embed=embed) 70 | self.sub_role.update(mentionable=False) 71 | self.poll_msg = poll_msg 72 | self.poll_msg.pin() 73 | for emoji in responses: 74 | sleep(.5) 75 | self.poll_msg.add_reaction(emoji) 76 | 77 | @HootPlugin.command("subscribe") 78 | def subscribe_member(self, event): 79 | """ 80 | ***The Subscribe Command*** 81 | 82 | This adds the subscriber role, so you can be notified when a new poll is posted. 83 | """ 84 | if self.sub_role.id not in event.member.roles: 85 | event.member.add_role(self.sub_role) 86 | event.msg.add_reaction("👍") 87 | 88 | @HootPlugin.command("unsubscribe") 89 | def unsubscribe_member(self, event): 90 | """ 91 | ***The Unsubscribe Command*** 92 | 93 | This removes the subscriber role 94 | """ 95 | if self.sub_role.id in event.member.roles: 96 | event.member.remove_role(self.sub_role) 97 | event.msg.add_reaction("👍") 98 | -------------------------------------------------------------------------------- /models/base.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from jester import JesterClient 4 | 5 | 6 | class Column: 7 | 8 | __slots__ = "type", "optional", "default", "unique" 9 | 10 | def __init__(self, typ: str, optional: bool = False, default: str = None, unique = False): 11 | self.type = typ 12 | self.optional = optional 13 | self.default = default 14 | self.unique = unique 15 | 16 | def compile(self): 17 | text = self.type 18 | if not self.optional: 19 | text += " NOT NULL" 20 | if self.default is not None: 21 | text += " DEFAULT " + self.default 22 | if self.unique and self.optional: 23 | raise ValueError("Unique keys can not be optional") 24 | elif self.unique: 25 | text += " PRIMARY KEY" 26 | return text 27 | 28 | def __eq__(self, other: str): 29 | return self, other 30 | 31 | 32 | class BaseMeta(type): 33 | 34 | def __new__(mcs, name, bases, clsattrs): 35 | 36 | if name != 'Base': 37 | table_name = clsattrs.get("TABLE_NAME", name.lower()) 38 | _fields = OrderedDict({name: arg for name, arg in clsattrs.items() if isinstance(arg, Column)}) 39 | 40 | with JesterClient() as client: 41 | client.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") 42 | all_db = [l[0] for l in client.fetch_all()] 43 | if table_name not in all_db: 44 | columns = " ".join("{} {},".format(name, val.compile()) for name, val in _fields.items())[:-1] 45 | client.execute("CREATE TABLE {} ({})".format(table_name, columns)) 46 | 47 | clsattrs["_fields"] = _fields 48 | clsattrs['table_name'] = table_name 49 | 50 | return super().__new__(mcs, name, bases, clsattrs) 51 | 52 | 53 | class Base(metaclass=BaseMeta): 54 | 55 | table_name: str 56 | _fields: dict 57 | 58 | def __init__(self, args: tuple): 59 | for name, value in zip(self._fields, args): 60 | setattr(self, name, value) 61 | 62 | def __iter__(self): 63 | for field in self._fields: 64 | yield getattr(self, field) 65 | 66 | @classmethod 67 | def create(cls, **fields): 68 | fields = OrderedDict(fields) 69 | 70 | if not all(name in fields for name, col in cls._fields.items() if not col.optional): 71 | raise ValueError("Not all required columns were provided") 72 | if not all(name in cls._fields for name in fields): 73 | raise ValueError("Provided unknown columns") 74 | 75 | with JesterClient() as client: 76 | values = ", ".join("?" for _ in fields) 77 | client.execute("INSERT INTO {} ({}) VALUES ({})".format(cls.table_name, ", ".join(fields), values), 78 | *fields.values()) 79 | 80 | @classmethod 81 | def _create_query(cls, querys): 82 | if isinstance(querys[0], tuple): 83 | matched = {} 84 | for query in querys: 85 | for name, col in cls._fields.items(): 86 | if query[0] is col: 87 | matched[name] = query[1] 88 | else: 89 | primary = next(name for name, col in cls._fields.items() if col.unique) 90 | matched = {primary: querys[0]} 91 | 92 | return " AND ".join("{} = ?".format(name) for name in matched), matched.values() 93 | 94 | @classmethod 95 | def find_all(cls): 96 | with JesterClient() as client: 97 | client.execute("SELECT * FROM " + cls.table_name) 98 | return [*map(cls, client.fetch_all())] 99 | 100 | @classmethod 101 | def find(cls, *querys): 102 | query, values = cls._create_query(querys) 103 | sql = "SELECT * FROM {} WHERE ".format(cls.table_name) + query 104 | with JesterClient() as client: 105 | client.execute(sql, *values) 106 | return [*map(cls, client.fetch_all())] 107 | 108 | @classmethod 109 | def find_one(cls, *query): 110 | return cls.find(*query)[0] 111 | 112 | @classmethod 113 | def delete(cls, *querys): 114 | query, values = cls._create_query(querys) 115 | sql = "DELETE FROM {} WHERE ".format(cls.table_name) + query 116 | with JesterClient() as client: 117 | client.execute(sql, *values) 118 | 119 | def delete_self(self): 120 | query = list(zip(self._fields.values(), self)) 121 | to_remove = [] 122 | for i, q in enumerate(query): 123 | if q[1] is None: 124 | to_remove.append(i) 125 | for i in to_remove: 126 | query.pop(i) 127 | self.delete(*query) 128 | -------------------------------------------------------------------------------- /plugins/basic.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | 3 | from models.mutes import Mute 4 | from utils.base import HootPlugin 5 | 6 | from disco.bot import CommandLevels 7 | from gevent.timeout import Timeout 8 | 9 | 10 | class ModPlugin(HootPlugin): 11 | 12 | @HootPlugin.command("kick", "", level=CommandLevels.MOD) 13 | def kick_user(self, event, target): 14 | """ 15 | ***The Kick Command*** 16 | 17 | This command will kick target member from the server 18 | 19 | ***Required Values*** 20 | > __target__ **The user's full discord name, mention, or ID** 21 | """ 22 | target.kick() 23 | event.msg.add_reaction("👍") 24 | self.log_action("Kick", "Kicked {t} from the server. Moderator: {e.author.mention}", target, e=event) 25 | 26 | @HootPlugin.command("ban", "", level=CommandLevels.MOD) 27 | def ban_user(self, event, target): 28 | """ 29 | ***The Ban Command*** 30 | 31 | This command will ban target member from the server 32 | 33 | ***Required Values*** 34 | > __target__ **The user's full discord name, mention, or ID** 35 | """ 36 | target.ban() 37 | event.msg.add_reaction("👍") 38 | self.log_action("Ban", "Banned {t} from the server. Moderator: {e.author.mention}", target, e=event) 39 | 40 | @HootPlugin.command("mute", " [length:time...]", level=CommandLevels.MOD) 41 | def mute_user(self, event, target, length: list = None): 42 | """ 43 | ***The Mute Command*** 44 | 45 | This command restrict the target's message permissions either forever, or a certain amount of time if specified. 46 | 47 | ***Required Values*** 48 | > __target__ **The user's full discord name, mention, or ID** 49 | 50 | **Optional Values** 51 | > __length__ **The amount of time until unmute in discord format. 52 | """ 53 | target.add_role(self.config["MUTE_ROLE"]) 54 | event.msg.add_reaction("👍") 55 | if length: 56 | seconds = sum(length) 57 | self.spawn_later(seconds, self.unmute, target) 58 | self.log_action("Muted", "Muted {t.mention} for {s} seconds. Moderator: {e.author.mention}", target, 59 | s=seconds, e=event) 60 | Mute.create(target=target.id, end_time=int(time() + seconds)) 61 | else: 62 | Mute.create(target=target.id, end_time=time() * 2) # This should ensure they never get unmuted, in theory? 63 | 64 | @HootPlugin.command("unmute", "", level=CommandLevels.MOD) 65 | def unmute_user(self, event, target): 66 | """ 67 | ***The Unmute Command*** 68 | 69 | This command will great the ability to send messages to a muted user. Avoid using this on timed mutes. 70 | 71 | ***Required Values*** 72 | > __target__ **The user's full discord name, mention, or ID** 73 | """ 74 | self.unmute(target, force=True) 75 | event.msg.add_reaction("👍") 76 | 77 | @HootPlugin.command("badavatar", "", level=CommandLevels.MOD) 78 | def block_avatar(self, event, target): 79 | """ 80 | ***The Ban Avatar Command*** 81 | 82 | This command will mute a user until they modify their avatar to something more appropriate. If they do not change after a specified amount of time, they'll need to contact a mod to be unmuted. 83 | 84 | ***Required Values*** 85 | > __target__ **The user's full discord name, mention, or ID** 86 | """ 87 | self.mute_user(event, target) 88 | bad_avatar = target.user.avatar 89 | 90 | dm = target.user.open_dm() 91 | dm.send_message(self.config['avatar_notification']) 92 | 93 | def changed_name(update_event): 94 | if getattr(update_event.user, "avatar", bad_avatar) == bad_avatar: 95 | return False 96 | return True 97 | 98 | async_update = self.wait_for_event("PresenceUpdate", changed_name, user__id=target.id) 99 | 100 | try: 101 | async_update.get(timeout=self.config["avatar_timeout"]) 102 | except Timeout: 103 | return 104 | 105 | self.unmute(target, force=True) 106 | dm.send_message(self.config["avatar_release"]) 107 | 108 | @HootPlugin.command("jammer", "", level=CommandLevels.TRUSTED) 109 | def make_jammer(self, event, target): 110 | """ 111 | Gives the jam role to a person 112 | """ 113 | target.add_role("688936866132656184") 114 | event.msg.add_reaction("👍") 115 | 116 | @HootPlugin.command("echo", " ", level=CommandLevels.MOD) 117 | def echo(self, event, channel: int, message: str): 118 | self.client.api.channels_messages_create(channel, message) 119 | 120 | -------------------------------------------------------------------------------- /plugins/admin.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from models.mutes import Mute 4 | from utils.base import HootPlugin 5 | from utils.paginator import PaginatorEmbed 6 | 7 | from disco.bot import CommandLevels 8 | from disco.types.message import MessageTable 9 | 10 | 11 | class AdminPlugin(HootPlugin): 12 | 13 | def load(self, ctx): 14 | self.start_time = datetime.now() 15 | self._disabled = [] 16 | self._commands = {} 17 | 18 | @HootPlugin.command("disable", "", group="plugin", level=CommandLevels.MOD) 19 | def disable_plugin(self, event, plugin_name: str): 20 | try: 21 | plugin = self.bot.plugins[plugin_name] 22 | except KeyError: 23 | return event.msg.reply("Unable to find plugin '{}'".format(plugin_name)) 24 | self.bot.rmv_plugin(plugin.__class__) 25 | self._disabled.append(plugin_name) 26 | for key, cmd in self._commands.items(): 27 | if cmd.plugin.__class__.__name__ == plugin_name: 28 | del self._commands[key] 29 | event.msg.add_reaction("👍") 30 | 31 | @HootPlugin.command("reload", "", group="plugin", level=CommandLevels.MOD) 32 | def reload_plugin(self, event, plugin_name: str): 33 | try: 34 | plugin = self.bot.plugins[plugin_name] 35 | except KeyError: 36 | return event.msg.reply("Unable to find plugin '{}'".format(plugin_name)) 37 | self.bot.reload_plugin(plugin.__class__) 38 | event.msg.add_reaction("👍") 39 | 40 | @HootPlugin.command("enable", " [style:str]", group="plugin", level=CommandLevels.MOD) 41 | def enabled_plugin(self, event, plugin_name: str, style: str="partial"): 42 | if style.lower() == "complete": 43 | self.bot.add_plugin_module(plugin_name) # Add catch if plugin doesn't exist 44 | shorthand = plugin_name.split(".")[-1] 45 | if shorthand in self._disabled: 46 | self._disabled.remove(shorthand) 47 | else: 48 | for plugin_path in self.bot.config.plugins: 49 | if plugin_path.split('.')[-1] == plugin_name: 50 | self.bot.add_plugin_module(plugin_path) 51 | break 52 | else: 53 | return event.msg.reply("Unable to find plugin '{}'".format(plugin_name)) 54 | if plugin_name in self._disabled: 55 | self._disabled.remove(plugin_name) 56 | event.msg.add_reaction("👍") 57 | 58 | @HootPlugin.command("disable", " [cmd_group:str]", group="command", level=CommandLevels.MOD) 59 | def disable_command(self, event, cmd_name: str, cmd_group: str = None): 60 | found = False 61 | for plugin in self.bot.plugins.values(): 62 | for command in plugin.commands: 63 | if command.triggers[0] == cmd_name and command.group == cmd_group: 64 | plugin.commands.remove(command) 65 | self._commands[(command.triggers[0], command.group)] = command 66 | self.bot.recompute() 67 | found = True 68 | break 69 | if found: 70 | break 71 | else: 72 | return event.msg.reply("Unable to find command '{}'".format(cmd_name)) 73 | event.msg.add_reaction("👍") 74 | 75 | @HootPlugin.command("enable", " [cmd_group:str]", group="command", level=CommandLevels.MOD) 76 | def enable_command(self, event, cmd_name: str, cmd_group: str = None): 77 | if (cmd_name, cmd_group) not in self._commands: 78 | return event.msg.reply("Unable to find disabled command '{}'".format(cmd_name)) 79 | cmd = self._commands.pop((cmd_name, cmd_group)) 80 | cmd.plugin.commands.append(cmd) 81 | self.bot.recompute() 82 | event.msg.add_reaction("👍") 83 | 84 | @HootPlugin.command("dashboard", level=CommandLevels.MOD) 85 | def display_stats(self, event): 86 | ping = (datetime.now() - event.msg.timestamp).microseconds // 1000 87 | uptime = str(datetime.now() - self.start_time).split(":") 88 | if ',' in uptime[0]: 89 | days, hours = uptime[0].split(',') 90 | else: 91 | days, hours = 0, int(uptime[0]) 92 | uptime = "{} days, {} hours, {} minutes and {} seconds".format( 93 | days, hours, 94 | int(uptime[1]), 95 | int(float(uptime[2])) 96 | ) 97 | plugin_table = MessageTable() 98 | plugin_table.set_header("Plugin", "Status") 99 | for plugin in sorted([*self.bot.plugins.keys()] + self._disabled): 100 | plugin_table.add(plugin, "Enabled" if plugin not in self._disabled else "Disabled") 101 | 102 | command_table = MessageTable() 103 | command_table.set_header("Command", "Group", "Plugin", "Status") 104 | cmds = [*self._commands.values()] 105 | for plugin in self.bot.plugins.values(): 106 | cmds.extend(plugin.commands) 107 | cmds.sort(key=lambda x: x.triggers[0]) 108 | for command in cmds: 109 | command_table.add(command.triggers[0], 110 | command.group or "None", 111 | command.plugin.__class__.__name__, 112 | "Disabled" if command in self._commands.values() else "Enabled" 113 | ) 114 | 115 | description = """Statistics: 116 | - Up time: {} 117 | - Ping: {}ms 118 | 119 | Plugins: 120 | {} 121 | 122 | Commands of enabled plugins: 123 | {} 124 | """.format(uptime, ping, plugin_table.compile(), command_table.compile()) 125 | broken_up = description.split("\n") 126 | final_description = [""] 127 | for part in broken_up: 128 | if len(final_description[-1] + part) + 4 > 2048: 129 | final_description[-1] += '```' 130 | final_description.append("```" + part + "\n") 131 | else: 132 | final_description[-1] += part + "\n" 133 | 134 | PaginatorEmbed(event, final_description, title="HootBoot's Dashboard", color=0x6832E3) 135 | 136 | @HootPlugin.command("mutes", level=CommandLevels.MOD) 137 | def show_mutes(self, event): 138 | mutes = Mute.find_all() 139 | mute_table = MessageTable() 140 | mute_table.set_header("Member", "Unmute") 141 | for mute in mutes: 142 | mute_table.add( 143 | "<@{}>".format(mute.target), 144 | datetime.fromtimestamp(mute.end_time).strftime("%b %d, %I:%M %p") 145 | ) 146 | PaginatorEmbed(event, [mute_table.compile()], title="Ongoing Mutes", color=0x6832E3) 147 | 148 | 149 | -------------------------------------------------------------------------------- /plugins/mail.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from time import time 3 | from weakref import WeakValueDictionary 4 | 5 | from models.mail import MailRoom 6 | from utils.base import HootPlugin 7 | 8 | from disco.api.http import APIException 9 | from disco.bot import CommandLevels 10 | from disco.util.sanitize import S 11 | from gevent.timeout import Timeout 12 | 13 | 14 | class MailPlugin(HootPlugin): 15 | 16 | def load(self, ctx): 17 | self.room_greenlets = WeakValueDictionary() 18 | self.preping = [] 19 | self.channel_cache = [] 20 | 21 | def get_room(self, channel_id: int): 22 | if channel_id in self.channel_cache: 23 | return False, None 24 | 25 | try: 26 | room = MailRoom.find_one(MailRoom.channel == channel_id) 27 | except IndexError: 28 | self.channel_cache.append(channel_id) 29 | if len(self.channel_cache) > self.config['max_cache']: 30 | self.channel_cache.pop(0) 31 | return False, None 32 | return True, room 33 | 34 | @HootPlugin.listen("Ready") 35 | def setup_channels(self, event): 36 | rooms = MailRoom.find_all() 37 | for room in rooms: 38 | try: 39 | channel = self.client.api.channels_get(room.channel) 40 | delta = (channel.get_message(channel.last_message_id).timestamp + timedelta(seconds=self.config["expiration"]) - datetime.now()) 41 | except APIException: 42 | room.delete_self() 43 | continue 44 | 45 | if delta.days < 0: 46 | self.expire_room(room) 47 | else: 48 | self.room_greenlets[room.channel] = self.spawn_later(delta.seconds, self.expire_room, room) 49 | 50 | def expire_room(self, room: MailRoom): 51 | room.delete_self() 52 | self.client.api.channels_messages_create(room.user, self.config['closing_message']) 53 | self.client.api.channels_delete(room.channel) 54 | self.log_action("Removed Mail", "Removed mail channel named <#{c}>", c=room.channel) 55 | 56 | @HootPlugin.command("close", "", level=CommandLevels.MOD) 57 | def close_room(self, event, channel): 58 | """ 59 | ***The Close Command*** 60 | 61 | This command will close target mail channel 62 | 63 | ***Required Values*** 64 | > __channel__ **The channel mention or ID** 65 | """ 66 | try: 67 | room = MailRoom.find_one(MailRoom.channel == channel) 68 | except IndexError: 69 | event.msg.reply(self.config["unknown_room"]) 70 | else: 71 | self.room_greenlets[room.channel].kill() 72 | self.expire_room(room) 73 | 74 | @HootPlugin.listen("MessageCreate") 75 | def on_mod_message(self, event): 76 | if event.author.id == self.client.state.me.id: 77 | return 78 | 79 | if list(self.bot.get_commands_for_message( 80 | self.bot.config.commands_require_mention, 81 | self.bot.config.commands_mention_rules, 82 | self.bot.config.commands_prefix, 83 | event)): 84 | return 85 | 86 | exists, room = self.get_room(event.channel_id) 87 | if not exists: 88 | return 89 | 90 | self.client.api.channels_messages_create(room.user, event.content or "") 91 | if event.attachments: 92 | self.client.api.channels_messages_create(room.channel, """__**Attachments:**__ 93 | {}""".format("\n".join([f" - {a.url}" for a in event.attachments.values()]))) 94 | 95 | @HootPlugin.listen("MessageCreate") 96 | def on_dm_message(self, event): 97 | if event.channel.type != 1 or event.author.id == self.client.state.me.id: # Not in DM or self 98 | return 99 | 100 | if event.author.id in self.preping: 101 | return 102 | 103 | if list(self.bot.get_commands_for_message( 104 | self.bot.config.commands_require_mention, 105 | self.bot.config.commands_mention_rules, 106 | self.bot.config.commands_prefix, 107 | event)): 108 | return 109 | 110 | try: 111 | room = MailRoom.find_one(event.channel_id) 112 | except IndexError: 113 | self.create_room(event) 114 | else: 115 | if room.channel in self.room_greenlets: # I shouldn't need to do this, but it doesn't hurt... 116 | self.room_greenlets[room.channel].kill() 117 | self.room_greenlets[room.channel] = self.spawn_later(self.config['expiration'], self.expire_room, room) 118 | self.client.api.channels_messages_create(room.channel, S(event.content) or "") 119 | if event.attachments: 120 | self.client.api.channels_messages_create(room.channel, """__**Attachments:**__ 121 | {}""".format("\n".join([f" - {a.url}" for a in event.attachments.values()]))) 122 | 123 | def create_room(self, msg): 124 | self.preping.append(msg.author.id) 125 | confirm = msg.reply(self.config['confirmation_message']).chain(False).\ 126 | add_reaction("✅").\ 127 | add_reaction("❎") 128 | 129 | reaction_async = self.wait_for_event("MessageReactionAdd", message_id=confirm.id, user_id=msg.author.id) 130 | 131 | try: 132 | reaction = reaction_async.get(timeout=self.config["confirm_patience"]).emoji 133 | except Timeout: 134 | self.preping.remove(msg.author.id) 135 | return msg.reply(self.config["confirm_expired"]) 136 | 137 | if reaction.name == "❎": 138 | self.preping.remove(msg.author.id) 139 | return msg.reply(self.config["ending_conv"]) 140 | elif reaction.name != "✅": 141 | self.preping.remove(msg.author.id) 142 | return msg.reply(self.config["bad_reaction"]) 143 | 144 | new_channel = self.client.api.guilds_channels_create( 145 | self.config["GUILD_ID"], 0, msg.author.username, parent_id=self.config["mail_parent"]) 146 | new_channel.send_message("__**NEW MAIL FROM *{}***__\n\n".format(msg.author.mention) + msg.content) 147 | if msg.attachments: 148 | new_channel.send_message("""__**Attachments:**__ 149 | {}""".format("\n".join([f" - {a.url}" for a in msg.attachments]))) 150 | 151 | MailRoom.create( 152 | user=msg.channel_id, 153 | channel=new_channel.id, 154 | date=int(time()), 155 | message=msg.content 156 | ) 157 | 158 | room = MailRoom.find_one(msg.channel_id) 159 | self.room_greenlets[new_channel.id] = self.spawn_later(self.config["expiration"], self.expire_room, room) 160 | self.preping.remove(msg.author.id) 161 | self.log_action("Created Mail", "Created a new mail room {c.mention} with {t.mention}", 162 | msg.author, c=new_channel) 163 | 164 | # TODO: Capture message edits 165 | -------------------------------------------------------------------------------- /plugins/logging.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict, deque 2 | from datetime import datetime 3 | 4 | from utils.base import HootPlugin 5 | 6 | from disco.types.message import MessageEmbed 7 | from holster.emitter import Priority 8 | 9 | 10 | def space_name(name: str): 11 | new_text = name[0] 12 | for char in name[1:]: 13 | if char.upper() == char: 14 | new_text += " " + char 15 | else: 16 | new_text += char 17 | return new_text 18 | 19 | 20 | to_add = set() 21 | 22 | 23 | def logging_wrapper(*event_names: str, **kwargs): 24 | def function_wrapper(func): 25 | def wrapper(self, event): 26 | if not self.config['enabled'].get(event.__class__.__name__): 27 | return 28 | 29 | embed = MessageEmbed() 30 | embed.title = space_name(event.__class__.__name__) 31 | embed.color = 0x6832E3 32 | embed.description = "" 33 | embed.timestamp = datetime.utcnow().isoformat() 34 | data = func(self, event) 35 | if "thumbnail" in data: 36 | embed.set_thumbnail(url=data["thumbnail"]) 37 | if "link" in data: 38 | embed.url = data['link'] 39 | 40 | for part in data['parts']: 41 | for key, value in part.items(): 42 | embed.description += "**" + key.title() + ":** " + value + "\n" 43 | embed.description += "\n" 44 | 45 | self.client.api.channels_messages_create(self.config["logging_channel"], " ", embed=embed) 46 | 47 | for event in event_names: 48 | HootPlugin.listen(event, priority=Priority.BEFORE, **kwargs)(wrapper) 49 | 50 | to_add.add(wrapper) 51 | return wrapper 52 | return function_wrapper 53 | 54 | 55 | class LoggingPlugin(HootPlugin): 56 | 57 | def load(self, ctx): 58 | self.msg_cache = defaultdict(lambda: deque(maxlen=self.config['max_message_cache'])) 59 | self.channel_cache = deque(maxlen=self.config["max_channel_cache"]) 60 | self.voice_cache = deque(maxlen=self.config['max_voice_cache']) 61 | 62 | def get_msg(self, channel: int, msg_id: int): 63 | return next((m for m in self.msg_cache[channel] if m.id == msg_id), None) 64 | 65 | def get_channel(self, channel: int): 66 | return next((c for c in self.channel_cache if c.id == channel), None) 67 | 68 | def get_voice(self, user_id: int): 69 | return next((v for v in self.voice_cache if v.user.id == user_id), None) 70 | 71 | @HootPlugin.listen("MessageUpdate") 72 | @HootPlugin.listen("MessageCreate") 73 | def update_cache(self, event): 74 | old_event = self.get_msg(event.channel_id, event.id) 75 | if old_event: 76 | self.msg_cache[event.channel_id].remove(old_event) 77 | else: 78 | if not self.get_channel(event.channel_id): 79 | self.channel_cache.append(event.channel) 80 | self.msg_cache[event.channel_id].append(event) 81 | 82 | @HootPlugin.listen("ChannelCreate") 83 | @HootPlugin.listen("ChannelUpdate") 84 | def update_channel(self, event): 85 | old_channel = self.get_channel(event.id) 86 | if old_channel: 87 | self.channel_cache.remove(old_channel) 88 | self.channel_cache.append(event) 89 | 90 | @HootPlugin.listen("VoiceStateUpdate") 91 | def update_voice_channel(self, event): 92 | old_state = self.get_voice(event.user.id) 93 | if old_state is not None: 94 | self.voice_cache.remove(old_state) 95 | if event.channel_id: 96 | self.voice_cache.append(event) 97 | 98 | @logging_wrapper("MessageDelete") 99 | def log_msg_delete(self, event): 100 | 101 | old_message = self.get_msg(event.channel_id, event.id) 102 | 103 | if old_message: 104 | self.msg_cache[event.channel_id].remove(old_message) 105 | return { 106 | "link": "https://discordapp.com/channels/" + "/".join(map(str, (old_message.guild.id, 107 | event.channel_id, event.id))), 108 | "thumbnail": old_message.author.avatar_url, 109 | "parts": [ 110 | { 111 | "channel": "<#" + str(event.channel_id) + ">", 112 | "author": old_message.author.mention, 113 | "content": old_message.content, 114 | "attachment amount": str(len(old_message.attachments)), 115 | "timestamp": old_message.timestamp.isoformat(), 116 | } 117 | ] 118 | } 119 | else: 120 | return {"parts": [{ 121 | "status": "Message not cached", 122 | "channel": "<#" + str(event.channel_id) + ">", 123 | }]} 124 | 125 | @logging_wrapper("MessageUpdate") 126 | def on_msg_edit(self, event): 127 | payload = { 128 | "link": "https://discordapp.com/channel/" + "/".join(map(str, (event.guild.id, 129 | event.channel_id, event.id))), 130 | "thumbnail": event.author.avatar_url, 131 | "parts": [{ 132 | "message": "*meta*", 133 | "channel": "<#" + str(event.channel_id) + ">", 134 | "author": event.author.mention 135 | }] 136 | } 137 | 138 | old_msg = self.get_msg(event.channel_id, event.id) 139 | if old_msg: 140 | payload['parts'].append({ 141 | "message": "*__old__*", 142 | "content": old_msg.content, 143 | "attachment amount": str(len(old_msg.attachments)), 144 | "timestamp": old_msg.timestamp.isoformat() 145 | }) 146 | 147 | payload['parts'].append({ 148 | "message": "*__new__*", 149 | "content": event.content, 150 | "attachment amount": str(len(event.attachments)), 151 | "timestamp": event.timestamp.isoformat() 152 | }) 153 | 154 | return payload 155 | 156 | @logging_wrapper("ChannelUpdate", "ChannelDelete", "ChannelCreate") 157 | def on_channel_update_or_delete(self, event): 158 | old_channel = self.get_channel(event.id) 159 | payload = { 160 | "link": "https://discordapp.com/channel/{}/{}".format(event.guild_id, event.id), 161 | "thumbnail": event.guild.icon_url, 162 | "parts": [] 163 | } 164 | if event.__class__.__name__ == "ChannelUpdate" and old_channel: 165 | payload['parts'].append({ 166 | "channel": "*__old__*", 167 | "name": old_channel.name, 168 | "topic": old_channel.topic, 169 | "type": str(old_channel.type).replace("_", " ").title(), 170 | "overwrite count": str(len(old_channel.overwrites)), 171 | "parent": "none" if not old_channel.parent_id else old_channel.parent.mention 172 | }) 173 | payload['parts'].append({ 174 | "channel": "*__new__*" if event.__class__.__name__ != "ChannelDelete" else "*__deleted__*", 175 | "name": event.name, 176 | "topic": event.topic, 177 | "type": str(event.type).replace("_", " ").title(), 178 | "overwrite count": str(len(event.overwrites)), 179 | "parent": "none" if not old_channel.parent_id else old_channel.parent.mention 180 | }) 181 | return payload 182 | 183 | @logging_wrapper("GuildBanAdd", "GuildBanRemove", "GuildMemberAdd", "GuildMemberRemove") 184 | def on_guild_ban(self, event): 185 | return { 186 | "thumbnail": event.user.avatar_url, 187 | "parts": [{ 188 | "name": str(event.user), 189 | "is bot": str(event.user.bot), 190 | "user id": str(event.user.id) 191 | }] 192 | } 193 | 194 | @logging_wrapper("GuildMemberUpdate") 195 | def member_updated(self, event): 196 | return { 197 | "thumbnail": event.user.avatar_url, 198 | "parts": [{ 199 | "user": event.user.mention, 200 | "is bot": str(event.user.bot), 201 | "roles": ", ".join(map(str, (event.guild.roles[rid] for rid in event.roles))), 202 | "nick": event.nick 203 | }] 204 | } 205 | 206 | @logging_wrapper("VoiceStateUpdate") 207 | def updated_voice_state(self, event): 208 | old_voice = self.get_voice(event.user.id) 209 | if old_voice is None: 210 | state = "__**Joined**__" 211 | elif event.channel_id is not None: 212 | state = "__**Status Update**__" 213 | else: 214 | state = "__**Left**__" 215 | 216 | return { 217 | "thumbnail": event.user.avatar_url, 218 | "parts": [{ 219 | "state": state, 220 | "channel": "<#" + str(event.channel_id or old_voice.channel_id) + ">", 221 | "user": event.user.mention, 222 | "is deaf": str(event.deaf or event.self_deaf), 223 | "is mute": str(event.mute or event.self_mute), 224 | }] 225 | } 226 | -------------------------------------------------------------------------------- /plugins/infract.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | from datetime import datetime 3 | 4 | from models.moderations import Infraction, Note 5 | from models.mutes import Mute 6 | from utils.base import HootPlugin 7 | from utils.paginator import PaginatorEmbed 8 | 9 | from disco.bot import CommandLevels 10 | from disco.types.message import MessageEmbed 11 | from disco.util.snowflake import to_datetime 12 | 13 | 14 | class InfractionPlugin(HootPlugin): 15 | 16 | @HootPlugin.schedule(60 * 60) 17 | def expire_infractions(self): 18 | for infraction in Infraction.find_all(): 19 | if infraction.type == "warn" and time() - infraction.date > 7.884e+6: 20 | infraction.delete_self() 21 | elif infraction.type == "strike" and time() - infraction.date > 1.577e+7: 22 | infraction.delete_self() 23 | 24 | @HootPlugin.command("history", "", level=CommandLevels.MOD) 25 | def target_history(self, event, member): 26 | """ 27 | ***The History Command*** 28 | 29 | This command will get a user's infraction history and other information. 30 | 31 | ***Required Values*** 32 | > __member__ **The user's full discord name, mention, or ID** 33 | """ 34 | for embed in self.get_history(member, True): 35 | event.msg.reply(embed=embed) 36 | 37 | @HootPlugin.command("selfhistory", level=CommandLevels.DEFAULT) 38 | def self_history(self, event): 39 | """ 40 | ***The Self History Command*** 41 | 42 | This command can be used by anyone, and gets their own infraction history. 43 | """ 44 | member = self.client.api.guilds_members_get(self.config['GUILD_ID'], event.author.id) # Quick hack for DMs 45 | dm = lambda *x, **y: self.dm(member.user.open_dm(), *x, **y) 46 | for embed in self.get_history(member, False): 47 | dm("", embed=embed) 48 | 49 | def get_history(self, member, show_mods: bool): 50 | infractions = Infraction.find(Infraction.user == member.id) 51 | total_warns = len([i for i in infractions if i.type == "warn"]) 52 | active_warns = total_warns % self.config['warns_to_strike'] 53 | active_strikes = len([i for i in infractions if i.type == "strike"]) \ 54 | + total_warns // self.config['warns_to_strike'] 55 | 56 | embed = MessageEmbed() 57 | embed.title = member.name + "'s History" 58 | embed.description = f"""__**Details:**__ 59 | 60 | Total Warnings: **{active_warns} / {self.config['warns_to_strike']}** 61 | Total Strikes: **{active_strikes} / {self.config['strike_to_ban']}** 62 | Strikes from Warnings: **{total_warns // self.config['warns_to_strike']}** 63 | Account Creation date: **{to_datetime(member.id)}** 64 | """ 65 | embed.set_thumbnail(url=member.user.get_avatar_url()) 66 | embed.color = 0x6832E3 67 | embeds = [embed] 68 | if infractions: 69 | embed.description += """ 70 | __**Infractions**__ 71 | """ 72 | 73 | infraction_base = """ 74 | ICIN: **{}** 75 | Type: **{}** 76 | Reason: ***{}*** 77 | Date: **{}** 78 | {}""" 79 | 80 | for i, infraction in enumerate(infractions): 81 | new_infraction = infraction_base.format( 82 | i, 83 | infraction.type, 84 | infraction.reason or "", 85 | datetime.utcfromtimestamp(infraction.date), 86 | f"Moderator: <@{infraction.moderator}>" if show_mods else "" 87 | ) 88 | if len(embeds[-1].description + new_infraction) >= 2048: 89 | next_embed = MessageEmbed() 90 | next_embed.color = 0x6832E3 91 | next_embed.description = new_infraction 92 | embeds.append(next_embed) 93 | else: 94 | embeds[-1].description += new_infraction 95 | 96 | return embeds 97 | 98 | @HootPlugin.command("strike", " [reason:str...]", level=CommandLevels.MOD) 99 | def strike_user(self, event, member, reason: str = None): 100 | """ 101 | ***The Strike Command*** 102 | 103 | This command will strike a member, and serve out the according punishment. (For serious infractions) 104 | 105 | ***Required Values*** 106 | > __member__ **The user's full discord name, mention, or ID** 107 | 108 | **Optional Values** 109 | > __reason__ **The reason for the strike** 110 | """ 111 | if reason is not None: 112 | Infraction.create( 113 | user=member.id, 114 | type="strike", 115 | reason=reason, 116 | moderator=event.author.id, 117 | date=int(time()) 118 | ) 119 | else: 120 | Infraction.create( 121 | user=member.id, 122 | type="strike", 123 | moderator=event.author.id, 124 | date=int(time()) 125 | ) 126 | 127 | event.msg.add_reaction("👍") 128 | dm = lambda *x: self.dm(member.user.open_dm(), *x) 129 | 130 | if reason is not None: 131 | dm(self.config['msgs']['strike_manual'].format( 132 | reason=reason, 133 | length=self.config['auto_actions']['strike']['mute'] // 60) 134 | ) 135 | self.log_action("Strike", "{t.mention} was striked for '{r}' by {m.mention}", 136 | member, r=reason, m=event.author) 137 | else: 138 | dm(self.config['msgs']['strike_manual_no_reason'].format( 139 | length=self.config['auto_actions']['strike']['mute'] // 60) 140 | ) 141 | self.log_action("Strike", "{t.mention} was striked, no reason was provided, by {m.mention}", 142 | member, e=event.author) 143 | 144 | if len(Infraction.find(Infraction.user == member.id, 145 | Infraction.type == "strike")) == self.config['strike_to_ban']: 146 | member.ban() 147 | else: 148 | self.execute_action(member, self.config['auto_actions']['strike']) 149 | 150 | @HootPlugin.command("warn", " [reason:str...]", level=CommandLevels.MOD) 151 | def warn_user(self, event, member, reason: str = None): 152 | """ 153 | ***The Warn Command*** 154 | 155 | This command will warn target member, and serve out the according punishment. (For minor infractions) 156 | 157 | ***Required Values*** 158 | > __member__ **The user's full discord name, mention, or ID** 159 | 160 | **Optional Values** 161 | > __reason__ **The reason for the user's warning** 162 | """ 163 | if reason is not None: 164 | Infraction.create( 165 | user=member.id, 166 | type="warn", 167 | reason=reason, 168 | moderator=event.author.id, 169 | date=int(time()) 170 | ) 171 | else: 172 | Infraction.create( 173 | user=member.id, 174 | type="warn", 175 | moderator=event.author.id, 176 | date=int(time()) 177 | ) 178 | 179 | event.msg.add_reaction("👍") 180 | dm = lambda *x: self.dm(member.user.open_dm(), *x) 181 | 182 | if reason is not None: 183 | dm(self.config['msgs']['warn'].format(reason=reason, length=self.config['auto_actions']['warn']['mute'] // 60)) 184 | self.log_action("Warn", "{t.mention} was warned for '{r}' by {m.mention}", 185 | member, r=reason, m=event.author) 186 | else: 187 | dm(self.config['msgs']['warn_no_reason'].format(length=self.config['auto_actions']['warn']['mute'] // 60)) 188 | self.log_action("Warn", "{t.mention} was warned, no reason was provided, by {m.mention}", 189 | member, m=event.author) 190 | 191 | if not len(Infraction.find(Infraction.user == member.id, 192 | Infraction.type == 'warn')) % self.config['warns_to_strike']: 193 | dm(self.config['msgs']['strike_auto'].format(length=self.config['auto_actions']['strike']['mute'] // 60)) 194 | self.execute_action(member, self.config['auto_actions']['strike']) 195 | else: 196 | self.execute_action(member, self.config['auto_actions']['warn']) 197 | 198 | @HootPlugin.command("repeal", " ", level=CommandLevels.MOD) 199 | def repeal_infraction(self, event, member, ICIN: int): 200 | """ 201 | ***The Repeal Command*** 202 | 203 | This command will remove an infraction from a user, either a warn or a strike. Keep in mind, infractions will expire naturally so only remove if it was a mistake. 204 | 205 | ***Required Values*** 206 | > __member__ **The user's full discord name, mention, or ID** 207 | 208 | > __ICIN__ **The infraction's ID, check history if unsure** 209 | """ 210 | for i, infraction in enumerate(Infraction.find(Infraction.user == member.id)): 211 | if i == ICIN: 212 | infraction.delete_self() 213 | event.msg.add_reaction("👍") 214 | break 215 | else: 216 | event.msg.reply("ICIN does not exist for that user, sorry.") 217 | 218 | @HootPlugin.command("note", " [note:str...]", level=CommandLevels.MOD) 219 | def append_note(self, event, member, note: str = None): 220 | """ 221 | ***The Note Command*** 222 | 223 | This command will let a moderator observe notes on a member, or add a note to a member if provided 224 | 225 | ***Required Values*** 226 | > __member__ **The user's full discord name, mention, or ID** 227 | 228 | ***Optional Values*** 229 | 230 | > __note__ **A note to record to that member, if provided** 231 | """ 232 | if isinstance(note, str): 233 | Note.create( 234 | user=member.id, 235 | content=note, 236 | moderator=event.author.id, 237 | date=int(time()) 238 | ) 239 | event.msg.add_reaction("👍") 240 | else: 241 | notes = Note.find(Note.user == member.id) 242 | note_list = [""] 243 | for note in notes: 244 | if len("\n\n" + note.content + note_list[-1]) > 2048: 245 | note_list.append(note.content) 246 | else: 247 | note_list[-1] += ("\n\n" if note_list[-1] else "") + note.content 248 | 249 | if not notes: 250 | note_list[0] = "No notes exist for this user" 251 | 252 | PaginatorEmbed(event, note_list, title="Notes for {}".format(member.name), color=0x6832E3) 253 | 254 | @HootPlugin.listen("Ready") 255 | def schedule_unmutes(self, _): 256 | mutes = Mute.find_all() 257 | unmutes = {} 258 | for mute in mutes: 259 | if time() >= mute.end_time: 260 | mute.delete_self() 261 | if mute.target not in unmutes: 262 | unmutes[mute.target] = True, 263 | else: 264 | unmutes[mute.target] = False, int(mute.end_time - time()) 265 | 266 | def remove_mute(user: int): 267 | self.client.api.guilds_members_roles_remove( 268 | self.config['GUILD_ID'], 269 | user, 270 | self.config["MUTE_ROLE"] 271 | ) 272 | return Mute.delete(Mute.target == user) 273 | 274 | for target, doit in unmutes.items(): 275 | if doit[0]: 276 | self.client.api.guilds_members_roles_remove(self.config['GUILD_ID'], target, self.config["MUTE_ROLE"]) 277 | else: 278 | self.spawn_later(doit[1], remove_mute, target) 279 | 280 | def execute_action(self, member, action): 281 | if "mute" in action: 282 | member.add_role(self.config["MUTE_ROLE"]) 283 | self.spawn_later(action['mute'], self.unmute, member) 284 | Mute.create(target=member.id, end_time=int(time() + action['mute'])) 285 | --------------------------------------------------------------------------------