Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions menus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Comment on lines +8770 to +8876

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.

class MoreERLCConfiguration(discord.ui.View):
def __init__(self, bot, settings):
super().__init__(timeout=None)
Expand Down
150 changes: 150 additions & 0 deletions utils/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)

Comment on lines +2131 to +2266

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.


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
Comment on lines +2271 to +2284

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



class MyMiddleware:
Expand Down