Skip to content

Commit 4f95ad2

Browse files
author
timhauke
committed
Feat: Enhance Lavalink integration and track requester details
- Centralize Lavalink configuration in `settings.py` and refactor `LavalinkService` for robust node management and event handling. - Enhance the `/status lavalink` command to provide comprehensive node details, including uptime, memory, CPU, frame statistics, and version. - Implement detailed requester information storage on `lavalink.AudioTrack` objects, capturing all Discord user flags and badges. - Integrate the display of these requester badges across various commands and events, including `/queue`, "Now Playing" embeds, playlist embeds, and profile embeds. - Improve voice channel permission checks in `connection_commands.py` for more precise error messages. - Update documentation workflow to use a Puppeteer configuration for `mmdc` to ensure consistent architecture diagram rendering.
1 parent 52cbbd9 commit 4f95ad2

File tree

14 files changed

+168
-84
lines changed

14 files changed

+168
-84
lines changed

.github/workflows/docs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
run: npm install -g @mermaid-js/mermaid-cli
3232

3333
- name: Regenerate architecture diagram
34-
run: mmdc -i docs/system_architecture.mmd -o assets/images/architecture.png -t dark
34+
run: mmdc -i docs/system_architecture.mmd -o assets/images/architecture.png -t dark -p docs/puppeteer-config.json
3535

3636
- name: Ensure diagram is committed
3737
run: git diff --exit-code assets/images/architecture.png

docs/puppeteer-config.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"args": ["--no-sandbox", "--disable-setuid-sandbox"]
3+
}

src/commands/connection_commands.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Optional
77

88
import discord
9+
import lavalink
910
from discord import app_commands
1011
from discord.ext import commands
1112

@@ -31,16 +32,21 @@ def _channel_info(channel: discord.VoiceChannel) -> str:
3132
f"User limit `{channel.user_limit or '∞'}`"
3233
)
3334

34-
def _permissions_summary(self, member: discord.Member, channel: discord.VoiceChannel) -> str:
35+
def _permissions_summary(
36+
self, member: discord.Member, channel: discord.VoiceChannel
37+
) -> tuple[str, list[str]]:
3538
"""List permission status for required voice capabilities."""
3639
perms = channel.permissions_for(member)
3740
lines = []
41+
missing = []
3842
for attr in REQUIRED_VOICE_PERMS:
3943
label = attr.replace("_", " ").title()
4044
granted = getattr(perms, attr, False)
4145
icon = "✅" if granted else "❌"
4246
lines.append(f"{icon} {label}")
43-
return "\n".join(lines)
47+
if not granted:
48+
missing.append(attr)
49+
return "\n".join(lines), missing
4450

4551
@staticmethod
4652
def _find_player(bot: commands.Bot, guild_id: int) -> Optional[lavalink.DefaultPlayer]:
@@ -85,12 +91,12 @@ async def connect(self, inter: discord.Interaction):
8591
if not me:
8692
return await inter.response.send_message("Unable to resolve bot member.", ephemeral=True)
8793

88-
summary = self._permissions_summary(me, channel)
89-
missing = [attr for attr in REQUIRED_VOICE_PERMS if "❌" in summary.splitlines()[REQUIRED_VOICE_PERMS.index(attr)]]
94+
summary, missing = self._permissions_summary(me, channel)
9095
if missing:
96+
missing_lines = "\n".join(f"- {attr.replace('_', ' ').title()}" for attr in missing)
9197
embed = factory.error(
9298
"I am missing voice permissions in this channel:",
93-
"\n".join(f"- {attr.replace('_', ' ').title()}" for attr in missing),
99+
missing_lines,
94100
)
95101
return await inter.response.send_message(embed=embed, ephemeral=True)
96102

@@ -106,7 +112,8 @@ async def connect(self, inter: discord.Interaction):
106112
if player.volume != profile.default_volume:
107113
await player.set_volume(profile.default_volume)
108114

109-
embed = factory.success("Connected", f"Joined voice channel:\n{self._channel_info(channel)}")
115+
connection_details = f"Joined voice channel:\n{self._channel_info(channel)}"
116+
embed = factory.success("Connected", connection_details)
110117
embed.add_field(name="Permissions", value=summary, inline=False)
111118
await inter.response.send_message(embed=embed)
112119

