feat: in-game custom commands via ERLC webhooks#12
Draft
HolsterJr10 wants to merge 1 commit into
Draft
Conversation
ar-cyber
reviewed
Jun 12, 2026
Comment on lines
+8770
to
+8876
| 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() | ||
|
|
||
|
|
Member
There was a problem hiding this comment.
This all needs to be in its own ui file following new styling changes. I'll have to do all the views in here another time.
Comment on lines
+2131
to
+2266
| 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), | ||
| ) | ||
|
|
Member
There was a problem hiding this comment.
Warning
These endpoints cannot be exposed to the public API. This should be implemented on the API or a seperate repo for something like this.
Comment on lines
+2271
to
+2284
| 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 |
Member
There was a problem hiding this comment.
Put this in utils/utils.py but following the previous comment in this file it shouldn't be here at all
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
receives ERLC v2 webhook events and executes configurable actions when players type ;commands in-game.
working:
not working yet: