Files
deer-flow/scripts/wizard/ui.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

305 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Terminal UI helpers for the Setup Wizard."""
from __future__ import annotations
import getpass
import shutil
import sys
try:
import termios
import tty
except ImportError: # pragma: no cover - non-Unix fallback
termios = None
tty = None
# ── ANSI colours ──────────────────────────────────────────────────────────────
def _supports_color() -> bool:
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
def _c(text: str, code: str) -> str:
if _supports_color():
return f"\033[{code}m{text}\033[0m"
return text
def green(text: str) -> str:
return _c(text, "32")
def red(text: str) -> str:
return _c(text, "31")
def yellow(text: str) -> str:
return _c(text, "33")
def cyan(text: str) -> str:
return _c(text, "36")
def bold(text: str) -> str:
return _c(text, "1")
def inverse(text: str) -> str:
return _c(text, "7")
# ── UI primitives ─────────────────────────────────────────────────────────────
def print_header(title: str) -> None:
width = max(len(title) + 4, 44)
bar = "" * width
print()
print(f"{bar}")
print(f"{title.ljust(width - 2)}")
print(f"{bar}")
print()
def print_section(title: str) -> None:
print()
print(bold(f"── {title} ──"))
print()
def print_success(message: str) -> None:
print(f" {green('')} {message}")
def print_warning(message: str) -> None:
print(f" {yellow('!')} {message}")
def print_error(message: str) -> None:
print(f" {red('')} {message}")
def print_info(message: str) -> None:
print(f" {cyan('')} {message}")
def _ask_choice_with_numbers(prompt: str, options: list[str], default: int | None = None) -> int:
for i, opt in enumerate(options, 1):
marker = f" {green('*')}" if default is not None and i - 1 == default else " "
print(f"{marker} {i}. {opt}")
print()
while True:
suffix = f" [{default + 1}]" if default is not None else ""
raw = input(f"{prompt}{suffix}: ").strip()
if raw == "" and default is not None:
return default
if raw.isdigit():
idx = int(raw) - 1
if 0 <= idx < len(options):
return idx
print(f" Please enter a number between 1 and {len(options)}.")
def _supports_arrow_menu() -> bool:
return (
termios is not None
and tty is not None
and hasattr(sys.stdin, "isatty")
and hasattr(sys.stdout, "isatty")
and sys.stdin.isatty()
and sys.stdout.isatty()
and sys.stderr.isatty()
)
def _clear_rendered_lines(count: int) -> None:
if count <= 0:
return
sys.stdout.write("\x1b[2K\r")
for _ in range(count):
sys.stdout.write("\x1b[1A\x1b[2K\r")
def _read_key(fd: int) -> str:
first = sys.stdin.read(1)
if first != "\x1b":
return first
second = sys.stdin.read(1)
if second != "[":
return first
third = sys.stdin.read(1)
return f"\x1b[{third}"
def _terminal_width() -> int:
return max(shutil.get_terminal_size(fallback=(80, 24)).columns, 40)
def _truncate_line(text: str, max_width: int) -> str:
if len(text) <= max_width:
return text
if max_width <= 1:
return text[:max_width]
return f"{text[: max_width - 1]}"
def _render_choice_menu(options: list[str], selected: int) -> int:
number_width = len(str(len(options)))
menu_width = _terminal_width()
content_width = max(menu_width - 3, 20)
for i, opt in enumerate(options, 1):
line = _truncate_line(f"{i:>{number_width}}. {opt}", content_width)
if i - 1 == selected:
print(f"{green('')} {inverse(bold(line))}")
else:
print(f" {line}")
sys.stdout.flush()
return len(options)
def _ask_choice_with_arrows(prompt: str, options: list[str], default: int | None = None) -> int:
selected = default if default is not None else 0
typed = ""
fd = sys.stdin.fileno()
original_settings = termios.tcgetattr(fd)
rendered_lines = 0
try:
sys.stdout.write("\x1b[?25l")
sys.stdout.flush()
tty.setcbreak(fd)
prompt_help = f"{prompt} (↑/↓ move, Enter confirm, number quick-select)"
print(cyan(_truncate_line(prompt_help, max(_terminal_width() - 2, 20))))
while True:
if rendered_lines:
_clear_rendered_lines(rendered_lines)
rendered_lines = _render_choice_menu(options, selected)
key = _read_key(fd)
if key == "\x03":
raise KeyboardInterrupt
if key in ("\r", "\n"):
if typed:
idx = int(typed) - 1
if 0 <= idx < len(options):
selected = idx
typed = ""
break
if key == "\x1b[A":
selected = (selected - 1) % len(options)
typed = ""
continue
if key == "\x1b[B":
selected = (selected + 1) % len(options)
typed = ""
continue
if key in ("\x7f", "\b"):
typed = typed[:-1]
continue
if key.isdigit():
typed += key
continue
if rendered_lines:
_clear_rendered_lines(rendered_lines)
print(f"{prompt}: {options[selected]}")
return selected
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, original_settings)
sys.stdout.write("\x1b[?25h")
sys.stdout.flush()
def ask_choice(prompt: str, options: list[str], default: int | None = None) -> int:
"""Present a menu and return the 0-based index of the selected option."""
if _supports_arrow_menu():
return _ask_choice_with_arrows(prompt, options, default=default)
return _ask_choice_with_numbers(prompt, options, default=default)
def ask_multi_choice(prompt: str, options: list[str], default: list[int] | None = None) -> list[int]:
"""Present a numbered multi-select menu and return 0-based indexes."""
has_default = default is not None
default_indexes = list(default or [])
for i, opt in enumerate(options, 1):
marker = f" {green('*')}" if has_default and i - 1 in default_indexes else " "
print(f"{marker} {i}. {opt}")
print()
suffix = ""
if default_indexes:
suffix = f" [{','.join(str(idx + 1) for idx in default_indexes)}]"
elif has_default:
suffix = " [none]"
while True:
raw = input(f"{prompt}{suffix}: ").strip().lower()
if raw == "" and has_default:
return default_indexes
if raw in {"none", "no", "n", "skip"}:
return []
if raw == "all":
return list(range(len(options)))
parts = [part.strip() for part in raw.replace(" ", ",").split(",") if part.strip()]
selected: list[int] = []
valid = bool(parts)
for part in parts:
if not part.isdigit():
valid = False
break
idx = int(part) - 1
if not 0 <= idx < len(options):
valid = False
break
if idx not in selected:
selected.append(idx)
if valid:
return selected
print(f" Enter comma-separated numbers between 1 and {len(options)}, 'all', or 'none'.")
def ask_text(prompt: str, default: str = "", required: bool = False) -> str:
"""Ask for a text value, returning default if the user presses Enter."""
suffix = f" [{default}]" if default else ""
while True:
value = input(f"{prompt}{suffix}: ").strip()
if value:
return value
if default:
return default
if not required:
return ""
print(" This field is required.")
def ask_secret(prompt: str) -> str:
"""Ask for a secret value (hidden input)."""
while True:
value = getpass.getpass(f"{prompt}: ").strip()
if value:
return value
print(" API key cannot be empty.")
def ask_yes_no(prompt: str, default: bool = True) -> bool:
"""Ask a yes/no question."""
suffix = "[Y/N]"
while True:
raw = input(f"{prompt} {suffix}: ").strip().lower()
if raw == "":
return default
if raw in ("y", "yes"):
return True
if raw in ("n", "no"):
return False
print(" Please enter y or n.")