Files
deer-flow/scripts/wizard/writer.py
T
DanielWalnut aa015462a7 feat(im): Add user-owned IM channel connections (#3487)
* Add user-owned IM channel connections

* Fix dev startup and channel connect popup

* Use async channel connect flow

* Harden dev service daemon startup

* Support local IM channel connections

* Align IM connections with local channels

* Fix safe user id digest algorithm

* Address Copilot IM channel feedback

* Address IM channel review comments

* Support all integrated IM channel connections

* Format additional channel connection tests

* Keep unavailable channel connect buttons clickable

* Fix IM channel provider icons

* Add runtime setup for enabled IM channels

* Guard global shortcut key handling

* Keep configured IM channels editable

* Avoid password autofill for channel secrets

* Make channel threads visible to connection owners

* Persist IM runtime config locally

* Allow disconnecting runtime IM channels

* Route no-auth channel sessions to local user

* Use default user for auth-disabled local mode

* Show IM channel source on threads

* Prefill IM channel runtime config

* Reflect IM channel runtime health

* Ignore Feishu message read events

* Ignore Feishu non-content message events

* Let setup wizard enable IM channels

* Fix frontend formatting after merge

* Stabilize backend tests without local config

* Isolate channel runtime config tests

* Address channel connection review comments

* Use sha256 user buckets with legacy migration

* Ensure runtime IM channels are ready after restart

* Persist disconnected IM channel state

* Address channel connection review comments

* Address channel connection review findings

Frontend connect flow:
- Open the runtime-config dialog only when a provider still needs
  credentials; configured providers go straight to the connect flow, so
  the binding-code/deep-link path is reachable from the UI again.
- After saving credentials, continue into the connect flow when a user
  binding is still required (multi-user mode) instead of stopping at a
  "Connected" toast.
- Extract shared provider-state helpers to core/channels/provider-state
  and add unit + e2e coverage for the direct-connect and
  configure-then-connect paths.

Provider status semantics:
- Report connection_status from the user's newest connection row;
  with no binding it is not_connected, except in auth-disabled local
  mode where a configured running channel is effectively connected.

Concurrency and event-loop correctness:
- Offload ChannelRuntimeConfigStore construction and writes, channel
  service construction, and Slack connection replies to threads; add a
  tests/blocking_io/ anchor for the runtime-config handlers.
- Consume binding codes with a conditional UPDATE so a code can only be
  used once under concurrent workers; retry upsert_connection as an
  update when a concurrent insert wins the unique constraint.
- Serialize ensure_channel_ready per channel so concurrent provider
  polls cannot double-start a channel worker.

Config and migration hardening:
- Stop mutating the get_app_config()-cached Telegram provider config;
  the runtime store now owns the UI-entered bot username.
- Register channel_connections in STARTUP_ONLY_FIELDS with the
  standardized startup-only Field description.
- Match the legacy unsafe-id bucket by recomputing its exact SHA-1 name
  so another user's same-prefix bucket can never be migrated.
- Remove the unused Telegram process_webhook_update path and document
  src/core/channels in the frontend docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address PR review comments on authz scoping and channel runtime

Security (review feedback from ShenAC-SAC):
- Scope internal-token callers to the connection owner carried in
  X-DeerFlow-Owner-User-Id instead of bypassing owner checks outright,
  in both require_permission(owner_check=True) and the stateless run
  endpoints. Internal callers keep access to their own and
  shared/legacy threads, and may claim a default-owned channel thread
  for its real owner, but a leaked internal token no longer grants
  cross-user thread access.
- Require admin privileges for POST/DELETE /api/channels/{provider}/
  runtime-config: runtime credentials and channel workers are
  instance-wide shared state (same model as the MCP config API).
  Read-only provider listing stays available to all users.

Performance (review feedback from willem-bd):
- Skip the redundant thread channel-metadata PATCH after the first
  successful backfill per thread.
- Reuse the per-connection Slack WebClient until its token changes
  instead of constructing one per outbound message.
- Reconcile channel readiness for all providers concurrently in
  GET /api/channels/providers.

Also resolve the code-quality unused-import flag in the blocking-io
anchor by pre-importing the channel service via importlib.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix prettier formatting in provider-state test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reconcile UI runtime channel config with config reload on restart

Main now reloads a channel's config.yaml entry on restart_channel()
(#3514, issue #3497). Adapt the user-owned connection flow to coexist:

- configure_channel() restarts with reload_config=False — the caller
  just supplied the authoritative config (browser-entered credentials
  that are never written to config.yaml), so a file reload must not
  clobber it with the stale on-disk entry.
- _load_channel_config() re-applies the UI runtime-store overlay used
  at startup, so an operator-triggered restart keeps browser-entered
  credentials for channels without a config.yaml entry and does not
  resurrect a channel disconnected from the UI.
- Offload the reload's disk IO (config.yaml + runtime store) with
  asyncio.to_thread, matching the blocking-IO policy on this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:24:58 +08:00

318 lines
11 KiB
Python

"""Config file writer for the Setup Wizard.
Writes config.yaml as a minimal working configuration and updates .env
without wiping existing user customisations where possible.
"""
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
from typing import Any
import yaml
CHANNEL_CONNECTION_PROVIDERS: tuple[str, ...] = (
"telegram",
"slack",
"discord",
"feishu",
"dingtalk",
"wechat",
"wecom",
)
def _project_root() -> Path:
return Path(__file__).resolve().parents[2]
# ── .env helpers ──────────────────────────────────────────────────────────────
def read_env_file(env_path: Path) -> dict[str, str]:
"""Parse a .env file into a dict (ignores comments and blank lines)."""
result: dict[str, str] = {}
if not env_path.exists():
return result
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, value = line.partition("=")
result[key.strip()] = value.strip()
return result
def write_env_file(env_path: Path, pairs: dict[str, str]) -> None:
"""Merge *pairs* into an existing (or new) .env file.
Existing keys are updated in place; new keys are appended.
Lines with comments and other formatting are preserved.
"""
lines: list[str] = []
if env_path.exists():
lines = env_path.read_text(encoding="utf-8").splitlines()
updated: set[str] = set()
new_lines: list[str] = []
for line in lines:
stripped = line.strip()
if stripped and not stripped.startswith("#") and "=" in stripped:
key = stripped.split("=", 1)[0].strip()
if key in pairs:
new_lines.append(f"{key}={pairs[key]}")
updated.add(key)
continue
new_lines.append(line)
for key, value in pairs.items():
if key not in updated:
new_lines.append(f"{key}={value}")
env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
# ── config.yaml helpers ───────────────────────────────────────────────────────
def _yaml_dump(data: Any) -> str:
return yaml.safe_dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
def _default_tools() -> list[dict[str, Any]]:
return [
{"name": "image_search", "use": "deerflow.community.image_search.tools:image_search_tool", "group": "web", "max_results": 5},
{"name": "ls", "use": "deerflow.sandbox.tools:ls_tool", "group": "file:read"},
{"name": "read_file", "use": "deerflow.sandbox.tools:read_file_tool", "group": "file:read"},
{"name": "glob", "use": "deerflow.sandbox.tools:glob_tool", "group": "file:read"},
{"name": "grep", "use": "deerflow.sandbox.tools:grep_tool", "group": "file:read"},
{"name": "write_file", "use": "deerflow.sandbox.tools:write_file_tool", "group": "file:write"},
{"name": "str_replace", "use": "deerflow.sandbox.tools:str_replace_tool", "group": "file:write"},
{"name": "bash", "use": "deerflow.sandbox.tools:bash_tool", "group": "bash"},
]
def _build_tools(
*,
base_tools: list[dict[str, Any]] | None,
search_use: str | None,
search_tool_name: str,
search_extra_config: dict | None,
web_fetch_use: str | None,
web_fetch_tool_name: str,
web_fetch_extra_config: dict | None,
include_bash_tool: bool,
include_write_tools: bool,
) -> list[dict[str, Any]]:
tools = deepcopy(base_tools if base_tools is not None else _default_tools())
tools = [
tool
for tool in tools
if tool.get("name") not in {search_tool_name, web_fetch_tool_name, "write_file", "str_replace", "bash"}
]
web_group = "web"
if search_use:
search_tool: dict[str, Any] = {
"name": search_tool_name,
"use": search_use,
"group": web_group,
}
if search_extra_config:
search_tool.update(search_extra_config)
tools.insert(0, search_tool)
if web_fetch_use:
fetch_tool: dict[str, Any] = {
"name": web_fetch_tool_name,
"use": web_fetch_use,
"group": web_group,
}
if web_fetch_extra_config:
fetch_tool.update(web_fetch_extra_config)
insert_idx = 1 if search_use else 0
tools.insert(insert_idx, fetch_tool)
if include_write_tools:
tools.extend(
[
{"name": "write_file", "use": "deerflow.sandbox.tools:write_file_tool", "group": "file:write"},
{"name": "str_replace", "use": "deerflow.sandbox.tools:str_replace_tool", "group": "file:write"},
]
)
if include_bash_tool:
tools.append({"name": "bash", "use": "deerflow.sandbox.tools:bash_tool", "group": "bash"})
return tools
def _make_model_config_name(model_name: str) -> str:
"""Derive a meaningful config model name from the provider model identifier.
Replaces path separators and dots with hyphens so the result is a clean
YAML-friendly identifier (e.g. "google/gemini-2.5-pro""gemini-2-5-pro",
"gpt-5.4""gpt-5-4", "deepseek-chat""deepseek-chat").
"""
# Take only the last path component for namespaced models (e.g. "org/model-name")
base = model_name.split("/")[-1]
# Replace dots with hyphens so "gpt-5.4" → "gpt-5-4"
return base.replace(".", "-")
def _build_channel_connections_config(enabled_providers: list[str]) -> dict[str, Any]:
selected = set(enabled_providers)
unknown = selected.difference(CHANNEL_CONNECTION_PROVIDERS)
if unknown:
raise ValueError(f"Unknown channel connection provider(s): {', '.join(sorted(unknown))}")
return {
"enabled": bool(selected),
**{provider: {"enabled": provider in selected} for provider in CHANNEL_CONNECTION_PROVIDERS},
}
def build_minimal_config(
*,
provider_use: str,
model_name: str,
display_name: str,
api_key_field: str,
env_var: str | None,
extra_model_config: dict | None = None,
base_url: str | None = None,
search_use: str | None = None,
search_tool_name: str = "web_search",
search_extra_config: dict | None = None,
web_fetch_use: str | None = None,
web_fetch_tool_name: str = "web_fetch",
web_fetch_extra_config: dict | None = None,
sandbox_use: str = "deerflow.sandbox.local:LocalSandboxProvider",
allow_host_bash: bool = False,
include_bash_tool: bool = False,
include_write_tools: bool = True,
channel_connection_providers: list[str] | None = None,
config_version: int = 5,
base_config: dict[str, Any] | None = None,
) -> str:
"""Build the content of a minimal config.yaml."""
from datetime import date
today = date.today().isoformat()
model_entry: dict[str, Any] = {
"name": _make_model_config_name(model_name),
"display_name": display_name,
"use": provider_use,
"model": model_name,
}
if env_var:
model_entry[api_key_field] = f"${env_var}"
extra_model_fields = dict(extra_model_config or {})
if "base_url" in extra_model_fields and not base_url:
base_url = extra_model_fields.pop("base_url")
if base_url:
model_entry["base_url"] = base_url
if extra_model_fields:
model_entry.update(extra_model_fields)
data: dict[str, Any] = deepcopy(base_config or {})
data["config_version"] = config_version
data["models"] = [model_entry]
base_tools = data.get("tools")
if not isinstance(base_tools, list):
base_tools = None
tools = _build_tools(
base_tools=base_tools,
search_use=search_use,
search_tool_name=search_tool_name,
search_extra_config=search_extra_config,
web_fetch_use=web_fetch_use,
web_fetch_tool_name=web_fetch_tool_name,
web_fetch_extra_config=web_fetch_extra_config,
include_bash_tool=include_bash_tool,
include_write_tools=include_write_tools,
)
data["tools"] = tools
sandbox_config = deepcopy(data.get("sandbox") if isinstance(data.get("sandbox"), dict) else {})
sandbox_config["use"] = sandbox_use
if sandbox_use == "deerflow.sandbox.local:LocalSandboxProvider":
sandbox_config["allow_host_bash"] = allow_host_bash
else:
sandbox_config.pop("allow_host_bash", None)
data["sandbox"] = sandbox_config
if channel_connection_providers is not None:
data["channel_connections"] = _build_channel_connections_config(channel_connection_providers)
header = (
f"# DeerFlow Configuration\n"
f"# Generated by 'make setup' on {today}\n"
f"# Run 'make setup' to reconfigure, or edit this file for advanced options.\n"
f"# Full reference: config.example.yaml\n\n"
)
return header + _yaml_dump(data)
def write_config_yaml(
config_path: Path,
*,
provider_use: str,
model_name: str,
display_name: str,
api_key_field: str,
env_var: str | None,
extra_model_config: dict | None = None,
base_url: str | None = None,
search_use: str | None = None,
search_tool_name: str = "web_search",
search_extra_config: dict | None = None,
web_fetch_use: str | None = None,
web_fetch_tool_name: str = "web_fetch",
web_fetch_extra_config: dict | None = None,
sandbox_use: str = "deerflow.sandbox.local:LocalSandboxProvider",
allow_host_bash: bool = False,
include_bash_tool: bool = False,
include_write_tools: bool = True,
channel_connection_providers: list[str] | None = None,
) -> None:
"""Write (or overwrite) config.yaml with a minimal working configuration."""
# Read config_version from config.example.yaml if present
config_version = 5
example_path = config_path.parent / "config.example.yaml"
if example_path.exists():
try:
import yaml as _yaml
raw = _yaml.safe_load(example_path.read_text(encoding="utf-8")) or {}
config_version = int(raw.get("config_version", 5))
example_defaults = raw
except Exception:
example_defaults = None
else:
example_defaults = None
content = build_minimal_config(
provider_use=provider_use,
model_name=model_name,
display_name=display_name,
api_key_field=api_key_field,
env_var=env_var,
extra_model_config=extra_model_config,
base_url=base_url,
search_use=search_use,
search_tool_name=search_tool_name,
search_extra_config=search_extra_config,
web_fetch_use=web_fetch_use,
web_fetch_tool_name=web_fetch_tool_name,
web_fetch_extra_config=web_fetch_extra_config,
sandbox_use=sandbox_use,
allow_host_bash=allow_host_bash,
include_bash_tool=include_bash_tool,
include_write_tools=include_write_tools,
channel_connection_providers=channel_connection_providers,
config_version=config_version,
base_config=example_defaults,
)
config_path.write_text(content, encoding="utf-8")