diff --git a/config/emojis.conf b/config/emojis.conf new file mode 100644 index 0000000..7373fb2 --- /dev/null +++ b/config/emojis.conf @@ -0,0 +1,9 @@ +[EMOJIS] + +tick = :✅: +clock = :⏱️: +warning = :⚠️: +config = :⚙️: +stats = :📊: +utility = :⏱️: +cancel = :❌: diff --git a/data/schema.sql b/data/schema.sql index 74e0b84..52c49c5 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -1,10 +1,12 @@ -- Metadata {{{ -CREATE TABLE VersionHistory( - version INTEGER NOT NULL, - time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, - author TEXT +CREATE TABLE version_history( + component TEXT NOT NULL, + from_version INTEGER NOT NULL, + to_version INTEGER NOT NULL, + author TEXT NOT NULL, + _timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), ); -INSERT INTO VersionHistory (version, author) VALUES (1, 'Initial Creation'); +INSERT INTO version_history (component, from_version, to_version, author) VALUES ('ROOT', 0, 1, 'Initial Creation'); CREATE OR REPLACE FUNCTION update_timestamp_column() @@ -31,76 +33,6 @@ CREATE TABLE bot_config( ); -- }}} --- Channel Linker {{{ - -CREATE TABLE links( - linkid SERIAL PRIMARY KEY, - name TEXT -); - -CREATE TABLE channel_webhooks( - channelid BIGINT PRIMARY KEY, - webhookid BIGINT NOT NULL, - token TEXT NOT NULL -); - -CREATE TABLE channel_links( - linkid INTEGER NOT NULL REFERENCES links (linkid) ON DELETE CASCADE, - channelid BIGINT NOT NULL REFERENCES channel_webhooks (channelid) ON DELETE CASCADE, - PRIMARY KEY (linkid, channelid) -); - - --- }}} - --- Stream Alerts {{{ - --- DROP TABLE IF EXISTS stream_alerts; --- DROP TABLE IF EXISTS streams; --- DROP TABLE IF EXISTS alert_channels; --- DROP TABLE IF EXISTS streamers; - -CREATE TABLE streamers( - userid BIGINT PRIMARY KEY, - login_name TEXT NOT NULL, - display_name TEXT NOT NULL -); - -CREATE TABLE alert_channels( - subscriptionid SERIAL PRIMARY KEY, - guildid BIGINT NOT NULL, - channelid BIGINT NOT NULL, - streamerid BIGINT NOT NULL REFERENCES streamers (userid) ON DELETE CASCADE, - created_by BIGINT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - paused BOOLEAN NOT NULL DEFAULT FALSE, - end_delete BOOLEAN NOT NULL DEFAULT FALSE, - live_message TEXT, - end_message TEXT -); -CREATE INDEX alert_channels_guilds ON alert_channels (guildid); -CREATE UNIQUE INDEX alert_channels_channelid_streamerid ON alert_channels (channelid, streamerid); - -CREATE TABLE streams( - streamid SERIAL PRIMARY KEY, - streamerid BIGINT NOT NULL REFERENCES streamers (userid) ON DELETE CASCADE, - start_at TIMESTAMPTZ NOT NULL, - twitch_stream_id BIGINT, - game_name TEXT, - title TEXT, - end_at TIMESTAMPTZ -); - -CREATE TABLE stream_alerts( - alertid SERIAL PRIMARY KEY, - streamid INTEGER NOT NULL REFERENCES streams (streamid) ON DELETE CASCADE, - subscriptionid INTEGER NOT NULL REFERENCES alert_channels (subscriptionid) ON DELETE CASCADE, - sent_at TIMESTAMPTZ NOT NULL, - messageid BIGINT NOT NULL, - resolved_at TIMESTAMPTZ -); - - --- }}} +-- TODO: Profile data -- vim: set fdm=marker: diff --git a/requirements.txt b/requirements.txt index 402aaf8..15f10a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,7 @@ -aiohttp==3.7.4.post0 -cachetools==4.2.2 -configparser==5.0.2 +aiohttp +cachetools +configparser discord.py [voice] -iso8601==0.1.16 +iso8601 psycopg[pool] -pytz==2021.1 -twitchAPI +pytz diff --git a/src/bot.py b/src/bot.py index 0b61a61..2fa4221 100644 --- a/src/bot.py +++ b/src/bot.py @@ -13,8 +13,6 @@ from meta.monitor import ComponentMonitor, StatusLevel, ComponentStatus from data import Database -from constants import DATA_VERSION - for name in conf.config.options('LOGGING_LEVELS', no_defaults=True): logging.getLogger(name).setLevel(conf.logging_levels[name]) @@ -57,15 +55,10 @@ async def main(): intents.presences = False async with db.open(): - version = await db.version() - if version.version != DATA_VERSION: - error = f"Data model version is {version}, required version is {DATA_VERSION}! Please migrate." - logger.critical(error) - raise RuntimeError(error) async with aiohttp.ClientSession() as session: async with LionBot( - command_prefix='!leo!', + command_prefix=conf.bot.get('prefix', '!!'), intents=intents, appname=appname, shardname=shardname, @@ -81,7 +74,7 @@ async def main(): shard_count=sharding.shard_count, help_command=None, proxy=conf.bot.get('proxy', None), - chunk_guilds_at_startup=False, + chunk_guilds_at_startup=True, ) as lionbot: ctx_bot.set(lionbot) lionbot.system_monitor.add_component( diff --git a/src/botdata.py b/src/botdata.py new file mode 100644 index 0000000..025e44d --- /dev/null +++ b/src/botdata.py @@ -0,0 +1,26 @@ +from data import Registry, RowModel, Table +from data.columns import String, Timestamp, Integer, Bool + + +class VersionHistory(RowModel): + """ + CREATE TABLE version_history( + component TEXT NOT NULL, + from_version INTEGER NOT NULL, + to_version INTEGER NOT NULL, + author TEXT NOT NULL, + _timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ); + """ + _tablename_ = 'version_history' + _cache_ = {} + + component = String() + from_version = Integer() + to_version = Integer() + author = String() + _timestamp = Timestamp() + + +class BotData(Registry): + version_history = VersionHistory.table diff --git a/src/constants.py b/src/constants.py index 3a34de6..ad63a2c 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,6 +1,7 @@ CONFIG_FILE = "config/bot.conf" -DATA_VERSION = 1 - -MAX_COINS = 2147483647 - 1 HINT_ICON = "https://projects.iamcal.com/emoji-data/img-apple-64/1f4a1.png" + +SCHEMA_VERSIONS = { + 'ROOT': 1, +} diff --git a/src/meta/LionBot.py b/src/meta/LionBot.py index fed52d8..d31065e 100644 --- a/src/meta/LionBot.py +++ b/src/meta/LionBot.py @@ -3,6 +3,7 @@ import logging import asyncio from weakref import WeakValueDictionary +from constants import SCHEMA_VERSIONS import discord from discord.utils import MISSING from discord.ext.commands import Bot, Cog, HybridCommand, HybridCommandError @@ -13,6 +14,7 @@ from aiohttp import ClientSession from data import Database from utils.lib import tabulate from babel.translator import LeoBabel +from botdata import BotData, VersionHistory from .config import Conf from .logger import logging_context, log_context, log_action_stack, log_wrap, set_logging_context @@ -43,6 +45,7 @@ class LionBot(Bot): self.appname = appname self.shardname = shardname # self.appdata = appdata + self.data: BotData = db.load_registry(BotData()) self.config = config self.translator = LeoBabel() @@ -53,6 +56,10 @@ class LionBot(Bot): self._locks = WeakValueDictionary() self._running_events = set() + @property + def dbconn(self): + return self.db + @property def core(self): return self.get_cog('CoreCog') @@ -129,6 +136,10 @@ class LionBot(Bot): await wrapper() async def start(self, token: str, *, reconnect: bool = True): + await self.data.init() + for component, req in SCHEMA_VERSIONS.items(): + await self.version_check(component, req) + with logging_context(action="Login"): start_task = asyncio.create_task(self.login(token)) await start_task @@ -137,6 +148,24 @@ class LionBot(Bot): run_task = asyncio.create_task(self.connect(reconnect=reconnect)) await run_task + async def version_check(self, component: str, req_version: int): + # Query the database to confirm that the given component is listed with the given version. + # Typically done upon loading a component + rows = await VersionHistory.fetch_where(component=component).order_by('_timestamp', ORDER.DESC).limit(1) + + version = rows[0].to_version if rows else 0 + + if version != req_version: + raise ValueError(f"Component {component} failed version check. Has version '{version}', required version '{req_version}'") + else: + logger.debug( + "Component %s passed version check with version %s", + component, + version + ) + return True + + def dispatch(self, event_name: str, *args, **kwargs): with logging_context(action=f"Dispatch {event_name}"): super().dispatch(event_name, *args, **kwargs) diff --git a/src/modules/__init__.py b/src/modules/__init__.py index 099c81b..07e226e 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -1,8 +1,6 @@ this_package = 'modules' active = [ - '.sysadmin', - '.voicefix', ] diff --git a/src/modules/sysadmin/__init__.py b/src/modules/sysadmin/__init__.py deleted file mode 100644 index 4a96ce6..0000000 --- a/src/modules/sysadmin/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ - -async def setup(bot): - from .exec_cog import Exec - - await bot.add_cog(Exec(bot)) diff --git a/src/modules/sysadmin/exec_cog.py b/src/modules/sysadmin/exec_cog.py deleted file mode 100644 index 776292a..0000000 --- a/src/modules/sysadmin/exec_cog.py +++ /dev/null @@ -1,336 +0,0 @@ -import io -import ast -import sys -import types -import asyncio -import traceback -import builtins -import inspect -import logging -from io import StringIO - -from typing import Callable, Any, Optional - -from enum import Enum - -import discord -from discord.ext import commands -from discord.ext.commands.errors import CheckFailure -from discord.ui import TextInput, View -from discord.ui.button import button -import discord.app_commands as appcmd - -from meta.logger import logging_context, log_wrap -from meta import conf -from meta.context import context, ctx_bot -from meta.LionContext import LionContext -from meta.LionCog import LionCog -from meta.LionBot import LionBot - -from utils.ui import FastModal, input - -from wards import sys_admin - - -def _(arg): return arg - -def _p(ctx, arg): return arg - - -logger = logging.getLogger(__name__) - -class ExecModal(FastModal, title="Execute"): - code: TextInput = TextInput( - label="Code to execute", - style=discord.TextStyle.long, - required=True - ) - - -class ExecStyle(Enum): - EXEC = 'exec' - EVAL = 'eval' - - -class ExecUI(View): - def __init__(self, ctx, code=None, style=ExecStyle.EXEC, ephemeral=True) -> None: - super().__init__() - - self.ctx: LionContext = ctx - self.interaction: Optional[discord.Interaction] = ctx.interaction - self.code: Optional[str] = code - self.style: ExecStyle = style - self.ephemeral: bool = ephemeral - - self._modal: Optional[ExecModal] = None - self._msg: Optional[discord.Message] = None - - async def interaction_check(self, interaction: discord.Interaction): - """Only allow the original author to use this View""" - if interaction.user.id != self.ctx.author.id: - await interaction.response.send_message( - ("You cannot use this interface!"), - ephemeral=True - ) - return False - else: - return True - - async def run(self): - if self.code is None: - if (interaction := self.interaction) is not None: - self.interaction = None - await interaction.response.send_modal(self.get_modal()) - await self.wait() - else: - # Complain - # TODO: error_reply - await self.ctx.reply("Pls give code.") - else: - await self.interaction.response.defer(thinking=True, ephemeral=self.ephemeral) - await self.compile() - await self.wait() - - @button(label="Recompile") - async def recompile_button(self, interaction, butt): - # Interaction response with modal - await interaction.response.send_modal(self.get_modal()) - - @button(label="Show Source") - async def source_button(self, interaction, butt): - if len(self.code) > 1900: - # Send as file - with StringIO(self.code) as fp: - fp.seek(0) - file = discord.File(fp, filename="source.py") - await interaction.response.send_message(file=file, ephemeral=True) - else: - # Send as message - await interaction.response.send_message( - content=f"```py\n{self.code}```", - ephemeral=True - ) - - def create_modal(self) -> ExecModal: - modal = ExecModal() - - @modal.submit_callback() - async def exec_submit(interaction: discord.Interaction): - if self.interaction is None: - self.interaction = interaction - await interaction.response.defer(thinking=True) - else: - await interaction.response.defer() - - # Set code - self.code = modal.code.value - - # Call compile - await self.compile() - - return modal - - def get_modal(self): - self._modal = self.create_modal() - self._modal.code.default = self.code - return self._modal - - async def compile(self): - # Call _async - result = await _async(self.code, style=self.style.value) - - # Display output - await self.show_output(result) - - async def show_output(self, output): - # Format output - # If output message exists and not ephemeral, edit - # Otherwise, send message, add buttons - if len(output) > 1900: - # Send as file - with StringIO(output) as fp: - fp.seek(0) - args = { - 'content': None, - 'attachments': [discord.File(fp, filename="output.md")] - } - else: - args = { - 'content': f"```md\n{output}```", - 'attachments': [] - } - - if self._msg is None: - if self.interaction is not None: - msg = await self.interaction.edit_original_response(**args, view=self) - else: - # Send new message - if args['content'] is None: - args['file'] = args.pop('attachments')[0] - msg = await self.ctx.reply(**args, ephemeral=self.ephemeral, view=self) - - if not self.ephemeral: - self._msg = msg - else: - if self.interaction is not None: - await self.interaction.edit_original_response(**args, view=self) - else: - # Edit message - await self._msg.edit(**args) - - -def mk_print(fp: io.StringIO) -> Callable[..., None]: - def _print(*args, file: Any = fp, **kwargs): - return print(*args, file=file, **kwargs) - return _print - - -def mk_status_printer(bot, printer): - async def _status(details=False): - if details: - status = await bot.system_monitor.get_overview() - else: - status = await bot.system_monitor.get_summary() - printer(status) - return status - return _status - - -@log_wrap(action="Code Exec") -async def _async(to_eval: str, style='exec'): - newline = '\n' * ('\n' in to_eval) - logger.info( - f"Exec code with {style}: {newline}{to_eval}" - ) - - output = io.StringIO() - _print = mk_print(output) - - scope: dict[str, Any] = dict(sys.modules) - scope['__builtins__'] = builtins - scope.update(builtins.__dict__) - scope['ctx'] = ctx = context.get() - scope['bot'] = ctx_bot.get() - scope['print'] = _print # type: ignore - scope['print_status'] = mk_status_printer(scope['bot'], _print) - - try: - if ctx and ctx.message: - source_str = f"" - elif ctx and ctx.interaction: - source_str = f"" - else: - source_str = "Unknown async" - - code = compile( - to_eval, - source_str, - style, - ast.PyCF_ALLOW_TOP_LEVEL_AWAIT - ) - func = types.FunctionType(code, scope) - - ret = func() - if inspect.iscoroutine(ret): - ret = await ret - if ret is not None: - _print(repr(ret)) - except Exception: - _, exc, tb = sys.exc_info() - _print("".join(traceback.format_tb(tb))) - _print(f"{type(exc).__name__}: {exc}") - - result = output.getvalue().strip() - newline = '\n' * ('\n' in result) - logger.info( - f"Exec complete, output: {newline}{result}" - ) - return result - - -class Exec(LionCog): - guild_ids = conf.bot.getintlist('admin_guilds') - - def __init__(self, bot: LionBot): - self.bot = bot - - async def cog_check(self, ctx: LionContext) -> bool: # type: ignore - passed = await sys_admin(ctx.bot, ctx.author.id) - if passed: - return True - else: - raise CheckFailure( - "You must be a bot owner to do this!" - ) - - @commands.hybrid_command( - name=_('async'), - description=_("Execute arbitrary code with Exec") - ) - @appcmd.describe( - string="Code to execute.", - ) - async def async_cmd(self, ctx: LionContext, - string: Optional[str] = None, - ): - await ExecUI(ctx, string, ExecStyle.EXEC, ephemeral=False).run() - - @commands.hybrid_command( - name=_('reload'), - description=_("Reload a given LionBot extension. Launches an ExecUI.") - ) - @appcmd.describe( - extension=_("Name of the extension to reload. See autocomplete for options."), - force=_("Whether to force an extension reload even if it doesn't exist.") - ) - @appcmd.guilds(*guild_ids) - async def reload_cmd(self, ctx: LionContext, extension: str, force: Optional[bool] = False): - """ - This is essentially just a friendly wrapper to reload an extension. - It is equivalent to running "await bot.reload_extension(extension)" in eval, - with a slightly nicer interface through the autocomplete and error handling. - """ - exists = (extension in self.bot.extensions) - if not (force or exists): - embed = discord.Embed(description=f"Unknown extension {extension}", colour=discord.Colour.red()) - await ctx.reply(embed=embed) - else: - # Uses an ExecUI to simplify error handling and re-execution - if exists: - string = f"await bot.reload_extension('{extension}')" - else: - string = f"await bot.load_extension('{extension}')" - await ExecUI(ctx, string, ExecStyle.EVAL).run() - - @reload_cmd.autocomplete('extension') - async def reload_extension_acmpl(self, interaction: discord.Interaction, partial: str): - keys = set(self.bot.extensions.keys()) - results = [ - appcmd.Choice(name=key, value=key) - for key in keys - if partial.lower() in key.lower() - ] - if not results: - results = [ - appcmd.Choice(name=f"No extensions found matching {partial}", value="None") - ] - return results[:25] - - @commands.hybrid_command( - name=_('shutdown'), - description=_("Shutdown (or restart) the client.") - ) - @appcmd.guilds(*guild_ids) - async def shutdown_cmd(self, ctx: LionContext): - """ - Shutdown the client. - Maybe do something friendly here? - """ - logger.info("Shutting down on admin request.") - await ctx.reply( - embed=discord.Embed( - description=f"Understood {ctx.author.mention}, cleaning up and shutting down!", - colour=discord.Colour.orange() - ) - ) - await self.bot.close() diff --git a/src/modules/voicefix b/src/modules/voicefix deleted file mode 160000 index d1297ae..0000000 --- a/src/modules/voicefix +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d1297ae986bf862a7075eeb68e8e2151de48bf18