@@ -159,7 +166,7 @@ async def voiceinfo(self, inter: discord.Interaction):
159166
embed.add_field(name="Players Active", value=f"`{player.is_playing}`", inline=True)
160167
embed.add_field(name="Queue Size", value=f"`{len(player.queue)}`", inline=True)
161168

162-
summary = self._permissions_summary(inter.guild.me, channel) # type: ignore
169+
summary, _ = self._permissions_summary(inter.guild.me, channel) # type: ignore[arg-type]
163170
embed.add_field(name="Permissions", value=summary, inline=False)
164171

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

src/commands/info_commands.py

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import os
88
import platform
99
import statistics
10-
from collections import Counter
1110
from typing import Optional, Tuple
1211

1312
import discord
@@ -202,10 +201,15 @@ async def status(self, inter: discord.Interaction):
202201

203202
embed.add_field(
204203
name="Guild Footprint",
205-
value=(
206-
f"`{self._format_number(guild_count)}` guilds\n"
207-
f"`{self._format_number(member_count)}` members\n"
208-
f"`{self._format_number(text_channels)}` text / `{self._format_number(voice_channels)}` voice channels"
204+
value="\n".join(
205+
[
206+
f"`{self._format_number(guild_count)}` guilds",
207+
f"`{self._format_number(member_count)}` members",
208+
(
209+
f"`{self._format_number(text_channels)}` text / "
210+
f"`{self._format_number(voice_channels)}` voice channels"
211+
),
212+
]
209213
),
210214
inline=False,
211215
)
@@ -219,10 +223,12 @@ async def status(self, inter: discord.Interaction):
219223
inline=True,
220224
)
221225

