Skip to content

feat: in-game custom commands via ERLC webhooks#12

Draft
HolsterJr10 wants to merge 1 commit into
ERM-Systems:Developmentfrom
HolsterJr10:feat-ingame-custom-commands
Draft

feat: in-game custom commands via ERLC webhooks#12
HolsterJr10 wants to merge 1 commit into
ERM-Systems:Developmentfrom
HolsterJr10:feat-ingame-custom-commands

Conversation

@HolsterJr10

Copy link
Copy Markdown

receives ERLC v2 webhook events and executes configurable actions when players type ;commands in-game.

working:

  • webhook endpoint with Ed25519 signature verification
  • config page in /config under ER:LC Integration (In-Game Commands button)
  • add/remove commands via modals
  • enable/disable toggle
  • command matching (trigger + argument parsing)
  • caller resolution (roblox id -> discord member via oauth2)
  • send_message and ping_role actions

not working yet:

  • move_to_voice needs more testing (channel mapping config is clunky)
  • debug logging still in place, needs cleanup before merge

Comment thread menus.py
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()


Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 thread utils/api.py
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),
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 thread utils/api.py
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put this in utils/utils.py but following the previous comment in this file it shouldn't be here at all

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants