Initial Template

This commit is contained in:
2025-08-01 02:12:27 +10:00
parent 42af454864
commit 230ea88d23
11 changed files with 83 additions and 438 deletions

9
config/emojis.conf Normal file
View File

@@ -0,0 +1,9 @@
[EMOJIS]
tick = :✅:
clock = :⏱️:
warning = :⚠️:
config = :⚙️:
stats = :📊:
utility = :⏱️:
cancel = :❌:

View File

@@ -1,10 +1,12 @@
-- Metadata {{{ -- Metadata {{{
CREATE TABLE VersionHistory( CREATE TABLE version_history(
version INTEGER NOT NULL, component TEXT NOT NULL,
time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, from_version INTEGER NOT NULL,
author TEXT 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() CREATE OR REPLACE FUNCTION update_timestamp_column()
@@ -31,76 +33,6 @@ CREATE TABLE bot_config(
); );
-- }}} -- }}}
-- Channel Linker {{{ -- TODO: Profile data
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
);
-- }}}
-- vim: set fdm=marker: -- vim: set fdm=marker:

View File

@@ -1,8 +1,7 @@
aiohttp==3.7.4.post0 aiohttp
cachetools==4.2.2 cachetools
configparser==5.0.2 configparser
discord.py [voice] discord.py [voice]
iso8601==0.1.16 iso8601
psycopg[pool] psycopg[pool]
pytz==2021.1 pytz
twitchAPI

View File

@@ -13,8 +13,6 @@ from meta.monitor import ComponentMonitor, StatusLevel, ComponentStatus
from data import Database from data import Database
from constants import DATA_VERSION
for name in conf.config.options('LOGGING_LEVELS', no_defaults=True): for name in conf.config.options('LOGGING_LEVELS', no_defaults=True):
logging.getLogger(name).setLevel(conf.logging_levels[name]) logging.getLogger(name).setLevel(conf.logging_levels[name])
@@ -57,15 +55,10 @@ async def main():
intents.presences = False intents.presences = False
async with db.open(): 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 aiohttp.ClientSession() as session:
async with LionBot( async with LionBot(
command_prefix='!leo!', command_prefix=conf.bot.get('prefix', '!!'),
intents=intents, intents=intents,
appname=appname, appname=appname,
shardname=shardname, shardname=shardname,
@@ -81,7 +74,7 @@ async def main():
shard_count=sharding.shard_count, shard_count=sharding.shard_count,
help_command=None, help_command=None,
proxy=conf.bot.get('proxy', None), proxy=conf.bot.get('proxy', None),
chunk_guilds_at_startup=False, chunk_guilds_at_startup=True,
) as lionbot: ) as lionbot:
ctx_bot.set(lionbot) ctx_bot.set(lionbot)
lionbot.system_monitor.add_component( lionbot.system_monitor.add_component(

26
src/botdata.py Normal file
View File

@@ -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

View File

@@ -1,6 +1,7 @@
CONFIG_FILE = "config/bot.conf" 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" HINT_ICON = "https://projects.iamcal.com/emoji-data/img-apple-64/1f4a1.png"
SCHEMA_VERSIONS = {
'ROOT': 1,
}

View File

@@ -3,6 +3,7 @@ import logging
import asyncio import asyncio
from weakref import WeakValueDictionary from weakref import WeakValueDictionary
from constants import SCHEMA_VERSIONS
import discord import discord
from discord.utils import MISSING from discord.utils import MISSING
from discord.ext.commands import Bot, Cog, HybridCommand, HybridCommandError from discord.ext.commands import Bot, Cog, HybridCommand, HybridCommandError
@@ -13,6 +14,7 @@ from aiohttp import ClientSession
from data import Database from data import Database
from utils.lib import tabulate from utils.lib import tabulate
from babel.translator import LeoBabel from babel.translator import LeoBabel
from botdata import BotData, VersionHistory
from .config import Conf from .config import Conf
from .logger import logging_context, log_context, log_action_stack, log_wrap, set_logging_context 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.appname = appname
self.shardname = shardname self.shardname = shardname
# self.appdata = appdata # self.appdata = appdata
self.data: BotData = db.load_registry(BotData())
self.config = config self.config = config
self.translator = LeoBabel() self.translator = LeoBabel()
@@ -53,6 +56,10 @@ class LionBot(Bot):
self._locks = WeakValueDictionary() self._locks = WeakValueDictionary()
self._running_events = set() self._running_events = set()
@property
def dbconn(self):
return self.db
@property @property
def core(self): def core(self):
return self.get_cog('CoreCog') return self.get_cog('CoreCog')
@@ -129,6 +136,10 @@ class LionBot(Bot):
await wrapper() await wrapper()
async def start(self, token: str, *, reconnect: bool = True): 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"): with logging_context(action="Login"):
start_task = asyncio.create_task(self.login(token)) start_task = asyncio.create_task(self.login(token))
await start_task await start_task
@@ -137,6 +148,24 @@ class LionBot(Bot):
run_task = asyncio.create_task(self.connect(reconnect=reconnect)) run_task = asyncio.create_task(self.connect(reconnect=reconnect))
await run_task 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): def dispatch(self, event_name: str, *args, **kwargs):
with logging_context(action=f"Dispatch {event_name}"): with logging_context(action=f"Dispatch {event_name}"):
super().dispatch(event_name, *args, **kwargs) super().dispatch(event_name, *args, **kwargs)

View File

@@ -1,8 +1,6 @@
this_package = 'modules' this_package = 'modules'
active = [ active = [
'.sysadmin',
'.voicefix',
] ]

View File

@@ -1,5 +0,0 @@
async def setup(bot):
from .exec_cog import Exec
await bot.add_cog(Exec(bot))

View File

@@ -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"<msg: {ctx.message.id}>"
elif ctx and ctx.interaction:
source_str = f"<iid: {ctx.interaction.id}>"
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()