From ad133b60382f4ab252002df4e95451e3fbf36bea Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Fri, 12 Jun 2026 08:39:35 +0930 Subject: [PATCH 01/21] remove useless whitelabel part --- erm.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/erm.py b/erm.py index 4eb5bb8..fb3adfb 100644 --- a/erm.py +++ b/erm.py @@ -310,9 +310,6 @@ async def setup_hook(self) -> None: -if config("ENVIRONMENT") == "CUSTOM": - Bot.__bases__ = (commands.Bot,) - bot = Bot( command_prefix=get_prefix, case_insensitive=True, From c1e6ca346e05c1b53ea1e574050260029dfff40e Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Fri, 12 Jun 2026 08:44:21 +0930 Subject: [PATCH 02/21] remove tons of whitelabel componentry from erm.py --- erm.py | 68 +--------------------------------------------------------- 1 file changed, 1 insertion(+), 67 deletions(-) diff --git a/erm.py b/erm.py index fb3adfb..9552b64 100644 --- a/erm.py +++ b/erm.py @@ -94,7 +94,7 @@ async def rate_limited_fetch(coro, endpoint_type="default"): raise setup = False -accepted_envs = ["PRODUCTION", "DEVELOPMENT", "ALPHA", "CUSTOM"] +accepted_envs = ["PRODUCTION", "DEVELOPMENT", "ALPHA"] sentry_url = config("SENTRY_URL", "") @@ -339,38 +339,7 @@ def running(): @bot.before_invoke async def AutoDefer(ctx: commands.Context): - if ( - environment == "CUSTOM" - and config("CUSTOM_GUILD_ID", default="0") != "0" - and not getattr(ctx.bot, "whitelist_disabled", False) - ): - if ctx.guild.id != int(config("CUSTOM_GUILD_ID")): - if ctx.interaction: - await ctx.interaction.response.send_message( - embed=discord.Embed( - title="Not Permitted", - description="This bot is not permitted to be used in this server. You can change this in the **Whitelabel Bot Dashboard**.", - color=BLANK_COLOR, - ), - ephemeral=True, - ) - raise Exception(f"Guild not permitted to use this bot: {ctx.guild.id}") - guild_id = ctx.guild.id - if (environment != "CUSTOM" or int(config("CUSTOM_GUILD_ID", default="0")) != guild_id) and await has_whitelabel(bot, guild_id): - if "jishaku" in ctx.command.qualified_name: - return - if ctx.interaction: - await ctx.interaction.response.send_message( - embed=discord.Embed( - title="Not Permitted", - description="There is a whitelabel bot already in this server.", - color=BLANK_COLOR, - ), - ephemeral=True, - ) - raise Exception("Whitelabel bot already in use") - bot.internal_command_storage[ctx.message.id] = datetime.datetime.now(tz=pytz.UTC).timestamp() if ctx.command: if ctx.command.extras.get("ephemeral") is True: @@ -405,41 +374,6 @@ async def loggingCommandExecution(ctx: commands.Context): "Command could not be found in internal context storage. Please report." ) - -@bot.event -async def on_message( - message, -): # DO NOT COG - - if not message.guild: - return await bot.process_commands(message) - - if ( - environment == "CUSTOM" - and config("CUSTOM_GUILD_ID", default=None) != 0 - and not getattr(bot, "whitelist_disabled", False) - ): - if message.guild.id != int(config("CUSTOM_GUILD_ID")): - ctx = await bot.get_context(message) - if ctx.command is not None: - await message.reply( - embed=discord.Embed( - title="Not Permitted", - description="This bot is not permitted to be used in this server. You can change this in the **Whitelabel Bot Dashboard**.", - color=BLANK_COLOR, - ) - ) - return - - if environment == "PRODUCTION" and await bot.whitelabel.db.find_one({"GuildID": str(message.guild.id)}) is not None: - return - - await bot.process_commands(message) - - -client = roblox.Client() - - async def staff_check(bot_obj, guild, member): guild_settings = await bot_obj.settings.find_by_id(guild.id) member_role_ids = [r.id for r in member.roles] From 13f1d48529c1c74ea63ac3f9662c6155826c1239 Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Fri, 12 Jun 2026 08:51:39 +0930 Subject: [PATCH 03/21] remove a fuck ton of whitelabel references --- .env.template | 3 +-- AGENTS.md | 1 - cogs/Search.py | 10 ++++------ documentation/architecture.md | 2 -- erm.py | 16 +--------------- events/on_message.py | 2 -- tasks/check_reminders.py | 7 ------- tasks/iterate_ics.py | 2 +- tasks/iterate_prc_logs.py | 25 +++---------------------- tasks/sync_weather.py | 7 ++----- utils/api.py | 3 +-- 11 files changed, 13 insertions(+), 65 deletions(-) diff --git a/.env.template b/.env.template index d6b08e0..2b5b909 100644 --- a/.env.template +++ b/.env.template @@ -7,8 +7,7 @@ SENTRY_URL= PRODUCTION_BOT_TOKEN= DEVELOPMENT_BOT_TOKEN= BLOXLINK_API_KEY= -# DO NOT CHANGE, EVEN IF YOU'RE SELF HOSTING -CUSTOM_GUILD_ID=0 + # If you want to use a custom DB name (instead of `erm`), uncomment this and enable it # DB_NAME= diff --git a/AGENTS.md b/AGENTS.md index 0eee776..b5f3fd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,6 @@ Per [documentation/coding-assistants.md](documentation/coding-assistants.md): ## Gotchas -- `CUSTOM_GUILD_ID` must remain `0` — do not change - `DB_NAME` defaults to `erm`; uncomment in `.env` only if needed - `GITHUB_TOKEN` is reserved for future use, not used by the bot - Database schema reference: [documentation/database-schema.md](documentation/database-schema.md) diff --git a/cogs/Search.py b/cogs/Search.py index ea38109..f8391a8 100644 --- a/cogs/Search.py +++ b/cogs/Search.py @@ -101,7 +101,7 @@ async def mywarnings( roblox_id=roblox_player.id ) - if member and bot.environment != "CUSTOM": + if member: try: discord_member = await guild.fetch_member(member.discord_id) except discord.NotFound: @@ -113,8 +113,7 @@ async def mywarnings( for role in discord_member.roles if role.id in magic_flags_reverse ) - elif member and bot.environment == "CUSTOM": - applied_flags.update(["ERM Staff"]) + applied_flags = list(applied_flags) if ( @@ -356,7 +355,7 @@ async def search(self, ctx, *, query): roblox_id=roblox_player.id ) - if member and bot.environment != "CUSTOM": + if member: try: discord_member = await guild.fetch_member(member.discord_id) except discord.NotFound: @@ -368,8 +367,7 @@ async def search(self, ctx, *, query): for role in discord_member.roles if role.id in magic_flags_reverse ) - elif member and bot.environment == "CUSTOM": - applied_flags.update(["ERM Staff"]) + applied_flags = list(applied_flags) if ( diff --git a/documentation/architecture.md b/documentation/architecture.md index 96d91ef..4ad1531 100644 --- a/documentation/architecture.md +++ b/documentation/architecture.md @@ -106,8 +106,6 @@ staggered 30-second delay between each to avoid startup load spikes. `REMINDERS_ENABLED` and `ACTIONS_ENABLED` respectively and will not start if those are set to `FALSE`. -`change_status` does not run when `ENVIRONMENT=CUSTOM`. - --- ## Data Layer diff --git a/erm.py b/erm.py index 9552b64..88a7276 100644 --- a/erm.py +++ b/erm.py @@ -141,9 +141,6 @@ async def is_owner(self, user: discord.User): # Else fall back to the original if user.id == 1394817794427846737: return True - - if environment != "CUSTOM": # let's not allow custom bot owners to use jishaku lol - return await super().is_owner(user) else: return False @@ -208,13 +205,6 @@ async def setup_hook(self) -> None: self.accounts = Accounts(self) - if environment == "CUSTOM": - doc = await self.whitelabel.db.find_one({"GuildID": config("CUSTOM_GUILD_ID", default="0")}) - if not doc: - raise Exception( - "Custom guild ID not found in the database. This means the whitelabel subscription is overdue." - ) - self.roblox = roblox.Client() self.prc_api = PRCApiClient( self, @@ -279,11 +269,7 @@ async def setup_hook(self) -> None: if environment == "DEVELOPMENT": pass # await bot.tree.sync(guild=discord.Object(id=987798554972143728)) - elif environment == "CUSTOM": - await self.tree.sync() - # Prevent auto syncing - # await bot.tree.sync() - # guild specific: leave blank if global (global registration can take 1-24 hours) + bot.is_synced = True self.saved_latencies = { "shards": [], diff --git a/events/on_message.py b/events/on_message.py index e10a171..1036795 100644 --- a/events/on_message.py +++ b/events/on_message.py @@ -56,8 +56,6 @@ async def on_message(self, message: discord.Message): if not message.guild: return - if await has_whitelabel(bot, message.guild.id) and (bot.environment != "CUSTOM" or int(config("CUSTOM_GUILD_ID", default="0")) != message.guild.id): - return if not hasattr(bot, "settings"): return diff --git a/tasks/check_reminders.py b/tasks/check_reminders.py index 1cc842a..73efb46 100644 --- a/tasks/check_reminders.py +++ b/tasks/check_reminders.py @@ -145,13 +145,6 @@ async def iterate_reminder(bot, guild_obj): @tasks.loop(minutes=1) async def check_reminders(bot): query = {} - if bot.environment != "PRODUCTION": - try: - query = {"_id": int(config("CUSTOM_GUILD_ID"))} - except Exception as e: - logging.warning(f"Reminder task failed: {e}") - return - try: for guild_obj in await bot.reminders.db.find(query).to_list(None): try: diff --git a/tasks/iterate_ics.py b/tasks/iterate_ics.py index 6342dc9..aa0ac1a 100644 --- a/tasks/iterate_ics.py +++ b/tasks/iterate_ics.py @@ -12,7 +12,7 @@ async def iterate_ics(bot): # This will aim to constantly update the Integration Command Storage # and the relevant storage data. - async for item in bot.ics.db.find({} if bot.environment in ["PRODUCTION", "ALPHA", "DEVELOPMENT"] else {"guild": config("CUSTOM_GUILD_ID")}): + async for item in bot.ics.db.find({}): guild = bot.get_guild(item["guild"]) if not guild: diff --git a/tasks/iterate_prc_logs.py b/tasks/iterate_prc_logs.py index 7f1f7c8..5e12ca9 100644 --- a/tasks/iterate_prc_logs.py +++ b/tasks/iterate_prc_logs.py @@ -45,8 +45,8 @@ count_aggregate = global_aggregate + [{"$count": "total"}] - -async def iterate_prc_logs_global(bot): +@tasks.loop(minutes=7, reconnect=True) +async def iterate_prc_logs(bot): try: server_count_list = await (await bot.settings.db.aggregate(count_aggregate)).to_list(length=None) server_count = server_count_list[0]["total"] if server_count_list else 0 @@ -78,16 +78,7 @@ async def iterate_prc_logs_global(bot): -async def iterate_prc_logs_custom(bot): - guild_id = config("CUSTOM_GUILD_ID") - if not guild_id: - logging.info("No custom guild ID provided for custom environment") - return - try: - await unprimitive_guild_process({"_id": int(guild_id)}, bot) - except Exception as e: - logging.warning(f"error processing guild: {e}") async def unprimitive_guild_process(items, bot): guild = bot.get_guild(items["_id"]) or await bot.fetch_guild( @@ -98,9 +89,6 @@ async def unprimitive_guild_process(items, bot): settings = await bot.settings.find_by_id(guild.id) erlc_settings = settings.get("ERLC", {}) - if await has_whitelabel(bot, guild.id) and not config("CUSTOM_GUILD_ID") == str(guild.id): - logging.warning("Not handling {} due to whitelabel instance existing") - return channels = { "kill_logs": erlc_settings.get("kill_logs"), @@ -220,15 +208,8 @@ async def process_guild(bot, items, semaphore): except Exception as e: logging.warning(f"error processing guild: {e}") + await iterate_prc_logs(bot) -@tasks.loop(minutes=7, reconnect=True) -async def iterate_prc_logs(bot): - if bot.environment == "PRODUCTION": - await iterate_prc_logs_global(bot) - else: - await iterate_prc_logs_custom( - bot - ) async def fetch_logs_with_retry(guild_id, bot, retries=3): diff --git a/tasks/sync_weather.py b/tasks/sync_weather.py index 05e8389..aa2c924 100644 --- a/tasks/sync_weather.py +++ b/tasks/sync_weather.py @@ -84,7 +84,6 @@ async def fetch_weather(session: aiohttp.ClientSession, lat: float, lon: float, @tasks.loop(minutes=2, reconnect=True) async def sync_weather(bot): chosen_filter = { - "CUSTOM": {"_id": int(config("CUSTOM_GUILD_ID", default=0))}, "_": { "_id": { "$nin": [ @@ -93,7 +92,7 @@ async def sync_weather(bot): ] } }, - }["CUSTOM" if config("ENVIRONMENT") == "CUSTOM" else "_"] + } try: logging.info("Starting weather sync task...") @@ -143,9 +142,7 @@ async def sync_weather(bot): processed += 1 guild_id = guild_data["_id"] - if config("ENVIRONMENT") == "CUSTOM": - if guild_id != int(config("CUSTOM_GUILD_ID", default=0)): - continue + weather_settings = guild_data["ERLC"]["weather"] location = weather_settings["location"] diff --git a/utils/api.py b/utils/api.py index 2e9eefa..2a67433 100644 --- a/utils/api.py +++ b/utils/api.py @@ -2144,8 +2144,7 @@ def __init__( async def __call__(self, request: Request, call_next): guild_id = "" try: - if config("ENVIRONMENT") == "CUSTOM": - raise Exception("We're already redirected.") + request_json = await request.json() guild_id = int( From dc637b2247d1f0d8dc393b3ecb4baf6934ca04c4 Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Fri, 12 Jun 2026 09:00:09 +0930 Subject: [PATCH 04/21] more removal and fix on has_whitelabel --- tasks/check_reminders.py | 3 +-- tasks/sync_weather.py | 11 ----------- utils/utils.py | 3 ++- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/tasks/check_reminders.py b/tasks/check_reminders.py index 73efb46..42a92c2 100644 --- a/tasks/check_reminders.py +++ b/tasks/check_reminders.py @@ -120,8 +120,7 @@ async def process_reminder(bot, guild, item, guild_obj): async def iterate_reminder(bot, guild_obj): """Iterate through all reminders for a guild and process any that are due.""" - if await has_whitelabel(bot, guild_obj["_id"]): - return + guild = bot.get_guild(int(guild_obj["_id"])) if not guild: diff --git a/tasks/sync_weather.py b/tasks/sync_weather.py index aa2c924..5e59e69 100644 --- a/tasks/sync_weather.py +++ b/tasks/sync_weather.py @@ -83,16 +83,6 @@ async def fetch_weather(session: aiohttp.ClientSession, lat: float, lon: float, @tasks.loop(minutes=2, reconnect=True) async def sync_weather(bot): - chosen_filter = { - "_": { - "_id": { - "$nin": [ - int(item["GuildID"] or 0) - async for item in bot.whitelabel.db.find({}) - ] - } - }, - } try: logging.info("Starting weather sync task...") @@ -106,7 +96,6 @@ async def sync_weather(bot): {"ERLC.weather.sync_weather": True}, ], "ERLC.weather.location": {"$exists": True, "$ne": ""}, - **chosen_filter, } }, { diff --git a/utils/utils.py b/utils/utils.py index 5381a7a..517c84c 100644 --- a/utils/utils.py +++ b/utils/utils.py @@ -81,7 +81,8 @@ async def generalised_interaction_check_failure( async def has_whitelabel(bot, guild_id: int) -> bool: - if (item := await bot.whitelabel.db.find_one({"GuildID": str(guild_id)})) is not None and config("ENVIRONMENT") not in ["ALPHA", "DEVELOPMENT"]: + item = await bot.whitelabel.db.find_one({"GuildID": str(guild_id)}) + if item: guild = bot.get_guild(guild_id) token = item.get("Token") b64_userid = token.split(".")[0] From 1cdb083187b55e3068694f64730079e3aed80f57 Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Fri, 12 Jun 2026 21:37:20 +0930 Subject: [PATCH 05/21] Check whitelabel task --- tasks/check_whitelabel.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tasks/check_whitelabel.py diff --git a/tasks/check_whitelabel.py b/tasks/check_whitelabel.py new file mode 100644 index 0000000..2def9e0 --- /dev/null +++ b/tasks/check_whitelabel.py @@ -0,0 +1,35 @@ +import discord +from discord.ext import tasks +from erm import Bot +import datetime +import aiohttp + + + +@tasks.loop(hours=2) +async def check_whitelabel(bot: Bot): + async for item in bot.whitelabel.db.find({}): + try: + guild = await bot.fetch_guild(int(item["GuildID"])) # looking at the db, guild ids aren't always ints + except: + return + try: + owner = await guild.fetch_member(int(item["DiscordID"])) + except: + return + bot_member = guild.me + time = datetime.datetime.now(tz=datetime.UTC) + expiry = datetime.datetime.fromtimestamp(item["Expiry"]) + if expiry > time: + await owner.send(embed=discord.Embed( + title="Whitelabel Subscription Expired", + description="Your whitelabel subscription has expired, therefore, the avatar, banner, and bio will be reset. Please renew your subscription through the web dashboard or open a ticket if you need assistance." + )) + await bot_member.edit(avatar=None, banner=None, bio=None, reason="Whitelabel subscription expired") + return + + session = aiohttp.ClientSession() + av = await (await session.post(item["UserData"]["AvatarURL"])).read() + banner = await (await session.post(item["UserData"]["BannerURL"])).read() + await bot_member.edit(avatar=av, banner=banner, bio=item["UserData"]["Bio"]) + From 99a0e1b0a2a7114b5fca119da57469e55b692c5d Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sat, 20 Jun 2026 17:19:05 +0930 Subject: [PATCH 06/21] feat(ui.Sessions): add creation view --- ui/Sessions.py | 128 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 ui/Sessions.py diff --git a/ui/Sessions.py b/ui/Sessions.py new file mode 100644 index 0000000..014bcc8 --- /dev/null +++ b/ui/Sessions.py @@ -0,0 +1,128 @@ +import discord +from discord.ext import commands +import typing +from menus import CustomModal +from base64 import urlsafe_b64decode +import json + +class SessionsEmbedCreationView(discord.ui.LayoutView): + def __init__(self, bot: commands.Bot, type: typing.Literal['vote', 'start', 'shutdown']): + super().__init__(timeout=None) + self.cont = discord.ui.Container() + self.type = type + self.bot = bot + match type: + case 'vote': + self.cont.add_item( + discord.ui.TextDisplay( + "### Create Session Vote Message\n" + "Please use Discohook to design your session vote message. When you are done, please press the button saying 'I Have Created My Message'. Please note that you may only have one message.\n" + "**Main Variables**\n" + "- Your vote button must be a button and must have the label set to `{vote_button}`. If it is not set to that, your vote button will not work.\n" + "- If you want a view votes button, you must have another button with the label set to `{view_votes_button}`, else, that will not work.\n" + "**Other Variables**\n" + "- {user}: The user initiating the session vote\n" + "- {vote_button_name}: The name of the vote button. If you select the dynamic button option (where the button's name changes on the amount of votes), this will say 'vote' by default.\n" + "- {required_members}: How many members are needed to start the vote\n" + "- {erlc}: A variable with references to your linked ERLC server\n" + " - {erlc.name}: The name of your ER:LC server\n" + " - {erlc.code}: The code to your ER:LC server\n" + ) + ) + case 'start': + self.cont.add_item(discord.ui.TextDisplay( + "### Create Session Start Message\n" + "Please use Discohook to create your session start message. When you are done, press the button saying 'I Have Created My Message'. Please note that you may only have one message.\n" + "**Main Variables and Notes**\n" + "- You will not be able to add any other components than __link buttons__. Regular buttons and anything else will be ignored.\n" + "- It is advised that if you create a join URL, you set the code part to {erlc.code}. It will automatically change to your join code.\n" + "**Other Variables**\n" + "- {user}: The user who started the session\n" + "- {erlc}: A variable with references to your linked ERLC server\n" + " - {erlc.name}: The name of your ER:LC server\n" + " - {erlc.code}: The code to your ER:LC server\n" + " - {erlc.players}: The players in your ER:LC server. If this is present, your message will be edited every 5 minutes to reflect the current amount of members.\n" + )) + case 'shutdown': + self.cont.add_item(discord.ui.TextDisplay( + "### Create Session End Message\n" + "Please use Discohook to create your session end message. When you are done, press the button saying 'I Have Created My Message'. Please note that you may only have one message.\n" + "**Main Variables and Notes**\n" + "- Only link buttons are permitted." + "**Other Variables**:" + "- {user}: The user responsible for ending this session" + "- {erlc}: A variable with references to your linked ERLC server " + " - {erlc.name}: The name of your ER:LC server\n" + " - {erlc.max_players}: The highest amount of players." + )) + self.cont.add_item(discord.ui.Separator()) + self.row = discord.ui.ActionRow(discord.ui.Button(url="https://discohook.app", label="Access Discohook")) + self.button = discord.ui.Button( + label = "I Have Created My Message", + style=discord.ButtonStyle.blurple + ) + self.button.callback = self.submit + + async def submit(self, interaction: discord.Interaction): + modal = CustomModal( + "Submit Message", + [ + ( + "url", + discord.ui.Label( + text="URL", + description="Please send your URL into this textbox.", + component=discord.ui.TextInput(max_length=3999) + ) + ) + ] + ) + await interaction.response.send_modal(modal) + await modal.wait() + val: str = modal.url.component.value + if not val.startswith("https://discohook.app"): + return await interaction.followup.send("This is not a valid discohook.app URL.", ephemeral=True) + data_encoded = val.replace("?data=", "") + if not val.startswith("ey"): # ey is { in base64 + return await interaction.followup.send("This discohook.app url does not have a message attached.") + + data = urlsafe_b64decode(data_encoded).decode() + try: + data = json.loads(data) + except: + return await interaction.followup.send("The data is not valid. Please try again.") + + message = data["messages"][0]["data"] + self.satisfied_conditions = False # Checks for buttons being correct. + for component_block in message["components"]: + for component in component_block: + if not component["type"] == 2: # button + return await interaction.followup.send("Your data has components that are not permitted.") + match self.type: + case 'vote': + if component["label"] == "{vote_button}": + component["custom_id"] = "vote_button" + self.satisfied_conditions = True + continue + if component["label"] == "{view_votes_button}": + component["custom_id"] = "view_votes_button" + continue + case 'start': + if component["style"] != 5: + self.satisfied_conditions = False + break + case 'shutdown': + if component["style"] != 5: + self.satisfied_conditions = False + break + + if not self.satisfied_conditions: + return await interaction.followup.send("Your components are invalid for the specific type of embed you are making. This may be because you have regular buttons in the start and end styling (which only allow links) or you may have missing buttons in the vote area.") + + settings = await self.bot.settings.find(interaction.guild.id) + if not settings.get('sessions'): + settings["sessions"] = { + "data": message + } + await self.bot.settings.update(settings) + return await interaction.followup.send("Successfully saved embed.") \ No newline at end of file From 42fe6327f7d6eb3e68c082fab3fb6686920923d3 Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sat, 20 Jun 2026 17:21:12 +0930 Subject: [PATCH 07/21] fix(ui.Sessions): attach the objects to the view --- ui/Sessions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/Sessions.py b/ui/Sessions.py index 014bcc8..441de97 100644 --- a/ui/Sessions.py +++ b/ui/Sessions.py @@ -62,7 +62,9 @@ def __init__(self, bot: commands.Bot, type: typing.Literal['vote', 'start', 'shu style=discord.ButtonStyle.blurple ) self.button.callback = self.submit - + self.row.add_item(self.button) + self.cont.add_item(self.row) + self.add_item(self.cont) async def submit(self, interaction: discord.Interaction): modal = CustomModal( "Submit Message", From 7b401fb225f7d276e6ed634a282ee52006494612 Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sat, 20 Jun 2026 17:48:33 +0930 Subject: [PATCH 08/21] feat(cogs.Sessions+more): Implement vote button --- cogs/Sessions.py | 56 ++++++++++++++++++++++++++++++++++++++++++++++ erm.py | 3 ++- utils/constants.py | 4 +++- utils/mongo.py | 6 ++--- 4 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 cogs/Sessions.py diff --git a/cogs/Sessions.py b/cogs/Sessions.py new file mode 100644 index 0000000..f660f94 --- /dev/null +++ b/cogs/Sessions.py @@ -0,0 +1,56 @@ +import discord +from discord.ext import commands +from discord import app_commands +from erm import Bot +from utils.constants import CUSTOM_IDS_FOR_SESSIONS + +class Sessions(commands.Cog): + def __init__(self, bot: Bot): + self.bot = bot + + @commands.Cog.listener() + async def on_interaction(self, interaction: discord.Interaction): + if interaction.type != discord.InteractionType.component or not interaction.message: + return + id = interaction.data.get("custom_id") + if not id in CUSTOM_IDS_FOR_SESSIONS: + return + + guild = interaction.guild + settings = await self.bot.settings.find(guild.id) + if not settings: return + view = discord.ui.View.from_message(interaction.message, timeout=None) + session = await self.bot.sessions.find(guild.id) + if not session: + return + if id == "vote_button": + if interaction.user.id in session["voted_users"]: + action = "decrement" + else: + action = "increment" + await self.bot.sessions.increment(guild.id, 1 if action == "increment" else -1, "votes") + if action == "decrement": + session["voted_users"].remove(interaction.user.id) + else: + session["voted_users"].append(interaction.user.id) + + if settings["sessions"]["dynamic_button"]: + item = None + while item == None: + children = view.children + for c in children: + if isinstance(c, discord.ui.Container): + children = c.children + if isinstance(c, discord.ui.ActionRow): + children = c.children + if isinstance(c, discord.ui.Button) and c.custom_id == "vote_button": + item = c + item.label = f"{session["votes"]}/{session["required_votes"]}" + await interaction.response.edit_message(view=view) + else: + await interaction.response.send_message("Successfully counted your vote for the session." if action == "increment" else "Successfully removed your vote from the session.") + await self.bot.sessions.update(guild.id) + return + + + \ No newline at end of file diff --git a/erm.py b/erm.py index 4eb5bb8..0ac5da0 100644 --- a/erm.py +++ b/erm.py @@ -195,7 +195,8 @@ async def setup_hook(self) -> None: self.server_keys = ServerKeys(self.db, "server_keys") self.maple_county = self.mongo[f"{f"{dbname}_" if dbname != "erm" else ""}MapleCounty"] self.mc_keys = MapleKeys(self.maple_county, "Auth") - + self.sessions = Document(self.db, "sessions") + self.staff_connections = StaffConnections(self.db, "staff_connections") self.ics = IntegrationCommandStorage(self.db, "logged_command_data") self.actions = Actions(self.db, "actions") diff --git a/utils/constants.py b/utils/constants.py index c6b899c..642b342 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -182,4 +182,6 @@ }, "remove_ingame_perms": False, "end_shift": False -} \ No newline at end of file +} + +CUSTOM_IDS_FOR_SESSIONS = ["vote_button", "view_votes_button"] \ No newline at end of file diff --git a/utils/mongo.py b/utils/mongo.py index b168a83..755aec7 100644 --- a/utils/mongo.py +++ b/utils/mongo.py @@ -21,7 +21,7 @@ def __init__(self, connection: AsyncDatabase, document_name): self.logger = logging.getLogger(__name__) # <-- Pointer Methods --> - async def update(self, dict): + async def update(self, dict) -> None: """ For simpler calls, points to self.update_by_id """ @@ -46,7 +46,7 @@ async def delete(self, id): await self.delete_by_id(id) # <-- Actual Methods --> - async def find_by_id(self, id): + async def find_by_id(self, id) -> dict | None: """ Returns the data found under `id` Params: @@ -149,7 +149,7 @@ async def increment(self, id, amount, field): """ await self.db.update_one({"_id": id}, {"$inc": {field: amount}}) - async def get_all(self): + async def get_all(self) -> list[dict]: """ Returns a list of all data in the document """ From 8e3790aeddf9bb8e97230e7d361699df9a4845e4 Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sat, 20 Jun 2026 19:55:02 +0930 Subject: [PATCH 09/21] make sure guild ids are included in the custom id --- cogs/Sessions.py | 3 +++ ui/Sessions.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cogs/Sessions.py b/cogs/Sessions.py index f660f94..db97601 100644 --- a/cogs/Sessions.py +++ b/cogs/Sessions.py @@ -13,6 +13,9 @@ async def on_interaction(self, interaction: discord.Interaction): if interaction.type != discord.InteractionType.component or not interaction.message: return id = interaction.data.get("custom_id") + if not id.endswith(f":{interaction.guild.id}"): + return + id = id.removesuffix(f":{interaction.guild.id}") if not id in CUSTOM_IDS_FOR_SESSIONS: return diff --git a/ui/Sessions.py b/ui/Sessions.py index 441de97..45c4c09 100644 --- a/ui/Sessions.py +++ b/ui/Sessions.py @@ -103,11 +103,11 @@ async def submit(self, interaction: discord.Interaction): match self.type: case 'vote': if component["label"] == "{vote_button}": - component["custom_id"] = "vote_button" + component["custom_id"] = f"vote_button:{interaction.guild.id}" self.satisfied_conditions = True continue if component["label"] == "{view_votes_button}": - component["custom_id"] = "view_votes_button" + component["custom_id"] = f"view_votes_button:{interaction.guild.id}" continue case 'start': if component["style"] != 5: From 57fd2182486d61d4f1f17da86c60a95395ce2b8a Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sat, 20 Jun 2026 20:05:32 +0930 Subject: [PATCH 10/21] feat(cogs.Sessions,ui.Sessions): session vote cmd --- cogs/Sessions.py | 62 +++++++++++++++++++++++++++++++++++++++++++++--- ui/Sessions.py | 7 ++---- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/cogs/Sessions.py b/cogs/Sessions.py index db97601..1c2595c 100644 --- a/cogs/Sessions.py +++ b/cogs/Sessions.py @@ -3,6 +3,8 @@ from discord import app_commands from erm import Bot from utils.constants import CUSTOM_IDS_FOR_SESSIONS +import discord.http +import json class Sessions(commands.Cog): def __init__(self, bot: Bot): @@ -37,9 +39,9 @@ async def on_interaction(self, interaction: discord.Interaction): else: session["voted_users"].append(interaction.user.id) - if settings["sessions"]["dynamic_button"]: + if settings["sessions"].get("dynamic_button"): item = None - while item == None: + while not item: children = view.children for c in children: if isinstance(c, discord.ui.Container): @@ -54,6 +56,60 @@ async def on_interaction(self, interaction: discord.Interaction): await interaction.response.send_message("Successfully counted your vote for the session." if action == "increment" else "Successfully removed your vote from the session.") await self.bot.sessions.update(guild.id) return + elif id == "view_voters_button": + cont = discord.ui.Container( + discord.ui.TextDisplay( + "### Voters\n" + f"{"".join(["-" + str(user) + "\n" for user in session["voted_users"]])}" + ) + ) + return await interaction.response.send_message(view=discord.ui.LayoutView().add_item(cont)) + @commands.hybrid_group(name = "session", description="Session-related commands") + async def session(self, ctx: commands.Context): + if not ctx.invoked_subcommand: + return await ctx.reply(embed=discord.Embed(title="Invalid Subcommand", description="No valid subcommand was invoked.")) - \ No newline at end of file + @session.command(name = "vote", description="Create a session vote") + @require_settings(["sessions"]) + @is_admin() + @app_commands.describe(required_votes="The votes required for the session to start") + async def _vote(self, ctx: commands.Context, required_votes: int | None=None): + settings = await self.bot.settings.find(ctx.guild.id) + if not settings: + return + if await self.bot.sessions.find(ctx.guild.id): + return await ctx.reply(embed=discord.Embed( + title = "Current Session", + description="There is already an active session." + )) + + session_data = { + "_id": ctx.guild.id, + "user": ctx.author.id, + "voted_users": [], + "started": False, + "votes": 0, + "required_votes": required_votes or settings["sessions"]["required_votes_default"] or 5 + } + + d = settings["sessions"]["d"].replace( + "{user}", + ctx.author.mention + ).replace( + "{vote_button_name}", + settings["sessions"].get("vote_button_name", "vote") + ).replace( + "{required_members}", + required_votes or settings["sessions"]["required_votes_default"] or 5 + ) + j = json.loads(d) + if settings["sessions"].get("dynamic_button"): + j["components"][0]["label"] = f"0/{session_data["required_votes"]}" + + await self.bot.http.send_message(settings["session"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) + await self.bot.sessions.insert(session_data) + await ctx.reply("Successfully sent session message.") + +async def setup(bot: Bot): + await bot.add_cog(Sessions(bot)) \ No newline at end of file diff --git a/ui/Sessions.py b/ui/Sessions.py index 45c4c09..d78b15c 100644 --- a/ui/Sessions.py +++ b/ui/Sessions.py @@ -2,7 +2,7 @@ from discord.ext import commands import typing from menus import CustomModal -from base64 import urlsafe_b64decode +from base64 import urlsafe_b64decode, b64encode import json class SessionsEmbedCreationView(discord.ui.LayoutView): @@ -24,9 +24,6 @@ def __init__(self, bot: commands.Bot, type: typing.Literal['vote', 'start', 'shu "- {user}: The user initiating the session vote\n" "- {vote_button_name}: The name of the vote button. If you select the dynamic button option (where the button's name changes on the amount of votes), this will say 'vote' by default.\n" "- {required_members}: How many members are needed to start the vote\n" - "- {erlc}: A variable with references to your linked ERLC server\n" - " - {erlc.name}: The name of your ER:LC server\n" - " - {erlc.code}: The code to your ER:LC server\n" ) ) case 'start': @@ -124,7 +121,7 @@ async def submit(self, interaction: discord.Interaction): settings = await self.bot.settings.find(interaction.guild.id) if not settings.get('sessions'): settings["sessions"] = { - "data": message + "d": json.dumps(message) } await self.bot.settings.update(settings) return await interaction.followup.send("Successfully saved embed.") \ No newline at end of file From dd0e05064f7fd5c9dc459864bdd73be727e841c2 Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sat, 20 Jun 2026 20:12:28 +0930 Subject: [PATCH 11/21] fix(cogs.Sessions)+feat(utils): add checks --- cogs/Sessions.py | 2 +- utils/utils.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cogs/Sessions.py b/cogs/Sessions.py index 1c2595c..ab8d734 100644 --- a/cogs/Sessions.py +++ b/cogs/Sessions.py @@ -1,7 +1,7 @@ import discord from discord.ext import commands from discord import app_commands -from erm import Bot +from erm import Bot, is_admin, require_settings from utils.constants import CUSTOM_IDS_FOR_SESSIONS import discord.http import json diff --git a/utils/utils.py b/utils/utils.py index 5381a7a..46b87f8 100644 --- a/utils/utils.py +++ b/utils/utils.py @@ -202,12 +202,12 @@ class GuildCheckFailure(commands.CheckFailure): pass -def require_settings(): +def require_settings(setting_lists: list[str]=[]): async def predicate(ctx: commands.Context): if ctx.guild is None: return True settings = await ctx.bot.settings.find_by_id(ctx.guild.id) - if not settings: + if not settings or not all(setting in settings for setting in setting_lists): raise GuildCheckFailure() else: return True From 2d94dd4f0a073ff62058fda97ca3115a8f3dfb0a Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sat, 20 Jun 2026 21:57:41 +0930 Subject: [PATCH 12/21] feat(cogs.Session): create session start/end --- cogs/Sessions.py | 131 ++++++++++++++++++++++++++++++++++++++++++++++- ui/Sessions.py | 4 +- 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/cogs/Sessions.py b/cogs/Sessions.py index ab8d734..2fd0fe0 100644 --- a/cogs/Sessions.py +++ b/cogs/Sessions.py @@ -93,7 +93,7 @@ async def _vote(self, ctx: commands.Context, required_votes: int | None=None): "required_votes": required_votes or settings["sessions"]["required_votes_default"] or 5 } - d = settings["sessions"]["d"].replace( + d = settings["sessions"]["vote"].replace( "{user}", ctx.author.mention ).replace( @@ -107,9 +107,136 @@ async def _vote(self, ctx: commands.Context, required_votes: int | None=None): if settings["sessions"].get("dynamic_button"): j["components"][0]["label"] = f"0/{session_data["required_votes"]}" - await self.bot.http.send_message(settings["session"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) + msg = await self.bot.http.send_message(settings["session"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) + session_data["vote_message"] = msg["id"] await self.bot.sessions.insert(session_data) await ctx.reply("Successfully sent session message.") + @session.command(name = "start", description="Start a session") + @require_settings(["sessions"]) + @is_admin() + async def _start(self, ctx: commands.Context): + settings = await self.bot.settings.find(ctx.guild.id) + if not settings: + return + session = await self.bot.sessions.find(ctx.guild.id) + if not session: + return await ctx.reply(embed=discord.Embed( + title = "No Session", + description="There is no active session." + )) + info = await self.bot.prc_api.get_server_status(ctx.guild.id) + d = settings["sessions"]["start"].replace( + "{user}", + ctx.author.mention + ).replace( + "{erlc.name}", + info.name + ).replace( + "{erlc.code}", + info.join_key + ).replace( + "{erlc.players}", + info.current_players + ) + j = json.loads(d) + channel = await ctx.guild.fetch_channel(settings["session"]["channel_id"]) + msg = await channel.fetch_message(session["vote_message"]) + view = discord.ui.View.from_message(msg) + item = None + while not item: + children = view.children + for c in children: + if isinstance(c, discord.ui.Container): + children = c.children + if isinstance(c, discord.ui.ActionRow): + children = c.children + if isinstance(c, discord.ui.Button) and c.custom_id == "vote_button": + item = c + item.disabled = True + await msg.edit(view=view) + s = await self.bot.http.send_message(settings["session"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) + session["started"], session["message"] = True, s["id"] + await self.bot.sessions.update(session) + return await ctx.reply("The session message has been successfully sent!") + @session.command(name = "start", description="Start a session") + @require_settings(["sessions"]) + @is_admin() + async def _start(self, ctx: commands.Context): + settings = await self.bot.settings.find(ctx.guild.id) + if not settings: + return + session = await self.bot.sessions.find(ctx.guild.id) + if not session: + return await ctx.reply(embed=discord.Embed( + title = "No Session", + description="There is no active session." + )) + info = await self.bot.prc_api.get_server_status(ctx.guild.id) + d = settings["sessions"]["start"].replace( + "{user}", + ctx.author.mention + ).replace( + "{erlc.name}", + info.name + ).replace( + "{erlc.code}", + info.join_key + ).replace( + "{erlc.players}", + info.current_players + ) + + j = json.loads(d) + channel = await ctx.guild.fetch_channel(settings["session"]["channel_id"]) + msg = await channel.fetch_message(session["vote_message"]) + view = discord.ui.View.from_message(msg) + item = None + while not item: + children = view.children + for c in children: + if isinstance(c, discord.ui.Container): + children = c.children + if isinstance(c, discord.ui.ActionRow): + children = c.children + if isinstance(c, discord.ui.Button) and c.custom_id == "vote_button": + item = c + item.disabled = True + await msg.edit(view=view) + s = await self.bot.http.send_message(settings["session"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) + session["message"] = s["id"] + await self.bot.sessions.update(session) + return await ctx.reply("The session message has been successfully sent!") + @session.command(name = "end", description="End a session") + @require_settings(["sessions"]) + @is_admin() + async def _end(self, ctx: commands.Context): + settings = await self.bot.settings.find(ctx.guild.id) + if not settings: + return + session = await self.bot.sessions.find(ctx.guild.id) + if not session: + return await ctx.reply(embed=discord.Embed( + title = "No Session", + description="There is no active session." + )) + + info = await self.bot.prc_api.get_server_status(ctx.guild.id) + d = settings["sessions"]["end"].replace( + "{user}", + ctx.author.mention + ).replace( + "{erlc.name}", + info.name + ).replace( + "{erlc.code}", + info.join_key + ).replace( + "{erlc.max_players}", + session.get("analytics", {}).get("max_players", 0) + ) + j = json.loads(d) + await self.bot.http.send_message(settings["session"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) + return await ctx.reply("The session message has been successfully sent!") async def setup(bot: Bot): await bot.add_cog(Sessions(bot)) \ No newline at end of file diff --git a/ui/Sessions.py b/ui/Sessions.py index d78b15c..be0aa29 100644 --- a/ui/Sessions.py +++ b/ui/Sessions.py @@ -121,7 +121,9 @@ async def submit(self, interaction: discord.Interaction): settings = await self.bot.settings.find(interaction.guild.id) if not settings.get('sessions'): settings["sessions"] = { - "d": json.dumps(message) + self.type: json.dumps(message) } + else: + settings["sessions"][self.type] = json.dumps(message) await self.bot.settings.update(settings) return await interaction.followup.send("Successfully saved embed.") \ No newline at end of file From 7edb28b04d12588a5eb4d1a18ff371385247195f Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sat, 20 Jun 2026 22:37:17 +0930 Subject: [PATCH 13/21] some shit --- cogs/Sessions.py | 53 ++++------------------------------------- tasks/check_sessions.py | 31 ++++++++++++++++++++++++ utils/prc_api.py | 2 +- 3 files changed, 36 insertions(+), 50 deletions(-) create mode 100644 tasks/check_sessions.py diff --git a/cogs/Sessions.py b/cogs/Sessions.py index 2fd0fe0..701c9b1 100644 --- a/cogs/Sessions.py +++ b/cogs/Sessions.py @@ -111,6 +111,7 @@ async def _vote(self, ctx: commands.Context, required_votes: int | None=None): session_data["vote_message"] = msg["id"] await self.bot.sessions.insert(session_data) await ctx.reply("Successfully sent session message.") + @session.command(name = "start", description="Start a session") @require_settings(["sessions"]) @is_admin() @@ -125,6 +126,7 @@ async def _start(self, ctx: commands.Context): description="There is no active session." )) info = await self.bot.prc_api.get_server_status(ctx.guild.id) + if "{erlc.players}" in settings["sessions"]["start"]: session["dynamic"] = True d = settings["sessions"]["start"].replace( "{user}", ctx.author.mention @@ -138,6 +140,7 @@ async def _start(self, ctx: commands.Context): "{erlc.players}", info.current_players ) + session["user"] = ctx.author.mention j = json.loads(d) channel = await ctx.guild.fetch_channel(settings["session"]["channel_id"]) @@ -156,55 +159,7 @@ async def _start(self, ctx: commands.Context): item.disabled = True await msg.edit(view=view) s = await self.bot.http.send_message(settings["session"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) - session["started"], session["message"] = True, s["id"] - await self.bot.sessions.update(session) - return await ctx.reply("The session message has been successfully sent!") - @session.command(name = "start", description="Start a session") - @require_settings(["sessions"]) - @is_admin() - async def _start(self, ctx: commands.Context): - settings = await self.bot.settings.find(ctx.guild.id) - if not settings: - return - session = await self.bot.sessions.find(ctx.guild.id) - if not session: - return await ctx.reply(embed=discord.Embed( - title = "No Session", - description="There is no active session." - )) - info = await self.bot.prc_api.get_server_status(ctx.guild.id) - d = settings["sessions"]["start"].replace( - "{user}", - ctx.author.mention - ).replace( - "{erlc.name}", - info.name - ).replace( - "{erlc.code}", - info.join_key - ).replace( - "{erlc.players}", - info.current_players - ) - - j = json.loads(d) - channel = await ctx.guild.fetch_channel(settings["session"]["channel_id"]) - msg = await channel.fetch_message(session["vote_message"]) - view = discord.ui.View.from_message(msg) - item = None - while not item: - children = view.children - for c in children: - if isinstance(c, discord.ui.Container): - children = c.children - if isinstance(c, discord.ui.ActionRow): - children = c.children - if isinstance(c, discord.ui.Button) and c.custom_id == "vote_button": - item = c - item.disabled = True - await msg.edit(view=view) - s = await self.bot.http.send_message(settings["session"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) - session["message"] = s["id"] + session["message"], session["channel"] = s["id"] await self.bot.sessions.update(session) return await ctx.reply("The session message has been successfully sent!") @session.command(name = "end", description="End a session") diff --git a/tasks/check_sessions.py b/tasks/check_sessions.py new file mode 100644 index 0000000..f891b00 --- /dev/null +++ b/tasks/check_sessions.py @@ -0,0 +1,31 @@ +from discord.ext import tasks +import discord +import logging +from erm import Bot +import discord.http + +@tasks.loop(hours=5) +async def check_sessions(bot: Bot): + async for session in bot.sessions.db.find({"dynamic": True}): + guild = session["_id"] + g = await bot.fetch_guild(guild) + settings = await bot.settings.find(guild) + channel = await bot.fetch_channel(settings["sessions"]["channel_id"]) + players = await bot.prc_api.get_server_players(guild) + info = await bot.prc_api.get_server_status(guild) + + d = settings["sessions"]["start"].replace( + "{user}", + session["user"] + ).replace( + "{erlc.name}", + info.name + ).replace( + "{erlc.code}", + info.join_key + ).replace( + "{erlc.players}", + info.current_players + ) + s = await bot.http.edit_message(settings["session"]["channel_id"], session["message"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) + \ No newline at end of file diff --git a/utils/prc_api.py b/utils/prc_api.py index f93566e..1f51d7c 100644 --- a/utils/prc_api.py +++ b/utils/prc_api.py @@ -203,7 +203,7 @@ async def send_test_request(self, server_key: str) -> int | ServerStatus: ) ) - async def get_server_players(self, guild_id: int) -> list: + async def get_server_players(self, guild_id: int) -> list[Player]: status_code, response_json = await self._send_api_request( "GET", "/server/players", guild_id ) From 25ce18677919ef08096f5ea049be4c7c026abf1a Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sat, 20 Jun 2026 22:41:35 +0930 Subject: [PATCH 14/21] oops --- cogs/Sessions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cogs/Sessions.py b/cogs/Sessions.py index 701c9b1..ec1f551 100644 --- a/cogs/Sessions.py +++ b/cogs/Sessions.py @@ -192,6 +192,7 @@ async def _end(self, ctx: commands.Context): ) j = json.loads(d) await self.bot.http.send_message(settings["session"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) + await self.bot.sessions.delete(session["_id"]) return await ctx.reply("The session message has been successfully sent!") async def setup(bot: Bot): await bot.add_cog(Sessions(bot)) \ No newline at end of file From 5c8111d48f39285af7613745fdf76e49bc62c8c1 Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sat, 20 Jun 2026 22:52:33 +0930 Subject: [PATCH 15/21] break shit for no reason and come back to it tmrw --- cogs/Sessions.py | 3 ++- ui/Sessions.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cogs/Sessions.py b/cogs/Sessions.py index ec1f551..187a7d3 100644 --- a/cogs/Sessions.py +++ b/cogs/Sessions.py @@ -1,7 +1,7 @@ import discord from discord.ext import commands from discord import app_commands -from erm import Bot, is_admin, require_settings +from erm import Bot, is_admin, require_settings, is_management from utils.constants import CUSTOM_IDS_FOR_SESSIONS import discord.http import json @@ -194,5 +194,6 @@ async def _end(self, ctx: commands.Context): await self.bot.http.send_message(settings["session"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) await self.bot.sessions.delete(session["_id"]) return await ctx.reply("The session message has been successfully sent!") + async def setup(bot: Bot): await bot.add_cog(Sessions(bot)) \ No newline at end of file diff --git a/ui/Sessions.py b/ui/Sessions.py index be0aa29..07edd07 100644 --- a/ui/Sessions.py +++ b/ui/Sessions.py @@ -5,6 +5,9 @@ from base64 import urlsafe_b64decode, b64encode import json +class SessionsConfigurationView(discord.ui.View): + + class SessionsEmbedCreationView(discord.ui.LayoutView): def __init__(self, bot: commands.Bot, type: typing.Literal['vote', 'start', 'shutdown']): super().__init__(timeout=None) From 1f3be6ebf0c910608fc2a923f2ae66c294d468f3 Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sun, 21 Jun 2026 16:13:19 +0930 Subject: [PATCH 16/21] a fuck ton of changes --- cogs/ERLC.py | 8 +- cogs/Sessions.py | 207 +++++++++++++++++++++++++++++++++++---------- ui/CustomModals.py | 103 ++++++++++++++++++++++ ui/Selects.py | 8 ++ ui/Sessions.py | 144 +++++++++++++++++-------------- utils/constants.py | 3 +- utils/utils.py | 2 +- 7 files changed, 360 insertions(+), 115 deletions(-) create mode 100644 ui/CustomModals.py create mode 100644 ui/Selects.py diff --git a/cogs/ERLC.py b/cogs/ERLC.py index 29f9168..74db74e 100644 --- a/cogs/ERLC.py +++ b/cogs/ERLC.py @@ -868,7 +868,7 @@ async def operate_and_reload_serverinfo( queue: int = await self.bot.prc_api.get_server_queue( guild_id, minimal=True ) # this only returns the count - client = roblox.Client() + client = self.bot.roblox embed1 = discord.Embed(title=f"{status.name}", color=BLANK_COLOR) embed1.set_author(name=ctx.guild.name, icon_url=ctx.guild.icon) @@ -881,10 +881,14 @@ async def operate_and_reload_serverinfo( ), inline=False, ) + try: + owner_name = (await client.get_user(status.owner_id)).name + except: + owner_name = "Unknown" embed1.add_field( name="Server Ownership", value=( - f"> **Owner:** [{(await client.get_user(status.owner_id)).name}](https://roblox.com/users/{status.owner_id}/profile)\n" + f"> **Owner:** [{owner_name}](https://roblox.com/users/{status.owner_id}/profile)\n" f"> **Co-Owners:** {f', '.join([f'[{user.name}](https://roblox.com/users/{user.id}/profile)' for user in await client.get_users(status.co_owner_ids, expand=False)])}" ), inline=False, diff --git a/cogs/Sessions.py b/cogs/Sessions.py index 187a7d3..8fb573d 100644 --- a/cogs/Sessions.py +++ b/cogs/Sessions.py @@ -2,10 +2,13 @@ from discord.ext import commands from discord import app_commands from erm import Bot, is_admin, require_settings, is_management -from utils.constants import CUSTOM_IDS_FOR_SESSIONS +from utils.constants import CUSTOM_IDS_FOR_SESSIONS, SESSION_VIEW_TYPES import discord.http import json - +from menus import CustomDropdown +from ui.CustomModals import CustomModalButton +from ui.Sessions import SessionsEmbedCreationView +from ui.Selects import SimpleTextChannelSelect class Sessions(commands.Cog): def __init__(self, bot: Bot): self.bot = bot @@ -33,7 +36,7 @@ async def on_interaction(self, interaction: discord.Interaction): action = "decrement" else: action = "increment" - await self.bot.sessions.increment(guild.id, 1 if action == "increment" else -1, "votes") + session["votes"] += 1 if action == "increment" else -1 if action == "decrement": session["voted_users"].remove(interaction.user.id) else: @@ -41,29 +44,29 @@ async def on_interaction(self, interaction: discord.Interaction): if settings["sessions"].get("dynamic_button"): item = None - while not item: - children = view.children - for c in children: - if isinstance(c, discord.ui.Container): - children = c.children - if isinstance(c, discord.ui.ActionRow): - children = c.children - if isinstance(c, discord.ui.Button) and c.custom_id == "vote_button": - item = c + + for c in view.walk_children(): + if isinstance(c, discord.ui.Button) and c.custom_id == f"vote_button:{guild.id}": + item = c + break + + if item is None: + return item.label = f"{session["votes"]}/{session["required_votes"]}" await interaction.response.edit_message(view=view) else: await interaction.response.send_message("Successfully counted your vote for the session." if action == "increment" else "Successfully removed your vote from the session.") - await self.bot.sessions.update(guild.id) + await self.bot.sessions.update(session) return - elif id == "view_voters_button": + elif id == "view_votes_button": + print("e") cont = discord.ui.Container( discord.ui.TextDisplay( "### Voters\n" - f"{"".join(["-" + str(user) + "\n" for user in session["voted_users"]])}" + f"{"".join([f"- <@{str(user)}>\n" for user in session["voted_users"]]) or "> No people have voted for the session."}" ) ) - return await interaction.response.send_message(view=discord.ui.LayoutView().add_item(cont)) + return await interaction.response.send_message(view=discord.ui.LayoutView().add_item(cont), ephemeral=True) @commands.hybrid_group(name = "session", description="Session-related commands") async def session(self, ctx: commands.Context): @@ -98,19 +101,19 @@ async def _vote(self, ctx: commands.Context, required_votes: int | None=None): ctx.author.mention ).replace( "{vote_button_name}", - settings["sessions"].get("vote_button_name", "vote") + settings["sessions"].get("vote_button_label", "vote") if not settings["sessions"].get("dynamic_button") else f"0/{session_data["required_votes"]}" ).replace( "{required_members}", required_votes or settings["sessions"]["required_votes_default"] or 5 ) j = json.loads(d) if settings["sessions"].get("dynamic_button"): - j["components"][0]["label"] = f"0/{session_data["required_votes"]}" + j["components"][0]["components"][0]["label"] = f"0/{session_data["required_votes"]}" - msg = await self.bot.http.send_message(settings["session"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) + msg = await self.bot.http.send_message(settings["sessions"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) session_data["vote_message"] = msg["id"] await self.bot.sessions.insert(session_data) - await ctx.reply("Successfully sent session message.") + return await (ctx.reply if not ctx.interaction else ctx.interaction.response.send_message)(embed=discord.Embed(title = f"{self.bot.emoji_controller.get_emoji("success")} Successfully posted session vote message", description=f"You can find it at <#{settings["sessions"]["channel_id"]}>", colour=discord.Colour.green()), ephemeral=True) @session.command(name = "start", description="Start a session") @require_settings(["sessions"]) @@ -125,43 +128,46 @@ async def _start(self, ctx: commands.Context): title = "No Session", description="There is no active session." )) - info = await self.bot.prc_api.get_server_status(ctx.guild.id) + try: + info = await self.bot.prc_api.get_server_status(ctx.guild.id) + except: + info = None if "{erlc.players}" in settings["sessions"]["start"]: session["dynamic"] = True d = settings["sessions"]["start"].replace( "{user}", ctx.author.mention ).replace( "{erlc.name}", - info.name + info.name if info else "{erlc.name}" ).replace( "{erlc.code}", - info.join_key + f"`{info.join_key}`" if info else "{erlc.code}" ).replace( "{erlc.players}", - info.current_players + str(info.current_players if info else "{erlc.players}") ) session["user"] = ctx.author.mention j = json.loads(d) - channel = await ctx.guild.fetch_channel(settings["session"]["channel_id"]) + channel = await ctx.guild.fetch_channel(settings["sessions"]["channel_id"]) msg = await channel.fetch_message(session["vote_message"]) view = discord.ui.View.from_message(msg) item = None - while not item: - children = view.children - for c in children: - if isinstance(c, discord.ui.Container): - children = c.children - if isinstance(c, discord.ui.ActionRow): - children = c.children - if isinstance(c, discord.ui.Button) and c.custom_id == "vote_button": - item = c - item.disabled = True + + for c in view.walk_children(): + if isinstance(c, discord.ui.Button) and c.custom_id == f"vote_button:{ctx.guild.id}": + item = c + break + if item == None: + pass + else: + item.disabled = True await msg.edit(view=view) - s = await self.bot.http.send_message(settings["session"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) - session["message"], session["channel"] = s["id"] + s = await self.bot.http.send_message(settings["sessions"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) + session["message"], session["channel"] = s["id"], settings["sessions"]["channel_id"] await self.bot.sessions.update(session) - return await ctx.reply("The session message has been successfully sent!") + return await (ctx.reply if not ctx.interaction else ctx.interaction.response.send_message)(embed=discord.Embed(title = f"{self.bot.emoji_controller.get_emoji("success")} Successfully posted session start message", description=f"You can find it at <#{settings["sessions"]["channel_id"]}>", colour=discord.Colour.green()), ephemeral=True) + @session.command(name = "end", description="End a session") @require_settings(["sessions"]) @is_admin() @@ -175,25 +181,132 @@ async def _end(self, ctx: commands.Context): title = "No Session", description="There is no active session." )) - - info = await self.bot.prc_api.get_server_status(ctx.guild.id) - d = settings["sessions"]["end"].replace( + try: + info = await self.bot.prc_api.get_server_status(ctx.guild.id) + except: info = None + d = settings["sessions"]["shutdown"].replace( "{user}", ctx.author.mention ).replace( "{erlc.name}", - info.name + info.name if info else "{erlc.name}" ).replace( "{erlc.code}", - info.join_key + info.join_key if info else "{erlc.code}" ).replace( "{erlc.max_players}", - session.get("analytics", {}).get("max_players", 0) + str(session.get("analytics", {}).get("max_players", 0)) ) j = json.loads(d) - await self.bot.http.send_message(settings["session"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) + await self.bot.http.send_message(settings["sessions"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) await self.bot.sessions.delete(session["_id"]) - return await ctx.reply("The session message has been successfully sent!") - + return await (ctx.reply if not ctx.interaction else ctx.interaction.response.send_message)(embed=discord.Embed(title = f"{self.bot.emoji_controller.get_emoji("success")} Successfully posted session end message", description=f"You can find it at <#{settings["sessions"]["channel_id"]}>", colour=discord.Colour.green()), ephemeral=True) + @session.command(name = "config", description = "Manage the session config") + @is_management() + @require_settings() + async def _config(self, ctx: commands.Context): + settings = await self.bot.settings.find(ctx.guild.id) + if not settings: + return + if not settings.get("sessions"): + settings["sessions"] = {} + sel = CustomDropdown( + ctx.author.id, + [ + discord.SelectOption(label = "Channel", description="Select the channel for sessions to be sent to.", value="channel"), + discord.SelectOption(label = "Vote Message", description="Edit the session vote message", value="vote"), + discord.SelectOption(label = "Start Message", description="Edit the start message", value = "start"), + discord.SelectOption(label = "End Message", description="Edit the session end message", value = "shutdown"), + discord.SelectOption(label = "Other Options", description="Edit other options, such as the dynamic button.", value = "other"), + discord.SelectOption(label = "Finish", description="Finish editing these options.", value = "done") + ] + ) + msg: discord.Message = None + while True: + cont = discord.ui.Container( + discord.ui.TextDisplay( + "### Configure Sessions\n" + "Please select the item in the dropdown below to configure sessions." + ), + discord.ui.Separator(), + discord.ui.ActionRow(sel) + ) + + if not msg: + msg = await ctx.reply(view = (view := discord.ui.LayoutView().add_item(cont))) + else: + await msg.edit(view = (view := discord.ui.LayoutView().add_item(cont))) + await view.wait() + match sel.values[0]: + case "channel": + ch = SimpleTextChannelSelect(default_values=[discord.SelectDefaultValue(id=settings["sessions"].get("channel_id", 0), type=discord.SelectDefaultValueType.channel)]) + cont = discord.ui.Container( + discord.ui.TextDisplay( + "### Set Session Channel\n" + "Please select the channel for session messages to be sent to" + ), + discord.ui.Separator(), + discord.ui.ActionRow(ch) + ) + await msg.edit(view = (view := discord.ui.LayoutView().add_item(cont))) + await view.wait() + print("e") + settings['sessions']["channel_id"] = ch.values[0].id + case "other": + modal = CustomModalButton( + ctx.author.id, + "Set Other Options", + "Set Other Options", + [ + ( + "vote_button_name", + discord.ui.Label( + text = "Vote Button Label", + description="If you are not using the dynamic button, set a vote label name.", + component=discord.ui.TextInput( + default = settings["sessions"].get("vote_button_label", "vote") + ) + ) + ), + ( + "default_required_votes", + discord.ui.Label( + text = "Default Required Votes", + description="If a user does not specify the amount of votes, this will be used instead.", + component=discord.ui.TextInput(default = settings["sessions"].get("required_votes_default", 5)) + ) + ), + ( + "dynamic_button", + discord.ui.Label( + text = "Dynamic Button", + description="Do you wish to enable the dynamic button?", + component=discord.ui.Checkbox(default = settings["sessions"].get("dynamic_button", False)) + ) + ) + ] + ) + cont = discord.ui.Container( + discord.ui.TextDisplay( + "### Other Options\n" + "Please press the button below to continue!" + ), + discord.ui.Separator(), + discord.ui.ActionRow(modal) + ) + await msg.edit(view = (view := discord.ui.LayoutView().add_item(cont))) + await view.wait() + settings["sessions"]["vote_button_name"] = modal.values[0] + settings["sessions"]["required_votes_default"] = modal.values[1] + settings["sessions"]["dynamic_button"] = bool(modal.values[2]) + case "done": + await self.bot.settings.update(settings) + return await msg.edit(view=discord.ui.LayoutView().add_item(discord.ui.TextDisplay("### Success\nYour settings were successfully saved!"))) + case val if sel.values[0] in SESSION_VIEW_TYPES: + await self.bot.settings.update(settings) + await msg.edit(view=(view:=SessionsEmbedCreationView(self.bot, type=val))) + await view.wait() + settings = await self.bot.settings.find(ctx.guild.id) + async def setup(bot: Bot): await bot.add_cog(Sessions(bot)) \ No newline at end of file diff --git a/ui/CustomModals.py b/ui/CustomModals.py new file mode 100644 index 0000000..7cc4fc3 --- /dev/null +++ b/ui/CustomModals.py @@ -0,0 +1,103 @@ +import discord, typing +from menus import CustomModal +from utils.utils import generalised_interaction_check_failure + +class CustomModalButton(discord.ui.Button): + def __init__( + self, + user_id, + title: str, + label: str, + options: typing.List[typing.Tuple[str | typing.Literal[str], discord.ui.TextInput | discord.ui.Label | discord.ui.TextDisplay]], + epher_args: typing.Optional[dict] = None, + ): + super().__init__(label=label or "Enter Strike Amount", style=discord.ButtonStyle.secondary) + self.value = None + self.user_id = user_id + self.modal: typing.Union[None, CustomModal] = None + self.title = title or self.label + self.label = label + self.options = options + self.epher_args = epher_args or {} + + # When the confirm button is pressed, set the inner value to `True` and + # stop the View from listening to more input. + # We also send the user an ephemeral message that we're confirming their choice. + async def callback(self, interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.defer(ephemeral=True, thinking=True) + return await generalised_interaction_check_failure(interaction.followup) + + self.modal = CustomModal(self.label, self.options, self.epher_args) + await interaction.response.send_modal(self.modal) + await self.modal.wait() + + self.values = [] + for component in self.modal.children: + if isinstance(component, discord.ui.TextDisplay): continue + if isinstance(component, discord.ui.Label): + # Labels have a .component attribute + target = component.component + else: + # Other components are the target themselves + target = component + + # Skip if target doesn't have value/values + if hasattr(target, 'values'): + self.values.append(target.values) + elif hasattr(target, 'value'): + self.values.append(target.value) + self.parent.view.values = self.values + self.parent.view.stop() + return + +class CustomModalExecutorButton(discord.ui.Button): + def __init__( + self, + user_id, + title: str, + label: str, + options: typing.List[typing.Tuple[str | typing.Literal[str], discord.ui.TextInput | discord.ui.Label]], + func: typing.Callable, + epher_args: typing.Optional[dict] = None, + + ): + super().__init__(label=label or "Enter Strike Amount", style=discord.ButtonStyle.secondary) + self.value = None + self.user_id = user_id + self.modal: typing.Union[None, CustomModal] = None + self.title = title or self.label + self.label = label + self.options = options + self.func = func + self.epher_args = epher_args or {} + + # When the confirm button is pressed, set the inner value to `True` and + # stop the View from listening to more input. + # We also send the user an ephemeral message that we're confirming their choice. + async def callback(self, interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.defer(ephemeral=True, thinking=True) + return await generalised_interaction_check_failure(interaction.followup) + + self.modal = CustomModal(self.label, self.options, self.epher_args) + await interaction.response.send_modal(self.modal) + await self.modal.wait() + + self.values = [] + for component in self.modal.children: + if isinstance(component, discord.ui.TextDisplay): continue + if isinstance(component, discord.ui.Label): + # Labels have a .component attribute + target = component.component + else: + # Other components are the target themselves + target = component + + # Skip if target doesn't have value/values + if hasattr(target, 'values'): + self.values.append(target.values) + elif hasattr(target, 'value'): + self.values.append(target.value) + await self.func(interaction, self) + \ No newline at end of file diff --git a/ui/Selects.py b/ui/Selects.py new file mode 100644 index 0000000..44aadb6 --- /dev/null +++ b/ui/Selects.py @@ -0,0 +1,8 @@ +import discord +class SimpleTextChannelSelect(discord.ui.ChannelSelect): + def __init__(self, limit=1, **kwargs): + super().__init__(placeholder="Select Channels" if limit > 1 else "Select a Channel", max_values=limit, channel_types=[discord.ChannelType.text], **kwargs) + + async def callback(self, interaction: discord.Interaction): + await interaction.response.defer() + await self.parent.view.stop() \ No newline at end of file diff --git a/ui/Sessions.py b/ui/Sessions.py index 07edd07..255b291 100644 --- a/ui/Sessions.py +++ b/ui/Sessions.py @@ -5,11 +5,9 @@ from base64 import urlsafe_b64decode, b64encode import json -class SessionsConfigurationView(discord.ui.View): - class SessionsEmbedCreationView(discord.ui.LayoutView): - def __init__(self, bot: commands.Bot, type: typing.Literal['vote', 'start', 'shutdown']): + def __init__(self, bot: commands.Bot, type: typing.Literal['vote', 'start', 'shutdown'],): super().__init__(timeout=None) self.cont = discord.ui.Container() self.type = type @@ -66,67 +64,85 @@ def __init__(self, bot: commands.Bot, type: typing.Literal['vote', 'start', 'shu self.cont.add_item(self.row) self.add_item(self.cont) async def submit(self, interaction: discord.Interaction): - modal = CustomModal( - "Submit Message", - [ - ( - "url", - discord.ui.Label( - text="URL", - description="Please send your URL into this textbox.", - component=discord.ui.TextInput(max_length=3999) - ) - ) - ] - ) - await interaction.response.send_modal(modal) - await modal.wait() - val: str = modal.url.component.value - if not val.startswith("https://discohook.app"): - return await interaction.followup.send("This is not a valid discohook.app URL.", ephemeral=True) - data_encoded = val.replace("?data=", "") - if not val.startswith("ey"): # ey is { in base64 - return await interaction.followup.send("This discohook.app url does not have a message attached.") - - data = urlsafe_b64decode(data_encoded).decode() try: - data = json.loads(data) - except: - return await interaction.followup.send("The data is not valid. Please try again.") - - message = data["messages"][0]["data"] - self.satisfied_conditions = False # Checks for buttons being correct. - for component_block in message["components"]: - for component in component_block: - if not component["type"] == 2: # button - return await interaction.followup.send("Your data has components that are not permitted.") - match self.type: - case 'vote': - if component["label"] == "{vote_button}": - component["custom_id"] = f"vote_button:{interaction.guild.id}" - self.satisfied_conditions = True - continue - if component["label"] == "{view_votes_button}": - component["custom_id"] = f"view_votes_button:{interaction.guild.id}" - continue - case 'start': - if component["style"] != 5: - self.satisfied_conditions = False - break - case 'shutdown': - if component["style"] != 5: - self.satisfied_conditions = False - break + settings = await self.bot.settings.find(interaction.guild.id) + if not settings.get('sessions'): + return + modal = CustomModal( + "Submit Message", + [ + ( + "url", + discord.ui.Label( + text="URL", + description="Please send your URL into this textbox.", + component=discord.ui.TextInput(max_length=3999) + ) + ) + ] + ) + await interaction.response.send_modal(modal) + await modal.wait() + val: str = modal.url.component.value + if not val.startswith("https://discohook.app"): + return await interaction.followup.send("This is not a valid discohook.app URL.", ephemeral=True) + try: + data_encoded = val.split("?data=")[1] + except: + return await interaction.followup.send("This discohook.app url does not have a message attached.") + print(data_encoded) + + data = urlsafe_b64decode(data_encoded + "==").decode() + try: + data = json.loads(data) + except: + return await interaction.followup.send("The data is not valid. Please try again.") + + message = data["messages"][0]["data"] + self.satisfied_conditions = False # Checks for buttons being correct. + if not message["components"] and self.type != "vote": + print(message["components"]) + self.satisfied_conditions = True + else: + for component in message["components"][0]["components"]: # + print(component) + if not component["type"] == 2: # button + return await interaction.followup.send("Your data has components that are not permitted.") + match self.type: + case 'vote': + if component["label"] == "{vote_button}": + if not settings["sessions"].get("dynamic_button", True): + component["label"] = settings["sessions"]["vote_button_label"] + if component["style"] == 5: continue + component["custom_id"] = f"vote_button:{interaction.guild.id}" + self.satisfied_conditions = True + continue + if component["label"] == "{view_votes_button}": + if component["style"] == 5: continue + component["label"] = "View Votes" + component["custom_id"] = f"view_votes_button:{interaction.guild.id}" + continue + if component["style"] == 5: + if component["custom_id"]: + del component["custom_id"] + case 'start': + if component["style"] == 5: + if component["custom_id"]: + del component["custom_id"] + self.satisfied_conditions = True + break + + case 'shutdown': + if component["style"] == 5: + self.satisfied_conditions = True + break + + if not self.satisfied_conditions: + return await interaction.followup.send("Your components are invalid for the specific type of embed you are making. This may be because you have regular buttons in the start and end styling (which only allow links) or you may have missing buttons in the vote area.") - if not self.satisfied_conditions: - return await interaction.followup.send("Your components are invalid for the specific type of embed you are making. This may be because you have regular buttons in the start and end styling (which only allow links) or you may have missing buttons in the vote area.") - - settings = await self.bot.settings.find(interaction.guild.id) - if not settings.get('sessions'): - settings["sessions"] = { - self.type: json.dumps(message) - } - else: settings["sessions"][self.type] = json.dumps(message) - await self.bot.settings.update(settings) - return await interaction.followup.send("Successfully saved embed.") \ No newline at end of file + print(settings["sessions"]) + await self.bot.settings.update(settings) + self.stop() + except Exception as e: + print(str(e.with_traceback(None))) \ No newline at end of file diff --git a/utils/constants.py b/utils/constants.py index 642b342..4b83464 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -184,4 +184,5 @@ "end_shift": False } -CUSTOM_IDS_FOR_SESSIONS = ["vote_button", "view_votes_button"] \ No newline at end of file +CUSTOM_IDS_FOR_SESSIONS = ["vote_button", "view_votes_button"] +SESSION_VIEW_TYPES = ["vote", "start", "shutdown"] \ No newline at end of file diff --git a/utils/utils.py b/utils/utils.py index 46b87f8..22c2fe3 100644 --- a/utils/utils.py +++ b/utils/utils.py @@ -217,7 +217,7 @@ async def predicate(ctx: commands.Context): async def update_ics(bot, ctx, channel, return_val: dict, ics_id: int): try: - status: ServerStatus = await bot.prc_api.get_server_status(ctx.guild.id) + status: ServerStatus|None = await bot.prc_api.get_server_status(ctx.guild.id) except prc_api.ResponseFailure: status = None if not isinstance(status, ServerStatus): From f8878aff818690b49b4428ab471db4d140e37931 Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sun, 21 Jun 2026 16:18:59 +0930 Subject: [PATCH 17/21] feat(sessions): Session analytics (backend) --- cogs/Sessions.py | 6 +++++- tasks/check_sessions.py | 20 +++++++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/cogs/Sessions.py b/cogs/Sessions.py index 8fb573d..c507f85 100644 --- a/cogs/Sessions.py +++ b/cogs/Sessions.py @@ -93,7 +93,11 @@ async def _vote(self, ctx: commands.Context, required_votes: int | None=None): "voted_users": [], "started": False, "votes": 0, - "required_votes": required_votes or settings["sessions"]["required_votes_default"] or 5 + "required_votes": required_votes or settings["sessions"]["required_votes_default"] or 5, + "analytics": { + "max_players": 0, + "player_counts": [] + } } d = settings["sessions"]["vote"].replace( diff --git a/tasks/check_sessions.py b/tasks/check_sessions.py index f891b00..152fe99 100644 --- a/tasks/check_sessions.py +++ b/tasks/check_sessions.py @@ -4,7 +4,7 @@ from erm import Bot import discord.http -@tasks.loop(hours=5) +@tasks.loop(minutes=5) async def check_sessions(bot: Bot): async for session in bot.sessions.db.find({"dynamic": True}): guild = session["_id"] @@ -12,20 +12,26 @@ async def check_sessions(bot: Bot): settings = await bot.settings.find(guild) channel = await bot.fetch_channel(settings["sessions"]["channel_id"]) players = await bot.prc_api.get_server_players(guild) - info = await bot.prc_api.get_server_status(guild) - + try: + info = await bot.prc_api.get_server_status(guild) + except: info = None d = settings["sessions"]["start"].replace( "{user}", session["user"] ).replace( "{erlc.name}", - info.name + info.name if info else "{erlc.name}" ).replace( "{erlc.code}", - info.join_key + f"`{info.join_key}`" if info else "{erlc.code}" ).replace( "{erlc.players}", - info.current_players + str(info.current_players) if info else "{erlc.players}" ) - s = await bot.http.edit_message(settings["session"]["channel_id"], session["message"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) + await bot.http.edit_message(settings["session"]["channel_id"], session["message"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) + if info: + if info.current_players > session["analytics"]["max_players"]: + session["analytics"]["max_players"] = info.current_players + session["analytics"]["player_counts"].append(info.current_players) + await bot.sessions.update(session) \ No newline at end of file From 8da72ae5deeb9f6dee7b1f944227e3195d6ef252 Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sun, 21 Jun 2026 16:43:37 +0930 Subject: [PATCH 18/21] session analytics --- cogs/Sessions.py | 77 +++++++++++++++++++++++++++++++++++++++++ tasks/check_sessions.py | 62 +++++++++++++++++---------------- 2 files changed, 110 insertions(+), 29 deletions(-) diff --git a/cogs/Sessions.py b/cogs/Sessions.py index c507f85..c5263bc 100644 --- a/cogs/Sessions.py +++ b/cogs/Sessions.py @@ -9,9 +9,37 @@ from ui.CustomModals import CustomModalButton from ui.Sessions import SessionsEmbedCreationView from ui.Selects import SimpleTextChannelSelect +import utils.prc_api as prc_api +import matplotlib.pyplot as plt +import matplotlib +matplotlib.use("Agg") +import io, asyncio + +def is_erlc_server_linked(): + async def predicate(ctx: commands.Context): + if ctx.guild is None: + return False + guild_id = ctx.guild.id + + try: + await ctx.bot.prc_api.get_server_status(guild_id) + except prc_api.ResponseFailure as exc: + error = prc_api.ServerLinkNotFound(platform="erlc") + try: + error.code = exc.json_data.get("code") or exc.status_code + except json.JSONDecodeError: + pass + raise error + + return True + + return commands.check(predicate) + class Sessions(commands.Cog): def __init__(self, bot: Bot): self.bot = bot + plt.style.use("dark_background") + self.fig, self.ax = plt.subplots(figsize=(8, 6)) @commands.Cog.listener() async def on_interaction(self, interaction: discord.Interaction): @@ -205,6 +233,55 @@ async def _end(self, ctx: commands.Context): await self.bot.http.send_message(settings["sessions"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) await self.bot.sessions.delete(session["_id"]) return await (ctx.reply if not ctx.interaction else ctx.interaction.response.send_message)(embed=discord.Embed(title = f"{self.bot.emoji_controller.get_emoji("success")} Successfully posted session end message", description=f"You can find it at <#{settings["sessions"]["channel_id"]}>", colour=discord.Colour.green()), ephemeral=True) + def generate_graph(self, session): + """ + Generates player graph. + Very blocking so run in an executor + """ + self.ax.clear() + self.ax.plot(session["analytics"]["player_counts"], label="Current Players") + self.ax.set_xticks([]) + self.ax.set_title("Player Graph") + self.ax.set_ylabel("Player Count") + self.ax.legend(loc='upper center', ncol=8, frameon=True) + self.ax.margins(x=0) + self.fig.tight_layout() + + buf = io.BytesIO() + self.fig.savefig(buf, format="png", bbox_inches="tight", dpi=100, facecolor="black") + buf.seek(0) + return buf + @session.command(name = "info", description="View analytics about your session") + @require_settings(["sessions"]) + @is_erlc_server_linked() + async def _info(self, ctx: commands.Context): + session = await self.bot.sessions.find(ctx.guild.id) + if not session or not session["message"]: + return await ctx.reply(embed=discord.Embed( + title = "No Session", + description="There is no active session." + )) + info = await self.bot.prc_api.get_server_status(ctx.guild.id) + cont = discord.ui.Container(discord.ui.TextDisplay( + "### Session Status\n" + "Below are some analytics regarding your current session. ERM collects data such as your player counts and max player counts and these are deleted when the session is over." + )) + graph = await asyncio.get_event_loop().run_in_executor(None, self.generate_graph, session) + cont.add_item(discord.ui.Separator()) + cont.add_item(discord.ui.TextDisplay( + "### Player Analytics\n" + f"> **Current Amount of Players**: {info.current_players}\n" + f"> **Current Amount of Modcalls**: {len(await self.bot.prc_api.get_mod_calls(ctx.guild.id))}\n" + f"> **Highest Player Count**: {session["analytics"]["max_players"]}" + )) + cont.add_item(discord.ui.Separator()) + file = discord.File(graph, filename="graph.png") + cont.add_item(discord.ui.MediaGallery(discord.MediaGalleryItem(discord.UnfurledMediaItem("attachment://graph.png")))) + cont.add_item(discord.ui.Separator()) + cont.add_item(discord.ui.ActionRow(discord.ui.Button(url=f"https://erlc.gg/join/{info.join_key}", label="Join Server"))) + return await ctx.reply(view=discord.ui.LayoutView().add_item(cont), files=[file]) + + @session.command(name = "config", description = "Manage the session config") @is_management() @require_settings() diff --git a/tasks/check_sessions.py b/tasks/check_sessions.py index 152fe99..9f71ca0 100644 --- a/tasks/check_sessions.py +++ b/tasks/check_sessions.py @@ -3,35 +3,39 @@ import logging from erm import Bot import discord.http - -@tasks.loop(minutes=5) +import json +@tasks.loop(minutes=5, reconnect=True) async def check_sessions(bot: Bot): + print("Ran!") async for session in bot.sessions.db.find({"dynamic": True}): - guild = session["_id"] - g = await bot.fetch_guild(guild) - settings = await bot.settings.find(guild) - channel = await bot.fetch_channel(settings["sessions"]["channel_id"]) - players = await bot.prc_api.get_server_players(guild) + print(f"Parsing session for guild {session["_id"]}") try: - info = await bot.prc_api.get_server_status(guild) - except: info = None - d = settings["sessions"]["start"].replace( - "{user}", - session["user"] - ).replace( - "{erlc.name}", - info.name if info else "{erlc.name}" - ).replace( - "{erlc.code}", - f"`{info.join_key}`" if info else "{erlc.code}" - ).replace( - "{erlc.players}", - str(info.current_players) if info else "{erlc.players}" - ) - await bot.http.edit_message(settings["session"]["channel_id"], session["message"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) - if info: - if info.current_players > session["analytics"]["max_players"]: - session["analytics"]["max_players"] = info.current_players - session["analytics"]["player_counts"].append(info.current_players) - await bot.sessions.update(session) - \ No newline at end of file + guild = session["_id"] + g = await bot.fetch_guild(guild) + settings = await bot.settings.find(guild) + try: + info = await bot.prc_api.get_server_status(guild) + except: info = None + print(info) + d = settings["sessions"]["start"].replace( + "{user}", + session["user"] + ).replace( + "{erlc.name}", + info.name if info else "{erlc.name}" + ).replace( + "{erlc.code}", + f"`{info.join_key}`" if info else "{erlc.code}" + ).replace( + "{erlc.players}", + str(info.current_players) if info else "{erlc.players}" + ) + j = json.loads(d) + await bot.http.edit_message(settings["sessions"]["channel_id"], session["message"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) + if info: + if info.current_players > session["analytics"]["max_players"]: + session["analytics"]["max_players"] = info.current_players + session["analytics"]["player_counts"].append(info.current_players) + await bot.sessions.update(session) + except Exception as e: + logging.warning(f"error: {str(e)}") \ No newline at end of file From 3cd273dea391b72a40bdc1ee859863bcae9b3b99 Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sun, 21 Jun 2026 17:37:32 +0930 Subject: [PATCH 19/21] fix interaction issues --- cogs/Sessions.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cogs/Sessions.py b/cogs/Sessions.py index c5263bc..04b5076 100644 --- a/cogs/Sessions.py +++ b/cogs/Sessions.py @@ -14,6 +14,7 @@ import matplotlib matplotlib.use("Agg") import io, asyncio +import datetime def is_erlc_server_linked(): async def predicate(ctx: commands.Context): @@ -145,7 +146,7 @@ async def _vote(self, ctx: commands.Context, required_votes: int | None=None): msg = await self.bot.http.send_message(settings["sessions"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) session_data["vote_message"] = msg["id"] await self.bot.sessions.insert(session_data) - return await (ctx.reply if not ctx.interaction else ctx.interaction.response.send_message)(embed=discord.Embed(title = f"{self.bot.emoji_controller.get_emoji("success")} Successfully posted session vote message", description=f"You can find it at <#{settings["sessions"]["channel_id"]}>", colour=discord.Colour.green()), ephemeral=True) + return await (ctx.reply if not ctx.interaction else ctx.interaction.followup.send)(embed=discord.Embed(title = f"{self.bot.emoji_controller.get_emoji("success")} Successfully posted session vote message", description=f"You can find it at <#{settings["sessions"]["channel_id"]}>", colour=discord.Colour.green()), ephemeral=True) @session.command(name = "start", description="Start a session") @require_settings(["sessions"]) @@ -198,7 +199,7 @@ async def _start(self, ctx: commands.Context): s = await self.bot.http.send_message(settings["sessions"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) session["message"], session["channel"] = s["id"], settings["sessions"]["channel_id"] await self.bot.sessions.update(session) - return await (ctx.reply if not ctx.interaction else ctx.interaction.response.send_message)(embed=discord.Embed(title = f"{self.bot.emoji_controller.get_emoji("success")} Successfully posted session start message", description=f"You can find it at <#{settings["sessions"]["channel_id"]}>", colour=discord.Colour.green()), ephemeral=True) + return await (ctx.reply if not ctx.interaction else ctx.interaction.followup.send)(embed=discord.Embed(title = f"{self.bot.emoji_controller.get_emoji("success")} Successfully posted session start message", description=f"You can find it at <#{settings["sessions"]["channel_id"]}>", colour=discord.Colour.green()), ephemeral=True) @session.command(name = "end", description="End a session") @require_settings(["sessions"]) @@ -232,7 +233,7 @@ async def _end(self, ctx: commands.Context): j = json.loads(d) await self.bot.http.send_message(settings["sessions"]["channel_id"], params=discord.http.MultipartParameters(payload = j, multipart=None, files=None)) await self.bot.sessions.delete(session["_id"]) - return await (ctx.reply if not ctx.interaction else ctx.interaction.response.send_message)(embed=discord.Embed(title = f"{self.bot.emoji_controller.get_emoji("success")} Successfully posted session end message", description=f"You can find it at <#{settings["sessions"]["channel_id"]}>", colour=discord.Colour.green()), ephemeral=True) + return await (ctx.reply if not ctx.interaction else ctx.interaction.followup.send)(embed=discord.Embed(title = f"{self.bot.emoji_controller.get_emoji("success")} Successfully posted session end message", description=f"You can find it at <#{settings["sessions"]["channel_id"]}>", colour=discord.Colour.green()), ephemeral=True) def generate_graph(self, session): """ Generates player graph. From 2fb2198caad0321194b284cdeee00179ef8b1114 Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sun, 21 Jun 2026 19:07:30 +0930 Subject: [PATCH 20/21] some more fixes --- cogs/Sessions.py | 7 +++++-- ui/Sessions.py | 9 +++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/cogs/Sessions.py b/cogs/Sessions.py index 04b5076..445acfe 100644 --- a/cogs/Sessions.py +++ b/cogs/Sessions.py @@ -169,12 +169,15 @@ async def _start(self, ctx: commands.Context): d = settings["sessions"]["start"].replace( "{user}", ctx.author.mention + ).replace( + "{user_mentions}", + f"{" | ".join([f"<@{user}>" for user in session["voted_users"]])}" ).replace( "{erlc.name}", info.name if info else "{erlc.name}" ).replace( "{erlc.code}", - f"`{info.join_key}`" if info else "{erlc.code}" + f"{info.join_key}" if info else "{erlc.code}" ).replace( "{erlc.players}", str(info.current_players if info else "{erlc.players}") @@ -257,7 +260,7 @@ def generate_graph(self, session): @is_erlc_server_linked() async def _info(self, ctx: commands.Context): session = await self.bot.sessions.find(ctx.guild.id) - if not session or not session["message"]: + if not session or not session.get("message"): return await ctx.reply(embed=discord.Embed( title = "No Session", description="There is no active session." diff --git a/ui/Sessions.py b/ui/Sessions.py index 255b291..d52f5c7 100644 --- a/ui/Sessions.py +++ b/ui/Sessions.py @@ -36,6 +36,7 @@ def __init__(self, bot: commands.Bot, type: typing.Literal['vote', 'start', 'shu "- It is advised that if you create a join URL, you set the code part to {erlc.code}. It will automatically change to your join code.\n" "**Other Variables**\n" "- {user}: The user who started the session\n" + "- {user_mentions}: The mentions of the users who voted.\n" "- {erlc}: A variable with references to your linked ERLC server\n" " - {erlc.name}: The name of your ER:LC server\n" " - {erlc.code}: The code to your ER:LC server\n" @@ -46,10 +47,10 @@ def __init__(self, bot: commands.Bot, type: typing.Literal['vote', 'start', 'shu "### Create Session End Message\n" "Please use Discohook to create your session end message. When you are done, press the button saying 'I Have Created My Message'. Please note that you may only have one message.\n" "**Main Variables and Notes**\n" - "- Only link buttons are permitted." - "**Other Variables**:" - "- {user}: The user responsible for ending this session" - "- {erlc}: A variable with references to your linked ERLC server " + "- Only link buttons are permitted.\n" + "**Other Variables**:\n" + "- {user}: The user responsible for ending this session\n" + "- {erlc}: A variable with references to your linked ERLC server \n" " - {erlc.name}: The name of your ER:LC server\n" " - {erlc.max_players}: The highest amount of players." )) From 6e23fdc262e3290a830fc38b4be6f3aa50a2a9f1 Mon Sep 17 00:00:00 2001 From: ar-cyber Date: Sun, 21 Jun 2026 21:22:48 +0930 Subject: [PATCH 21/21] some shit --- cogs/Sessions.py | 2 +- tasks/check_sessions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cogs/Sessions.py b/cogs/Sessions.py index 445acfe..76c1b46 100644 --- a/cogs/Sessions.py +++ b/cogs/Sessions.py @@ -137,7 +137,7 @@ async def _vote(self, ctx: commands.Context, required_votes: int | None=None): settings["sessions"].get("vote_button_label", "vote") if not settings["sessions"].get("dynamic_button") else f"0/{session_data["required_votes"]}" ).replace( "{required_members}", - required_votes or settings["sessions"]["required_votes_default"] or 5 + str(required_votes or settings["sessions"]["required_votes_default"] or 5) ) j = json.loads(d) if settings["sessions"].get("dynamic_button"): diff --git a/tasks/check_sessions.py b/tasks/check_sessions.py index 9f71ca0..51cbc40 100644 --- a/tasks/check_sessions.py +++ b/tasks/check_sessions.py @@ -25,7 +25,7 @@ async def check_sessions(bot: Bot): info.name if info else "{erlc.name}" ).replace( "{erlc.code}", - f"`{info.join_key}`" if info else "{erlc.code}" + f"{info.join_key}" if info else "{erlc.code}" ).replace( "{erlc.players}", str(info.current_players) if info else "{erlc.players}"