diff --git a/backend/app/channels/discord.py b/backend/app/channels/discord.py index 784dc5cb6a..213f067626 100644 --- a/backend/app/channels/discord.py +++ b/backend/app/channels/discord.py @@ -108,7 +108,7 @@ class DiscordChannel(Channel): try: import discord except ImportError: - logger.error("discord.py is not installed. Install it with: uv add discord.py") + logger.error("discord.py is not installed. The Discord channel needs the 'discord' extra: run `cd backend && uv sync --extra discord`.") return if not self._bot_token: diff --git a/backend/tests/test_detect_uv_extras.py b/backend/tests/test_detect_uv_extras.py index 8c99d903f1..df5364c3c7 100644 --- a/backend/tests/test_detect_uv_extras.py +++ b/backend/tests/test_detect_uv_extras.py @@ -285,6 +285,35 @@ def test_detect_from_config_ignores_commented_buzz_block(tmp_path): assert detect.detect_from_config(cfg) == [] +def test_detect_from_config_discord_via_channels_enabled(tmp_path): + cfg = tmp_path / "config.yaml" + cfg.write_text( + "channels:\n discord:\n enabled: true\n bot_token: $DISCORD_BOT_TOKEN\n", + ) + assert detect.detect_from_config(cfg) == ["discord"] + + +def test_detect_from_config_discord_disabled_returns_no_extras(tmp_path): + cfg = tmp_path / "config.yaml" + cfg.write_text("channels:\n discord:\n enabled: false\n") + assert detect.detect_from_config(cfg) == [] + + +def test_detect_from_config_ignores_commented_discord_block(tmp_path): + """Mirrors the commented example block shipped in config.example.yaml.""" + cfg = tmp_path / "config.yaml" + cfg.write_text( + "# channels:\n# discord:\n# enabled: false\n# bot_token: $DISCORD_BOT_TOKEN\ndatabase:\n backend: sqlite\n", + ) + assert detect.detect_from_config(cfg) == [] + + +def test_detect_from_config_combines_discord_with_postgres(tmp_path): + cfg = tmp_path / "config.yaml" + cfg.write_text("database:\n backend: postgres\nchannels:\n discord:\n enabled: true\n") + assert detect.detect_from_config(cfg) == ["discord", "postgres"] + + def test_detect_from_config_memory_stream_bridge_returns_no_extras(tmp_path): cfg = tmp_path / "config.yaml" cfg.write_text("stream_bridge:\n type: memory\n queue_maxsize: 256\n") diff --git a/backend/tests/test_discord_channel.py b/backend/tests/test_discord_channel.py index 5304f11b6c..810f688572 100644 --- a/backend/tests/test_discord_channel.py +++ b/backend/tests/test_discord_channel.py @@ -6,6 +6,7 @@ import asyncio import builtins import gc import json +import sys import threading import weakref from types import SimpleNamespace @@ -34,6 +35,22 @@ def test_discord_channel_init() -> None: assert channel.name == "discord" +@pytest.mark.asyncio +async def test_start_without_discord_module_points_at_the_extra(caplog) -> None: + """discord.py ships in the optional ``discord`` extra, so the missing-dependency + hint must send the operator to ``uv sync --extra discord`` — the same command + ``scripts/detect_uv_extras.py`` and the Docker builds already use — rather than + ``uv add``, which would rewrite pyproject.toml and diverge from ``uv sync --locked``.""" + channel = DiscordChannel(bus=MessageBus(), config={"bot_token": "token"}) + + with caplog.at_level("ERROR", logger="app.channels.discord"), patch.dict(sys.modules, {"discord": None}): + await channel.start() + + assert any("uv sync --extra discord" in record.message for record in caplog.records) + assert not any("uv add" in record.message for record in caplog.records) + assert channel._running is False + + # --------------------------------------------------------------------------- # thread-mapping persistence across restart (#2897) # --------------------------------------------------------------------------- diff --git a/config.example.yaml b/config.example.yaml index 013ee2644e..068be4033a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -2985,6 +2985,8 @@ run_ownership: # allowed_users: [] # empty = allow all # card_template_id: "" # Optional: AI Card template ID for streaming updates # +# # Discord joins as a bot; requires the `discord` dependency extra +# # (uv sync --extra discord) for discord.py. # discord: # enabled: false # bot_token: $DISCORD_BOT_TOKEN diff --git a/scripts/detect_uv_extras.py b/scripts/detect_uv_extras.py index eaee579514..09d3af3629 100755 --- a/scripts/detect_uv_extras.py +++ b/scripts/detect_uv_extras.py @@ -13,6 +13,7 @@ Order of resolution: - stream_bridge.type == redis -> redis - tools[].name == browser_navigate -> browser - sandbox.ownership.type == redis -> redis + - channels.discord.enabled == true -> discord - channels.buzz.enabled == true -> buzz - models[].use == langchain_ollama:* -> ollama 3. Runtime environment toggles that enable optional backends: