diff --git a/.env.template b/.env.template index d6b08e0f..2b5b9091 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 0eee7761..b5f3fd77 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/ERLC.py b/cogs/ERLC.py index 29f91684..74db74e9 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/Search.py b/cogs/Search.py index ea381099..f8391a81 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/cogs/Sessions.py b/cogs/Sessions.py new file mode 100644 index 00000000..76c1b460 --- /dev/null +++ b/cogs/Sessions.py @@ -0,0 +1,397 @@ +import discord +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, 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 +import utils.prc_api as prc_api +import matplotlib.pyplot as plt +import matplotlib +matplotlib.use("Agg") +import io, asyncio +import datetime + +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): + 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 + + 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" + session["votes"] += 1 if action == "increment" else -1 + if action == "decrement": + session["voted_users"].remove(interaction.user.id) + else: + session["voted_users"].append(interaction.user.id) + + if settings["sessions"].get("dynamic_button"): + item = None + + 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(session) + return + elif id == "view_votes_button": + print("e") + cont = discord.ui.Container( + discord.ui.TextDisplay( + "### Voters\n" + 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), ephemeral=True) + + @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.")) + + @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, + "analytics": { + "max_players": 0, + "player_counts": [] + } + } + + d = settings["sessions"]["vote"].replace( + "{user}", + ctx.author.mention + ).replace( + "{vote_button_name}", + settings["sessions"].get("vote_button_label", "vote") if not settings["sessions"].get("dynamic_button") else f"0/{session_data["required_votes"]}" + ).replace( + "{required_members}", + str(required_votes or settings["sessions"]["required_votes_default"] or 5) + ) + j = json.loads(d) + if settings["sessions"].get("dynamic_button"): + j["components"][0]["components"][0]["label"] = f"0/{session_data["required_votes"]}" + + 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.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"]) + @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." + )) + 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( + "{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}" + ).replace( + "{erlc.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["sessions"]["channel_id"]) + msg = await channel.fetch_message(session["vote_message"]) + view = discord.ui.View.from_message(msg) + item = None + + 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["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.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"]) + @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." + )) + 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 if info else "{erlc.name}" + ).replace( + "{erlc.code}", + info.join_key if info else "{erlc.code}" + ).replace( + "{erlc.max_players}", + str(session.get("analytics", {}).get("max_players", 0)) + ) + 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.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. + 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.get("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() + 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/documentation/architecture.md b/documentation/architecture.md index 2581d41f..ed42929d 100644 --- a/documentation/architecture.md +++ b/documentation/architecture.md @@ -112,8 +112,6 @@ staggered 2-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 4eb5bb8d..171341bc 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", "") @@ -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 @@ -195,7 +192,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") @@ -208,13 +206,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 +270,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": [], @@ -310,9 +297,6 @@ async def setup_hook(self) -> None: -if config("ENVIRONMENT") == "CUSTOM": - Bot.__bases__ = (commands.Bot,) - bot = Bot( command_prefix=get_prefix, case_insensitive=True, @@ -342,38 +326,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: @@ -408,41 +361,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] diff --git a/events/on_message.py b/events/on_message.py index 0e043dd0..10257e9f 100644 --- a/events/on_message.py +++ b/events/on_message.py @@ -57,8 +57,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 1cc842a3..42a92c2f 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: @@ -145,13 +144,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/check_sessions.py b/tasks/check_sessions.py new file mode 100644 index 00000000..51cbc408 --- /dev/null +++ b/tasks/check_sessions.py @@ -0,0 +1,41 @@ +from discord.ext import tasks +import discord +import logging +from erm import Bot +import discord.http +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}): + print(f"Parsing session for guild {session["_id"]}") + try: + 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 diff --git a/tasks/check_whitelabel.py b/tasks/check_whitelabel.py new file mode 100644 index 00000000..2def9e05 --- /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"]) + diff --git a/tasks/iterate_ics.py b/tasks/iterate_ics.py index 6342dc91..aa0ac1a2 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 502ee601..1edfc278 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"), @@ -221,15 +209,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 b829290f..d34522b1 100644 --- a/tasks/sync_weather.py +++ b/tasks/sync_weather.py @@ -83,17 +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 = { - "CUSTOM": {"_id": int(config("CUSTOM_GUILD_ID", default=0))}, - "_": { - "_id": { - "$nin": [ - int(item["GuildID"] or 0) - async for item in bot.whitelabel.db.find({}) - ] - } - }, - }["CUSTOM" if config("ENVIRONMENT") == "CUSTOM" else "_"] try: logging.info("Starting weather sync task...") @@ -107,7 +96,6 @@ async def sync_weather(bot): {"ERLC.weather.sync_weather": True}, ], "ERLC.weather.location": {"$exists": True, "$ne": ""}, - **chosen_filter, } }, { @@ -143,9 +131,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/ui/CustomModals.py b/ui/CustomModals.py new file mode 100644 index 00000000..7cc4fc34 --- /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 00000000..44aadb6a --- /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 new file mode 100644 index 00000000..d52f5c7a --- /dev/null +++ b/ui/Sessions.py @@ -0,0 +1,149 @@ +import discord +from discord.ext import commands +import typing +from menus import CustomModal +from base64 import urlsafe_b64decode, b64encode +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" + ) + ) + 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" + "- {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" + " - {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.\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." + )) + 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 + self.row.add_item(self.button) + self.cont.add_item(self.row) + self.add_item(self.cont) + async def submit(self, interaction: discord.Interaction): + try: + 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.") + + settings["sessions"][self.type] = json.dumps(message) + 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/api.py b/utils/api.py index 2e9eefa7..2a674333 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( diff --git a/utils/constants.py b/utils/constants.py index c6b899c7..4b83464d 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -182,4 +182,7 @@ }, "remove_ingame_perms": False, "end_shift": False -} \ 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/mongo.py b/utils/mongo.py index b168a837..755aec79 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 """ diff --git a/utils/prc_api.py b/utils/prc_api.py index f93566eb..1f51d7ce 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 ) diff --git a/utils/utils.py b/utils/utils.py index 5381a7a6..4ee1cc22 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] @@ -202,12 +203,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 @@ -217,7 +218,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):