From 7aca7c7bf785c26210df2308eb2ef0b9072abacd Mon Sep 17 00:00:00 2001 From: Ismael Leon Date: Sun, 27 Sep 2026 02:52:48 -0600 Subject: [PATCH 1/4] Design interactive controls and slash commands --- .../design/2026-09-27-interactive-controls.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/design/2026-09-27-interactive-controls.md diff --git a/docs/design/2026-09-27-interactive-controls.md b/docs/design/2026-09-27-interactive-controls.md new file mode 100644 index 0000000..802cb23 --- /dev/null +++ b/docs/design/2026-09-27-interactive-controls.md @@ -0,0 +1,114 @@ +# Interactive controls and slash commands + +## Goal + +Make the bot usable without typing commands: buttons on the Now Playing +message, and every command available as a `/` command alongside `!`. + +## Decisions + +- **Hybrid commands.** Every command answers to both `!name` and `/name`. + Aliases (`!p`, `!q`, `!bass`, …) stay prefix-only, so the `/` menu does not + fill with duplicates. +- **Buttons.** Two rows on each Now Playing message: `⏮ ⏯ ⏭ ⏹` and + `🔁 🔀 📜 🎤`. No effects menu. +- **Old messages.** When the next track starts, or the player is destroyed, the + previous Now Playing message keeps its embed and its buttons are disabled. + +## Architecture + +### `utils/controls.py` — shared playback actions + +Pure functions over `Optional[MusicPlayer]` returning an `Outcome(ok, message)`: +`pause`, `resume`, `toggle_pause`, `skip`, `previous`, `shuffle`, `set_loop`, +`cycle_loop`. They hold the wording ("Nothing is playing.", "Skipped ⏭") in one +place. Commands and buttons both call them and differ only in how they deliver +the result. + +### `utils/now_playing_view.py` — the buttons + +`NowPlayingControls(discord.ui.View)`, built per announcement with the player, +the track and its requester. + +- `interaction_check`: the presser must be in the bot's voice channel, and the + player must still be the guild's live one. Otherwise an ephemeral refusal. +- `⏯` and `🔁` edit the message in place (the button's emoji, the Loop field). +- `⏭ ⏮ ⏹ 🔀` post a short public line naming who pressed it, because they + change playback for everyone. +- `📜` answers with the queue, ephemerally. +- `🎤` posts the lyrics to the channel through a `LyricsProvider`, which the + Lyrics cog registers on load and clears on unload. The view never imports the + cog. With no provider registered, the button is not added. +- `timeout=None`, so a long track never loses its controls. A view with no + timeout stays in discord.py's view store until `stop()` is called, so every + retired view is stopped; otherwise each track played would leak one. + +### `utils/announcer.py` + +`now_playing` sends the embed with a fresh view and remembers the message. +`retire()` disables and stops the previous view. It runs before each new +announcement and when the player is destroyed. + +### Hybrid migration + +- `/play` defers first: joining voice can take up to 20s and yt-dlp 3–8s, and + an interaction must be answered within 3s. `ctx.defer()` does not check + whether the interaction was already answered, so it is called exactly once; + `ctx.typing()` does check, and is safe after it. +- `/play` on an idle player replies "Starting …", because the Now Playing + announcement goes to the channel, and a deferred interaction left without a + reply shows "thinking…" forever. +- `/loop mode:` is `Literal["track", "queue", "off"]` and `/volume vol:` is + `commands.Range[int, 0, 100]`. Discord then offers and validates them, and + under `!` a bad value is a `BadArgument` that `utils.errors` already reports. +- Effects: `!effect` with no argument still shows the active effect; + `!effect ` and `/effect name:` (a choice list built from `EFFECTS`) + apply one. The `!bass`-style shortcuts are unchanged. +- `/play query:` autocompletes from YouTube's search-suggestion endpoint + (`services/suggestions.py`). It is unofficial, so it has a 1.5s timeout and + any failure yields no suggestions rather than an error. URLs are not + completed. +- Slash commands are registered with Discord by an owner-only, hidden `!sync` + (`cogs/admin.py`), run once after a deploy that changes commands. Syncing on + every start would hit Discord's rate limit, and the bot restarts on its own. +- The command tree is declared guild-only (`allowed_contexts`), so `/` commands + are not offered in DMs at all. The existing `guild_only` check still covers + `!`. +- Errors and voice-check refusals are sent with `ephemeral=True`: under `/` only + the person who erred sees them; under `!` the flag is ignored. +- `utils.errors` unwraps `HybridCommandError` as well as `CommandInvokeError`. +- `!help` and the bot's presence mention `/play`. + +## Edge cases + +| Situation | Behaviour | +|---|---| +| Button pressed by someone outside the bot's voice channel | Ephemeral "join my voice channel" | +| Button pressed after the player was destroyed but before the message was retired | Ephemeral "This player has ended" | +| `⏭` with nothing playing | Ephemeral "Nothing is playing." | +| `⏮` with no history | Ephemeral "No previous track in history." | +| `🔀` on an empty queue | Ephemeral "Queue is empty." | +| `🎤` finds no lyrics | Ephemeral "Couldn't find lyrics" | +| Admin disconnects the bot from voice | Player is destroyed, which retires the message | +| Now Playing channel deleted | Sending and retiring swallow `HTTPException`, as announcing already does | +| Bot restarts | Old messages' buttons answer "interaction failed". Known limitation: no player survives a restart for them to control | +| Suggestion endpoint slow or down | `/play` offers no suggestions and still works | +| `/play` in a DM | Not offered by Discord | + +## Testing + +Unit tests, no gateway: + +- `controls`: every action with no player, idle player and playing player. +- `NowPlayingControls`: the interaction check (outside voice, destroyed + player), each button's delivery (edit vs public line vs ephemeral), the + lyrics button's presence with and without a provider. +- `ChannelAnnouncer`: retiring disables and stops the previous view; a failing + edit is swallowed. +- `suggestions`: parses the endpoint's JSON, returns `[]` on timeout, bad JSON + and URLs. +- Commands: `/play` defers once and answers on an idle player; `!effect` with and + without a name; errors unwrap `HybridCommandError`; replies are ephemeral. + +Manual, in a server, after `!sync`: each button, the `/` menu with its +choices and autocompletion, and a button pressed from outside voice. From 0e0e471791e2a7249abc2a56c4ee25868f5a6d74 Mon Sep 17 00:00:00 2001 From: Ismael Leon Date: Sun, 27 Sep 2026 03:09:37 -0600 Subject: [PATCH 2/4] Add playback buttons to the Now Playing message Pause, skip, previous, stop, loop, shuffle, queue and lyrics can be used from buttons by anyone in the bot's voice channel. The actions live in utils/controls.py so commands and buttons share one implementation and one wording. When the next track starts, or the player is destroyed, the previous message's buttons are disabled and its view is stopped, so views never pile up in discord.py's store. --- tests/test_announcer.py | 67 +++++++++++ tests/test_controls.py | 100 +++++++++++++++++ tests/test_now_playing_view.py | 199 +++++++++++++++++++++++++++++++++ utils/announcer.py | 41 ++++++- utils/controls.py | 79 +++++++++++++ utils/now_playing_view.py | 151 +++++++++++++++++++++++++ utils/player.py | 7 +- 7 files changed, 635 insertions(+), 9 deletions(-) create mode 100644 tests/test_announcer.py create mode 100644 tests/test_controls.py create mode 100644 tests/test_now_playing_view.py create mode 100644 utils/controls.py create mode 100644 utils/now_playing_view.py diff --git a/tests/test_announcer.py b/tests/test_announcer.py new file mode 100644 index 0000000..259e39e --- /dev/null +++ b/tests/test_announcer.py @@ -0,0 +1,67 @@ +"""Now Playing messages: each one gets controls, and the previous one loses them.""" + +from unittest.mock import AsyncMock, MagicMock + +import discord +import pytest + +from utils import now_playing_view +from utils.announcer import ChannelAnnouncer +from utils.now_playing_view import NowPlayingControls + + +@pytest.fixture +def channel(): + ch = MagicMock() + ch.send = AsyncMock(side_effect=lambda **kw: MagicMock(edit=AsyncMock())) + return ch + + +@pytest.fixture(autouse=True) +def no_lyrics(monkeypatch): + monkeypatch.setattr(now_playing_view, "_lyrics", None) + + +async def test_now_playing_is_sent_with_controls(channel, player, track_factory): + announcer = ChannelAnnouncer(channel) + await announcer.now_playing(player, track_factory("Song"), MagicMock()) + view = channel.send.await_args.kwargs["view"] + assert isinstance(view, NowPlayingControls) and not view.is_finished() + + +async def test_the_next_track_retires_the_previous_controls(channel, player, track_factory): + announcer = ChannelAnnouncer(channel) + await announcer.now_playing(player, track_factory("One"), MagicMock()) + first_view = channel.send.await_args.kwargs["view"] + first_message = announcer._live[0] + + await announcer.now_playing(player, track_factory("Two"), MagicMock()) + + assert first_view.is_finished() + assert all(item.disabled for item in first_view.children) + first_message.edit.assert_awaited_once_with(view=first_view) + + +async def test_retire_with_nothing_shown_does_nothing(channel): + await ChannelAnnouncer(channel).retire() + + +async def test_retire_survives_a_deleted_message(channel, player, track_factory): + announcer = ChannelAnnouncer(channel) + await announcer.now_playing(player, track_factory("Song"), MagicMock()) + announcer._live[0].edit.side_effect = discord.NotFound(MagicMock(), "gone") + await announcer.retire() + assert announcer._live is None + + +async def test_a_failed_send_leaves_no_live_view(channel, player, track_factory): + channel.send.side_effect = discord.Forbidden(MagicMock(), "no access") + announcer = ChannelAnnouncer(channel) + await announcer.now_playing(player, track_factory("Song"), MagicMock()) + assert announcer._live is None + + +def test_destroying_the_player_retires_its_controls(player): + player.announcer.retire = MagicMock() + player.destroy() + player.announcer.retire.assert_called_once() diff --git a/tests/test_controls.py b/tests/test_controls.py new file mode 100644 index 0000000..8f5c691 --- /dev/null +++ b/tests/test_controls.py @@ -0,0 +1,100 @@ +""" +The playback actions shared by commands and Now Playing buttons. + +Each action is checked with no player, with an idle one and with one that is +playing, since the buttons can be pressed in any of those states. +""" + +from unittest.mock import MagicMock + +import pytest + +from utils import controls + + +@pytest.fixture +def voice(fake_guild): + vc = MagicMock() + vc.is_playing.return_value = True + vc.is_paused.return_value = False + fake_guild.voice_client = vc + return vc + + +def test_pause_a_playing_track(player, voice): + outcome = controls.pause(player) + assert outcome.ok and "Paused" in outcome.message + voice.pause.assert_called_once() + + +def test_resume_a_paused_track(player, voice): + voice.is_playing.return_value = False + voice.is_paused.return_value = True + outcome = controls.resume(player) + assert outcome.ok and "Resumed" in outcome.message + voice.resume.assert_called_once() + + +@pytest.mark.parametrize("action", [controls.pause, controls.skip]) +def test_actions_on_an_idle_player_report_nothing_playing(player, action): + outcome = action(player) + assert not outcome.ok and "Nothing is playing" in outcome.message + + +@pytest.mark.parametrize("action", [ + controls.pause, controls.resume, controls.skip, + controls.previous, controls.shuffle, controls.cycle_loop, controls.stop, +]) +def test_actions_without_a_player_fail_politely(action): + assert not action(None).ok + + +def test_resume_when_nothing_is_paused(player): + assert controls.resume(player).message == "Nothing is paused." + + +def test_skip_a_playing_track(player, voice): + outcome = controls.skip(player) + assert outcome.ok and "Skipped" in outcome.message + voice.stop.assert_called_once() + + +def test_previous_with_no_history(player): + outcome = controls.previous(player) + assert not outcome.ok and "No previous track" in outcome.message + + +def test_previous_with_history(player, track_factory): + player.history.append(track_factory("Earlier")) + assert controls.previous(player).ok + + +def test_shuffle_an_empty_queue(player): + outcome = controls.shuffle(player) + assert not outcome.ok and "empty" in outcome.message + + +def test_shuffle_a_queue(player, track_factory): + player.add_many([track_factory(str(i)) for i in range(5)]) + assert controls.shuffle(player).ok + assert len(player.queue) == 5 + + +@pytest.mark.parametrize("start, expected", [ + ("off", "track"), ("track", "queue"), ("queue", "off"), +]) +def test_cycle_loop_walks_every_mode(player, start, expected): + player.loop_mode = start + outcome = controls.cycle_loop(player) + assert outcome.ok and player.loop_mode == expected + assert expected in outcome.message + + +def test_set_loop(player): + assert controls.set_loop(player, "queue").ok + assert player.loop_mode == "queue" + + +def test_stop_destroys_the_player(player): + outcome = controls.stop(player) + assert outcome.ok and player.is_destroyed diff --git a/tests/test_now_playing_view.py b/tests/test_now_playing_view.py new file mode 100644 index 0000000..b86d20c --- /dev/null +++ b/tests/test_now_playing_view.py @@ -0,0 +1,199 @@ +""" +The Now Playing buttons, pressed through faked interactions. + +Button callbacks are invoked directly with a fake interaction, the way +discord.py would after its own dispatch; ``interaction_check`` is tested on its +own, since discord.py runs it before any callback. +""" + +from unittest.mock import AsyncMock, MagicMock + +import discord +import pytest + +from utils import now_playing_view +from utils.now_playing_view import NowPlayingControls + + +@pytest.fixture +def voice(fake_guild): + vc = MagicMock() + vc.is_playing.return_value = True + vc.is_paused.return_value = False + vc.channel = MagicMock(name="voice-channel") + vc.disconnect = AsyncMock() + fake_guild.voice_client = vc + return vc + + +@pytest.fixture +def interaction(voice): + i = MagicMock() + i.user = MagicMock(spec=discord.Member) + i.user.mention = "<@42>" + i.user.voice.channel = voice.channel + i.response.send_message = AsyncMock() + i.response.edit_message = AsyncMock() + i.response.defer = AsyncMock() + i.followup.send = AsyncMock() + i.channel = MagicMock(spec=discord.TextChannel) + return i + + +@pytest.fixture +def no_lyrics(monkeypatch): + monkeypatch.setattr(now_playing_view, "_lyrics", None) + + +@pytest.fixture +async def view(player, track_factory, no_lyrics): + return NowPlayingControls(player, track_factory("Song"), MagicMock()) + + +def reply(interaction) -> tuple[str, bool]: + """The text of the interaction's reply, and whether it was ephemeral.""" + call = interaction.response.send_message.await_args + return call.kwargs["embed"].description, call.kwargs.get("ephemeral", False) + + +def button(view: NowPlayingControls, emoji: str) -> discord.ui.Button: + return next(b for b in view.children + if isinstance(b, discord.ui.Button) and str(b.emoji) == emoji) + + +async def press(view: NowPlayingControls, emoji: str, interaction) -> None: + b = button(view, emoji) + await b.callback(interaction) + + +# -- who may press ------------------------------------------------------ + +async def test_a_listener_in_the_bots_channel_may_press(view, interaction): + assert await view.interaction_check(interaction) is True + + +async def test_someone_outside_the_voice_channel_is_refused(view, interaction): + interaction.user.voice.channel = MagicMock(name="another-channel") + assert await view.interaction_check(interaction) is False + text, ephemeral = reply(interaction) + assert ephemeral and "voice channel" in text + + +async def test_someone_not_in_voice_at_all_is_refused(view, interaction): + interaction.user.voice = None + assert await view.interaction_check(interaction) is False + + +async def test_a_destroyed_player_refuses_every_press(view, interaction): + view.player.destroy() + assert await view.interaction_check(interaction) is False + text, ephemeral = reply(interaction) + assert ephemeral and "ended" in text + + +# -- each button ---------------------------------------------------------- + +async def test_pause_edits_the_message_and_flips_the_button(view, interaction, voice): + await press(view, "⏸", interaction) + voice.pause.assert_called_once() + interaction.response.edit_message.assert_awaited_once() + assert str(view.pause_button.emoji) == "▶️" + + +async def test_pause_again_resumes(view, interaction, voice): + await press(view, "⏸", interaction) + voice.is_playing.return_value = False + voice.is_paused.return_value = True + await press(view, "▶️", interaction) + voice.resume.assert_called_once() + assert str(view.pause_button.emoji) == "⏸" + + +async def test_skip_is_announced_publicly_with_who_pressed(view, interaction, voice): + await press(view, "⏭", interaction) + voice.stop.assert_called_once() + text, ephemeral = reply(interaction) + assert not ephemeral and "Skipped" in text and "<@42>" in text + + +async def test_skip_with_nothing_playing_is_a_private_error(view, interaction, voice): + voice.is_playing.return_value = False + await press(view, "⏭", interaction) + text, ephemeral = reply(interaction) + assert ephemeral and "Nothing is playing" in text + + +async def test_previous_with_no_history_is_a_private_error(view, interaction): + await press(view, "⏮", interaction) + text, ephemeral = reply(interaction) + assert ephemeral and "No previous track" in text + + +async def test_stop_destroys_the_player(view, interaction): + await press(view, "⏹", interaction) + assert view.player.is_destroyed + text, ephemeral = reply(interaction) + assert not ephemeral and "Disconnected" in text + + +async def test_loop_cycles_and_shows_the_mode_in_place(view, interaction): + await press(view, "🔁", interaction) + assert view.player.loop_mode == "track" + embed = interaction.response.edit_message.await_args.kwargs["embed"] + assert any(f.value == "Track" for f in embed.fields) + + +async def test_shuffle_an_empty_queue_is_a_private_error(view, interaction): + await press(view, "🔀", interaction) + text, ephemeral = reply(interaction) + assert ephemeral and "empty" in text + + +async def test_queue_is_shown_only_to_who_asked(view, interaction): + await press(view, "📜", interaction) + call = interaction.response.send_message.await_args + assert call.kwargs["ephemeral"] is True + assert "Queue" in call.kwargs["embed"].title + + +# -- lyrics ------------------------------------------------------------------- + +async def test_no_lyrics_button_without_a_provider(view): + assert all(str(getattr(b, "emoji", "")) != "🎤" for b in view.children) + + +async def test_lyrics_button_hands_the_channel_to_the_provider( + player, track_factory, interaction, monkeypatch): + provider = AsyncMock(return_value=True) + monkeypatch.setattr(now_playing_view, "_lyrics", provider) + view = NowPlayingControls(player, track_factory("Song"), MagicMock()) + await press(view, "🎤", interaction) + provider.assert_awaited_once_with(interaction.channel, player) + interaction.followup.send.assert_not_awaited() + + +async def test_lyrics_not_found_is_a_private_error( + player, track_factory, interaction, monkeypatch): + monkeypatch.setattr(now_playing_view, "_lyrics", AsyncMock(return_value=False)) + view = NowPlayingControls(player, track_factory("Song"), MagicMock()) + await press(view, "🎤", interaction) + call = interaction.followup.send.await_args + assert call.kwargs["ephemeral"] is True + assert "Couldn't find lyrics" in call.kwargs["embed"].description + + +def test_registering_and_clearing_the_provider(monkeypatch): + monkeypatch.setattr(now_playing_view, "_lyrics", None) + provider = AsyncMock() + now_playing_view.set_lyrics_provider(provider) + assert now_playing_view._lyrics is provider + now_playing_view.set_lyrics_provider(None) + assert now_playing_view._lyrics is None + + +# -- retiring ------------------------------------------------------------------- + +async def test_retire_disables_every_button_and_stops_the_view(view): + view.retire() + assert all(item.disabled for item in view.children) + assert view.is_finished() diff --git a/utils/announcer.py b/utils/announcer.py index bafa92f..3ef6b0e 100644 --- a/utils/announcer.py +++ b/utils/announcer.py @@ -8,11 +8,16 @@ """ import logging +from typing import TYPE_CHECKING, Optional import discord from services.media import Track -from utils.embeds import info_embed, load_error_embed, now_playing_embed +from utils.embeds import info_embed, load_error_embed +from utils.now_playing_view import NowPlayingControls + +if TYPE_CHECKING: + from utils.player import MusicPlayer log = logging.getLogger("loopify.announcer") @@ -22,10 +27,30 @@ class ChannelAnnouncer: def __init__(self, channel: discord.abc.Messageable) -> None: self.channel = channel + # The Now Playing message whose buttons are still live, if any. + self._live: Optional[tuple[discord.Message, NowPlayingControls]] = None + + async def now_playing(self, player: "MusicPlayer", track: Track, + requester: discord.abc.User) -> None: + await self.retire() + view = NowPlayingControls(player, track, requester) + message = await self._send(view.embed(), view) + if message is None: + view.stop() + else: + self._live = (message, view) - async def now_playing(self, track: Track, requester: discord.abc.User, - loop_mode: str) -> None: - await self._send(now_playing_embed(track, requester, loop_mode=loop_mode)) + async def retire(self) -> None: + """Disable the live Now Playing buttons, so only the current track has any.""" + live, self._live = self._live, None + if live is None: + return + message, view = live + view.retire() + try: + await message.edit(view=view) + except discord.HTTPException as e: + log.debug("Could not retire Now Playing controls: %s", e) async def load_failed(self, track: Track) -> None: await self._send(load_error_embed(track)) @@ -36,10 +61,14 @@ async def idle_disconnect(self, after_seconds: float) -> None: f"Disconnected after {after_seconds / 60:.0f} minutes of inactivity.", )) - async def _send(self, embed: discord.Embed) -> None: + async def _send(self, embed: discord.Embed, + view: Optional[discord.ui.View] = None) -> Optional[discord.Message]: """A channel that has been deleted, or that we lost access to, is not an error worth propagating into the playback loop.""" try: - await self.channel.send(embed=embed) + if view is None: + return await self.channel.send(embed=embed) + return await self.channel.send(embed=embed, view=view) except discord.HTTPException as e: log.debug("Could not announce to channel: %s", e) + return None diff --git a/utils/controls.py b/utils/controls.py new file mode 100644 index 0000000..69554a8 --- /dev/null +++ b/utils/controls.py @@ -0,0 +1,79 @@ +""" +Playback actions shared by commands and the Now Playing buttons. + +Both surfaces do the same thing and must say the same thing; only how the +answer is delivered differs — an embed for a command, an in-place edit or an +ephemeral reply for a button. So each action returns an :class:`Outcome` and +leaves the delivery to its caller. +""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal, Optional + +if TYPE_CHECKING: + from utils.player import MusicPlayer + +LoopMode = Literal["off", "track", "queue"] + +LOOP_ICONS: dict[str, str] = {"off": "➡️", "track": "🔂", "queue": "🔁"} +_NEXT_LOOP: dict[str, LoopMode] = {"off": "track", "track": "queue", "queue": "off"} + +NOTHING_PLAYING = "Nothing is playing." +STOPPED = "Disconnected and cleared the queue." + + +@dataclass(frozen=True) +class Outcome: + ok: bool + message: str + + +def pause(player: Optional["MusicPlayer"]) -> Outcome: + if player and player.pause(): + return Outcome(True, "Paused ⏸") + return Outcome(False, "Nothing is playing right now.") + + +def resume(player: Optional["MusicPlayer"]) -> Outcome: + if player and player.resume(): + return Outcome(True, "Resumed ▶️") + return Outcome(False, "Nothing is paused.") + + +def skip(player: Optional["MusicPlayer"]) -> Outcome: + if player and player.skip(): + return Outcome(True, "Skipped ⏭") + return Outcome(False, NOTHING_PLAYING) + + +def previous(player: Optional["MusicPlayer"]) -> Outcome: + if player and player.go_previous(): + return Outcome(True, "Playing previous track ⏮") + return Outcome(False, "No previous track in history.") + + +def shuffle(player: Optional["MusicPlayer"]) -> Outcome: + if not player or player.is_empty: + return Outcome(False, "Queue is empty.") + player.shuffle() + return Outcome(True, "Queue shuffled 🔀") + + +def set_loop(player: Optional["MusicPlayer"], mode: LoopMode) -> Outcome: + if not player: + return Outcome(False, NOTHING_PLAYING) + player.loop_mode = mode + return Outcome(True, f"Loop mode set to **{mode}** {LOOP_ICONS[mode]}") + + +def cycle_loop(player: Optional["MusicPlayer"]) -> Outcome: + if not player: + return Outcome(False, NOTHING_PLAYING) + return set_loop(player, _NEXT_LOOP[player.loop_mode]) + + +def stop(player: Optional["MusicPlayer"]) -> Outcome: + if not player: + return Outcome(False, NOTHING_PLAYING) + player.destroy() + return Outcome(True, STOPPED) diff --git a/utils/now_playing_view.py b/utils/now_playing_view.py new file mode 100644 index 0000000..431a99f --- /dev/null +++ b/utils/now_playing_view.py @@ -0,0 +1,151 @@ +""" +The buttons under each Now Playing message. + +Every button runs the same action as its command (see :mod:`utils.controls`); +what differs is the delivery. A button that changes what the message shows +(pause, loop) edits it in place. One that changes playback for everyone (skip, +previous, stop, shuffle) answers publicly, naming who pressed it. Anything +personal — the queue, a refusal, an error — is ephemeral. + +The lyrics button needs the Lyrics cog, which this module must not import: the +cog registers itself with :func:`set_lyrics_provider` on load, and without a +provider the button is left off. +""" + +from typing import TYPE_CHECKING, Awaitable, Callable, Optional + +import discord + +from services.media import Track +from utils import controls +from utils.embeds import error_embed, now_playing_embed, queue_embed, success_embed + +if TYPE_CHECKING: + from utils.player import MusicPlayer + +# Posts the playing track's lyrics to a channel; False when there are none. +LyricsProvider = Callable[[discord.abc.Messageable, "MusicPlayer"], Awaitable[bool]] + +_lyrics: Optional[LyricsProvider] = None + +PAUSE, RESUME = "⏸", "▶️" + + +def set_lyrics_provider(provider: Optional[LyricsProvider]) -> None: + global _lyrics + _lyrics = provider + + +class NowPlayingControls(discord.ui.View): + def __init__(self, player: "MusicPlayer", track: Track, + requester: discord.abc.User) -> None: + # No timeout: a long track must not lose its controls halfway through. + # The announcer retires the view instead, which also frees it. + super().__init__(timeout=None) + self.player = player + self.track = track + self.requester = requester + if _lyrics is None: + self.remove_item(self.lyrics_button) + + def embed(self) -> discord.Embed: + return now_playing_embed(self.track, self.requester, + loop_mode=self.player.loop_mode) + + def retire(self) -> None: + """Grey every button out and release the view from discord.py's store.""" + for item in self.children: + if isinstance(item, discord.ui.Button): + item.disabled = True + self.stop() + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if self.player.is_destroyed: + await _private_error(interaction, "This player has ended.") + return False + vc = self.player.voice + user = interaction.user + listening = (isinstance(user, discord.Member) and user.voice is not None + and vc is not None and user.voice.channel == vc.channel) + if not listening: + await _private_error(interaction, + "Join my voice channel to use these controls.") + return False + return True + + async def _announce(self, interaction: discord.Interaction, + outcome: controls.Outcome) -> None: + if not outcome.ok: + return await _private_error(interaction, outcome.message) + await interaction.response.send_message(embed=success_embed( + f"{outcome.message} — {interaction.user.mention}")) + + async def _edit_in_place(self, interaction: discord.Interaction, + outcome: controls.Outcome) -> None: + if not outcome.ok: + return await _private_error(interaction, outcome.message) + await interaction.response.edit_message(embed=self.embed(), view=self) + + # -- Row 1: transport --------------------------------------------------- + + @discord.ui.button(emoji="⏮", style=discord.ButtonStyle.secondary, row=0) + async def previous_button(self, interaction: discord.Interaction, + _button: "discord.ui.Button[NowPlayingControls]") -> None: + await self._announce(interaction, controls.previous(self.player)) + + @discord.ui.button(emoji=PAUSE, style=discord.ButtonStyle.primary, row=0) + async def pause_button(self, interaction: discord.Interaction, + button: "discord.ui.Button[NowPlayingControls]") -> None: + if controls.pause(self.player).ok: + button.emoji = RESUME + elif controls.resume(self.player).ok: + button.emoji = PAUSE + else: + return await _private_error(interaction, controls.NOTHING_PLAYING) + await interaction.response.edit_message(embed=self.embed(), view=self) + + @discord.ui.button(emoji="⏭", style=discord.ButtonStyle.secondary, row=0) + async def skip_button(self, interaction: discord.Interaction, + _button: "discord.ui.Button[NowPlayingControls]") -> None: + await self._announce(interaction, controls.skip(self.player)) + + @discord.ui.button(emoji="⏹", style=discord.ButtonStyle.danger, row=0) + async def stop_button(self, interaction: discord.Interaction, + _button: "discord.ui.Button[NowPlayingControls]") -> None: + await self._announce(interaction, controls.stop(self.player)) + + # -- Row 2: queue and extras ------------------------------------------ + + @discord.ui.button(emoji="🔁", style=discord.ButtonStyle.secondary, row=1) + async def loop_button(self, interaction: discord.Interaction, + _button: "discord.ui.Button[NowPlayingControls]") -> None: + await self._edit_in_place(interaction, controls.cycle_loop(self.player)) + + @discord.ui.button(emoji="🔀", style=discord.ButtonStyle.secondary, row=1) + async def shuffle_button(self, interaction: discord.Interaction, + _button: "discord.ui.Button[NowPlayingControls]") -> None: + await self._announce(interaction, controls.shuffle(self.player)) + + @discord.ui.button(emoji="📜", style=discord.ButtonStyle.secondary, row=1) + async def queue_button(self, interaction: discord.Interaction, + _button: "discord.ui.Button[NowPlayingControls]") -> None: + await interaction.response.send_message( + embed=queue_embed(self.player.to_list(), self.player.current), + ephemeral=True) + + @discord.ui.button(emoji="🎤", style=discord.ButtonStyle.secondary, row=1) + async def lyrics_button(self, interaction: discord.Interaction, + _button: "discord.ui.Button[NowPlayingControls]") -> None: + channel = interaction.channel + if _lyrics is None or not isinstance(channel, discord.abc.Messageable): + return await _private_error(interaction, "Lyrics are unavailable.") + # The lookup can take seconds; Discord wants an answer within three. + await interaction.response.defer() + if not await _lyrics(channel, self.player): + await interaction.followup.send(embed=error_embed( + "Couldn't find lyrics for this song."), ephemeral=True) + + +async def _private_error(interaction: discord.Interaction, message: str) -> None: + await interaction.response.send_message(embed=error_embed(message), + ephemeral=True) diff --git a/utils/player.py b/utils/player.py index fe0a842..fd43677 100644 --- a/utils/player.py +++ b/utils/player.py @@ -396,9 +396,7 @@ async def _player_loop(self) -> None: if not silent: await self.announcer.now_playing( - track, track.get("requester") or self.guild.me, - self.loop_mode, - ) + self, track, track.get("requester") or self.guild.me) await self._wait_for_end(track) # Measured from the spawn, not from _start_ts, which is @@ -523,6 +521,9 @@ def destroy(self) -> None: asyncio.ensure_future(vc.disconnect(force=True)) if self._task and not self._task.done(): self._task.cancel() + # destroy() is sync, so the Now Playing buttons are greyed out in a + # task of their own; left live, they would act on a dead player. + self.bot.loop.create_task(self.announcer.retire()) if self._on_destroy is not None: self._on_destroy(self.guild.id) From fa1c2472e1aeebd1e6463e86e97d0b0177dadc9b Mon Sep 17 00:00:00 2001 From: Ismael Leon Date: Sun, 27 Sep 2026 03:09:38 -0600 Subject: [PATCH 3/4] Make every command a slash command too Commands are hybrid: each answers to both !name and /name. /play defers before joining voice, answers even when Now Playing takes over, and suggests searches as you type. /loop and /effect offer their choices, and /volume its range. Errors and refusals are ephemeral under /, and the error handler unwraps the extra layer slash commands add. Slash commands are registered by an owner-only !sync rather than at every start. --- cogs/admin.py | 28 ++++++++ cogs/effects.py | 48 ++++++++++---- cogs/lyrics.py | 91 +++++++++++++++++--------- cogs/music.py | 130 ++++++++++++++++++++------------------ config.py | 1 + main.py | 8 ++- services/suggestions.py | 50 +++++++++++++++ tests/test_checks.py | 6 ++ tests/test_commands.py | 95 +++++++++++++++++++++++++--- tests/test_errors.py | 22 +++++++ tests/test_help.py | 10 ++- tests/test_lyrics_cog.py | 65 +++++++++++++++++++ tests/test_suggestions.py | 42 ++++++++++++ utils/checks.py | 26 ++++---- utils/context.py | 12 +++- utils/errors.py | 15 ++++- utils/help.py | 3 +- 17 files changed, 521 insertions(+), 131 deletions(-) create mode 100644 cogs/admin.py create mode 100644 services/suggestions.py create mode 100644 tests/test_suggestions.py diff --git a/cogs/admin.py b/cogs/admin.py new file mode 100644 index 0000000..4c92b46 --- /dev/null +++ b/cogs/admin.py @@ -0,0 +1,28 @@ +import logging + +from discord.ext import commands + +from utils.context import GuildContext +from utils.embeds import success_embed + +log = logging.getLogger("loopify.admin") + + +class Admin(commands.Cog, name="Admin"): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + # Not run at startup: Discord rate-limits syncing, and the bot restarts on + # its own (systemd, network loss). Run it once after a deploy that adds, + # removes or changes a command. + @commands.command(hidden=True) + @commands.is_owner() + async def sync(self, ctx: GuildContext) -> None: + """Register the slash commands with Discord.""" + synced = await self.bot.tree.sync() + log.info("Synced %d slash commands", len(synced)) + await ctx.send(embed=success_embed(f"Synced **{len(synced)}** slash commands.")) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Admin(bot)) diff --git a/cogs/effects.py b/cogs/effects.py index e67c396..702cf61 100644 --- a/cogs/effects.py +++ b/cogs/effects.py @@ -1,12 +1,14 @@ import time from dataclasses import dataclass +from typing import Optional import discord +from discord import app_commands from discord.ext import commands from config import COMMAND_PREFIX -from utils.checks import same_voice_channel -from utils.context import GuildContext +from utils.checks import in_same_voice_channel, same_voice_channel +from utils.context import GuildContext, hybrid_command from utils.embeds import BLURPLE, error_embed, success_embed from utils.player import players @@ -75,13 +77,13 @@ async def _switch_to(self, ctx: GuildContext, name: str | None, filter_str: str, if self._throttled(ctx): await ctx.send(embed=error_embed( f"Easy — wait {_EFFECT_COOLDOWN:.0f}s between effect changes." - )) + ), ephemeral=True) return player = players.get(ctx.guild.id) if player and player.apply_effect(name, filter_str, rate): await ctx.send(embed=success_embed(label)) else: - await ctx.send(embed=error_embed("Nothing is playing.")) + await ctx.send(embed=error_embed("Nothing is playing."), ephemeral=True) @commands.command(name=_EFFECT_NAMES[0], aliases=_EFFECT_NAMES[1:], help=f"Apply an audio effect. Use {COMMAND_PREFIX}effects to see " @@ -93,27 +95,45 @@ async def apply_effect(self, ctx: GuildContext) -> None: effect = EFFECTS[name] await self._switch_to(ctx, name, effect.filter, effect.label, effect.rate) - @commands.command(name="reset", aliases=["fxreset", "noeffect"]) + @hybrid_command(name="reset", aliases=["fxreset", "noeffect"]) @same_voice_channel() async def reset_effect(self, ctx: GuildContext) -> None: """Remove all audio effects.""" await self._switch_to(ctx, None, "", "Audio effects removed ✅") - @commands.command(name="effect") - async def current_effect(self, ctx: GuildContext) -> None: - """Show the active audio effect.""" - player = players.get(ctx.guild.id) - name = (player.effect_name if player else None) or "none" - await ctx.send(embed=success_embed(f"Current effect: **{name}**")) + @hybrid_command(name="effect") + @app_commands.describe(name="The effect to apply; leave it out to see the active one") + @app_commands.choices(name=[app_commands.Choice(name=n, value=n) for n in EFFECTS]) + async def effect(self, ctx: GuildContext, name: Optional[str] = None) -> None: + """Show the active audio effect, or apply one by name.""" + if name is None: + player = players.get(ctx.guild.id) + active = (player.effect_name if player else None) or "none" + await ctx.send(embed=success_embed(f"Current effect: **{active}**")) + return + name = name.lower() + chosen = EFFECTS.get(name) + if chosen is None: + await ctx.send(embed=error_embed( + f"Unknown effect `{name}`. See `{ctx.clean_prefix}effects`."), ephemeral=True) + return + if await in_same_voice_channel(ctx): + await self._switch_to(ctx, name, chosen.filter, chosen.label, chosen.rate) - @commands.command(name="effects") + @hybrid_command(name="effects") async def list_effects(self, ctx: GuildContext) -> None: """List all available audio effects.""" prefix = ctx.clean_prefix + if ctx.interaction is None: + names = ", ".join(f"`{prefix}{name}`" for name in EFFECTS) + else: + # The per-effect shortcuts are aliases, and aliases do not exist + # under `/`; there every effect goes through /effect. + names = ("Pick one with `/effect`: " + + ", ".join(f"`{name}`" for name in EFFECTS)) await ctx.send(embed=discord.Embed( title="🎛️ Available Effects", - description=", ".join(f"`{prefix}{name}`" for name in EFFECTS) - + f"\n\nUse `{prefix}reset` to remove all effects.", + description=f"{names}\n\nUse `{prefix}reset` to remove all effects.", color=BLURPLE, )) diff --git a/cogs/lyrics.py b/cogs/lyrics.py index e04b917..8c0784e 100644 --- a/cogs/lyrics.py +++ b/cogs/lyrics.py @@ -14,9 +14,10 @@ # Genius fallback then built a Cog instead of a result and raised TypeError, # which the command reported as nothing at all. from services.synced_lyrics import Line, index_at, search_terms -from utils.context import GuildContext +from utils import now_playing_view +from utils.context import GuildContext, hybrid_command from utils.embeds import (error_embed, info_embed, lyrics_embed, lyrics_pages, - synced_lyrics_embed) + success_embed, synced_lyrics_embed) from utils.player import MusicPlayer, players LyricsResult = synced_lyrics.Lyrics @@ -178,8 +179,10 @@ def __init__(self, bot: commands.Bot) -> None: async def cog_load(self) -> None: # One session for the cog. Building one per request is what leaked in #34. self._session = aiohttp.ClientSession() + now_playing_view.set_lyrics_provider(self.show_current) async def cog_unload(self) -> None: + now_playing_view.set_lyrics_provider(None) for guild_id in list(self._following): self.stop_following(guild_id) if self._session is not None: @@ -241,21 +244,22 @@ def stop_following(self, guild_id: int) -> None: if not task.done(): task.cancel() - async def _follow(self, ctx: GuildContext, player: MusicPlayer, + async def _follow(self, dest: discord.abc.Messageable, player: MusicPlayer, lyrics: LyricsResult) -> None: """Post the live message and start keeping it up to date.""" - self.stop_following(ctx.guild.id) # one per guild; the newest wins + guild_id = player.guild.id + self.stop_following(guild_id) # one per guild; the newest wins index = index_at(lyrics.lines, player.position) - message = await ctx.send( + message = await dest.send( embed=synced_lyrics_embed( lyrics.title, lyrics.artist, lyrics.lines, index, player.position, player.current.get("duration") if player.current else None), - view=FollowControls(lambda: self.stop_following(ctx.guild.id)), + view=FollowControls(lambda: self.stop_following(guild_id)), ) follower = LyricsFollower(message, player, self._for_track) - task = self.bot.loop.create_task(self._run_follower(ctx.guild.id, follower)) - self._following[ctx.guild.id] = (follower, task) + task = self.bot.loop.create_task(self._run_follower(guild_id, follower)) + self._following[guild_id] = (follower, task) async def _run_follower(self, guild_id: int, follower: LyricsFollower) -> None: try: @@ -267,13 +271,51 @@ async def _run_follower(self, guild_id: int, follower: LyricsFollower) -> None: finally: self._following.pop(guild_id, None) + # -- Showing what was found --------------------------------------- + + async def _present(self, dest: discord.abc.Messageable, found: LyricsResult, + player: Optional[MusicPlayer]) -> None: + """ + Post lyrics that were found. ``player`` is given when they are the + playing track's, and only then can they be followed: a lyric needs a + clock, and a search result has none. + """ + if found.instrumental: + await dest.send(embed=info_embed( + "\U0001f3b5 Instrumental", + f"**{found.title}** has no lyrics to show." + )) + return + if found.synced and player and player.current: + return await self._follow(dest, player, found) + + note = "not synced - showing the full lyrics" if player else "" + view = LyricsPages(found.title, found.artist, found.plain, note) + if view.total > 1: + await dest.send(embed=view.embed(), view=view) + else: + await dest.send(embed=view.embed()) + + async def show_current(self, dest: discord.abc.Messageable, + player: MusicPlayer) -> bool: + """Post the playing track's lyrics. False when there are none to post.""" + if not player.current: + return False + found = await self._for_track(player.current) + if found is None: + return False + await self._present(dest, found, player) + return True + # -- The command --------------------------------------------------- - @commands.command(aliases=["ly"]) + @hybrid_command(aliases=["ly"]) async def lyrics(self, ctx: GuildContext, *, query: Optional[str] = None) -> None: """Follow the lyrics of the current song, or look a song up.""" - async with ctx.typing(): + # Ephemeral under `/`: the lyrics themselves go to the channel (below), + # so all this interaction ever shows is an error or an acknowledgement. + async with ctx.typing(ephemeral=True): player = players.get(ctx.guild.id) if query: title = self._split_query(query)[0] @@ -285,32 +327,23 @@ async def lyrics(self, ctx: GuildContext, *, await ctx.send(embed=error_embed( f"Nothing is playing. Provide a song name: " f"`{ctx.clean_prefix}lyrics `" - )) + ), ephemeral=True) return if found is None: await ctx.send(embed=error_embed( f"Couldn't find lyrics for **{title}**." - )) + ), ephemeral=True) return - if found.instrumental: - await ctx.send(embed=info_embed( - "\U0001f3b5 Instrumental", - f"**{found.title}** has no lyrics to show." - )) + if ctx.interaction is None: + await self._present(ctx, found, None if query else player) return - - # Only the playing track can be followed: a lyric needs a clock, and - # a search result has none. - if found.synced and not query and player and player.current: - return await self._follow(ctx, player, found) - - note = "" if query else "not synced - showing the full lyrics" - view = LyricsPages(found.title, found.artist, found.plain, note) - if view.total > 1: - await ctx.send(embed=view.embed(), view=view) - else: - await ctx.send(embed=view.embed()) + # A reply to an interaction can only be edited with its token, which + # expires after 15 minutes; the live message is edited for as long + # as the music plays. Posted to the channel, it is the bot's own. + await self._present(ctx.channel, found, None if query else player) + await ctx.send(embed=success_embed("🎤 Lyrics posted."), + ephemeral=True) async def setup(bot: commands.Bot) -> None: diff --git a/cogs/music.py b/cogs/music.py index bd60e47..a6b755c 100644 --- a/cogs/music.py +++ b/cogs/music.py @@ -2,13 +2,16 @@ import logging from typing import Optional, cast +import aiohttp import discord +from discord import app_commands from discord.ext import commands -from services import media +from services import media, suggestions from services.media import Track +from utils import controls from utils.player import players, MusicPlayer, MAX_QUEUE -from utils.context import GuildContext +from utils.context import GuildContext, hybrid_command from utils.embeds import (added_embed, error_embed, now_playing_embed, queue_embed, success_embed) from utils.checks import user_in_voice, same_voice_channel @@ -35,9 +38,23 @@ def _is_playlist_url(query: str) -> bool: return "/sets/" in q or "/album/" in q # SoundCloud set / Bandcamp album +async def _reply(ctx: GuildContext, outcome: controls.Outcome) -> None: + """Report an action's outcome; under `/`, a failure only to whoever asked.""" + embed = success_embed(outcome.message) if outcome.ok else error_embed(outcome.message) + await ctx.send(embed=embed, ephemeral=not outcome.ok) + + class Music(commands.Cog, name="🎵 Music & Queue"): def __init__(self, bot: commands.Bot) -> None: self.bot = bot + self._session: Optional[aiohttp.ClientSession] = None + + async def cog_load(self) -> None: + self._session = aiohttp.ClientSession() + + async def cog_unload(self) -> None: + if self._session is not None: + await self._session.close() # ── Helpers ─────────────────────────────────────────────────────── @@ -75,7 +92,8 @@ def _player(self, ctx: GuildContext) -> MusicPlayer: # ── Playback commands ───────────────────────────────────────────── - @commands.command(aliases=["p"]) + @hybrid_command(aliases=["p"]) + @app_commands.describe(query="A song to search for, or a link") @commands.cooldown(rate=3, per=5.0, type=commands.BucketType.user) @commands.max_concurrency(1, per=commands.BucketType.user, wait=False) @user_in_voice() @@ -84,6 +102,10 @@ async def play(self, ctx: GuildContext, *, query: str) -> None: Tip: prefix a search with `sc:` to search SoundCloud, e.g. `!play sc: lofi`. """ + # Joining voice and asking yt-dlp can take far longer than the three + # seconds Discord gives a slash command to answer. Once only: unlike + # ctx.typing(), ctx.defer() fails on an interaction already answered. + await ctx.defer() query = query.strip() if len(query) > MAX_QUERY_LEN: await ctx.send(embed=error_embed("That query is too long.")) @@ -110,6 +132,14 @@ async def play(self, ctx: GuildContext, *, query: str) -> None: return await self._enqueue(ctx, player, [track], None) + @play.autocomplete("query") + async def _suggest(self, _interaction: discord.Interaction, + current: str) -> list[app_commands.Choice[str]]: + if self._session is None: + return [] + terms = await suggestions.fetch(self._session, current) + return [app_commands.Choice(name=t, value=t) for t in terms] + async def _enqueue(self, ctx: GuildContext, player: MusicPlayer, tracks: list[Track], batch_label: Optional[str]) -> None: """Add one or many tracks and report to the channel.""" @@ -124,6 +154,11 @@ async def _enqueue(self, ctx: GuildContext, player: MusicPlayer, return if not was_idle: await ctx.send(embed=added_embed(tracks[0])) + elif ctx.interaction is not None: + # Now Playing goes to the channel, not to this interaction, which + # would otherwise be left "thinking..." forever. + await ctx.send(embed=success_embed( + f"▶️ Starting **{tracks[0]['title']}**")) else: added = player.add_many(tracks) if added == 0: @@ -136,62 +171,44 @@ async def _enqueue(self, ctx: GuildContext, player: MusicPlayer, f"Added **{added} tracks** from {batch_label} to the queue.{skipped}" )) - @commands.command() + @hybrid_command() @same_voice_channel() async def pause(self, ctx: GuildContext) -> None: """Pause the current track.""" - # Routed through the player so it can stop its playback clock; that - # clock is what lets an effect change resume in the right place. - player = players.get(ctx.guild.id) - if player and player.pause(): - await ctx.send(embed=success_embed("Paused ⏸")) - else: - await ctx.send(embed=error_embed("Nothing is playing right now.")) + await _reply(ctx, controls.pause(players.get(ctx.guild.id))) - @commands.command() + @hybrid_command() @same_voice_channel() async def resume(self, ctx: GuildContext) -> None: """Resume a paused track.""" - player = players.get(ctx.guild.id) - if player and player.resume(): - await ctx.send(embed=success_embed("Resumed ▶️")) - else: - await ctx.send(embed=error_embed("Nothing is paused.")) + await _reply(ctx, controls.resume(players.get(ctx.guild.id))) - @commands.command() + @hybrid_command() @same_voice_channel() async def skip(self, ctx: GuildContext) -> None: """Skip the current track.""" - player = players.get(ctx.guild.id) - if player and player.skip(): - await ctx.send(embed=success_embed("Skipped ⏭")) - else: - await ctx.send(embed=error_embed("Nothing is playing.")) + await _reply(ctx, controls.skip(players.get(ctx.guild.id))) - @commands.command(aliases=["prev"]) + @hybrid_command(aliases=["prev"]) @same_voice_channel() async def previous(self, ctx: GuildContext) -> None: """Go back to the previous track.""" - player = players.get(ctx.guild.id) - if player and player.go_previous(): - await ctx.send(embed=success_embed("Playing previous track ⏮")) - else: - await ctx.send(embed=error_embed("No previous track in history.")) + await _reply(ctx, controls.previous(players.get(ctx.guild.id))) - @commands.command(aliases=["dc", "leave"]) + @hybrid_command(aliases=["dc", "leave"]) @same_voice_channel() async def stop(self, ctx: GuildContext) -> None: """Stop music and disconnect the bot.""" player = players.get(ctx.guild.id) if player: - player.destroy() + controls.stop(player) elif ctx.voice_client: await ctx.voice_client.disconnect(force=True) - await ctx.send(embed=success_embed("Disconnected and cleared the queue.")) + await ctx.send(embed=success_embed(controls.STOPPED)) # ── Queue commands ──────────────────────────────────────────────── - @commands.command(aliases=["q"]) + @hybrid_command(aliases=["q"]) async def queue(self, ctx: GuildContext, page: int = 1) -> None: """Show the current queue.""" player = players.get(ctx.guild.id) @@ -200,7 +217,7 @@ async def queue(self, ctx: GuildContext, page: int = 1) -> None: return await ctx.send(embed=queue_embed(player.to_list(), player.current, page=page)) - @commands.command(aliases=["np", "current"]) + @hybrid_command(aliases=["np", "current"]) async def nowplaying(self, ctx: GuildContext) -> None: """Show the currently playing track.""" player = players.get(ctx.guild.id) @@ -209,13 +226,11 @@ async def nowplaying(self, ctx: GuildContext) -> None: return await ctx.send(embed=now_playing_embed(player.current, ctx.author, loop_mode=player.loop_mode)) - @commands.command() + @hybrid_command() @same_voice_channel() - async def volume(self, ctx: GuildContext, vol: int) -> None: + async def volume(self, ctx: GuildContext, + vol: commands.Range[int, 0, 100]) -> None: """Set volume (0–100).""" - if not 0 <= vol <= 100: - await ctx.send(embed=error_embed("Volume must be between 0 and 100.")) - return player = players.get(ctx.guild.id) if not player: await ctx.send(embed=error_embed("Nothing is playing.")) @@ -223,34 +238,29 @@ async def volume(self, ctx: GuildContext, vol: int) -> None: player.set_volume(vol / 100) await ctx.send(embed=success_embed(f"Volume set to **{vol}%** 🔊")) - @commands.command() + @hybrid_command() + # Choices rather than a Literal annotation: the Literal converter is + # case-sensitive, and `!loop Track` has always worked. + @app_commands.choices(mode=[app_commands.Choice(name=m, value=m) + for m in ("track", "queue", "off")]) @same_voice_channel() async def loop(self, ctx: GuildContext, mode: str = "track") -> None: """Set loop mode: track | queue | off""" mode = mode.lower() - if mode not in ("track", "queue", "off"): - await ctx.send(embed=error_embed("Loop mode must be `track`, `queue`, or `off`.")) + if mode not in controls.LOOP_ICONS: + await ctx.send(embed=error_embed("Loop mode must be `track`, `queue`, or `off`."), + ephemeral=True) return - player = players.get(ctx.guild.id) - if not player: - await ctx.send(embed=error_embed("Nothing is playing.")) - return - player.loop_mode = mode - icons = {"track": "🔂", "queue": "🔁", "off": "➡️"} - await ctx.send(embed=success_embed(f"Loop mode set to **{mode}** {icons[mode]}")) + await _reply(ctx, controls.set_loop(players.get(ctx.guild.id), + cast(controls.LoopMode, mode))) - @commands.command() + @hybrid_command() @same_voice_channel() async def shuffle(self, ctx: GuildContext) -> None: """Shuffle the queue.""" - player = players.get(ctx.guild.id) - if not player or player.is_empty: - await ctx.send(embed=error_embed("Queue is empty.")) - return - player.shuffle() - await ctx.send(embed=success_embed("Queue shuffled 🔀")) + await _reply(ctx, controls.shuffle(players.get(ctx.guild.id))) - @commands.command() + @hybrid_command() @same_voice_channel() async def remove(self, ctx: GuildContext, index: int) -> None: """Remove a track from the queue by its position.""" @@ -261,7 +271,7 @@ async def remove(self, ctx: GuildContext, index: int) -> None: return await ctx.send(embed=success_embed(f"Removed **{track['title']}** from the queue.")) - @commands.command() + @hybrid_command() @same_voice_channel() async def move(self, ctx: GuildContext, from_pos: int, to_pos: int) -> None: """Move a track to another position in the queue.""" @@ -271,7 +281,7 @@ async def move(self, ctx: GuildContext, from_pos: int, to_pos: int) -> None: else: await ctx.send(embed=error_embed("Invalid positions.")) - @commands.command() + @hybrid_command() @same_voice_channel() async def clear(self, ctx: GuildContext) -> None: """Clear the queue (keeps the current track playing).""" @@ -280,7 +290,7 @@ async def clear(self, ctx: GuildContext) -> None: player.clear() await ctx.send(embed=success_embed("Queue cleared 🗑️")) - @commands.command() + @hybrid_command() @same_voice_channel() async def autoplay(self, ctx: GuildContext) -> None: """Toggle autoplay (auto-queue related tracks when the queue ends).""" diff --git a/config.py b/config.py index 9a7f820..d01e244 100644 --- a/config.py +++ b/config.py @@ -43,6 +43,7 @@ def project_path(value: Optional[str]) -> Optional[str]: "cogs.music", "cogs.effects", "cogs.lyrics", + "cogs.admin", ] diff --git a/main.py b/main.py index 22b22c9..03ba0bf 100644 --- a/main.py +++ b/main.py @@ -3,6 +3,7 @@ from typing import cast import discord +from discord import app_commands from discord.ext import commands import config @@ -27,6 +28,9 @@ intents=intents, help_command=None, # replaced by the generated one below case_insensitive=True, + # Every command acts on a server's voice and queue, so `/` commands are not + # offered in DMs at all. guild_only below still covers the `!` ones. + allowed_contexts=app_commands.AppCommandContext(guild=True), ) bot.add_check(guild_only) @@ -38,7 +42,7 @@ async def on_ready() -> None: await bot.change_presence( activity=discord.Activity( type=discord.ActivityType.listening, - name=f"{COMMAND_PREFIX}play", + name="/play", ) ) @@ -50,7 +54,7 @@ async def on_command_error(ctx: commands.Context[commands.Bot], await errors.handle(ctx, error) -@bot.command(name="help") +@bot.hybrid_command(name="help") async def help_command(ctx: commands.Context[commands.Bot]) -> None: """Show this message.""" await ctx.send(embed=build_help(bot, COMMAND_PREFIX)) diff --git a/services/suggestions.py b/services/suggestions.py new file mode 100644 index 0000000..fde0e0e --- /dev/null +++ b/services/suggestions.py @@ -0,0 +1,50 @@ +""" +Search suggestions for `/play`, from YouTube's autocomplete endpoint. + +The endpoint is unofficial and undocumented, so everything here is best +effort: a slow, changed or unreachable endpoint yields no suggestions, never an +error. Discord drops an autocomplete answer after 3 seconds, hence the short +timeout. +""" + +import logging +from typing import Any + +import aiohttp + +log = logging.getLogger("loopify.suggestions") + +SUGGEST_URL = "https://suggestqueries.google.com/complete/search" +REQUEST_TIMEOUT = 1.5 +MAX_SUGGESTIONS = 10 +MAX_CHOICE_LEN = 100 # Discord's cap on a choice's name and value + + +async def fetch(session: aiohttp.ClientSession, query: str) -> list[str]: + """Up to MAX_SUGGESTIONS search terms completing ``query``.""" + query = query.strip() + if not query or query.startswith("http"): + return [] + try: + async with session.get( + SUGGEST_URL, + # client=firefox answers plain JSON; ds=yt restricts to YouTube. + params={"client": "firefox", "ds": "yt", "q": query}, + timeout=aiohttp.ClientTimeout(total=REQUEST_TIMEOUT), + ) as response: + if response.status != 200: + return [] + payload = await response.json(content_type=None) + except Exception as e: + log.debug("Suggestions failed for %r: %s", query, e) + return [] + return _terms(payload) + + +def _terms(payload: Any) -> list[str]: + """The suggestions from ``[query, [term, ...], ...]``, or none if it isn't that.""" + if not (isinstance(payload, list) and len(payload) > 1 + and isinstance(payload[1], list)): + return [] + terms = [t[:MAX_CHOICE_LEN] for t in payload[1] if isinstance(t, str) and t] + return terms[:MAX_SUGGESTIONS] diff --git a/tests/test_checks.py b/tests/test_checks.py index 432a448..f882a57 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -84,3 +84,9 @@ async def test_same_channel_passes_when_the_bot_is_not_connected_yet(ctx): ctx.author.voice = in_channel(MagicMock()) ctx.voice_client = None assert await same_voice_channel().predicate(ctx) is True + + +async def test_refusals_are_private_under_slash(ctx): + """Under `/` only the person refused sees why; under `!` the flag is ignored.""" + assert await user_in_voice().predicate(ctx) is False + assert ctx.send.await_args.kwargs["ephemeral"] is True diff --git a/tests/test_commands.py b/tests/test_commands.py index 2c5d259..34b851d 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -13,6 +13,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from discord.ext import commands from cogs.music import Music, MAX_QUERY_LEN, _is_playlist_url from cogs.effects import EFFECTS, Effects @@ -27,6 +28,8 @@ def ctx(fake_guild): c.voice_client = None c.author = MagicMock() c.typing = MagicMock(return_value=AsyncMock()) + c.defer = AsyncMock() + c.interaction = None # invoked as `!command` unless a test says so return c @@ -120,17 +123,22 @@ async def test_stop_with_no_player_and_no_voice_still_confirms(music_cog, ctx): # -- out-of-range and oversized input ---------------------------------- -@pytest.mark.parametrize("vol", [-1, 101, 1000]) -async def test_volume_out_of_range_is_rejected(music_cog, ctx, vol): - await Music.volume.callback(music_cog, ctx, vol) - assert "between 0 and 100" in sent_text(ctx) +async def convert_volume(ctx, raw: str): + """Run the volume argument through its converter, as `!volume <raw>` would.""" + param = Music.volume.clean_params["vol"] + return await commands.run_converters(ctx, param.converter, raw, param) + + +@pytest.mark.parametrize("vol", ["-1", "101", "1000"]) +async def test_volume_out_of_range_is_rejected(ctx, vol): + with pytest.raises(commands.RangeError): + await convert_volume(ctx, vol) @pytest.mark.parametrize("vol", [0, 50, 100]) -async def test_volume_boundaries_are_accepted(music_cog, ctx, vol): +async def test_volume_boundaries_are_accepted(ctx, vol): """0 and 100 are valid - an off-by-one here would reject mute and max.""" - await Music.volume.callback(music_cog, ctx, vol) - assert "between 0 and 100" not in sent_text(ctx) + assert await convert_volume(ctx, str(vol)) == vol async def test_loop_rejects_an_unknown_mode(music_cog, ctx): @@ -205,7 +213,7 @@ async def test_effect_changes_are_throttled_per_guild(effects_cog, ctx): async def test_current_effect_reports_none_when_idle(effects_cog, ctx): - await Effects.current_effect.callback(effects_cog, ctx) + await Effects.effect.callback(effects_cog, ctx) assert "none" in sent_text(ctx).lower() @@ -311,3 +319,74 @@ async def test_play_copes_with_the_author_leaving_voice_mid_command(music_cog, c await Music.play.callback(music_cog, ctx, query="bohemian rhapsody") assert "voice channel" in sent_text(ctx) search.assert_not_awaited() + + +# -- slash invocation ----------------------------------------------------- + +async def test_play_defers_before_any_slow_work(music_cog, ctx): + """Joining voice alone can outlast Discord's three seconds to answer.""" + ctx.author.voice = None + await Music.play.callback(music_cog, ctx, query="bohemian rhapsody") + ctx.defer.assert_awaited_once() + + +async def test_slash_play_on_an_idle_player_still_answers(music_cog, ctx, player, track_factory): + """Now Playing goes to the channel; the interaction needs a reply of its own.""" + ctx.interaction = MagicMock() + await music_cog._enqueue(ctx, player, [track_factory("Song")], None) + assert "Starting" in sent_text(ctx) and "Song" in sent_text(ctx) + + +async def test_prefix_play_on_an_idle_player_leaves_it_to_now_playing( + music_cog, ctx, player, track_factory): + await music_cog._enqueue(ctx, player, [track_factory("Song")], None) + ctx.send.assert_not_awaited() + + +async def test_failures_are_private_under_slash(music_cog, ctx): + await Music.skip.callback(music_cog, ctx) + assert ctx.send.await_args.kwargs["ephemeral"] is True + + +async def test_play_suggests_search_terms(music_cog): + music_cog._session = MagicMock() + with patch("cogs.music.suggestions.fetch", + new=AsyncMock(return_value=["bohemian rhapsody"])): + choices = await Music._suggest(music_cog, MagicMock(), "bohem") + assert [c.value for c in choices] == ["bohemian rhapsody"] + + +async def test_no_suggestions_before_the_cog_has_loaded(music_cog): + assert await Music._suggest(music_cog, MagicMock(), "bohem") == [] + + +# -- /effect <name> --------------------------------------------------------- + +async def test_effect_by_name_applies_it(effects_cog, ctx): + ctx.author.voice.channel = ctx.voice_client + with patch.object(effects_cog, "_switch_to", new=AsyncMock()) as switch: + await Effects.effect.callback(effects_cog, ctx, "Nightcore") + name, flt, _label, rate = switch.await_args.args[1:] + assert name == "nightcore" and flt == EFFECTS["nightcore"].filter + assert rate == EFFECTS["nightcore"].rate + + +async def test_an_unknown_effect_name_is_refused(effects_cog, ctx): + await Effects.effect.callback(effects_cog, ctx, "banana") + assert "Unknown effect" in sent_text(ctx) + + +async def test_applying_by_name_still_needs_the_same_voice_channel(effects_cog, ctx): + ctx.author.voice = None + with patch.object(effects_cog, "_switch_to", new=AsyncMock()) as switch: + await Effects.effect.callback(effects_cog, ctx, "bass") + switch.assert_not_awaited() + assert "voice channel" in sent_text(ctx) + + +async def test_effects_list_under_slash_points_at_effect(effects_cog, ctx): + ctx.clean_prefix = "/" + ctx.interaction = MagicMock() + await Effects.list_effects.callback(effects_cog, ctx) + text = sent_text(ctx) + assert "/effect" in text and "`/bass`" not in text diff --git a/tests/test_errors.py b/tests/test_errors.py index 6064016..36e2dc6 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -10,6 +10,7 @@ import discord import pytest +from discord import app_commands from discord.ext import commands from utils import errors @@ -260,3 +261,24 @@ def test_the_global_check_is_registered_on_the_bot(monkeypatch): assert guild_only in main.bot._checks finally: sys.modules.pop("main", None) + + +# -- slash invocation ----------------------------------------------------- + +async def test_an_out_of_range_value_names_the_bounds(ctx): + await errors.handle(ctx, commands.RangeError(101, minimum=0, maximum=100)) + assert "between 0 and 100" in sent_text(ctx) + + +async def test_a_slash_command_error_is_unwrapped_through_both_layers(ctx, caplog): + """Under `/` the real exception sits two wrappers deep.""" + real = RuntimeError("boom") + inner = app_commands.CommandInvokeError(MagicMock(), real) + await errors.handle(ctx, commands.HybridCommandError(inner)) + assert "Something went wrong" in sent_text(ctx) + assert any(r.exc_info and r.exc_info[1] is real for r in caplog.records) + + +async def test_error_replies_are_private_under_slash(ctx): + await errors.handle(ctx, missing_arg()) + assert ctx.send.await_args.kwargs["ephemeral"] is True diff --git a/tests/test_help.py b/tests/test_help.py index ff8d3ec..67cf588 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -33,13 +33,14 @@ def help_text(embed: discord.Embed) -> str: async def test_every_registered_command_is_documented(bot): text = help_text(build(bot, PREFIX)) - undocumented = [c.name for c in bot.commands if f"{PREFIX}{c.name}" not in text] + undocumented = [c.name for c in bot.commands + if not c.hidden and f"{PREFIX}{c.name}" not in text] assert undocumented == [], f"commands missing from !help: {undocumented}" async def test_effect_is_documented(bot): """The exact command the hardcoded help had silently dropped.""" - assert f"{PREFIX}effect`" in help_text(build(bot, PREFIX)) + assert f"{PREFIX}effect [name]`" in help_text(build(bot, PREFIX)) async def test_every_alias_is_documented(bot): @@ -84,3 +85,8 @@ async def move(ctx, from_pos: int, to_pos: int): assert "`!move <from_pos> <to_pos>`" in line assert "`!mv`" in line assert "Move a track." in line + + +async def test_the_owner_sync_command_stays_out_of_help(bot): + assert bot.get_command("sync").hidden + assert f"{PREFIX}sync" not in help_text(build(bot, PREFIX)) diff --git a/tests/test_lyrics_cog.py b/tests/test_lyrics_cog.py index 11251c5..a51edec 100644 --- a/tests/test_lyrics_cog.py +++ b/tests/test_lyrics_cog.py @@ -150,3 +150,68 @@ async def test_a_query_without_a_separator_is_searched_once(cog, monkeypatch): await cog._search("Bohemian Rhapsody") assert fetch.await_count == 1, "there is no other order to try" + + +# -- the Now Playing lyrics button --------------------------------------- + +async def test_the_cog_offers_itself_to_the_lyrics_button_while_loaded(monkeypatch): + from utils import now_playing_view + + monkeypatch.setattr(now_playing_view, "_lyrics", None) + cog = LyricsCog(MagicMock()) + await cog.cog_load() + assert now_playing_view._lyrics == cog.show_current + await cog.cog_unload() + assert now_playing_view._lyrics is None + + +async def test_show_current_with_nothing_playing(cog): + player = MagicMock(current=None) + assert await cog.show_current(MagicMock(), player) is False + + +async def test_show_current_when_nothing_is_found(cog, monkeypatch): + monkeypatch.setattr(cog, "_for_track", AsyncMock(return_value=None)) + channel = MagicMock(send=AsyncMock()) + assert await cog.show_current(channel, MagicMock()) is False + channel.send.assert_not_awaited() + + +async def test_show_current_posts_unsynced_lyrics_to_the_channel(cog, monkeypatch): + plain = synced_lyrics.Lyrics("T", "A", plain="just words") + monkeypatch.setattr(cog, "_for_track", AsyncMock(return_value=plain)) + channel = MagicMock(send=AsyncMock()) + assert await cog.show_current(channel, MagicMock()) is True + assert "just words" in channel.send.await_args.kwargs["embed"].description + + +async def test_show_current_follows_synced_lyrics(cog, monkeypatch): + synced = synced_lyrics.Lyrics("T", "A", lines=((0.0, "line"),)) + monkeypatch.setattr(cog, "_for_track", AsyncMock(return_value=synced)) + follow = AsyncMock() + monkeypatch.setattr(cog, "_follow", follow) + channel, player = MagicMock(), MagicMock() + assert await cog.show_current(channel, player) is True + follow.assert_awaited_once_with(channel, player, synced) + + +# -- /lyrics ---------------------------------------------------------------- + +async def test_slash_lyrics_go_to_the_channel_not_the_interaction(cog, monkeypatch): + """An interaction reply stops being editable after 15 minutes; a live + lyrics message is edited for as long as the music plays.""" + from cogs.lyrics import Lyrics + from utils.player import players + + monkeypatch.setattr(players, "get", lambda guild_id: None) + found = synced_lyrics.Lyrics("T", "A", plain="words") + monkeypatch.setattr(cog, "_search", AsyncMock(return_value=found)) + present = AsyncMock() + monkeypatch.setattr(cog, "_present", present) + ctx = MagicMock(send=AsyncMock(), interaction=MagicMock()) + ctx.typing = MagicMock(return_value=AsyncMock()) + + await Lyrics.lyrics.callback(cog, ctx, query="T - A") + + assert present.await_args.args[0] is ctx.channel + assert ctx.send.await_args.kwargs["ephemeral"] is True diff --git a/tests/test_suggestions.py b/tests/test_suggestions.py new file mode 100644 index 0000000..78241bb --- /dev/null +++ b/tests/test_suggestions.py @@ -0,0 +1,42 @@ +"""Search suggestions for `/play`: best effort, and never an error.""" + +import asyncio + +from services import suggestions +from tests.test_synced_lyrics import FakeResponse, FakeSession + +PAYLOAD = ["bohemian", ["bohemian rhapsody", "bohemian rhapsody live"], [], {}] + + +async def test_returns_the_suggested_terms(): + session = FakeSession(FakeResponse(200, PAYLOAD)) + assert await suggestions.fetch(session, "bohemian") == [ + "bohemian rhapsody", "bohemian rhapsody live"] + assert session.calls[0]["params"]["q"] == "bohemian" + + +async def test_urls_and_blank_input_are_not_looked_up(): + session = FakeSession(FakeResponse(200, PAYLOAD)) + assert await suggestions.fetch(session, "https://youtu.be/x") == [] + assert await suggestions.fetch(session, " ") == [] + assert session.calls == [] + + +async def test_caps_the_number_and_length_of_suggestions(): + long = ["x" * 150] + [f"s{i}" for i in range(30)] + session = FakeSession(FakeResponse(200, ["q", long])) + found = await suggestions.fetch(session, "q") + assert len(found) == suggestions.MAX_SUGGESTIONS + assert all(len(s) <= suggestions.MAX_CHOICE_LEN for s in found) + + +async def test_a_failing_endpoint_yields_nothing(): + for session in ( + FakeSession(FakeResponse(503)), + FakeSession(OSError("connection refused")), + FakeSession(asyncio.TimeoutError()), + FakeSession(FakeResponse(200, raises=ValueError("not json"))), + FakeSession(FakeResponse(200, {"unexpected": "shape"})), + FakeSession(FakeResponse(200, ["q", "not a list"])), + ): + assert await suggestions.fetch(session, "q") == [] diff --git a/utils/checks.py b/utils/checks.py index 05b0242..169281a 100644 --- a/utils/checks.py +++ b/utils/checks.py @@ -13,21 +13,25 @@ def user_in_voice() -> Callable[[T], T]: async def predicate(ctx: GuildContext) -> bool: if not ctx.author.voice or not ctx.author.voice.channel: await ctx.send(embed=error_embed( - "You must be in a voice channel to use this command.")) + "You must be in a voice channel to use this command."), ephemeral=True) return False return True return commands.check(predicate) +async def in_same_voice_channel(ctx: GuildContext) -> bool: + """The same-channel rule, callable on its own by a command that needs it + for only some of its arguments.""" + if not ctx.author.voice: + await ctx.send(embed=error_embed("You must be in a voice channel."), ephemeral=True) + return False + if ctx.voice_client and ctx.author.voice.channel != ctx.voice_client.channel: + await ctx.send(embed=error_embed( + "You must be in the same voice channel as me."), ephemeral=True) + return False + return True + + def same_voice_channel() -> Callable[[T], T]: """Check: user must be in the same voice channel as the bot.""" - async def predicate(ctx: GuildContext) -> bool: - if not ctx.author.voice: - await ctx.send(embed=error_embed("You must be in a voice channel.")) - return False - if ctx.voice_client and ctx.author.voice.channel != ctx.voice_client.channel: - await ctx.send(embed=error_embed( - "You must be in the same voice channel as me.")) - return False - return True - return commands.check(predicate) + return commands.check(in_same_voice_channel) diff --git a/utils/context.py b/utils/context.py index 30eac24..5eca21a 100644 --- a/utils/context.py +++ b/utils/context.py @@ -11,7 +11,7 @@ without a ``None`` test the check has already made impossible. """ -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional import discord from discord.ext import commands @@ -29,8 +29,18 @@ def me(self) -> discord.Member: ... @property def voice_client(self) -> Optional[discord.VoiceClient]: ... + + # mypy cannot solve commands.hybrid_command's union of Concatenate callback + # types when the callback is a cog method (it can for a plain function, and + # pyright can for both). This is the same decorator with a signature mypy + # accepts; at runtime it is commands.hybrid_command itself. + def hybrid_command( + name: str = ..., **attrs: Any, + ) -> Callable[[Callable[..., Coroutine[Any, Any, None]]], + commands.HybridCommand[Any, ..., None]]: ... else: GuildContext = commands.Context + hybrid_command = commands.hybrid_command async def guild_only(ctx: commands.Context[Any]) -> bool: diff --git a/utils/errors.py b/utils/errors.py index b0f2171..bc9e990 100644 --- a/utils/errors.py +++ b/utils/errors.py @@ -14,12 +14,18 @@ import logging import discord +from discord import app_commands from discord.ext import commands from utils.embeds import error_embed log = logging.getLogger("loopify.errors") +# What discord.py wraps a command's real exception in. Under `/` it is wrapped +# twice: HybridCommandError around app_commands.CommandInvokeError around it. +_WRAPPERS = (commands.CommandInvokeError, commands.HybridCommandError, + app_commands.CommandInvokeError) + def usage(ctx: commands.Context) -> str: """ @@ -41,6 +47,8 @@ def _input_detail(error: commands.UserInputError) -> str: return f"Missing the `{error.param.name}` argument." if isinstance(error, commands.TooManyArguments): return "That command takes fewer arguments than you gave it." + if isinstance(error, commands.RangeError): + return f"That value must be between {error.minimum} and {error.maximum}." if isinstance(error, commands.BadArgument): return "One of those arguments isn't the right type." return "I couldn't make sense of that." @@ -48,8 +56,8 @@ def _input_detail(error: commands.UserInputError) -> str: async def handle(ctx: commands.Context, error: Exception) -> None: """Reply to the user, or log, depending on what went wrong.""" - # discord.py wraps exceptions raised inside a command body. - error = getattr(error, "original", error) + while isinstance(error, _WRAPPERS): + error = error.original if isinstance(error, commands.CommandNotFound): return @@ -81,6 +89,7 @@ async def handle(ctx: commands.Context, error: Exception) -> None: async def _reply(ctx: commands.Context, message: str) -> None: try: - await ctx.send(embed=error_embed(message)) + # Under `/` only the person who erred sees it; under `!` it is ignored. + await ctx.send(embed=error_embed(message), ephemeral=True) except discord.HTTPException as e: log.debug("Could not report an error to the channel: %s", e) diff --git a/utils/help.py b/utils/help.py index 86dbe32..8b6c398 100644 --- a/utils/help.py +++ b/utils/help.py @@ -58,6 +58,7 @@ def build(bot: commands.Bot, prefix: str) -> discord.Embed: embed.add_field(name=name, value=value, inline=False) embed.set_footer( text=f"Tip: {prefix}play takes YouTube/SoundCloud searches " - f"(prefix with sc:) and most links yt-dlp supports." + f"(prefix with sc:) and most links yt-dlp supports. " + f"Every command also works as /command." ) return embed From a7cba33f4035497d194fc070ecd811425c6e4a7a Mon Sep 17 00:00:00 2001 From: Ismael Leon <ismaleonsaenz@gmail.com> Date: Sun, 27 Sep 2026 03:09:38 -0600 Subject: [PATCH 4/4] Document buttons, slash commands and !sync --- README.md | 12 +++++++++++- deploy/README.md | 8 ++++++++ docs/design/2026-09-27-interactive-controls.md | 4 ++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fd287e6..0a76b9f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ A fully-featured Discord music bot built with Python. Supports YouTube and Sound - 📋 **Queue system** — Full queue management with shuffle, loop, and history - 🎛️ **Audio effects** — Bass boost, nightcore, vaporwave, 8D audio, echo, and more - 🎤 **Live lyrics** — One message that follows the song line by line, karaoke-style +- 🎛️ **Buttons** — Pause, skip, previous, stop, loop, shuffle, queue and lyrics right under Now Playing +- ⚡ **Slash commands** — Every command also works as `/command`, with search suggestions in `/play` - 🔊 **Volume control** — Per-server volume adjustment - 🔁 **Loop modes** — Loop a single track or the entire queue - ▶️ **Autoplay** — Automatically queue related tracks when the queue ends @@ -20,6 +22,14 @@ A fully-featured Discord music bot built with Python. Supports YouTube and Sound ## 📋 Commands +Every command below also works as a slash command: `/play`, `/skip`, `/effect`… +Aliases (`!p`, `!q`, `!bass`…) are `!`-only. + +Each **Now Playing** message carries buttons for anyone in the bot's voice +channel: ⏮ ⏯ ⏭ ⏹ on the first row, 🔁 (cycle loop) 🔀 (shuffle) 📜 (queue, +shown only to you) 🎤 (live lyrics) on the second. When the next song starts, +the previous message's buttons are disabled. + ### ▶️ Playback | Command | Description | @@ -58,7 +68,7 @@ A fully-featured Discord music bot built with Python. Supports YouTube and Sound | `!8d` | Apply 8D audio (use headphones!) | | `!karaoke` | Remove center vocals | | `!reset` | Remove all audio effects | -| `!effect` | Show which effect is currently active | +| `!effect [name]` | Show the active effect, or apply one by name (`/effect` lists them) | | `!effects` | List all available effects | ### 🎤 Extras diff --git a/deploy/README.md b/deploy/README.md index 0876229..c0f3ffa 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -52,6 +52,11 @@ sudo systemctl start loopify-bot sudo journalctl -u loopify-bot -f # look for "Logged in as ..." ``` +Then, from the Discord account that owns the bot application, send `!sync` in +any server the bot is in. That registers the `/` commands with Discord; until +it runs, only the `!` commands exist. The bot does not do this on its own at +startup because Discord rate-limits it and the bot restarts unattended. + ## 3. Updating later ```bash @@ -66,6 +71,9 @@ The units are reinstalled every time on purpose: a pull can change how the bot i *run* (sandboxing, resource caps, stop timeouts), and restarting alone would keep the old configuration while the repo claimed otherwise. +If the update added, removed or changed a command, send `!sync` again so the +`/` menu matches. + ### If the host was deployed by copying files instead of cloning `update.sh` refuses to run and tells you how to convert it in place: diff --git a/docs/design/2026-09-27-interactive-controls.md b/docs/design/2026-09-27-interactive-controls.md index 802cb23..cb94ea1 100644 --- a/docs/design/2026-09-27-interactive-controls.md +++ b/docs/design/2026-09-27-interactive-controls.md @@ -20,8 +20,8 @@ message, and every command available as a `/` command alongside `!`. ### `utils/controls.py` — shared playback actions Pure functions over `Optional[MusicPlayer]` returning an `Outcome(ok, message)`: -`pause`, `resume`, `toggle_pause`, `skip`, `previous`, `shuffle`, `set_loop`, -`cycle_loop`. They hold the wording ("Nothing is playing.", "Skipped ⏭") in one +`pause`, `resume`, `skip`, `previous`, `shuffle`, `set_loop`, `cycle_loop`, +`stop`. They hold the wording ("Nothing is playing.", "Skipped ⏭") in one place. Commands and buttons both call them and differ only in how they deliver the result.