├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── bot.py ├── bot_base ├── __init__.py ├── blacklist │ ├── __init__.py │ └── blacklist.py ├── bot.py ├── caches │ ├── __init__.py │ ├── abc.py │ ├── entry.py │ └── timed.py ├── cancellable_wait_for.py ├── cog.py ├── cogs │ ├── __init__.py │ └── internal.py ├── context.py ├── converters │ ├── __init__.py │ └── time.py ├── db │ ├── __init__.py │ └── mongo.py ├── exceptions.py ├── paginators │ ├── __init__.py │ └── disnake_paginator.py └── wraps │ ├── __init__.py │ ├── channel.py │ ├── member.py │ ├── meta.py │ ├── thread.py │ └── user.py ├── docs ├── Makefile ├── conf.py ├── index.rst ├── make.bat ├── modules │ ├── objects │ │ ├── cancellable_wait_for.rst │ │ ├── context.rst │ │ ├── exceptions.rst │ │ └── wrapped_objects.rst │ └── subclass.rst └── requirements.txt ├── images └── image_one.png ├── readme.md ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── conftest.py └── test_timed_cache.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [Skelmis] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | venv/ 3 | .idea 4 | notes.txt 5 | # Distribution / packaging 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | share/python-wheels/ 20 | *.egg-info/ 21 | .installed.cfg 22 | *.egg 23 | MANIFEST 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Skelmis 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 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from bot_base import BotBase 5 | 6 | logging.basicConfig(level=logging.INFO) 7 | 8 | bot = BotBase( 9 | command_prefix="t.", 10 | mongo_url=os.environ["MONGO_URL"], 11 | mongo_database_name="my_bot", 12 | load_builtin_commands=True, 13 | ) 14 | 15 | 16 | @bot.event 17 | async def on_ready(): 18 | print("I'm up.") 19 | 20 | 21 | @bot.command() 22 | async def echo(ctx): 23 | await ctx.message.delete() 24 | 25 | text = await ctx.get_input("What should I say?", timeout=5) 26 | 27 | if not text: 28 | return await ctx.send("You said nothing!") 29 | 30 | await ctx.send(text) 31 | 32 | 33 | @bot.command() 34 | async def ping(ctx): 35 | await ctx.send_basic_embed("Pong!") 36 | 37 | bot.run(os.environ["TOKEN"]) 38 | -------------------------------------------------------------------------------- /bot_base/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import namedtuple 3 | 4 | from .exceptions import * 5 | from .cancellable_wait_for import CancellableWaitFor 6 | from .bot import BotBase 7 | from .context import BotContext 8 | from .cog import Cog 9 | 10 | __version__ = "1.7.1" 11 | 12 | 13 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 14 | VersionInfo = namedtuple("VersionInfo", "major minor micro releaselevel serial") 15 | version_info = VersionInfo( 16 | major=1, 17 | minor=7, 18 | micro=1, 19 | releaselevel="production", 20 | serial=0, 21 | ) 22 | -------------------------------------------------------------------------------- /bot_base/blacklist/__init__.py: -------------------------------------------------------------------------------- 1 | from .blacklist import BlacklistManager 2 | 3 | __all__ = ("BlacklistManager",) 4 | -------------------------------------------------------------------------------- /bot_base/blacklist/blacklist.py: -------------------------------------------------------------------------------- 1 | from bot_base.db import MongoManager 2 | 3 | 4 | class BlacklistManager: 5 | def __init__(self, db: MongoManager): 6 | self.db = db 7 | 8 | self.users = set() 9 | self.guilds = set() 10 | 11 | def __contains__(self, item: int) -> bool: 12 | """ 13 | Checks whether an id is contained within 14 | an internal blacklist or not 15 | """ 16 | assert isinstance(item, int) 17 | 18 | in_users = item in self.users 19 | in_guilds = item in self.guilds 20 | 21 | return in_users or in_guilds 22 | 23 | async def initialize(self) -> None: 24 | """ 25 | Called sometime on creation in order to 26 | populate the internal blacklist. 27 | """ 28 | all_guild_entries = await self.db.guild_blacklist.get_all() 29 | for guild in all_guild_entries: 30 | self.guilds.add(guild["_id"]) 31 | 32 | all_user_entries = await self.db.user_blacklist.get_all() 33 | for user in all_user_entries: 34 | self.users.add(user["_id"]) 35 | 36 | async def add_to_blacklist( 37 | self, item: int, reason: str = "Unknown", is_guild_blacklist: bool = True 38 | ) -> None: 39 | """ 40 | Add a given int to the internal blacklist 41 | as well as persist it within the db 42 | """ 43 | assert isinstance(item, int) 44 | 45 | if is_guild_blacklist: 46 | self.guilds.add(item) 47 | await self.db.guild_blacklist.upsert({"_id": item, "reason": reason}) 48 | 49 | else: 50 | self.users.add(item) 51 | await self.db.user_blacklist.upsert({"_id": item, "reason": reason}) 52 | 53 | async def remove_from_blacklist( 54 | self, item: int, is_guild_blacklist: bool = True 55 | ) -> None: 56 | """ 57 | Removes a given blacklist, ignoring if 58 | they weren't blacklisted 59 | """ 60 | assert isinstance(item, int) 61 | 62 | if is_guild_blacklist: 63 | self.guilds.discard(item) 64 | await self.db.guild_blacklist.delete({"_id": item}) 65 | 66 | else: 67 | self.users.discard(item) 68 | await self.db.user_blacklist.delete({"_id": item}) 69 | -------------------------------------------------------------------------------- /bot_base/bot.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import functools 4 | import sys 5 | import logging 6 | import traceback 7 | from typing import Optional, List, Any, Dict, Union, Callable, Coroutine 8 | 9 | import humanize 10 | from alaric import AQ 11 | from alaric.comparison import EQ 12 | 13 | from bot_base import CancellableWaitFor 14 | from bot_base.caches import TimedCache 15 | 16 | try: 17 | import nextcord 18 | from nextcord import DiscordException, abc 19 | from nextcord.ext import commands 20 | from nextcord.ext.commands.converter import CONVERTER_MAPPING 21 | except ModuleNotFoundError: 22 | import disnake as nextcord 23 | from disnake import DiscordException, abc 24 | from disnake.ext import commands 25 | from disnake.ext.commands.converter import CONVERTER_MAPPING 26 | 27 | from bot_base.blacklist import BlacklistManager 28 | from bot_base.context import BotContext 29 | from bot_base.db import MongoManager 30 | from bot_base.exceptions import PrefixNotFound, BlacklistedEntry 31 | from bot_base.wraps import ( 32 | WrappedChannel, 33 | WrappedMember, 34 | WrappedUser, 35 | WrappedThread, 36 | ) 37 | 38 | log = logging.getLogger(__name__) 39 | 40 | 41 | CONVERTER_MAPPING[nextcord.User] = WrappedUser 42 | CONVERTER_MAPPING[nextcord.Member] = WrappedMember 43 | CONVERTER_MAPPING[nextcord.TextChannel] = WrappedChannel 44 | 45 | 46 | class BotBase(commands.Bot): 47 | """ 48 | Attributes 49 | ---------- 50 | command_prefix: str 51 | Your bots command prefix 52 | leave_db: bool 53 | If ``True``, don't create a database instance. 54 | 55 | Defaults to ``False`` 56 | do_command_stats: bool = True, 57 | mongo_url: Optional[str] = None, 58 | load_builtin_commands: bool = False, 59 | mongo_database_name: Optional[str] = None, 60 | 61 | """ 62 | 63 | def __init__( 64 | self, 65 | *args, 66 | command_prefix: str, 67 | leave_db: bool = False, 68 | do_command_stats: bool = True, 69 | mongo_url: Optional[str] = None, 70 | load_builtin_commands: bool = False, 71 | mongo_database_name: Optional[str] = None, 72 | **kwargs, 73 | ) -> None: 74 | if not leave_db: 75 | self.db: MongoManager = MongoManager(mongo_url, mongo_database_name) 76 | 77 | self.do_command_stats: bool = do_command_stats 78 | try: 79 | self.blacklist: BlacklistManager = BlacklistManager(self.db) 80 | except AttributeError: 81 | log.warning( 82 | "You do not have a blacklist setup. " 83 | "Please set `self.db` to a instance/subclass of MongoManager before " 84 | "calling (..., leave_db=True) if you wish to have a blacklist." 85 | ) 86 | self.blacklist = None 87 | 88 | self._uptime: datetime.datetime = datetime.datetime.now( 89 | tz=datetime.timezone.utc 90 | ) 91 | self.prefix_cache: TimedCache = TimedCache() 92 | 93 | self.DEFAULT_PREFIX: str = command_prefix 94 | kwargs["command_prefix"] = self.get_command_prefix 95 | 96 | super().__init__(*args, **kwargs) 97 | 98 | if load_builtin_commands: 99 | self.load_extension("bot_base.cogs.internal") 100 | 101 | # These events do include the on_ prefix 102 | self._single_event_type_sheet: Dict[str, Callable] = { 103 | "on_message": self.get_wrapped_message, 104 | } 105 | self._double_event_type_sheet: Dict[str, Callable] = { 106 | "on_message_edit": lambda before, after: ( 107 | self.get_wrapped_message(before), 108 | self.get_wrapped_message(after), 109 | ) 110 | } 111 | 112 | @property 113 | def uptime(self) -> datetime.datetime: 114 | """Returns when the bot was initialized.""" 115 | return self._uptime 116 | 117 | def get_uptime(self) -> str: 118 | """Returns a human readable string for the bots uptime.""" 119 | return humanize.precisedelta( 120 | self.uptime - datetime.datetime.now(tz=datetime.timezone.utc) 121 | ) 122 | 123 | def get_bot_uptime(self) -> str: 124 | """Returns a human readable string for the bots uptime. 125 | 126 | Notes 127 | ----- 128 | Deprecated. 129 | """ 130 | log.warning("This method is deprecated, use get_uptime instead") 131 | return self.get_uptime() 132 | 133 | async def on_ready(self) -> None: 134 | if self.blacklist: 135 | await self.blacklist.initialize() 136 | 137 | async def get_command_prefix( 138 | self, bot: "BotBase", message: nextcord.Message 139 | ) -> List[str]: 140 | try: 141 | prefix = await self.get_guild_prefix(guild_id=message.guild.id) 142 | 143 | prefix = self.get_case_insensitive_prefix(message.content, prefix) 144 | 145 | return commands.when_mentioned_or(prefix)(self, message) 146 | 147 | except (AttributeError, PrefixNotFound): 148 | prefix = self.get_case_insensitive_prefix( 149 | message.content, self.DEFAULT_PREFIX 150 | ) 151 | return commands.when_mentioned_or(prefix)(self, message) 152 | 153 | @staticmethod 154 | def get_case_insensitive_prefix(content, prefix): 155 | if content.casefold().startswith(prefix.casefold()): 156 | # The prefix matches, now return the one the user used 157 | # such that dpy will dispatch the given command 158 | prefix_length = len(prefix) 159 | prefix = content[:prefix_length] 160 | 161 | return prefix 162 | 163 | async def get_guild_prefix(self, guild_id: Optional[int] = None) -> str: 164 | """ 165 | Using a cached property fetch prefixes 166 | for a guild and return em. 167 | 168 | Parameters 169 | ---------- 170 | guild_id : int 171 | The guild we want prefixes for 172 | 173 | Returns 174 | ------- 175 | str 176 | The prefix 177 | 178 | Raises 179 | ------ 180 | PrefixNotFound 181 | We failed to find and 182 | return a valid prefix 183 | """ 184 | if guild_id in self.prefix_cache: 185 | return self.prefix_cache.get_entry(guild_id) 186 | 187 | prefix_data = await self.db.config.find({"_id": guild_id}) 188 | 189 | if not prefix_data: 190 | raise PrefixNotFound 191 | 192 | prefix: Optional[str] = prefix_data.get("prefix") 193 | if not prefix: 194 | raise PrefixNotFound 195 | 196 | self.prefix_cache.add_entry(guild_id, prefix, override=True) 197 | return prefix 198 | 199 | async def on_command_error(self, ctx: BotContext, error: DiscordException) -> None: 200 | """Generic error handling for common errors. 201 | 202 | Also logs statistics if enabled. 203 | """ 204 | error = getattr(error, "original", error) 205 | 206 | if isinstance(error, commands.NoPrivateMessage): 207 | await ctx.author.send("This command cannot be used in private messages.") 208 | elif isinstance(error, commands.DisabledCommand): 209 | await ctx.author.send("Sorry. This command is disabled and cannot be used.") 210 | elif isinstance(error, commands.CommandInvokeError): 211 | original = error.original 212 | if not isinstance(original, nextcord.HTTPException): 213 | print(f"In {ctx.command.qualified_name}:", file=sys.stderr) 214 | traceback.print_tb(original.__traceback__) 215 | print(f"{original.__class__.__name__}: {original}", file=sys.stderr) 216 | elif isinstance(error, commands.ArgumentParsingError): 217 | await ctx.send(error) 218 | elif isinstance(error, commands.NotOwner): 219 | await ctx.send("You do not have permissions to run this command.") 220 | elif isinstance(error, BlacklistedEntry): 221 | await ctx.send(error.message) 222 | 223 | if not isinstance(error, commands.CommandNotFound) and self.do_command_stats: 224 | if ( 225 | await self.db.command_usage.find( 226 | AQ(EQ("_id", ctx.command.qualified_name)) 227 | ) 228 | is None 229 | ): 230 | await self.db.command_usage.upsert( 231 | AQ(EQ("_id", ctx.command.qualified_name)), 232 | { 233 | "_id": ctx.command.qualified_name, 234 | "usage_count": 0, 235 | "failure_count": 1, 236 | }, 237 | ) 238 | else: 239 | await self.db.command_usage.increment( 240 | AQ(EQ("_id", ctx.command.qualified_name)), "failure_count", 1 241 | ) 242 | 243 | log.debug(f"Command failed: `{ctx.command.qualified_name}`") 244 | raise error 245 | 246 | async def on_command_completion(self, ctx: BotContext) -> None: 247 | """Logs all commands as stats if `do_command_stats` is enabled.""" 248 | if ctx.command.qualified_name == "logout": 249 | return 250 | 251 | if self.do_command_stats: 252 | if ( 253 | await self.db.command_usage.find( 254 | AQ(EQ("_id", ctx.command.qualified_name)) 255 | ) 256 | is None 257 | ): 258 | await self.db.command_usage.upsert( 259 | AQ(EQ("_id", ctx.command.qualified_name)), 260 | { 261 | "_id": ctx.command.qualified_name, 262 | "usage_count": 1, 263 | "failure_count": 0, 264 | }, 265 | ) 266 | else: 267 | await self.db.command_usage.increment( 268 | AQ(EQ("_id", ctx.command.qualified_name)), "usage_count", 1 269 | ) 270 | log.debug(f"Command executed: `{ctx.command.qualified_name}`") 271 | 272 | async def on_guild_join(self, guild: nextcord.Guild) -> None: 273 | """Leaves blacklisted guilds automatically.""" 274 | if self.blacklist and guild.id in self.blacklist.guilds: 275 | log.info("Leaving blacklisted Guild(id=%s)", guild.id) 276 | await guild.leave() 277 | 278 | async def process_commands(self, message: nextcord.Message) -> None: 279 | """Ignores commands from blacklisted users and guilds.""" 280 | ctx = await self.get_context(message, cls=BotContext) 281 | 282 | if self.blacklist and ctx.author.id in self.blacklist.users: 283 | log.debug(f"Ignoring blacklisted user: {ctx.author.id}") 284 | raise BlacklistedEntry(f"Ignoring blacklisted user: {ctx.author.id}") 285 | 286 | if ( 287 | self.blacklist 288 | and ctx.guild is not None 289 | and ctx.guild.id in self.blacklist.guilds 290 | ): 291 | log.debug(f"Ignoring blacklisted guild: {ctx.guild.id}") 292 | raise BlacklistedEntry(f"Ignoring blacklisted guild: {ctx.guild.id}") 293 | 294 | if ctx.command: 295 | log.debug( 296 | "Invoked command %s for User(id=%s)", 297 | ctx.command.qualified_name, 298 | ctx.author.id, 299 | ) 300 | 301 | await self.invoke(ctx) 302 | 303 | async def on_message(self, message: nextcord.Message) -> None: 304 | """Ignores messages from bots.""" 305 | if message.author.bot: 306 | log.debug("Ignoring a message from a bot.") 307 | return 308 | 309 | await self.process_commands(message) 310 | 311 | async def get_or_fetch_member(self, guild_id: int, member_id: int) -> WrappedMember: 312 | """Looks up a member in cache or fetches if not found.""" 313 | guild = await self.get_or_fetch_guild(guild_id) 314 | member = guild.get_member(member_id) 315 | if member is not None: 316 | return WrappedMember(member, bot=self) 317 | 318 | member = await guild.fetch_member(member_id) 319 | return WrappedMember(member, bot=self) 320 | 321 | async def get_or_fetch_channel(self, channel_id: int) -> WrappedChannel: 322 | """Looks up a channel in cache or fetches if not found.""" 323 | channel = self.get_channel(channel_id) 324 | if channel: 325 | return self.get_wrapped_channel(channel) 326 | 327 | channel = await self.fetch_channel(channel_id) 328 | return self.get_wrapped_channel(channel) 329 | 330 | async def get_or_fetch_guild(self, guild_id: int) -> nextcord.Guild: 331 | """Looks up a guild in cache or fetches if not found.""" 332 | guild = self.get_guild(guild_id) 333 | if guild: 334 | return guild 335 | 336 | guild = await self.fetch_guild(guild_id) 337 | return guild 338 | 339 | async def get_or_fetch_user(self, user_id: int) -> WrappedUser: 340 | """Looks up a user in cache or fetches if not found.""" 341 | user = self.get_user(user_id) 342 | if user: 343 | return WrappedUser(user, bot=self) 344 | 345 | user = await self.fetch_user(user_id) 346 | return WrappedUser(user, bot=self) 347 | 348 | def get_wrapped_channel( 349 | self, 350 | channel: Union[abc.GuildChannel, abc.PrivateChannel, nextcord.Thread], 351 | ) -> Union[WrappedThread, WrappedChannel]: 352 | if isinstance(channel, nextcord.Thread): 353 | return WrappedThread(channel, self) 354 | 355 | return WrappedChannel(channel, self) 356 | 357 | def get_wrapped_person( 358 | self, person: Union[nextcord.User, nextcord.Member] 359 | ) -> Union[WrappedUser, WrappedMember]: 360 | if isinstance(person, nextcord.Member): 361 | return WrappedMember(person, self) 362 | 363 | return WrappedUser(person, self) 364 | 365 | def get_wrapped_message(self, message: nextcord.Message) -> nextcord.Message: 366 | """ 367 | Wrap the relevant params in message with meta classes. 368 | 369 | These fields are: 370 | message.channel: Union[WrappedThread, WrappedChannel] 371 | message.author: Union[WrappedUser, WrappedMember] 372 | """ 373 | message.channel = self.get_wrapped_channel(message.channel) 374 | message.author = self.get_wrapped_person(message.author) 375 | 376 | return message 377 | 378 | def dispatch(self, event_name: str, *args: Any, **kwargs: Any) -> None: 379 | _name = f"on_{event_name}" 380 | # If we know the event, dispatch the wrapped one 381 | if _name in self._single_event_type_sheet: 382 | wrapped_arg = self._single_event_type_sheet[_name](args[0]) 383 | super().dispatch(event_name, wrapped_arg) # type: ignore 384 | 385 | elif _name in self._double_event_type_sheet: 386 | wrapped_first_arg, wrapped_second_arg = self._double_event_type_sheet[ 387 | _name 388 | ](args[0], args[1]) 389 | super().dispatch(event_name, wrapped_first_arg, wrapped_second_arg, self) 390 | 391 | else: 392 | super().dispatch(event_name, *args, **kwargs) # type: ignore 393 | 394 | def cancellable_wait_for( 395 | self, event: str, *, check=None, timeout: int = None 396 | ) -> CancellableWaitFor: 397 | """Refer to :py:class:`CancellableWaitFor`""" 398 | return CancellableWaitFor(self, event=event, check=check, timeout=timeout) 399 | 400 | @staticmethod 401 | async def sleep_with_condition( 402 | seconds: float, 403 | condition: Union[ 404 | functools.partial, 405 | Callable[[Any], bool], 406 | Callable[[Any], Coroutine[Any, Any, bool]], 407 | ], 408 | *, 409 | interval: float = 5, 410 | ) -> None: 411 | """Sleep until either condition is True or we run out of seconds 412 | 413 | Parameters 414 | ---------- 415 | seconds: float 416 | How long to sleep for up to 417 | condition 418 | Pass either: 419 | - A sync function to call which returns a bool 420 | - An async function to call which returns a bool 421 | 422 | .. note:: 423 | 424 | If you wish to use arguments in you functions, 425 | pass an instance of :class:`functools.partial` 426 | interval: float 427 | How long to sleep in-between each condition check. 428 | 429 | Defaults to 5 seconds. 430 | """ 431 | if asyncio.iscoroutinefunction(condition): 432 | wrapped_condition = condition 433 | elif callable(condition) or isinstance(condition, functools.partial): 434 | 435 | async def wrapped_condition(): 436 | return condition() 437 | 438 | else: 439 | 440 | raise TypeError("Unknown input argument") 441 | 442 | remaining_seconds = seconds 443 | while remaining_seconds > 0: 444 | 445 | remaining_seconds -= interval 446 | await asyncio.sleep(interval) 447 | 448 | if await wrapped_condition(): 449 | return 450 | -------------------------------------------------------------------------------- /bot_base/caches/__init__.py: -------------------------------------------------------------------------------- 1 | from bot_base.caches.entry import Entry 2 | from bot_base.caches.timed import TimedCache 3 | -------------------------------------------------------------------------------- /bot_base/caches/abc.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import runtime_checkable, Protocol, Any 3 | 4 | 5 | @runtime_checkable 6 | class Cache(Protocol): 7 | def add_entry( 8 | self, key: Any, value: Any, *, ttl: timedelta = None, override: bool = False 9 | ) -> None: 10 | """ 11 | Adds an entry to the cache with an optional time to live. 12 | 13 | Parameters 14 | ---------- 15 | key: Any 16 | The key to store this value under 17 | value: Any 18 | The value for the given key 19 | ttl: timedelta, optional 20 | How long this entry should be valid for. 21 | Defaults to forever 22 | override: bool, optional 23 | If True, overrides an entry if it already exists 24 | 25 | Raises 26 | ------ 27 | ExistingEntry 28 | """ 29 | raise NotImplementedError 30 | 31 | def delete_entry(self, key: Any) -> None: 32 | """ 33 | Deletes an entry in the cache. 34 | 35 | Parameters 36 | ---------- 37 | key: Any 38 | The entry to delete 39 | 40 | Notes 41 | ----- 42 | If the entry doesnt exist this won't act 43 | differently to if it did exist. 44 | """ 45 | raise NotImplementedError 46 | 47 | def __contains__(self, item: Any) -> bool: 48 | """ 49 | Returns True if the item exists in the cache. 50 | """ 51 | raise NotImplementedError 52 | 53 | def force_clean(self) -> None: 54 | """ 55 | Iterates over the cache, removing outdated entries. 56 | 57 | Implemented since by default the cache only cleans 58 | on access. I.e its lazy 59 | """ 60 | raise NotImplementedError 61 | 62 | def get_entry(self, key: Any) -> Any: 63 | """ 64 | Parameters 65 | ---------- 66 | key: Any 67 | The key to get an entry for 68 | 69 | Returns 70 | ------- 71 | Any 72 | The value for this if 73 | 74 | Raises 75 | ------ 76 | NonExistentEntry 77 | Either the cache doesn't contain 78 | the key, or the Entry timed out 79 | """ 80 | raise NotImplementedError 81 | -------------------------------------------------------------------------------- /bot_base/caches/entry.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Optional 3 | 4 | import attr 5 | 6 | 7 | @attr.s(slots=True) 8 | class Entry: 9 | value: Any = attr.ib() 10 | expiry_time: Optional[datetime] = attr.ib(default=None) 11 | -------------------------------------------------------------------------------- /bot_base/caches/timed.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta, datetime 2 | from typing import Any, Dict, Optional, Generic, TypeVar 3 | 4 | from bot_base.caches import Entry 5 | from bot_base.caches.abc import Cache 6 | from bot_base.exceptions import NonExistentEntry, ExistingEntry 7 | 8 | KT = TypeVar("KT", bound=Any) 9 | VT = TypeVar("VT", bound=Any) 10 | 11 | 12 | class TimedCache(Cache, Generic[KT, VT]): 13 | __slots__ = ("cache", "global_ttl", "non_lazy") 14 | 15 | def __init__( 16 | self, 17 | *, 18 | global_ttl: Optional[timedelta] = None, 19 | lazy_eviction: bool = True, 20 | ): 21 | """ 22 | Parameters 23 | ---------- 24 | global_ttl: Optional[timedelta] 25 | A default TTL for any added entries. 26 | lazy_eviction: bool 27 | Whether this cache should perform lazy eviction or not. 28 | 29 | Defaults to True 30 | """ 31 | self.cache: Dict[KT, Entry] = {} 32 | self.non_lazy: bool = not lazy_eviction 33 | self.global_ttl: Optional[timedelta] = global_ttl 34 | 35 | def __contains__(self, item: Any) -> bool: 36 | try: 37 | entry = self.cache[item] 38 | if entry.expiry_time and entry.expiry_time < datetime.now(): 39 | self.delete_entry(item) 40 | return False 41 | except KeyError: 42 | return False 43 | else: 44 | return True 45 | 46 | def __len__(self): 47 | self.force_clean() 48 | return len(self.cache.keys()) 49 | 50 | def add_entry( 51 | self, 52 | key: KT, 53 | value: VT, 54 | *, 55 | ttl: Optional[timedelta] = None, 56 | override: bool = False, 57 | ) -> None: 58 | """ 59 | Add an entry to the cache. 60 | 61 | Parameters 62 | ---------- 63 | key 64 | The key to store this under. 65 | value 66 | The item you want to store in the cache 67 | ttl: Optional[timedelta] 68 | An optional period of time to expire 69 | this entry after. 70 | override: bool 71 | Whether or not to override an existing value 72 | 73 | Raises 74 | ------ 75 | ExistingEntry 76 | You are trying to insert a duplicate key 77 | 78 | Notes 79 | ----- 80 | ttl passed to this method will 81 | take precendence over the global ttl. 82 | """ 83 | self._perform_eviction() 84 | if key in self and not override: 85 | raise ExistingEntry 86 | 87 | if ttl or self.global_ttl: 88 | ttl = ttl or self.global_ttl 89 | self.cache[key] = Entry(value=value, expiry_time=(datetime.now() + ttl)) 90 | else: 91 | self.cache[key] = Entry(value=value) 92 | 93 | def delete_entry(self, key: KT) -> None: 94 | """ 95 | Delete a key from the cache 96 | 97 | Parameters 98 | ---------- 99 | key 100 | The key to delete 101 | """ 102 | self._perform_eviction() 103 | try: 104 | self.cache.pop(key) 105 | except KeyError: 106 | pass 107 | 108 | def get_entry(self, key: KT) -> VT: 109 | """ 110 | Fetch a value from the cache 111 | 112 | Parameters 113 | ---------- 114 | key 115 | The key you wish to 116 | retrieve a value for 117 | 118 | Returns 119 | ------- 120 | VT 121 | The provided value 122 | 123 | Raises 124 | ------ 125 | NonExistentEntry 126 | No value exists in the cache 127 | for the provided key. 128 | 129 | """ 130 | self._perform_eviction() 131 | if key not in self: 132 | raise NonExistentEntry 133 | 134 | return self.cache[key].value 135 | 136 | def force_clean(self) -> None: 137 | """ 138 | Clear out all outdated cache items. 139 | """ 140 | now = datetime.now() 141 | self.cache = { 142 | k: v 143 | for k, v in self.cache.items() 144 | if (v.expiry_time and v.expiry_time > now) or not v.expiry_time 145 | } 146 | 147 | def _perform_eviction(self): 148 | if self.non_lazy: 149 | self.force_clean() 150 | -------------------------------------------------------------------------------- /bot_base/cancellable_wait_for.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import secrets 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from bot_base import EventCancelled 8 | 9 | if TYPE_CHECKING: 10 | from bot_base import BotBase 11 | 12 | 13 | class CancellableWaitFor: 14 | def __init__(self, bot: BotBase, *, event, check=None, timeout=None): 15 | """Test""" 16 | self.bot: BotBase = bot 17 | 18 | self._event = event 19 | self._check = check 20 | self._timeout = timeout 21 | 22 | self.__is_running: bool = False 23 | self.__cancel_key = secrets.token_hex(16) 24 | 25 | self.__result = None 26 | 27 | @property 28 | def result(self): 29 | return self.__result 30 | 31 | def copy(self) -> CancellableWaitFor: 32 | """Creates and returns a new copy of this cancellable event.""" 33 | return CancellableWaitFor( 34 | self.bot, 35 | event=self._event, 36 | check=self._check, 37 | timeout=self._timeout, 38 | ) 39 | 40 | async def wait(self) -> Any: 41 | """ 42 | Block until your event returns or is cancelled. 43 | 44 | Returns 45 | ------- 46 | Any 47 | The return value of the event your waiting for. 48 | 49 | Raises 50 | ------ 51 | EventCancelled 52 | The waiting event was cancelled before a result was formed. 53 | """ 54 | if self.__is_running: 55 | raise RuntimeError( 56 | "Cannot wait on this instance more then once, " 57 | "possibly meant to wait on a `.copy()` of this instance?" 58 | ) 59 | 60 | self.__result = None 61 | self.__is_running = True 62 | # ?tag multi wait for in discord.gg/dpy 63 | done, pending = await asyncio.wait( 64 | [ 65 | self.bot.loop.create_task( 66 | self.bot.wait_for( 67 | self._event, check=self._check, timeout=self._timeout 68 | ) 69 | ), 70 | self.bot.loop.create_task(self.bot.wait_for(self.__cancel_key)), 71 | ], 72 | return_when=asyncio.FIRST_COMPLETED, 73 | ) 74 | 75 | try: 76 | self.__is_running = False 77 | result = done.pop().result() 78 | except Exception as e: 79 | self.__is_running = False 80 | # If the first finished task died for any reason, 81 | # the exception will be replayed here. 82 | raise e 83 | 84 | for future in done: 85 | # If any exception happened in any other done tasks 86 | # we don't care about the exception, but don't want the noise of 87 | # non-retrieved exceptions 88 | future.exception() 89 | 90 | for future in pending: 91 | future.cancel() 92 | 93 | if result == self.__cancel_key: 94 | # Event got cancelled 95 | raise EventCancelled 96 | 97 | self.__result = result 98 | return result 99 | 100 | def cancel(self): 101 | """Cancel waiting for the event.""" 102 | self.bot.dispatch(self.__cancel_key, self.__cancel_key) 103 | -------------------------------------------------------------------------------- /bot_base/cog.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from typing import TYPE_CHECKING 5 | 6 | try: 7 | from nextcord.ext import commands 8 | except ModuleNotFoundError: 9 | from disnake.ext import commands 10 | 11 | if TYPE_CHECKING: 12 | from bot_base import BotBase 13 | 14 | 15 | class Cog(commands.Cog): 16 | """A cog subclass which allows for async setup. 17 | 18 | Attempts to call an async method async_init 19 | on load, implement the method as required. 20 | """ 21 | def __init__(self, bot: BotBase): 22 | self.bot: BotBase = bot 23 | 24 | internal_hook = getattr(self, "async_init", None) 25 | if internal_hook: 26 | try: 27 | asyncio.create_task(internal_hook) 28 | except RuntimeError as e: 29 | raise RuntimeError("Cog's must be loaded in an async context.") from e 30 | 31 | if TYPE_CHECKING: 32 | async def async_init(self) -> None: 33 | ... 34 | 35 | -------------------------------------------------------------------------------- /bot_base/cogs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Skelmis/Discord-Bot-Base/9b581a8bdfd0378a91bd15ed43d8a1612a8c411a/bot_base/cogs/__init__.py -------------------------------------------------------------------------------- /bot_base/cogs/internal.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from bot_base import BotBase 4 | from bot_base.context import BotContext 5 | 6 | try: 7 | import nextcord as discord 8 | from nextcord.ext import commands 9 | except ModuleNotFoundError: 10 | import disnake as discord 11 | from disnake.ext import commands 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | class Internal(commands.Cog): 17 | def __init__(self, bot): 18 | self.bot: BotBase = bot 19 | 20 | def cog_check(self, ctx) -> bool: 21 | try: 22 | _exists = self.bot.blacklist 23 | return True 24 | except AttributeError: 25 | return False 26 | 27 | @commands.Cog.listener() 28 | async def on_initial_ready(self): 29 | log.info(f"{self.__class__.__name__}: Ready") 30 | 31 | @commands.group(invoke_without_command=True) 32 | @commands.is_owner() 33 | async def blacklist(self, ctx: BotContext) -> None: 34 | """Top level blacklist interface""" 35 | await ctx.send_help(ctx.command) 36 | 37 | @blacklist.group(invoke_without_command=True) 38 | @commands.is_owner() 39 | async def add(self, ctx: BotContext) -> None: 40 | """Add something to the blacklist""" 41 | await ctx.send_help(ctx.command) 42 | 43 | @add.command(name="person") 44 | @commands.is_owner() 45 | async def add_person( 46 | self, ctx: BotContext, user: discord.Object, *, reason=None 47 | ) -> None: 48 | """Add someone to the blacklist""" 49 | await self.bot.blacklist.add_to_blacklist( 50 | user.id, reason=reason, is_guild_blacklist=False 51 | ) 52 | await ctx.send_basic_embed(f"I have added <@{user.id}> to the blacklist.") 53 | 54 | @add.command(name="guild") 55 | @commands.is_owner() 56 | async def add_guild( 57 | self, ctx: BotContext, guild: discord.Object, *, reason=None 58 | ) -> None: 59 | await self.bot.blacklist.add_to_blacklist( 60 | guild.id, reason=reason, is_guild_blacklist=True 61 | ) 62 | await ctx.send_basic_embed( 63 | f"I have added the guild `{guild.id}` to the blacklist" 64 | ) 65 | 66 | @blacklist.command() 67 | @commands.is_owner() 68 | async def list(self, ctx: BotContext) -> None: 69 | """List all current blacklists""" 70 | if self.bot.blacklist.users: 71 | user_blacklists = "\n".join(f"`{u}`" for u in self.bot.blacklist.users) 72 | else: 73 | user_blacklists = "No user's blacklisted." 74 | 75 | if self.bot.blacklist.guilds: 76 | guild_blacklists = "\n".join(f"`{g}`" for g in self.bot.blacklist.guilds) 77 | else: 78 | guild_blacklists = "No guild's blacklisted." 79 | 80 | await ctx.send( 81 | embed=discord.Embed( 82 | title="Blacklists", 83 | description=f"Users:\n{user_blacklists}\n\nGuilds:\n{guild_blacklists}", 84 | ) 85 | ) 86 | 87 | @blacklist.group(invoke_without_command=True) 88 | @commands.is_owner() 89 | async def remove(self, ctx: BotContext) -> None: 90 | """Remove something from the blacklist""" 91 | await ctx.send_help(ctx.command) 92 | 93 | @remove.command(name="person") 94 | @commands.is_owner() 95 | async def remove_person(self, ctx: BotContext, user: discord.Object) -> None: 96 | """Remove a person from the blacklist. 97 | 98 | Does nothing if they weren't blacklisted. 99 | """ 100 | await self.bot.blacklist.remove_from_blacklist( 101 | user.id, is_guild_blacklist=False 102 | ) 103 | await ctx.send_basic_embed("I have completed that action for you.") 104 | 105 | @remove.command(name="guild") 106 | @commands.is_owner() 107 | async def remove_guild(self, ctx: BotContext, guild: discord.Object) -> None: 108 | """Remove a guild from the blacklist. 109 | 110 | Does nothing if they weren't blacklisted. 111 | """ 112 | await self.bot.blacklist.remove_from_blacklist( 113 | guild.id, is_guild_blacklist=True 114 | ) 115 | await ctx.send_basic_embed("I have completed that action for you.") 116 | 117 | 118 | def setup(bot): 119 | bot.add_cog(Internal(bot)) 120 | -------------------------------------------------------------------------------- /bot_base/context.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | try: 4 | from nextcord.ext import commands 5 | except ModuleNotFoundError: 6 | from disnake.ext import commands 7 | 8 | from bot_base.wraps import Meta 9 | 10 | if TYPE_CHECKING: 11 | from bot_base import BotBase 12 | 13 | 14 | class BotContext(commands.Context, Meta): 15 | def __init__(self, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | bot: "BotBase" = self.bot 18 | self._wrapped_bot = bot 19 | 20 | self.message = bot.get_wrapped_message(self.message) 21 | -------------------------------------------------------------------------------- /bot_base/converters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Skelmis/Discord-Bot-Base/9b581a8bdfd0378a91bd15ed43d8a1612a8c411a/bot_base/converters/__init__.py -------------------------------------------------------------------------------- /bot_base/converters/time.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | time_regex = re.compile(r"(( ?(\d{1,5})(h|s|m|d))+)") 4 | time_dict = {"h": 3600, "s": 1, "m": 60, "d": 86400} 5 | 6 | 7 | async def time_convertor(argument): 8 | raise RuntimeWarning("Lol dont") 9 | args = argument.lower() 10 | matches = re.findall(time_regex, args) 11 | if not matches: 12 | return 0 13 | 14 | matches = matches[0][0].split(" ") 15 | matches = [filter(None, re.split(r"(\d+)", s)) for s in matches] 16 | time = 0 17 | for match in matches: 18 | key, value = match 19 | try: 20 | time += time_dict[value] * float(key) 21 | except KeyError: 22 | raise commands.BadArgument( 23 | f"{value} is an invalid time key! h|m|s|d are valid arguments" 24 | ) 25 | except ValueError: 26 | raise commands.BadArgument(f"{key} is not a number!") 27 | return time 28 | -------------------------------------------------------------------------------- /bot_base/db/__init__.py: -------------------------------------------------------------------------------- 1 | from .mongo import MongoManager 2 | 3 | __all__ = ("MongoManager",) 4 | -------------------------------------------------------------------------------- /bot_base/db/mongo.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | from typing import List, Dict 4 | 5 | from alaric import Document 6 | from motor.motor_asyncio import AsyncIOMotorClient 7 | 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | class MongoManager: 13 | def __init__(self, connection_url, database_name=None): 14 | self.database_name = database_name or "production" 15 | 16 | self.__mongo = AsyncIOMotorClient(connection_url) 17 | self.db = self.__mongo[self.database_name] 18 | 19 | # Documents 20 | self.user_blacklist = Document(self.db, "user_blacklist") 21 | self.guild_blacklist = Document(self.db, "guild_blacklist") 22 | 23 | def typed_lookup(self, attr: str) -> Document: 24 | return getattr(self, attr) 25 | 26 | # def __getattr__(self, item) -> Document: 27 | # """ 28 | # Parameters 29 | # ---------- 30 | # item : str 31 | # Denotes the 'table' to return 32 | # 33 | # Returns 34 | # ------- 35 | # Document 36 | # A Document made for said item 37 | # """ 38 | # doc: Document = Document(self.db, item) 39 | # setattr(self, item, doc) 40 | # 41 | # return doc 42 | 43 | def get_current_documents(self) -> List[Document]: 44 | class_vars = vars(self) 45 | documents = [] 46 | for v in class_vars.values(): 47 | if isinstance(v, Document): 48 | documents.append(v) 49 | 50 | return documents 51 | 52 | async def run_backup(self): 53 | """ 54 | Backs up the database within the same cluster. 55 | """ 56 | documents: List[Document] = self.get_current_documents() 57 | 58 | epoch: str = str(round(datetime.datetime.utcnow().timestamp())) 59 | name = f"backup-{epoch}" 60 | backup_db = self.__mongo[name] 61 | log.info("Backing up the database, this backup is '%s'", name) 62 | 63 | for document in documents: 64 | backup_doc: Document = Document(backup_db, document.document_name) 65 | all_data: List[Dict] = await document.get_all() 66 | if not all_data: 67 | continue 68 | 69 | await backup_doc.bulk_insert(all_data) 70 | -------------------------------------------------------------------------------- /bot_base/exceptions.py: -------------------------------------------------------------------------------- 1 | try: 2 | from nextcord import DiscordException 3 | except ModuleNotFoundError: 4 | from disnake import DiscordException 5 | 6 | 7 | class PrefixNotFound(DiscordException): 8 | """A prefix for this guild was not found.""" 9 | 10 | 11 | class ExistingEntry(DiscordException): 12 | """An entry was already found in the cache with this key.""" 13 | 14 | 15 | class NonExistentEntry(DiscordException): 16 | """No entry found in the cache with this key.""" 17 | 18 | 19 | class EventCancelled(DiscordException): 20 | """The waiting event was cancelled before a result was formed.""" 21 | 22 | 23 | class BlacklistedEntry(DiscordException): 24 | def __init__(self, message: str): 25 | self.message: str = message 26 | -------------------------------------------------------------------------------- /bot_base/paginators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Skelmis/Discord-Bot-Base/9b581a8bdfd0378a91bd15ed43d8a1612a8c411a/bot_base/paginators/__init__.py -------------------------------------------------------------------------------- /bot_base/paginators/disnake_paginator.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union, TypeVar, Optional, Callable 2 | 3 | import disnake 4 | from disnake.ext import commands 5 | 6 | # Inspired by https://github.com/nextcord/nextcord-ext-menus 7 | 8 | T = TypeVar("T") 9 | 10 | 11 | class PaginationView(disnake.ui.View): 12 | FIRST_PAGE = "\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\ufe0f" 13 | PREVIOUS_PAGE = "\N{BLACK LEFT-POINTING TRIANGLE}\ufe0f" 14 | NEXT_PAGE = "\N{BLACK RIGHT-POINTING TRIANGLE}\ufe0f" 15 | LAST_PAGE = "\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\ufe0f" 16 | STOP = "\N{BLACK SQUARE FOR STOP}\ufe0f" 17 | 18 | def __init__( 19 | self, 20 | author_id: int, 21 | paginator: "DisnakePaginator", 22 | *, 23 | timeout: Optional[float] = 180, 24 | ): 25 | super().__init__(timeout=timeout) 26 | self.author_id: int = author_id 27 | self._paginator: "DisnakePaginator" = paginator 28 | 29 | # Default to disabled, we change them later anyway if actually required. 30 | self.first_page_button = disnake.ui.Button(label=self.FIRST_PAGE, disabled=True) 31 | self.previous_page_button = disnake.ui.Button( 32 | label=self.PREVIOUS_PAGE, disabled=True 33 | ) 34 | self.next_page_button = disnake.ui.Button(label=self.NEXT_PAGE, disabled=True) 35 | self.last_page_button = disnake.ui.Button(label=self.LAST_PAGE, disabled=True) 36 | self.stop_button = disnake.ui.Button(label=self.STOP, disabled=True) 37 | 38 | self.first_page_button.callback = self._paginator.go_to_first_page 39 | self.previous_page_button.callback = self._paginator.go_to_previous_page 40 | self.next_page_button.callback = self._paginator.go_to_next_page 41 | self.last_page_button.callback = self._paginator.go_to_last_page 42 | self.stop_button.callback = self._paginator.stop_pages 43 | 44 | self.add_item(self.first_page_button) 45 | self.add_item(self.previous_page_button) 46 | self.add_item(self.next_page_button) 47 | self.add_item(self.last_page_button) 48 | self.add_item(self.stop_button) 49 | 50 | async def interaction_check(self, interaction: disnake.MessageInteraction) -> bool: 51 | return interaction.user.id == self.author_id 52 | 53 | async def on_timeout(self) -> None: 54 | self.stop() 55 | await self._paginator.stop() 56 | 57 | 58 | class DisnakePaginator: 59 | def __init__( 60 | self, 61 | items_per_page: int, 62 | input_data: List[T], 63 | *, 64 | try_ephemeral: bool = True, 65 | delete_buttons_on_stop: bool = False, 66 | page_formatter: Optional[Callable] = None, 67 | ): 68 | """ 69 | A simplistic paginator built for Disnake. 70 | 71 | Parameters 72 | ---------- 73 | items_per_page: int 74 | How many items to show per page. 75 | input_data: List[Any] 76 | The data to be paginated. 77 | try_ephemeral: bool 78 | Whether or not to try send the interaction 79 | as ephemeral. Defaults to ``True`` 80 | delete_buttons_on_stop: bool 81 | When the paginator is stopped, should 82 | the buttons be deleted? Defaults to ``False`` 83 | which merely disables them. 84 | page_formatter: Callable 85 | An inline formatter to save the need to 86 | subclass/override ``format_page`` 87 | """ 88 | self._current_page_index = 0 89 | self._items_per_page: int = items_per_page 90 | self.__input_data: List[T] = input_data 91 | self._try_ephemeral: bool = try_ephemeral 92 | self._delete_buttons_on_stop: bool = delete_buttons_on_stop 93 | self._inline_format_page: Optional[Callable] = page_formatter 94 | 95 | if items_per_page <= 0: 96 | raise ValueError("items_per_page must be 1 or higher.") 97 | 98 | if self._items_per_page == 1: 99 | self._paged_data: List[T] = self.__input_data 100 | 101 | else: 102 | self._paged_data: List[List[T]] = [ 103 | self.__input_data[i : i + self._items_per_page] 104 | for i in range(0, len(self.__input_data), self._items_per_page) 105 | ] 106 | 107 | self._is_done: bool = False 108 | self._message: Optional[disnake.Message] = None 109 | self._pagination_view: Optional[PaginationView] = None 110 | 111 | @property 112 | def current_page(self) -> int: 113 | """The current page for this paginator.""" 114 | return self._current_page_index + 1 115 | 116 | @current_page.setter 117 | def current_page(self, value) -> None: 118 | if value > self.total_pages: 119 | raise ValueError( 120 | "Cannot change current page to a page bigger then this paginator." 121 | ) 122 | 123 | self._current_page_index = value - 1 124 | 125 | @property 126 | def total_pages(self) -> int: 127 | "How many pages exist in this paginator." 128 | return len(self._paged_data) 129 | 130 | @property 131 | def requires_pagination(self) -> bool: 132 | """Does this paginator have more then 1 page.""" 133 | return len(self._paged_data) != 1 134 | 135 | @property 136 | def has_prior_page(self) -> bool: 137 | """Can we move backwards pagination wide.""" 138 | return self.current_page != 1 139 | 140 | @property 141 | def has_next_page(self) -> bool: 142 | """Can we move forward pagination wise.""" 143 | return self.current_page != self.total_pages 144 | 145 | async def start( 146 | self, 147 | *, 148 | interaction: disnake.Interaction = None, 149 | context: commands.Context = None, 150 | ): 151 | """ 152 | Start paginating this paginator. 153 | 154 | Parameters 155 | ---------- 156 | interaction: disnake.Interaction 157 | The Interaction to start 158 | this pagination on. 159 | context: commands.Context 160 | The Context to start paginating on. 161 | """ 162 | first_page: Union[str, disnake.Embed] = await self.format_page( 163 | self._paged_data[self._current_page_index], self.current_page 164 | ) 165 | 166 | send_kwargs = {} 167 | if isinstance(first_page, disnake.Embed): 168 | send_kwargs["embed"] = first_page 169 | else: 170 | send_kwargs["content"] = first_page 171 | 172 | if interaction: 173 | self._pagination_view = PaginationView(interaction.user.id, self) 174 | if interaction.response._responded: 175 | self._message = await interaction.original_message() 176 | if self.requires_pagination: 177 | await self._message.edit(**send_kwargs, view=self._pagination_view) 178 | 179 | else: 180 | await self._message.edit(**send_kwargs) 181 | 182 | else: 183 | if self.requires_pagination: 184 | await interaction.send( 185 | **send_kwargs, 186 | ephemeral=self._try_ephemeral, 187 | view=self._pagination_view, 188 | ) 189 | 190 | else: 191 | await interaction.send( 192 | **send_kwargs, 193 | ephemeral=self._try_ephemeral, 194 | ) 195 | 196 | self._message = await interaction.original_message() 197 | 198 | elif context: 199 | self._pagination_view = PaginationView(context.author.id, self) 200 | if self.requires_pagination: 201 | self._message = await context.channel.send( 202 | **send_kwargs, 203 | view=self._pagination_view, 204 | ) 205 | 206 | else: 207 | self._message = await context.channel.send(**send_kwargs) 208 | 209 | else: 210 | raise RuntimeError("Context or Interaction is required.") 211 | 212 | await self._set_buttons() 213 | 214 | async def stop(self): 215 | """Stop paginating this paginator.""" 216 | self._is_done = True 217 | await self._set_buttons() 218 | 219 | async def _set_buttons(self) -> disnake.Message: 220 | """Sets buttons based on current page.""" 221 | if not self.requires_pagination: 222 | # No pagination required 223 | return await self._message.edit(view=None) 224 | 225 | if self._is_done: 226 | # Disable all buttons 227 | if self._delete_buttons_on_stop: 228 | return await self._message.edit(view=None) 229 | 230 | self._pagination_view.stop_button.disabled = True 231 | self._pagination_view.next_page_button.disabled = True 232 | self._pagination_view.last_page_button.disabled = True 233 | self._pagination_view.first_page_button.disabled = True 234 | self._pagination_view.previous_page_button.disabled = True 235 | return await self._message.edit(view=self._pagination_view) 236 | 237 | # Toggle buttons 238 | if self.has_prior_page: 239 | self._pagination_view.first_page_button.disabled = False 240 | self._pagination_view.previous_page_button.disabled = False 241 | else: 242 | # Cannot go backwards 243 | self._pagination_view.first_page_button.disabled = True 244 | self._pagination_view.previous_page_button.disabled = True 245 | 246 | if self.has_next_page: 247 | self._pagination_view.next_page_button.disabled = False 248 | self._pagination_view.last_page_button.disabled = False 249 | else: 250 | self._pagination_view.next_page_button.disabled = True 251 | self._pagination_view.last_page_button.disabled = True 252 | 253 | self._pagination_view.stop_button.disabled = False 254 | 255 | return await self._message.edit(view=self._pagination_view) 256 | 257 | async def show_page(self, page_number: int): 258 | """ 259 | Change to the given page. 260 | 261 | Parameters 262 | ---------- 263 | page_number: int 264 | The page you wish to see. 265 | 266 | Raises 267 | ------ 268 | ValueError 269 | Page number is too big for this paginator. 270 | """ 271 | self.current_page = page_number 272 | page: Union[str, disnake.Embed] = await self.format_page( 273 | self._paged_data[self._current_page_index], self.current_page 274 | ) 275 | if isinstance(page, disnake.Embed): 276 | await self._message.edit(embed=page) 277 | else: 278 | await self._message.edit(content=page) 279 | await self._set_buttons() 280 | 281 | async def go_to_first_page(self, interaction: disnake.MessageInteraction): 282 | """Paginate to the first page.""" 283 | await interaction.response.defer() 284 | await self.show_page(1) 285 | 286 | async def go_to_previous_page(self, interaction: disnake.Interaction): 287 | """Paginate to the previous viewable page.""" 288 | await interaction.response.defer() 289 | await self.show_page(self.current_page - 1) 290 | 291 | async def go_to_next_page(self, interaction: disnake.Interaction): 292 | """Paginate to the next viewable page.""" 293 | await interaction.response.defer() 294 | await self.show_page(self.current_page + 1) 295 | 296 | async def go_to_last_page(self, interaction: disnake.Interaction): 297 | """Paginate to the last viewable page.""" 298 | await interaction.response.defer() 299 | await self.show_page(self.total_pages) 300 | 301 | async def stop_pages(self, interaction: disnake.Interaction): 302 | """Stop paginating this paginator.""" 303 | await interaction.response.defer() 304 | await self.stop() 305 | 306 | async def format_page( 307 | self, page_items: Union[T, List[T]], page_number: int 308 | ) -> Union[str, disnake.Embed]: 309 | """Given the page items, format them how you wish. 310 | 311 | Calls the inline formatter if not overridden, 312 | otherwise returns ``page_items`` as a string. 313 | 314 | Parameters 315 | ---------- 316 | page_items: Union[T, List[T]] 317 | The items for this page. 318 | If ``items_per_page`` is ``1`` then this 319 | will be a singular item. 320 | page_number: int 321 | This pages number. 322 | """ 323 | if self._inline_format_page: 324 | return self._inline_format_page(self, page_items, page_number) 325 | 326 | return str(page_items) 327 | -------------------------------------------------------------------------------- /bot_base/wraps/__init__.py: -------------------------------------------------------------------------------- 1 | from .channel import WrappedChannel 2 | from .meta import Meta 3 | from .member import WrappedMember 4 | from .user import WrappedUser 5 | from .thread import WrappedThread 6 | 7 | __all__ = ("WrappedChannel", "Meta", "WrappedMember", "WrappedUser", "WrappedThread") 8 | -------------------------------------------------------------------------------- /bot_base/wraps/channel.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | try: 4 | from nextcord import abc 5 | from nextcord.ext import commands 6 | except ModuleNotFoundError: 7 | from disnake import abc 8 | from disnake.ext import commands 9 | 10 | from bot_base.wraps.meta import Meta 11 | 12 | 13 | class WrappedChannel(Meta, abc.GuildChannel, abc.PrivateChannel): # noqa 14 | """Wraps nextcord.TextChannel for ease of stuff""" 15 | 16 | @classmethod 17 | async def convert(cls, ctx, argument: str) -> "WrappedChannel": 18 | channel: Union[ 19 | abc.GuildChannel, abc.PrivateChannel 20 | ] = await commands.TextChannelConverter().convert(ctx=ctx, argument=argument) 21 | return cls(channel, ctx.bot) 22 | 23 | def __getattr__(self, item): 24 | return getattr(self._wrapped_item, item) 25 | -------------------------------------------------------------------------------- /bot_base/wraps/member.py: -------------------------------------------------------------------------------- 1 | try: 2 | import nextcord 3 | from nextcord.ext import commands 4 | except ModuleNotFoundError: 5 | import disnake as nextcord 6 | from disnake.ext import commands 7 | 8 | from bot_base.wraps.meta import Meta 9 | 10 | 11 | class WrappedMember(Meta, nextcord.Member): 12 | """Wraps discord.Member for ease of stuff""" 13 | 14 | @classmethod 15 | async def convert(cls, ctx, argument: str) -> "WrappedMember": 16 | member: nextcord.Member = await commands.MemberConverter().convert( 17 | ctx=ctx, argument=argument 18 | ) 19 | return cls(member, ctx.bot) 20 | 21 | def __getattr__(self, item): 22 | return getattr(self._wrapped_item, item) 23 | -------------------------------------------------------------------------------- /bot_base/wraps/meta.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Optional, TYPE_CHECKING, Any 3 | 4 | try: 5 | import nextcord 6 | except ModuleNotFoundError: 7 | import disnake as nextcord 8 | 9 | from . import channel 10 | 11 | if TYPE_CHECKING: 12 | from bot_base import BotBase 13 | 14 | 15 | class Meta: 16 | """ 17 | Used to inject functionality into multiple 18 | class's and reduce code duplication 19 | """ 20 | 21 | def __init__(self, wrapped_item, bot: "BotBase"): 22 | self._wrapped_item = wrapped_item 23 | self._wrapped_bot = bot 24 | 25 | if isinstance(wrapped_item, type(self)): 26 | self._wrapped_item = wrapped_item._wrapped_item 27 | 28 | # def __getattr__(self, item): 29 | # attr = getattr(self._wrapped_item, item, MISSING) 30 | # if attr is MISSING: 31 | # raise AttributeError(item) 32 | # 33 | # return attr 34 | 35 | # @property 36 | # def __class__(self): 37 | # return type(self._wrapped_item) 38 | 39 | def __instancecheck__(self, instance): 40 | return isinstance(instance, type(self._wrapped_item)) 41 | 42 | def __subclasscheck__(self, subclass): 43 | return issubclass(subclass, self._wrapped_item) 44 | 45 | def __eq__(self, other: Any) -> bool: 46 | """ 47 | If other is not of this type, or the type 48 | we wrap its False. 49 | If other is not us, but what we wrap then 50 | compare them as applicable. 51 | If other is same type as us, compare 52 | what we both wrap. 53 | """ 54 | if not isinstance(other, (type(self._wrapped_item), type(self))): 55 | return False 56 | 57 | if isinstance(other, type(self._wrapped_item)): 58 | return other.id == self._wrapped_item.id 59 | 60 | return other._wrapped_item.id == self._wrapped_item.id 61 | 62 | def __hash__(self): 63 | return hash(self._wrapped_item) 64 | 65 | async def prompt( 66 | self, 67 | message: str, 68 | *, 69 | timeout=60.0, 70 | delete_after=True, 71 | author_id=None, 72 | ): 73 | """An interactive reaction confirmation dialog. 74 | 75 | Parameters 76 | ---------- 77 | message : str 78 | The message to show along with the prompt. 79 | timeout : float 80 | How long to wait before returning. 81 | delete_after : bool 82 | Whether to delete the confirmation message after we're done. 83 | author_id : Optional[int] 84 | The member who should respond to the prompt. Defaults to the author of the Context's message. 85 | 86 | Returns 87 | -------- 88 | Optional[bool] 89 | ``True`` if explicit confirm, 90 | ``False`` if explicit deny, 91 | ``None`` if deny due to timeout 92 | 93 | Taken from R.Danny 94 | """ 95 | fmt = f"{message}\n\nReact with \N{WHITE HEAVY CHECK MARK} to confirm or \N{CROSS MARK} to deny." 96 | 97 | # Ensure we can gather author id 98 | try: 99 | author_id = ( 100 | author_id or self.author.id or self.id 101 | ) # self.id for User/Member 102 | except AttributeError: 103 | if issubclass(type(self), channel.WrappedChannel): 104 | raise RuntimeError( 105 | "Expected author_id when using prompt on a TextChannel" 106 | ) 107 | 108 | author_id = self.id 109 | 110 | msg = await self.send(fmt) 111 | confirm = None 112 | 113 | def check(payload): 114 | nonlocal confirm 115 | if payload.message_id != msg.id or payload.user_id != author_id: 116 | return False 117 | codepoint = str(payload.emoji) 118 | if codepoint == "\N{WHITE HEAVY CHECK MARK}": 119 | confirm = True 120 | return True 121 | elif codepoint == "\N{CROSS MARK}": 122 | confirm = False 123 | return True 124 | return False 125 | 126 | for emoji in ("\N{WHITE HEAVY CHECK MARK}", "\N{CROSS MARK}"): 127 | await msg.add_reaction(emoji) 128 | try: 129 | await self._wrapped_bot.wait_for( 130 | "raw_reaction_add", check=check, timeout=timeout 131 | ) 132 | except asyncio.TimeoutError: 133 | confirm = None 134 | try: 135 | if delete_after: 136 | await msg.delete() 137 | finally: 138 | return confirm 139 | 140 | async def send_basic_embed( 141 | self, 142 | desc: str, 143 | *, 144 | color=None, 145 | target=None, 146 | reply: bool = False, 147 | contain_timestamp: bool = True, 148 | include_command_invoker: bool = True, 149 | **kwargs, 150 | ) -> nextcord.Message: 151 | """Wraps a string to send formatted as an embed""" 152 | from bot_base.context import BotContext 153 | 154 | target = target or ( 155 | self.message # ctx, reply=True 156 | if reply and isinstance(self, BotContext) 157 | else self.channel # ctx, reply=False 158 | if isinstance(self, BotContext) 159 | else self # Anything else (member.send) 160 | ) 161 | 162 | embed = nextcord.Embed(description=desc) 163 | 164 | if color: 165 | embed.colour = color 166 | 167 | if contain_timestamp and isinstance(self, BotContext): 168 | # Doesnt work on Channels, Users, Members 169 | embed.timestamp = self.message.created_at 170 | 171 | if include_command_invoker and not isinstance(self, channel.WrappedChannel): 172 | try: 173 | text = self.author.display_name 174 | icon_url = self.author.avatar.url 175 | except AttributeError: 176 | text = self.display_name 177 | icon_url = self.avatar.url 178 | 179 | embed.set_footer(text=text, icon_url=icon_url) 180 | 181 | if reply and isinstance(target, nextcord.Message): 182 | return await target.reply(embed=embed, **kwargs) 183 | else: 184 | return await target.send(embed=embed, **kwargs) 185 | 186 | async def get_input( 187 | self, 188 | title: str = None, 189 | description: str = None, 190 | *, 191 | timeout: int = 100, 192 | delete_after: bool = True, 193 | author_id=None, 194 | ) -> Optional[str]: 195 | from bot_base.context import BotContext 196 | 197 | if title and not description: 198 | embed = nextcord.Embed( 199 | title=title, 200 | ) 201 | elif not title and description: 202 | embed = nextcord.Embed( 203 | description=description, 204 | ) 205 | elif title and description: 206 | embed = nextcord.Embed( 207 | title=title, 208 | description=description, 209 | ) 210 | else: 211 | raise RuntimeError("Expected at-least title or description") 212 | 213 | sent = await self.send(embed=embed) 214 | val = None 215 | 216 | try: 217 | author_id = ( 218 | author_id or self.author.id or self.id 219 | ) # or self.id for User/Member 220 | except AttributeError: 221 | if issubclass(type(self), channel.WrappedChannel): 222 | raise RuntimeError( 223 | "Expected author_id when using prompt on a TextChannel" 224 | ) 225 | 226 | author_id = self.id 227 | 228 | try: 229 | if issubclass(type(self), channel.WrappedChannel): 230 | check = ( 231 | lambda message: message.author.id == author_id 232 | and message.channel.id == self.id, 233 | ) 234 | elif isinstance(self, BotContext): 235 | if not self.guild: 236 | check = ( 237 | lambda message: message.author.id == author_id 238 | and not message.guild 239 | ) 240 | else: 241 | check = ( 242 | lambda message: message.author.id == author_id 243 | and message.channel.id == self.channel.id 244 | ) 245 | else: 246 | check = ( 247 | lambda message: message.author.id == author_id and not message.guild 248 | ) 249 | 250 | msg = await self._wrapped_bot.wait_for( 251 | "message", timeout=timeout, check=check 252 | ) 253 | 254 | if msg: 255 | val = msg.content 256 | except asyncio.TimeoutError: 257 | if delete_after: 258 | await sent.delete() 259 | 260 | return val 261 | 262 | try: 263 | if delete_after: 264 | await sent.delete() 265 | await msg.delete() 266 | finally: 267 | return val 268 | -------------------------------------------------------------------------------- /bot_base/wraps/thread.py: -------------------------------------------------------------------------------- 1 | try: 2 | import nextcord 3 | from nextcord.ext import commands 4 | except ModuleNotFoundError: 5 | import disnake as nextcord 6 | from disnake.ext import commands 7 | 8 | from bot_base.wraps import Meta 9 | 10 | 11 | class WrappedThread(Meta, nextcord.Thread): 12 | @classmethod 13 | async def convert(cls, ctx, argument: str) -> "WrappedThread": 14 | _meta: nextcord.Thread = await commands.ThreadConverter().convert( 15 | ctx=ctx, argument=argument 16 | ) 17 | return cls(_meta, ctx.bot) 18 | 19 | def __getattr__(self, item): 20 | return getattr(self._wrapped_item, item) 21 | -------------------------------------------------------------------------------- /bot_base/wraps/user.py: -------------------------------------------------------------------------------- 1 | try: 2 | import nextcord 3 | from nextcord.ext import commands 4 | except ModuleNotFoundError: 5 | import disnake as nextcord 6 | from disnake.ext import commands 7 | 8 | from bot_base.wraps.meta import Meta 9 | 10 | 11 | class WrappedUser(Meta, nextcord.User): 12 | """Wraps discord.user for ease of stuff""" 13 | 14 | @classmethod 15 | async def convert(cls, ctx, argument: str) -> "WrappedUser": 16 | user: nextcord.User = await commands.UserConverter().convert( 17 | ctx=ctx, argument=argument 18 | ) 19 | return cls(user, ctx.bot) 20 | 21 | def __getattr__(self, item): 22 | return getattr(self._wrapped_item, item) 23 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = "Discord Bot Base" 10 | copyright = "2022, Skelmis" 11 | author = "Skelmis" 12 | 13 | # -- General configuration --------------------------------------------------- 14 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 15 | 16 | extensions = [ 17 | "sphinx.ext.autodoc", 18 | "sphinx.ext.napoleon", 19 | "sphinx.ext.intersphinx", 20 | ] 21 | 22 | templates_path = ["_templates"] 23 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 24 | 25 | 26 | # -- Options for HTML output ------------------------------------------------- 27 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 28 | 29 | html_theme = "furo" 30 | html_static_path = ["_static"] 31 | 32 | intersphinx_mapping = { 33 | "python": ("https://docs.python.org/3", None), 34 | "nextcord": ("https://docs.nextcord.dev/en/latest/", None), 35 | } 36 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Discord Bot Base's documentation! 2 | ============================================ 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | modules/subclass.rst 9 | 10 | .. toctree:: 11 | :maxdepth: 2 12 | :caption: Objects: 13 | 14 | modules/objects/exceptions.rst 15 | modules/objects/wrapped_objects.rst 16 | modules/objects/cancellable_wait_for.rst 17 | 18 | 19 | 20 | Indices and tables 21 | ================== 22 | 23 | * :ref:`genindex` 24 | * :ref:`modindex` 25 | * :ref:`search` 26 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/modules/objects/cancellable_wait_for.rst: -------------------------------------------------------------------------------- 1 | CancellableWaitFor 2 | ------------------ 3 | 4 | .. currentmodule:: bot_base 5 | 6 | .. autoclass:: CancellableWaitFor 7 | :members: 8 | :undoc-members: 9 | :special-members: __init__ 10 | -------------------------------------------------------------------------------- /docs/modules/objects/context.rst: -------------------------------------------------------------------------------- 1 | Custom Context 2 | -------------- 3 | 4 | .. currentmodule:: bot_base 5 | 6 | .. autoclass:: BotContext 7 | :members: 8 | :undoc-members: 9 | :special-members: __init__ 10 | -------------------------------------------------------------------------------- /docs/modules/objects/exceptions.rst: -------------------------------------------------------------------------------- 1 | Exceptions 2 | ---------- 3 | 4 | .. automodule:: bot_base.exceptions 5 | :members: 6 | :undoc-members: 7 | :special-members: __init__ 8 | -------------------------------------------------------------------------------- /docs/modules/objects/wrapped_objects.rst: -------------------------------------------------------------------------------- 1 | Wrapped Objects 2 | --------------- 3 | 4 | 5 | .. automodule:: bot_base.wraps 6 | :members: 7 | :undoc-members: 8 | :special-members: __init__ 9 | -------------------------------------------------------------------------------- /docs/modules/subclass.rst: -------------------------------------------------------------------------------- 1 | Bot Subclass 2 | ------------ 3 | 4 | .. currentmodule:: bot_base 5 | 6 | .. autoclass:: BotBase 7 | :members: 8 | :undoc-members: 9 | :exclude-members: on_command_error,process_commands 10 | 11 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==5.1.1 2 | furo==2022.6.21 -------------------------------------------------------------------------------- /images/image_one.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Skelmis/Discord-Bot-Base/9b581a8bdfd0378a91bd15ed43d8a1612a8c411a/images/image_one.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## Discord Bot Base 2 | 3 | 4 | A subclass of `commands.Bot` which implements the following features 5 | and reduces the boilerplate between discord bots. 6 | 7 | This supports Nextcord and Disnake. 8 | 9 | [https://pypi.org/project/Bot-Base/](https://pypi.org/project/Bot-Base/) 10 | 11 | Documentation no longer exists and this project is no longer actively developed. It is still actively used though 12 | 13 | Basic embed: 14 | 15 | ![Example image](./images/image_one.png) 16 | 17 | ### Disclaimer 18 | 19 | I am fine with this being used for personal/private usage. 20 | However, I am not fine with other people using this for things such as tutorials, etc, without first contacting me and getting approval. Don't be that guy. 21 | 22 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dnspython==2.2.1 2 | motor==3.0.0 3 | humanize==4.2.0 4 | attrs>=21.4.0 5 | alaric>=1.0.1 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from setuptools import setup 3 | 4 | with open("readme.md", "r") as fh: 5 | long_description = fh.read() 6 | 7 | _version_regex = ( 8 | r"^__version__ = ('|\")((?:[0-9]+\.)*[0-9]+(?:\.?([a-z]+)(?:\.?[0-9])?)?)\1$" 9 | ) 10 | 11 | try: 12 | with open("bot_base/__init__.py") as stream: 13 | match = re.search(_version_regex, stream.read(), re.MULTILINE) 14 | version = match.group(2) 15 | except FileNotFoundError: 16 | version = "0.0.0" 17 | 18 | 19 | def parse_requirements_file(path): 20 | with open(path) as fp: 21 | dependencies = (d.strip() for d in fp.read().split("\n") if d.strip()) 22 | return [d for d in dependencies if not d.startswith("#")] 23 | 24 | 25 | setup( 26 | name="Bot-Base", 27 | version=version, 28 | author="Skelmis", 29 | author_email="ethan@koldfusion.xyz", 30 | description="A simplistic yet feature rich discord bot template.", 31 | long_description=long_description, 32 | long_description_content_type="text/markdown", 33 | url="https://github.com/Skelmis/DPY-Bot-Base", 34 | extras_requires={ 35 | "nextcord": ["nextcord"], 36 | "disnake": ["disnake"], 37 | }, 38 | packages=[ 39 | "bot_base", 40 | "bot_base.db", 41 | "bot_base.blacklist", 42 | "bot_base.wraps", 43 | "bot_base.caches", 44 | "bot_base.converters", 45 | "bot_base.cogs", 46 | "bot_base.paginators", 47 | ], 48 | install_requires=parse_requirements_file("requirements.txt"), 49 | classifiers=[ 50 | "Programming Language :: Python :: 3", 51 | "Operating System :: OS Independent", 52 | "Development Status :: 5 - Production/Stable", 53 | ], 54 | python_requires=">=3.8", 55 | ) 56 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Skelmis/Discord-Bot-Base/9b581a8bdfd0378a91bd15ed43d8a1612a8c411a/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bot_base.caches import TimedCache 4 | 5 | 6 | @pytest.fixture 7 | def create_timed_cache() -> TimedCache: 8 | return TimedCache() 9 | -------------------------------------------------------------------------------- /tests/test_timed_cache.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import timedelta 3 | 4 | import pytest 5 | 6 | from bot_base import NonExistentEntry, ExistingEntry 7 | 8 | 9 | def test_cache_add(create_timed_cache): 10 | assert not create_timed_cache.cache 11 | 12 | create_timed_cache.add_entry("key", "value") 13 | assert create_timed_cache.cache 14 | 15 | with pytest.raises(ExistingEntry): 16 | create_timed_cache.add_entry("key", "different value") 17 | 18 | create_timed_cache.add_entry("key", "A third value", override=True) 19 | 20 | 21 | def test_delete_entry(create_timed_cache): 22 | create_timed_cache.add_entry("key", "value") 23 | assert "key" in create_timed_cache.cache 24 | 25 | create_timed_cache.delete_entry("key") 26 | assert "key" not in create_timed_cache.cache 27 | 28 | # Idempotent 29 | create_timed_cache.delete_entry("key") 30 | 31 | 32 | def test_get_entry(create_timed_cache): 33 | create_timed_cache.add_entry("key", "value") 34 | assert "key" in create_timed_cache.cache 35 | 36 | r_1 = create_timed_cache.get_entry("key") 37 | assert r_1 == "value" 38 | 39 | with pytest.raises(NonExistentEntry): 40 | create_timed_cache.get_entry("key_2") 41 | 42 | 43 | def test_contains(create_timed_cache): 44 | assert "key" not in create_timed_cache 45 | 46 | create_timed_cache.add_entry("key", "value") 47 | 48 | assert "key" in create_timed_cache 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_eviction(create_timed_cache): 53 | create_timed_cache.add_entry("key", "value", ttl=timedelta(seconds=1)) 54 | assert "key" in create_timed_cache 55 | assert create_timed_cache.cache 56 | await asyncio.sleep(1.25) 57 | assert "key" not in create_timed_cache 58 | assert not create_timed_cache.cache 59 | 60 | 61 | @pytest.mark.asyncio 62 | async def test_force_clean(create_timed_cache): 63 | create_timed_cache.add_entry("key", "value", ttl=timedelta(seconds=1)) 64 | create_timed_cache.add_entry( 65 | "key_2", 66 | "value", 67 | ) 68 | assert "key" in create_timed_cache 69 | assert "key_2" in create_timed_cache 70 | 71 | await asyncio.sleep(1.25) 72 | 73 | create_timed_cache.force_clean() 74 | assert "key" not in create_timed_cache 75 | assert "key_2" in create_timed_cache 76 | 77 | 78 | @pytest.mark.asyncio 79 | async def test_non_lazy(create_timed_cache): 80 | create_timed_cache.non_lazy = True 81 | 82 | create_timed_cache.add_entry(1, 2, ttl=timedelta(seconds=0.5)) 83 | assert 1 in create_timed_cache.cache 84 | 85 | await asyncio.sleep(0.75) 86 | assert 1 in create_timed_cache.cache 87 | create_timed_cache.add_entry(2, 2) 88 | assert 1 not in create_timed_cache.cache 89 | --------------------------------------------------------------------------------