222-
runtime_info = (
223-
f"Python `{platform.python_version()}`\n"
224-
f"discord.py `{discord.__version__}`\n"
225-
f"Host `{platform.system()} {platform.release()}`"
226+
runtime_info = "\n".join(
227+
[
228+
f"Python `{platform.python_version()}`",
229+
f"discord.py `{discord.__version__}`",
230+
f"Host `{platform.system()} {platform.release()}`",
231+
]
226232
)
227233
embed.add_field(name="Runtime", value=runtime_info, inline=True)
228234

@@ -259,7 +265,13 @@ async def botinfo(self, inter: discord.Interaction):
259265
owner_names = ", ".join(owner.name for owner in ([app_info.owner] if app_info.owner else []))
260266
guild_count = len(self.bot.guilds)
261267
total_members = sum(g.member_count or 0 for g in self.bot.guilds)
262-
unique_users = len({member.id for g in self.bot.guilds for member in g.members}) if inter.guild else total_members
268+
if inter.guild:
269+
unique_users = len({member.id for member in inter.guild.members})
270+
else:
271+
seen_users: set[int] = set()
272+
for guild in self.bot.guilds:
273+
seen_users.update(member.id for member in guild.members)
274+
unique_users = len(seen_users)
263275
cpu_percent, memory_mb = self._process_metrics()
264276

265277
embed = factory.primary("🤖 Bot Information")
@@ -275,15 +287,14 @@ async def botinfo(self, inter: discord.Interaction):
275287
),
276288
inline=False,
277289
)
278-
embed.add_field(
279-
name="Runtime",
280-
value=(
281-
f"Python `{platform.python_version()}`\n"
282-
f"discord.py `{discord.__version__}`\n"
283-
f"Host `{platform.system()} {platform.release()}`"
284-
),
285-
inline=True,
290+
runtime_meta = "\n".join(
291+
[
292+
f"Python `{platform.python_version()}`",
293+
f"discord.py `{discord.__version__}`",
294+
f"Host `{platform.system()} {platform.release()}`",
295+
]
286296
)
297+
embed.add_field(name="Runtime", value=runtime_meta, inline=True)
287298
if cpu_percent is not None or memory_mb is not None:
288299
process_lines = []
289300
if cpu_percent is not None:
@@ -348,16 +359,26 @@ async def lavalink(self, inter: discord.Interaction):
348359

349360
embed = factory.primary("🎛️ Lavalink Nodes")
350361
for node in nodes:
362+
cpu_line = (
363+
f"CPU `{(node['cpu_lavalink'] or 0) * 100:.1f}%` LL / "
364+
f"`{(node['cpu_system'] or 0) * 100:.1f}%` SYS"
365+
)
366+
memory_line = (
367+
f"Memory `{self._format_bytes(node['memory_used'])}` / "
368+
f"`{self._format_bytes(node['memory_allocated'])}`"
369+
)
351370
lines = [
352371
f"Region `{node['region']}`",
353372
f"Players `{node['playing']}/{node['players']}`",
354-
f"CPU `{(node['cpu_lavalink'] or 0)*100:.1f}%` LL / `{(node['cpu_system'] or 0)*100:.1f}%` SYS",
355-
f"Memory `{self._format_bytes(node['memory_used'])}` / `{self._format_bytes(node['memory_allocated'])}`",
373+
cpu_line,
374+
memory_line,
356375
]
357376
if node["frames"] is not None:
358-
lines.append(
359-
f"Frames sent `{node['frames']}`, deficit `{node['deficit']}`, nulled `{node['nulled']}`"
377+
frame_line = (
378+
f"Frames sent `{node['frames']}`, deficit `{node['deficit']}`, "
379+
f"nulled `{node['nulled']}`"
360380
)
381+
lines.append(frame_line)
361382
embed.add_field(name=node["name"], value="\n".join(lines), inline=False)
362383

363384
await inter.response.send_message(embed=embed)

src/commands/music_controls.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,9 @@ def _build_nowplaying_embed(
130130
embed.add_field(name="Up Next", value=self._up_next_block(player), inline=False)
131131
return embed
132132

133-
def _tag_tracks(self, tracks: List[lavalink.AudioTrack], requester: Optional[discord.abc.User]) -> List[lavalink.AudioTrack]:
133+
def _tag_tracks(
134+
self, tracks: List[lavalink.AudioTrack], requester: Optional[discord.abc.User]
135+
) -> List[lavalink.AudioTrack]:
134136
"""Attach requester metadata to Lavalink track objects."""
135137
if requester:
136138
requester_id = requester.id
@@ -186,7 +188,8 @@ async def _player(self, inter: discord.Interaction) -> Optional[lavalink.Default
186188
player = self.bot.lavalink.player_manager.get(inter.guild.id)
187189

188190
if not player:
189-
await inter.response.send_message(embed=factory.error("Failed to establish Lavalink player."), ephemeral=True)
191+
error_embed = factory.error("Failed to establish Lavalink player.")
192+
await inter.response.send_message(embed=error_embed, ephemeral=True)
190193
return None
191194

192195
if not player.is_connected:
@@ -471,7 +474,9 @@ def disable_all_items(self):
471474
child.disabled = True
472475

473476
@discord.ui.button(label="Refresh", style=discord.ButtonStyle.secondary)
474-
async def refresh_button(self, interaction: discord.Interaction, button: discord.ui.Button): # type: ignore[override]
477+
async def refresh_button( # type: ignore[override]
478+
self, interaction: discord.Interaction, button: discord.ui.Button
479+
):
475480
"""Allow users to refresh the embed on demand."""
476481
await self.refresh()
477482
if not interaction.response.is_done():

src/commands/queue_commands.py

Lines changed: 53 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,21 @@ async def queue(self, inter: discord.Interaction):
7979
items.append(f"`Now` {track_str(player.current)}")
8080
items.extend(track_str(track) for track in player.queue)
8181

82-
paginator = EmbedPaginator(entries=items, per_page=10, guild_id=inter.guild.id if inter.guild else None)
82+
paginator = EmbedPaginator(
83+
entries=items,
84+
per_page=10,
85+
guild_id=inter.guild.id if inter.guild else None,
86+
)
8387
embed = paginator.make_embed()
8488
embed.title = "🎶 Queue Overview"
8589
embed.description = "\n".join(items[:10])
86-
embed.set_footer(
87-
text=f"{embed.footer.text}{self._queue_summary(player)}" if embed.footer and embed.footer.text else self._queue_summary(player)
88-
)
90+
summary_text = self._queue_summary(player)
91+
footer_text = embed.footer.text if embed.footer else None
92+
footer_icon = getattr(embed.footer, "icon_url", None) if embed.footer else None
93+
if footer_text:
94+
embed.set_footer(text=f"{footer_text}{summary_text}", icon_url=footer_icon)
95+
else:
96+
embed.set_footer(text=summary_text, icon_url=None)
8997
await inter.response.send_message(embed=embed, view=paginator, ephemeral=True)
9098

9199
@app_commands.command(name="remove", description="Remove a track by its 1-based position.")
@@ -143,7 +151,12 @@ async def shuffle(self, inter: discord.Interaction):
143151

144152
@app_commands.command(name="move", description="Move a track within the queue.")
145153
@app_commands.describe(src="From (1-based)", dest="To (1-based)")
146-
async def move(self, inter: discord.Interaction, src: app_commands.Range[int, 1, 9999], dest: app_commands.Range[int, 1, 9999]):
154+
async def move(
155+
self,
156+
inter: discord.Interaction,
157+
src: app_commands.Range[int, 1, 9999],
158+
dest: app_commands.Range[int, 1, 9999],
159+
):
147160
"""Reorder a track within the queue."""
148161
factory = EmbedFactory(inter.guild.id if inter.guild else None)
149162
if not inter.guild:
@@ -224,11 +237,13 @@ async def playlist_save(self, inter: discord.Interaction, name: str, include_cur
224237

225238
cleaned = name.strip()
226239
if not cleaned or len(cleaned) > 64:
227-
return await inter.response.send_message(embed=factory.error("Playlist name must be 1-64 characters."), ephemeral=True)
240+
error_embed = factory.error("Playlist name must be 1-64 characters.")
241+
return await inter.response.send_message(embed=error_embed, ephemeral=True)
228242

229243
player = self._player(inter.guild)
230244
if not player or (not player.queue and not player.current):
231-
return await inter.response.send_message(embed=factory.warning("No tracks to persist."), ephemeral=True)
245+
warning_embed = factory.warning("No tracks to persist.")
246+
return await inter.response.send_message(embed=warning_embed, ephemeral=True)
232247

233248
tracks: List[lavalink.AudioTrack] = []
234249
if include_current and player.current:
@@ -255,10 +270,10 @@ async def playlist_save(self, inter: discord.Interaction, name: str, include_cur
255270
inter.user.id,
256271
exc,
257272
)
258-
return await inter.response.send_message(
259-
embed=factory.error("Failed to save playlist. Please try again later."), ephemeral=True
260-
)
261-
embed = factory.success("Playlist Saved", f"Stored **{count}** track(s) as `{cleaned}`.")
273+
error_embed = factory.error("Failed to save playlist. Please try again later.")
274+
return await inter.response.send_message(embed=error_embed, ephemeral=True)
275+
save_message = f"Stored **{count}** track(s) as `{cleaned}`."
276+
embed = factory.success("Playlist Saved", save_message)
262277
embed.add_field(name="Tip", value="Use `/playlist load` to queue the playlist later.", inline=False)
263278
await inter.response.send_message(embed=embed, ephemeral=True)
264279

@@ -274,17 +289,22 @@ async def playlist_load(self, inter: discord.Interaction, name: str, replace_que
274289

275290
player = self._player(inter.guild)
276291
if not player or not player.is_connected:
277-
return await inter.response.send_message(
278-
embed=factory.error("VectoBeat must be connected to voice before loading a playlist. Use `/connect` first."),
279-
ephemeral=True,
292+
message = (
293+
"VectoBeat must be connected to voice before loading a playlist. "
294+
"Use `/connect` first."
280295
)
296+
error_embed = factory.error(message)
297+
return await inter.response.send_message(embed=error_embed, ephemeral=True)
281298

282299
await inter.response.defer(ephemeral=True)
283300

284301
service = self._playlist_service()
302+
default_requester = inter.user.id if isinstance(inter.user, discord.User) else None
285303
try:
286304
tracks = await service.load_playlist(
287-
inter.guild.id, name.strip(), default_requester=inter.user.id if isinstance(inter.user, discord.User) else None
305+
inter.guild.id,
306+
name.strip(),
307+
default_requester=default_requester,
288308
)
289309
if self.bot.logger:
290310
self.bot.logger.info(
@@ -303,12 +323,11 @@ async def playlist_load(self, inter: discord.Interaction, name: str, replace_que
303323
inter.user.id,
304324
exc,
305325
)
306-
return await inter.followup.send(
307-
embed=factory.error("Failed to load playlist from storage. Please try again later."),
308-
ephemeral=True,
309-
)
326+
error_embed = factory.error("Failed to load playlist from storage. Please try again later.")
327+
return await inter.followup.send(embed=error_embed, ephemeral=True)
310328
if not tracks:
311-
return await inter.followup.send(embed=factory.warning(f"No playlist found with the name `{name}`."), ephemeral=True)
329+
warning = factory.warning(f"No playlist found with the name `{name}`.")
330+
return await inter.followup.send(embed=warning, ephemeral=True)
312331

313332
autop_flag = player.fetch("autoplay_enabled")
314333
if replace_queue:
@@ -337,7 +356,8 @@ async def playlist_load(self, inter: discord.Interaction, name: str, replace_que
337356
if len(tracks) > 5:
338357
summary += f"\n...`{len(tracks) - 5}` more"
339358

340-
embed = factory.success("Playlist Loaded", f"Queued **{len(tracks)}** track(s) from `{name}`.")
359+
load_message = f"Queued **{len(tracks)}** track(s) from `{name}`."
360+
embed = factory.success("Playlist Loaded", load_message)
341361
embed.add_field(name="Preview", value=summary, inline=False)
342362
embed.add_field(name="Queue Summary", value=self._queue_summary(player), inline=False)
343363
await inter.followup.send(embed=embed, ephemeral=True)
@@ -356,11 +376,11 @@ async def playlist_list(self, inter: discord.Interaction):
356376
except PlaylistStorageError as exc:
357377
if self.bot.logger:
358378
self.bot.logger.error("Failed to list playlists for guild %s: %s", inter.guild.id, exc)
359-
return await inter.response.send_message(
360-
embed=factory.error("Unable to query playlists from storage. Please try again later."), ephemeral=True
361-
)
379+
error_embed = factory.error("Unable to query playlists from storage. Please try again later.")
380+
return await inter.response.send_message(embed=error_embed, ephemeral=True)
362381
if not names:
363-
return await inter.response.send_message(embed=factory.warning("No playlists saved yet."), ephemeral=True)
382+
warning_embed = factory.warning("No playlists saved yet.")
383+
return await inter.response.send_message(embed=warning_embed, ephemeral=True)
364384

365385
embed = factory.primary("Saved Playlists")
366386
embed.description = "\n".join(f"- `{name}`" for name in names)
@@ -378,7 +398,10 @@ async def playlist_delete(self, inter: discord.Interaction, name: str):
378398
removed = await service.delete_playlist(inter.guild.id, cleaned)
379399
if removed and self.bot.logger:
380400
self.bot.logger.info(
381-
"Deleted playlist '%s' for guild %s by user %s", cleaned, inter.guild.id, inter.user.id
401+
"Deleted playlist '%s' for guild %s by user %s",
402+
cleaned,
403+
inter.guild.id,
404+
inter.user.id,
382405
)
383406
except PlaylistStorageError as exc:
384407
if self.bot.logger:
@@ -389,13 +412,11 @@ async def playlist_delete(self, inter: discord.Interaction, name: str):
389412
inter.user.id,
390413
exc,
391414
)
392-
return await inter.response.send_message(
393-
embed=factory.error("Failed to delete playlist from storage. Please try again later."), ephemeral=True
394-
)
415+
error_embed = factory.error("Failed to delete playlist from storage. Please try again later.")
416+
return await inter.response.send_message(embed=error_embed, ephemeral=True)
395417
if not removed:
396-
return await inter.response.send_message(
397-
embed=factory.warning(f"No playlist found with the name `{cleaned}`."), ephemeral=True
398-
)
418+
warning_embed = factory.warning(f"No playlist found with the name `{cleaned}`.")
419+
return await inter.response.send_message(embed=warning_embed, ephemeral=True)
399420

400421
embed = factory.success("Playlist Deleted", f"Removed `{cleaned}` from storage.")
401422
await inter.response.send_message(embed=embed, ephemeral=True)

0 commit comments

Comments
 (0)