diff --git a/menus.py b/menus.py index 493303f..ea59885 100644 --- a/menus.py +++ b/menus.py @@ -8736,6 +8736,144 @@ async def more_options(self, interaction: discord.Interaction, button: discord.u view=view ) + @discord.ui.button(label="In-Game Commands", row=3) + async def ingame_commands( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + val = await self.interaction_check(interaction) + if val is False: + return + + settings = await self.bot.settings.find_by_id(interaction.guild.id) + cmd_config = settings.get("ERLC", {}).get("ingame_commands", {"enabled": False, "commands": []}) + + embed = discord.Embed(title="In-Game Commands", description="", color=BLANK_COLOR) + embed.set_author( + name=interaction.guild.name, + icon_url=interaction.guild.icon.url if interaction.guild.icon else "", + ) + + status = "Enabled" if cmd_config.get("enabled") else "Disabled" + embed.description = f"**Status:** {status}\n\n" + + commands = cmd_config.get("commands", []) + if commands: + for cmd in commands: + embed.description += f"> **;{cmd['trigger']}** → {cmd['action']}\n" + else: + embed.description += "> No commands configured." + + view = InGameCommandsConfigView(self.bot, interaction.user.id) + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + + +class InGameCommandsConfigView(discord.ui.View): + def __init__(self, bot, user_id): + super().__init__(timeout=300) + self.bot = bot + self.user_id = user_id + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return interaction.user.id == self.user_id + + @discord.ui.select( + placeholder="In-Game Commands", + row=0, + options=[ + discord.SelectOption(label="Enabled", value="enabled"), + discord.SelectOption(label="Disabled", value="disabled"), + ], + max_values=1, + ) + async def toggle_enabled(self, interaction: discord.Interaction, select: discord.ui.Select): + await interaction.response.defer() + sett = await self.bot.settings.find_by_id(interaction.guild.id) + if not sett.get("ERLC"): + sett["ERLC"] = {} + if not sett["ERLC"].get("ingame_commands"): + sett["ERLC"]["ingame_commands"] = {"enabled": False, "commands": []} + sett["ERLC"]["ingame_commands"]["enabled"] = select.values[0] == "enabled" + await self.bot.settings.update_by_id(sett) + await config_change_log(self.bot, interaction.guild, interaction.user, f"In-Game Commands {select.values[0]}") + + @discord.ui.button(label="Add Command", style=discord.ButtonStyle.success, row=1) + async def add_command(self, interaction: discord.Interaction, button: discord.ui.Button): + modal = InGameCommandModal() + await interaction.response.send_modal(modal) + if await modal.wait(): + return + + trigger = modal.trigger_input.value.strip().lower() + action = modal.action_input.value.strip().lower() + channel_id = modal.channel_input.value.strip() + extra = modal.extra_input.value.strip() + + if action not in ("move_to_voice", "send_message", "ping_role"): + await modal.interaction.followup.send("Invalid action. Use: move_to_voice, send_message, or ping_role", ephemeral=True) + return + + cmd_data = {"trigger": trigger, "action": action} + if channel_id: + cmd_data["channel"] = int(channel_id) + if action == "move_to_voice" and extra: + pairs = [p.strip().split("=") for p in extra.split(",") if "=" in p] + cmd_data["voice_channels"] = {k.strip(): v.strip() for k, v in pairs} + elif action == "ping_role" and extra: + cmd_data["role"] = int(extra) + elif action == "send_message" and extra: + cmd_data["message"] = extra + + sett = await self.bot.settings.find_by_id(interaction.guild.id) + if not sett.get("ERLC"): + sett["ERLC"] = {} + if not sett["ERLC"].get("ingame_commands"): + sett["ERLC"]["ingame_commands"] = {"enabled": False, "commands": []} + sett["ERLC"]["ingame_commands"]["commands"].append(cmd_data) + await self.bot.settings.update_by_id(sett) + await modal.interaction.followup.send(f"Added command **;{trigger}** → {action}", ephemeral=True) + + @discord.ui.button(label="Remove Command", style=discord.ButtonStyle.danger, row=1) + async def remove_command(self, interaction: discord.Interaction, button: discord.ui.Button): + modal = CustomModal( + "Remove In-Game Command", + [("trigger", discord.ui.TextInput(label="Command Trigger", placeholder="e.g. ts"))], + {"ephemeral": True, "thinking": True}, + ) + await interaction.response.send_modal(modal) + if await modal.wait(): + return + + trigger = modal.trigger.value.strip().lower() + sett = await self.bot.settings.find_by_id(interaction.guild.id) + commands = sett.get("ERLC", {}).get("ingame_commands", {}).get("commands", []) + new_commands = [c for c in commands if c.get("trigger", "").lower() != trigger] + + if len(new_commands) == len(commands): + await modal.interaction.followup.send(f"No command with trigger **;{trigger}** found.", ephemeral=True) + return + + sett["ERLC"]["ingame_commands"]["commands"] = new_commands + await self.bot.settings.update_by_id(sett) + await modal.interaction.followup.send(f"Removed command **;{trigger}**", ephemeral=True) + + +class InGameCommandModal(discord.ui.Modal, title="Add In-Game Command"): + trigger_input = discord.ui.TextInput(label="Trigger (without ;)", placeholder="e.g. ts") + action_input = discord.ui.TextInput(label="Action", placeholder="move_to_voice, send_message, or ping_role") + channel_input = discord.ui.TextInput(label="Channel ID (for send_message/ping_role)", required=False, placeholder="Channel ID") + extra_input = discord.ui.TextInput( + label="Extra Config", + required=False, + placeholder="move_to_voice: 1=CHANNEL_ID,2=CHANNEL_ID | ping_role: ROLE_ID | send_message: message text", + style=discord.TextStyle.paragraph, + ) + + async def on_submit(self, interaction: discord.Interaction): + self.interaction = interaction + await interaction.response.defer(ephemeral=True) + self.stop() + + class MoreERLCConfiguration(discord.ui.View): def __init__(self, bot, settings): super().__init__(timeout=None) diff --git a/utils/api.py b/utils/api.py index 2e9eefa..6288c5f 100644 --- a/utils/api.py +++ b/utils/api.py @@ -2128,10 +2128,160 @@ async def POST_search_guild_members( status_code=500, detail=f"Internal server error: {str(e)}" ) + async def POST_erlc_webhook(self, request: Request): + signature = request.headers.get("X-Signature-Ed25519") + timestamp = request.headers.get("X-Signature-Timestamp") + if not signature or not timestamp: + raise HTTPException(status_code=401, detail="Missing signature headers") + + body = await request.body() + logger.info(f"ERLC webhook received: {body[:500]}") + if not verify_erlc_signature(signature, timestamp, body): + raise HTTPException(status_code=401, detail="Invalid signature") + + payload = await request.json() + server_key = payload.get("server") + events = payload.get("events", []) + logger.info(f"ERLC webhook: server={server_key}, events={len(events)}, types={[e.get('event') for e in events]}") + + guild_doc = await self.bot.server_keys.db.find_one({"key": server_key}) + if not guild_doc: + guild_doc = await self.bot.server_keys.db.find_one({"key": {"$regex": f"{server_key}$"}}) + if not guild_doc: + logger.warning(f"ERLC webhook: no guild found for server key {server_key[:10]}...") + return {"status": "ok"} + + guild_id = guild_doc["_id"] + settings = await self.bot.settings.find_by_id(guild_id) + if not settings: + return {"status": "ok"} + + guild = self.bot.get_guild(guild_id) + if not guild: + return {"status": "ok"} + + for event in events: + event_type = event.get("event") + data = event.get("data", {}) + + if event_type == "CustomCommand": + await self._handle_custom_command(guild, settings, event) + + return {"status": "ok"} + + async def _handle_custom_command(self, guild, settings, event): + data = event.get("data", {}) + command_text = data.get("command", "") + argument = data.get("argument", "") + caller_id = int(event.get("origin", 0)) or None + logger.info(f"Custom command: command={command_text}, arg={argument}, caller={caller_id}") + + cmd_config = settings.get("ERLC", {}).get("ingame_commands", {}) + if not cmd_config.get("enabled"): + logger.info("In-game commands disabled") + return + + commands = cmd_config.get("commands", []) + logger.info(f"Available triggers: {[c.get('trigger') for c in commands]}") + matched = None + for cmd in commands: + if cmd.get("trigger", "").lower() == command_text.lower(): + matched = cmd + break + + if not matched: + return + + action = matched.get("action") + + # Resolve the caller's Discord member + member = None + if caller_id: + try: + member = await self.bot.accounts.roblox_to_discord(guild, "", roblox_user_id=caller_id) + except Exception: + member = None + logger.info(f"Resolved caller {caller_id} -> {member}") + + if action == "move_to_voice": + if not member: + logger.info("move_to_voice: no member found") + return + voice_channels = matched.get("voice_channels", {}) + target_channel_id = voice_channels.get(argument) or matched.get("channel") + if not target_channel_id: + logger.info(f"move_to_voice: no channel for arg={argument}, voice_channels={voice_channels}") + return + channel = guild.get_channel(int(target_channel_id)) + if not channel: + logger.info(f"move_to_voice: channel {target_channel_id} not found") + return + if not member.voice: + logger.info(f"move_to_voice: {member} not in a voice channel") + return + try: + await member.move_to(channel) + except discord.HTTPException: + pass + + elif action == "send_message": + channel_id = matched.get("channel") + if not channel_id: + return + channel = guild.get_channel(int(channel_id)) + if not channel: + return + message = matched.get("message", "") + roblox_name = "" + if caller_id: + try: + roblox_user = await self.bot.roblox.get_user(caller_id) + roblox_name = roblox_user.name + except Exception: + pass + message = message.replace("{player}", roblox_name).replace("{arg}", argument) + await channel.send(message) + + elif action == "ping_role": + channel_id = matched.get("channel") + role_id = matched.get("role") + if not channel_id or not role_id: + return + channel = guild.get_channel(int(channel_id)) + if not channel: + return + message = matched.get("message", "") + roblox_name = "" + if caller_id: + try: + roblox_user = await self.bot.roblox.get_user(caller_id) + roblox_name = roblox_user.name + except Exception: + pass + message = message.replace("{player}", roblox_name).replace("{arg}", argument) + await channel.send( + content=f"<@&{role_id}> {message}", + allowed_mentions=discord.AllowedMentions(roles=True), + ) + api = FastAPI() from fastapi import Request +from cryptography.hazmat.primitives.serialization import load_der_public_key +import base64 + +ERLC_PUBLIC_KEY = load_der_public_key(base64.b64decode(config("ERLC_WEBHOOK_PUBLIC_KEY", default="MCowBQYDK2VwAyEAjSICb9pp0kHizGQtdG8ySWsDChfGqi+gyFCttigBNOA="))) + + +def verify_erlc_signature(signature_hex: str, timestamp: str, body: bytes) -> bool: + try: + signature = bytes.fromhex(signature_hex) + message = timestamp.encode("utf-8") + body + ERLC_PUBLIC_KEY.verify(signature, message) + return True + except Exception: + return False class MyMiddleware: