├── .gitignore ├── LICENSE ├── README.md ├── cogs ├── admin.py ├── animals.py ├── automod.py ├── batch.py ├── botadmin.py ├── botconfig.py ├── config.py ├── conversion.py ├── database.py ├── files.py ├── help.py ├── home.py ├── info.py ├── interactions.py ├── logging.py ├── manager.py ├── misc.py ├── mod.py ├── monitor.py ├── music.py ├── rtfm.py ├── server.py ├── spotify.py ├── stats.py ├── tasks.py ├── tracking.py └── utility.py ├── core.py ├── data ├── assets │ ├── FreeSansBold.ttf │ ├── Helvetica-Bold.ttf │ ├── Helvetica.ttf │ ├── Monospace.ttf │ ├── avatar_mask.png │ ├── banner.png │ ├── bargraph.png │ ├── blue.png │ ├── roo.png │ └── snowbanner.png ├── csvs │ ├── axolotl_url.csv │ ├── bat_url.csv │ ├── bear_url.csv │ ├── bird_url.csv │ ├── bunny_url.csv │ ├── dog_url.csv │ ├── eagle_doggo_url.csv │ ├── facts.csv │ ├── fish_url.csv │ ├── jisoo_url.csv │ ├── kate_url.csv │ ├── panda_url.csv │ ├── penguin_url.csv │ ├── pig_url.csv │ ├── possum_url.csv │ ├── raccoon_url.csv │ ├── redpanda_url.csv │ ├── sheep_url.csv │ ├── snek_url.csv │ └── squirrel_url.csv ├── emojis │ ├── bot.csv │ └── roo.csv ├── scripts │ ├── authorization.sql │ ├── config.sql │ ├── music.sql │ ├── servers.sql │ ├── stats.sql │ └── users.sql └── txts │ ├── changelog.txt │ ├── overview.txt │ └── privacy.txt ├── eco_prod.yml ├── eco_test.yml ├── requirements.txt ├── selfhost.md ├── settings ├── cleanup.py ├── constants.py ├── database.py └── setup.py ├── starter.py └── utilities ├── checks.py ├── cleaner.py ├── converters.py ├── db.py ├── decorators.py ├── discord.py ├── exceptions.py ├── formatting.py ├── helpers.py ├── http.py ├── humantime.py ├── images.py ├── override.py ├── pagination.py ├── saver.py ├── spotify.py ├── utils.py └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .ngcvenv 4 | .testervenv 5 | .venv 6 | .vscode 7 | *.DS_Store 8 | *__pycache__ 9 | *.sock 10 | *.json 11 | *.log 12 | *.rdb 13 | *.0 14 | # These just contain logging files 15 | /data/json 16 | /data/logs 17 | /data/pm2 18 | # This is just a bot todo list file 19 | /data/txts/todo.txt 20 | # Work in progress 21 | /aaaaa 22 | # My secret keys 23 | /config.py 24 | 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Hecate 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 | -------------------------------------------------------------------------------- /cogs/animals.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import discord 3 | import random 4 | 5 | from discord.ext import commands 6 | 7 | from utilities import checks 8 | from utilities import decorators 9 | 10 | 11 | async def read_shuffle_and_send(animal, ctx): 12 | """Rewrote wuacks shitty code but yeah thanks anyways wuack""" 13 | with open(f"./data/csvs/{animal}_url.csv", "r") as f: 14 | urls = list(csv.reader(f)) 15 | 16 | embed = discord.Embed( 17 | title=f"{animal.capitalize()}!", color=ctx.bot.config.EMBED_COLOR 18 | ) 19 | embed.set_image(url=random.choice(urls)[0]) 20 | await ctx.send(embed=embed) 21 | 22 | 23 | async def setup(bot): 24 | await bot.add_cog(Animals(bot)) 25 | 26 | 27 | class Animals(commands.Cog): 28 | "Get animal facts and pictures :)" 29 | 30 | def __init__(self, bot): 31 | self.bot = bot 32 | 33 | @decorators.command( 34 | brief="Random picture of a cat", 35 | aliases=[ 36 | "cats", 37 | "meow", 38 | ], 39 | ) 40 | @checks.cooldown() 41 | async def cat(self, ctx): 42 | robot = ctx.guild.me 43 | async with ctx.channel.typing(): 44 | data = await self.bot.http_utils.get( 45 | "http://aws.random.cat/meow", res_method="json" 46 | ) 47 | 48 | embed = discord.Embed(title="Meow", color=robot.color) 49 | embed.set_image(url=data["file"]) 50 | embed.set_footer(text="http://random.cat/") 51 | 52 | await ctx.send(embed=embed) 53 | 54 | @decorators.command(brief="Random picture of a fox") 55 | @checks.cooldown() 56 | async def fox(self, ctx): 57 | """Random picture of a fox""" 58 | robot = ctx.guild.me 59 | async with ctx.channel.typing(): 60 | data = await self.bot.http_utils.get( 61 | "https://randomfox.ca/floof/", res_method="json" 62 | ) 63 | 64 | embed = discord.Embed(title="Floof", color=robot.color) 65 | embed.set_image(url=data["image"]) 66 | embed.set_footer(text="https://randomfox.ca/") 67 | 68 | await ctx.send(embed=embed) 69 | 70 | @decorators.command( 71 | brief="Random picture of a duck", aliases=["quack", "ducc", "ducks"] 72 | ) 73 | @checks.cooldown() 74 | async def duck(self, ctx): 75 | """Random picture of a duck""" 76 | robot = ctx.guild.me 77 | async with ctx.channel.typing(): 78 | data = await self.bot.http_utils.get( 79 | "https://random-d.uk/api/random", res_method="json" 80 | ) 81 | embed = discord.Embed(title="Quack", color=robot.color) 82 | embed.set_image(url=data["url"]) 83 | embed.set_footer(text="https://random-d.uk/") 84 | 85 | await ctx.send(embed=embed) 86 | 87 | @decorators.command( 88 | brief="Random picture of a raccoon", aliases=["racc", "raccoons"] 89 | ) 90 | @checks.cooldown() 91 | async def raccoon(self, ctx): 92 | """Random picture of a raccoon""" 93 | await read_shuffle_and_send("raccoon", ctx) 94 | 95 | @decorators.command( 96 | brief="Random picture of a dog", 97 | aliases=[ 98 | "dogs", 99 | "bark", 100 | ], 101 | ) 102 | @checks.cooldown() 103 | async def dog(self, ctx): 104 | """Random picture of a dog""" 105 | await read_shuffle_and_send("dog", ctx) 106 | 107 | @decorators.command( 108 | brief="Random picture of a squirrel", 109 | aliases=[ 110 | "squ", 111 | "squirrels", 112 | ], 113 | ) 114 | @checks.cooldown() 115 | async def squirrel(self, ctx): 116 | """Random picture of a squirrel""" 117 | await read_shuffle_and_send("squirrel", ctx) 118 | 119 | @decorators.command( 120 | brief="Random picture of a bear", 121 | aliases=[ 122 | "bears", 123 | ], 124 | ) 125 | @checks.cooldown() 126 | async def bear(self, ctx): 127 | """Random picture of a bear""" 128 | await read_shuffle_and_send("bear", ctx) 129 | 130 | @decorators.command( 131 | brief="Random picture of a possum", 132 | aliases=[ 133 | "possums", 134 | ], 135 | ) 136 | @checks.cooldown() 137 | async def possum(self, ctx): 138 | """Random picture of a possum""" 139 | await read_shuffle_and_send("possum", ctx) 140 | 141 | @decorators.command( 142 | brief="Random picture of an axolotl", 143 | aliases=[ 144 | "axolotls", 145 | ], 146 | ) 147 | @checks.cooldown() 148 | async def axolotl(self, ctx): 149 | """Random picture of an axolotl""" 150 | await read_shuffle_and_send("axolotl", ctx) 151 | 152 | @decorators.command(brief="Random picture of a pig", aliases=["pigs"]) 153 | @checks.cooldown() 154 | async def pig(self, ctx): 155 | """Random picture of a pig""" 156 | await read_shuffle_and_send("pig", ctx) 157 | 158 | @decorators.command(brief="Random picture of a penguin", aliases=["penguins"]) 159 | @checks.cooldown() 160 | async def penguin(self, ctx): 161 | """Random picture of a penguin""" 162 | await read_shuffle_and_send("penguin", ctx) 163 | 164 | @decorators.command( 165 | brief="Random picture of a bunny", aliases=["bunnies", "rabbit"] 166 | ) 167 | @checks.cooldown() 168 | async def bunny(self, ctx): 169 | """Random picture of a bunny""" 170 | await read_shuffle_and_send("bunny", ctx) 171 | 172 | @decorators.command(brief="Random picture of a snake", aliases=["snek", "snakes"]) 173 | @checks.cooldown() 174 | async def snake(self, ctx): 175 | """Random picture of a snake""" 176 | await read_shuffle_and_send("snake", ctx) 177 | 178 | @decorators.command(brief="Random picture of a sheep", aliases=["shep"]) 179 | @checks.cooldown() 180 | async def sheep(self, ctx): 181 | """Random picture of a sheep""" 182 | await read_shuffle_and_send("sheep", ctx) 183 | 184 | @decorators.command( 185 | brief="Random picture of a panda (may be a redpanda)", aliases=["pandas"] 186 | ) 187 | @checks.cooldown() 188 | async def panda(self, ctx): 189 | """Random picture of a panda (may be a redpanda)""" 190 | await read_shuffle_and_send("panda", ctx) 191 | 192 | @decorators.command(brief="Random picture of a redpanda", aliases=["redpandas"]) 193 | @checks.cooldown() 194 | async def redpanda(self, ctx): 195 | """ "Random picture of a redpanda""" 196 | await read_shuffle_and_send("redpanda", ctx) 197 | 198 | @decorators.command(brief="Random picture of a birb", aliases=["birb"]) 199 | @checks.cooldown() 200 | async def bird(self, ctx): 201 | """Random picture of a birb""" 202 | await read_shuffle_and_send("bird", ctx) 203 | 204 | @decorators.command(brief="Random animal fact", aliases=["anifact"]) 205 | @checks.cooldown() 206 | async def animalfact(self, ctx): 207 | """Random animal fact""" 208 | with open("data/csvs/facts.csv") as f: 209 | await ctx.rep_or_ref(random.choice(f.readlines())) 210 | -------------------------------------------------------------------------------- /cogs/home.py: -------------------------------------------------------------------------------- 1 | import io 2 | import discord 3 | import PIL as pillow 4 | 5 | from discord.ext import commands 6 | 7 | from utilities import utils 8 | from utilities import checks 9 | from utilities import converters 10 | from utilities import decorators 11 | 12 | HOME = 805638877762420786 # Support Server 13 | WELCOME = 847612677013766164 # Welcome channel 14 | GENERAL = 805638877762420789 # Chatting channel 15 | TESTING = 871900448955727902 # Testing channel 16 | ANNOUNCE = 852361774871216150 # Announcement channel 17 | 18 | 19 | async def setup(bot): 20 | await bot.add_cog(Home(bot)) 21 | 22 | 23 | class Home(commands.Cog): 24 | """ 25 | Server specific cog. 26 | """ 27 | 28 | def __init__(self, bot): 29 | self.bot = bot 30 | 31 | @property 32 | def home(self): 33 | return self.bot.get_guild(HOME) 34 | 35 | @property 36 | def welcomer(self): 37 | return self.bot.get_channel(WELCOME) 38 | 39 | @property 40 | def booster(self): 41 | return self.bot.get_channel(TESTING) 42 | 43 | ##################### 44 | ## Event Listeners ## 45 | ##################### 46 | 47 | @commands.Cog.listener() 48 | @decorators.wait_until_ready() 49 | @decorators.event_check(lambda s, m: m.guild.id == HOME) 50 | async def on_member_join(self, member): 51 | if self.bot.tester is False: 52 | await self.welcome(member) 53 | 54 | async def welcome(self, member): 55 | byteav = await member.display_avatar.with_size(128).read() 56 | buffer = await self.bot.loop.run_in_executor( 57 | None, self.create_welcome_image, byteav, member 58 | ) 59 | dfile = discord.File(fp=buffer, filename="welcome.png") 60 | 61 | embed = discord.Embed( 62 | title=f"WELCOME TO {member.guild.name.upper()}!", 63 | description=f"> Click [here]({self.bot.oauth}) to invite {self.bot.user.mention}\n" 64 | f"> Click [here](https://discord.com/channels/{HOME}/{ANNOUNCE}) for announcements.\n" 65 | f"> Click [here](https://discord.com/channels/{HOME}/{GENERAL}) to start chatting.\n" 66 | f"> Click [here](https://discord.com/channels/{HOME}/{TESTING}) to run commands.\n", 67 | timestamp=discord.utils.utcnow(), 68 | color=self.bot.mode.EMBED_COLOR, 69 | url=self.bot.oauth, 70 | ) 71 | embed.set_thumbnail(url=utils.get_icon(member.guild)) 72 | embed.set_image(url="attachment://welcome.png") 73 | embed.set_footer(text=f"Server Population: {member.guild.member_count} ") 74 | await self.welcomer.send(f"{member.mention}", file=dfile, embed=embed) 75 | 76 | def create_welcome_image(self, bytes_avatar, member): 77 | banner = pillow.Image.open("./data/assets/banner.png").resize((725, 225)) 78 | blue = pillow.Image.open("./data/assets/blue.png") 79 | mask = pillow.Image.open("./data/assets/avatar_mask.png") 80 | 81 | avatar = pillow.Image.open(io.BytesIO(bytes_avatar)) 82 | 83 | try: 84 | composite = pillow.Image.composite(avatar, mask, mask) 85 | except ValueError: # Sometimes the avatar isn't resized properly 86 | avatar = avatar.resize((128, 128)) 87 | composite = pillow.Image.composite(avatar, mask, mask) 88 | blue.paste(im=composite, box=(0, 0), mask=composite) 89 | banner.paste(im=blue, box=(40, 45), mask=blue.split()[3]) 90 | 91 | text = "{}\nWelcome to {}".format(str(member), member.guild.name) 92 | draw = pillow.ImageDraw.Draw(banner) 93 | font = pillow.ImageFont.truetype( 94 | "./data/assets/FreeSansBold.ttf", 40, encoding="utf-8" 95 | ) 96 | draw.text((190, 60), text, (211, 211, 211), font=font) 97 | buffer = io.BytesIO() 98 | banner.save(buffer, "png") # 'save' function for PIL 99 | buffer.seek(0) 100 | return buffer 101 | 102 | async def thank_booster(self, member): 103 | byteav = await member.display_avatar.with_size(128).read() 104 | buffer = await self.bot.loop.run_in_executor( 105 | None, self.create_booster_image, byteav, member 106 | ) 107 | dfile = discord.File(fp=buffer, filename="booster.png") 108 | 109 | embed = discord.Embed( 110 | title=f"Thank you for boosting!", 111 | # description=f"> Click [here]({self.bot.oauth}) to invite {self.bot.user.mention}\n" 112 | # f"> Click [here](https://discord.com/channels/{HOME}/{ANNOUNCE}) for announcements.\n" 113 | # f"> Click [here](https://discord.com/channels/{HOME}/{GENERAL}) to start chatting.\n" 114 | # f"> Click [here](https://discord.com/channels/{HOME}/{TESTING}) to run commands.\n", 115 | timestamp=discord.utils.utcnow(), 116 | color=self.bot.mode.EMBED_COLOR, 117 | url=self.bot.oauth, 118 | ) 119 | embed.set_thumbnail(url=utils.get_icon(member.guild)) 120 | embed.set_image(url="attachment://booster.png") 121 | embed.set_footer( 122 | text=f"Server Boosts: {member.guild.premium_subscription_count} " 123 | ) 124 | await self.booster.send(f"{member.mention}", file=dfile, embed=embed) 125 | 126 | def create_booster_image(self, bytes_avatar, member): 127 | banner = pillow.Image.open("./data/assets/roo.png") # .resize((725, 225)) 128 | blue = pillow.Image.open("./data/assets/blue.png") 129 | mask = pillow.Image.open("./data/assets/avatar_mask.png") 130 | 131 | avatar = pillow.Image.open(io.BytesIO(bytes_avatar)) 132 | 133 | try: 134 | composite = pillow.Image.composite(avatar, mask, mask) 135 | except ValueError: # Sometimes the avatar isn't resized properly 136 | avatar = avatar.resize((128, 128)) 137 | composite = pillow.Image.composite(avatar, mask, mask) 138 | blue.paste(im=composite, box=(0, 0), mask=composite) 139 | banner.paste(im=blue, box=(40, 45), mask=blue.split()[3]) 140 | 141 | # text = "{}\nWelcome to {}".format(str(member), member.guild.name) 142 | # draw = pillow.ImageDraw.Draw(banner) 143 | # font = pillow.ImageFont.truetype( 144 | # "./data/assets/FreeSansBold.ttf", 40, encoding="utf-8" 145 | # ) 146 | # draw.text((190, 60), text, (211, 211, 211), font=font) 147 | buffer = io.BytesIO() 148 | banner.save(buffer, "png") # 'save' function for PIL 149 | buffer.seek(0) 150 | return buffer 151 | 152 | @decorators.command(hidden=True, brief="Test the welcome", name="welcome") 153 | @decorators.is_home(HOME) 154 | @checks.has_perms(manage_guild=True) 155 | @checks.bot_has_perms(embed_links=True, attach_files=True) 156 | async def _welcome(self, ctx, user: converters.DiscordMember = None): 157 | user = user or ctx.author 158 | await self.welcome(user) 159 | 160 | @decorators.command(hidden=True, brief="Test the boost", name="booster") 161 | @decorators.is_home(HOME) 162 | @checks.has_perms(manage_guild=True) 163 | @checks.bot_has_perms(embed_links=True, attach_files=True) 164 | async def _booster(self, ctx, user: converters.DiscordMember = None): 165 | user = user or ctx.author 166 | await self.thank_booster(user) 167 | -------------------------------------------------------------------------------- /cogs/interactions.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | import json 3 | import discord 4 | from discord.ext import commands 5 | from discord import app_commands 6 | 7 | from utilities import cleaner, pagination 8 | 9 | 10 | async def setup(bot): 11 | await bot.add_cog(Interactions(bot)) 12 | bot.tree.add_command(avatar, guild=discord.Object(805638877762420786)) 13 | bot.tree.add_command(_ascii, guild=discord.Object(805638877762420786)) 14 | 15 | 16 | class Interactions(commands.Cog): 17 | """ 18 | Hub for slash and context menu commands. 19 | """ 20 | 21 | def __init__(self, bot): 22 | self.bot = bot 23 | 24 | @app_commands.command(name="prefix", description="Show my current prefixes.") 25 | async def prefix(self, interaction): 26 | 27 | prefixes = self.bot.get_guild_prefixes(interaction.guild) 28 | mention_fmt = interaction.guild.me.display_name 29 | # Lets remove the mentions and replace with @name 30 | del prefixes[0] 31 | del prefixes[0] 32 | 33 | prefixes.insert(0, f"@{mention_fmt}") 34 | 35 | await interaction.response.send_message( 36 | f"My current prefix{' is' if len(prefixes) == 1 else 'es are'} `{', '.join(prefixes)}`", 37 | ephemeral=True, 38 | ) 39 | 40 | # @app_commands.context_menu() 41 | # async def avatar(interaction, user: discord.User): 42 | # await interaction.response.send_message(user.display_avatar.url) 43 | 44 | 45 | @app_commands.context_menu() 46 | @app_commands.guilds(discord.Object(id=805638877762420786)) 47 | async def avatar(interaction, user: discord.Member): 48 | await interaction.response.send_message(user.display_avatar.url) 49 | 50 | 51 | @app_commands.context_menu(name="Ascii") 52 | @app_commands.guilds(discord.Object(id=805638877762420786)) 53 | async def _ascii(interaction, user: discord.Member): 54 | if user != interaction.client: 55 | image_bytes = await interaction.client.http_utils.get( 56 | user.display_avatar.url, res_method="read" 57 | ) 58 | path = BytesIO(image_bytes) 59 | image = interaction.client.get_cog("Misc").ascii_image(path) 60 | await interaction.response.send_message( 61 | file=discord.File(image, filename="matrix.png") 62 | ) 63 | else: 64 | image_bytes = await interaction.client.http_utils.get( 65 | interaction.client.display_avatar.url, res_method="read" 66 | ) 67 | path = BytesIO(image_bytes) 68 | image = interaction.client.get_cog("Misc").ascii_image(path) 69 | await interaction.response.send_message( 70 | file=discord.File(image, filename="matrix.png") 71 | ) 72 | -------------------------------------------------------------------------------- /cogs/misc.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import asyncio 3 | import random 4 | import numpy as np 5 | 6 | from PIL import Image, ImageDraw, ImageFont 7 | from discord.ext import commands 8 | from discord import app_commands 9 | from io import BytesIO 10 | 11 | from utilities import checks 12 | from utilities import converters 13 | from utilities import decorators 14 | 15 | 16 | async def setup(bot): 17 | await bot.add_cog(Misc(bot)) 18 | 19 | 20 | class Misc(commands.Cog): 21 | """ 22 | Miscellaneous stuff. 23 | """ 24 | 25 | def __init__(self, bot): 26 | self.bot = bot 27 | 28 | @decorators.command( 29 | brief="Finds the 'best' definition of a word", aliases=["urban"] 30 | ) 31 | @checks.cooldown() 32 | async def define(self, ctx, *, search: commands.clean_content): 33 | """ 34 | Usage: {0}define 35 | Alias: {0}urban 36 | Output: 37 | Attempts to fetch an urban dictionary 38 | definition based off of your search query. 39 | """ 40 | async with ctx.channel.typing(): 41 | try: 42 | url = await self.bot.http_utils.get( 43 | f"https://api.urbandictionary.com/v0/define?term={search}", 44 | res_method="json", 45 | ) 46 | except Exception: 47 | return await ctx.send("Urban API returned invalid data... fuck them.") 48 | 49 | if not url: 50 | return await ctx.send("I think the API broke...") 51 | 52 | if not len(url["list"]): 53 | return await ctx.send("Couldn't find your search in the dictionary...") 54 | 55 | result = sorted( 56 | url["list"], reverse=True, key=lambda g: int(g["thumbs_up"]) 57 | )[0] 58 | 59 | definition = result["definition"] 60 | if len(definition) >= 2000: 61 | definition = definition[:2000] 62 | definition = definition.rsplit(" ", 1)[0] 63 | definition += "..." 64 | 65 | await ctx.send_or_reply( 66 | f"📚 Definitions for **{result['word']}**```yaml\n{definition}```" 67 | ) 68 | 69 | def ascii_image(self, path): 70 | image = Image.open(path) 71 | sc = 0.2 72 | gcf = 0.2 73 | bgcolor = "#060e16" 74 | re_list = list(" .,:;irsXA253hMHGS#9B&@") 75 | chars = np.asarray(re_list) 76 | font = ImageFont.load_default() 77 | font = ImageFont.truetype("./data/assets/Monospace.ttf", 10) 78 | letter_width = font.getsize("x")[0] 79 | letter_height = font.getsize("x")[1] 80 | wcf = letter_height / letter_width 81 | img = image.convert("RGBA") 82 | 83 | width_by_letter = round(img.size[0] * sc * wcf) 84 | height_by_letter = round(img.size[1] * sc) 85 | s = (width_by_letter, height_by_letter) 86 | img = img.resize(s) 87 | img = np.sum(np.asarray(img), axis=2) 88 | img -= img.min() 89 | img = (1.0 - img / img.max()) ** gcf * (chars.size - 1) 90 | lines = ("".join(r) for r in chars[len(chars) - img.astype(int) - 1]) 91 | new_img_width = letter_width * width_by_letter 92 | new_img_height = letter_height * height_by_letter 93 | new_img = Image.new("RGBA", (new_img_width, new_img_height), bgcolor) 94 | draw = ImageDraw.Draw(new_img) 95 | y = 0 96 | for line in lines: 97 | draw.text((0, y), line, "#FFFFFF", font=font) 98 | y += letter_height 99 | 100 | buffer = BytesIO() 101 | new_img.save(buffer, "png") # 'save' function for PIL 102 | buffer.seek(0) 103 | return buffer 104 | 105 | @decorators.command( 106 | name="matrix", 107 | aliases=["ascii", "print"], 108 | brief="Generate a dot matrix of an image.", 109 | ) 110 | @checks.cooldown() 111 | async def matrix(self, ctx, *, url=None): 112 | """ 113 | Usage: {0}print [user, image url, or image attachment] 114 | Aliases: {0}matrix, {0}ascii 115 | Output: Creates a dot matrix of the passed image. 116 | Notes: Accepts a url or picks the first attachment. 117 | """ 118 | 119 | if url == None and len(ctx.message.attachments) == 0: 120 | await ctx.send_or_reply( 121 | "Usage: `{}matrix [user, url, or attachment]`".format(ctx.prefix) 122 | ) 123 | return 124 | 125 | if url == None: 126 | url = ctx.message.attachments[0].url 127 | 128 | # Let's check if the "url" is actually a user 129 | try: 130 | test_user = await converters.DiscordUser().convert(ctx, url) 131 | url = test_user.display_avatar.url 132 | except Exception: 133 | pass 134 | 135 | message = await ctx.load("Generating dot matrix...") 136 | 137 | try: 138 | image_bytes = await self.bot.http_utils.get(url, res_method="read") 139 | except Exception: 140 | await message.edit(content="Invalid url or attachment.") 141 | return 142 | 143 | path = BytesIO(image_bytes) 144 | if not path: 145 | await message.edit(content="Invalid url or attachment.") 146 | return 147 | 148 | image = self.ascii_image(path) 149 | await ctx.rep_or_ref(file=discord.File(image, filename="matrix.png")) 150 | await message.delete() 151 | 152 | @decorators.command(brief="Just try it and see.") 153 | @checks.cooldown() 154 | async def size(self, ctx, *, user: converters.DiscordUser = None): 155 | user = user or ctx.author 156 | 157 | def find_size(snowflake): 158 | s = 0 159 | while snowflake: 160 | snowflake //= 10 161 | s += snowflake % 10 162 | return (s % 10) * 2 163 | 164 | size = find_size(user.id) 165 | if user.id == self.bot.developer_id: 166 | size *= 5 167 | 168 | await ctx.send_or_reply(f"**{user.display_name}'s** size: 8{'=' * size}D") 169 | -------------------------------------------------------------------------------- /cogs/rtfm.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import re 4 | import zlib 5 | import discord 6 | from discord.ext import commands 7 | 8 | 9 | async def setup(bot): 10 | await bot.add_cog(RTFM(bot)) 11 | 12 | 13 | class SphinxObjectFileReader: 14 | # Inspired by Sphinx's InventoryFileReader 15 | BUFSIZE = 16 * 1024 16 | 17 | def __init__(self, buffer): 18 | self.stream = io.BytesIO(buffer) 19 | 20 | def readline(self): 21 | return self.stream.readline().decode("utf-8") 22 | 23 | def skipline(self): 24 | self.stream.readline() 25 | 26 | def read_compressed_chunks(self): 27 | decompressor = zlib.decompressobj() 28 | while True: 29 | chunk = self.stream.read(self.BUFSIZE) 30 | if len(chunk) == 0: 31 | break 32 | yield decompressor.decompress(chunk) 33 | yield decompressor.flush() 34 | 35 | def read_compressed_lines(self): 36 | buf = b"" 37 | for chunk in self.read_compressed_chunks(): 38 | buf += chunk 39 | pos = buf.find(b"\n") 40 | while pos != -1: 41 | yield buf[:pos].decode("utf-8") 42 | buf = buf[pos + 1 :] 43 | pos = buf.find(b"\n") 44 | 45 | 46 | class RTFM(commands.Cog): 47 | """Copied and pasted from RDanny.""" 48 | 49 | def __init__(self, bot): 50 | self.bot = bot 51 | 52 | def finder(self, text, collection, *, key=None, lazy=True): 53 | suggestions = [] 54 | text = str(text) 55 | pat = ".*?".join(map(re.escape, text)) 56 | regex = re.compile(pat, flags=re.IGNORECASE) 57 | for item in collection: 58 | to_search = key(item) if key else item 59 | r = regex.search(to_search) 60 | if r: 61 | suggestions.append((len(r.group()), r.start(), item)) 62 | 63 | def sort_key(tup): 64 | if key: 65 | return tup[0], tup[1], key(tup[2]) 66 | return tup 67 | 68 | if lazy: 69 | return (z for _, _, z in sorted(suggestions, key=sort_key)) 70 | else: 71 | return [z for _, _, z in sorted(suggestions, key=sort_key)] 72 | 73 | def parse_object_inv(self, stream, url): 74 | # key: URL 75 | # n.b.: key doesn't have `discord` or `discord.ext.commands` namespaces 76 | result = {} 77 | 78 | # first line is version info 79 | inv_version = stream.readline().rstrip() 80 | 81 | if inv_version != "# Sphinx inventory version 2": 82 | raise RuntimeError("Invalid objects.inv file version.") 83 | 84 | # next line is "# Project: " 85 | # then after that is "# Version: " 86 | projname = stream.readline().rstrip()[11:] 87 | version = stream.readline().rstrip()[11:] 88 | 89 | # next line says if it's a zlib header 90 | line = stream.readline() 91 | if "zlib" not in line: 92 | raise RuntimeError("Invalid objects.inv file, not z-lib compatible.") 93 | 94 | # This code mostly comes from the Sphinx repository. 95 | entry_regex = re.compile(r"(?x)(.+?)\s+(\S*:\S*)\s+(-?\d+)\s+(\S+)\s+(.*)") 96 | for line in stream.read_compressed_lines(): 97 | match = entry_regex.match(line.rstrip()) 98 | if not match: 99 | continue 100 | 101 | name, directive, prio, location, dispname = match.groups() 102 | domain, _, subdirective = directive.partition(":") 103 | if directive == "py:module" and name in result: 104 | # From the Sphinx Repository: 105 | # due to a bug in 1.1 and below, 106 | # two inventory entries are created 107 | # for Python modules, and the first 108 | # one is correct 109 | continue 110 | 111 | # Most documentation pages have a label 112 | if directive == "std:doc": 113 | subdirective = "label" 114 | 115 | if location.endswith("$"): 116 | location = location[:-1] + name 117 | 118 | key = name if dispname == "-" else dispname 119 | prefix = f"{subdirective}:" if domain == "std" else "" 120 | 121 | if projname == "discord.py": 122 | key = key.replace("discord.ext.commands.", "").replace("discord.", "") 123 | 124 | result[f"{prefix}{key}"] = os.path.join(url, location) 125 | 126 | return result 127 | 128 | async def build_rtfm_lookup_table(self, page_types): 129 | cache = {} 130 | for key, page in page_types.items(): 131 | sub = cache[key] = {} 132 | async with self.bot.session.get(page + "/objects.inv") as resp: 133 | if resp.status != 200: 134 | raise RuntimeError( 135 | "Cannot build rtfm lookup table, try again later." 136 | ) 137 | 138 | stream = SphinxObjectFileReader(await resp.read()) 139 | cache[key] = self.parse_object_inv(stream, page) 140 | 141 | self._rtfm_cache = cache 142 | 143 | async def do_rtfm(self, ctx, key, obj): 144 | page_types = { 145 | "latest": "https://discordpy.readthedocs.io/en/latest", 146 | "python": "https://docs.python.org/3", 147 | "master": "https://discordpy.readthedocs.io/en/master", 148 | } 149 | 150 | if obj is None: 151 | await ctx.send(page_types[key]) 152 | return 153 | 154 | if not hasattr(self, "_rtfm_cache"): 155 | await ctx.trigger_typing() 156 | await self.build_rtfm_lookup_table(page_types) 157 | 158 | obj = re.sub(r"^(?:discord\.(?:ext\.)?)?(?:commands\.)?(.+)", r"\1", obj) 159 | 160 | if key.startswith("latest"): 161 | # point the abc.Messageable types properly: 162 | q = obj.lower() 163 | for name in dir(discord.abc.Messageable): 164 | if name[0] == "_": 165 | continue 166 | if q == name: 167 | obj = f"abc.Messageable.{name}" 168 | break 169 | 170 | cache = list(self._rtfm_cache[key].items()) 171 | 172 | matches = self.finder(obj, cache, key=lambda t: t[0], lazy=False)[:8] 173 | 174 | e = discord.Embed(color=self.bot.mode.EMBED_COLOR) 175 | if len(matches) == 0: 176 | return await ctx.send("Could not find anything. Sorry.") 177 | 178 | e.description = "\n".join(f"[`{key}`]({url})" for key, url in matches) 179 | await ctx.send_or_reply(embed=e) 180 | 181 | @commands.group(aliases=["rtfd"], invoke_without_command=True) 182 | async def rtfm(self, ctx, *, obj: str = None): 183 | """Gives you a documentation link for a discord.py entity. 184 | Events, objects, and functions are all supported through 185 | a cruddy fuzzy algorithm. 186 | """ 187 | await self.do_rtfm(ctx, "latest", obj) 188 | 189 | @rtfm.command(name="python", aliases=["py"]) 190 | async def rtfm_python(self, ctx, *, obj: str = None): 191 | """Gives you a documentation link for a Python entity.""" 192 | await self.do_rtfm(ctx, "python", obj) 193 | 194 | @rtfm.command(name="master", aliases=["2.0"]) 195 | async def rtfm_master(self, ctx, *, obj: str = None): 196 | """Gives you a documentation link for a discord.py entity (master branch)""" 197 | await self.do_rtfm(ctx, "master", obj) 198 | -------------------------------------------------------------------------------- /cogs/spotify.py: -------------------------------------------------------------------------------- 1 | import typing 2 | import discord 3 | from discord.ext import commands, tasks 4 | 5 | from utilities import checks 6 | from utilities import converters 7 | from utilities import decorators 8 | from utilities import pagination 9 | 10 | from utilities import spotify 11 | from utilities.discord import oauth as discord_oauth 12 | 13 | 14 | async def setup(bot): 15 | await bot.add_cog(Spotify(bot)) 16 | 17 | 18 | class Spotify(commands.Cog): 19 | """ 20 | Spotify account statistics. 21 | """ 22 | 23 | def __init__(self, bot): 24 | self.bot = bot 25 | self.time_map = { 26 | "short_term": "month", 27 | "medium_term": "semester", 28 | "long_term": "year", 29 | } 30 | if self.bot.production: 31 | self.live_stats.start() 32 | 33 | @tasks.loop(seconds=1) 34 | async def live_stats(self): 35 | 36 | r = await self.bot.http_utils.post( 37 | "http://localhost:3000/stats", 38 | data={ 39 | "members": len(self.bot.users), 40 | "servers": len(self.bot.guilds), 41 | "messages": sum(self.bot.message_stats.values()), 42 | }, 43 | headers={"Content-Type": "application/json"}, 44 | res_method="json", 45 | ) 46 | 47 | def truncate(self, string, max_chars=20): 48 | return (string[: max_chars - 3] + "...") if len(string) > max_chars else string 49 | 50 | def hyperlink(self, name, url, max_chars=20): 51 | return f"**[{self.truncate(name, max_chars)}]({url})**" 52 | 53 | def format_track(self, track): 54 | return self.hyperlink(track["name"], track["external_urls"]["spotify"]) 55 | 56 | def format_artists(self, artists): 57 | artists = artists[:3] # more than 3 artists looks bad on embed 58 | max_chars = 40 // len(artists) 59 | return ", ".join( 60 | self.hyperlink( 61 | artist["name"], artist["external_urls"]["spotify"], max_chars 62 | ) 63 | for artist in artists 64 | ) 65 | 66 | async def get_spotify_user(self, ctx, user): 67 | sp_user = await spotify.User.load(user.id) 68 | if not sp_user: 69 | if user == ctx.author: 70 | view = discord.ui.View() 71 | button = discord.ui.Button( 72 | label="Click here to connect your Spotify account!", 73 | url=discord_oauth.get_auth_url(), 74 | ) 75 | view.add_item(button) 76 | await ctx.fail( 77 | "You have not connected your Spotify account yet.", view=view 78 | ) 79 | 80 | else: 81 | await ctx.fail( 82 | f"User **{user}** `{user.id}` has not connected their Spotify account yet." 83 | ) 84 | return sp_user 85 | 86 | @decorators.group( 87 | name="spotify", 88 | aliases=["sp", "sf"], 89 | brief="Manage spotify stats and playlists", 90 | ) 91 | @checks.cooldown() 92 | async def _sp(self, ctx): 93 | if not ctx.invoked_subcommand: 94 | sp_user = await self.get_spotify_user(ctx, ctx.author) 95 | if sp_user: 96 | await ctx.success("You have already connected your spotify account.") 97 | 98 | @_sp.command(brief="Get top spotify tracks.", aliases=["tt"]) 99 | async def top_tracks( 100 | self, 101 | ctx, 102 | user: typing.Optional[converters.DiscordMember], 103 | time_frame: converters.SpotifyTimeFrame = "short_term", 104 | ): 105 | user = user or ctx.author 106 | sp_user = await self.get_spotify_user(ctx, user) 107 | if not sp_user: 108 | return 109 | 110 | top_tracks = await sp_user.get_top_tracks(time_range=time_frame) 111 | print(top_tracks) 112 | 113 | if not top_tracks.get("items"): 114 | await ctx.fail( 115 | f"{f'User **{user}** `{user.id}` has' if user != ctx.author else 'You have'} no top tracks." 116 | ) 117 | return 118 | 119 | entries = [ 120 | f"{self.format_track(track)} by {self.format_artists(track['artists'])}" 121 | for track in top_tracks["items"] 122 | ] 123 | 124 | p = pagination.SimplePages( 125 | entries=entries, 126 | per_page=10, 127 | ) 128 | p.embed.title = f"{user.display_name}'s top Spotify tracks in the past {self.time_map[time_frame]}." 129 | p.embed.set_thumbnail(url=spotify.CONSTANTS.WHITE_ICON) 130 | await p.start(ctx) 131 | 132 | @_sp.command(brief="Get top spotify artists.", aliases=["ta"]) 133 | async def top_artists( 134 | self, 135 | ctx, 136 | user: typing.Optional[converters.DiscordMember], 137 | time_frame: converters.SpotifyTimeFrame = "short_term", 138 | ): 139 | user = user or ctx.author 140 | sp_user = await self.get_spotify_user(ctx, user) 141 | if not sp_user: 142 | return 143 | 144 | top_artists = await sp_user.get_top_artists() 145 | 146 | if not top_artists.get("items"): 147 | await ctx.fail( 148 | f"{f'User **{user}** `{user.id}` has' if user != ctx.author else 'You have'} no top artists." 149 | ) 150 | return 151 | 152 | entries = [ 153 | self.hyperlink( 154 | artist["name"], artist["external_urls"]["spotify"], max_chars=50 155 | ) 156 | for artist in top_artists["items"] 157 | ] 158 | 159 | p = pagination.SimplePages( 160 | entries=entries, 161 | per_page=10, 162 | ) 163 | p.embed.title = f"{user.display_name}'s top Spotify artists in the past {self.time_map[time_frame]}." 164 | p.embed.set_thumbnail(url=spotify.CONSTANTS.WHITE_ICON) 165 | await p.start(ctx) 166 | 167 | @_sp.command(brief="Get recent Spotify listens", aliases=["r"]) 168 | async def recent(self, ctx, *, user: converters.DiscordMember = None): 169 | user = user or ctx.author 170 | sp_user = await self.get_spotify_user(ctx, user) 171 | if not sp_user: 172 | return 173 | 174 | recent = await sp_user.get_recently_played() 175 | print(recent) 176 | if not recent.get("items"): 177 | await ctx.fail( 178 | f"{f'User **{user}** `{user.id}` has' if user != ctx.author else 'You have'} no recent tracks." 179 | ) 180 | return 181 | 182 | entries = [ 183 | f"{self.format_track(item['track'])} by {self.format_artists(item['track']['artists'])}" 184 | for item in recent["items"] 185 | ] 186 | 187 | p = pagination.SimplePages( 188 | entries=entries, 189 | per_page=10, 190 | ) 191 | p.embed.title = f"{user.display_name}'s recently played Spotify tracks." 192 | p.embed.set_thumbnail(url=spotify.CONSTANTS.WHITE_ICON) 193 | await p.start(ctx) 194 | 195 | @_sp.command(brief="Get current song data") 196 | async def plst(self, ctx, *, user: converters.DiscordMember = None): 197 | sp_user = await self.get_spotify_user(ctx, ctx.author) 198 | url = "https://open.spotify.com/playlist/490Su62TmufWRkdxmggDnY?si=37542cff68fb4899" 199 | url = "https://open.spotify.com/playlist/6K9uHsMwuRGRWvHLNK4rT2?si=f7be2003e315494b" 200 | uri = spotify.url_to_uri(url) 201 | data = await spotify.get_playlist(uri) 202 | del data["tracks"] 203 | print(data) 204 | 205 | @_sp.command(brief="Get user playlists") 206 | async def playlists(self, ctx, *, user: converters.DiscordMember = None): 207 | user = user or ctx.author 208 | sp_user = await self.get_spotify_user(ctx, ctx.author) 209 | data = await sp_user.get_playlists() 210 | if not data.get("items"): 211 | await ctx.fail( 212 | f"{f'User **{user}** `{user.id}` has' if user != ctx.author else 'You have'} no recent tracks." 213 | ) 214 | return 215 | 216 | entries = [ 217 | f"{self.format_track(playlist)} ID: `{playlist['id']}`" 218 | for playlist in data["items"] 219 | ] 220 | 221 | p = pagination.SimplePages( 222 | entries=entries, 223 | per_page=10, 224 | ) 225 | p.embed.title = f"{user.display_name}'s Spotify playlists." 226 | p.embed.set_thumbnail(url=spotify.CONSTANTS.WHITE_ICON) 227 | await p.start(ctx) 228 | 229 | @_sp.command(brief="Get user playlists") 230 | async def user_playlists(self, ctx, username): 231 | data = await spotify.get_user_playlists(username) 232 | if not data.get("items"): 233 | await ctx.fail(f"No user found with username: `{username}`") 234 | return 235 | 236 | entries = [ 237 | f"{self.format_track(playlist)} ID: `{playlist['id']}`" 238 | for playlist in data["items"] 239 | ] 240 | 241 | p = pagination.SimplePages( 242 | entries=entries, 243 | per_page=10, 244 | ) 245 | p.embed.title = f"{username}'s Spotify playlists." 246 | p.embed.set_thumbnail(url=spotify.CONSTANTS.WHITE_ICON) 247 | await p.start(ctx) 248 | 249 | @_sp.command(brief="Disconnect your Spotify account.") 250 | async def disconnect(self, ctx): 251 | query = """ 252 | DELETE FROM spotify_auth 253 | WHERE user_id = $1; 254 | """ 255 | status = await self.bot.cxn.execute(query, ctx.author.id) 256 | if status != "DELETE 0": 257 | await ctx.success("Successfully disconnected your spotify account.") 258 | else: 259 | await ctx.fail("You have not connected your Spotify account yet.") 260 | -------------------------------------------------------------------------------- /data/assets/FreeSansBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hecate946/Neutra/a99596685d0b84a3d13d9c1802e985f5d7aa86c3/data/assets/FreeSansBold.ttf -------------------------------------------------------------------------------- /data/assets/Helvetica-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hecate946/Neutra/a99596685d0b84a3d13d9c1802e985f5d7aa86c3/data/assets/Helvetica-Bold.ttf -------------------------------------------------------------------------------- /data/assets/Helvetica.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hecate946/Neutra/a99596685d0b84a3d13d9c1802e985f5d7aa86c3/data/assets/Helvetica.ttf -------------------------------------------------------------------------------- /data/assets/Monospace.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hecate946/Neutra/a99596685d0b84a3d13d9c1802e985f5d7aa86c3/data/assets/Monospace.ttf -------------------------------------------------------------------------------- /data/assets/avatar_mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hecate946/Neutra/a99596685d0b84a3d13d9c1802e985f5d7aa86c3/data/assets/avatar_mask.png -------------------------------------------------------------------------------- /data/assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hecate946/Neutra/a99596685d0b84a3d13d9c1802e985f5d7aa86c3/data/assets/banner.png -------------------------------------------------------------------------------- /data/assets/bargraph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hecate946/Neutra/a99596685d0b84a3d13d9c1802e985f5d7aa86c3/data/assets/bargraph.png -------------------------------------------------------------------------------- /data/assets/blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hecate946/Neutra/a99596685d0b84a3d13d9c1802e985f5d7aa86c3/data/assets/blue.png -------------------------------------------------------------------------------- /data/assets/roo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hecate946/Neutra/a99596685d0b84a3d13d9c1802e985f5d7aa86c3/data/assets/roo.png -------------------------------------------------------------------------------- /data/assets/snowbanner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hecate946/Neutra/a99596685d0b84a3d13d9c1802e985f5d7aa86c3/data/assets/snowbanner.png -------------------------------------------------------------------------------- /data/csvs/eagle_doggo_url.csv: -------------------------------------------------------------------------------- 1 | https://cdn.discordapp.com/attachments/771797860407705660/823633875816808538/20210322_200009.jpg 2 | https://cdn.discordapp.com/attachments/771797860407705660/823633874793529375/20210322_200013.jpg 3 | https://cdn.discordapp.com/attachments/771797860407705660/821904467431522335/20210317_235059.jpg 4 | https://cdn.discordapp.com/attachments/771797860407705660/821904467108691968/20210317_225613.jpg 5 | https://cdn.discordapp.com/attachments/771797860407705660/821904427262672956/20210317_203820.jpg 6 | https://cdn.discordapp.com/attachments/771797860407705660/821904426662232124/20210224_133250.jpg 7 | https://cdn.discordapp.com/attachments/771797860407705660/821904317371383808/20210212_114622.jpg 8 | https://cdn.discordapp.com/attachments/771797860407705660/821904317048291358/20210211_142656.jpg 9 | https://cdn.discordapp.com/attachments/771797860407705660/821904277357592586/20210209_172838.jpg 10 | https://cdn.discordapp.com/attachments/771797860407705660/821904276887437312/20210209_172828.jpg 11 | https://cdn.discordapp.com/attachments/771797860407705660/821904168679374858/20210207_162629.jpg 12 | https://cdn.discordapp.com/attachments/771797860407705660/821904168192311306/20210207_162627.jpg 13 | https://cdn.discordapp.com/attachments/771797860407705660/821904103185186876/20210207_101736.jpg 14 | https://cdn.discordapp.com/attachments/771797860407705660/821904102744129547/20210205_145945.jpg 15 | https://cdn.discordapp.com/attachments/771797860407705660/821904000630390805/20210202_123259.jpg 16 | https://cdn.discordapp.com/attachments/771797860407705660/821904000198115378/20210129_154303.jpg 17 | https://cdn.discordapp.com/attachments/771797860407705660/821903901661462528/20210129_141221.jpg 18 | https://cdn.discordapp.com/attachments/771797860407705660/821903901389225984/20210129_004226.jpg 19 | https://cdn.discordapp.com/attachments/771797860407705660/821903827317030942/20210127_190625.jpg 20 | https://cdn.discordapp.com/attachments/771797860407705660/821903826662850620/20210123_012900.jpg 21 | https://cdn.discordapp.com/attachments/771797860407705660/821903691895537724/20210118_195335.jpg 22 | https://cdn.discordapp.com/attachments/771797860407705660/821903691199545374/20210118_195316.jpg 23 | https://cdn.discordapp.com/attachments/771797860407705660/821903650308751410/20210118_195231.jpg 24 | https://cdn.discordapp.com/attachments/771797860407705660/821903649835450378/20210118_182938.jpg 25 | https://cdn.discordapp.com/attachments/771797860407705660/821903649835450378/20210118_182938.jpg 26 | https://cdn.discordapp.com/attachments/771797860407705660/821903599972909076/20210115_125112.jpg 27 | https://cdn.discordapp.com/attachments/771797860407705660/821903599390294077/20210115_015222.jpg 28 | https://cdn.discordapp.com/attachments/771797860407705660/821903556620845086/20210112_200501.jpg 29 | https://cdn.discordapp.com/attachments/771797860407705660/821903556273242142/20210111_020927.jpg 30 | https://cdn.discordapp.com/attachments/771797860407705660/821903444675002408/20210111_005140.jpg 31 | https://cdn.discordapp.com/attachments/771797860407705660/821903444284801084/20210110_150651.jpg 32 | https://cdn.discordapp.com/attachments/771797860407705660/821903268107518003/20210110_150641.jpg 33 | https://cdn.discordapp.com/attachments/771797860407705660/821903267785211924/IMG-20210107-WA0001.jpg 34 | https://cdn.discordapp.com/attachments/771797860407705660/821903126013149194/20210106_223409.jpg 35 | https://cdn.discordapp.com/attachments/771797860407705660/821903126013149194/20210106_223409.jpg 36 | https://cdn.discordapp.com/attachments/771797860407705660/821903124821704704/20210106_223416.jpg 37 | https://cdn.discordapp.com/attachments/771797860407705660/821903008807649320/20210106_010221.jpg 38 | https://cdn.discordapp.com/attachments/771797860407705660/821903007850692628/20210105_181623.jpg 39 | https://cdn.discordapp.com/attachments/771797860407705660/821902896379068466/20210105_155824.jpg 40 | https://cdn.discordapp.com/attachments/771797860407705660/821902895766568970/20201231_150649.jpg 41 | https://cdn.discordapp.com/attachments/771797860407705660/821902780847226901/20201227_134317.jpg 42 | https://cdn.discordapp.com/attachments/771797860407705660/821902774212100156/20201225_133123.jpg 43 | https://cdn.discordapp.com/attachments/771797860407705660/821902567876722748/20201218_124937.jpg 44 | https://cdn.discordapp.com/attachments/771797860407705660/821902567403552798/20201206_204321.jpg 45 | https://cdn.discordapp.com/attachments/771797860407705660/821902389904539668/20201127_234122.jpg 46 | https://cdn.discordapp.com/attachments/771797860407705660/821902389052702770/20201126_170422.jpg 47 | https://cdn.discordapp.com/attachments/771797860407705660/821902247180107806/20201125_223226.jpg 48 | https://cdn.discordapp.com/attachments/771797860407705660/821902246626983986/20201125_150059.jpg 49 | https://cdn.discordapp.com/attachments/771797860407705660/821902202573422592/20201125_150050.jpg 50 | https://cdn.discordapp.com/attachments/771797860407705660/821902202372751360/IMG-20201123-WA0000.jpg 51 | https://cdn.discordapp.com/attachments/771797860407705660/821902105966149655/20201120_135905.jpg 52 | https://cdn.discordapp.com/attachments/771797860407705660/821902105526009886/20201118_131815.jpg 53 | https://cdn.discordapp.com/attachments/771797860407705660/821901948902047774/20201116_142846.jpg 54 | https://cdn.discordapp.com/attachments/771797860407705660/821901948902047774/20201116_142846.jpg 55 | https://cdn.discordapp.com/attachments/771797860407705660/821901948365307904/20201114_113701.jpg 56 | https://cdn.discordapp.com/attachments/771797860407705660/821901774050164741/IMG-20201113-WA0000.jpg 57 | https://cdn.discordapp.com/attachments/771797860407705660/821901773753155635/20201113_014009.jpg 58 | https://cdn.discordapp.com/attachments/771797860407705660/821901634891808848/20201113_010259.jpg 59 | https://cdn.discordapp.com/attachments/771797860407705660/821901634085978142/20201113_010627.jpg 60 | https://cdn.discordapp.com/attachments/771797860407705660/821901515920113664/20201110_175843.jpg 61 | https://cdn.discordapp.com/attachments/771797860407705660/821901515094360084/20201109_195125.jpg 62 | https://cdn.discordapp.com/attachments/771797860407705660/821901428187987968/20201109_192552.jpg 63 | https://cdn.discordapp.com/attachments/771797860407705660/821901427597115422/20201105_131831.jpg 64 | https://cdn.discordapp.com/attachments/771797860407705660/821901427597115422/20201105_131831.jpg 65 | https://cdn.discordapp.com/attachments/771797860407705660/821901348048076830/20201103_164330.jpg 66 | https://cdn.discordapp.com/attachments/771797860407705660/821901346901852160/20201102_151527.jpg 67 | https://cdn.discordapp.com/attachments/771797860407705660/821901180061351946/20201022_113014.jpg 68 | https://cdn.discordapp.com/attachments/771797860407705660/821901179525136424/20201021_122320.jpg 69 | https://cdn.discordapp.com/attachments/771797860407705660/821901065690546226/20201020_111831.jpg 70 | https://cdn.discordapp.com/attachments/771797860407705660/821901065246212126/20201017_165710.jpg 71 | https://cdn.discordapp.com/attachments/771797860407705660/821900945394499644/20201001_180411.jpg 72 | https://cdn.discordapp.com/attachments/771797860407705660/821900945008492564/20201001_003914.jpg 73 | https://cdn.discordapp.com/attachments/771797860407705660/821900890528153620/20200928_110814.jpg 74 | https://cdn.discordapp.com/attachments/771797860407705660/821900889844875284/20200927_122619.jpg 75 | https://cdn.discordapp.com/attachments/771797860407705660/821900777114566736/20200920_011942.jpg 76 | https://cdn.discordapp.com/attachments/771797860407705660/821900776594079815/20200919_232446.jpg 77 | https://cdn.discordapp.com/attachments/771797860407705660/821900645060837376/20200918_162809.jpg 78 | https://cdn.discordapp.com/attachments/771797860407705660/821900643697819679/20200918_114728.jpg 79 | https://cdn.discordapp.com/attachments/771797860407705660/821900472666554368/20200914_194902.jpg 80 | https://cdn.discordapp.com/attachments/771797860407705660/821900472239259740/20200914_174559.jpg 81 | https://cdn.discordapp.com/attachments/771797860407705660/821900357370118144/20200905_173538.jpg 82 | https://cdn.discordapp.com/attachments/771797860407705660/821900357079793674/20200905_173521.jpg 83 | https://cdn.discordapp.com/attachments/771797860407705660/821900239211462706/20200828_125119.jpg 84 | https://cdn.discordapp.com/attachments/771797860407705660/821900238586380359/20200824_132234.jpg 85 | https://cdn.discordapp.com/attachments/771797860407705660/821899875934404628/20200820_141517.jpg 86 | https://cdn.discordapp.com/attachments/771797860407705660/821899875637002260/20200819_025822.jpg 87 | https://cdn.discordapp.com/attachments/771797860407705660/821899656367833088/20200805_205717.jpg 88 | https://cdn.discordapp.com/attachments/771797860407705660/821899655800422410/20200804_225615.jpg 89 | https://cdn.discordapp.com/attachments/771797860407705660/821899467619434566/20200801_154405.jpg 90 | https://cdn.discordapp.com/attachments/771797860407705660/821899466600087582/20200730_144039.jpg 91 | https://cdn.discordapp.com/attachments/771797860407705660/821899346286215188/20200729_224613.jpg 92 | https://cdn.discordapp.com/attachments/771797860407705660/821899343065907290/20200725_143542.jpg 93 | https://cdn.discordapp.com/attachments/771797860407705660/821899172126392390/20200725_002742.jpg 94 | https://cdn.discordapp.com/attachments/771797860407705660/821899171668688946/20200721_161122.jpg 95 | https://cdn.discordapp.com/attachments/771797860407705660/821899077301829632/20200720_231128.jpg 96 | https://cdn.discordapp.com/attachments/771797860407705660/821899076526014484/20200719_163124.jpg 97 | https://cdn.discordapp.com/attachments/771797860407705660/821898778083590174/20200715_144601.jpg 98 | https://cdn.discordapp.com/attachments/771797860407705660/821898776230756362/20200715_132544.jpg 99 | https://cdn.discordapp.com/attachments/771797860407705660/821898775827316757/IMG-20200711-WA0002.jpg 100 | https://cdn.discordapp.com/attachments/771797860407705660/821898687814172733/IMG-20200711-WA0001.jpg 101 | https://cdn.discordapp.com/attachments/771797860407705660/821898687613239328/IMG-20200711-WA0000.jpg 102 | https://cdn.discordapp.com/attachments/771797860407705660/804127409937973300/20210107_215130.jpg -------------------------------------------------------------------------------- /data/csvs/jisoo_url.csv: -------------------------------------------------------------------------------- 1 | https://preview.redd.it/nyssfmzwan261.jpg?width=640&crop=smart&auto=webp&s=fff0e5478f9eca4d6618b700c73baad2b14b6415 2 | https://external-preview.redd.it/Ig4M37fcgB6dmu_yO_Df_IXYieGCAZSZD5nQYwyZA10.jpg?width=640&crop=smart&auto=webp&s=6319df492ce0db0b7e02cd1e32536607c5cf2577 3 | https://preview.redd.it/zc2djf4c9l261.jpg?width=640&crop=smart&auto=webp&s=7252050aaa28b5e5097efdc8fc96b87ac94965dc 4 | https://preview.redd.it/rg1ctjuc2g261.jpg?width=640&crop=smart&auto=webp&s=68eabc2d60eb1dafa0556e81e5f975586dee972f 5 | https://external-preview.redd.it/nMUYj2MRVztc19m3B4-_dhyzuQxplx6iq0HcaHaHdLM.jpg?width=640&crop=smart&auto=webp&s=cbe4e946a80c457b4dfbcb62056c14468276619e 6 | https://preview.redd.it/6d1wcfxm69261.jpg?width=640&crop=smart&auto=webp&s=70754ca1d4ef7696bb3890ba77e3f7b297f88a66 7 | https://external-preview.redd.it/ST4UVAi07qkZAP70o1VRjOD4d2zhg0YCuvMjwG9RMR0.png?width=640&crop=smart&auto=webp&s=dfb9b3d1f0b4c222bae60c76e9a7b4cb1eb45f93 8 | https://preview.redd.it/6bclki77s3261.jpg?width=640&crop=smart&auto=webp&s=efc5a873895203e41ded33e4cd04c62792ad2017 9 | https://preview.redd.it/97flkke2u1261.jpg?width=640&crop=smart&auto=webp&s=00e485f573fe4ed466a934274ed4d2a138458e01 10 | https://external-preview.redd.it/YE9qEHLFieVCr2YoyPz1E3mZx_0y13lPUyehGT97vOA.png?width=640&crop=smart&auto=webp&s=48b78e2e6807d419fae56405f739c5561fb61d6a 11 | https://preview.redd.it/xmivyn0itw161.jpg?width=640&crop=smart&auto=webp&s=503376bb560c6e4d7267923e9f8f940b25835715 12 | https://preview.redd.it/do77eugrhw161.jpg?width=640&crop=smart&auto=webp&s=35f28c9b2d5d01b98936bf210eacb579feea1757 13 | https://preview.redd.it/wvegma65qu161.jpg?width=640&crop=smart&auto=webp&s=2aac9c4f45395faa7befd3889b0d371d5808538c 14 | https://external-preview.redd.it/BZfzQGRvM63ZgOsWM488N2I6hsQ745CvVuU14CowvQE.jpg?width=640&crop=smart&auto=webp&s=216197c0ed9780f8c000c43e43f18218f184bb77 15 | https://preview.redd.it/u280x1853s161.jpg?width=640&crop=smart&auto=webp&s=fd0e9c81450b8d447283e791f780b8fc594fef29 16 | https://preview.redd.it/xhcbz181in161.jpg?width=640&crop=smart&auto=webp&s=1df5bf8220c377894c0e5a2e5c13f8b80fb907f1 17 | https://external-preview.redd.it/OYp3BhjkapTy_oH7l-UC3Vp0FPM1SV9QD9EgitikUbw.jpg?width=640&crop=smart&auto=webp&s=74388baa53b17b9c8fbd138b07e8ae91075a18e3 18 | https://preview.redd.it/9gacy8tscg161.jpg?width=640&crop=smart&auto=webp&s=a9bea8294ac0a221a98eef7af13f03acb8bc506e 19 | https://external-preview.redd.it/EcPp7V624R3Pgfp2tC7PblBwptMRMSDTMbXxJcdPEMY.jpg?width=640&crop=smart&auto=webp&s=ab32e8b15e016adcc4900a9cd66977cd47273490 20 | https://external-preview.redd.it/YwQ7D9XtKYHuIj0hUVqBqQwfQdnKdYyqXndfXQ44sZI.png?width=640&crop=smart&auto=webp&s=ec874ade7f79163e39fac72c112aaf1feb1bf80d 21 | https://external-preview.redd.it/YwQ7D9XtKYHuIj0hUVqBqQwfQdnKdYyqXndfXQ44sZI.png?width=640&crop=smart&auto=webp&s=ec874ade7f79163e39fac72c112aaf1feb1bf80d 22 | https://external-preview.redd.it/ucXFK-lgqOS3hIIqafWj32o0e2Cc7uJU9Y8vMZoQKTU.jpg?width=640&crop=smart&auto=webp&s=2c58301ac5168f6d710dbc47b90f418208206ada 23 | https://preview.redd.it/w5uhyqzkbz061.jpg?width=640&crop=smart&auto=webp&s=3909c0a28577d6f1401a8049b9464c1bd87a2252 24 | https://preview.redd.it/1uikpvsz0v061.jpg?width=640&crop=smart&auto=webp&s=77f87b80019c83369b8653cdf6d645dedab1b529 25 | https://external-preview.redd.it/fjuO_DkYosIUpoHYLneUhQBtzoiVg991m7X_56E5QUI.jpg?width=640&crop=smart&auto=webp&s=f1ef1006943408c31021efc40bea1fb4efa919fa 26 | https://preview.redd.it/pqmj7z6zop061.jpg?width=640&crop=smart&auto=webp&s=719c15436e5dc2881111dc96be0330d452961b44 27 | https://preview.redd.it/6iqnj6jfyn061.jpg?width=640&crop=smart&auto=webp&s=83601c927e776e0d3fbe0462625251ccd5a63708 28 | https://external-preview.redd.it/VLuE9VzOW2Cew8XmUQRBgdmu5uVnkvjqEHtYM9hQAoQ.jpg?width=640&crop=smart&auto=webp&s=056478320f5fffddf05373776b8f0af34895f246 29 | https://preview.redd.it/3si3j4sqki061.jpg?width=640&crop=smart&auto=webp&s=334ceffce6ed1672298987600157062d3119fc7b 30 | https://preview.redd.it/stl2bv00sg061.jpg?width=640&crop=smart&auto=webp&s=876a5772bbd3959029137d63f2aff8f36f412ee9 31 | https://external-preview.redd.it/MlV8_dw3DxpknOYWbfb134FhKxEMhb6Z7vLGDPhxM0o.jpg?width=640&crop=smart&auto=webp&s=3d7c29c2f4553ee26bbf421868f9f1404123ce81 32 | https://preview.redd.it/xpui6gk0o9061.jpg?width=640&crop=smart&auto=webp&s=38d1d33b0a1266c76074934078876d6b583aefa0 33 | https://external-preview.redd.it/uN3fV8d7n6ucRiMNHq-F7PvN5y74PZeQuw-HdrrqNmk.jpg?width=640&crop=smart&auto=webp&s=92906e3c42086aefa206fc80f8246b40dba2c176 34 | https://preview.redd.it/b87wnqswo6061.jpg?width=640&crop=smart&auto=webp&s=104497db03c20114b1867130561bc2cc78a62e11 35 | https://preview.redd.it/cqkgtkafk2061.jpg?width=640&crop=smart&auto=webp&s=64bac766c50eedf3bd4daee88186d276154c5b87 36 | https://external-preview.redd.it/zimWBXEOwKvoOw2s3HFh27ml5S69C9A4Q5N3nYCCeB8.jpg?width=640&crop=smart&auto=webp&s=88e5250e3097d2359f6d61018f53ed80cc501512 37 | https://preview.redd.it/6ukgxvzchvz51.jpg?width=640&crop=smart&auto=webp&s=1036b8c431043484db7f4d82f234849a6dbcf348 38 | https://external-preview.redd.it/NIcks-ZRNErE5EM7P7uOHUg9nM8LZ-UjmBZwWTTPi8E.jpg?width=640&crop=smart&auto=webp&s=b4dff787615f1e0a75c9a8725541263a09a6bbf1 39 | https://preview.redd.it/277y54pp8oz51.png?width=640&crop=smart&auto=webp&s=4c5814ba34ff036f4e701a176791fd65723ae80c 40 | https://external-preview.redd.it/W3BvMWZabNTaPyoLaApYKrUUsIwBeWFo2K8cE3EGx3A.jpg?width=640&crop=smart&auto=webp&s=f3ccbb9551f30f7c0f3f7269e0cf0542180da3c3 41 | https://preview.redd.it/ceqd0uqi2iz51.jpg?width=640&crop=smart&auto=webp&s=0156893d3d83f9257d2ccb07fceda1df65ccce96 42 | https://preview.redd.it/o1aw6lrlzgz51.jpg?width=640&crop=smart&auto=webp&s=7948ac57402c61940db6f2a4d81c22a23a18079a 43 | https://external-preview.redd.it/zdr9Rj_GoDYvhZMq6CtDezwp-7ZZl-kJGMt3Ehsh29c.jpg?width=640&crop=smart&auto=webp&s=8f08e1c4f59f464a26789fecf2bcd2f8dd7e5b9c 44 | https://preview.redd.it/zwheezjqpbz51.jpg?width=640&crop=smart&auto=webp&s=e53b7725e8aefd5e70fa75d2a3a7ae6a3e969bda 45 | https://preview.redd.it/zcqlgb1b1az51.jpg?width=640&crop=smart&auto=webp&s=89ce969e5c8767f6d4bbe1b3e6f70d191079ef78 46 | https://external-preview.redd.it/Ry8U30CRpN0162Ff5ShTQz3vNy78o2x_0HS6eQHA0JU.jpg?width=640&crop=smart&auto=webp&s=3ec309b72030311c4f6c9e072fd67b33cd07a14f 47 | https://preview.redd.it/q8rftfqrk4z51.jpg?width=640&crop=smart&auto=webp&s=3d0c5511b4d8c8fd64dd442c2e05cbe60616a7b1 48 | https://preview.redd.it/f4xb2m6np2z51.jpg?width=640&crop=smart&auto=webp&s=d6047be4a38b8865a2d9fb9c2dafd0598c20074b 49 | https://preview.redd.it/57vt4r5okvy51.png?width=640&crop=smart&auto=webp&s=e801511987c3ecff2434c3495c8e2aa7a320e35b 50 | https://external-preview.redd.it/GiVKJkzWeJQODjUxmb8j8AMgwPy9qqz1OYMeSzBmt58.jpg?width=640&crop=smart&auto=webp&s=4480fb1ca8692a8d04f0b1dcfd709aadec178e74 51 | https://preview.redd.it/71zn6f0aaoy51.png?width=640&crop=smart&auto=webp&s=f6a4e6086d3f6bbcc72caaed26bfcd0887673e3f 52 | https://external-preview.redd.it/MgA0wjrLpQLA86Bclz9magnL8YlF4AI6IkpXl1wr-IE.jpg?width=640&crop=smart&auto=webp&s=2b5c6ff921ce537b6d828db4f31a40f3a346f814 53 | https://preview.redd.it/ek0fki4nghy51.jpg?width=640&crop=smart&auto=webp&s=eee8aad01296d339f8eb083f64da279dfc255f0c 54 | https://external-preview.redd.it/Ohk_rF1Or43tgEENhjoVs4gjZumWC6mvn7hEL76LJlk.jpg?width=640&crop=smart&auto=webp&s=eec9a9727eb52be3f8cbd263ec11892d4f8d7d44 55 | https://preview.redd.it/28a45rnkydy51.jpg?width=640&crop=smart&auto=webp&s=5573ff2f5026d7d0dfb6ae0d7cd344fc31cf9ee4 56 | https://preview.redd.it/d2zjw5ia1ay51.jpg?width=640&crop=smart&auto=webp&s=9675dc5a895eee37175429f1726c04bc4a8730bc 57 | https://preview.redd.it/xe6pk34gp9y51.jpg?width=640&crop=smart&auto=webp&s=bec5a8fcdc1f143fca96a8b56d6472db9734a4a6 58 | https://external-preview.redd.it/KsPWwF0IWS7dx0VzH-pQ_nFjdRXVTDOLyuLMTCEon5g.jpg?width=640&crop=smart&auto=webp&s=434ec1ee8eadf01b154fd47ae2eb2ac86eaca65d 59 | https://preview.redd.it/outndhc8q2y51.jpg?width=640&crop=smart&auto=webp&s=491f4eda48aa9514387707ee0138f2738dda60e3 60 | https://external-preview.redd.it/WkDAh0naBH4fPAGxyC7_s7_6izC2nkm2Nr2mSSWfI6I.jpg?width=640&crop=smart&auto=webp&s=569b30cbf82dfd6e7b0443513a5fcfdddfbbd4ee 61 | https://preview.redd.it/bgd9suctovx51.jpg?width=640&crop=smart&auto=webp&s=951c54e044c3eebc9dab2743c5856a14f5753f70 62 | https://external-preview.redd.it/ZBB6J6QdvZrz6MulGk4PcGjMQlR5jNZQ3eTcvI5-RHY.jpg?width=640&crop=smart&auto=webp&s=b29442bfe3aae6af31903c43a5313103d0680774 63 | https://preview.redd.it/mkkycgqvbqx51.jpg?width=640&crop=smart&auto=webp&s=70ebebf1a309dee1139b313ceb058fcc88e9810f 64 | https://preview.redd.it/17htqqcfgox51.jpg?width=640&crop=smart&auto=webp&s=bf8ae63188800714502fc4a5d8af5effcf53723f 65 | https://external-preview.redd.it/33sDXXnU9uSGQS9WhHXNQipz7i_EjsjBtA-9bFwPhFI.png?width=640&crop=smart&auto=webp&s=6ca4b1cdd1b5c8c846dd987eb4185cd0f923c5b0 66 | https://preview.redd.it/5y2p0l5i3ox51.jpg?width=640&crop=smart&auto=webp&s=c22d2993dc7b03df258c1a8831e4834234035a87 67 | https://preview.redd.it/iozo51nzxkx51.jpg?width=640&crop=smart&auto=webp&s=f90a98c834c6912e9a3d7827660b284b592cd279 68 | https://preview.redd.it/zuwuyjcdhhx51.jpg?width=640&crop=smart&auto=webp&s=debddc7b874efe18065502c4e35fdc3e2bf2ca1d 69 | https://preview.redd.it/52sc4xs44ix51.jpg?width=640&crop=smart&auto=webp&s=0c4e201e7ab4dd5259b1a3c4c0e302276bd93aa6 70 | https://external-preview.redd.it/nJaa4cMXGTitbcEE6vWNUDUTUCsKn_DtzMeHNq9pN00.jpg?width=640&crop=smart&auto=webp&s=16e7ec09878e698fcf2723f9dee40bbd21427b06 71 | https://preview.redd.it/1f754qskgcx51.jpg?width=640&crop=smart&auto=webp&s=032cfe9484f86fc117ba47f1d2f69e5d1922d067 72 | https://preview.redd.it/2aw7i7spbax51.jpg?width=640&crop=smart&auto=webp&s=7a935c478e71c28b3b35d774e2bd8cd6d86c0158 73 | https://external-preview.redd.it/fanjo31dze2BOqfFBSZ93i8AHYMLkEqqNLo6RTg_Uhc.jpg?width=640&crop=smart&auto=webp&s=53e3039ff8223e78c03e02795c8ee137e3a581e8 74 | https://preview.redd.it/2yv3q0py13x51.jpg?width=640&crop=smart&auto=webp&s=a8fb705a4cd13544a3abba8d5dc286b042cc17e6 75 | https://external-preview.redd.it/Npm1eaxNx17yPSxakwR7rJYpb7etTeCghOCAggGyPTI.jpg?width=640&crop=smart&auto=webp&s=e3f6d00ac3f52b5f1bfc1489a332d62d75fc8773 76 | https://preview.redd.it/02q25gvuhzw51.jpg?width=640&crop=smart&auto=webp&s=dd9282ed133b3db0f103930868fb50e575826e53 77 | https://preview.redd.it/hbce12qtxvw51.jpg?width=640&crop=smart&auto=webp&s=0ab2b87d8c5a1135b800905bf29c39faa31c8066 78 | https://external-preview.redd.it/tQXiWUBoOiB0do4j6wGoBOex6w0Qr3Q6i7sTDpR-QDc.jpg?width=640&crop=smart&auto=webp&s=48f629765f1e24edc9141f0a8b5e8b5cc7231342 79 | https://preview.redd.it/y76e8415sow51.jpg?width=640&crop=smart&auto=webp&s=65dc403c1a47288cdef70d2a7e783dd03e43503d -------------------------------------------------------------------------------- /data/csvs/kate_url.csv: -------------------------------------------------------------------------------- 1 | https://www4.pictures.zimbio.com/gi/Kate+McKinnon+Bombshell+New+York+Screening+Zu6h4elYx29l.jpg 2 | https://i.pinimg.com/originals/cd/c4/1c/cdc41c3329ff08246539f457d097ce4c.jpg 3 | https://pbs.twimg.com/profile_images/910582982846615553/1ToJl2gN_400x400.jpg 4 | https://i.pinimg.com/originals/68/20/73/6820734b9fabcc04a6638edad033b614.jpg 5 | https://pbs.twimg.com/media/D9nqfuPW4AAUGN7.jpg 6 | https://ih1.redbubble.net/image.514531654.3046/flat,750x1000,075,f.u19.jpg 7 | https://media1.tenor.com/images/6e4bbb11f4d2678eb1b0279a3028a553/tenor.gif?itemid=11869730 8 | https://ih1.redbubble.net/image.515162615.1072/flat,750x,075,f-pad,750x1000,f8f8f8.u8.jpg 9 | https://pbs.twimg.com/profile_images/1312818784357494784/61ZucmXR.jpg 10 | https://i.pinimg.com/originals/aa/2e/1d/aa2e1d4343eea1f80c51c6d900d05a1f.png 11 | https://i.pinimg.com/originals/7a/e1/dd/7ae1dd255aa5155e899fd5f6edc58970.jpg 12 | https://static.onecms.io/wp-content/uploads/sites/20/2015/01/mckinnon-800.jpg 13 | https://i.pinimg.com/originals/f9/e2/ca/f9e2cacd47c5227371c9c0bcd14a38dc.jpg 14 | https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRZmJ6EDDkKx6HPhT7FX9I5yJtLnI6uUdTK4bmtDTV_EevAOWOCVBf0lM8slKaEpgEiRl7dfgecF7Y-xhpeZ27BPALtH4bVbDZyig&usqp=CAU&ec=45732301 15 | https://i.pinimg.com/originals/6f/1b/1b/6f1b1b288074c6785fee45a7e8c03ba6.jpg 16 | https://pbs.twimg.com/profile_images/1175625915017170945/U4N0gIZi_400x400.jpg 17 | https://pbs.twimg.com/profile_images/755095969591689217/AlPF57UB_400x400.jpg 18 | https://pbs.twimg.com/profile_images/854892810721411072/oAkhqBxJ.jpg 19 | https://i.pinimg.com/originals/f8/01/e0/f801e09fe79871d8a1cd47b9b1120ccf.jpg 20 | https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSPfT7Wkwb8xYFXRpzK9n-GdcVHuR9p54o4wMYNqZmCHylfkSNFAoVgMMTg-3vzDY3G9o-TpnZqu5vfZpdUy4HRDv_UUsYIw1xRtA&usqp=CAU&ec=45732301 21 | https://pbs.twimg.com/media/DS165VsUMAA0s83.jpg 22 | https://i.ytimg.com/vi/X5FgreIMIQo/hqdefault.jpg 23 | https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTF8PADVal_QV33d_CsBUj3BSzc3WOoOfgyn3RrulFhEMsku9siKgkYqlP5RM5sUZRc9wqahWmODBiMqLCexu3PVYo4tgdrjv8MJA&usqp=CAU&ec=45732301 24 | https://pbs.twimg.com/media/EVX8uk1XsAIOwEo.jpg 25 | https://i.pinimg.com/originals/23/e8/e4/23e8e411ef60e4621316a1551517ace0.jpg 26 | https://pbs.twimg.com/media/EGyd0EbWwAAKptE.jpg 27 | https://goofyamerica.com/wp-content/uploads/2016/12/IMG_2326.jpg 28 | https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRbuiiLzHnnUXnZ8bhiockZdoWQMUq3ynD6LKI3_xo59NGr9l5QDvtZxMfBbb0rPGKbnGODJOmnz33viVnDjHrJAe3wRnbbxc-nKQ&usqp=CAU&ec=45732301 29 | https://i.imgflip.com/22mqk1.jpg 30 | https://media1.giphy.com/media/3o7TKDGpMsDI0h80Mg/source.gif 31 | https://i.pinimg.com/236x/1e/28/5b/1e285b84325f9f1e91198e7da27f5991--sexy-wife-kate-mckinnon.jpg 32 | https://pbs.twimg.com/media/EmR7pDIW8AA3763.jpg 33 | https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSqMnwnNCwoUZBewIgFeojIbZwRwF8C34-xiw&usqp=CAU 34 | https://pyxis.nymag.com/v1/imgs/37d/4d7/f8e634dbdccc6409e682309a47efc2fcfd-18-kate-mckinnon-bombshell.rsocial.w1200.jpg 35 | https://ca-times.brightspotcdn.com/dims4/default/a199dd2/2147483647/strip/true/crop/2000x1275+0+0/resize/840x536!/quality/90/?url=https%3A%2F%2Fcalifornia-times-brightspot.s3.amazonaws.com%2F42%2F64%2Fc86f12f77f5dd5fc82f187ca7755%2Fla-1469749626-snap-photo 36 | https://pbs.twimg.com/media/EV1lVxhWAAQX8hw.jpg 37 | https://imagesvc.meredithcorp.io/v3/mm/image?url=https%3A%2F%2Fstatic.onecms.io%2Fwp-content%2Fuploads%2Fsites%2F6%2F2017%2F06%2Fnup_176196_0096-2000.jpg 38 | https://decider.com/wp-content/uploads/2016/02/kate-mckinnon-snl.jpg?quality=80&strip=all&w=646&h=431&crop=1 39 | https://compote.slate.com/images/d74f2292-24bd-44d9-91c7-cf58eb60b308.jpeg?width=780&height=520&rect=1620x1080&offset=70x0 40 | https://www.nydailynews.com/resizer/l3DlCJUv2nn-HfmctfbPIJL7mJQ=/415x343/top/arc-anglerfish-arc2-prod-tronc.s3.amazonaws.com/public/DJADJAK6XJDGDHSGCUN7I3MZGM.jpg 41 | https://media1.popsugar-assets.com/files/thumbor/1p0LEAnBCT9tZozwgw-paWCO8FY/654x138:2097x1581/fit-in/2048xorig/filters:format_auto-!!-:strip_icc-!!-/2019/10/25/872/n/1922283/8970a9305db3536f935041.04082046_/i/kate-mckinnon-funniest-snl-skits.png 42 | https://tvline.com/wp-content/uploads/2017/10/snl-premiere-ratings-2.jpg?w=620 43 | https://static.hollywoodreporter.com/wp-content/uploads/2020/10/KateMcKinnonSNL-1602425716-928x523.jpg 44 | https://ca-times.brightspotcdn.com/dims4/default/7a4b67c/2147483647/strip/true/crop/1022x575+0+0/resize/1200x675!/quality/90/?url=https%3A%2F%2Fcalifornia-times-brightspot.s3.amazonaws.com%2Ff3%2Fb5%2F750b47446ff3d5a817ff6c078680%2Fla-1501268206-kilpm1ooe2-snap-image 45 | https://static.independent.co.uk/2020/11/08/16/newFile.jpg?width=1200 46 | https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTxIDvbakxghnuOAnTBupqk409aBXb_zviSKEwK6bnR7mXvNJq5mWvegVIGlwEokhbBBQXo128FSYRjiz_xXQtDoIxKAtCy4TqqIw&usqp=CAU&ec=45732301 47 | https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTVPIJ_eehkU-i6ZjpU0GdsrIYN15ojglSZaKLS42C8YVwkRfSRN-v6Nr7_6VRDhnkUGkr39IJ-GUvhoH2NRukKNzi10DSS-H_aNQ&usqp=CAU&ec=45732301 48 | https://pbs.twimg.com/media/Dce979RVwAM1t-M.jpg 49 | https://d1qxviojg2h5lt.cloudfront.net/images/01DRF882WMM2WKDC6Q57X0WXG3/Kate-McKinnon-characters.png 50 | https://pyxis.nymag.com/v1/imgs/857/bdf/1bebe56503d4d54cbc5eb2283fe188a470-10-kate-mckinnon-snl.rsquare.w700.jpg 51 | https://img.thedailybeast.com/image/upload/c_crop,d_placeholder_euli9k,h_1428,w_2538,x_0,y_0/dpr_1.5/c_limit,w_1044/fl_lossy,q_auto/v1523768090/KM_ucz2gt 52 | https://greginhollywood.com/wordpress/wp-content/uploads/screen_shot_2018-04-15_at_2.33.48_pm.png 53 | https://imagesvc.meredithcorp.io/v3/mm/image?url=https%3A%2F%2Fstatic.onecms.io%2Fwp-content%2Fuploads%2Fsites%2F6%2F2018%2F01%2Fnup_181427_0094-2000.jpg 54 | https://cdn.pastemagazine.com/www/articles/mckinnon%20snl%20main.jpg 55 | https://api.time.com/wp-content/uploads/2015/12/merkel-time.png 56 | https://i.ytimg.com/vi/3M0Ht5xUo4I/maxresdefault.jpg 57 | https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTqGdHJqjhabzhsDPvx95QLCR8WD5mkAq8f1qVpsl0tIlLrc8Is20gxoGsr0tQc1tmQ4TigKGwKUj_yJfcf75h6VuNKhD4ZBY-Qzg&usqp=CAU&ec=45732301 58 | https://s.yimg.com/ny/api/res/1.2/155KmnpjMpc0tEoi3gaj9Q--~A/YXBwaWQ9aGlnaGxhbmRlcjtzbT0xO3c9ODAw/https://media-mbst-pub-ue1.s3.amazonaws.com/creatr-uploaded-images/2020-11/64ed4c30-1bf6-11eb-9f94-9da466e3a572 59 | https://heavy.com/wp-content/uploads/2019/04/gettyimages-1009188030-e1556677392877.jpg?quality=65&strip=all&w=780 60 | https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQutPGkxL3JKO-EcxhUKDo2XWW1TBNVjEr6U_zakcMmGUBWHCB3SrPMh2PZ5mS5cg966nA2Dwp9jcbVvjKsblgOj_6NxM1qwMdkHg&usqp=CAU&ec=45732301 61 | https://imagesvc.meredithcorp.io/v3/mm/image?url=https%3A%2F%2Fstatic.onecms.io%2Fwp-content%2Fuploads%2Fsites%2F6%2F2018%2F01%2Fgettyimages-491285526-2000.jpg 62 | https://s1.ibtimes.com/sites/www.ibtimes.com/files/styles/embed/public/2018/01/21/snl-kate-mckinnon.jpg 63 | https://www.cheatsheet.com/wp-content/uploads/2018/04/Ruth-Bader-Ginsberg.png 64 | https://i.pinimg.com/originals/7b/b6/3e/7bb63e50c5584ca91c3190440addeed0.png 65 | https://d.newsweek.com/en/full/1658201/kate-mckinnon.png?w=1600&h=1600&q=88&f=c1f691e95504a836427b3d87e21bcbad 66 | https://i.dailymail.co.uk/1s/2020/10/27/22/34925404-0-image-a-35_1603839140920.jpg 67 | https://todayheadline.co/wp-content/uploads/2020/10/34925014-8886505-image-m-45_1603840057562.jpg 68 | https://hips.hearstapps.com/hmg-prod.s3.amazonaws.com/images/steve-carell-episode-1752-pictured-kate-mckinnon-as-ruth-news-photo-1600609869.jpg?crop=0.668xw:1.00xh;0.184xw,0&resize=640:* 69 | https://images3.cliqueclack.com/p/wp-content/blogs.dir/8/files/2012/10/McKinnon.jpg 70 | https://img.cinemablend.com/filter:scale/quill/e/7/5/e/d/b/e75edbff83eaf615877b56abd93f408b3733d8d7.jpg?fw=1200 71 | https://assets3.thrillist.com/v1/image/1728477/414x310/crop;jpeg_quality=65.jpg 72 | https://lh3.googleusercontent.com/proxy/JUKpeLx7pCMSyAz0nkAJ2l-aKkLetbz4ZyFAbD_R2j4CM_GMsAVUh9AePwWTZ14A4gn5YF4VUMFkU-IQeJ33GxKmvKQKFipEl6fyo_BNrzT794okXQV_VBlcLVDu0EQVPMOlIewaXEQaFaoKlwsZaUmHRN6gRSrhgnbCb73ZwACJ2dwqt6v6hIyl7qjpHA 73 | https://s2.dmcdn.net/v/2ckyX1Fh64E7oAtcV/x240 74 | https://cdn.vox-cdn.com/thumbor/-DpqFJCrKZ53SQF-kOw95Ir9vys=/0x0:1440x754/fit-in/1200x630/cdn.vox-cdn.com/uploads/chorus_asset/file/13185399/Screen_Shot_2018_09_30_at_12.29.58_AM.png 75 | https://api.time.com/wp-content/uploads/2017/10/kellyanne-conway-snl-kate-mckinnon.png 76 | https://www.baltimorefishbowl.com/wp-content/uploads/2016/02/Screen-Shot-2016-02-08-at-3.38.38-PM-500x302.png 77 | https://pbs.twimg.com/media/DMcRlF7X4AI8fbc.jpg:large 78 | https://iv1.lisimg.com/image/16459763/740full-kate-mckinnon.jpg 79 | https://media.vanityfair.com/photos/59cbc5be73a8876bfb18da07/master/w_1440,h_978,c_limit/kate-mckinnon-1117-cover-ss01.jpg 80 | https://i.pinimg.com/originals/86/87/44/868744f50c9953573eda6b56eba7a242.jpg 81 | https://i.pinimg.com/originals/a3/f5/55/a3f5554ee12bced7e28e7f1c45154122.jpg 82 | https://pyxis.nymag.com/v1/imgs/394/b63/d9a0880f8c5a39764e5a3033c981101479-12-kate-mckinnon.rsquare.w1200.jpg 83 | https://pyxis.nymag.com/v1/imgs/394/b63/d9a0880f8c5a39764e5a3033c981101479-12-kate-mckinnon.rsquare.w1200.jpg 84 | https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTGrLHMKzTuR0b2gDYzONopcgZ_MfL_mMNURA&usqp=CAU 85 | https://img.cinemablend.com/filter:scale/quill/b/e/9/7/a/c/be97ac33ea2a1fce7eac9798b248d01cc4b7408b.jpg?mw=600 86 | https://www.goldderby.com/wp-content/uploads/2019/09/Kate-McKinnon-Saturday-Night-Live.jpg?w=620&h=360&crop=1 87 | https://images.toofab.com/image/93/1by1/2018/05/21/93f3def1d9435ed5b84bcd65c6871ac0_xl.jpg 88 | https://compote.slate.com/images/aa16ac6e-f18b-483e-bd95-995510f81d55.jpg 89 | https://www.out.com/sites/default/files/2016/12/11/snl_dyke_and_fats_save_christmas.jpg 90 | https://i.ytimg.com/vi/oR4DVwmHCkE/maxresdefault.jpg 91 | https://31.media.tumblr.com/c22ef8e1dc7a500f5e1541a121f499f9/tumblr_n38bxstIwU1rdzuduo1_1280.png 92 | https://ih0.redbubble.net/image.329617711.4405/flat,1000x1000,075,f.u2.jpg 93 | https://www.etonline.com/sites/default/files/styles/max_1280x720/public/images/2019-11/kate_mckinnon_warren_snl_1280.jpg?h=c673cd1c&itok=tbcLsT1i 94 | https://media.hollywood.com/images/638x425/1813129.jpg 95 | https://www.indiewire.com/wp-content/uploads/2019/03/Kate-McKinnon-To-Have-and-Have-Not.png?w=780 96 | https://static.hollywoodreporter.com/sites/default/files/2018/05/screen_shot_2018-05-18_at_10.26.25_am_2_-_h_2018_0-compressed.jpg 97 | https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSEPesQ9jJHEI9CjpeH0_xi5OQM-29DGF9e5A&usqp=CAU 98 | https://www.out.com/sites/default/files/2015/01/19/SNL_1673_03_Calvin_Klein_1.jpg 99 | https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTuGCCN9AF5DfO89cEGgfmldic9bnP66A1dxA&usqp=CAU 100 | https://img.buzzfeed.com/buzzfeed-static/static/2015-01/18/1/campaign_images/webdr06/snl-justin-bieber-calvin-klein-campaign-2-3892-1421563810-8_dblbig.jpg 101 | https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcS2WZo-7beqjR4dbTOFelmKbTORzzFq0fYi_ENkYgjbYVux9cJOw4YOKYq_I0FpKVi2gSiTJ7pTjCLdvH0FYW-R6Xh-YocfQrEHmw&usqp=CAU&ec=45732301 102 | https://nowtoronto.com/wp-content/uploads/2020/05/stage-comedy_cover2-0305.jpg 103 | https://i.redd.it/sebt3zhy2br41.jpg 104 | https://i.pinimg.com/originals/a1/23/08/a123081287c3dc191fcf9664f5abfdfc.jpg 105 | https://i.pinimg.com/236x/89/cb/83/89cb8343a36b18682409658ad225d607--kate-mckinnon-senior-portraits.jpg 106 | https://i.pinimg.com/236x/38/e7/24/38e7240c2e59a231050ea57d60b4a3b1--kate-mckinnon-night-live.jpg 107 | -------------------------------------------------------------------------------- /data/csvs/pig_url.csv: -------------------------------------------------------------------------------- 1 | https://pbs.twimg.com/media/CVEWaK5UwAAUYOC?format=jpg&name=small 2 | https://pbs.twimg.com/media/C3mTY-_UMAADGhF?format=jpg&name=360x360 3 | https://pbs.twimg.com/media/CuVp6gpVMAEq-6O?format=jpg&name=small 4 | https://pbs.twimg.com/media/C3mTY-_UMAADGhF?format=jpg&name=240x240 5 | https://pbs.twimg.com/media/CuVp6gpVMAEq-6O?format=jpg&name=120x120 6 | https://pbs.twimg.com/media/CuIEHCeUkAAo2MO?format=jpg&name=120x120 7 | https://pbs.twimg.com/media/CtnTS7eUEAAfnux?format=jpg&name=120x120 8 | https://pbs.twimg.com/media/CtSWa-CVUAESo6G?format=jpg&name=240x240 9 | https://pbs.twimg.com/media/CtDMIMyUAAAgI-T?format=jpg&name=120x120 10 | https://pbs.twimg.com/media/CuIEHCeUkAAo2MO?format=jpg&name=small 11 | https://pbs.twimg.com/media/CtnTS7eUEAAfnux?format=jpg&name=small 12 | https://pbs.twimg.com/media/CtSWa-CVUAESo6G?format=jpg&name=900x900 13 | https://pbs.twimg.com/media/CtDMIMyUAAAgI-T?format=jpg&name=small 14 | https://pbs.twimg.com/media/Cs4Uj17UMAE0bd_?format=jpg&name=small 15 | https://pbs.twimg.com/media/Csp6lDAUIAEoW7C?format=jpg&name=small 16 | https://pbs.twimg.com/media/CskVoeDUEAAbv5D?format=jpg&name=small 17 | https://pbs.twimg.com/media/CsUPNfMUMAI-Vvr?format=jpg&name=small 18 | https://pbs.twimg.com/media/CsHJi1mVMAAmksv?format=jpg&name=small 19 | https://pbs.twimg.com/media/Cr60MH9UAAAYRBh?format=jpg&name=small 20 | https://pbs.twimg.com/media/CrkKhUtVYAAP75J?format=jpg&name=small 21 | https://pbs.twimg.com/media/Con-jH5UIAAaZ-D?format=jpg&name=small 22 | https://pbs.twimg.com/media/CokQiiRUIAA41Cn?format=jpg&name=small 23 | https://pbs.twimg.com/media/Cn5DSWaUAAA4Qnb?format=jpg&name=small 24 | https://pbs.twimg.com/media/CnSEaFeUAAEbXnq?format=jpg&name=small 25 | https://pbs.twimg.com/media/ClznN9pUgAA_Pny?format=jpg&name=small 26 | https://pbs.twimg.com/media/ClUetagUkAAzbuQ?format=jpg&name=small 27 | https://pbs.twimg.com/media/Ck_jOb2UoAAUuK8?format=jpg&name=small 28 | https://pbs.twimg.com/media/CkrfRflUkAQnAAH?format=jpg&name=small 29 | https://pbs.twimg.com/media/CkgoLZCUkAQ06Eo?format=jpg&name=small 30 | https://pbs.twimg.com/media/CkRjV9bUUAI44Jv?format=jpg&name=small 31 | https://pbs.twimg.com/media/CkBf5FHUUAAbzHX?format=jpg&name=small 32 | https://pbs.twimg.com/media/CjoS9iRUgAInjrV?format=jpg&name=small 33 | https://pbs.twimg.com/media/Celg-JpXIAAIYbX?format=jpg&name=small 34 | https://pbs.twimg.com/media/Cd19L4MUUAEoO2a?format=jpg&name=small 35 | https://pbs.twimg.com/media/Cdo5IlEVAAE9RgN?format=jpg&name=small 36 | https://pbs.twimg.com/media/CbwZUNJUYAAmc4J?format=jpg&name=small 37 | https://pbs.twimg.com/media/CanmkoMUEAECbni?format=jpg&name=small 38 | https://pbs.twimg.com/media/CadKV_vUsAA4wWp?format=jpg&name=small 39 | https://pbs.twimg.com/media/CaN-hTXUkAAdD2c?format=jpg&name=small 40 | https://pbs.twimg.com/media/CYYoi78UwAA0ppl?format=jpg&name=small 41 | https://pbs.twimg.com/media/CWwmtePVAAEdwGe?format=jpg&name=small 42 | https://pbs.twimg.com/media/CWcIPzOU8AEmSxt?format=jpg&name=small 43 | https://pbs.twimg.com/media/CWSNxUvUkAAyXWI?format=jpg&name=small 44 | https://pbs.twimg.com/media/CWNCxN5UEAA-LS4?format=jpg&name=small 45 | https://pbs.twimg.com/media/CWCq5FoUkAAL3TS?format=jpg&name=small 46 | https://pbs.twimg.com/media/CV9q7FbUwAAxXJI?format=jpg&name=small 47 | https://pbs.twimg.com/media/CV4dKLHUkAAqx-U?format=jpg&name=small 48 | https://pbs.twimg.com/media/CVzScbfU8AAfMtQ?format=jpg&name=small 49 | https://pbs.twimg.com/media/CVuK_oXVEAAASys?format=jpg&name=small 50 | https://pbs.twimg.com/media/CVjtRFgUEAANenZ?format=jpg&name=small 51 | https://pbs.twimg.com/media/CVeNGj1UAAAMfZ-?format=jpg&name=small 52 | https://pbs.twimg.com/media/CVZbedeUwAAG0b_?format=jpg&name=small 53 | https://pbs.twimg.com/media/CVUPPflUYAAfJYH?format=jpg&name=small 54 | https://pbs.twimg.com/media/CVPEUW1VAAEaRgH?format=jpg&name=small 55 | https://pbs.twimg.com/media/CVJgGuZUsAA-XC6?format=jpg&name=small 56 | https://pbs.twimg.com/media/CU_XrfBUYAAWAvd?format=jpg&name=small 57 | https://pbs.twimg.com/media/CU5Sa-vVEAAS1mj?format=jpg&name=small 58 | https://pbs.twimg.com/media/CU07R2EUcAASt9W?format=jpg&name=small 59 | https://pbs.twimg.com/media/CUwIGMUUkAIWjga?format=jpg&name=small 60 | https://pbs.twimg.com/media/CUqtgTlVEAA7QGm?format=jpg&name=small 61 | https://pbs.twimg.com/media/CUlwyDIUYAAAlA-?format=jpg&name=small 62 | https://pbs.twimg.com/media/CUgN9V8VAAAA_Eb?format=jpg&name=small 63 | https://pbs.twimg.com/media/CUbYbHqUYAAx_uO?format=jpg&name=small 64 | https://pbs.twimg.com/media/CUWVDYSUcAAqudf?format=jpg&name=small 65 | https://pbs.twimg.com/media/CUQ-dC4VEAAVr_a?format=jpg&name=small 66 | https://pbs.twimg.com/media/CULuUk9U8AErP_1?format=jpg&name=small 67 | https://pbs.twimg.com/media/CUGt0HBUcAAey3c?format=jpg&name=small 68 | -------------------------------------------------------------------------------- /data/csvs/squirrel_url.csv: -------------------------------------------------------------------------------- 1 | https://pbs.twimg.com/media/CGJpRN9UUAA072u?format=jpg&name=900x900 2 | https://pbs.twimg.com/media/CFJ_k1hUUAAUg_G?format=jpg&name=small 3 | https://pbs.twimg.com/media/CEq-RjlUkAAhxBD?format=jpg&name=small 4 | https://pbs.twimg.com/media/CEVd8rLUEAAmEX6?format=jpg&name=small 5 | https://pbs.twimg.com/media/CGJpRN9UUAA072u?format=jpg&name=240x240 6 | https://pbs.twimg.com/media/CFJ_k1hUUAAUg_G?format=jpg&name=240x240 7 | https://pbs.twimg.com/media/CEq-RjlUkAAhxBD?format=jpg&name=120x120 8 | https://pbs.twimg.com/media/CEVd8rLUEAAmEX6?format=jpg&name=120x120 9 | https://pbs.twimg.com/media/CESVog1WIAMjWgc?format=jpg&name=240x240 10 | https://pbs.twimg.com/media/CELHptGUEAAEW68?format=jpg&name=120x120 11 | https://pbs.twimg.com/media/CESVog1WIAMjWgc?format=jpg&name=small 12 | https://pbs.twimg.com/media/CELHptGUEAAEW68?format=jpg&name=small 13 | https://pbs.twimg.com/media/CD-pb1EUIAAccki?format=jpg&name=small 14 | https://pbs.twimg.com/media/CDjRWy5UkAAal8m?format=jpg&name=small 15 | https://pbs.twimg.com/media/CDTgGkMVEAAjpkD?format=jpg&name=small 16 | https://pbs.twimg.com/media/CDIU44TW4AEb8RM?format=jpg&name=small 17 | https://pbs.twimg.com/media/CDDzb12UMAAD21k?format=jpg&name=small 18 | https://pbs.twimg.com/media/CC_vR9XUsAEYvFG?format=jpg&name=small 19 | https://pbs.twimg.com/media/CC022d-UEAA8VBS?format=jpg&name=small 20 | https://pbs.twimg.com/media/CCZNZpRUMAA0abA?format=jpg&name=small 21 | https://pbs.twimg.com/media/CCAgOgrWYAAoAgl?format=jpg&name=small 22 | https://pbs.twimg.com/media/CB8mgHvUsAACxcQ?format=jpg&name=small 23 | https://pbs.twimg.com/media/CB2GgvxVIAIYhjJ?format=jpg&name=small 24 | https://pbs.twimg.com/media/CBsXcOSVEAAFTP3?format=jpg&name=small 25 | https://pbs.twimg.com/media/CBmeXbWUEAANPKt?format=jpg&name=small 26 | https://pbs.twimg.com/media/CBc-_SGVEAA2pi8?format=jpg&name=small 27 | https://pbs.twimg.com/media/CBZYi93VEAEmoVR?format=jpg&name=small 28 | https://pbs.twimg.com/media/CBSQO_xUkAAIZAM?format=jpg&name=small 29 | https://pbs.twimg.com/media/CBOj4QGUIAAoMsL?format=jpg&name=small 30 | https://pbs.twimg.com/media/CBJgBxLVAAEws4E?format=jpg&name=small 31 | https://pbs.twimg.com/media/CA_bR6kVIAAUBBp?format=jpg&name=small 32 | https://pbs.twimg.com/media/CA-MgjlUkAAfBqa?format=jpg&name=900x900 33 | https://pbs.twimg.com/media/CA6x5hjWcAMiciK?format=jpg&name=small 34 | https://pbs.twimg.com/media/CA5pDrmWUAAqtRf?format=jpg&name=small 35 | https://pbs.twimg.com/media/CA0zRvgUIAEjxPv?format=jpg&name=small 36 | https://pbs.twimg.com/media/CAuzzztU0AAjBD3?format=jpg&name=small 37 | https://pbs.twimg.com/media/CAt5FntU8AAEwdl?format=jpg&name=small 38 | https://pbs.twimg.com/media/CAp3fUzUkAAU6wy?format=jpg&name=900x900 39 | https://pbs.twimg.com/media/CAoqKNbUwAAebSO?format=jpg&name=small 40 | https://pbs.twimg.com/media/CAkEMwEUsAAHhN5?format=jpg&name=small 41 | https://pbs.twimg.com/media/CAjZ-IJU8AEAWMk?format=jpg&name=small 42 | https://pbs.twimg.com/media/CAfu4bDUIAIF_IT?format=jpg&name=small 43 | https://pbs.twimg.com/media/CAakDvMUMAAfl8k?format=jpg&name=small 44 | https://pbs.twimg.com/media/CAZAG1VUQAExFm9?format=jpg&name=small 45 | https://pbs.twimg.com/media/CAWj2xlUwAArha_?format=jpg&name=small 46 | https://pbs.twimg.com/media/CAUr518UcAAW1E5?format=jpg&name=small 47 | https://pbs.twimg.com/media/CATY3N1UwAA44zh?format=jpg&name=small 48 | https://pbs.twimg.com/media/CARhy01UUAE7ztM?format=jpg&name=small 49 | https://pbs.twimg.com/media/CARBtfSUYAEmqtr?format=jpg&name=900x900 50 | https://pbs.twimg.com/media/CAQCcusUQAAGli3?format=jpg&name=small 51 | https://pbs.twimg.com/media/CAPyCaFVAAA4CIT?format=jpg&name=small 52 | https://pbs.twimg.com/media/CAPGIG6UcAAUUyd?format=jpg&name=small 53 | https://pbs.twimg.com/media/CAPD10cUIAAx-qQ?format=jpg&name=small 54 | https://pbs.twimg.com/media/CANEqgRUkAA2HtV?format=jpg&name=small 55 | https://pbs.twimg.com/media/CANEjstUUAEkhOw?format=jpg&name=small 56 | https://pbs.twimg.com/media/CAM-l-8UYAEHIWR?format=jpg&name=small 57 | https://pbs.twimg.com/media/CAM8kacU8AAMDuS?format=jpg&name=small 58 | https://pbs.twimg.com/media/CAM8hZ_UcAAfORR?format=jpg&name=small 59 | https://pbs.twimg.com/media/CAM8daSVEAA1JCj?format=jpg&name=small 60 | -------------------------------------------------------------------------------- /data/emojis/bot.csv: -------------------------------------------------------------------------------- 1 | https://cdn.discordapp.com/attachments/885950387755036733/885951524222672976/loading.gif 2 | https://cdn.discordapp.com/attachments/885950387755036733/885951526214979644/help.png 3 | https://cdn.discordapp.com/attachments/885950387755036733/885951528127586324/checkmark.png 4 | https://cdn.discordapp.com/attachments/885950387755036733/885951529813696522/failed.png 5 | https://cdn.discordapp.com/attachments/885950387755036733/885951531353010206/warn.png 6 | https://cdn.discordapp.com/attachments/885950387755036733/885951546112749608/error.png 7 | https://cdn.discordapp.com/attachments/885950387755036733/885951547807256616/announce.png 8 | https://cdn.discordapp.com/attachments/885950387755036733/885951549325590528/1234.png 9 | https://cdn.discordapp.com/attachments/885950387755036733/885951550898438245/info.png 10 | https://cdn.discordapp.com/attachments/885950387755036733/885951552882356294/exclamation.png 11 | https://cdn.discordapp.com/attachments/885950387755036733/885951568153804820/trash.png 12 | https://cdn.discordapp.com/attachments/885950387755036733/885951570183856168/forward.png 13 | https://cdn.discordapp.com/attachments/885950387755036733/885951571685412894/forward2.png 14 | https://cdn.discordapp.com/attachments/885950387755036733/885951573417672724/backward.png 15 | https://cdn.discordapp.com/attachments/885950387755036733/885951575200239696/backward2.png 16 | https://cdn.discordapp.com/attachments/885950387755036733/885951590203260988/desktop.png 17 | https://cdn.discordapp.com/attachments/885950387755036733/885951592266870825/mobile.png 18 | https://cdn.discordapp.com/attachments/885950387755036733/885951594259181568/web.png 19 | https://cdn.discordapp.com/attachments/885950387755036733/885951596402454558/online.png 20 | https://cdn.discordapp.com/attachments/885950387755036733/885951598008889374/offline.png 21 | https://cdn.discordapp.com/attachments/885950387755036733/885951612412133376/dnd.png 22 | https://cdn.discordapp.com/attachments/885950387755036733/885951614626693160/idle.png 23 | https://cdn.discordapp.com/attachments/885950387755036733/885951616811958312/owner.png 24 | https://cdn.discordapp.com/attachments/885950387755036733/885951619924099092/emoji.png 25 | https://cdn.discordapp.com/attachments/885950387755036733/885951621484380220/members.png 26 | https://cdn.discordapp.com/attachments/885950387755036733/885951634214117426/categories.png 27 | https://cdn.discordapp.com/attachments/885950387755036733/885951635656958003/textchannel.png 28 | https://cdn.discordapp.com/attachments/885950387755036733/885951637322096710/voicechannel.png 29 | https://cdn.discordapp.com/attachments/885950387755036733/885951638790094868/messages.png 30 | https://cdn.discordapp.com/attachments/885950387755036733/885951640530714685/command.png 31 | https://cdn.discordapp.com/attachments/885950387755036733/885951656494264340/role.png 32 | https://cdn.discordapp.com/attachments/885950387755036733/885951658364903517/invite.png 33 | https://cdn.discordapp.com/attachments/885950387755036733/885951660613066752/bot.png 34 | https://cdn.discordapp.com/attachments/885950387755036733/885951663045750824/question.png 35 | https://cdn.discordapp.com/attachments/885950387755036733/885951665109348362/lock.png 36 | https://cdn.discordapp.com/attachments/885950387755036733/885951678363353119/unlock.png 37 | https://cdn.discordapp.com/attachments/885950387755036733/885951680108195881/letter.png 38 | https://cdn.discordapp.com/attachments/885950387755036733/885951682318577705/num0.png 39 | https://cdn.discordapp.com/attachments/885950387755036733/885951683937579008/num1.png 40 | https://cdn.discordapp.com/attachments/885950387755036733/885951685409771571/num2.png 41 | https://cdn.discordapp.com/attachments/885950387755036733/885951700538638336/num3.png 42 | https://cdn.discordapp.com/attachments/885950387755036733/885951702845521941/num4.png 43 | https://cdn.discordapp.com/attachments/885950387755036733/885951704921669642/num5.png 44 | https://cdn.discordapp.com/attachments/885950387755036733/885951707601850428/num6.png 45 | https://cdn.discordapp.com/attachments/885950387755036733/885951709409607750/num7.png 46 | https://cdn.discordapp.com/attachments/885950387755036733/885951722466459668/num8.png 47 | https://cdn.discordapp.com/attachments/885950387755036733/885951724685254756/num9.png 48 | https://cdn.discordapp.com/attachments/885950387755036733/885951726396514374/stop.png 49 | https://cdn.discordapp.com/attachments/885950387755036733/885951727952609280/stopsign.png 50 | https://cdn.discordapp.com/attachments/885950387755036733/885951730255282196/clock.png 51 | https://cdn.discordapp.com/attachments/885950387755036733/885951744507519016/alarm.png 52 | https://cdn.discordapp.com/attachments/885950387755036733/885951746311086160/stopwatch.png 53 | https://cdn.discordapp.com/attachments/885950387755036733/885951748294983720/log.png 54 | https://cdn.discordapp.com/attachments/885950387755036733/885951750484418560/database.png 55 | https://cdn.discordapp.com/attachments/885950387755036733/885951752275365898/privacy.png 56 | https://cdn.discordapp.com/attachments/885950387755036733/885951766561165332/deletedata.png 57 | https://cdn.discordapp.com/attachments/885950387755036733/885951768322789496/heart.png 58 | https://cdn.discordapp.com/attachments/885950387755036733/885951770164080650/graph.png 59 | https://cdn.discordapp.com/attachments/885950387755036733/885951772093468772/upload.png 60 | https://cdn.discordapp.com/attachments/885950387755036733/885951773867638804/download.png 61 | https://cdn.discordapp.com/attachments/885950387755036733/885951789105561620/right.png 62 | https://cdn.discordapp.com/attachments/885950387755036733/885951791005597736/kick.png 63 | https://cdn.discordapp.com/attachments/885950387755036733/885951793949982790/ban.png 64 | https://cdn.discordapp.com/attachments/885950387755036733/885951795527057418/robot.png 65 | https://cdn.discordapp.com/attachments/885950387755036733/885951797120860170/plus.png 66 | https://cdn.discordapp.com/attachments/885950387755036733/885951811037581322/minus.png 67 | https://cdn.discordapp.com/attachments/885950387755036733/885951813117968394/undo.png 68 | https://cdn.discordapp.com/attachments/885950387755036733/885951814644662292/redo.png 69 | https://cdn.discordapp.com/attachments/885950387755036733/885951816498569256/audioadd.png 70 | https://cdn.discordapp.com/attachments/885950387755036733/885951817861726238/audioremove.png 71 | https://cdn.discordapp.com/attachments/885950387755036733/885951832873132142/pin.png 72 | https://cdn.discordapp.com/attachments/885950387755036733/885951834458587197/pass.png 73 | https://cdn.discordapp.com/attachments/885950387755036733/885951836526362654/fail.png 74 | https://cdn.discordapp.com/attachments/885950387755036733/885951838912925696/snowflake.png 75 | https://cdn.discordapp.com/attachments/885950387755036733/885951840880033802/botdev.png 76 | https://cdn.discordapp.com/attachments/885950387755036733/885951855178432512/user.png 77 | https://cdn.discordapp.com/attachments/885950387755036733/885951857841811486/join.png 78 | https://cdn.discordapp.com/attachments/885950387755036733/885951859628589056/leave.png 79 | https://cdn.discordapp.com/attachments/885950387755036733/885951861138522182/nitro.png 80 | https://cdn.discordapp.com/attachments/885950387755036733/885951862761730058/staff.png 81 | https://cdn.discordapp.com/attachments/885950387755036733/885951878674919474/partner.png 82 | https://cdn.discordapp.com/attachments/885950387755036733/885951881157951558/boost.png 83 | https://cdn.discordapp.com/attachments/885950387755036733/885951883297054720/balance.png 84 | https://cdn.discordapp.com/attachments/885950387755036733/885951885448716288/brilliance.png 85 | https://cdn.discordapp.com/attachments/885950387755036733/885951888825135144/bravery.png 86 | https://cdn.discordapp.com/attachments/885950387755036733/885951901194141696/moderator.png 87 | https://cdn.discordapp.com/attachments/885950387755036733/885951904176291900/bughuntergold.png 88 | https://cdn.discordapp.com/attachments/885950387755036733/885951905996623902/bughunter.png 89 | https://cdn.discordapp.com/attachments/885950387755036733/885951907804364850/supporter.png 90 | https://cdn.discordapp.com/attachments/885950387755036733/885951909817638922/hypesquad.png 91 | https://cdn.discordapp.com/attachments/885950387755036733/885951923189059584/like.png 92 | https://cdn.discordapp.com/attachments/885950387755036733/885951924875186196/dislike.png 93 | https://cdn.discordapp.com/attachments/885950387755036733/885951927282696192/youtube.png 94 | https://cdn.discordapp.com/attachments/885950387755036733/885951929228869652/pause.png 95 | https://cdn.discordapp.com/attachments/885950387755036733/885951931594440795/play.png 96 | https://cdn.discordapp.com/attachments/885950387755036733/885951947386011648/music.png 97 | https://cdn.discordapp.com/attachments/885950387755036733/885951949437026395/volume.png 98 | https://cdn.discordapp.com/attachments/885950387755036733/885951951710351370/skip.png 99 | https://cdn.discordapp.com/attachments/885950387755036733/885951954180780042/wave.png 100 | https://cdn.discordapp.com/attachments/885950387755036733/885951956026261574/loop.png 101 | https://cdn.discordapp.com/attachments/885950387755036733/885951969410293800/shuffle.png 102 | https://cdn.discordapp.com/attachments/885950387755036733/885951971742339122/github.png 103 | https://cdn.discordapp.com/attachments/885950387755036733/885951973776580608/admin.png 104 | https://cdn.discordapp.com/attachments/885950387755036733/885951975064219708/web.png 105 | https://cdn.discordapp.com/attachments/885950387755036733/885951976834207864/clarinet.png -------------------------------------------------------------------------------- /data/emojis/roo.csv: -------------------------------------------------------------------------------- 1 | https://cdn.discordapp.com/attachments/885945653409230878/885949993473679370/rooZoom.png 2 | https://cdn.discordapp.com/attachments/885945653409230878/885949994920726599/rooBot.png 3 | https://cdn.discordapp.com/attachments/885945653409230878/885949998800437278/rooRun.gif 4 | https://cdn.discordapp.com/attachments/885945653409230878/885950001258332260/rooEww.png 5 | https://cdn.discordapp.com/attachments/885945653409230878/885950013698613298/rooBlink.gif 6 | https://cdn.discordapp.com/attachments/885945653409230878/885950017045688361/rooFacepalm.png 7 | https://cdn.discordapp.com/attachments/885945653409230878/885950018916331591/rooPeek.png 8 | https://cdn.discordapp.com/attachments/885945653409230878/885950022053679135/rooSword.png 9 | https://cdn.discordapp.com/attachments/885945653409230878/885950031729950730/rooSipCold.gif 10 | https://cdn.discordapp.com/attachments/885945653409230878/885950034586251294/rooHazmat.png 11 | https://cdn.discordapp.com/attachments/885945653409230878/885950039640379432/rooBlanketClap.gif 12 | https://cdn.discordapp.com/attachments/885945653409230878/885950042182139964/rooCringe.png 13 | https://cdn.discordapp.com/attachments/885945653409230878/885950043859861585/rooBlanket.png 14 | https://cdn.discordapp.com/attachments/885945653409230878/885950045545988106/rooBlush.png 15 | https://cdn.discordapp.com/attachments/885945653409230878/885950047156600832/rooUhh.png 16 | https://cdn.discordapp.com/attachments/885945653409230878/885950061702422528/rooSmug.gif 17 | https://cdn.discordapp.com/attachments/885945653409230878/885950065892548628/rooPog.png 18 | https://cdn.discordapp.com/attachments/885945653409230878/885950068119711774/rooDizzy.png 19 | https://cdn.discordapp.com/attachments/885945653409230878/885950069680009216/rooPonder.png 20 | https://cdn.discordapp.com/attachments/885945653409230878/885950071475171358/rooHappy.png 21 | https://cdn.discordapp.com/attachments/885945653409230878/885950083932246087/rooBlank.png 22 | https://cdn.discordapp.com/attachments/885945653409230878/885950086113280041/rooBullied.png 23 | https://cdn.discordapp.com/attachments/885945653409230878/885950092245360730/rooClap.gif 24 | https://cdn.discordapp.com/attachments/885945653409230878/885950094053113937/rooCry.png 25 | https://cdn.discordapp.com/attachments/885945653409230878/885950096657764382/rooJump.gif 26 | https://cdn.discordapp.com/attachments/885945653409230878/885950113757925396/rooSipMad.gif 27 | https://cdn.discordapp.com/attachments/885945653409230878/885950116194816000/rooDoor.gif 28 | https://cdn.discordapp.com/attachments/885945653409230878/885950128119246848/rooSquishy.gif 29 | https://cdn.discordapp.com/attachments/885945653409230878/885950131952836658/rooSipHappy.gif 30 | https://cdn.discordapp.com/attachments/885945653409230878/885950135199203368/rooEyes.gif 31 | https://cdn.discordapp.com/attachments/885945653409230878/885950137774522399/rooMath.png 32 | https://cdn.discordapp.com/attachments/885945653409230878/885950140031049728/rooBarf.png 33 | https://cdn.discordapp.com/attachments/885945653409230878/885950141801037844/rooNoodle.gif 34 | https://cdn.discordapp.com/attachments/885945653409230878/885950143818530856/rooLick.png 35 | https://cdn.discordapp.com/attachments/885945653409230878/885950157345148978/rooDead.png 36 | https://cdn.discordapp.com/attachments/885945653409230878/885950159773650984/rooPoggers.png 37 | https://cdn.discordapp.com/attachments/885945653409230878/885950164513210448/rooCat.png 38 | https://cdn.discordapp.com/attachments/885945653409230878/885950167017197629/rooShy.png 39 | https://cdn.discordapp.com/attachments/885945653409230878/885950169051459614/rooAngel.png 40 | https://cdn.discordapp.com/attachments/885945653409230878/885950179600117860/rooSquish.png 41 | https://cdn.discordapp.com/attachments/885945653409230878/885950185916739644/rooWee.gif 42 | https://cdn.discordapp.com/attachments/885945653409230878/885950194045308988/rooEnraged.gif 43 | https://cdn.discordapp.com/attachments/885945653409230878/885950200760397835/rooCoffee.gif 44 | https://cdn.discordapp.com/attachments/885945653409230878/885950210004627506/rooWave.gif 45 | https://cdn.discordapp.com/attachments/885945653409230878/885950212793835561/rooSalute.png 46 | https://cdn.discordapp.com/attachments/885945653409230878/885950217915072522/rooLeave.gif 47 | https://cdn.discordapp.com/attachments/885945653409230878/885950220263915542/rooKill.png 48 | https://cdn.discordapp.com/attachments/885945653409230878/885950226756677642/rooTalk.gif 49 | https://cdn.discordapp.com/attachments/885945653409230878/885950233605984296/rooPointMad.png 50 | https://cdn.discordapp.com/attachments/885945653409230878/885950239855493210/rooPointPog.png 51 | https://cdn.discordapp.com/attachments/885945653409230878/885950243781361725/rooPointClown.png 52 | https://cdn.discordapp.com/attachments/885945653409230878/885950247937904720/rooPointSad.png 53 | https://cdn.discordapp.com/attachments/885945653409230878/885950250479669328/rooParty.png 54 | https://cdn.discordapp.com/attachments/885945653409230878/885950255525408828/rooSpy.gif 55 | https://cdn.discordapp.com/attachments/885945653409230878/885950265688199168/rooPeek.gif 56 | https://cdn.discordapp.com/attachments/885945653409230878/885950273770651648/rooPat.gif 57 | https://cdn.discordapp.com/attachments/885945653409230878/885950276056518696/rooNap.png 58 | https://cdn.discordapp.com/attachments/885945653409230878/885950277771989042/rooGun.png 59 | https://cdn.discordapp.com/attachments/885945653409230878/885950279349059644/rooHandsUp.png 60 | https://cdn.discordapp.com/attachments/885945653409230878/885950284063461376/rooEat.gif 61 | https://cdn.discordapp.com/attachments/885945653409230878/885950303923470357/rooSneeze.gif 62 | https://cdn.discordapp.com/attachments/885945653409230878/885950306314248242/rooSad.png 63 | https://cdn.discordapp.com/attachments/885945653409230878/885950309493510174/rooThink.png 64 | https://cdn.discordapp.com/attachments/885945653409230878/885950311909441576/rooDab.png 65 | https://cdn.discordapp.com/attachments/885945653409230878/885950314497343508/rooCoolSip.png 66 | https://cdn.discordapp.com/attachments/885945653409230878/885950326186868808/rooCool.png 67 | https://cdn.discordapp.com/attachments/885945653409230878/885950327944265758/rooCop.png 68 | https://cdn.discordapp.com/attachments/885945653409230878/885950331110977546/rooAngel.png 69 | https://cdn.discordapp.com/attachments/885945653409230878/885950333682077696/rooRich.png 70 | https://cdn.discordapp.com/attachments/885945653409230878/885950335380758558/rooDuck.png 71 | https://cdn.discordapp.com/attachments/885945653409230878/885950357795131452/rooSad.gif 72 | https://cdn.discordapp.com/attachments/885945653409230878/885950360139751494/rooFat.png 73 | https://cdn.discordapp.com/attachments/885945653409230878/885950362832494623/rooFire.png 74 | https://cdn.discordapp.com/attachments/885945653409230878/885950366594789436/rooPopcorn.png 75 | https://cdn.discordapp.com/attachments/885945653409230878/885950368402534460/rooSleep.png 76 | https://cdn.discordapp.com/attachments/885945653409230878/885950380041715712/rooMean.png 77 | https://cdn.discordapp.com/attachments/885945653409230878/885950386601590805/rooLove.gif 78 | https://cdn.discordapp.com/attachments/885945653409230878/885950389109792768/roo.gif 79 | https://cdn.discordapp.com/attachments/885945653409230878/885950396105916466/rooFight.gif 80 | https://cdn.discordapp.com/attachments/885945653409230878/885950398769299496/rooAww.png 81 | https://cdn.discordapp.com/attachments/885945653409230878/885950418876784751/rooCool.gif 82 | https://cdn.discordapp.com/attachments/885945653409230878/885950421460451338/rooSweat.png 83 | https://cdn.discordapp.com/attachments/885945653409230878/885950423893176361/rooClapping.gif 84 | https://cdn.discordapp.com/attachments/885945653409230878/885950426627833866/rooJedi.png 85 | https://cdn.discordapp.com/attachments/885945653409230878/885950428502712320/rooPJS.png 86 | https://cdn.discordapp.com/attachments/885945653409230878/885950441786056744/rooLove.png 87 | https://cdn.discordapp.com/attachments/885945653409230878/885950443627347998/rooDerp.png 88 | https://cdn.discordapp.com/attachments/885945653409230878/885950448400498708/rooPing.png 89 | https://cdn.discordapp.com/attachments/885945653409230878/885950450673803274/rooPumpkin.png 90 | https://cdn.discordapp.com/attachments/885945653409230878/885950452687044648/rooDevil.png 91 | https://cdn.discordapp.com/attachments/885945653409230878/885950463927779338/rooMissYou.png 92 | https://cdn.discordapp.com/attachments/885945653409230878/885950475181101086/rooHammerPing.gif 93 | https://cdn.discordapp.com/attachments/885945653409230878/885950479031480320/rooTrain.gif 94 | https://cdn.discordapp.com/attachments/885945653409230878/885950481741017118/rooWizard.png 95 | https://cdn.discordapp.com/attachments/885945653409230878/885950484203053088/rooWhine.png 96 | https://cdn.discordapp.com/attachments/885945653409230878/885950486807736340/rooDracula.png 97 | https://cdn.discordapp.com/attachments/885945653409230878/885950488770641930/rooPirate.png 98 | https://cdn.discordapp.com/attachments/885945653409230878/885950490632945774/rooSanta.png 99 | https://cdn.discordapp.com/attachments/885945653409230878/885950492809756712/rooIce.png 100 | https://cdn.discordapp.com/attachments/885945653409230878/885950496068743168/rooChemist.png 101 | https://cdn.discordapp.com/attachments/885945653409230878/885950509612163102/rooSick.png 102 | https://cdn.discordapp.com/attachments/885945653409230878/885950511843520542/rooSnowman.png 103 | https://cdn.discordapp.com/attachments/885945653409230878/885950514347540500/rooGift.png 104 | https://cdn.discordapp.com/attachments/885945653409230878/885950517476458506/rooWorker.png 105 | https://cdn.discordapp.com/attachments/885945653409230878/885950519598809118/rooBeanie.png 106 | https://cdn.discordapp.com/attachments/885945653409230878/885950532534013952/rooSmush.png 107 | https://cdn.discordapp.com/attachments/885945653409230878/885950535113535558/rooSnowball.gif 108 | https://cdn.discordapp.com/attachments/885945653409230878/885950537713991751/rooYes.png 109 | https://cdn.discordapp.com/attachments/885945653409230878/885950539953766480/rooHeart.png 110 | https://cdn.discordapp.com/attachments/885945653409230878/885950541656625202/rooNo.png 111 | https://cdn.discordapp.com/attachments/885945653409230878/885950555124563968/rooCookie.png 112 | https://cdn.discordapp.com/attachments/885945653409230878/885950557645340672/rooCozy.png 113 | https://cdn.discordapp.com/attachments/885945653409230878/885950559331442738/rooBlindfold.png 114 | https://cdn.discordapp.com/attachments/885945653409230878/885950561667645460/rooSanta.png 115 | https://cdn.discordapp.com/attachments/885945653409230878/885950565052452984/rooElf.png 116 | https://cdn.discordapp.com/attachments/885945653409230878/885950577698275358/rooPainter.png 117 | https://cdn.discordapp.com/attachments/885945653409230878/885950580080652288/rooWut.png 118 | https://cdn.discordapp.com/attachments/885945653409230878/885950582710468628/rooSquint.png 119 | https://cdn.discordapp.com/attachments/885945653409230878/885950585260630097/rooUhm.png 120 | https://cdn.discordapp.com/attachments/885945653409230878/885950588863528970/rooSpoon.png 121 | https://cdn.discordapp.com/attachments/885945653409230878/885950600372711504/rooOrgasm.png 122 | https://cdn.discordapp.com/attachments/885945653409230878/885950603287744512/rooWhistle.png 123 | https://cdn.discordapp.com/attachments/885945653409230878/885950605741400064/rooSmh.png 124 | https://cdn.discordapp.com/attachments/885945653409230878/885950607637250138/rooWorried.png 125 | https://cdn.discordapp.com/attachments/885945653409230878/885950609314971669/rooLurk.png 126 | https://cdn.discordapp.com/attachments/885945653409230878/885950624410255430/rooConfused.png 127 | https://cdn.discordapp.com/attachments/885945653409230878/885950631939043398/rooReaper.png 128 | https://cdn.discordapp.com/attachments/885945653409230878/885950636426924082/rooTrash.gif 129 | https://cdn.discordapp.com/attachments/885945653409230878/885950638675083274/rooBackpack.png 130 | https://cdn.discordapp.com/attachments/885945653409230878/885950643917967432/rooSack.png 131 | https://cdn.discordapp.com/attachments/885945653409230878/885950649060175962/rooFatSanta.png 132 | https://cdn.discordapp.com/attachments/885945653409230878/885950653011222568/rooFish.png 133 | https://cdn.discordapp.com/attachments/885945653409230878/885950656660242433/rooWtf.png 134 | https://cdn.discordapp.com/attachments/885945653409230878/885950658598015056/rooInvert.png -------------------------------------------------------------------------------- /data/scripts/authorization.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE TABLE IF NOT EXISTS spotify_auth ( 3 | user_id BIGINT PRIMARY KEY, 4 | token_info JSONB DEFAULT '{}'::JSONB 5 | ); 6 | 7 | 8 | CREATE TABLE IF NOT EXISTS discord_auth ( 9 | user_id BIGINT PRIMARY KEY, 10 | token_info JSONB DEFAULT '{}'::JSONB 11 | ); -------------------------------------------------------------------------------- /data/scripts/config.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS config ( 2 | client_id BIGINT PRIMARY KEY, 3 | presence TEXT NOT NULL DEFAULT '', 4 | activity TEXT NOT NULL DEFAULT '', 5 | status TEXT, 6 | version REAL DEFAULT 1, 7 | ownerlocked BOOLEAN DEFAULT False, 8 | reboot_invoker TEXT, 9 | reboot_message_id BIGINT, 10 | reboot_channel_id BIGINT, 11 | reboot_count BIGINT NOT NULL DEFAULT 0, 12 | runtime DOUBLE PRECISION DEFAULT 0.0 NOT NULL, 13 | starttime DOUBLE PRECISION DEFAULT EXTRACT(EPOCH FROM NOW()), 14 | last_run DOUBLE PRECISION 15 | ); -------------------------------------------------------------------------------- /data/scripts/music.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS playlists ( 2 | id BIGSERIAL PRIMARY KEY, 3 | owner_id BIGINT, 4 | name TEXT, 5 | queue JSONB DEFAULT '{}'::JSONB, 6 | uses BIGINT NOT NULL DEFAULT 0, 7 | likes BIGINT NOT NULL DEFAULT 0, 8 | insertion TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') 9 | ); 10 | CREATE UNIQUE INDEX IF NOT EXISTS playlists_idx ON playlists(owner_id, name); 11 | 12 | -- Automatically saved songs 13 | CREATE TABLE IF NOT EXISTS tracks ( 14 | id BIGSERIAL PRIMARY KEY, 15 | requester_id BIGINT, 16 | title TEXT, 17 | url TEXT, 18 | uploader TEXT, 19 | insertion TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') 20 | ); 21 | 22 | -- User specifically saved songs 23 | CREATE TABLE IF NOT EXISTS saved ( 24 | id BIGSERIAL PRIMARY KEY, 25 | requester_id BIGINT, 26 | title TEXT, 27 | url TEXT, 28 | uploader TEXT, 29 | insertion TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') 30 | ); 31 | 32 | CREATE TABLE IF NOT EXISTS musicconf ( 33 | server_id BIGINT PRIMARY KEY, 34 | bind BIGINT, 35 | djrole BIGINT, 36 | djlock BOOLEAN NOT NULL DEFAULT False 37 | ); 38 | 39 | CREATE TABLE IF NOT EXISTS voicetime ( 40 | server_id BIGINT PRIMARY KEY, 41 | user_id BIGINT, 42 | seconds DOUBLE PRECISION NOT NULL DEFAULT 0.0, 43 | lastchanged DOUBLE PRECISION NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()), 44 | starttime DOUBLE PRECISION NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) 45 | ); 46 | 47 | CREATE TABLE IF NOT EXISTS spotify ( 48 | id BIGSERIAL PRIMARY KEY, 49 | user_id BIGINT, 50 | album_id TEXT, 51 | artist_id TEXT, 52 | track_id TEXT, 53 | insertion TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') 54 | ); -------------------------------------------------------------------------------- /data/scripts/servers.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS servers ( 2 | server_id BIGINT PRIMARY KEY, 3 | muterole BIGINT, 4 | antiinvite BOOLEAN DEFAULT False, 5 | reassign BOOLEAN DEFAULT True, 6 | autoroles BIGINT [] DEFAULT '{}', 7 | profanities TEXT [] DEFAULT '{}' 8 | ); 9 | CREATE TABLE IF NOT EXISTS prefixes ( 10 | server_id BIGINT, 11 | prefix VARCHAR(30), 12 | UNIQUE (server_id, prefix) 13 | ); 14 | CREATE TABLE IF NOT EXISTS logs ( 15 | server_id BIGINT PRIMARY KEY, 16 | avatars BOOLEAN DEFAULT True, 17 | channels BOOLEAN DEFAULT True, 18 | emojis BOOLEAN DEFAULT True, 19 | invites BOOLEAN DEFAULT True, 20 | joins BOOLEAN DEFAULT True, 21 | leaves BOOLEAN DEFAULT True, 22 | messages BOOLEAN DEFAULT True, 23 | moderation BOOLEAN DEFAULT True, 24 | nicknames BOOLEAN DEFAULT True, 25 | usernames BOOLEAN DEFAULT True, 26 | roles BOOLEAN DEFAULT True, 27 | server BOOLEAN DEFAULT True, 28 | voice BOOLEAN DEFAULT True 29 | ); 30 | CREATE TABLE IF NOT EXISTS log_data ( 31 | server_id BIGINT PRIMARY KEY, 32 | channel_id BIGINT, 33 | webhook_id BIGINT, 34 | webhook_token TEXT, 35 | entities BIGINT [] DEFAULT '{}' 36 | ); 37 | CREATE TABLE IF NOT EXISTS command_config ( 38 | id BIGSERIAL PRIMARY KEY, 39 | server_id BIGINT, 40 | entity_id BIGINT, 41 | command TEXT, 42 | insertion TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') 43 | ); 44 | CREATE UNIQUE INDEX IF NOT EXISTS command_config_idx ON command_config(entity_id, command); 45 | CREATE TABLE IF NOT EXISTS plonks ( 46 | id BIGSERIAL PRIMARY KEY, 47 | server_id BIGINT, 48 | entity_id BIGINT, 49 | insertion TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') 50 | ); 51 | CREATE UNIQUE INDEX IF NOT EXISTS permissions_idx ON plonks(server_id, entity_id); 52 | CREATE TABLE IF NOT EXISTS warns ( 53 | id BIGSERIAL PRIMARY KEY, 54 | user_id BIGINT, 55 | server_id BIGINT, 56 | reason TEXT, 57 | insertion TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') 58 | ); 59 | CREATE TABLE IF NOT EXISTS tasks ( 60 | id BIGSERIAL PRIMARY KEY, 61 | expires TIMESTAMP, 62 | created TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC'), 63 | event TEXT, 64 | extra jsonb DEFAULT '{}'::jsonb 65 | ); 66 | CREATE TABLE IF NOT EXISTS invites ( 67 | invitee BIGINT, 68 | inviter BIGINT, 69 | server_id BIGINT 70 | ); 71 | CREATE TABLE IF NOT EXISTS servericons ( 72 | server_id BIGINT, 73 | icon TEXT, 74 | first_seen TIMESTAMP 75 | ); 76 | CREATE TABLE IF NOT EXISTS icons ( 77 | hash TEXT PRIMARY KEY, 78 | url TEXT, 79 | msgid BIGINT, 80 | id bigint, 81 | size bigint, 82 | height bigint, 83 | width bigint, 84 | insertion TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') 85 | ); -------------------------------------------------------------------------------- /data/scripts/stats.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS emojidata ( 2 | index BIGSERIAL PRIMARY KEY, 3 | server_id BIGINT, 4 | author_id BIGINT, 5 | emoji_id BIGINT, 6 | total BIGINT DEFAULT 0 NOT NULL 7 | ); 8 | CREATE UNIQUE INDEX IF NOT EXISTS emojidata_idx ON emojidata(server_id, author_id, emoji_id); 9 | 10 | CREATE TABLE IF NOT EXISTS messages ( 11 | index BIGSERIAL PRIMARY KEY, 12 | unix REAL, 13 | timestamp TIMESTAMP, 14 | -- content TEXT, # DROPPED AND DEPRECATED FOR DISCORD VERIFICATION REQUIREMENTS. 15 | message_id BIGINT, 16 | author_id BIGINT, 17 | channel_id BIGINT, 18 | server_id BIGINT, 19 | deleted BOOLEAN DEFAULT False, 20 | edited BOOLEAN DEFAULT False 21 | ); 22 | 23 | CREATE TABLE IF NOT EXISTS commands ( 24 | index BIGSERIAL PRIMARY KEY, 25 | server_id BIGINT, 26 | channel_id BIGINT, 27 | author_id BIGINT, 28 | timestamp TIMESTAMP, 29 | prefix TEXT, 30 | command TEXT, 31 | failed BOOLEAN 32 | ); -------------------------------------------------------------------------------- /data/scripts/users.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS usernames ( 2 | id BIGSERIAL PRIMARY KEY, 3 | user_id BIGINT, 4 | username TEXT, 5 | insertion TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') 6 | ); 7 | CREATE INDEX IF NOT EXISTS usernames_idx ON usernames(user_id, username); 8 | 9 | -- Deprecated 10 | -- CREATE TABLE IF NOT EXISTS activities ( 11 | -- id BIGSERIAL PRIMARY KEY, 12 | -- user_id BIGINT, 13 | -- activity TEXT, 14 | -- insertion TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') 15 | -- ); 16 | -- CREATE INDEX IF NOT EXISTS activities_idx ON activities(user_id, activity); 17 | 18 | 19 | CREATE TABLE IF NOT EXISTS usernicks ( 20 | id BIGSERIAL PRIMARY KEY, 21 | user_id BIGINT, 22 | server_id BIGINT, 23 | nickname TEXT, 24 | insertion TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') 25 | ); 26 | CREATE INDEX IF NOT EXISTS usernicks_idx ON usernicks(user_id, nickname); 27 | 28 | 29 | CREATE TABLE IF NOT EXISTS userroles ( 30 | user_id BIGINT, 31 | server_id BIGINT, 32 | roles TEXT, 33 | UNIQUE(user_id, server_id) 34 | ); 35 | 36 | CREATE TABLE IF NOT EXISTS tracker ( 37 | user_id BIGINT PRIMARY KEY, 38 | unix NUMERIC, 39 | action TEXT 40 | ); 41 | 42 | CREATE TABLE IF NOT EXISTS userstatus ( 43 | user_id BIGINT PRIMARY KEY, 44 | online DOUBLE PRECISION DEFAULT 0 NOT NULL, 45 | idle DOUBLE PRECISION DEFAULT 0 NOT NULL, 46 | dnd DOUBLE PRECISION DEFAULT 0 NOT NULL, 47 | last_changed DOUBLE PRECISION DEFAULT EXTRACT(EPOCH FROM NOW()), 48 | starttime DOUBLE PRECISION DEFAULT EXTRACT(EPOCH FROM NOW()) 49 | ); 50 | 51 | CREATE TABLE IF NOT EXISTS avatars ( 52 | hash TEXT PRIMARY KEY, 53 | url TEXT, 54 | msgid BIGINT, 55 | id bigint, 56 | size bigint, 57 | height bigint, 58 | width bigint, 59 | insertion TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') 60 | ); 61 | 62 | CREATE TABLE IF NOT EXISTS useravatars ( 63 | user_id BIGINT, 64 | avatar TEXT, 65 | first_seen TIMESTAMP 66 | ); 67 | 68 | 69 | CREATE TABLE IF NOT EXISTS voice ( 70 | server_id BIGINT, 71 | user_id BIGINT, 72 | connected BOOLEAN, 73 | first_seen TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') 74 | ); 75 | 76 | CREATE TABLE IF NOT EXISTS statuses ( 77 | user_id BIGINT, 78 | status TEXT, 79 | first_seen TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') 80 | ); 81 | 82 | CREATE TABLE IF NOT EXISTS whitelist ( 83 | user_id BIGINT PRIMARY KEY 84 | ); -------------------------------------------------------------------------------- /data/txts/changelog.txt: -------------------------------------------------------------------------------- 1 | (2021-04-18 04:39:51.557752+00:00) Add changelog command 2 | (2021-04-18 04:47:58.615907+00:00) Add stopwatch command 3 | (2021-04-18 05:52:34.945789+00:00) Add calculate command 4 | (2021-04-18 18:17:55.180723+00:00) Rewrite blacklisting system 5 | (2021-04-20 18:12:58.939397+00:00) Add dehoist command 6 | (2021-04-21 05:11:56.069368+00:00) Add ascify command 7 | (2021-04-21 05:45:47.548010+00:00) Add massascify command 8 | (2021-04-21 05:46:01.079785+00:00) Add massdehoist command 9 | (2021-04-21 19:26:40.744981+00:00) Add usertimes command 10 | (2021-05-06 16:30:58.580511+00:00) Cleanup files cog, add implementation dates, elaborate help docstrings 11 | (2021-05-06 16:31:39.001011+00:00) Rename help.py to commands.py, add multiple command info helper commands 12 | (2021-05-11 05:54:00.829567+00:00) Add audit log searching commands. 13 | 14 | (2021-08-21 05:28:21.124744+00:00) Add github commit history command. 15 | -------------------------------------------------------------------------------- /data/txts/overview.txt: -------------------------------------------------------------------------------- 1 | Hello! I'm {0}, and I specialize in tracking and moderation. 2 | I was designed to collect all sorts of data on servers, users, 3 | messages, emojis, online time, and more! I also come with a fast 4 | and clean moderation system that offers every opportunity for effective 5 | server management. Apart from moderation and tracking, I feature {1} 6 | commands across {2} categories that provide awesome utilities! 7 | Some examples include managing roles and logging server actions. -------------------------------------------------------------------------------- /data/txts/privacy.txt: -------------------------------------------------------------------------------- 1 | By being in a server where {0} ({1}) 2 | is present, you consent to have your user statistics recorded 3 | for moderational use by the server owners and administrators. 4 | This data is collected for informative purposes and will not be 5 | disclosed by the bot's developer. All liability lies in the hands 6 | of server owners, administrators, and moderators. Any form of user data, 7 | including but not limited to messages, attachments, emoji usage, 8 | and last online timestamps, will be recorded by {0} 9 | to be used by server owners, administrators, and moderators. 10 | If you do not consent to having your user data recorded, consult 11 | your server managers. Additionally, if you have concerns regarding user 12 | data collection, voice them by joining the support server. 13 | Note that if your collected user data is purged from the bot's database, 14 | all logs (if present) remain in control of server moderators. -------------------------------------------------------------------------------- /eco_prod.yml: -------------------------------------------------------------------------------- 1 | apps: 2 | script : ./starter.py 3 | args : production 4 | name : Neutra 5 | interpreter : python3 6 | interpreter_args: -u 7 | exec_mode : fork 8 | instances : 1 9 | watch : false 10 | log_date_format : YYYY-MM-DD HH:mm 11 | error_file : ./data/pm2/err.yml 12 | out_file : ./data/pm2/out.yml 13 | pid_file : ./data/pm2/pid.pid -------------------------------------------------------------------------------- /eco_test.yml: -------------------------------------------------------------------------------- 1 | apps: 2 | script : ./starter.py 3 | args : tester 4 | name : Tester 5 | interpreter : python3 6 | interpreter_args: -u 7 | exec_mode : fork 8 | instances : 1 9 | watch : false 10 | log_date_format : YYYY-MM-DD HH:mm 11 | error_file : ./data/pm2/err.yml 12 | out_file : ./data/pm2/out.yml 13 | pid_file : ./data/pm2/pid.pid -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.4 2 | aiosignal==1.3.1 3 | aiosmtplib==2.0.2 4 | async-timeout==4.0.2 5 | asyncpg==0.27.0 6 | attrs==23.1.0 7 | better-profanity==0.7.0 8 | charset-normalizer==3.1.0 9 | click==8.1.3 10 | colorama==0.4.6 11 | dateutils==0.6.12 12 | discord-ext-menus==1.0.0a29+g4429b56 13 | discord.py @ git+https://github.com/Rapptz/discord.py@66155faf00cfb3d7cfc976c834d85e336c206505 14 | frozenlist==1.3.3 15 | geographiclib==2.0 16 | geopy==2.3.0 17 | humanize==4.7.0 18 | idna==3.4 19 | multidict==6.0.4 20 | objgraph==3.6.0 21 | parsedatetime==2.6 22 | Pillow==10.0.0 23 | psutil==5.9.5 24 | pyparsing==3.1.0 25 | python-dateutil==2.8.2 26 | pytz==2023.3 27 | six==1.16.0 28 | timeago==1.0.16 29 | Unidecode==1.3.6 30 | yarl==1.9.2 31 | youtube-dl==2021.12.17 32 | -------------------------------------------------------------------------------- /selfhost.md: -------------------------------------------------------------------------------- 1 | # Installing Neutra 2 | This guide is for self-hosting Neutra. If you just want to install the bot without getting the code, go to https://discord.com/oauth2/authorize?client_id=806953546372087818&scope=bot 3 | 4 | ## Step One: Install Ubuntu 5 | 6 | Enable WSL if you're on windows 7 | 8 | ## Step Two: Git Clone 9 | 10 | Install git with 11 | sudo apt install git 12 | 13 | 14 | Cd into your preferred directory and clone the repository with 15 | git clone https://github.com/Hecate946/Neutra 16 | 17 | 18 | ## Step Three: Install Postgresql 19 | 20 | Install postgresql and postgresql-contrib with the command 21 | sudo apt install postgresql postgresql-contrib 22 | 23 | 24 | Switch to your new postgres account with 25 | sudo -i -u postgres 26 | 27 | 28 | You should be logged in as a postgres user. 29 | 30 | From there, you can access the psql prompt with 31 | psql 32 | 33 | 34 | Add a password with 35 | \password 36 | 37 | 38 | You will be prompted to enter a password. Don't forget it... 39 | 40 | Create the database the bot uses with the command 41 | CREATE DATABASE ; 42 | 43 | 44 | For example, 45 | CREATE DATABASE Neutra; 46 | 47 | ## Step Four: Create A Discord Application 48 | 49 | Go to [this link](https://discord.com/developers/applications) and create an application. 50 | 51 | Go to the bot tab on the left hand side and make your application a bot. 52 | 53 | Make sure you enable both privileged gateway intents 54 | 55 | ![image](https://user-images.githubusercontent.com/83441732/116746625-c5166800-a9ca-11eb-9a4d-64468fb3179c.png) 56 | 57 | ## Step Five: Pip Install 58 | 59 | To install the libraries for Neutra, navigate to the bot's folder and run: 60 | ```yaml 61 | pip install -r requirements.txt 62 | ``` 63 | 64 | If you are on windows, do this in cmd instead of ubuntu. 65 | 66 | ## Step Six: Configure Bot 67 | Create a file called `config.py` and copy all the contents of `config_example.py` into it. 68 | Follow the directions in the comments of `config_example.py` then run: 69 | ```yaml 70 | python3 starter.py [mode] 71 | ``` 72 | Where the mode determines which credentials in the config.py to use. 73 | Valid modes are "dev", "tester", and "production" 74 | The mode will default to production. 75 | Production mode requires all webhooks to be setup, 76 | If you are planning to selfhost, use tester mode. 77 | -------------------------------------------------------------------------------- /settings/cleanup.py: -------------------------------------------------------------------------------- 1 | # Module for deleting useless entries in postgres 2 | import logging 3 | 4 | from . import database 5 | 6 | log = logging.getLogger("INFO_LOGGER") 7 | 8 | conn = database.cxn 9 | 10 | 11 | async def basic_cleanup(guilds): 12 | query = "SELECT server_id FROM servers" 13 | await find_discrepancy(query, guilds) 14 | 15 | 16 | async def find_discrepancy(query, guilds): 17 | server_list = [x.id for x in guilds] 18 | records = await conn.fetch(query) 19 | for record in records: 20 | server_id = record["server_id"] 21 | if server_id not in server_list: 22 | await destroy_server(server_id) 23 | 24 | 25 | async def purge_discrepancies(guilds): 26 | print("Running purge_discrepancies(guilds)") 27 | query = "SELECT server_id FROM servers" 28 | await find_discrepancy(query, guilds) 29 | print(f"{query.split()[-1]}_query") 30 | 31 | query = "SELECT server_id FROM prefixes" 32 | await find_discrepancy(query, guilds) 33 | print(f"{query.split()[-1]}_query") 34 | 35 | query = "SELECT server_id FROM logs" 36 | await find_discrepancy(query, guilds) 37 | print(f"{query.split()[-1]}_query") 38 | 39 | query = "SELECT server_id FROM log_data" 40 | await find_discrepancy(query, guilds) 41 | print(f"{query.split()[-1]}_query") 42 | 43 | query = "SELECT server_id FROM warns" 44 | await find_discrepancy(query, guilds) 45 | print(f"{query.split()[-1]}_query") 46 | 47 | query = "SELECT server_id FROM invites" 48 | await find_discrepancy(query, guilds) 49 | print(f"{query.split()[-1]}_query") 50 | 51 | query = "SELECT server_id FROM emojidata" 52 | await find_discrepancy(query, guilds) 53 | print(f"{query.split()[-1]}_query") 54 | 55 | query = "SELECT server_id FROM messages" 56 | await find_discrepancy(query, guilds) 57 | print(f"{query.split()[-1]}_query") 58 | 59 | query = "SELECT server_id FROM usernicks" 60 | await find_discrepancy(query, guilds) 61 | print(f"{query.split()[-1]}_query") 62 | 63 | query = "SELECT server_id FROM userroles" 64 | await find_discrepancy(query, guilds) 65 | print(f"{query.split()[-1]}_query") 66 | 67 | 68 | async def destroy_server(guild_id): 69 | """Delete all records of a server from the db""" 70 | 71 | query = "DELETE FROM servers WHERE server_id = $1" 72 | await conn.execute(query, guild_id) 73 | 74 | query = "DELETE FROM prefixes WHERE server_id = $1" 75 | await conn.execute(query, guild_id) 76 | 77 | query = "DELETE FROM warns WHERE server_id = $1" 78 | await conn.execute(query, guild_id) 79 | 80 | query = "DELETE FROM invites WHERE server_id = $1" 81 | await conn.execute(query, guild_id) 82 | 83 | query = "DELETE FROM emojidata WHERE server_id = $1" 84 | await conn.execute(query, guild_id) 85 | 86 | query = "DELETE FROM messages WHERE server_id = $1" 87 | await conn.execute(query, guild_id) 88 | 89 | query = "DELETE FROM usernicks WHERE server_id = $1" 90 | await conn.execute(query, guild_id) 91 | 92 | query = "DELETE FROM userroles WHERE server_id = $1" 93 | await conn.execute(query, guild_id) 94 | 95 | log.info(f"Destroyed server [{guild_id}]") 96 | -------------------------------------------------------------------------------- /settings/database.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import time 4 | import asyncio 5 | import asyncpg 6 | import logging 7 | 8 | from collections import defaultdict 9 | 10 | from settings import constants 11 | from utilities import utils 12 | 13 | log = logging.getLogger("INFO_LOGGER") 14 | 15 | scripts = [x[:-4] for x in sorted(os.listdir("./data/scripts")) if x.endswith(".sql")] 16 | cxn = asyncio.get_event_loop().run_until_complete( 17 | asyncpg.create_pool(constants.postgres) 18 | ) 19 | 20 | prefixes = dict() 21 | settings = defaultdict(dict) 22 | 23 | 24 | async def initialize(bot, members): 25 | await scriptexec() 26 | await set_config_id(bot) 27 | await load_prefixes() 28 | await update_db(bot.guilds, members) 29 | await load_settings() 30 | 31 | 32 | async def set_config_id(bot): 33 | # Initialize the config table 34 | # with the bot's client ID. 35 | query = """ 36 | INSERT INTO config 37 | VALUES ($1) 38 | ON CONFLICT (client_id) 39 | DO NOTHING; 40 | """ 41 | await cxn.execute(query, bot.user.id) 42 | 43 | 44 | async def scriptexec(): 45 | # We execute the SQL script to make sure we have all our tables. 46 | for script in scripts: 47 | with open(f"./data/scripts/{script}.sql", "r", encoding="utf-8") as script: 48 | try: 49 | await cxn.execute(script.read()) 50 | except Exception as e: 51 | print(utils.traceback_maker(e)) 52 | 53 | 54 | async def update_server(server, member_list): 55 | # Update a server when the bot joins. 56 | query = """ 57 | INSERT INTO servers (server_id) VALUES ($1) 58 | ON CONFLICT DO NOTHING; 59 | """ 60 | st = time.time() 61 | await cxn.execute(query, server.id) 62 | 63 | query = """ 64 | INSERT INTO userstatus (user_id) 65 | VALUES ($1) ON CONFLICT DO NOTHING; 66 | """ 67 | await cxn.executemany( 68 | query, 69 | ((m.id,) for m in member_list), 70 | ) 71 | 72 | log.info(f"Server {server.name} Updated [{server.id}] Time: {time.time() - st}") 73 | 74 | 75 | async def update_db(guilds, member_list): 76 | # Main database updater. This is mostly just for updating new servers and members that the bot joined when offline. 77 | query = """ 78 | INSERT INTO servers (server_id) VALUES ($1) 79 | ON CONFLICT DO NOTHING; 80 | """ 81 | st = time.time() 82 | await cxn.executemany( 83 | query, 84 | ((s.id,) for s in guilds), 85 | ) 86 | 87 | query = """ 88 | INSERT INTO userstatus (user_id) 89 | VALUES ($1) ON CONFLICT DO NOTHING; 90 | """ 91 | await cxn.executemany( 92 | query, 93 | ((m.id,) for m in member_list), 94 | ) 95 | log.info(f"Database Update: {time.time() - st}") 96 | 97 | 98 | async def load_settings(): 99 | query = """ 100 | SELECT 101 | servers.server_id, 102 | (SELECT ROW_TO_JSON(_) FROM (SELECT 103 | servers.muterole, 104 | servers.antiinvite, 105 | servers.reassign, 106 | servers.autoroles, 107 | servers.profanities 108 | ) AS _) AS settings 109 | FROM servers; 110 | """ 111 | records = await cxn.fetch(query) 112 | if records: 113 | for record in records: 114 | settings[record["server_id"]].update(json.loads(record["settings"])) 115 | 116 | 117 | async def fix_server(server): 118 | query = """ 119 | SELECT 120 | servers.server_id, 121 | (SELECT ROW_TO_JSON(_) FROM (SELECT 122 | servers.muterole, 123 | servers.antiinvite, 124 | servers.reassign, 125 | servers.autoroles, 126 | servers.profanities 127 | ) AS _) AS settings 128 | FROM servers 129 | WHERE server_id = $1; 130 | """ 131 | record = await cxn.fetchrow(query, server) 132 | if record: 133 | settings[record["server_id"]].update(json.loads(record["settings"])) 134 | 135 | 136 | async def load_prefixes(): 137 | query = """ 138 | SELECT server_id, ARRAY_REMOVE(ARRAY_AGG(prefix), NULL) as prefix_list 139 | FROM prefixes GROUP BY server_id; 140 | """ 141 | records = await cxn.fetch(query) 142 | for server_id, prefix_list in records: 143 | prefixes[server_id] = prefix_list 144 | -------------------------------------------------------------------------------- /settings/setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | from re import match 4 | 5 | 6 | def start(): 7 | config = {} 8 | msg = "Hello! My name is Neutra, and I'm a moderation bot made by Hecate#3523\n" 9 | msg += "I'm going to walk you through a few steps to get started.\n" 10 | msg += "Firstly, we'll be creating a file names config.json that will hold all my secrets.\n" 11 | msg += "In order to run, I'll need a bot token. Visit this link: https://discord.com/developers/applications\n" 12 | msg += "then click on 'create an application'\n" 13 | msg += "Next, you'll need to give me a name.\n" 14 | msg += "I'd appreciate if you name me Neutra... but if you don't I guess I'll get used to it :((\n" 15 | msg += "After creating an application, click on the 'bot' tab, and select the copy token option.\n" 16 | msg += "This token is extremely important, do not share it with anyone for any reason.\n" 17 | print(msg) 18 | token = input("Once you've copied the token, enter it here: ") 19 | while len(token) < 15: 20 | print("That does not look like a valid token.") 21 | token = input("Once you've copied the token, enter it here: ") 22 | msg = "Next, assuming you've configured postgresql, you have to enter my connection URI.\n" 23 | msg += "If you haven't setup postgres, here's a link to their homepage where you should get started.\n" 24 | msg += "https://www.postgresql.org/\n" 25 | msg += "Back to the connection URI, it should look like this: postgres://user:password@host:post/dbname\n" 26 | msg += "substitute the 'user' with your postgres user, the password with your configured password,\n" 27 | msg += "the host with either an IP address or 'localhost', the port number (usually 5432) and finally, the database name." 28 | print(msg) 29 | postgres = input("Enter the postgres connection URI here: ") 30 | some_trash_regex = "postgres://.*:.*@.*:.*/.*" 31 | while not match(some_trash_regex, postgres): 32 | print("That does not look like a valid postgres URI.") 33 | postgres = input("Enter the postgres connection URI here: ") 34 | msg = "You're almost done. Now its time to enter a color for all my embeds. It should be a hex code.\n" 35 | msg += "As an example, aqua blue is 29f4ff\n" 36 | msg += "If you're not sure which color you want, I love this color: 661538" 37 | print(msg) 38 | embed = input("Enter my embed color code: ") 39 | while len(embed) != 6: 40 | print( 41 | "That does not seem like a valid hex code. It should be 6 digits in hexadecimal." 42 | ) 43 | embed = input("Enter my embed color code: ") 44 | try: 45 | test = int(embed, 16) 46 | except: 47 | embed = "the while loop will run again because it couldnt convert" 48 | msg = ( 49 | "Alrighty, now you have to enter your discord ID, registering you as an owner." 50 | ) 51 | print(msg) 52 | owners = input("Enter your discord ID here: ") 53 | while len(owners) > 20 or len(owners) < 16: 54 | print( 55 | "That does not seem like a valid discord ID. Enable developer mode and right click on your avatar to find your ID." 56 | ) 57 | owners = input("Enter your discord ID here: ") 58 | try: 59 | stuff = [int(owners)] 60 | except: 61 | owners = "This wont be a valid discord id, so the while loop runs again" 62 | prefix = input( 63 | "Great job! All set now. Just enter what prefix you want me to use as default, and you're done: " 64 | ) 65 | print("Done! Run the bot again to start") 66 | print("Feel free to harass Hecate#3523 if you have issues.") 67 | 68 | config["admins"] = [] 69 | config["avchan"] = None 70 | config["bitly"] = None 71 | config["botlog"] = None 72 | config["embed"] = int(embed, 16) 73 | config["github"] = "https://github.com/Hecate946/Neutra" 74 | config["gtoken"] = None 75 | config["owners"] = [int(owners)] 76 | config["postgres"] = postgres 77 | config["prefix"] = prefix 78 | config["support"] = "https://discord.gg/947ramn" 79 | config["tester"] = token 80 | config["timezonedb"] = None 81 | config["token"] = token 82 | with open("./config.json", "w", encoding="utf-8") as fp: 83 | json.dump(config, fp, indent=2) 84 | 85 | sys.exit(0) 86 | -------------------------------------------------------------------------------- /starter.py: -------------------------------------------------------------------------------- 1 | import click 2 | import asyncio 3 | 4 | 5 | @click.command() 6 | @click.argument("mode", default="production") 7 | def main(mode): 8 | """Launches the bot.""" 9 | mode = mode.lower() 10 | from core import bot 11 | 12 | if mode in ["dev", "development"]: 13 | bot.development = True 14 | 15 | elif mode in ["tester", "testing"]: 16 | bot.tester = True 17 | else: 18 | bot.production = True 19 | 20 | block = "#" * (len(mode) + 19) 21 | startmsg = f"{block}\n## Running {mode.capitalize()} Mode ## \n{block}" 22 | click.echo(startmsg) 23 | # run the application ... 24 | asyncio.run(bot.run()) 25 | 26 | 27 | if __name__ == "__main__": 28 | main() 29 | -------------------------------------------------------------------------------- /utilities/checks.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | import config 5 | 6 | from utilities import override 7 | 8 | owners = config.OWNERS 9 | admins = config.ADMINS 10 | 11 | 12 | def is_owner(ctx): 13 | """Checks if the author is one of the owners""" 14 | return ctx.author.id in owners 15 | 16 | 17 | def is_admin(ctx): 18 | if ctx.author.id in ctx.bot.config.ADMINS or ctx.author.id in ctx.bot.config.OWNERS: 19 | return True 20 | return 21 | 22 | 23 | def is_home(ctx): 24 | if not ctx.guild: 25 | return False 26 | 27 | if ctx.guild.id not in ctx.bot.home_guilds: 28 | return False 29 | return True 30 | 31 | 32 | def is_disabled(ctx, command): 33 | """ 34 | Check if a command is disabled for the context 35 | """ 36 | config = ctx.bot.get_cog("Config") 37 | if not config: 38 | return False 39 | 40 | ignored = config.ignored 41 | command_config = config.command_config 42 | # Reasons for bypassing 43 | if ctx.guild is None: 44 | return False # Do not restrict in DMs. 45 | 46 | if is_admin(ctx): 47 | return False # Contibutors are immune. 48 | 49 | if isinstance(ctx.author, discord.Member): 50 | if ctx.author.guild_permissions.manage_guild: 51 | return False # Manage guild is immune. 52 | 53 | # Now check channels, roles, and users. 54 | if ctx.channel.id in ignored[ctx.guild.id]: 55 | return True # Channel is ignored. 56 | 57 | if ctx.author.id in ignored[ctx.guild.id]: 58 | return True # User is ignored. 59 | 60 | if any( 61 | ( 62 | role_id in ignored[ctx.guild.id] 63 | for role_id in [r.id for r in ctx.author.roles] 64 | ) 65 | ): 66 | return True # Role is ignored. 67 | 68 | if str(command) in command_config[ctx.guild.id]: 69 | return True # Disabled for the whole server. 70 | 71 | if str(command) in command_config[ctx.channel.id]: 72 | return True # Disabled for the channel 73 | 74 | if str(command) in command_config[ctx.author.id]: 75 | return True # Disabled for the user 76 | 77 | if any((str(command) in command_config[role_id] for role_id in ctx.author._roles)): 78 | return True # Disabled for the role 79 | 80 | return False # Ok just in case we get here... 81 | 82 | 83 | def is_mod(): 84 | async def pred(ctx): 85 | return await check_permissions(ctx, {"manage_guild": True}) 86 | 87 | return commands.check(pred) 88 | 89 | 90 | async def check_permissions(ctx, perms, *, check=all): 91 | """Checks if author has permissions to a permission""" 92 | if ctx.author.id in owners: 93 | return True 94 | 95 | resolved = ctx.author.guild_permissions 96 | guild_perm_checker = check( 97 | getattr(resolved, name, None) == value for name, value in perms.items() 98 | ) 99 | if guild_perm_checker is False: 100 | # Try to see if the user has channel permissions that override 101 | resolved = ctx.channel.permissions_for(ctx.author) 102 | return check( 103 | getattr(resolved, name, None) == value for name, value in perms.items() 104 | ) 105 | 106 | 107 | async def check_bot_permissions(ctx, perms, *, check=all): 108 | """Checks if author has permissions to a permission""" 109 | if ctx.guild: 110 | resolved = ctx.guild.me.guild_permissions 111 | guild_perm_checker = check( 112 | getattr(resolved, name, None) == value for name, value in perms.items() 113 | ) 114 | if guild_perm_checker is False: 115 | # Try to see if the user has channel permissions that override 116 | resolved = ctx.channel.permissions_for(ctx.guild.me) 117 | return check( 118 | getattr(resolved, name, None) == value for name, value in perms.items() 119 | ) 120 | else: 121 | return True 122 | 123 | 124 | def has_perms(*, check=all, **perms): # Decorator to check if a user has perms 125 | 126 | invalid = set(perms) - set(discord.Permissions.VALID_FLAGS) 127 | if invalid: 128 | raise TypeError(f"Invalid permission(s): {', '.join(invalid)}") 129 | 130 | async def pred(ctx): 131 | result = await check_permissions(ctx, perms, check=check) 132 | perm_list = [ 133 | x.title().replace("_", " ").replace("Tts", "TTS").replace("Guild", "Server") 134 | for x in perms 135 | ] 136 | if result is False: 137 | raise commands.BadArgument( 138 | message=f"You are missing the following permission{'' if len(perm_list) == 1 else 's'}: `{', '.join(perm_list)}`" 139 | ) 140 | return result 141 | 142 | return commands.check(pred) 143 | 144 | 145 | # def bot_has_perms(*, check=all, **perms): # Decorator to check if the bot has perms 146 | 147 | # invalid = set(perms) - set(discord.Permissions.VALID_FLAGS) 148 | # if invalid: 149 | # raise TypeError(f"Invalid permission(s): {', '.join(invalid)}") 150 | 151 | # async def pred(ctx): 152 | # result = await check_bot_permissions(ctx, perms, check=check) 153 | # if ( 154 | # result is False 155 | # ): # We know its a guild because permissions failed in check_bot_permissions() 156 | # guild_perms = [x[0] for x in ctx.guild.me.guild_permissions if x[1] is True] 157 | # channel_perms = [ 158 | # x[0] for x in ctx.channel.permissions_for(ctx.guild.me) if x[1] is True 159 | # ] 160 | # botperms = guild_perms + channel_perms 161 | # perms_needed = [] 162 | # for x in perms: 163 | # if not x in botperms: # Only complain about the perms we don't have 164 | # perms_needed.append(x) 165 | 166 | # perm_list = [ 167 | # x.title().replace("_", " ").replace("Tts", "TTS") for x in perms_needed 168 | # ] 169 | # raise commands.BadArgument( 170 | # message=f"I require the following permission{'' if len(perm_list) == 1 else 's'}: `{', '.join(perm_list)}`" 171 | # ) 172 | # return result 173 | 174 | # return commands.check(pred) 175 | 176 | 177 | def bot_has_perms(**perms): 178 | """Similar to :func:`.has_permissions` except checks if the bot itself has 179 | the permissions listed. 180 | This check raises a special exception, :exc:`.BotMissingPermissions` 181 | that is inherited from :exc:`.CheckFailure`. 182 | """ 183 | 184 | invalid = set(perms) - set(discord.Permissions.VALID_FLAGS) 185 | if invalid: 186 | raise TypeError(f"Invalid permission(s): {', '.join(invalid)}") 187 | 188 | def predicate(ctx): 189 | guild = ctx.guild 190 | me = guild.me if guild is not None else ctx.bot.user 191 | permissions = ctx.channel.permissions_for(me) 192 | 193 | missing = [ 194 | perm for perm, value in perms.items() if getattr(permissions, perm) != value 195 | ] 196 | 197 | if not missing: 198 | return True 199 | 200 | perm_list = [ 201 | x.title().replace("_", " ").replace("Guild", "Server").replace("Tts", "TTS") 202 | for x in missing 203 | ] 204 | raise commands.BadArgument( 205 | f"I require the following permission{'' if len(perm_list) == 1 else 's'}: `{', '.join(perm_list)}`" 206 | ) 207 | 208 | return commands.check(predicate) 209 | 210 | 211 | def is_bot_admin(): # Decorator for bot admin commands 212 | async def pred(ctx): 213 | return is_admin(ctx) 214 | 215 | return commands.check(pred) 216 | 217 | 218 | async def check_priv(ctx, member): 219 | """ 220 | Handle permission hierarchy for commands 221 | Return the reason for failure. 222 | """ 223 | try: 224 | # Self checks 225 | if member == ctx.author: 226 | return f"You cannot {ctx.command.name} yourself." 227 | if member.id == ctx.bot.user.id: 228 | return f"I cannot {ctx.command.name} myself." 229 | 230 | # Bot lacks permissions 231 | if member.id == ctx.guild.owner.id: 232 | return f"I cannot {ctx.command.name} the server owner." 233 | if ctx.guild.me.top_role.position == member.top_role.position: 234 | return f"I cannot {ctx.command.name} a user with equal permissions." 235 | if ctx.guild.me.top_role.position < member.top_role.position: 236 | return f"I cannot {ctx.command.name} a user with superior permissions." 237 | if member.id in owners: 238 | return f"I cannot {ctx.command.name} my creator." 239 | 240 | # Check if user bypasses 241 | if ctx.author.id == ctx.guild.owner.id: 242 | return 243 | if ctx.author.id in owners: 244 | return 245 | # Now permission check 246 | if ctx.author.top_role.position == member.top_role.position: 247 | return f"You cannot {ctx.command.name} a user with equal permissions." 248 | if ctx.author.top_role.position < member.top_role.position: 249 | return f"You cannot {ctx.command.name} a user with superior permissions." 250 | except Exception as e: 251 | print(e) 252 | pass 253 | 254 | 255 | async def role_priv(ctx, role): 256 | """ 257 | Handle permission hierarchy for commands 258 | Return the reason for failure. 259 | """ 260 | # First role validity check 261 | if role.is_bot_managed(): 262 | return f"Role `{role.name}` is managed by a bot." 263 | if role.is_premium_subscriber(): 264 | return f"Role `{role.name}` is this server's booster role." 265 | if role.is_integration(): 266 | return f"Role `{role.name}` is managed by an integration." 267 | 268 | # Bot lacks permissions 269 | if ctx.guild.me.top_role.position == role.position: 270 | return f"Role `{role.name}` is my highest role." 271 | if ctx.guild.me.top_role.position < role.position: 272 | return f"Role `{role.name}` is above my highest role." 273 | 274 | # Check if user bypasses 275 | if ctx.author.id == ctx.guild.owner.id: 276 | return 277 | 278 | # Now permission check 279 | if ctx.author.top_role.position == role.position: 280 | return f"Role `{role.name}` is your highest role." 281 | if ctx.author.top_role.position < role.position: 282 | return f"Role `{role.name}` is above your highest role." 283 | 284 | 285 | async def nick_priv(ctx, member): 286 | # Bot lacks permissions 287 | if member.id == ctx.guild.owner.id: 288 | return f"User `{member}` is the server owner. I cannot edit the nickname of the server owner." 289 | if ctx.me.top_role.position < member.top_role.position: 290 | return "I cannot rename users with superior permissions." 291 | if ctx.me.top_role.position == member.top_role.position and member.id != ctx.me.id: 292 | return "I cannot rename users with equal permissions." 293 | 294 | # Check if user bypasses 295 | if ctx.author.id == ctx.guild.owner.id: 296 | return # Owner bypasses 297 | if ctx.author.id == member.id: 298 | return # They can edit their own nicknames 299 | 300 | # Now permission check 301 | if ctx.author.top_role.position < member.top_role.position: 302 | return f"You cannot nickname a user with superior permissions." 303 | if ctx.author.top_role.position == member.top_role.position: 304 | return f"You cannot nickname a user with equal permissions." 305 | 306 | 307 | async def checker(ctx, value): 308 | if type(value) is list: 309 | for x in value: 310 | result = await check_priv(ctx, member=x) 311 | if type(value) is not list: 312 | result = await check_priv(ctx, member=value) 313 | return result 314 | 315 | 316 | def can_handle(ctx, permission: str): 317 | """Checks if bot has permissions or is in DMs right now""" 318 | return isinstance(ctx.channel, discord.DMChannel) or getattr( 319 | ctx.channel.permissions_for(ctx.guild.me), permission 320 | ) 321 | 322 | 323 | def has_guild_permissions(**perms): 324 | 325 | invalid = set(perms) - set(discord.Permissions.VALID_FLAGS) 326 | if invalid: 327 | raise TypeError(f"Invalid permission(s): {', '.join(invalid)}") 328 | 329 | def predicate(ctx): 330 | if not ctx.guild: 331 | raise commands.NoPrivateMessage 332 | 333 | permissions = ctx.author.guild_permissions 334 | missing = [ 335 | perm for perm, value in perms.items() if getattr(permissions, perm) != value 336 | ] 337 | 338 | if not missing: 339 | return True 340 | 341 | perm_list = [x.title().replace("_", " ").replace("Tts", "TTS") for x in missing] 342 | raise commands.BadArgument( 343 | f"You require the following permission{'' if len(perm_list) == 1 else 's'}: `{', '.join(perm_list)}`" 344 | ) 345 | 346 | return commands.check(predicate) 347 | 348 | 349 | def bot_has_guild_perms(**perms): 350 | 351 | invalid = set(perms) - set(discord.Permissions.VALID_FLAGS) 352 | if invalid: 353 | raise TypeError(f"Invalid permission(s): {', '.join(invalid)}") 354 | 355 | def predicate(ctx): 356 | if not ctx.guild: 357 | raise commands.NoPrivateMessage 358 | 359 | permissions = ctx.me.guild_permissions 360 | missing = [ 361 | perm for perm, value in perms.items() if getattr(permissions, perm) != value 362 | ] 363 | if not missing: 364 | return True 365 | 366 | perm_list = [x.title().replace("_", " ").replace("Tts", "TTS") for x in missing] 367 | raise commands.BadArgument( 368 | f"I require the following permission{'' if len(perm_list) == 1 else 's'}: `{', '.join(perm_list)}`" 369 | ) 370 | 371 | return commands.check(predicate) 372 | 373 | 374 | def dm_only(): 375 | def predicate(ctx): 376 | if ctx.guild is not None: 377 | raise commands.PrivateMessageOnly() 378 | return True 379 | 380 | return commands.check(predicate) 381 | 382 | 383 | def guild_only(): 384 | def predicate(ctx): 385 | if ctx.guild is None: 386 | raise commands.NoPrivateMessage() 387 | return True 388 | 389 | return commands.check(predicate) 390 | 391 | 392 | def cooldown(*args, **kwargs): 393 | return commands.check(override.CustomCooldown(*args, **kwargs)) 394 | -------------------------------------------------------------------------------- /utilities/cleaner.py: -------------------------------------------------------------------------------- 1 | # Module for cleaning messages from all unwanted content 2 | 3 | import re 4 | 5 | 6 | def clean_all(msg): 7 | msg = clean_invite_embed(msg) 8 | msg = clean_backticks(msg) 9 | msg = clean_mentions(msg) 10 | msg = clean_emojis(msg) 11 | return msg 12 | 13 | 14 | def clean_invite_embed(msg): 15 | """Prevents invites from embedding""" 16 | return msg.replace("discord.gg/", "discord.gg/\u200b") 17 | 18 | 19 | def clean_backticks(msg): 20 | """Prevents backticks from breaking code block formatting""" 21 | return msg.replace("`", "\U0000ff40") 22 | 23 | 24 | def clean_formatting(msg): 25 | """Escape formatting items in a string.""" 26 | return re.sub(r"([`*_])", r"\\\1", msg) 27 | 28 | 29 | def clean_mentions(msg): 30 | """Prevent discord mentions""" 31 | return msg.replace("@", "@\u200b") 32 | 33 | 34 | def clean_emojis(msg): 35 | """Escape custom emojis.""" 36 | return re.sub(r"<(a)?:([a-zA-Z0-9_]+):([0-9]+)>", "<\u200b\\1:\\2:\\3>", msg) 37 | -------------------------------------------------------------------------------- /utilities/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import time 4 | import logging 5 | 6 | from collections import defaultdict 7 | from utilities import utils 8 | 9 | log = logging.getLogger("INFO_LOGGER") 10 | 11 | 12 | class Database: 13 | def __init__(self, cxn): 14 | self.cxn = cxn 15 | 16 | self.prefixes = {} 17 | self.settings = defaultdict(dict) 18 | self.scripts = [ 19 | x[:-4] for x in sorted(os.listdir("./data/scripts")) if x.endswith(".sql") 20 | ] 21 | 22 | async def initialize(self, bot, members): 23 | await self.scriptexec() 24 | await self.set_config_id(bot) 25 | await self.load_prefixes() 26 | await self.update_db(bot.guilds, members) 27 | await self.load_settings() 28 | 29 | async def set_config_id(self, bot): 30 | # Initialize the config table 31 | # with the bot's client ID. 32 | query = """ 33 | INSERT INTO config 34 | VALUES ($1) 35 | ON CONFLICT (client_id) 36 | DO NOTHING; 37 | """ 38 | await self.cxn.execute(query, bot.user.id) 39 | 40 | async def scriptexec(self): 41 | # We execute the SQL script to make sure we have all our tables. 42 | for script in self.scripts: 43 | with open(f"./data/scripts/{script}.sql", "r", encoding="utf-8") as script: 44 | try: 45 | await self.cxn.execute(script.read()) 46 | except Exception as e: 47 | print(utils.traceback_maker(e)) 48 | 49 | async def update_server(self, server, member_list): 50 | # Update a server when the bot joins. 51 | query = """ 52 | INSERT INTO servers (server_id) VALUES ($1) 53 | ON CONFLICT DO NOTHING; 54 | """ 55 | st = time.time() 56 | await self.cxn.execute(query, server.id) 57 | 58 | query = """ 59 | INSERT INTO userstatus (user_id) 60 | VALUES ($1) ON CONFLICT DO NOTHING; 61 | """ 62 | await self.cxn.executemany( 63 | query, 64 | ((m.id,) for m in member_list), 65 | ) 66 | 67 | log.info(f"Server {server.name} Updated [{server.id}] Time: {time.time() - st}") 68 | 69 | async def update_db(self, guilds, member_list): 70 | # Main database updater. This is mostly just for updating new servers and members that the bot joined when offline. 71 | query = """ 72 | INSERT INTO servers (server_id) VALUES ($1) 73 | ON CONFLICT DO NOTHING; 74 | """ 75 | st = time.time() 76 | await self.cxn.executemany( 77 | query, 78 | ((s.id,) for s in guilds), 79 | ) 80 | 81 | query = """ 82 | INSERT INTO userstatus (user_id) 83 | VALUES ($1) ON CONFLICT DO NOTHING; 84 | """ 85 | await self.cxn.executemany( 86 | query, 87 | ((m.id,) for m in member_list), 88 | ) 89 | log.info(f"Database Update: {time.time() - st}") 90 | 91 | async def load_settings(self): 92 | query = """ 93 | SELECT 94 | servers.server_id, 95 | (SELECT ROW_TO_JSON(_) FROM (SELECT 96 | servers.muterole, 97 | servers.antiinvite, 98 | servers.reassign, 99 | servers.autoroles, 100 | servers.profanities 101 | ) AS _) AS settings 102 | FROM servers; 103 | """ 104 | records = await self.cxn.fetch(query) 105 | if records: 106 | for record in records: 107 | self.settings[record["server_id"]].update( 108 | json.loads(record["settings"]) 109 | ) 110 | 111 | async def fix_server(self, server): 112 | query = """ 113 | SELECT 114 | servers.server_id, 115 | (SELECT ROW_TO_JSON(_) FROM (SELECT 116 | servers.muterole, 117 | servers.antiinvite, 118 | servers.reassign, 119 | servers.autoroles, 120 | servers.profanities 121 | ) AS _) AS settings 122 | FROM servers 123 | WHERE server_id = $1; 124 | """ 125 | record = await self.cxn.fetchrow(query, server) 126 | if record: 127 | self.settings[record["server_id"]].update(json.loads(record["settings"])) 128 | 129 | async def load_prefixes(self): 130 | query = """ 131 | SELECT server_id, ARRAY_REMOVE(ARRAY_AGG(prefix), NULL) as prefix_list 132 | FROM prefixes GROUP BY server_id; 133 | """ 134 | records = await self.cxn.fetch(query) 135 | for server_id, prefix_list in records: 136 | self.prefixes[server_id] = prefix_list 137 | 138 | async def basic_cleanup(self, guilds): 139 | query = "SELECT server_id FROM servers" 140 | await self.find_discrepancy(query, guilds) 141 | 142 | async def find_discrepancy(self, query, guilds): 143 | server_list = [x.id for x in guilds] 144 | records = await self.cxn.fetch(query) 145 | for record in records: 146 | server_id = record["server_id"] 147 | if server_id not in server_list: 148 | await self.destroy_server(server_id) 149 | 150 | async def purge_discrepancies(self, guilds): 151 | print("Running purge_discrepancies(guilds)") 152 | query = "SELECT server_id FROM servers" 153 | await self.find_discrepancy(query, guilds) 154 | print(f"{query.split()[-1]}_query") 155 | 156 | query = "SELECT server_id FROM prefixes" 157 | await self.find_discrepancy(query, guilds) 158 | print(f"{query.split()[-1]}_query") 159 | 160 | query = "SELECT server_id FROM logs" 161 | await self.find_discrepancy(query, guilds) 162 | print(f"{query.split()[-1]}_query") 163 | 164 | query = "SELECT server_id FROM log_data" 165 | await self.find_discrepancy(query, guilds) 166 | print(f"{query.split()[-1]}_query") 167 | 168 | query = "SELECT server_id FROM warns" 169 | await self.find_discrepancy(query, guilds) 170 | print(f"{query.split()[-1]}_query") 171 | 172 | query = "SELECT server_id FROM invites" 173 | await self.find_discrepancy(query, guilds) 174 | print(f"{query.split()[-1]}_query") 175 | 176 | query = "SELECT server_id FROM emojidata" 177 | await self.find_discrepancy(query, guilds) 178 | print(f"{query.split()[-1]}_query") 179 | 180 | query = "SELECT server_id FROM messages" 181 | await self.find_discrepancy(query, guilds) 182 | print(f"{query.split()[-1]}_query") 183 | 184 | query = "SELECT server_id FROM usernicks" 185 | await self.find_discrepancy(query, guilds) 186 | print(f"{query.split()[-1]}_query") 187 | 188 | query = "SELECT server_id FROM userroles" 189 | await self.find_discrepancy(query, guilds) 190 | print(f"{query.split()[-1]}_query") 191 | 192 | async def destroy_server(self, guild_id): 193 | """Delete all records of a server from the db""" 194 | 195 | query = "DELETE FROM servers WHERE server_id = $1" 196 | await self.cxn.execute(query, guild_id) 197 | 198 | query = "DELETE FROM prefixes WHERE server_id = $1" 199 | await self.cxn.execute(query, guild_id) 200 | 201 | query = "DELETE FROM warns WHERE server_id = $1" 202 | await self.cxn.execute(query, guild_id) 203 | 204 | query = "DELETE FROM invites WHERE server_id = $1" 205 | await self.cxn.execute(query, guild_id) 206 | 207 | query = "DELETE FROM emojidata WHERE server_id = $1" 208 | await self.cxn.execute(query, guild_id) 209 | 210 | query = "DELETE FROM messages WHERE server_id = $1" 211 | await self.cxn.execute(query, guild_id) 212 | 213 | query = "DELETE FROM usernicks WHERE server_id = $1" 214 | await self.cxn.execute(query, guild_id) 215 | 216 | query = "DELETE FROM userroles WHERE server_id = $1" 217 | await self.cxn.execute(query, guild_id) 218 | 219 | log.info(f"Destroyed server [{guild_id}]") 220 | -------------------------------------------------------------------------------- /utilities/decorators.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | import functools 4 | from discord.ext import commands 5 | 6 | from utilities import override 7 | 8 | command = functools.partial(commands.command, cls=override.BotCommand) 9 | group = functools.partial(commands.group, cls=override.BotGroup) 10 | 11 | 12 | def event_check(func): 13 | """ 14 | Event decorator check. 15 | """ 16 | 17 | def check(method): 18 | method.callback = method 19 | 20 | @functools.wraps(method) 21 | async def wrapper(*args, **kwargs): 22 | if await discord.utils.maybe_coroutine(func, *args, **kwargs): 23 | await method(*args, **kwargs) 24 | 25 | return wrapper 26 | 27 | return check 28 | 29 | 30 | def wait_until_ready(bot=None): 31 | async def predicate(*args, **_): 32 | nonlocal bot 33 | self = args[0] if args else None 34 | if isinstance(self, commands.Cog): 35 | bot = bot or self.bot 36 | if bot.ready: 37 | return True 38 | 39 | return event_check(predicate) 40 | 41 | 42 | def defer_ratelimit(bot=None): 43 | async def predicate(*args, **_): 44 | nonlocal bot 45 | self = args[0] if args else None 46 | if isinstance(self, commands.Cog): 47 | bot = bot or self.bot 48 | await asyncio.sleep(1) 49 | return True 50 | 51 | return event_check(predicate) 52 | 53 | 54 | def is_home(home): 55 | """Support server only commands""" 56 | 57 | async def predicate(ctx): 58 | if type(home) != list: 59 | home_guild_list = [home] 60 | if ctx.guild and ctx.guild.id in home_guild_list: 61 | return True 62 | 63 | return commands.check(predicate) 64 | -------------------------------------------------------------------------------- /utilities/discord.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | import time 3 | import json 4 | from config import DISCORD 5 | from core import bot as client 6 | 7 | 8 | class CONSTANTS: 9 | API_URL = "https://discord.com/api" 10 | AUTH_URL = "https://discord.com/api/oauth2/authorize" 11 | TOKEN_URL = "https://discord.com/api/oauth2/token" 12 | SCOPES = [ 13 | "guilds", 14 | "guilds.join", 15 | "identify", 16 | "email", 17 | ] 18 | 19 | 20 | class Oauth: 21 | def __init__(self, scope=None): 22 | self.client_id = DISCORD.client_id 23 | self.client_secret = DISCORD.client_secret 24 | self.redirect_uri = DISCORD.redirect_uri 25 | self.scope = " ".join(CONSTANTS.SCOPES) 26 | 27 | def get_auth_url(self, scope=None): 28 | params = { 29 | "client_id": self.client_id, 30 | "redirect_uri": self.redirect_uri, 31 | "response_type": "code", 32 | "scope": scope or self.scope, 33 | "prompt": "none", # "consent" to force them to agree again 34 | } 35 | query_params = urlencode(params) 36 | return "%s?%s" % (CONSTANTS.AUTH_URL, query_params) 37 | 38 | def validate_token(self, token_info): 39 | """Checks a token is valid""" 40 | now = int(time.time()) 41 | return token_info["expires_at"] - now > 60 42 | 43 | async def get_access_token(self, user): 44 | """Gets the token or creates a new one if expired""" 45 | if self.validate_token(user.token_info): 46 | return user.token_info["access_token"] 47 | 48 | user.token_info = await self.refresh_access_token( 49 | user.user_id, user.token_info.get("refresh_token") 50 | ) 51 | 52 | return user.token_info["access_token"] 53 | 54 | async def refresh_access_token(self, user_id, refresh_token): 55 | params = { 56 | "client_id": self.client_id, 57 | "client_secret": self.client_secret, 58 | "grant_type": "refresh_token", 59 | "refresh_token": refresh_token, 60 | } 61 | 62 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 63 | 64 | token_info = await client.post( 65 | CONSTANTS.TOKEN_URL, data=params, headers=headers, res_method="json" 66 | ) 67 | token_info["expires_at"] = int(time.time()) + token_info["expires_in"] 68 | 69 | query = """ 70 | INSERT INTO discord_auth 71 | VALUES ($1, $2) 72 | ON CONFLICT (user_id) 73 | DO UPDATE SET token_info = $2 74 | WHERE discord_auth.user_id = $1; 75 | """ 76 | await client.cxn.execute(query, user_id, json.dumps(token_info)) 77 | 78 | return token_info 79 | 80 | async def request_access_token(self, code): 81 | params = { 82 | "client_id": self.client_id, 83 | "client_secret": self.client_secret, 84 | "redirect_uri": self.redirect_uri, 85 | "grant_type": "authorization_code", 86 | "scope": self.scope, 87 | "code": code, 88 | } 89 | 90 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 91 | 92 | token_info = await client.post( 93 | CONSTANTS.TOKEN_URL, data=params, headers=headers, res_method="json" 94 | ) 95 | token_info["expires_at"] = int(time.time()) + token_info["expires_in"] 96 | return token_info 97 | 98 | async def identify(self, access_token): 99 | 100 | headers = {"Authorization": f"Bearer {access_token}"} 101 | 102 | user_data = await client.get( 103 | url=CONSTANTS.API_URL + "/users/@me", headers=headers, res_method="json" 104 | ) 105 | return user_data 106 | 107 | 108 | oauth = Oauth() 109 | 110 | 111 | class User: # Discord user operations with scopes 112 | def __init__(self, token_info, user_id): 113 | self.token_info = token_info 114 | self.user_id = user_id 115 | 116 | @classmethod 117 | async def from_id(cls, user_id): 118 | query = """ 119 | SELECT token_info 120 | FROM discord_auth 121 | WHERE user_id = $1; 122 | """ 123 | token_info = await client.cxn.fetchval(query, int(user_id)) 124 | 125 | if token_info: 126 | token_info = json.loads(token_info) 127 | return cls(token_info, user_id) 128 | 129 | @classmethod 130 | async def from_token(cls, token_info): 131 | user_data = await oauth.identify(token_info.get("access_token")) 132 | user_id = int(user_data.get("id")) 133 | query = """ 134 | INSERT INTO discord_auth 135 | VALUES ($1, $2) 136 | ON CONFLICT (user_id) 137 | DO UPDATE SET token_info = $2 138 | WHERE discord_auth.user_id = $1; 139 | """ 140 | await client.cxn.execute(query, user_id, json.dumps(token_info)) 141 | 142 | return cls(token_info, user_id) 143 | 144 | async def get(self, url, *, access_token=None): 145 | access_token = access_token or await oauth.get_access_token(self) 146 | 147 | headers = { 148 | "Authorization": f"Bearer {access_token}", 149 | "Content-Type": "application/json", 150 | } 151 | return await client.get(url, headers=headers, res_method="json") 152 | 153 | async def identify(self): 154 | return await self.get(CONSTANTS.API_URL + "/users/@me") 155 | 156 | async def get_guilds(self): 157 | return await self.get(CONSTANTS.API_URL + "/users/@me/guilds") 158 | 159 | async def join_guild(self, guild_id): 160 | access_token = await oauth.get_access_token(self) 161 | 162 | params = {"access_token": access_token} 163 | headers = { 164 | "Authorization": f"Bot {DISCORD.token}", 165 | } 166 | return await client.put( 167 | CONSTANTS.API_URL + f"/guilds/{guild_id}/members/{self.user_id}", 168 | headers=headers, 169 | json=params, 170 | ) 171 | -------------------------------------------------------------------------------- /utilities/exceptions.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | 5 | class AmbiguityError(commands.BadArgument): 6 | """ 7 | Custom exception to raise when 8 | multiple objects match a search. 9 | """ 10 | 11 | def __init__(self, argument, name: str = "User", *args): 12 | msg = f"Multiple {name}s found from search: `{argument}`. Please retry using the ID or mention to prevent ambiguity." 13 | super().__init__(message=msg, *args) 14 | 15 | 16 | class IntractabilityError(commands.BadArgument): 17 | """ 18 | Custom exception to raise when 19 | an object cannot be manipulated. 20 | """ 21 | 22 | def __init__(self, reason, *args): 23 | super().__init__(message=reason, *args) 24 | 25 | 26 | class WebhookLimit(commands.BadArgument): 27 | """ 28 | Custom exception to raise when the max 29 | webhook limit for a channel is reached. 30 | """ 31 | 32 | def __init__(self, channel, *args): 33 | msg = f"Channel {channel.mention} has reached the maximum number of webhooks (10). Please delete a webhook and retry." 34 | super().__init__(message=msg, *args) 35 | -------------------------------------------------------------------------------- /utilities/formatting.py: -------------------------------------------------------------------------------- 1 | # R.danny 2 | # https://github.com/Rapptz/RoboDanny/blob/rewrite/cogs/utils/formats.py 3 | 4 | 5 | class plural: 6 | def __init__(self, value): 7 | self.value = value 8 | 9 | def __format__(self, format_spec): 10 | v = self.value 11 | singular, sep, plural = format_spec.partition("|") 12 | plural = plural or f"{singular}s" 13 | if abs(v) != 1: 14 | return f"{v} {plural}" 15 | return f"{v} {singular}" 16 | 17 | 18 | def human_join(seq, delim=", ", final="or"): 19 | size = len(seq) 20 | if size == 0: 21 | return "" 22 | 23 | if size == 1: 24 | return seq[0] 25 | 26 | if size == 2: 27 | return f"{seq[0]} {final} {seq[1]}" 28 | 29 | return delim.join(seq[:-1]) + f" {final} {seq[-1]}" 30 | 31 | 32 | class TabularData: 33 | def __init__(self): 34 | self._widths = [] 35 | self._columns = [] 36 | self._rows = [] 37 | 38 | def set_columns(self, columns): 39 | self._columns = columns 40 | self._widths = [len(c) + 2 for c in columns] 41 | 42 | def add_row(self, row): 43 | rows = [str(r) for r in row] 44 | self._rows.append(rows) 45 | for index, element in enumerate(rows): 46 | width = len(element) + 2 47 | if width > self._widths[index]: 48 | self._widths[index] = width 49 | 50 | def add_rows(self, rows): 51 | for row in rows: 52 | self.add_row(row) 53 | 54 | def render(self): 55 | """Renders a table in rST format. 56 | 57 | Example: 58 | 59 | +-------+-----+ 60 | | Name | Age | 61 | +-------+-----+ 62 | | Alice | 24 | 63 | | Bob | 19 | 64 | +-------+-----+ 65 | """ 66 | 67 | sep = "+".join("-" * w for w in self._widths) 68 | sep = f"+{sep}+" 69 | 70 | to_draw = [sep] 71 | 72 | def get_entry(d): 73 | elem = "|".join(f"{e:^{self._widths[i]}}" for i, e in enumerate(d)) 74 | return f"|{elem}|" 75 | 76 | to_draw.append(get_entry(self._columns)) 77 | to_draw.append(sep) 78 | 79 | for row in self._rows: 80 | to_draw.append(get_entry(row)) 81 | 82 | to_draw.append(sep) 83 | return "\n".join(to_draw) 84 | 85 | 86 | class plural: 87 | def __init__(self, value): 88 | self.value = value 89 | 90 | def __format__(self, format_spec): 91 | v = self.value 92 | singular, sep, plural = format_spec.partition("|") 93 | plural = plural or f"{singular}s" 94 | if abs(v) != 1: 95 | return f"{v} {plural}" 96 | return f"{v} {singular}" 97 | -------------------------------------------------------------------------------- /utilities/helpers.py: -------------------------------------------------------------------------------- 1 | from optparse import Option 2 | import discord 3 | import asyncio 4 | from discord.ext import menus 5 | from utilities import pagination, utils 6 | 7 | 8 | async def error_info(ctx, failed, option="User"): 9 | message = f"Failed to {str(ctx.command)} `{', '.join([x[0] for x in failed])}`" 10 | if len(message) > 2000: # Too many users... 11 | message = f"Failed to {str(ctx.command)} `{len(failed)} {option}{'' if len(failed) == 1 else 's'}`" 12 | 13 | mess = await ctx.fail( 14 | f"Failed to {str(ctx.command)} `{', '.join([x[0] for x in failed])}`" 15 | ) 16 | try: 17 | await mess.add_reaction(ctx.bot.emote_dict["error"]) 18 | except Exception: 19 | return 20 | 21 | def rxn_check(r): 22 | if ( 23 | r.message_id == mess.id 24 | and r.user_id == ctx.author.id 25 | and str(r.emoji) == ctx.bot.emote_dict["error"] 26 | ): 27 | return True 28 | return False 29 | 30 | try: 31 | await ctx.bot.wait_for("raw_reaction_add", timeout=30.0, check=rxn_check) 32 | await mess.delete() 33 | await ctx.send_or_reply( 34 | f"{ctx.bot.emote_dict['announce']} **Failure explanation:**" 35 | ) 36 | text = "\n".join([f"{option}: {x[0]} Reason: {x[1]}" for x in failed]) 37 | p = pagination.MainMenu( 38 | pagination.TextPageSource(text=text, prefix="```prolog") 39 | ) 40 | try: 41 | await p.start(ctx) 42 | except menus.MenuError as e: 43 | await ctx.send_or_reply(e) 44 | 45 | except asyncio.TimeoutError: 46 | try: 47 | await mess.clear_reactions() 48 | except Exception: 49 | try: 50 | await mess.remove_reaction(ctx.bot.emote_dict["error"], ctx.bot.user) 51 | except Exception: 52 | pass 53 | 54 | 55 | async def userperms(ctx): 56 | channel_perms = [ 57 | x[0] for x in ctx.channel.permissions_for(ctx.author) if x[1] is True 58 | ] 59 | guild_perms = [x[0] for x in ctx.author.guild_permissions if x[1] is True] 60 | userperms = guild_perms + channel_perms 61 | return userperms 62 | 63 | 64 | async def choose(ctx, search, option_dict): 65 | options = [x for x in option_dict] 66 | option_list = utils.disambiguate(search, options, None, 5) 67 | if len(option_list) != 1: 68 | if not option_list[0]["ratio"] == 1: 69 | option_list = [x["result"] for x in option_list] 70 | index, message = await pagination.Picker( 71 | embed_title="Select one of the closest matches.", 72 | list=option_list, 73 | ctx=ctx, 74 | ).pick(embed=True, syntax="prolog") 75 | 76 | if index < 0: 77 | await message.edit( 78 | content=f"{ctx.bot.emote_dict['info']} Selection cancelled.", 79 | embed=None, 80 | ) 81 | return (None, None) 82 | 83 | selection = option_list[index] 84 | return (option_dict[selection], message) 85 | else: 86 | selection = option_list[0]["result"] 87 | return (option_dict[selection], None) 88 | else: 89 | selection = option_list[0]["result"] 90 | return (option_dict[selection], None) 91 | -------------------------------------------------------------------------------- /utilities/http.py: -------------------------------------------------------------------------------- 1 | class Utils: 2 | ############################## 3 | ## Aiohttp Helper Functions ## 4 | ############################## 5 | 6 | def __init__(self, session): 7 | self.session = session 8 | 9 | async def query(self, url, method="get", res_method="text", *args, **kwargs): 10 | async with getattr(self.session, method.lower())(url, *args, **kwargs) as res: 11 | return await getattr(res, res_method)() 12 | 13 | async def get(self, url, *args, **kwargs): 14 | return await self.query(url, "get", *args, **kwargs) 15 | 16 | async def post(self, url, *args, **kwargs): 17 | return await self.query(url, "post", *args, **kwargs) 18 | 19 | async def put(self, url, *args, **kwargs): 20 | return await self.query(url, "put", *args, **kwargs) 21 | 22 | async def patch(self, url, *args, **kwargs): 23 | return await self.query(url, "patch", *args, **kwargs) 24 | -------------------------------------------------------------------------------- /utilities/humantime.py: -------------------------------------------------------------------------------- 1 | # R.danny 2 | # https://github.com/Rapptz/RoboDanny/blob/rewrite/cogs/utils/time.py 3 | import datetime 4 | import discord 5 | import parsedatetime as pdt 6 | from dateutil.relativedelta import relativedelta 7 | from utilities.formatting import plural, human_join 8 | from discord.ext import commands 9 | import re 10 | 11 | # Monkey patch mins and secs into the units 12 | units = pdt.pdtLocales["en_US"].units 13 | units["minutes"].append("mins") 14 | units["seconds"].append("secs") 15 | 16 | 17 | class ShortTime: 18 | compiled = re.compile( 19 | """ 20 | (?:(?P[0-9])(?:years?|y))? # e.g. 2y 21 | (?:(?P[0-9]{1,2})(?:months?|mo))? # e.g. 2months 22 | (?:(?P[0-9]{1,4})(?:weeks?|w))? # e.g. 10w 23 | (?:(?P[0-9]{1,5})(?:days?|d))? # e.g. 14d 24 | (?:(?P[0-9]{1,5})(?:hours?|h))? # e.g. 12h 25 | (?:(?P[0-9]{1,5})(?:minutes?|m))? # e.g. 10m 26 | (?:(?P[0-9]{1,5})(?:seconds?|s))? # e.g. 15s 27 | """, 28 | re.VERBOSE, 29 | ) 30 | 31 | def __init__(self, argument, *, now=None): 32 | match = self.compiled.fullmatch(argument) 33 | if match is None or not match.group(0): 34 | raise commands.BadArgument("invalid time provided") 35 | 36 | data = {k: int(v) for k, v in match.groupdict(default=0).items()} 37 | now = now or discord.utils.utcnow() 38 | self.dt = now + relativedelta(**data) 39 | 40 | @classmethod 41 | async def convert(cls, ctx, argument): 42 | return cls(argument, now=ctx.message.created_at) 43 | 44 | 45 | class PastShortTime: 46 | compiled = re.compile( 47 | """(?:(?P[0-9])(?:years?|y))? # e.g. 2y 48 | (?:(?P[0-9]{1,2})(?:months?|mo))? # e.g. 2months 49 | (?:(?P[0-9]{1,4})(?:weeks?|w))? # e.g. 10w 50 | (?:(?P[0-9]{1,5})(?:days?|d))? # e.g. 14d 51 | (?:(?P[0-9]{1,5})(?:hours?|h))? # e.g. 12h 52 | (?:(?P[0-9]{1,5})(?:minutes?|m))? # e.g. 10m 53 | (?:(?P[0-9]{1,5})(?:seconds?|s))? # e.g. 15s 54 | """, 55 | re.VERBOSE, 56 | ) 57 | 58 | def __init__(self, argument, *, now=None): 59 | match = self.compiled.fullmatch(argument) 60 | if match is None or not match.group(0): 61 | raise commands.BadArgument("Invalid time provided") 62 | 63 | data = {k: int(v) for k, v in match.groupdict(default=0).items()} 64 | now = now or discord.utils.utcnow() 65 | self.dt = now - relativedelta(**data) 66 | print("THIS IS THE DT") 67 | print(self.dt) 68 | 69 | @classmethod 70 | async def convert(cls, ctx, argument): 71 | return cls(argument, now=ctx.message.created_at) 72 | 73 | 74 | class HumanTime: 75 | calendar = pdt.Calendar(version=pdt.VERSION_CONTEXT_STYLE) 76 | 77 | def __init__(self, argument, *, now=None): 78 | now = now or discord.utils.utcnow() 79 | dt, status = self.calendar.parseDT(argument, sourceTime=now) 80 | if not status.hasDateOrTime: 81 | raise commands.BadArgument( 82 | 'Invalid time provided, try e.g. "tomorrow" or "3 days"' 83 | ) 84 | 85 | if not status.hasTime: 86 | # replace it with the current time 87 | dt = dt.replace( 88 | hour=now.hour, 89 | minute=now.minute, 90 | second=now.second, 91 | microsecond=now.microsecond, 92 | tzinfo=datetime.timezone.utc, 93 | ) 94 | else: 95 | dt = dt.replace(tzinfo=datetime.timezone.utc) 96 | 97 | self.dt = dt 98 | self._past = dt < now 99 | 100 | @classmethod 101 | async def convert(cls, ctx, argument): 102 | return cls(argument, now=ctx.message.created_at) 103 | 104 | 105 | class PastHumanTime: 106 | calendar = pdt.Calendar(version=pdt.VERSION_CONTEXT_STYLE) 107 | 108 | def __init__(self, argument, *, now=None): 109 | now = now or discord.utils.utcnow() 110 | argument = argument + " ago" 111 | dt, status = self.calendar.parseDT(argument, sourceTime=now) 112 | if not status.hasDateOrTime: 113 | raise commands.BadArgument( 114 | 'Invalid time provided, try e.g. "yesterday" or "3 days ago"' 115 | ) 116 | 117 | if not status.hasTime: 118 | # replace it with the current time 119 | dt = dt.replace( 120 | hour=now.hour, 121 | minute=now.minute, 122 | second=now.second, 123 | microsecond=now.microsecond, 124 | tzinfo=datetime.timezone.utc, 125 | ) 126 | else: 127 | dt = dt.replace(tzinfo=datetime.timezone.utc) 128 | 129 | self.dt = dt 130 | self._past = dt < now 131 | 132 | @classmethod 133 | async def convert(cls, ctx, argument): 134 | return cls(argument, now=ctx.message.created_at) 135 | 136 | 137 | class Time(HumanTime): 138 | def __init__(self, argument, *, now=None): 139 | try: 140 | o = ShortTime(argument, now=now) 141 | except Exception as e: 142 | super().__init__(argument) 143 | else: 144 | self.dt = o.dt 145 | self._past = False 146 | 147 | 148 | class NegativeTime(PastHumanTime): 149 | def __init__(self, argument, *, now=None): 150 | try: 151 | o = PastShortTime(argument, now=now) 152 | except Exception as e: 153 | print(e) 154 | super().__init__(argument) 155 | else: 156 | self.dt = o.dt 157 | self._past = False 158 | 159 | 160 | class FutureTime(Time): 161 | def __init__(self, argument, *, now=None): 162 | super().__init__(argument, now=now) 163 | 164 | if self._past: 165 | raise commands.BadArgument("this time is in the past") 166 | 167 | 168 | class PastTime(NegativeTime): 169 | def __init__(self, argument, *, now=None): 170 | super().__init__(argument, now=now) 171 | 172 | 173 | class UserFriendlyTime(commands.Converter): 174 | """That way quotes aren't absolutely necessary.""" 175 | 176 | def __init__(self, converter=None, *, default=None): 177 | if isinstance(converter, type) and issubclass(converter, commands.Converter): 178 | converter = converter() 179 | 180 | if converter is not None and not isinstance(converter, commands.Converter): 181 | raise TypeError("commands.Converter subclass necessary.") 182 | 183 | self.converter = converter 184 | self.default = default 185 | 186 | async def check_constraints(self, ctx, now, remaining): 187 | if not hasattr(self, "dt"): 188 | self.arg = remaining 189 | self.dt = None 190 | return self 191 | if self.dt < now: 192 | raise commands.BadArgument("This time is in the past.") 193 | 194 | if not remaining: 195 | if self.default is None: 196 | raise commands.BadArgument("Missing argument after the time.") 197 | remaining = self.default 198 | 199 | if self.converter is not None: 200 | self.arg = await self.converter.convert(ctx, remaining) 201 | else: 202 | self.arg = remaining 203 | return self 204 | 205 | def copy(self): 206 | cls = self.__class__ 207 | obj = cls.__new__(cls) 208 | obj.converter = self.converter 209 | obj.default = self.default 210 | return obj 211 | 212 | async def convert(self, ctx, argument): 213 | # Create a copy of ourselves to prevent race conditions from two 214 | # events modifying the same instance of a converter 215 | # argument = argument.replace('for') # people sometimes use "for" in the time string 216 | result = self.copy() 217 | try: 218 | calendar = HumanTime.calendar 219 | regex = ShortTime.compiled 220 | now = ctx.message.created_at 221 | 222 | match = regex.match(argument) 223 | if match is not None and match.group(0): 224 | print("matched") 225 | data = {k: int(v) for k, v in match.groupdict(default=0).items()} 226 | remaining = argument[match.end() :].strip() 227 | result.dt = now + relativedelta(**data) 228 | return await result.check_constraints(ctx, now, remaining) 229 | 230 | # apparently nlp does not like "from now" 231 | # it likes "from x" in other cases though so let me handle the 'now' case 232 | argument = argument.replace( 233 | ",", "" 234 | ) # In case someone actually says 3,600 seconds 235 | if argument.endswith("from now"): 236 | argument = argument[:-8].strip() 237 | 238 | if argument[0:2] == "me": 239 | # starts with "me to", "me in", or "me at " 240 | if argument[0:6] in ("me to ", "me in ", "me at "): 241 | argument = argument[6:] 242 | 243 | if argument.strip().startswith("for "): 244 | argument = argument[4:] 245 | 246 | elements = calendar.nlp(argument, sourceTime=now) 247 | if elements is None or len(elements) == 0: 248 | return await result.check_constraints(ctx, now, argument) 249 | 250 | # handle the following cases: 251 | # "date time" foo 252 | # date time foo 253 | # foo date time 254 | 255 | # first the first two cases: 256 | dt, status, begin, end, dt_string = elements[0] 257 | 258 | if not status.hasDateOrTime: 259 | raise commands.BadArgument( 260 | "Invalid time provided, try `tomorrow` or `2 days`." 261 | ) 262 | if begin not in (0, 1) and end != len(argument): 263 | raise commands.BadArgument( 264 | f"I did not understand your input. Please use the `{ctx.clean_prefix}examples` command for assistance." 265 | ) 266 | 267 | if not status.hasTime: 268 | # replace it with the current time 269 | dt = dt.replace( 270 | hour=now.hour, 271 | minute=now.minute, 272 | second=now.second, 273 | microsecond=now.microsecond, 274 | tzinfo=datetime.timezone.utc, 275 | ) 276 | else: 277 | dt = dt.replace(tzinfo=datetime.timezone.utc) 278 | 279 | # if midnight is provided, just default to next day 280 | if status.accuracy == pdt.pdtContext.ACU_HALFDAY: 281 | dt = dt.replace(day=now.day + 1) 282 | 283 | result.dt = dt 284 | 285 | if begin in (0, 1): 286 | if begin == 1: 287 | # check if it's quoted: 288 | # if argument[0] != '"': 289 | # raise commands.BadArgument( 290 | # "Expected quote before time input..." 291 | # ) 292 | 293 | # if not (end < len(argument) and argument[end] == '"'): 294 | # raise commands.BadArgument( 295 | # "If the time is quoted, you must unquote it." 296 | # ) 297 | 298 | remaining = argument[end + 1 :].lstrip(" ,.!") 299 | else: 300 | remaining = argument[end:].lstrip(" ,.!") 301 | elif len(argument) == end: 302 | remaining = argument[:begin].strip() 303 | 304 | return await result.check_constraints(ctx, now, remaining) 305 | except Exception as e: 306 | print(f"Error in UserFriendlyTime: {e}") 307 | 308 | 309 | def human_timedelta(dt, *, source=None, accuracy=3, brief=False, suffix=True): 310 | now = source or discord.utils.utcnow() 311 | # Microsecond free zone 312 | now = now.replace(microsecond=0, tzinfo=datetime.timezone.utc) 313 | dt = dt.replace(microsecond=0, tzinfo=datetime.timezone.utc) 314 | 315 | # This implementation uses relativedelta instead of the much more obvious 316 | # divmod approach with seconds because the seconds approach is not entirely 317 | # accurate once you go over 1 week in terms of accuracy since you have to 318 | # hardcode a month as 30 or 31 days. 319 | # A query like "11 months" can be interpreted as "!1 months and 6 days" 320 | if dt > now: 321 | delta = relativedelta(dt, now) 322 | suffix = "" 323 | else: 324 | delta = relativedelta(now, dt) 325 | suffix = " ago" if suffix else "" 326 | 327 | attrs = [ 328 | ("year", "y"), 329 | ("month", "mo"), 330 | ("day", "d"), 331 | ("hour", "h"), 332 | ("minute", "m"), 333 | ("second", "s"), 334 | ] 335 | 336 | output = [] 337 | for attr, brief_attr in attrs: 338 | elem = getattr(delta, attr + "s") 339 | if not elem: 340 | continue 341 | 342 | if attr == "day": 343 | weeks = delta.weeks 344 | if weeks: 345 | elem -= weeks * 7 346 | if not brief: 347 | output.append(format(plural(weeks), "week")) 348 | else: 349 | output.append(f"{weeks}w") 350 | 351 | if elem <= 0: 352 | continue 353 | 354 | if brief: 355 | output.append(f"{elem}{brief_attr}") 356 | else: 357 | output.append(format(plural(elem), attr)) 358 | 359 | if accuracy is not None: 360 | output = output[:accuracy] 361 | 362 | if len(output) == 0: 363 | return "now" 364 | else: 365 | if not brief: 366 | return human_join(output, final="and") + suffix 367 | else: 368 | return " ".join(output) + suffix 369 | -------------------------------------------------------------------------------- /utilities/images.py: -------------------------------------------------------------------------------- 1 | # https://github.com/CuteFwan/Koishi 2 | 3 | import io 4 | import math 5 | 6 | from datetime import datetime 7 | from PIL import Image, ImageFont, ImageDraw, ImageSequence 8 | 9 | from . import utils 10 | from settings.constants import Colors 11 | 12 | statusmap = { 13 | "online": Colors.GREEN, 14 | "idle": Colors.YELLOW, 15 | "dnd": Colors.RED, 16 | "offline": Colors.GRAY, 17 | } 18 | 19 | 20 | def get_piestatus(statuses, startdate): 21 | total = sum(statuses.values()) 22 | online = statuses.get("online", 0) 23 | idle = statuses.get("idle", 0) 24 | dnd = statuses.get("dnd", 0) 25 | offline = statuses.get("offline", 0) 26 | uptime = total - offline 27 | 28 | img = Image.new("RGBA", (2500, 1000), (0, 0, 0, 0)) 29 | draw = ImageDraw.Draw(img) 30 | font = ImageFont.truetype("./data/assets/Helvetica.ttf", 100) 31 | shape = [(50, 0), (1050, 1000)] 32 | start = 0 33 | for status, value in sorted(statuses.items(), key=lambda x: x[1], reverse=True): 34 | end = 360 * (value / total) + start 35 | draw.arc( 36 | shape, 37 | start=start, 38 | end=360 * (value / total) + start, 39 | fill=statusmap.get(status), 40 | width=200, 41 | ) 42 | start = end 43 | 44 | text = f"{uptime/total:.2%}" 45 | text_width, text_height = draw.textsize(text, font) 46 | position = ((1100 - text_width) / 2, (1000 - text_height) / 2) 47 | draw.text(position, text, Colors.WHITE, font=font) 48 | 49 | font = ImageFont.truetype("./data/assets/Helvetica-Bold.ttf", 85) 50 | draw.text((1200, 0), "Status Tracking Startdate:", fill=Colors.WHITE, font=font) 51 | font = ImageFont.truetype("./data/assets/Helvetica.ttf", 68) 52 | draw.text( 53 | (1200, 100), 54 | utils.timeago(datetime.utcnow() - startdate), 55 | fill=Colors.WHITE, 56 | font=font, 57 | ) 58 | font = ImageFont.truetype("./data/assets/Helvetica-Bold.ttf", 85) 59 | draw.text((1200, 300), "Total Online Time:", fill=Colors.WHITE, font=font) 60 | font = ImageFont.truetype("./data/assets/Helvetica.ttf", 68) 61 | draw.text( 62 | (1200, 400), 63 | f"{uptime/3600:.2f} {'Hour' if int(uptime/3600) == 1 else 'Hours'}", 64 | fill=Colors.WHITE, 65 | font=font, 66 | ) 67 | 68 | font = ImageFont.truetype("./data/assets/Helvetica-Bold.ttf", 85) 69 | draw.text((1200, 600), "Status Information:", fill=Colors.WHITE, font=font) 70 | font = ImageFont.truetype("./data/assets/Helvetica.ttf", 68) 71 | 72 | draw.rectangle((1200, 700, 1275, 775), fill=Colors.GREEN) 73 | draw.text( 74 | (1300, 710), 75 | f"Online: {online/total:.2%}", 76 | fill=Colors.WHITE, 77 | font=font, 78 | ) 79 | draw.rectangle((1850, 700, 1925, 775), fill=Colors.YELLOW) 80 | draw.text( 81 | (1950, 710), 82 | f"Idle: {idle/total:.2%}", 83 | fill=Colors.WHITE, 84 | font=font, 85 | ) 86 | draw.rectangle((1200, 800, 1275, 875), fill=Colors.RED) 87 | draw.text( 88 | (1300, 810), 89 | f"DND: {dnd/total:.2%}", 90 | fill=Colors.WHITE, 91 | font=font, 92 | ) 93 | draw.rectangle((1850, 800, 1925, 875), fill=Colors.GRAY, outline=(0, 0, 0)) 94 | draw.text( 95 | (1950, 810), 96 | f"Offline: {offline/total:.2%}", 97 | fill=Colors.WHITE, 98 | font=font, 99 | ) 100 | 101 | buffer = io.BytesIO() 102 | img.save(buffer, "png") 103 | buffer.seek(0) 104 | return buffer 105 | 106 | 107 | def get_barstatus(title, statuses): 108 | highest = max(statuses.values()) 109 | highest_unit = get_time_unit(highest) 110 | units = {stat: get_time_unit(value) for stat, value in statuses.items()} 111 | heights = {stat: (value / highest) * 250 for stat, value in statuses.items()} 112 | box_size = (400, 300) 113 | rect_x_start = { 114 | k: 64 + (84 * v) 115 | for k, v in {"online": 0, "idle": 1, "dnd": 2, "offline": 3}.items() 116 | } 117 | rect_width = 70 118 | rect_y_end = 275 119 | labels = {"online": "Online", "idle": "Idle", "dnd": "DND", "offline": "Offline"} 120 | base = Image.new(mode="RGBA", size=box_size, color=(0, 0, 0, 0)) 121 | with Image.open("./data/assets/bargraph.png") as grid: 122 | font = ImageFont.truetype("./data/assets/Helvetica.ttf", 15) 123 | draw = ImageDraw.Draw(base) 124 | draw.text((0, 0), highest_unit[1], fill=Colors.WHITE, font=font) 125 | draw.text((52, 2), title, fill=Colors.WHITE, font=font) 126 | divs = 11 127 | for i in range(divs): 128 | draw.line( 129 | ( 130 | (50, 25 + ((box_size[1] - 50) / (divs - 1)) * i), 131 | (box_size[0], 25 + ((box_size[1] - 50) / (divs - 1)) * i), 132 | ), 133 | fill=(*Colors.WHITE, 128), 134 | width=1, 135 | ) 136 | draw.text( 137 | (5, 25 + ((box_size[1] - 50) / (divs - 1)) * i - 6), 138 | f"{highest_unit[0]-i*highest_unit[0]/(divs-1):.2f}", 139 | fill=Colors.WHITE, 140 | font=font, 141 | ) 142 | for k, v in statuses.items(): 143 | draw.rectangle( 144 | ( 145 | (rect_x_start[k], rect_y_end - heights[k]), 146 | (rect_x_start[k] + rect_width, rect_y_end), 147 | ), 148 | fill=statusmap[k], 149 | ) 150 | draw.text( 151 | (rect_x_start[k], rect_y_end - heights[k] - 13), 152 | f"{units[k][0]} {units[k][1]}", 153 | fill=Colors.WHITE, 154 | font=font, 155 | ) 156 | draw.text( 157 | (rect_x_start[k], box_size[1] - 25), 158 | labels[k], 159 | fill=Colors.WHITE, 160 | font=font, 161 | ) 162 | del draw 163 | base.paste(grid, None, grid) 164 | buffer = io.BytesIO() 165 | base.save(buffer, "png") 166 | buffer.seek(0) 167 | return buffer 168 | 169 | 170 | def get_time_unit(stat): 171 | word = "" 172 | if stat >= 604800: 173 | stat /= 604800 174 | word = "Week" 175 | elif stat >= 86400: 176 | stat /= 86400 177 | word = "Day" 178 | elif stat >= 3600: 179 | stat /= 3600 180 | word = "Hour" 181 | elif stat >= 60: 182 | stat /= 60 183 | word = "Minute" 184 | else: 185 | word = "Second" 186 | stat = float(f"{stat:.1f}") 187 | if stat > 1 or stat == 0.0: 188 | word += "s" 189 | return stat, word 190 | 191 | 192 | def resize_to_limit(data, limit): 193 | """ 194 | Downsize it for huge PIL images. 195 | Half the resolution until the byte count is within the limit. 196 | """ 197 | current_size = data.getbuffer().nbytes 198 | while current_size > limit: 199 | with Image.open(data) as im: 200 | data = io.BytesIO() 201 | if im.format == "PNG": 202 | im = im.resize([i // 2 for i in im.size], resample=Image.BICUBIC) 203 | im.save(data, "png") 204 | elif im.format == "GIF": 205 | durations = [] 206 | new_frames = [] 207 | for frame in ImageSequence.Iterator(im): 208 | durations.append(frame.info["duration"]) 209 | new_frames.append( 210 | frame.resize([i // 2 for i in im.size], resample=Image.BICUBIC) 211 | ) 212 | new_frames[0].save( 213 | data, 214 | save_all=True, 215 | append_images=new_frames[1:], 216 | format="gif", 217 | version=im.info["version"], 218 | duration=durations, 219 | loop=0, 220 | background=im.info["background"], 221 | palette=im.getpalette(), 222 | ) 223 | data.seek(0) 224 | current_size = data.getbuffer().nbytes 225 | return data 226 | 227 | 228 | def extract_first_frame(data): 229 | with Image.open(data) as im: 230 | im = im.convert("RGBA") 231 | b = io.BytesIO() 232 | im.save(b, "gif") 233 | b.seek(0) 234 | return b 235 | 236 | 237 | def quilt(images): 238 | xbound = math.ceil(math.sqrt(len(images))) 239 | ybound = math.ceil(len(images) / xbound) 240 | size = int(2520 / xbound) 241 | 242 | with Image.new( 243 | "RGBA", size=(xbound * size, ybound * size), color=(0, 0, 0, 0) 244 | ) as base: 245 | x, y = 0, 0 246 | for avy in images: 247 | if avy: 248 | im = Image.open(io.BytesIO(avy)).resize( 249 | (size, size), resample=Image.BICUBIC 250 | ) 251 | base.paste(im, box=(x * size, y * size)) 252 | if x < xbound - 1: 253 | x += 1 254 | else: 255 | x = 0 256 | y += 1 257 | buffer = io.BytesIO() 258 | base.save(buffer, "png") 259 | buffer.seek(0) 260 | buffer = resize_to_limit(buffer, 8000000) 261 | return buffer 262 | -------------------------------------------------------------------------------- /utilities/override.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | from utilities import views 4 | 5 | 6 | class BotContext(commands.Context): 7 | def __init__(self, **kwargs): 8 | super().__init__(**kwargs) 9 | self.handled = False 10 | 11 | def is_owner(self): 12 | """Checks if the author is one of the owners""" 13 | return self.author.id in self.bot.config.OWNERS 14 | 15 | def is_admin(self): 16 | return ( 17 | self.author.id in self.bot.config.ADMINS 18 | or self.author.id in self.bot.config.OWNERS 19 | ) 20 | 21 | async def fail(self, content=None, refer=True, **kwargs): 22 | if refer: 23 | return await self.send_or_reply( 24 | self.bot.emote_dict["failed"] + " " + (content if content else ""), 25 | **kwargs, 26 | ) 27 | return await self.send( 28 | self.bot.emote_dict["failed"] + " " + (content if content else ""), **kwargs 29 | ) 30 | 31 | async def success(self, content=None, **kwargs): 32 | return await self.send_or_reply( 33 | self.bot.emote_dict["success"] + " " + (content if content else ""), 34 | **kwargs, 35 | ) 36 | 37 | async def music(self, content=None, **kwargs): 38 | return await self.send_or_reply( 39 | self.bot.emote_dict["music"] + " " + (content if content else ""), 40 | **kwargs, 41 | ) 42 | 43 | async def send_or_reply(self, content=None, **kwargs): 44 | if not self.channel.permissions_for(self.me).send_messages: 45 | return 46 | ref = self.message.reference 47 | if ref and isinstance(ref.resolved, discord.Message): 48 | return await self.send( 49 | content, **kwargs, reference=ref.resolved.to_reference() 50 | ) 51 | return await self.send(content, **kwargs) 52 | 53 | async def safe_send(self, content=None, **kwargs): 54 | try: 55 | return await self.send_or_reply(content, **kwargs) 56 | except Exception: 57 | return 58 | 59 | async def rep_or_ref(self, content=None, **kwargs): 60 | ref = self.message.reference 61 | if ref and isinstance(ref.resolved, discord.Message): 62 | return await self.send( 63 | content, **kwargs, reference=ref.resolved.to_reference() 64 | ) 65 | return await self.reply(content, **kwargs) 66 | 67 | async def react(self, reaction=None, content=None, **kwargs): 68 | try: 69 | return await self.message.add_reaction(reaction) 70 | except Exception: 71 | pass 72 | 73 | async def bold(self, content=None, **kwargs): 74 | return await self.send_or_reply( 75 | "**" + (content if content else "") + "**", **kwargs 76 | ) 77 | 78 | async def usage(self, usage=None, command=None, **kwargs): 79 | if command: 80 | name = command.qualified_name 81 | else: 82 | name = self.command.qualified_name 83 | content = ( 84 | f"Usage: `{self.prefix}{name} " 85 | + (usage if usage else self.command.signature) 86 | + "`" 87 | ) 88 | return await self.send_or_reply(content, **kwargs) 89 | 90 | async def load(self, content=None, **kwargs): 91 | content = f"{self.bot.emote_dict['loading']} **{content}**" 92 | return await self.send_or_reply(content, **kwargs) 93 | 94 | async def confirm(self, content="", *, suffix: bool = True, **kwargs): 95 | content = f"**{content} Do you wish to continue?**" if suffix else content 96 | return await views.Confirmation(self, content, **kwargs).prompt() 97 | 98 | async def dm(self, content=None, **kwargs): 99 | try: 100 | await self.author.send(content, **kwargs) 101 | except Exception: 102 | await self.send_or_reply(content, **kwargs) 103 | 104 | async def trigger_typing(self): 105 | try: 106 | await super().trigger_typing() 107 | except Exception: 108 | return 109 | 110 | 111 | class BotCommand(commands.Command): 112 | def __init__(self, func, **kwargs): 113 | super().__init__(func, **kwargs) 114 | self.cooldown_after_parsing = kwargs.pop("cooldown_after_parsing", True) 115 | self.examples = kwargs.pop("examples", None) 116 | self.implemented = kwargs.pop("implemented", None) 117 | self.updated = kwargs.pop("updated", None) 118 | self.writer = kwargs.pop("writer", 708584008065351681) 119 | # Maybe someday more will contribute... :(( 120 | 121 | 122 | class BotGroup(commands.Group): 123 | def __init__(self, func, **kwargs): 124 | super().__init__(func, **kwargs) 125 | self.case_insensitive = kwargs.pop("case_insensitive", True) 126 | self.cooldown_after_parsing = kwargs.pop("cooldown_after_parsing", True) 127 | self.invoke_without_command = kwargs.pop("invoke_without_command", False) 128 | self.examples = kwargs.pop("examples", None) 129 | self.implemented = kwargs.pop("implemented", None) 130 | self.updated = kwargs.pop("updated", None) 131 | self.writer = kwargs.pop("writer", 708584008065351681) 132 | 133 | 134 | class CustomCooldown: 135 | def __init__( 136 | self, 137 | rate: int = 3, 138 | per: float = 10.0, 139 | *, 140 | alter_rate: int = 0, 141 | alter_per: float = 0.0, 142 | bucket: commands.BucketType = commands.BucketType.user, 143 | bypass: list = [], 144 | ): 145 | self.cooldown = (rate, per) 146 | 147 | self.type = bucket 148 | self.bypass = bypass 149 | self.default_mapping = commands.CooldownMapping.from_cooldown(rate, per, bucket) 150 | self.altered_mapping = commands.CooldownMapping.from_cooldown( 151 | alter_rate, alter_per, bucket 152 | ) 153 | self.owner_mapping = commands.CooldownMapping.from_cooldown(0, 0, bucket) 154 | self.owner = 708584008065351681 155 | 156 | def __call__(self, ctx): 157 | key = self.altered_mapping._bucket_key(ctx.message) 158 | if key == self.owner: 159 | bucket = self.owner_mapping.get_bucket(ctx.message) 160 | elif key in self.bypass: 161 | bucket = self.altered_mapping.get_bucket(ctx.message) 162 | else: 163 | bucket = self.default_mapping.get_bucket(ctx.message) 164 | retry_after = bucket.update_rate_limit() 165 | if retry_after: 166 | raise commands.CommandOnCooldown(bucket, retry_after, self.type) 167 | return True 168 | -------------------------------------------------------------------------------- /utilities/spotify.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | 3 | import re 4 | import base64 5 | import time 6 | import json 7 | 8 | 9 | from config import SPOTIFY 10 | from core import bot as client 11 | 12 | 13 | def url_to_uri(url): 14 | if "open.spotify.com" in url: 15 | sub_regex = r"(http[s]?:\/\/)?(open.spotify.com)\/" 16 | url = "spotify:" + re.sub(sub_regex, "", url) 17 | url = url.replace("/", ":") 18 | # remove session id (and other query stuff) 19 | uri = re.sub("\?.*", "", url) 20 | return uri 21 | 22 | 23 | class CONSTANTS: 24 | WHITE_ICON = "https://cdn.discordapp.com/attachments/872338764276576266/927649624888602624/spotify_white.png" 25 | API_URL = "https://api.spotify.com/v1/" 26 | AUTH_URL = "https://accounts.spotify.com/authorize" 27 | TOKEN_URL = "https://accounts.spotify.com/api/token" 28 | SCOPES = [ 29 | # Spotify connect 30 | "user-read-playback-state", 31 | "user-modify-playback-state", 32 | "user-read-currently-playing", 33 | # Users 34 | "user-read-private", 35 | # Follow 36 | "user-follow-modify", 37 | "user-follow-read", 38 | # Library 39 | "user-library-modify", 40 | "user-library-read", 41 | # Listening history 42 | "user-read-playback-position", 43 | "user-top-read", 44 | "user-read-recently-played", 45 | # Playlists 46 | "playlist-modify-private", 47 | "playlist-read-collaborative", 48 | "playlist-read-private", 49 | "playlist-modify-public", 50 | ] 51 | 52 | 53 | class Oauth: 54 | def __init__(self, scope=None): 55 | self.scope = " ".join(CONSTANTS.SCOPES) 56 | 57 | self.client_token = None 58 | 59 | @property 60 | def headers(self): 61 | """ 62 | Return proper headers for all token requests 63 | """ 64 | auth_header = base64.b64encode( 65 | (SPOTIFY.client_id + ":" + SPOTIFY.client_secret).encode("ascii") 66 | ) 67 | return { 68 | "Authorization": "Basic %s" % auth_header.decode("ascii"), 69 | "Content-Type": "application/x-www-form-urlencoded", 70 | } 71 | 72 | def get_auth_url(self, state): 73 | """ 74 | Return an authorization url to get an access code 75 | """ 76 | params = { 77 | "client_id": SPOTIFY.client_id, 78 | "response_type": "code", 79 | "redirect_uri": SPOTIFY.redirect_uri, 80 | "state": state, 81 | "scope": " ".join(CONSTANTS.SCOPES), 82 | # "show_dialog": True 83 | } 84 | constructed = urlencode(params) 85 | return "%s?%s" % (CONSTANTS.AUTH_URL, constructed) 86 | 87 | def validate_token(self, token_info): 88 | """Checks a token is valid""" 89 | now = int(time.time()) 90 | return token_info["expires_at"] - now < 60 91 | 92 | async def get_access_token(self, user_id, token_info): 93 | """Gets the token or creates a new one if expired""" 94 | token_info["expires_at"] = int(time.time()) + token_info["expires_in"] 95 | if self.validate_token(token_info): 96 | return token_info["access_token"] 97 | 98 | token_info = await self.refresh_access_token( 99 | user_id, token_info.get("refresh_token") 100 | ) 101 | 102 | return token_info["access_token"] 103 | 104 | async def refresh_access_token(self, user_id, refresh_token): 105 | params = {"grant_type": "refresh_token", "refresh_token": refresh_token} 106 | token_info = await client.post( 107 | CONSTANTS.TOKEN_URL, data=params, headers=self.headers, res_method="json" 108 | ) 109 | if not token_info.get("refresh_token"): 110 | # Didn't get new refresh token. 111 | # Old one is still valid. 112 | token_info["refresh_token"] = refresh_token 113 | 114 | query = """ 115 | INSERT INTO spotify_auth 116 | VALUES ($1, $2) 117 | ON CONFLICT (user_id) 118 | DO UPDATE SET token_info = $2 119 | WHERE spotify_auth.user_id = $1; 120 | """ 121 | await client.cxn.execute(query, user_id, json.dumps(token_info)) 122 | 123 | return token_info 124 | 125 | async def request_access_token(self, code): 126 | payload = { 127 | "grant_type": "authorization_code", 128 | "code": code, 129 | "redirect_uri": SPOTIFY.redirect_uri, 130 | } 131 | token_info = await client.post( 132 | CONSTANTS.TOKEN_URL, data=payload, headers=self.headers, res_method="json" 133 | ) 134 | return token_info 135 | 136 | async def get_client_token(self): 137 | """Gets the token or creates a new one if expired""" 138 | if self.client_token and self.validate_token(self.client_token): 139 | return self.client_token["access_token"] 140 | 141 | client_token = await self.request_client_token() 142 | 143 | client_token["expires_at"] = int(time.time()) + client_token["expires_in"] 144 | self.client_token = client_token 145 | return self.client_token["access_token"] 146 | 147 | async def request_client_token(self): 148 | """Obtains a token from Spotify and returns it""" 149 | payload = {"grant_type": "client_credentials"} 150 | return await client.post( 151 | CONSTANTS.TOKEN_URL, data=payload, headers=self.headers, res_method="json" 152 | ) 153 | 154 | 155 | oauth = Oauth() 156 | 157 | 158 | class User: # Spotify user w discord user_id 159 | def __init__(self, user_id, token_info): 160 | self.user_id = user_id 161 | self.token_info = token_info 162 | 163 | @classmethod 164 | async def load(cls, user_id): 165 | query = """ 166 | SELECT token_info 167 | FROM spotify_auth 168 | WHERE user_id = $1; 169 | """ 170 | token_info = await client.cxn.fetchval(query, int(user_id)) 171 | 172 | if token_info: 173 | token_info = json.loads(token_info) 174 | return cls(user_id, token_info) 175 | 176 | async def auth(self): 177 | access_token = await oauth.get_access_token(self.user_id, self.token_info) 178 | 179 | headers = { 180 | "Authorization": f"Bearer {access_token}", 181 | "Content-Type": "application/json", 182 | } 183 | return headers 184 | 185 | async def get(self, url): 186 | return await client.get(url, headers=await self.auth(), res_method="json") 187 | 188 | async def put(self, url, headers=None, json=None, res_method=None): 189 | headers = headers or await self.auth() 190 | return await client.put(url, headers=headers, json=json, res_method=res_method) 191 | 192 | async def get_profile(self): 193 | return await self.get(CONSTANTS.API_URL + "me") 194 | 195 | async def get_playback_state(self): 196 | return await self.get(CONSTANTS.API_URL + "me/player") 197 | 198 | async def get_currently_playing(self): 199 | return await self.get(CONSTANTS.API_URL + "me/player/currently-playing") 200 | 201 | async def get_devices(self): 202 | return await self.get(CONSTANTS.API_URL + "me/player/devices") 203 | 204 | async def transfer_playback(self, devices, play: bool = False): 205 | return await self.put( 206 | CONSTANTS.API_URL + "me/player", json={"device_ids": devices, "play": play} 207 | ) 208 | 209 | async def get_recently_played(self, limit=50): 210 | params = {"limit": limit} 211 | query_params = urlencode(params) 212 | return await self.get( 213 | CONSTANTS.API_URL + "me/player/recently-played?" + query_params 214 | ) 215 | 216 | async def get_top_tracks(self, limit=50, time_range="long_term"): 217 | params = {"limit": limit, "time_range": time_range} 218 | query_params = urlencode(params) 219 | return await self.get(CONSTANTS.API_URL + "me/top/tracks?" + query_params) 220 | 221 | async def get_top_artists(self, limit=50, time_range="long_term"): 222 | params = {"limit": limit, "time_range": time_range} 223 | query_params = urlencode(params) 224 | return await self.get(CONSTANTS.API_URL + "me/top/artists?" + query_params) 225 | 226 | async def get_top_albums(self, limit=50): 227 | params = {"limit": limit} 228 | query_params = urlencode(params) 229 | return await self.get(CONSTANTS.API_URL + "me/albums?" + query_params) 230 | 231 | async def pause(self): 232 | return await self.put(CONSTANTS.API_URL + "me/player/pause") 233 | 234 | async def play(self, **kwargs): 235 | return await self.put(CONSTANTS.API_URL + "me/player/play", json=kwargs) 236 | 237 | async def play(self, **kwargs): 238 | return await self.put(CONSTANTS.API_URL + "me/player/play", json=kwargs) 239 | 240 | async def skip_to_next(self): 241 | return await client.post( 242 | CONSTANTS.API_URL + "me/player/next", 243 | headers=await self.auth(), 244 | res_method=None, 245 | ) 246 | 247 | async def skip_to_previous(self): 248 | return await client.post( 249 | CONSTANTS.API_URL + "me/player/previous", 250 | headers=await self.auth(), 251 | res_method=None, 252 | ) 253 | 254 | async def seek(self, position): 255 | params = {"position_ms": position * 1000} 256 | query_params = urlencode(params) 257 | return await self.put(CONSTANTS.API_URL + "me/player/seek?" + query_params) 258 | 259 | async def repeat(self, option): 260 | params = {"state": option} 261 | query_params = urlencode(params) 262 | return await self.put(CONSTANTS.API_URL + "me/player/repeat?" + query_params) 263 | 264 | async def shuffle(self, option: bool): 265 | params = {"state": option} 266 | query_params = urlencode(params) 267 | return await self.put(CONSTANTS.API_URL + "me/player/shuffle?" + query_params) 268 | 269 | async def volume(self, amount): 270 | params = {"volume_percent": amount} 271 | query_params = urlencode(params) 272 | return await self.put(CONSTANTS.API_URL + "me/player/volume?" + query_params) 273 | 274 | async def enqueue(self, uri): 275 | params = {"uri": uri} 276 | query_params = urlencode(params) 277 | return await client.post( 278 | CONSTANTS.API_URL + "me/player/queue?" + query_params, 279 | headers=await self.auth(), 280 | res_method=None, 281 | ) 282 | 283 | async def get_playlists(self, limit=50, offset=0): 284 | """Get a user's owned and followed playlists""" 285 | params = {"limit": limit, "offset": offset} 286 | query_params = urlencode(params) 287 | return await self.get(CONSTANTS.API_URL + "me/playlists?" + query_params) 288 | 289 | 290 | async def auth(): 291 | access_token = await oauth.get_client_token() 292 | 293 | headers = { 294 | "Authorization": f"Bearer {access_token}", 295 | "Content-Type": "application/json", 296 | } 297 | return headers 298 | 299 | 300 | async def _get(url): 301 | return await client.get(url, headers=await auth(), res_method="json") 302 | 303 | 304 | async def get_playlist(uri): 305 | playlist_id = uri.split(":")[-1] 306 | return await _get(CONSTANTS.API_URL + f"playlists/{playlist_id}") 307 | 308 | 309 | async def get_user_playlists(username, limit=50, offset=0): 310 | """Get a user's owned and followed playlists""" 311 | 312 | params = {"limit": limit, "offset": offset} 313 | query_params = urlencode(params) 314 | return await _get(CONSTANTS.API_URL + f"users/{username}/playlists?" + query_params) 315 | --------------------------------------------------------------------------------