├── .gitignore ├── LICENSE ├── README.md ├── dragory-migrate └── dragory-migrate.py └── profanity-filter ├── README.md ├── profanity-filter.py └── requirements.txt /.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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Kyber 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # modmail-plugins -------------------------------------------------------------------------------- /dragory-migrate/dragory-migrate.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sqlite3 3 | import re 4 | import os 5 | from datetime import datetime 6 | import secrets 7 | 8 | import discord 9 | from discord.ext import commands 10 | 11 | 12 | USER_CACHE = {} 13 | 14 | 15 | class Thread: 16 | statuses = {1: "open", 2: "closed", 3: "suspended"} 17 | 18 | __slots__ = [ 19 | "bot", 20 | "id", 21 | "status", 22 | "recipient", 23 | "creator", 24 | "creator_mod", 25 | "closer", 26 | "channel_id", 27 | "created_at", 28 | "scheduled_close_at", 29 | "scheduled_close_id", 30 | "alert_id", 31 | "messages", 32 | ] 33 | 34 | @classmethod 35 | async def from_data(cls, bot, data, cursor): 36 | # id 37 | # status 38 | # is_legacy 39 | # user_id 40 | # user_name 41 | # channel_id 42 | # created_at 43 | # scheduled_close_at 44 | # scheduled_close_id 45 | # scheduled_close_name 46 | # alert_id 47 | 48 | self = cls() 49 | self.bot = bot 50 | self.id = data[0] 51 | self.status = self.statuses[data[1]] 52 | 53 | user_id = data[3] 54 | if user_id: 55 | self.recipient = bot.get_user(int(user_id)) 56 | if self.recipient is None: 57 | try: 58 | if int(user_id) in USER_CACHE: 59 | user = USER_CACHE[int(user_id)] 60 | self.recipient = user 61 | else: 62 | self.recipient = await bot.fetch_user(int(user_id)) 63 | USER_CACHE[int(user_id)] = self.recipient 64 | except discord.NotFound: 65 | self.recipient = None 66 | else: 67 | self.recipient = None 68 | 69 | self.creator = self.recipient 70 | self.creator_mod = False 71 | self.closer = None 72 | 73 | self.channel_id = int(data[5]) 74 | self.created_at = datetime.fromisoformat(data[6]) 75 | self.scheduled_close_at = ( 76 | datetime.fromisoformat(data[7]) if data[7] else datetime.utcnow() 77 | ) 78 | self.scheduled_close_id = data[8] 79 | self.alert_id = data[9] 80 | 81 | self.messages = [] 82 | 83 | if self.id: 84 | for i in cursor.execute( 85 | "SELECT * FROM 'thread_messages' WHERE thread_id == ?", (self.id,) 86 | ): 87 | message = await ThreadMessage.from_data(bot, i) 88 | if message.type_ == "command" and "close" in message.body: 89 | self.closer = message.author 90 | elif message.type_ == "system" and message.body.startswith( 91 | "Thread was opened by " 92 | ): 93 | # user used the `newthread` command 94 | mod = message.body[:21] # gets name#discrim 95 | for i in bot.users: 96 | if str(i) == mod: 97 | self.creator = i 98 | self.creator_mod = True 99 | break 100 | self.messages.append(message) 101 | return self 102 | 103 | def serialize(self): 104 | """Turns it into a document""" 105 | payload = { 106 | "migrated": True, 107 | "open": not bool(self.closer), 108 | "channel_id": str(self.channel_id), 109 | "guild_id": str(self.bot.guild_id), 110 | "created_at": str(self.created_at), 111 | "closed_at": str(self.scheduled_close_at), 112 | "closer": None, 113 | "recipient": { 114 | "id": str(self.recipient.id), 115 | "name": self.recipient.name, 116 | "discriminator": self.recipient.discriminator, 117 | "avatar_url": str(self.recipient.avatar_url), 118 | "mod": False, 119 | }, 120 | "creator": { 121 | "id": str(self.creator.id), 122 | "name": self.creator.name, 123 | "discriminator": self.creator.discriminator, 124 | "avatar_url": str(self.creator.avatar_url), 125 | "mod": self.creator_mod, 126 | }, 127 | "messages": [m.serialize() for m in self.messages if m.serialize()], 128 | } 129 | if self.closer: 130 | payload["closer"] = { 131 | "id": str(self.closer.id), 132 | "name": self.closer.name, 133 | "discriminator": self.closer.discriminator, 134 | "avatar_url": str(self.closer.avatar_url), 135 | "mod": True, 136 | } 137 | return payload 138 | 139 | 140 | class ThreadMessage: 141 | types = { 142 | 1: "system", 143 | 2: "chat", 144 | 3: "from_user", 145 | 4: "to_user", 146 | 5: "legacy", 147 | 6: "command", 148 | } 149 | 150 | __slots__ = [ 151 | "bot", 152 | "id", 153 | "type_", 154 | "author", 155 | "body", 156 | "attachments", 157 | "content", 158 | "is_anonymous", 159 | "dm_message_id", 160 | "created_at", 161 | ] 162 | 163 | @classmethod 164 | async def from_data(cls, bot, data): 165 | # id 166 | # thread_id 167 | # message_type 168 | # user_id 169 | # user_name 170 | # body 171 | # is_anonymous 172 | # dm_message_id 173 | # created_at 174 | 175 | self = cls() 176 | self.bot = bot 177 | self.id = data[1] 178 | self.type_ = self.types[data[2]] 179 | 180 | user_id = data[3] 181 | if user_id: 182 | self.author = bot.get_user(int(user_id)) 183 | if self.author is None: 184 | try: 185 | if int(user_id) in USER_CACHE: 186 | user = USER_CACHE[int(user_id)] 187 | self.author = user 188 | else: 189 | self.author = await bot.fetch_user(int(user_id)) 190 | USER_CACHE[int(user_id)] = self.author 191 | except discord.NotFound: 192 | self.author = None 193 | else: 194 | self.author = None 195 | 196 | self.body = data[5] 197 | 198 | pattern = re.compile(r"http://[\d.]+:\d+/attachments/\d+/.*") 199 | self.attachments = pattern.findall(str(self.body)) 200 | if self.attachments: 201 | index = self.body.find(self.attachments[0]) 202 | self.content = self.body[:index] 203 | else: 204 | self.content = self.body 205 | 206 | self.is_anonymous = data[6] 207 | self.dm_message_id = data[7] 208 | self.created_at = datetime.fromisoformat(data[8]) 209 | self.attachments = pattern.findall(str(self.body)) 210 | return self 211 | 212 | def serialize(self): 213 | if self.type_ in ("from_user", "to_user"): 214 | return { 215 | "timestamp": str(self.created_at), 216 | "message_id": self.dm_message_id, 217 | "content": self.content, 218 | "author": { 219 | "id": str(self.author.id), 220 | "name": self.author.name, 221 | "discriminator": self.author.discriminator, 222 | "avatar_url": str(self.author.avatar_url), 223 | "mod": self.type_ == "to_user", 224 | } 225 | if self.author 226 | else None, 227 | "attachments": self.attachments, 228 | } 229 | 230 | 231 | class DragoryMigrate(commands.Cog): 232 | """ 233 | Cog that migrates thread logs from [Dragory's](https://github.com/dragory/modmailbot) 234 | modmail bot to this one. 235 | """ 236 | 237 | def __init__(self, bot): 238 | self.bot = bot 239 | self.output = "" 240 | 241 | @commands.command() 242 | @commands.is_owner() 243 | async def migratedb(self, ctx, url=None): 244 | """Migrates a database file to the mongo db. 245 | 246 | Provide an sqlite file as the attachment or a url 247 | pointing to the sqlite db. 248 | """ 249 | 250 | self.output = "" 251 | try: 252 | url = url or ctx.message.attachments[0].url 253 | except IndexError: 254 | await ctx.send("Provide an sqlite file as the attachment.") 255 | 256 | async with self.bot.session.get(url) as resp: 257 | # TODO: use BytesIO or sth 258 | with open("dragorydb.sqlite", "wb+") as f: 259 | f.write(await resp.read()) 260 | 261 | conn = sqlite3.connect("dragorydb.sqlite") 262 | c = conn.cursor() 263 | 264 | # Blocked Users 265 | for row in c.execute("SELECT * FROM 'blocked_users'"): 266 | # user_id 267 | # user_name 268 | # blocked_by 269 | # blocked_at 270 | 271 | user_id = row[0] 272 | 273 | cmd = self.bot.get_command("block") 274 | 275 | if int(user_id) in USER_CACHE: 276 | user = USER_CACHE[int(user_id)] 277 | else: 278 | user = await self.bot.fetch_user(int(user_id)) 279 | USER_CACHE[int(user_id)] = user 280 | self.bot.loop.create_task(ctx.invoke(cmd, user=user)) 281 | 282 | # Snippets 283 | for row in c.execute("SELECT * FROM 'snippets'"): 284 | # trigger body created_by created_at 285 | name = row[0] 286 | value = row[1] 287 | 288 | if "snippets" not in self.bot.config.cache: 289 | self.bot.config["snippets"] = {} 290 | 291 | self.bot.config.snippets[name] = value 292 | self.output += f"Snippet {name} added: {value}\n" 293 | 294 | tasks = [] 295 | 296 | prefix = os.getenv("LOG_URL_PREFIX", "/logs") 297 | if prefix == "NONE": 298 | prefix = "" 299 | 300 | async def convert_thread_log(row): 301 | thread = await Thread.from_data(self.bot, row, c) 302 | converted = thread.serialize() 303 | key = secrets.token_hex(6) 304 | converted["key"] = key 305 | converted["_id"] = key 306 | await self.bot.db.logs.insert_one(converted) 307 | log_url = f"{self.bot.config.log_url.strip('/')}{prefix}/{key}" 308 | print(f"Posted thread log: {log_url}") 309 | self.output += f"Posted thread log: {log_url}\n" 310 | 311 | # Threads 312 | for row in c.execute("SELECT * FROM 'threads'"): 313 | tasks.append(convert_thread_log(row)) 314 | 315 | with ctx.typing(): 316 | await asyncio.gather(*tasks) 317 | 318 | await self.bot.config.update() 319 | 320 | async with self.bot.session.post( 321 | "https://hasteb.in/documents", data=self.output 322 | ) as resp: 323 | key = (await resp.json())["key"] 324 | 325 | await ctx.send(f"Done. Logs: https://hasteb.in/{key}") 326 | conn.close() 327 | os.remove("dragorydb.sqlite") 328 | 329 | 330 | def setup(bot): 331 | bot.add_cog(DragoryMigrate(bot)) 332 | -------------------------------------------------------------------------------- /profanity-filter/README.md: -------------------------------------------------------------------------------- 1 | # Profanity Filter 2 | 3 | A simple filter that checks for profanity in a message and then deletes it. Many profanity detection libraries use a hard-coded list of bad words to detect and filter profanity, however this plugin utilises a library that uses a linear support vector machine (SVM) model trained on 200k human-labeled samples of clean and profane text strings. ([`profanity-check`](https://github.com/vzhou842/profanity-check)). 4 | 5 | Artificial intelligence in a discord bot? Heck yeah! 6 | 7 | ## Installation 8 | 9 | `?plugins add kyb3r/modmail-plugins/profanity-filter` -------------------------------------------------------------------------------- /profanity-filter/profanity-filter.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | from discord import Member, Role, TextChannel, DMChannel 4 | from discord.ext import commands 5 | from typing import Union 6 | 7 | from profanity_check import predict 8 | 9 | 10 | class ProfanityFilter(commands.Cog): 11 | """ 12 | A simple filter that checks for profanity in a message and 13 | then deletes it. Many profanity detection libraries use a hard-coded 14 | list of bad words to detect and filter profanity, however this 15 | plugin utilises a library that uses a linear support vector machine 16 | (SVM) model trained on 200k human-labeled samples of clean and profane 17 | text strings. ([`profanity-check`](https://github.com/vzhou842/profanity-check)). 18 | 19 | Artificial intelligence in a discord bot? Heck yeah! 20 | """ 21 | 22 | def __init__(self, bot): 23 | self.bot = bot 24 | self.coll = bot.plugin_db.get_partition(self) 25 | self.enabled = True 26 | self.whitelist = set() 27 | asyncio.create_task(self._set_config()) 28 | 29 | async def _set_config(self): 30 | config = await self.coll.find_one({"_id": "config"}) 31 | self.enabled = config.get("enabled", True) 32 | self.whitelist = set(config.get("whitelist", [])) 33 | 34 | @commands.group(invoke_without_command=True) 35 | @commands.is_owner() 36 | async def profanity(self, ctx, mode: bool): 37 | """Disable or enable the profanity filter. 38 | 39 | Usage: `profanity enable` / `profanity disable` 40 | """ 41 | self.enabled = mode 42 | 43 | await self.coll.update_one( 44 | {"_id": "config"}, {"$set": {"enabled": self.enabled}}, upsert=True 45 | ) 46 | 47 | await ctx.send(("Enabled" if mode else "Disabled") + " the profanity filter.") 48 | 49 | @commands.is_owner() 50 | @profanity.command() 51 | async def whitelist(self, ctx, target: Union[Member, Role, TextChannel]): 52 | """Whitelist a user, role or channel from the profanity filter. 53 | 54 | Usage: `profanity whitelist @dude` 55 | """ 56 | 57 | if target.id in self.whitelist: 58 | self.whitelist.remove(target.id) 59 | removed = True 60 | else: 61 | self.whitelist.add(target.id) 62 | removed = False 63 | 64 | await self.coll.update_one( 65 | {"_id": "config"}, 66 | {"$set": {"whitelist": list(self.whitelist)}}, 67 | upsert=True, 68 | ) 69 | 70 | await ctx.send( 71 | f"{'Un-w' if removed else 'W'}hitelisted " 72 | f"{target.mention} from the profanity filter." 73 | ) 74 | 75 | @commands.Cog.listener() 76 | async def on_message(self, message): 77 | 78 | if not self.enabled: 79 | return 80 | 81 | channel = message.channel 82 | author = message.author 83 | 84 | if isinstance(author, discord.User): # private channel 85 | return 86 | 87 | ids = {author.id, channel.id} | {r.id for r in author.roles} 88 | if self.whitelist.intersection(ids): # anything intersects 89 | return 90 | 91 | profane = bool(predict([message.content])[0]) 92 | if not profane: 93 | return 94 | 95 | await message.delete() 96 | 97 | temp = await channel.send( 98 | f"{author.mention} your message has " 99 | "been deleted for containing profanity." 100 | ) 101 | 102 | await asyncio.sleep(5) 103 | await temp.delete() 104 | 105 | 106 | def setup(bot): 107 | bot.add_cog(ProfanityFilter(bot)) 108 | -------------------------------------------------------------------------------- /profanity-filter/requirements.txt: -------------------------------------------------------------------------------- 1 | scikit-learn==0.20.2 2 | profanity-check 3 | --------------------------------------------------------------------------------