Skip to content
Closed
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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_INTERACTIONS_PUBLIC_KEY=
DISCORD_PUBLIC_KEY=${DISCORD_INTERACTIONS_PUBLIC_KEY}
NEXT_PUBLIC_DISCORD_CLIENT_ID=
DISCORD_SERVER_ID=1212545714798854164
NEXT_PUBLIC_DISCORD_SERVER_ID=${DISCORD_SERVER_ID}
NEXT_PUBLIC_DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
NEXT_PUBLIC_DISCORD_PUBLIC_KEY=${DISCORD_PUBLIC_KEY}

##################
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ bot/__pycache__/
frontend/.next/
frontend/node_modules/
frontend/dist/
test-report/*
bot/test-report/*
frontend/test-report/*

# Jest/coverage/cache
coverage/
Expand Down
3 changes: 3 additions & 0 deletions bot/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ share/python-wheels/
.installed.cfg
*.egg
MANIFEST
pyright_*
pytest.*
pytest_*

# PyInstaller
# Usually these files are written by a python script from a template
Expand Down
8 changes: 6 additions & 2 deletions bot/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ discord.py>=2.3.2
python-dotenv>=1.0.0
PyYAML>=6.0.1
pydantic>=2.4.2
PyNaCl>=1.5.0
PyNaCl>=1.6.2
lavalink>=5.9.0
redis>=5.0.1
prometheus-client>=0.20.0
pyinstrument>=5.1.1
aiohttp>=3.11.2
aiohttp>=3.13.3
aiofiles>=23.2.1
pytest>=8.0.0
pytest-html>=4.1.1
pytest-asyncio>=0.23.0
pytest-cov>=5.0.0
10 changes: 7 additions & 3 deletions bot/src/commands/chaos_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
import discord
from discord import app_commands
from discord.ext import commands
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from src.main import VectoBeat

from src.services.chaos_service import ChaosService
from src.utils.embeds import EmbedFactory


def _service(bot: commands.Bot) -> ChaosService:
def _service(bot: "VectoBeat") -> ChaosService:
service = getattr(bot, "chaos_service", None)
if not service:
raise RuntimeError("Chaos service not initialised.")
Expand All @@ -23,7 +26,7 @@ class ChaosCommands(commands.Cog):
"""Allow administrators to trigger or inspect chaos drills."""

def __init__(self, bot: commands.Bot):
self.bot = bot
self.bot = cast("VectoBeat", bot)

chaos = app_commands.Group(name="chaos", description="Chaos engineering playbook", guild_only=True)

Expand Down Expand Up @@ -61,7 +64,8 @@ async def status(self, inter: discord.Interaction) -> None:
@app_commands.describe(scenario="Scenario to run (leave empty for random).")
async def run(self, inter: discord.Interaction, scenario: Optional[str] = None) -> None:
if (error := self._ensure_manage_guild(inter)) is not None:
return await inter.response.send_message(error, ephemeral=True)
await inter.response.send_message(error, ephemeral=True)
return
service = _service(self.bot)
await inter.response.defer(ephemeral=True)
if scenario:
Expand Down
15 changes: 11 additions & 4 deletions bot/src/commands/compliance_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

from __future__ import annotations

from typing import TYPE_CHECKING, Dict, Any, Optional
from typing import TYPE_CHECKING, cast, Dict, Any, Optional
import io
from datetime import datetime, timezone

import discord
from discord import app_commands
from discord.ext import commands
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from src.main import VectoBeat

from src.utils.embeds import EmbedFactory
from src.utils.security import SensitiveScope, has_scope, log_sensitive_action
Expand All @@ -17,7 +20,7 @@
from src.services.analytics_export_service import AnalyticsExportService


def _service(bot: commands.Bot) -> AnalyticsExportService:
def _service(bot: "VectoBeat") -> AnalyticsExportService:
svc = getattr(bot, "analytics_export", None)
if not svc:
raise RuntimeError("Analytics export service not configured.")
Expand All @@ -28,7 +31,7 @@ class ComplianceCommands(commands.Cog):
"""Expose compliance-friendly exports for privileged staff."""

def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
self.bot = cast("VectoBeat", bot)

compliance = app_commands.Group(name="compliance", description="Compliance export controls", guild_only=True)

Expand Down Expand Up @@ -90,6 +93,9 @@ async def delete(self, inter: discord.Interaction, confirm: str) -> None:
if not self._is_admin(inter):
await inter.response.send_message("Compliance privileges required.", ephemeral=True)
return
if not inter.guild:
await inter.response.send_message("This command must be used in a guild.", ephemeral=True)
return
if confirm != "CONFIRM":
await inter.response.send_message("You must type 'CONFIRM' to execute deletion.", ephemeral=True)
return
Expand All @@ -104,7 +110,8 @@ async def delete(self, inter: discord.Interaction, confirm: str) -> None:
@compliance.command(name="status", description="Check compliance mode and data retention status.")
async def status(self, inter: discord.Interaction) -> None:
if not inter.guild:
return await inter.response.send_message("Guild only.", ephemeral=True)
await inter.response.send_message("Guild only.", ephemeral=True)
return

# Check Profile
profile_manager = getattr(self.bot, "profile_manager", None)
Expand Down
118 changes: 77 additions & 41 deletions bot/src/commands/connection_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
import lavalink
from discord import app_commands
from discord.ext import commands
from typing import TYPE_CHECKING, cast, Any

if TYPE_CHECKING:
from src.main import VectoBeat
from lavalink.errors import ClientError

from src.services.lavalink_service import LavalinkVoiceClient
Expand All @@ -21,11 +25,11 @@ class ConnectionCommands(commands.Cog):
"""Enterprise-ready voice connection controls for VectoBeat."""

def __init__(self, bot: commands.Bot):
self.bot = bot
self.bot: VectoBeat = cast(Any, bot) # type: ignore
self._connect_lock = asyncio.Lock()

@staticmethod
def _channel_info(channel: discord.VoiceChannel) -> str:
def _channel_info(channel: discord.VoiceChannel | discord.StageChannel) -> str:
"""Return a human friendly description of a voice channel."""
return (
f"`{channel.name}` (`{channel.id}`)\n"
Expand All @@ -34,7 +38,7 @@ def _channel_info(channel: discord.VoiceChannel) -> str:
)

def _permissions_summary(
self, member: discord.Member, channel: discord.VoiceChannel
self, member: discord.Member, channel: discord.VoiceChannel | discord.StageChannel
) -> tuple[str, list[str]]:
"""List permission status for required voice capabilities."""
perms = channel.permissions_for(member)
Expand All @@ -50,7 +54,7 @@ def _permissions_summary(
return "\n".join(lines), missing

@staticmethod
def _find_player(bot: commands.Bot, guild_id: int) -> Optional[lavalink.DefaultPlayer]:
def _find_player(bot: VectoBeat, guild_id: int) -> Optional[lavalink.DefaultPlayer]:
"""Return the Lavalink player associated with the guild."""
return bot.lavalink.player_manager.get(guild_id)

Expand All @@ -61,13 +65,14 @@ async def _ensure_ready(self):
try:
await manager.ensure_ready()
except Exception as exc: # pragma: no cover - defensive
if getattr(self.bot, "logger", None):
self.bot.logger.debug("Failed to refresh Lavalink nodes: %s", exc)
logger = getattr(self.bot, "logger", None)
if logger:
logger.debug("Failed to refresh Lavalink nodes: %s", exc)

# ------------------------------------------------------------------ commands
async def _configure_player(self, player: lavalink.DefaultPlayer, guild: discord.Guild, channel: discord.abc.GuildChannel) -> None:
"""Apply guild-specific settings to the player after connection."""
player.text_channel_id = channel.id
player.store("text_channel_id", channel.id)
manager = getattr(self.bot, "profile_manager", None)
settings_service = getattr(self.bot, "server_settings", None)

Expand All @@ -88,35 +93,48 @@ async def connect(self, inter: discord.Interaction) -> None:
factory = EmbedFactory(inter.guild.id if inter.guild else None)

if not inter.guild:
return await inter.response.send_message("This command can only be used within a guild.", ephemeral=True)
await inter.response.send_message("This command can only be used within a guild.", ephemeral=True)
return
assert inter.guild is not None

member = inter.guild.get_member(inter.user.id) if isinstance(inter.user, discord.User) else inter.user
if not member or not member.voice or not member.voice.channel:
voice = getattr(member, "voice", None)
if not member or not voice or not voice.channel:
error_embed = factory.error("You must be in a voice channel.")
return await inter.response.send_message(embed=error_embed, ephemeral=True)
await inter.response.send_message(embed=error_embed, ephemeral=True)
return

async with self._connect_lock:
await self._ensure_ready()

if not getattr(self, "bot", None):
return

player = self._find_player(self.bot, inter.guild.id)
if player and player.is_connected:
embed = factory.warning("Already connected.")
embed.add_field(name="Channel", value=self._channel_info(player.channel), inline=False) # type: ignore
return await inter.response.send_message(embed=embed, ephemeral=True)

if not self.bot.lavalink.node_manager.available_nodes:
return await inter.response.send_message(
if player.channel_id:
vc = inter.client.get_channel(int(player.channel_id))
if isinstance(vc, (discord.VoiceChannel, discord.StageChannel)):
embed.add_field(name="Channel", value=self._channel_info(vc), inline=False)
await inter.response.send_message(embed=embed, ephemeral=True)
return

if getattr(self.bot, "lavalink", None) and not self.bot.lavalink.node_manager.available_nodes:
await inter.response.send_message(
embed=factory.error("Lavalink node is offline. Please check connectivity."), ephemeral=True
)
return

me = inter.guild.me or inter.guild.get_member(self.bot.user.id)
channel = member.voice.channel
if not isinstance(channel, discord.VoiceChannel):
return await inter.response.send_message("I can only join standard voice channels.", ephemeral=True)
me = inter.guild.me or inter.guild.get_member(getattr(self.bot.user, "id", 0))
channel = voice.channel
if not isinstance(channel, (discord.VoiceChannel, discord.StageChannel)):
await inter.response.send_message("I can only join standard voice and stage channels.", ephemeral=True)
return

if not me:
return await inter.response.send_message("Unable to resolve bot member.", ephemeral=True)
await inter.response.send_message("Unable to resolve bot member.", ephemeral=True)
return

summary, missing = self._permissions_summary(me, channel)
if missing:
Expand All @@ -125,30 +143,35 @@ async def connect(self, inter: discord.Interaction) -> None:
"I am missing voice permissions in this channel:",
missing_lines,
)
return await inter.response.send_message(embed=embed, ephemeral=True)
await inter.response.send_message(embed=embed, ephemeral=True)
return

try:
await channel.connect(cls=LavalinkVoiceClient) # type: ignore[arg-type]
except ClientError as exc:
if getattr(self.bot, "logger", None):
self.bot.logger.warning("Lavalink not available for guild %s: %s", inter.guild.id, exc)
return await inter.response.send_message(
logger = getattr(self.bot, "logger", None)
if logger:
logger.warning("Lavalink not available for guild %s: %s", inter.guild.id, exc)
await inter.response.send_message(
embed=factory.error(
"No Lavalink node is currently available. Please ensure the server is running and reachable."
),
ephemeral=True,
)
return
except Exception as exc: # pragma: no cover - defensive
if getattr(self.bot, "logger", None):
self.bot.logger.error("Voice connection failed for guild %s: %s", inter.guild.id, exc)
return await inter.response.send_message(
logger = getattr(self.bot, "logger", None)
if logger:
logger.error("Voice connection failed for guild %s: %s", inter.guild.id, exc)
await inter.response.send_message(
embed=factory.error("Unable to join the voice channel right now. Please try again shortly."),
ephemeral=True,
)
return

player = self._find_player(self.bot, inter.guild.id)
if player:
await self._configure_player(player, inter.guild, inter.channel)
if player and isinstance(inter.channel, (discord.TextChannel, discord.VoiceChannel, discord.StageChannel, discord.Thread)):
await self._configure_player(player, inter.guild, inter.channel) # type: ignore

connection_details = f"Joined voice channel:\n{self._channel_info(channel)}"
embed = factory.success("Connected", connection_details)
Expand All @@ -160,21 +183,25 @@ async def disconnect(self, inter: discord.Interaction) -> None:
"""Disconnect from voice and destroy the Lavalink player."""
factory = EmbedFactory(inter.guild.id if inter.guild else None)
if not inter.guild:
return await inter.response.send_message("This command can only be used within a guild.", ephemeral=True)
await inter.response.send_message("This command can only be used within a guild.", ephemeral=True)
return

voice_client = inter.guild.voice_client
player = self._find_player(self.bot, inter.guild.id)

if not voice_client and not player:
return await inter.response.send_message(
await inter.response.send_message(
embed=factory.warning("VectoBeat is not connected."),
ephemeral=True,
)
return

details = []
if voice_client:
details.append(f"Left `{voice_client.channel.name}`")
await voice_client.disconnect()
if getattr(voice_client, "channel", None):
cname = getattr(voice_client.channel, "name", "Voice Channel")
details.append(f"Left `{cname}`")
await voice_client.disconnect(force=False)

if player:
await player.stop()
Expand All @@ -189,27 +216,36 @@ async def voiceinfo(self, inter: discord.Interaction) -> None:
"""Display diagnostics for the current voice session."""
factory = EmbedFactory(inter.guild.id if inter.guild else None)
if not inter.guild:
return await inter.response.send_message("This command can only be used within a guild.", ephemeral=True)
await inter.response.send_message("This command can only be used within a guild.", ephemeral=True)
return

player = self._find_player(self.bot, inter.guild.id)
voice_client = inter.guild.voice_client

if not player or not player.is_connected or not voice_client:
warning_embed = factory.warning("VectoBeat is not connected.")
return await inter.response.send_message(embed=warning_embed, ephemeral=True)
await inter.response.send_message(embed=warning_embed, ephemeral=True)
return

channel = getattr(voice_client, "channel", None)
if not channel:
warning_embed = factory.warning("VectoBeat is disconnected.")
await inter.response.send_message(embed=warning_embed, ephemeral=True)
return

embed = factory.primary("🔊 Voice Session")
channel = voice_client.channel # type: ignore
embed.add_field(name="Channel", value=self._channel_info(channel), inline=False)
embed.add_field(name="Channel", value=f"`{channel.name}` (`{channel.id}`)", inline=False)

latencies = getattr(self.bot, "latencies", [])
shard_latency = next((lat for sid, lat in latencies if sid == inter.guild.shard_id), self.bot.latency)
shard_latency = next((lat for sid, lat in latencies if sid == inter.guild.shard_id), getattr(self.bot, "latency", 0))
embed.add_field(name="Gateway Latency", value=f"`{shard_latency*1000:.2f} ms`", inline=True)
embed.add_field(name="Players Active", value=f"`{player.is_playing}`", inline=True)
embed.add_field(name="Queue Size", value=f"`{len(player.queue)}`", inline=True)
embed.add_field(name="Queue Size", value=f"`{len(getattr(player, 'queue', []))}`", inline=True)

summary, _ = self._permissions_summary(inter.guild.me, channel) # type: ignore[arg-type]
embed.add_field(name="Permissions", value=summary, inline=False)
me = inter.guild.me or inter.guild.get_member(getattr(self.bot.user, "id", 0))
if me and isinstance(channel, (discord.VoiceChannel, discord.StageChannel)):
summary, _ = self._permissions_summary(me, channel)
embed.add_field(name="Permissions", value=summary, inline=False)

await inter.response.send_message(embed=embed, ephemeral=True)

Expand Down
Loading
Loading