fix(startup): detect extras for browser-configured IM channels (#7280)

* fix(startup): detect extras for browser-configured IM channels

* docs(startup): clarify channel extra provider source

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
N 2026-10-23 07:55:51 +08:00 committed by GitHub
parent 859d45db7e
commit 64bfd9ffd5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 108 additions and 4 deletions

View File

@ -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. 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.<provider>.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 | | Channel | Transport | Difficulty |
|---------|-----------|------------| |---------|-----------|------------|
| Telegram | Bot API (long-polling) | Easy | | Telegram | Bot API (long-polling) | Easy |

View File

@ -318,6 +318,76 @@ def test_detect_from_config_combines_discord_with_postgres(tmp_path):
assert detect.detect_from_config(cfg) == ["discord", "postgres"] 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): def test_detect_from_config_memory_stream_bridge_returns_no_extras(tmp_path):
cfg = tmp_path / "config.yaml" cfg = tmp_path / "config.yaml"
cfg.write_text("stream_bridge:\n type: memory\n queue_maxsize: 256\n") cfg.write_text("stream_bridge:\n type: memory\n queue_maxsize: 256\n")

View File

@ -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" 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(): def test_single_extra():
proc = _run("postgres") proc = _run("postgres")
assert proc.returncode == 0 assert proc.returncode == 0

View File

@ -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. `DEER_FLOW_PROJECT_ROOT/config.yaml`, before the legacy checkout candidates.
Keep this lookup standard-library-only; test shadowing checkout configs and Keep this lookup standard-library-only; test shadowing checkout configs and
the CLI under `python -S` in `backend/tests/test_detect_uv_extras.py`. 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.<provider>.enabled` is true or both `channel_connections.enabled`
and `channel_connections.<provider>.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 `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 checkout has no config. `tests/test_serve_config_presence.py` runs the full
launcher through dependency detection with service operations stubbed. launcher through dependency detection with service operations stubbed.

View File

@ -15,6 +15,8 @@ Order of resolution:
- sandbox.ownership.type == redis -> redis - sandbox.ownership.type == redis -> redis
- channels.discord.enabled == true -> discord - channels.discord.enabled == true -> discord
- channels.buzz.enabled == true -> buzz - 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 - models[].use == langchain_ollama:* -> ollama
3. Runtime environment toggles that enable optional backends: 3. Runtime environment toggles that enable optional backends:
- DEER_FLOW_STREAM_BRIDGE_REDIS_URL -> redis - DEER_FLOW_STREAM_BRIDGE_REDIS_URL -> redis
@ -363,10 +365,13 @@ def detect_from_config(path: Path) -> list[str]:
extras.add("redis") extras.add("redis")
if (nested_section_value(lines, "sandbox.ownership", "type") or "").lower() == "redis": if (nested_section_value(lines, "sandbox.ownership", "type") or "").lower() == "redis":
extras.add("redis") extras.add("redis")
if (nested_section_value(lines, "channels.discord", "enabled") or "").lower() == "true": connections_enabled = (section_value(lines, "channel_connections", "enabled") or "").lower() == "true"
extras.add("discord") # Only channel providers with extras in backend/pyproject.toml [project.optional-dependencies] belong here; keep this set in sync.
if (nested_section_value(lines, "channels.buzz", "enabled") or "").lower() == "true": for provider in ("discord", "buzz"):
extras.add("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"): if tools_include_name(lines, "browser_navigate"):
extras.add("browser") extras.add("browser")
for provider in models_use_providers(lines): for provider in models_use_providers(lines):