diff --git a/README.md b/README.md index e52d38a381..86001fa4be 100644 --- a/README.md +++ b/README.md @@ -1053,6 +1053,12 @@ anonymous `tmp*.tmp` files require manual inspection because their writer cannot DeerFlow can also expose user-owned IM channel connections in the workspace UI. When `channel_connections` is enabled, logged-in users can bind Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, WeCom, QQ, or Buzz from the sidebar / Settings > Channels. It reuses the existing outbound `channels.*` transports, so no public IP or provider callback URL is required. Incoming IM messages then run under the connected DeerFlow user account. See [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md) for setup and security notes. Buzz backlog paging stops with a warning if timestamp-based queries cannot advance, and channel removal closes both live and backlog subscriptions. +For Discord and Buzz, the local and Docker launchers detect the required optional +dependencies when both `channel_connections.enabled` and the corresponding +`channel_connections..enabled` are `true`. Selecting Discord in +`make setup` sets these flags. A nonempty `UV_EXTRAS` overrides configuration +auto-detection, so include `discord` or `buzz` there if you set it manually. + | Channel | Transport | Difficulty | |---------|-----------|------------| | Telegram | Bot API (long-polling) | Easy | diff --git a/backend/tests/test_detect_uv_extras.py b/backend/tests/test_detect_uv_extras.py index 2fb108a5f2..5a7641407b 100644 --- a/backend/tests/test_detect_uv_extras.py +++ b/backend/tests/test_detect_uv_extras.py @@ -318,6 +318,76 @@ def test_detect_from_config_combines_discord_with_postgres(tmp_path): assert detect.detect_from_config(cfg) == ["discord", "postgres"] +@pytest.mark.parametrize("provider", ["discord", "buzz"]) +@pytest.mark.parametrize( + ("global_enabled", "provider_enabled", "expected"), + [("true", "true", True), ("false", "true", False), (None, "true", False), ("true", "false", False), ("true", None, False)], +) +def test_detect_from_config_channel_connections_requires_both_flags(tmp_path, provider, global_enabled, provider_enabled, expected): + cfg = tmp_path / "config.yaml" + content = "channel_connections:\n" + if global_enabled is not None: + content += f" enabled: {global_enabled}\n" + content += f" {provider}:\n enabled: {provider_enabled}\n" if provider_enabled is not None else f" {provider}: {{}}\n" + cfg.write_text(content, encoding="utf-8") + + assert detect.detect_from_config(cfg) == ([provider] if expected else []) + + +@pytest.mark.parametrize("provider", ["discord", "buzz"]) +@pytest.mark.parametrize("connections_enabled", ["true", "false"]) +def test_detect_from_config_preserves_and_deduplicates_legacy_channels(tmp_path, provider, connections_enabled): + cfg = tmp_path / "config.yaml" + cfg.write_text( + f"channels:\n {provider}:\n enabled: true\nchannel_connections:\n enabled: {connections_enabled}\n {provider}:\n enabled: true\n", + encoding="utf-8", + ) + + assert detect.detect_from_config(cfg) == [provider] + + +def test_detect_from_config_combines_channel_connections_with_other_extras(tmp_path): + cfg = tmp_path / "config.yaml" + cfg.write_text( + "database:\n backend: postgres\nchannels:\n discord:\n enabled: false\n buzz:\n enabled: false\nchannel_connections:\n enabled: true\n discord:\n enabled: true\n buzz:\n enabled: true\n", + encoding="utf-8", + ) + + assert detect.detect_from_config(cfg) == ["buzz", "discord", "postgres"] + + +@pytest.mark.parametrize("provider", ["discord", "buzz"]) +def test_detect_from_config_ignores_commented_channel_connections(tmp_path, provider): + cfg = tmp_path / "config.yaml" + cfg.write_text( + f"channel_connections:\n enabled: true\n # {provider}:\n # enabled: true\n", + encoding="utf-8", + ) + + assert detect.detect_from_config(cfg) == [] + + +def test_detect_discord_in_setup_wizard_output_without_site_packages(isolated_cwd): + from wizard.writer import build_minimal_config + + cfg = isolated_cwd / "config.yaml" + cfg.write_text( + build_minimal_config( + provider_use="langchain_openai:ChatOpenAI", + model_name="test-model", + display_name="Test model", + api_key_field="api_key", + env_var=None, + channel_connection_providers=["discord"], + ), + encoding="utf-8", + ) + + assert detect.detect_from_config(cfg) == ["discord"] + result = subprocess.run([sys.executable, "-S", str(DETECT_SCRIPT_PATH)], capture_output=True, text=True, check=True) + assert result.stdout == "--extra discord" + + 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_dev_entrypoint.py b/backend/tests/test_dev_entrypoint.py index 0e42b0d8f6..d79db18935 100644 --- a/backend/tests/test_dev_entrypoint.py +++ b/backend/tests/test_dev_entrypoint.py @@ -140,6 +140,20 @@ def test_no_explicit_extras_uses_the_runtime_selected_config(tmp_path: Path): assert proc.stdout.strip() == "--extra browser --extra postgres" +@pytest.mark.parametrize("uv_extras, expected", [(None, "--extra buzz --extra discord"), ("postgres", "--extra postgres")]) +def test_channel_connections_extras_respect_explicit_override(tmp_path: Path, uv_extras, expected): + config_path = tmp_path / "deployment.yaml" + config_path.write_text( + "channel_connections:\n enabled: true\n discord:\n enabled: true\n buzz:\n enabled: true\n", + encoding="utf-8", + ) + + proc = _run(uv_extras, config_path=config_path) + + assert proc.returncode == 0, proc.stderr + assert proc.stdout.strip() == expected + + def test_single_extra(): proc = _run("postgres") assert proc.returncode == 0 diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 8b6c329820..2285414fb6 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -81,6 +81,15 @@ with or without a leading BOM so the first section remains detectable. `DEER_FLOW_PROJECT_ROOT/config.yaml`, before the legacy checkout candidates. Keep this lookup standard-library-only; test shadowing checkout configs and the CLI under `python -S` in `backend/tests/test_detect_uv_extras.py`. +For Discord and Buzz, detect the provider extra when either legacy +`channels..enabled` is true or both `channel_connections.enabled` +and `channel_connections..enabled` are true. Keep these gates aligned +with `enabled_transport_providers()` in the channel service, including setup +wizard output. The provider set includes only channel providers declared in +`backend/pyproject.toml` under `[project.optional-dependencies]`; update it when +a channel gains or loses an extra. Providers whose SDKs are core dependencies +must not be added to this set. Nonempty `UV_EXTRAS` continues to override +config-derived extras. `serve.sh`'s config-presence gate also accepts the project-root file when the checkout has no config. `tests/test_serve_config_presence.py` runs the full launcher through dependency detection with service operations stubbed. diff --git a/scripts/detect_uv_extras.py b/scripts/detect_uv_extras.py index 5de3cae7f8..4c89c71903 100755 --- a/scripts/detect_uv_extras.py +++ b/scripts/detect_uv_extras.py @@ -15,6 +15,8 @@ Order of resolution: - sandbox.ownership.type == redis -> redis - channels.discord.enabled == true -> discord - channels.buzz.enabled == true -> buzz + - channel_connections.enabled and channel_connections.discord.enabled -> discord + - channel_connections.enabled and channel_connections.buzz.enabled -> buzz - models[].use == langchain_ollama:* -> ollama 3. Runtime environment toggles that enable optional backends: - DEER_FLOW_STREAM_BRIDGE_REDIS_URL -> redis @@ -363,10 +365,13 @@ def detect_from_config(path: Path) -> list[str]: extras.add("redis") if (nested_section_value(lines, "sandbox.ownership", "type") or "").lower() == "redis": extras.add("redis") - if (nested_section_value(lines, "channels.discord", "enabled") or "").lower() == "true": - extras.add("discord") - if (nested_section_value(lines, "channels.buzz", "enabled") or "").lower() == "true": - extras.add("buzz") + connections_enabled = (section_value(lines, "channel_connections", "enabled") or "").lower() == "true" + # Only channel providers with extras in backend/pyproject.toml [project.optional-dependencies] belong here; keep this set in sync. + for provider in ("discord", "buzz"): + legacy_enabled = (nested_section_value(lines, f"channels.{provider}", "enabled") or "").lower() == "true" + browser_enabled = connections_enabled and (nested_section_value(lines, f"channel_connections.{provider}", "enabled") or "").lower() == "true" + if legacy_enabled or browser_enabled: + extras.add(provider) if tools_include_name(lines, "browser_navigate"): extras.add("browser") for provider in models_use_providers(lines):