├── __init__.py ├── db ├── accounts.json ├── roles.json ├── admin.json ├── blacklist.json └── groups.json ├── plugins ├── settings │ ├── admin.json │ ├── groups.json │ └── imgur.json ├── xkcd.py ├── messages.py ├── groups.py ├── imgur.py ├── roles.py └── admin.py ├── config └── config.json ├── emoji.info ├── .gitignore ├── LICENSE ├── helpers.py ├── README.md ├── accounts.py └── bot.py /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /db/accounts.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | } -------------------------------------------------------------------------------- /db/roles.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | } -------------------------------------------------------------------------------- /db/admin.json: -------------------------------------------------------------------------------- 1 | { 2 | "servers": { 3 | 4 | } 5 | } -------------------------------------------------------------------------------- /db/blacklist.json: -------------------------------------------------------------------------------- 1 | { 2 | "users": [ 3 | 4 | ] 5 | } -------------------------------------------------------------------------------- /db/groups.json: -------------------------------------------------------------------------------- 1 | { 2 | "channels": [ 3 | 4 | ] 5 | } -------------------------------------------------------------------------------- /plugins/settings/admin.json: -------------------------------------------------------------------------------- 1 | { 2 | "log": true, 3 | "display_purges": true 4 | } -------------------------------------------------------------------------------- /plugins/settings/groups.json: -------------------------------------------------------------------------------- 1 | { 2 | "prefix": "Group_", 3 | "suffix": "" 4 | } -------------------------------------------------------------------------------- /plugins/settings/imgur.json: -------------------------------------------------------------------------------- 1 | { 2 | "client_id": "", 3 | "client_secret": "" 4 | } -------------------------------------------------------------------------------- /config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "owner": "your_uid_here", 3 | "token": "bot_token_here", 4 | "bot_name": "Ispyra", 5 | "bot_avatar": "avatar.png", 6 | "command_prefix": "|", 7 | "commands_on_edit": true, 8 | "log_file": "ispyra.log", 9 | "log_messages": true, 10 | "log_commands": true 11 | } 12 | -------------------------------------------------------------------------------- /emoji.info: -------------------------------------------------------------------------------- 1 | # I HATE EMOJIS SO MUCH 2 | # This file is basically a reference for me for easy acccess to emoji strings 3 | # The names might not even make sense to other people reading this 4 | # Oh well 5 | warning = "\U000026A0" 6 | exclaim = "\U00002757" 7 | blocked = "\U0001F6AB" 8 | question = "\U00002754" 9 | wave = "\U0001f44b" 10 | checkmark = "\U00002705" 11 | hammer = "\U0001F528" 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Ispira (https://github.com/Ispira) 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 | -------------------------------------------------------------------------------- /helpers.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import json 4 | import logging 5 | 6 | from datetime import datetime 7 | from discord.ext import commands as c 8 | 9 | # Get any needed config values 10 | with open("config/config.json") as cfg: 11 | config = json.load(cfg) 12 | 13 | owner = config["owner"] 14 | config = None 15 | 16 | #### Helper functions 17 | 18 | # Take a datetime object and return a prettified string 19 | def pretty_datetime(dt: datetime, display = "FULL"): 20 | pretty = "" 21 | if display.upper() == "FULL": 22 | pretty = f"{dt.year}-{dt.month}-{dt.day} {dt.hour}:{dt.minute}" 23 | elif display.upper() == "TIME": 24 | pretty = f"{dt.hour}:{dt.minute}:{dt.second}" 25 | return pretty 26 | 27 | # Create a logging flow and return the logger 28 | def get_logger(file_name): 29 | date = datetime.now() 30 | timestamp = "{0.year}-{0.month}-{0.day}_{0.hour}-{0.minute}-{0.second}".format(date) 31 | log_file = f"logs/{timestamp}_{file_name}" 32 | if not os.path.exists("logs"): 33 | os.makedirs("logs") 34 | log = logging.getLogger() 35 | log.setLevel(logging.INFO) 36 | log.addHandler(logging.FileHandler(filename=file_name, encoding="utf-8")) 37 | log.addHandler(logging.StreamHandler(sys.stdout)) 38 | return log 39 | 40 | # Update a database 41 | def update_db(db, name): 42 | with open(f"db/{name}.json", "w") as dbfile: 43 | json.dump(db, dbfile, indent=4) 44 | 45 | #### Checks 46 | 47 | # Command check returning if the user is the bot owner or not 48 | def is_owner(): 49 | return c.check(lambda ctx: ctx.message.author.id == owner) 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # **Ispyra** 2 | #### A discord bot utilizing [discord.py](https://github.com/Rapptz/discord.py) 3 | --- 4 | ## **Setup** 5 | ***Warning:*** This bot is made mainly for my specific server(s). No attempt is, or will ever 6 | be made to make installation or setup easier. This bot uses dev builds of discord.py and even Python itself. 7 | 8 | ***Prerequisites:*** 9 | - [Python 3.6](https://www.python.org/download/pre-releases/) 10 | - Python 3.6 is REQUIRED as this bot makes heavy use of the new string formatting. 11 | - [discord.py](https://github.com/Rapptz/discord.py) 12 | - Latest development version 13 | - [Discord API app](https://discordapp.com/developers/applications/me) 14 | - You'll need to create the bot account for the app if that wasn't obvious. 15 | - Required for Imgur plugin: 16 | - [imgurpython](https://github.com/Imgur/imgurpython) 17 | - [Imugr API app](http://api.imgur.com/) 18 | - Required for xkcd plugin: 19 | - [xkcd](https://pypi.python.org/pypi/xkcd/) 20 | 21 | ***Setup:*** 22 | - Download or clone the repo 23 | - Edit `config/config.json` with the appropriate info and settings 24 | - Edit config files in `plugins/settings` with the appropriate settings 25 | - Do **NOT** edit files in the `db` folder yourself. They will be updated by the bot. 26 | - Launch the bot via `python bot.py` or `python3 bot.py` depending on your system 27 | - If the bot can't create folders or files it'll complain because it needs to do that 28 | - If you're on windows run the command `chcp 65001` before starting the bot else you'll get errors for days 29 | - Set the admin log channels using the `admin_set` command in the channel you wish to log to 30 | - These channels are set per-server and will log only that server's kicks/bans/etc 31 | -------------------------------------------------------------------------------- /plugins/xkcd.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import xkcd 3 | #uncomment the following line to gain the ability to fly 4 | #import antigravity 5 | 6 | from discord.ext import commands as c 7 | 8 | class XKCD: 9 | """A plugin for those on the internet with good humor.""" 10 | def __init__(self, bot): 11 | self.bot = bot 12 | 13 | # Helper function for getting comics 14 | async def get_comic(self, comic, number = None): 15 | case = { 16 | "latest": lambda: xkcd.getLatestComic(), 17 | "random": lambda: xkcd.getRandomComic(), 18 | "number": lambda: xkcd.getComic(number), 19 | } 20 | function = case.get(comic, None) 21 | comic = self.bot.loop.run_in_executor(None, function) 22 | while True: 23 | await asyncio.sleep(0.25) 24 | if comic.done(): 25 | comic = comic.result() 26 | break 27 | try: 28 | link = comic.getImageLink() 29 | title = comic.getAsciiTitle().decode("ascii") 30 | alt_text = comic.getAsciiAltText().decode("ascii") 31 | number = comic.number 32 | return f"{number} - {link}\n**Title:** {title}\n**Alt:** {alt_text}" 33 | except AttributeError: 34 | return "\U00002754 Can't find that comic." 35 | 36 | @c.group(pass_context=True) 37 | async def xkcd(self, ctx): 38 | """Get comics from xkcd! 39 | 40 | Running the command without arguments will display the latest comic. 41 | """ 42 | if ctx.invoked_subcommand is None: 43 | comic = await self.get_comic("latest") 44 | await self.bot.say(comic) 45 | 46 | @xkcd.command(name="random") 47 | async def xkcd_random(self): 48 | """Get a random xkcd comic.""" 49 | comic = await self.get_comic("random") 50 | await self.bot.say(comic) 51 | 52 | @xkcd.command(name="number") 53 | async def xkcd_number(self, number: int): 54 | """Get an xkcd comic by number.""" 55 | comic = await self.get_comic("number", number) 56 | await self.bot.say(comic) 57 | 58 | @c.group(name="import") 59 | async def xkcd_import(self, module): 60 | """Related, since this bot is programmed in Python.""" 61 | if module == "antigravity": 62 | comic = await self.get_comic("number", 353) 63 | await self.bot.say(comic) 64 | 65 | def setup(bot): 66 | bot.add_cog(XKCD(bot)) 67 | -------------------------------------------------------------------------------- /plugins/messages.py: -------------------------------------------------------------------------------- 1 | from discord import Channel 2 | from discord.ext import commands as c 3 | from accounts import level 4 | from helpers import pretty_datetime 5 | 6 | class Messages: 7 | """Message management plugin. 8 | 9 | The star of the show is message cross-posting and moving, but this plugin handles 10 | pinning as well. 11 | """ 12 | def __init__(self, bot): 13 | self.bot = bot 14 | 15 | @c.command(name="xpost", asliases=["crosspost"], pass_context=True, no_pm=True) 16 | @level(1) 17 | async def x_post(self, ctx, message: str, destination: Channel): 18 | """Cross-posts a message to another channel.""" 19 | message = await self.bot.get_message(ctx.message.channel, message) 20 | timestamp = pretty_datetime(message.timestamp) 21 | header = f"{message.author.mention} - {message.channel.mention} ({timestamp}):" 22 | await self.bot.send_message(destination, 23 | f"{header}\n{message.content}") 24 | await self.bot.say("\U00002705") 25 | 26 | @c.command(name="move", pass_context=True, no_pm=True) 27 | @level(1) 28 | async def move_post(self, ctx, message: str, destination: Channel): 29 | """Move a message to a different channel.""" 30 | message = await self.bot.get_message(ctx.message.channel, message) 31 | timestamp = pretty_datetime(message.timestamp) 32 | here = destination.mention 33 | header = f"{message.author.mention} - {message.channel.mention} ({timestamp})" 34 | await self.bot.send_message(destination, 35 | f"{header} -> {here}:\n{message.content}") 36 | await self.bot.delete_message(message) 37 | await self.bot.say("\U00002705") 38 | 39 | @c.group(pass_context=True) 40 | @level(1) 41 | async def pin(self, ctx): 42 | """Pin or unpin a message.""" 43 | if ctx.invoked_subcommand is None: 44 | await self.bot.say("What do you expect me to pin? The tail on a donkey?") 45 | 46 | # Helper function to pin/unpin messages 47 | async def do_pinning(self, message_id, channel, pin=True): 48 | message = await self.bot.get_message(channel, message_id) 49 | if pin: 50 | await self.bot.pin_message(message) 51 | else: 52 | await self.bot.unpin_message(message) 53 | 54 | @pin.command(name="add", pass_context=True) 55 | async def pin_add(self, ctx, message: str): 56 | await self.do_pinning(message, ctx.message.channel) 57 | await self.bot.say("\U00002705") 58 | 59 | @pin.command(name="remove", pass_context=True) 60 | async def pin_remove(self, ctx, message: str): 61 | await self.do_pinning(message, ctx.message.channel, False) 62 | await self.bot.say("\U00002705") 63 | 64 | def setup(bot): 65 | bot.add_cog(Messages(bot)) 66 | -------------------------------------------------------------------------------- /plugins/groups.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | 4 | from discord import ChannelType 5 | from discord.ext import commands as c 6 | from helpers import update_db 7 | 8 | # Get the config 9 | with open("plugins/settings/groups.json") as cfg: 10 | config = json.load(cfg) 11 | 12 | # Grab the group database 13 | with open("db/groups.json") as grps: 14 | groups = json.load(grps) 15 | 16 | # Delete channels 17 | async def clear_channels(bot, location = None): 18 | for chan in groups["channels"][:]: 19 | channel = bot.get_channel(chan) 20 | if len(channel.voice_members) == 0: 21 | try: 22 | await bot.delete_channel(channel) 23 | await asyncio.sleep(0.25) 24 | except: 25 | if location is None: 26 | continue 27 | await bot.send_message(location, 28 | f"\U00002757 Unable to delete channel {channel.name}") 29 | finally: 30 | groups["channels"].remove(chan) 31 | if location is None: 32 | return 33 | update_db(groups, "groups") 34 | 35 | class Groups: 36 | """Group channel creation plugin. 37 | 38 | This plugin allows users to create temporary group channels. Prefix and suffix 39 | for the channel names are configurable via 'plugins/settings/groups.json'. 40 | The channels will NOT automatically delete themselves, it is expect that 41 | the users will run the 'group' command when they are finished with the channel, 42 | or an admin will occasionally purge empty channels. 43 | """ 44 | def __init__(self, bot): 45 | self.bot = bot 46 | self.prefix = config["prefix"] 47 | self.suffix = config["suffix"] 48 | 49 | @c.group(no_pm=True, pass_context=True) 50 | async def group(self, ctx): 51 | """Create group channels. 52 | 53 | Running the command without arguments will clear empty channels. 54 | """ 55 | if ctx.invoked_subcommand is None: 56 | await clear_channels(self.bot, ctx.message.channel) 57 | await self.bot.say("\U00002705 Cleared empty channels.") 58 | 59 | @group.command(name="create", pass_context=True) 60 | async def group_create(self, ctx, *, name: str = None): 61 | """Create a group channel. 62 | 63 | If a name is passed as an argument the channel will be set to that name 64 | with the prefix and suffix added. Otherwise the user's discriminator will 65 | be used as the channel name. 66 | """ 67 | if name is None: 68 | name = ctx.message.author.discriminator 69 | name = f"{self.prefix}{name}{self.suffix}" 70 | try: 71 | channel = await self.bot.create_channel(ctx.message.server, name, 72 | type=ChannelType.voice) 73 | await self.bot.say("\U00002705 Channel created.") 74 | groups["channels"].append(channel.id) 75 | update_db(groups, "groups") 76 | except: 77 | await self.bot.say("\U00002757") 78 | 79 | def setup(bot): 80 | bot.add_cog(Groups(bot)) 81 | -------------------------------------------------------------------------------- /plugins/imgur.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import random 4 | 5 | from discord.ext import commands as c 6 | from imgurpython import ImgurClient 7 | 8 | with open("plugins/settings/imgur.json") as imgr: 9 | config = json.load(imgr) 10 | 11 | class Imgur: 12 | """The most awesome images on the internet! 13 | 14 | This plugin allows basic searching on Imgur including subreddits. 15 | 16 | Warning: Searching on subreddits cannot be easily moderated, therefore it is 17 | extremely easy for a user to post images from an nsfw subreddit to whatever 18 | channel the bot is enabled in if this plugin is enabled without modification. 19 | The subreddit command can be disabled by changing 'enabled=True' to 'enabled=False' 20 | in the plugin's main file: 'plugins/imgur.py' on line 53. 21 | """ 22 | def __init__(self, bot): 23 | self.bot = bot 24 | self.client = ImgurClient(config["client_id"], config["client_secret"]) 25 | 26 | @c.group(pass_context=True) 27 | async def imgur(self, ctx): 28 | """Search on Imgur!""" 29 | if ctx.invoked_subcommand is None: 30 | await self.bot.say("Zoinks! You've taken a wrong turn! Try `help imgur`.") 31 | 32 | # Helper function to actually get/post the images 33 | async def post_image(self, request, query=None): 34 | case = { 35 | "subreddit": lambda: self.client.subreddit_gallery(query), 36 | "search" : lambda: self.client.gallery_search(query), 37 | "random" : lambda: self.client.gallery_random(), 38 | "top" : lambda: self.client.gallery("top"), 39 | "hot" : lambda: self.client.gallery("hot"), 40 | "rehost" : lambda: self.client.upload_from_url(query), 41 | } 42 | function = case.get(request, None) 43 | image = self.bot.loop.run_in_executor(None, function) 44 | while True: 45 | await asyncio.sleep(0.25) 46 | if image.done(): 47 | image = image.result() 48 | break 49 | if request == "rehost": 50 | await self.bot.say(image.get("link", None)) 51 | else: 52 | await self.bot.say(random.choice(image).link) 53 | 54 | @imgur.command(name="sub", aliases=["subreddit", "reddit", "r/"], enabled=True) 55 | async def imgur_subreddit(self, subreddit: str): 56 | """Get an image from a subreddit.""" 57 | await self.post_image("subreddit", subreddit) 58 | 59 | @imgur.command(name="search") 60 | async def imgur_search(self, *, query: str): 61 | """Search Imgur for (almost) anything.""" 62 | await self.post_image("search", query) 63 | 64 | @imgur.command(name="random") 65 | async def imgur_random(self): 66 | """One free random image.""" 67 | await self.post_image("random") 68 | 69 | @imgur.command(name="viral") 70 | async def imgur_viral(self, section: str = "top"): 71 | """Get one of the most viral images of the day. 72 | 73 | Section may be either 'top' or 'hot' and will get an image based on that criteria.""" 74 | section = section.lower() 75 | if section != "top": 76 | section = "hot" 77 | await self.post_image(section) 78 | 79 | @imgur.command(name="rehost") 80 | async def imgur_rehost(self, url: str): 81 | """Rehost an image from any link to Imgur.""" 82 | await self.post_image("rehost", url) 83 | 84 | def setup(bot): 85 | bot.add_cog(Imgur(bot)) 86 | -------------------------------------------------------------------------------- /accounts.py: -------------------------------------------------------------------------------- 1 | # Bot accounts to handle permissions 2 | # This allows customization to some degree of who can do what 3 | # Much easier than just Discord permissions or botmaster lists 4 | # Currently this just goes off of user id and permission level 5 | # However in the future this will include a login/password system 6 | import json 7 | 8 | from discord.ext import commands as c 9 | from helpers import update_db 10 | 11 | # Grab the config 12 | with open("config/config.json") as cfg: 13 | owner = json.load(cfg)["owner"] 14 | 15 | # Grab the account database 16 | with open("db/accounts.json") as accs: 17 | accounts = json.load(accs) 18 | 19 | def level(required=0): 20 | def check(ctx): 21 | uid = ctx.message.author.id 22 | # Bot owner can always do anything 23 | if uid == owner: 24 | return True 25 | # Account is required otherwise 26 | elif uid not in accounts: 27 | return False 28 | else: 29 | return accounts[uid]["level"] >= required 30 | return c.check(check) 31 | 32 | class Accounts: 33 | """Account system. 34 | 35 | This system allows users to be granted a permission level which can be used for 36 | command checks. The 'level' check functionis included for importing to other 37 | plugs to make life easier. The permission levels are global and apply to all 38 | servers. 39 | """ 40 | def __init__(self, bot): 41 | self.bot = bot 42 | 43 | @c.group(aliases=["accounts"], pass_context=True) 44 | async def account(self, ctx): 45 | """Add/remove/update accounts. 46 | 47 | Running the command without arguments will display your current account level. 48 | """ 49 | if ctx.invoked_subcommand is None: 50 | uid = ctx.message.author.id 51 | if uid in accounts: 52 | level = accounts[uid]["level"] 53 | await self.bot.say(f"Account level is: {level}") 54 | else: 55 | await self.bot.say("\U00002754 You do not have an account.") 56 | 57 | @account.command(name="search", aliases=["lookup"]) 58 | async def account_search(self, uid: str): 59 | """Look up an account based on user ID.""" 60 | if uid in accounts: 61 | level = accounts[uid]["level"] 62 | await self.bot.say(f"{uid} is level {level}.") 63 | else: 64 | await self.bot.say(f"{uid} doesn't have an account.") 65 | 66 | @account.command(name="add") 67 | @level(3) 68 | async def account_add(self, uid: str, level: int): 69 | """Add an account.""" 70 | if uid in accounts: 71 | await self.bot.say("\U00002754 Account already exists.") 72 | return 73 | accounts[uid] = {} 74 | accounts[uid]["level"] = level 75 | update_db(accounts, "accounts") 76 | await self.bot.say("\U00002705") 77 | 78 | @account.command(name="remove") 79 | @level(3) 80 | async def account_remove(self, uid: str): 81 | """Remove an acconut.""" 82 | if uid not in accounts: 83 | await self.bot.say(f"\U00002754 No account with ID {uid} exists.") 84 | return 85 | del accounts[uid] 86 | update_db(accounts, "accounts") 87 | await self.bot.say("\U00002705") 88 | 89 | @account.command(name="update", aliases=["change", "modify"]) 90 | @level(3) 91 | async def account_update(self, uid: str, level: int): 92 | """Change an account's level.""" 93 | if uid not in accounts: 94 | await self.bot.say(f"\U00002754 No accounts with ID {uid} exists.") 95 | return 96 | accounts[uid]["level"] = level 97 | update_db(accounts, "accounts") 98 | await self.bot.say("\U00002705") 99 | 100 | def setup(bot): 101 | bot.add_cog(Accounts(bot)) 102 | -------------------------------------------------------------------------------- /plugins/roles.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from discord import Role, Member 4 | from discord.ext import commands as c 5 | from accounts import level 6 | from helpers import update_db 7 | 8 | with open("db/roles.json") as rls: 9 | roles = json.load(rls) 10 | 11 | class Roles: 12 | """Add assignable roles to your server today! 13 | 14 | This plugin allows you to add roles to a list for users to safely assign themselves. 15 | The target is servers with 'category' roles (such as my gaming server which has 16 | roles for every game we support). This plugin allows said servers to let users set 17 | themselves to whatever combination of roles they choose, rather than having to ask an 18 | admin or set up a complicated role structure. 19 | """ 20 | def __init__(self, bot): 21 | self.bot = bot 22 | 23 | @c.group(aliases=["roles"], pass_context=True, no_pm=True) 24 | async def role(self, ctx): 25 | """Role related commands. 26 | 27 | Running the command without arguments will display the list of available 28 | roles in the current server. 29 | """ 30 | if ctx.invoked_subcommand is None: 31 | s_id = ctx.message.server.id 32 | if s_id in roles: 33 | message = " | ".join(roles[s_id]) 34 | await self.bot.say(f"`{message}`") 35 | else: 36 | await self.bot.say("\U00002754 This server has no available roles.") 37 | 38 | @role.command(name="get", pass_context=True) 39 | async def role_get(self, ctx, *, role_name: Role): 40 | """Get a role.""" 41 | s_id = ctx.message.server.id 42 | if s_id not in roles: 43 | await self.bot.say("\U00002754 This server has no available roles.") 44 | return 45 | 46 | if role_name.name in roles[s_id]: 47 | await self.bot.add_roles(ctx.message.author, role_name) 48 | await self.bot.say("\U00002705") 49 | else: 50 | await self.bot.say("\U00002754 That role is not assignable.") 51 | 52 | @role.command(name="lose", pass_context=True) 53 | async def role_lose(self, ctx, *, role_name: Role): 54 | """Remove a role from yourself.""" 55 | s_id = ctx.message.server.id 56 | if s_id not in roles: 57 | await self.bot.say("\U00002754 This server has no available roles.") 58 | return 59 | 60 | if role_name.name in roles[s_id]: 61 | await self.bot.remove_roles(ctx.message.author, role_name) 62 | await self.bot.say("\U00002705") 63 | else: 64 | await self.bot.say("\U00002754 That role is not assignable.") 65 | 66 | @role.command(name="add", pass_context=True) 67 | @level(2) 68 | async def role_add(self, ctx, *, role_name: Role): 69 | """Add an assignable role.""" 70 | s_id = ctx.message.server.id 71 | if s_id not in roles: 72 | roles[s_id] = [] 73 | roles[s_id].append(role_name.name) 74 | update_db(roles, "roles") 75 | await self.bot.say("\U00002705") 76 | 77 | @role.command(name="remove", pass_context=True) 78 | @level(2) 79 | async def role_remove(self, ctx, *, role_name: Role): 80 | """Remove an assignable role.""" 81 | s_id = ctx.message.server.id 82 | if s_id not in roles: 83 | await self.bot.say("\U00002757 This server has no assignable roles.") 84 | return 85 | roles[s_id].remove(role_name.name) 86 | if len(roles[s_id]) == 0: 87 | del roles[s_id] 88 | update_db(roles, "roles") 89 | await self.bot.say("\U00002705") 90 | 91 | @role.command(name="give") 92 | @level(2) 93 | async def role_give(self, member: Member, *, role_name: Role): 94 | """Give a role to another user. 95 | 96 | This command is NOT limited by the assignable roles list, so please use 97 | caustion when giving roles out. This command is intended to be a shortcut 98 | for setting users to higher roles than you would normally want available 99 | through the assignable roles list. 100 | """ 101 | await self.bot.add_roles(member, role_name) 102 | await self.bot.say("\U00002705") 103 | 104 | @role.command(name="take") 105 | @level(2) 106 | async def role_take(self, member: Member, role_name: Role): 107 | """Take a role from a user. 108 | 109 | This command is NOT limited by the assignable roles list, it will remove ANY 110 | role from a user. 111 | """ 112 | await self.bot.remove_roles(member, role_name) 113 | await self.bot.say("\U00002705") 114 | 115 | def setup(bot): 116 | bot.add_cog(Roles(bot)) 117 | -------------------------------------------------------------------------------- /plugins/admin.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | 4 | from discord import Member, Role 5 | from discord.ext import commands as c 6 | from accounts import level 7 | from helpers import update_db 8 | 9 | with open("plugins/settings/admin.json") as cfg: 10 | config = json.load(cfg) 11 | 12 | with open("db/admin.json") as admn: 13 | admin = json.load(admn) 14 | 15 | class Admin: 16 | """Administration plugin.""" 17 | def __init__(self, bot): 18 | self.bot = bot 19 | self.log = config["log"] 20 | self.display_purges = config["display_purges"] 21 | 22 | @c.command(no_pm=True, pass_context=True) 23 | @level(2) 24 | async def admin_set(self, ctx): 25 | """Set the logging channel for admin commands. 26 | 27 | The channel this command is invoked in will become the channel that all 28 | bot administration actions (kicks, bans, softbans, and unbans) are logged 29 | to. 30 | """ 31 | admin["servers"][ctx.message.server.id] = ctx.message.channel.id 32 | update_db(admin, "admin") 33 | await self.bot.say("\U00002705") 34 | 35 | # Helper function for logging 36 | async def log_to_channel(self, server, author, target, log_type, info): 37 | if self.log and (server.id in admin["servers"]): 38 | channel = server.get_channel(admin["servers"][server.id]) 39 | target = f"{target.name}#{target.discriminator}" 40 | header = f"**[{log_type}]** *by {author}*" 41 | body = f"**Member:** {target}\n**Reason:** {info}" 42 | await self.bot.send_message(channel, f"{header}\n{body}") 43 | 44 | @c.command(no_pm=True, pass_context=True) 45 | @level(2) 46 | async def kick(self, ctx, member: Member, *, reason: str = ""): 47 | """Kick a user.""" 48 | await self.bot.kick(member) 49 | await self.bot.say("\U00002705") 50 | await self.log_to_channel(ctx.message.server, ctx.message.author, 51 | member, "KICK", reason) 52 | 53 | @c.command(no_pm=True, pass_context=True) 54 | @level(2) 55 | async def ban(self, ctx, member: Member, 56 | purge: int = 7, *, reason: str = ""): 57 | """Ban a userr.""" 58 | await self.bot.ban(member, purge) 59 | await self.bot.say("\U00002705") 60 | await self.log_to_channel(ctx.message.server, ctx.message.author, 61 | member, "\U0001F528BAN\U0001F528", reason) 62 | 63 | @c.command(no_pm=True, pass_context=True) 64 | @level(2) 65 | async def unban(self, ctx, uid: str, *, reason: str = ""): 66 | """Unban a user by UID.""" 67 | for banned in await self.bot.get_bans(ctx.message.server): 68 | if banned.id == uid: 69 | user = banned 70 | break 71 | await self.bot.unban(ctx.message.server, user) 72 | await self.bot.say("\U00002705") 73 | await self.log_to_channel(ctx.message.server, ctx.message.author, 74 | user, "UNBAN", reason) 75 | 76 | @c.command(no_pm=True, pass_context=True) 77 | @level(2) 78 | async def softban(self, ctx, member: Member, 79 | purge: int = 1, *, reason: str = ""): 80 | """Softban (ban then unban) a user.""" 81 | await self.bot.ban(member, purge) 82 | await self.bot.unban(member.server, member) 83 | await self.bot.say("\U00002705") 84 | await self.log_to_channel(ctx.message.server, ctx.message.author, member, 85 | "\U0001F528SOFTBAN\U0001F528", reason) 86 | 87 | @c.command(no_pm=True) 88 | @level(1) 89 | async def mute(self, member: Member, switch: bool = True): 90 | """Mute or unmute a user.""" 91 | await self.bot.server_voice_state(member, mute=switch) 92 | await self.bot.say("\U00002705") 93 | 94 | @c.command(no_pm=True) 95 | @level(1) 96 | async def deafen(self, member: Member, switch: bool = True): 97 | """Deafen or undeafen a user.""" 98 | await self.bot.server_voice_state(member, deafen=switch) 99 | await self.bot.say("\U00002705") 100 | 101 | # Message purging helper function 102 | async def purge_messages(self, location, message, limit, check): 103 | removed = await self.bot.purge_from(message.channel, limit=limit, 104 | before=message, check=check) 105 | # Display information about the purge 106 | if self.display_purges: 107 | amount = len(removed) 108 | await self.bot.say(f"\U00002705 {amount} message(s) purged from {location}.") 109 | 110 | @c.group(pass_context=True) 111 | @level(1) 112 | async def purge(self, ctx): 113 | """Purge messages.""" 114 | if ctx.invoked_subcommand is None: 115 | await self.bot.say("\U00002754 What should be purged?") 116 | 117 | @purge.command(name="all", aliases=["everyone"], pass_context=True) 118 | async def purge_all(self, ctx, amount: int): 119 | """Purge messages from everyone.""" 120 | await self.purge_messages("everyone", ctx.message, amount, 121 | lambda m: m is not None) 122 | 123 | @purge.command(name="member", aliases=["user"], pass_context=True) 124 | async def purge_member(self, ctx, member: Member, amount: int): 125 | """Purge messages from a user.""" 126 | await self.purge_messages(f"{member.mention}", ctx.message, amount, 127 | lambda m: m.author.id == member.id) 128 | 129 | @purge.command(name="id", aliases=["uid"], pass_context=True) 130 | async def purge_uid(self, ctx, uid: str, amount: int): 131 | """Purge messages by UID.""" 132 | await self.purge_messages(uid, ctx.message, amount, 133 | lambda m: m.author.id == uid) 134 | 135 | @purge.command(name="role", aliases=["group"], pass_context=True) 136 | async def purge_role(self, ctx, role: Role, amount: int): 137 | """Purge messages from a role.""" 138 | await self.purge_messages(f"{role.name}", ctx.message, amount, 139 | lambda m: role in m.author.roles) 140 | 141 | @c.command(no_pm=True) 142 | async def nick(self, member: Member, *, name: str): 143 | """Change someone's nickname. 144 | 145 | If the nickname is set to '!none' it will be removed. 146 | """ 147 | if name.lower() == "!none": 148 | name = None 149 | try: 150 | await self.bot.change_nickname(member, name) 151 | await self.bot.say("\U00002705") 152 | except Exception as error: 153 | await self.bot.say(f"Unable to change nickname: {error}") 154 | 155 | def setup(bot): 156 | bot.add_cog(Admin(bot)) 157 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import asyncio 4 | import json 5 | 6 | from io import StringIO 7 | from datetime import datetime 8 | from discord import Game, InvalidArgument, HTTPException 9 | from discord.ext import commands as c 10 | from accounts import level 11 | from helpers import is_owner, get_logger 12 | 13 | VERSION = "2.1.1" 14 | 15 | # Set up config variables 16 | with open("config/config.json") as cfg: 17 | config = json.load(cfg) 18 | 19 | token = config["token"] 20 | bot_name = config["bot_name"] 21 | bot_avatar = config["bot_avatar"] 22 | prefix = config["command_prefix"] 23 | log_file = config["log_file"] 24 | log_messages = config["log_messages"] 25 | log_commands = config["log_commands"] 26 | cmd_on_edit = config["commands_on_edit"] 27 | 28 | # Grab the blacklist 29 | with open("db/blacklist.json") as bl: 30 | blacklist = json.load(bl)["users"] 31 | 32 | log = get_logger(log_file) 33 | 34 | # Set the bot and basic variables up 35 | description=""" 36 | General purpose chat and administration bot. 37 | """ 38 | bot = c.Bot(c.when_mentioned_or(prefix), pm_help=True, description=description) 39 | plugins = [] 40 | first_launch = True 41 | 42 | # Helper function to load plugins 43 | def load_plugins(): 44 | for p in os.listdir("plugins"): 45 | if p.endswith(".py"): 46 | p = p.rstrip(".py") 47 | try: 48 | bot.load_extension(f'plugins.{p}') 49 | plugins.append(p) 50 | except Exception as error: 51 | exc = "{0}: {1}".format(type(error).__name__, error) 52 | log.warning(f"Failed to load plugin {p}:\n {exc}") 53 | first_launch = False 54 | 55 | # Helper function to change avatar and username 56 | async def update_profile(name, picture): 57 | picture = f"config/{picture}" 58 | if os.path.isfile(picture): 59 | with open(picture, "rb") as avatar: 60 | await bot.edit_profile(avatar=avatar.read()) 61 | log.info("Bot avatar set.") 62 | await bot.edit_profile(username=name) 63 | log.info("Bot name set.") 64 | 65 | # Events 66 | @bot.event 67 | async def on_ready(): 68 | date = datetime.now() 69 | # Set the bot's name and avatar 70 | if first_launch: 71 | load_plugins() 72 | try: 73 | await update_profile(bot_name, bot_avatar) 74 | except Exception as err: 75 | await log.warning("Unable to update the bot's profile: {err}.") 76 | 77 | # Load the account commands 78 | bot.load_extension("accounts") 79 | 80 | # Status header 81 | log.info("------------------------STATUS------------------------") 82 | log.info(f"{date}") 83 | log.info(f"Ispyra v{VERSION}") 84 | log.info(f"Logged in as {bot.user} ({bot.user.id})") 85 | log.info("Plugins: {0}".format(", ".join(plugins))) 86 | log.info("------------------------STATUS------------------------") 87 | 88 | @bot.event 89 | async def on_message(msg): 90 | # Log it 91 | if log_messages: 92 | log.info(f"[{msg.server} - #{msg.channel}] <{msg.author}>: {msg.content}") 93 | # Handle the commands 94 | await bot.process_commands(msg) 95 | 96 | @bot.event 97 | async def on_message_edit(old, new): 98 | if cmd_on_edit: 99 | await bot.process_commands(new) 100 | 101 | @bot.event 102 | async def on_command(cmd, ctx): 103 | # Log it home skittle 104 | if log_commands: 105 | command = f"{ctx.message.content}" 106 | user = f"{ctx.message.author}" 107 | location = f"[{ctx.message.server}] - #{ctx.message.channel}" 108 | log.info(f'[COMMAND] `{command}` by `{user}` in `{location}`') 109 | 110 | @bot.event 111 | async def on_command_error(err, ctx): 112 | channel = ctx.message.channel 113 | if isinstance(err, c.NoPrivateMessage): 114 | await bot.send_message(channel, 115 | "\U000026A0 This command is not available in DMs.") 116 | elif isinstance(err, c.CheckFailure): 117 | await bot.send_message(channel, 118 | "\U0001F6AB I'm sorry, I'm afraid I can't do that.") 119 | elif isinstance(err, c.MissingRequiredArgument): 120 | await bot.send_message(channel, 121 | "\U00002754 Missing argument(s).") 122 | elif isinstance(err, c.DisabledCommand): 123 | pass 124 | 125 | @bot.event 126 | async def on_server_join(srv): 127 | log.info(f"[JOIN] {srv.name}") 128 | 129 | @bot.event 130 | async def on_server_remove(srv): 131 | log.info(f"[LEAVE] {srv.name}") 132 | 133 | # Global check for all commands 134 | # This applies to EVERY command, even those in extensions 135 | @bot.check 136 | def allowed(ctx): 137 | return ctx.message.author.id not in blacklist 138 | 139 | # Built-in commands 140 | # Step the bot 141 | @bot.command(name="quit") 142 | @is_owner() 143 | async def bot_quit(): 144 | """Shut the bot down.""" 145 | await bot.say("Shutting down...\n\U0001f44b") 146 | await bot.logout() 147 | 148 | @bot.command(name="info") 149 | async def bot_info(): 150 | """Display information about the bot.""" 151 | await bot.say("Ispyra {VERSION} (https://github.com/Ispira/Ispyra)") 152 | 153 | @bot.command(name="status", aliases=["playing"]) 154 | async def bot_status(*, status: str): 155 | """Change the bot's 'playing' status. 156 | 157 | If the status is set to '!none' it will be disabled. 158 | """ 159 | if status.lower() == "!none": 160 | game = None 161 | else: 162 | game = Game(name=status) 163 | await bot.change_status(game=game) 164 | await bot.say("\U00002705") 165 | 166 | @bot.command() 167 | async def ping(): 168 | await bot.say("Pong!") 169 | 170 | @bot.group(aliases=["plugins", "pl"], pass_context=True) 171 | async def plugin(ctx): 172 | """Plugin handling. 173 | 174 | Running the command without arguments will list loaded plugins. 175 | """ 176 | if ctx.invoked_subcommand is None: 177 | await bot.say(", ".join(plugins)) 178 | 179 | @plugin.command(name="load") 180 | @is_owner() 181 | async def plugin_load(name: str): 182 | """Load a plugin.""" 183 | if name in plugins: 184 | await bot.say(f"\U000026A0 Plugin {name} is already loaded.") 185 | return 186 | 187 | if not os.path.isfile(f"plugins/{name}.py"): 188 | await bot.say(f"\U00002754 No plugin {name} exists.") 189 | return 190 | 191 | try: 192 | bot.load_extension(f"plugins.{name}") 193 | plugins.append(name) 194 | await bot.say(f"\U00002705 Plugin {name} loaded.") 195 | except Exception as error: 196 | exc = "{0}: {1}".format(type(error).__name__, error) 197 | await bot.say(f"\U00002757 Error loading {name}.\n```py\n{exc}\n```") 198 | 199 | @plugin.command(name="unload") 200 | @is_owner() 201 | async def plugin_unload(name: str): 202 | """Unload a plugin.""" 203 | if name not in plugins: 204 | await bot.say(f"\U000026A0 Plugin {name} is not loaded.") 205 | return 206 | 207 | try: 208 | bot.unload_extension(f"plugins.{name}") 209 | plugins.remove(name) 210 | await bot.say(f"\U00002705 Plugin {name} unloaded.") 211 | except: 212 | await bot.say(f"\U00002757 Error unloading {name}.") 213 | 214 | @bot.command(name="eval", hidden=True, pass_context=True, enabled=False) 215 | @is_owner() 216 | async def evaluate(ctx, *, code: str): 217 | """Extremely unsafe eval command.""" 218 | code = code.strip("` ") 219 | result = None 220 | try: 221 | result = eval(code) 222 | if asyncio.iscoroutine(result): 223 | result = await result 224 | except Exception as err: 225 | await bot.say(python.format(type(err).__name__ + ": " + str(error))) 226 | return 227 | 228 | await bot.say(f"```py\n{result}\n```") 229 | 230 | @bot.command(name="exec", hidden=True, pass_context=True, enabled=False) 231 | @is_owner() 232 | async def execute(ctx, *, code: str): 233 | """If you thought eval was dangerous, wait'll you see exec!""" 234 | code = code.strip("```").lstrip("py") 235 | result = None 236 | env = {} 237 | env.update(locals()) 238 | stdout = sys.stdout 239 | redirect = sys.stdout = StringIO() 240 | 241 | try: 242 | exec(code, globals(), env) 243 | except Exception as err: 244 | await bot.say(python.format(type(err).__name__ + ": " + str(error))) 245 | finally: 246 | sys.stdout = stdout 247 | 248 | await bot.say(f"```\n{redirect.getvalue()}\n```") 249 | 250 | bot.run(token) 251 | --------------------------------------------------------------------------------