├── .gitignore ├── bot.py ├── command.py ├── constants.py ├── modules ├── help.py ├── info.py └── reddit.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | snakecord 2 | __pycache__ 3 | .vscode 4 | setup.json -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | import snekcord 2 | import constants 3 | from datetime import datetime 4 | 5 | 6 | class Client(snekcord.WebSocketClient): 7 | def __init__(self, *args, **kwargs): 8 | super().__init__(token='Bot ' + constants.SETUP['TOKEN'], *args, **kwargs) 9 | 10 | self.started_at = None 11 | constants.loader.set_global('client', self) 12 | constants.loader.add_module('modules.info') 13 | constants.loader.add_module('modules.reddit') 14 | constants.loader.add_module('modules.help') 15 | constants.loader.load() 16 | 17 | def run_forever(self): 18 | self.started_at = datetime.now() 19 | super().run_forever() 20 | 21 | 22 | client = Client() 23 | 24 | 25 | @client.on() 26 | async def message_create(evt): 27 | commands = constants.loader.get_global('commands') 28 | await commands.handle(evt) 29 | 30 | 31 | client.run_forever() 32 | -------------------------------------------------------------------------------- /command.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from snekcord import Message, EmbedBuilder 3 | 4 | 5 | class CommandError(Exception): 6 | def __init__( 7 | self, 8 | msg: str, 9 | message: Message, 10 | should_send: bool = True 11 | ) -> None: 12 | self.msg = msg 13 | self.message = message 14 | self.should_send = should_send 15 | 16 | async def send(self): 17 | embed = EmbedBuilder(title='**Error**', description=self.msg) 18 | await embed.send_to(self.message.channel) 19 | 20 | 21 | class Command: 22 | def __init__(self, func, name=None): 23 | self.name = name or func.__name__ 24 | self.aliases = [] 25 | self.func = func 26 | self.help_func = None 27 | self.__doc__ = None 28 | self.__invocation__ = None 29 | 30 | def help(self, func): 31 | self.help_func = func 32 | 33 | async def send_help(self, message: Message): 34 | if self.help_func is None: 35 | raise CommandError( 36 | 'This command has no help message', 37 | message 38 | ) 39 | await self.help_func(message) 40 | 41 | async def call(self, *args): 42 | try: 43 | await self.func(*args) 44 | except CommandError as e: 45 | await e.send() 46 | 47 | 48 | class CommandTable: 49 | def __init__(self, prefix, ignore_case=False, sep=' '): 50 | assert sep, 'Empty seperator' 51 | self.sep = sep 52 | self.prefix = prefix 53 | self.commands = {} 54 | self.ignore_case = ignore_case 55 | 56 | def command(self, func): 57 | cmd = Command(func) 58 | self.add_command(cmd) 59 | return cmd 60 | 61 | def get_command(self, name): 62 | return self.commands.get(name) 63 | 64 | def add_command(self, command): 65 | self.commands[command.name] = command 66 | for alias in command.aliases: 67 | self.commands[alias] = command 68 | 69 | def remove_command(self, command): 70 | self.commands.pop(command.name) 71 | for alias in command.aliases: 72 | self.commands.pop(alias) 73 | 74 | def _check_prefix(self, string, prefix): 75 | if not isinstance(prefix, str): 76 | raise TypeError('Prefix should be a string') 77 | if string.startswith(prefix): 78 | return prefix 79 | return None 80 | 81 | def check_prefix(self, message): 82 | prefix = self.prefix 83 | if callable(self.prefix): 84 | prefix = self.prefix(message) 85 | 86 | if not isinstance(prefix, str): 87 | for prefix in prefix: 88 | if self._check_prefix(message.content, prefix): 89 | return prefix 90 | return None 91 | 92 | return self._check_prefix(message.content, prefix) 93 | 94 | async def handle(self, evt): 95 | if evt.message.author.bot: 96 | return 97 | 98 | prefix = self.check_prefix(evt.message) 99 | if prefix is None: 100 | return None 101 | 102 | args = evt.message.content[len(prefix):].split(self.sep) 103 | name = args.pop(0) 104 | 105 | if self.ignore_case: 106 | name = name.lower() 107 | 108 | command = self.get_command(name) 109 | if command is None: 110 | return None 111 | 112 | return await command.call(evt, *args) 113 | 114 | 115 | def doc(doc): 116 | def wrapped(cmd): 117 | cmd.__doc__ = doc 118 | return cmd 119 | return wrapped 120 | 121 | 122 | def invocation(doc): 123 | def wrapped(cmd): 124 | cmd.__invocation__ = doc 125 | return cmd 126 | return wrapped 127 | 128 | 129 | class _Module: 130 | def __init__(self, path): 131 | self.path = path 132 | self.loaded = False 133 | 134 | def _import(self): 135 | importlib.import_module(self.path) 136 | self.loaded = True 137 | 138 | 139 | class ModuleLoader: 140 | def __init__(self): 141 | self.globals = {} 142 | self.modules = {} 143 | 144 | def set_global(self, name, value): 145 | self.globals[name] = value 146 | 147 | def get_global(self, name): 148 | return self.globals[name] 149 | 150 | def add_module(self, path): 151 | mod = _Module(path) 152 | self.modules[path] = mod 153 | return mod 154 | 155 | def remove_module(self, path): 156 | self.modules.pop(path) 157 | 158 | def load(self): 159 | for module in self.modules.values(): 160 | module._import() 161 | -------------------------------------------------------------------------------- /constants.py: -------------------------------------------------------------------------------- 1 | import json 2 | from command import CommandTable, ModuleLoader 3 | 4 | loader = ModuleLoader() 5 | loader.set_global('commands', CommandTable('>')) 6 | 7 | BLUE = 0x6adde6 8 | REPOSITORY = 'https://github.com/asleep-cult/helpers-python-bot/' 9 | 10 | with open('setup.json') as fp: 11 | SETUP = json.load(fp) 12 | -------------------------------------------------------------------------------- /modules/help.py: -------------------------------------------------------------------------------- 1 | import constants 2 | import command 3 | from typing import List, Tuple, Optional 4 | from snekcord import Message, EmbedBuilder 5 | 6 | commands = constants.loader.get_global('commands') 7 | client = constants.loader.get_global('client') 8 | 9 | 10 | def get_docs() -> List[Tuple[str]]: 11 | cmds = [] 12 | for name, cmd in commands.commands.items(): 13 | invocation = cmd.__invocation__ 14 | if invocation is None: 15 | invocation = f'{commands.prefix}{name}' 16 | 17 | docs = cmd.__doc__ 18 | if docs is None: 19 | docs = 'Not documented' 20 | 21 | cmds.append((invocation, docs)) 22 | return cmds 23 | 24 | 25 | async def send_help(message: Message, cmd: str): 26 | cmd = commands.get_command(cmd) 27 | if cmd is None: 28 | raise command.CommandError('That command doesn\'t exist') 29 | await cmd.send_help(message) 30 | 31 | 32 | @command.invocation( 33 | f'{commands.prefix}help [cmd]' 34 | ) 35 | @command.doc( 36 | 'Sends this message if no command is provided ' 37 | 'otherwise sends the command\'s help message' 38 | ) 39 | @commands.command 40 | async def help(message: Message, cmd: Optional[str] = None) -> None: 41 | if cmd is not None: 42 | return await send_help(message, cmd) 43 | 44 | embed = EmbedBuilder(title='Help', color=constants.BLUE) 45 | embed.set_footer(f'Type {commands.prefix}help ') 46 | 47 | for invocation, docs in get_docs(): 48 | embed.add_field(invocation, docs) 49 | 50 | await embed.send_to(message.channel) 51 | -------------------------------------------------------------------------------- /modules/info.py: -------------------------------------------------------------------------------- 1 | import os 2 | import gc 3 | import asyncio 4 | import threading 5 | import constants 6 | import command 7 | import utils 8 | from datetime import datetime 9 | from snekcord import EmbedBuilder 10 | from snekcord.clients.wsevents import MessageCreateEvent 11 | 12 | commands = constants.loader.get_global('commands') 13 | client = constants.loader.get_global('client') 14 | ICON_URL = 'https://cdn.discordapp.com/icons/%s/%s.png' 15 | 16 | 17 | @command.doc( 18 | 'Sends this guild\'s shard id and the shard\'s websocket latency' 19 | ) 20 | @commands.command 21 | async def ping(evt: MessageCreateEvent) -> None: 22 | shard = evt.shard 23 | embed = EmbedBuilder( 24 | title=':ping_pong: Ping', 25 | description=( 26 | f'**Shard {shard.id}**\n' 27 | f'Websocket Latency: {(shard.latency * 1000):.2f}ms' 28 | ), 29 | color=constants.BLUE 30 | ) 31 | await embed.send_to(evt.channel) 32 | 33 | 34 | @command.doc( 35 | 'Sends info about the bot\'s Python process' 36 | ) 37 | @commands.command 38 | async def info(evt: MessageCreateEvent) -> None: 39 | threads = threading.active_count() 40 | tasks = asyncio.all_tasks() 41 | collected = sum(gen['collected'] for gen in gc.get_stats()) 42 | delta = datetime.now() - client.started_at 43 | embed = EmbedBuilder( 44 | title='Info', 45 | description=( 46 | f'**Process ID**: {os.getpid()}\n' 47 | f'**Active Threads**: {threads}\n' 48 | f'**Asyncio Tasks**: {len(tasks)}\n' 49 | f'**Garbage Collected Objects**: {collected}\n' 50 | f'**Started**: {utils.humanize(delta)}' 51 | ), 52 | color=constants.BLUE 53 | ) 54 | await embed.send_to(evt.channel) 55 | 56 | 57 | @command.doc( 58 | 'Sends the bot\'s github repository' 59 | ) 60 | @commands.command 61 | async def source(evt: MessageCreateEvent) -> None: 62 | await evt.channel.messages.create(content=constants.REPOSITORY) -------------------------------------------------------------------------------- /modules/reddit.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import command 3 | import random 4 | import constants 5 | from typing import Optional 6 | from snekcord import Message, EmbedBuilder 7 | from snekcord.clients.wsevents import MessageCreateEvent 8 | from snekcord.utils import JsonField, JsonObject, JsonTemplate 9 | 10 | commands = constants.loader.get_global('commands') 11 | client = constants.loader.get_global('client') 12 | 13 | 14 | RedditPostTemplate = JsonTemplate( 15 | subreddit=JsonField('subreddit'), 16 | selftext=JsonField('selftext'), 17 | author=JsonField('author'), 18 | title=JsonField('title'), 19 | subreddit_name_prefixed=JsonField('subreddit_name_prefixed'), 20 | downs=JsonField('downs'), 21 | ups=JsonField('ups'), 22 | upvote_ratio=JsonField('upvote_ratio'), 23 | total_awards_received=JsonField('total_awards_received'), 24 | over_18=JsonField('over_18'), 25 | thumbnail=JsonField('thumbnail'), 26 | edited=JsonField('edited'), 27 | post_hint=JsonField('post_hint'), 28 | permalink=JsonField('permalink'), 29 | url=JsonField('url'), 30 | num_comments=JsonField('num_comments') 31 | ) 32 | 33 | class RedditPost(JsonObject, template=RedditPostTemplate): 34 | subreddit: str 35 | selftext: str 36 | author_fullname: str 37 | title: str 38 | subreddit_name_prefixed: str 39 | downs: int 40 | ups: int 41 | upvote_ratio: float 42 | total_awards_received: int 43 | over_18: bool 44 | thumbnail: str 45 | edited: bool 46 | post_hint: str 47 | permalink: str 48 | url: str 49 | num_comments: int 50 | 51 | 52 | class RedditClientError(Exception): 53 | def __init__(self, status_code, data): 54 | self.status_code = status_code 55 | self.data = data 56 | 57 | 58 | class RedditClient: 59 | BASE_URL = 'https://reddit.com' 60 | 61 | def __init__(self): 62 | self.client_session = aiohttp.ClientSession() 63 | 64 | async def request( 65 | self, 66 | subreddit: str, 67 | post_filter: Optional[str] = None, 68 | count: int = 30 69 | ) -> RedditPost: 70 | if post_filter is None: 71 | post_filter = '' 72 | url = f'{self.BASE_URL}/r/{subreddit}/{post_filter}.json' 73 | 74 | resp = await self.client_session.request( 75 | 'GET', url, params={'count': count} 76 | ) 77 | data = await resp.json() 78 | if resp.status != 200: 79 | raise RedditClientError(resp.status, data) 80 | return data 81 | 82 | async def close(self): 83 | await self.client_session.close() 84 | 85 | 86 | def form_embed(post: RedditPost) -> EmbedBuilder: 87 | builder = EmbedBuilder( 88 | title=post.title, 89 | color=constants.BLUE, 90 | url=RedditClient.BASE_URL + post.permalink, 91 | description=post.selftext 92 | ) 93 | builder.set_author(name=post.author) 94 | builder.set_description( 95 | f':arrow_up: :arrow_down: **{post.ups}** ' 96 | f'({post.upvote_ratio * 100}%)\n' 97 | f'**edited**: {str(bool(post.edited)).lower()}\n' 98 | f'**nsfw**: {str(post.over_18).lower()}\n' 99 | f':trophy: **{post.total_awards_received}**\n' 100 | f':speech_balloon: **{post.num_comments}**' 101 | ) 102 | 103 | if post.post_hint == 'image': 104 | builder.set_image(url=post.url) 105 | elif post.thumbnail is not None and post.thumbnail.startswith('http'): 106 | builder.set_image(url=post.thumbnail) 107 | if post.post_hint in ('link', 'rich:video'): 108 | trunc = post.url[:40] 109 | builder.embed.description += '\n\n**[%s...](%s)**' % (trunc, post.url) 110 | elif post.post_hint in (None, 'self'): 111 | trunc = post.selftext[:min((len(post.selftext), 1000))] 112 | builder.embed.description += '\n\n' + trunc + '...' 113 | 114 | return builder 115 | 116 | 117 | @command.invocation( 118 | f'{commands.prefix}reddit [post filter]' 119 | ) 120 | @command.doc( 121 | 'Sends a post from the subreddit' 122 | ) 123 | @commands.command 124 | async def reddit( 125 | evt: MessageCreateEvent, 126 | subreddit: str, 127 | post_filter: str = 'new' 128 | ) -> None: 129 | if subreddit.startswith('r/'): 130 | subreddit = subreddit[2:] 131 | 132 | client = RedditClient() 133 | try: 134 | data = await client.request(subreddit, post_filter) 135 | except RedditClientError as e: 136 | raise command.CommandError( 137 | f'Sorry, that request failed `Status Code: {e.status_code}`', 138 | evt.message 139 | ) 140 | except aiohttp.ClientError: 141 | raise command.CommandError('Sorry, that request failed', evt.message) 142 | finally: 143 | await client.close() 144 | 145 | try: 146 | post = random.choice(data['data']['children'])['data'] 147 | except (IndexError, KeyError): 148 | raise commands.CommandError('Unable to parse that response', evt.message) 149 | post = RedditPost.unmarshal(post) 150 | 151 | if post.over_18 and not evt.message.channel.nsfw: 152 | raise command.CommandError( 153 | 'That post is NSFW silly... try in an NSFW channel', 154 | evt.message 155 | ) 156 | 157 | await form_embed(post).send_to(evt.channel) 158 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | 4 | def humanize(delta: timedelta) -> str: 5 | seconds = delta.total_seconds() 6 | parts = [] 7 | 8 | for unit in ('seconds', 'minutes', 'hours', 'days'): 9 | seconds, mod = divmod(seconds, 60) 10 | mod = int(mod) 11 | if mod == 0: 12 | continue 13 | elif mod == 1: 14 | unit = unit[:-1] 15 | parts.append(f'{mod} {unit}') 16 | parts.reverse() 17 | 18 | return ( 19 | ', '.join(parts[:-1]) + 20 | (' and ' if len(parts) > 1 else '') + 21 | f'{parts[-1]} ago' 22 | ) 23 | --------------------------------------------------------------------------------