From c4368c90185087f27ce95a8948cd5499c5ae08c4 Mon Sep 17 00:00:00 2001 From: taohe Date: Thu, 11 Jun 2026 12:10:16 +0800 Subject: [PATCH] Add runtime setup for enabled IM channels --- backend/app/channels/service.py | 7 + .../gateway/routers/channel_connections.py | 158 ++++++++++-- .../tests/test_channel_connections_router.py | 89 ++++++- .../channel-runtime-config-dialog.tsx | 128 +++++++++ .../channels/workspace-channels-list.tsx | 108 ++++++-- .../settings/channels-settings-page.tsx | 243 +++++++++++------- frontend/src/core/channels/api.ts | 23 ++ frontend/src/core/channels/hooks.ts | 22 +- frontend/src/core/channels/types.ts | 10 + frontend/src/core/i18n/locales/en-US.ts | 4 + frontend/src/core/i18n/locales/types.ts | 3 + frontend/src/core/i18n/locales/zh-CN.ts | 4 + frontend/tests/e2e/channels.spec.ts | 133 ++++++++-- frontend/tests/unit/core/channels/api.test.ts | 36 +++ 14 files changed, 807 insertions(+), 161 deletions(-) create mode 100644 frontend/src/components/workspace/channels/channel-runtime-config-dialog.tsx diff --git a/backend/app/channels/service.py b/backend/app/channels/service.py index d5bd98d42..1261f8096 100644 --- a/backend/app/channels/service.py +++ b/backend/app/channels/service.py @@ -179,6 +179,13 @@ class ChannelService: return await self._start_channel(name, config) + async def configure_channel(self, name: str, config: dict[str, Any]) -> bool: + """Apply runtime config for a channel and restart it if the service is running.""" + self._config[name] = dict(config) + if not self._running: + return True + return await self.restart_channel(name) + async def _start_channel(self, name: str, config: dict[str, Any]) -> bool: """Instantiate and start a single channel.""" import_path = _CHANNEL_REGISTRY.get(name) diff --git a/backend/app/gateway/routers/channel_connections.py b/backend/app/gateway/routers/channel_connections.py index 59b6af620..68ec522b7 100644 --- a/backend/app/gateway/routers/channel_connections.py +++ b/backend/app/gateway/routers/channel_connections.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import secrets from datetime import UTC, datetime, timedelta from typing import Any @@ -14,10 +15,18 @@ from deerflow.persistence.channel_connections import ChannelConnectionRepository from deerflow.persistence.engine import get_session_factory router = APIRouter(prefix="/api/channels", tags=["channel-connections"]) +logger = logging.getLogger(__name__) _STATE_TTL_SECONDS = 600 +class ChannelCredentialFieldResponse(BaseModel): + name: str + label: str + type: str = "text" + required: bool = True + + class ChannelProviderResponse(BaseModel): provider: str display_name: str @@ -27,6 +36,7 @@ class ChannelProviderResponse(BaseModel): unavailable_reason: str | None = None auth_mode: str connection_status: str + credential_fields: list[ChannelCredentialFieldResponse] = Field(default_factory=list) class ChannelProvidersResponse(BaseModel): @@ -59,6 +69,10 @@ class ChannelConnectResponse(BaseModel): expires_in: int +class ChannelRuntimeConfigRequest(BaseModel): + values: dict[str, str] = Field(default_factory=dict) + + _PROVIDER_META: dict[str, dict[str, str]] = { "telegram": {"display_name": "Telegram", "auth_mode": "deep_link"}, "slack": {"display_name": "Slack", "auth_mode": "binding_code"}, @@ -69,6 +83,31 @@ _PROVIDER_META: dict[str, dict[str, str]] = { "wecom": {"display_name": "WeCom", "auth_mode": "binding_code"}, } +_CREDENTIAL_FIELDS: dict[str, tuple[dict[str, str], ...]] = { + "telegram": ( + {"name": "bot_token", "label": "Bot token", "type": "password"}, + {"name": "bot_username", "label": "Bot username", "type": "text"}, + ), + "slack": ( + {"name": "bot_token", "label": "Bot token", "type": "password"}, + {"name": "app_token", "label": "App token", "type": "password"}, + ), + "discord": ({"name": "bot_token", "label": "Bot token", "type": "password"},), + "feishu": ( + {"name": "app_id", "label": "App ID", "type": "text"}, + {"name": "app_secret", "label": "App secret", "type": "password"}, + ), + "dingtalk": ( + {"name": "client_id", "label": "Client ID", "type": "text"}, + {"name": "client_secret", "label": "Client secret", "type": "password"}, + ), + "wechat": ({"name": "bot_token", "label": "Bot token", "type": "password"},), + "wecom": ( + {"name": "bot_id", "label": "Bot ID", "type": "text"}, + {"name": "bot_secret", "label": "Bot secret", "type": "password"}, + ), +} + _RUNTIME_REQUIREMENTS: dict[str, tuple[str, ...]] = { "telegram": ("bot_token",), "slack": ("bot_token", "app_token"), @@ -140,8 +179,9 @@ def _runtime_channel_configured(provider: str, channels_config: dict[str, Any]) def _runtime_unavailable_reason(provider: str) -> str: - keys = " and ".join(f"channels.{provider}.{key}" for key in _RUNTIME_REQUIREMENTS[provider]) - return f"Enable and configure channels.{provider} with {keys}." + meta = _PROVIDER_META.get(provider) + display_name = meta["display_name"] if meta else provider + return f"Enter the required {display_name} credentials to connect this channel." def _provider_unavailable_reason( @@ -153,9 +193,7 @@ def _provider_unavailable_reason( if not provider_config.enabled: return None if not provider_config.configured: - if provider == "telegram": - return "Configure channel_connections.telegram.bot_username for Telegram deep links." - return f"Configure channel_connections.{provider}." + return _runtime_unavailable_reason(provider) if not _runtime_channel_configured(provider, channels_config): return _runtime_unavailable_reason(provider) return None @@ -231,6 +269,62 @@ def _newest_connection_by_provider(connections: list[dict[str, Any]]) -> dict[st return by_provider +def _credential_fields(provider: str) -> list[ChannelCredentialFieldResponse]: + fields = _CREDENTIAL_FIELDS.get(provider) + if fields is None: + raise HTTPException(status_code=404, detail="Unknown channel provider") + return [ChannelCredentialFieldResponse(**field) for field in fields] + + +def _provider_response( + config: ChannelConnectionsConfig, + channels_config: dict[str, Any], + provider: str, + meta: dict[str, str], + connection: dict[str, Any] | None = None, +) -> ChannelProviderResponse: + status, unavailable_reason = _provider_status(config, channels_config, provider) + return ChannelProviderResponse( + provider=provider, + display_name=meta["display_name"], + enabled=status["enabled"], + configured=status["configured"], + connectable=status["enabled"] and status["configured"] and unavailable_reason is None, + unavailable_reason=unavailable_reason, + auth_mode=meta["auth_mode"], + connection_status=connection["status"] if connection else "not_connected", + credential_fields=_credential_fields(provider), + ) + + +def _required_runtime_values(provider: str, values: dict[str, str]) -> dict[str, str]: + fields = _credential_fields(provider) + cleaned: dict[str, str] = {} + missing: list[str] = [] + for field in fields: + raw_value = values.get(field.name, "") + value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value or "").strip() + if field.required and not value: + missing.append(field.label) + cleaned[field.name] = value + if missing: + raise HTTPException(status_code=400, detail=f"Missing required channel configuration: {', '.join(missing)}") + return cleaned + + +async def _restart_runtime_channel_if_available(provider: str, runtime_config: dict[str, Any]) -> bool | None: + try: + from app.channels.service import get_channel_service + except Exception: + logger.exception("Failed to import channel service while configuring %s", provider) + return None + + service = get_channel_service() + if service is None: + return None + return await service.configure_channel(provider, runtime_config) + + @router.get("/providers", response_model=ChannelProvidersResponse) async def get_channel_providers(request: Request) -> ChannelProvidersResponse: config = _get_channel_connections_config(request) @@ -248,20 +342,10 @@ async def get_channel_providers(request: Request) -> ChannelProvidersResponse: providers: list[ChannelProviderResponse] = [] for provider, meta in _PROVIDER_META.items(): - status, unavailable_reason = _provider_status(config, channels_config, provider) + if not config.provider_status(provider)["enabled"]: + continue connection = by_provider.get(provider) - providers.append( - ChannelProviderResponse( - provider=provider, - display_name=meta["display_name"], - enabled=status["enabled"], - configured=status["configured"], - connectable=status["enabled"] and status["configured"] and unavailable_reason is None, - unavailable_reason=unavailable_reason, - auth_mode=meta["auth_mode"], - connection_status=connection["status"] if connection else "not_connected", - ) - ) + providers.append(_provider_response(config, channels_config, provider, meta, connection)) return ChannelProvidersResponse(enabled=config.enabled, providers=providers) @@ -320,3 +404,41 @@ async def connect_channel_provider(provider: str, request: Request) -> ChannelCo instruction=_connect_instruction(provider, code), expires_in=_STATE_TTL_SECONDS, ) + + +@router.post("/{provider}/runtime-config", response_model=ChannelProviderResponse) +async def configure_channel_provider_runtime( + provider: str, + body: ChannelRuntimeConfigRequest, + request: Request, +) -> ChannelProviderResponse: + config = _get_channel_connections_config(request) + if not config.enabled: + raise HTTPException(status_code=400, detail="Channel connections are disabled") + + provider_config = _provider_config(config, provider) + if not provider_config.enabled: + raise HTTPException(status_code=400, detail="Channel provider is not enabled") + + values = _required_runtime_values(provider, body.values) + channels_config = _get_channels_config(request) + existing = channels_config.get(provider) + runtime_config = dict(existing) if isinstance(existing, dict) else {} + runtime_config["enabled"] = True + + for key in _RUNTIME_REQUIREMENTS[provider]: + runtime_config[key] = values[key] + + if provider == "telegram": + provider_config.bot_username = values["bot_username"] + request.app.state.channel_connections_config = config + + channels_config[provider] = runtime_config + request.app.state.channels_config = channels_config + + started = await _restart_runtime_channel_if_available(provider, runtime_config) + if started is False: + display_name = _PROVIDER_META[provider]["display_name"] + raise HTTPException(status_code=400, detail=f"Failed to start {display_name} channel. Check the values and try again.") + + return _provider_response(config, channels_config, provider, _PROVIDER_META[provider]) diff --git a/backend/tests/test_channel_connections_router.py b/backend/tests/test_channel_connections_router.py index 42f7fc0f3..374210477 100644 --- a/backend/tests/test_channel_connections_router.py +++ b/backend/tests/test_channel_connections_router.py @@ -65,6 +65,46 @@ def _channels_config() -> dict: } +def test_get_providers_only_returns_enabled_channels_and_setup_fields(tmp_path): + import anyio + + repo = anyio.run(_make_repo, tmp_path) + config = ChannelConnectionsConfig.model_validate( + { + "enabled": True, + "slack": {"enabled": True}, + "discord": {"enabled": False}, + } + ) + app = _make_app(config, repo, {}) + + with TestClient(app) as client: + response = client.get("/api/channels/providers") + + assert response.status_code == 200 + body = response.json() + assert body["enabled"] is True + assert [provider["provider"] for provider in body["providers"]] == ["slack"] + assert body["providers"][0]["configured"] is False + assert body["providers"][0]["connectable"] is False + assert body["providers"][0]["credential_fields"] == [ + { + "name": "bot_token", + "label": "Bot token", + "type": "password", + "required": True, + }, + { + "name": "app_token", + "label": "App token", + "type": "password", + "required": True, + }, + ] + + anyio.run(repo.close) + + def test_get_providers_uses_existing_channels_config(tmp_path): import anyio @@ -111,17 +151,17 @@ def test_get_providers_reports_unconfigured_when_runtime_channel_is_missing(tmp_ assert by_provider["telegram"]["configured"] is True assert by_provider["slack"]["configured"] is False assert by_provider["slack"]["connectable"] is False - assert "channels.slack" in by_provider["slack"]["unavailable_reason"] + assert "Slack credentials" in by_provider["slack"]["unavailable_reason"] assert by_provider["discord"]["configured"] is False - assert "channels.discord" in by_provider["discord"]["unavailable_reason"] + assert "Discord credentials" in by_provider["discord"]["unavailable_reason"] assert by_provider["feishu"]["configured"] is False - assert "channels.feishu" in by_provider["feishu"]["unavailable_reason"] + assert "Feishu credentials" in by_provider["feishu"]["unavailable_reason"] assert by_provider["dingtalk"]["configured"] is False - assert "channels.dingtalk" in by_provider["dingtalk"]["unavailable_reason"] + assert "DingTalk credentials" in by_provider["dingtalk"]["unavailable_reason"] assert by_provider["wechat"]["configured"] is False - assert "channels.wechat" in by_provider["wechat"]["unavailable_reason"] + assert "WeChat credentials" in by_provider["wechat"]["unavailable_reason"] assert by_provider["wecom"]["configured"] is False - assert "channels.wecom" in by_provider["wecom"]["unavailable_reason"] + assert "WeCom credentials" in by_provider["wecom"]["unavailable_reason"] anyio.run(repo.close) @@ -315,7 +355,42 @@ def test_connect_unconfigured_runtime_channel_returns_400(tmp_path): response = client.post("/api/channels/slack/connect") assert response.status_code == 400 - assert "channels.slack" in response.json()["detail"] + assert "Slack credentials" in response.json()["detail"] + + anyio.run(repo.close) + + +def test_configure_provider_runtime_credentials_enables_connect_without_file_edits(tmp_path): + import anyio + + repo = anyio.run(_make_repo, tmp_path) + config = ChannelConnectionsConfig.model_validate( + { + "enabled": True, + "slack": {"enabled": True}, + } + ) + app = _make_app(config, repo, {}) + + with TestClient(app) as client: + configure_response = client.post( + "/api/channels/slack/runtime-config", + json={"values": {"bot_token": "xoxb-ui", "app_token": "xapp-ui"}}, + ) + connect_response = client.post("/api/channels/slack/connect") + + assert configure_response.status_code == 200 + configured = configure_response.json() + assert configured["provider"] == "slack" + assert configured["configured"] is True + assert configured["connectable"] is True + assert app.state.channels_config["slack"] == { + "enabled": True, + "bot_token": "xoxb-ui", + "app_token": "xapp-ui", + } + assert connect_response.status_code == 200 + assert connect_response.json()["provider"] == "slack" anyio.run(repo.close) diff --git a/frontend/src/components/workspace/channels/channel-runtime-config-dialog.tsx b/frontend/src/components/workspace/channels/channel-runtime-config-dialog.tsx new file mode 100644 index 000000000..49a166af1 --- /dev/null +++ b/frontend/src/components/workspace/channels/channel-runtime-config-dialog.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { LoaderCircleIcon } from "lucide-react"; +import { type FormEvent, useEffect, useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import type { + ChannelProvider, + ChannelRuntimeConfigValues, +} from "@/core/channels/types"; +import { useI18n } from "@/core/i18n/hooks"; + +type ChannelRuntimeConfigDialogProps = { + provider: ChannelProvider | null; + open: boolean; + submitting: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: ( + provider: ChannelProvider, + values: ChannelRuntimeConfigValues, + ) => void; +}; + +export function ChannelRuntimeConfigDialog({ + provider, + open, + submitting, + onOpenChange, + onSubmit, +}: ChannelRuntimeConfigDialogProps) { + const { t } = useI18n(); + const [values, setValues] = useState({}); + const fields = useMemo( + () => provider?.credential_fields ?? [], + [provider?.credential_fields], + ); + + useEffect(() => { + if (!open || !provider) { + setValues({}); + return; + } + setValues( + Object.fromEntries(fields.map((field) => [field.name, ""])) as + | ChannelRuntimeConfigValues + | {}, + ); + }, [fields, open, provider]); + + if (!provider) { + return null; + } + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + onSubmit(provider, values); + }; + + return ( + + +
+ + + {t.channels.setupTitle(provider.display_name)} + + {t.channels.setupDescription} + + +
+ {fields.map((field) => { + const inputId = `channel-${provider.provider}-${field.name}`; + return ( +
+ + { + setValues((current) => ({ + ...current, + [field.name]: event.target.value, + })); + }} + /> +
+ ); + })} +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/workspace/channels/workspace-channels-list.tsx b/frontend/src/components/workspace/channels/workspace-channels-list.tsx index 68066ed89..9fcb36067 100644 --- a/frontend/src/components/workspace/channels/workspace-channels-list.tsx +++ b/frontend/src/components/workspace/channels/workspace-channels-list.tsx @@ -1,6 +1,7 @@ "use client"; import { CheckIcon, LoaderCircleIcon } from "lucide-react"; +import { useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -13,6 +14,7 @@ import { } from "@/components/ui/sidebar"; import { Skeleton } from "@/components/ui/skeleton"; import { + useConfigureChannelProvider, useChannelProviders, useConnectChannelProvider, } from "@/core/channels/hooks"; @@ -26,6 +28,7 @@ import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; import { ChannelProviderIcon } from "./channel-provider-icon"; +import { ChannelRuntimeConfigDialog } from "./channel-runtime-config-dialog"; function providerCanConnect(provider: ChannelProvider): boolean { return ( @@ -50,11 +53,52 @@ function getProviderUnavailableReason( return provider.unavailable_reason ?? undefined; } +function providerNeedsRuntimeConfig(provider: ChannelProvider): boolean { + return ( + provider.enabled && + !provider.configured && + (provider.credential_fields?.length ?? 0) > 0 + ); +} + export function WorkspaceChannelsList() { const { open: isSidebarOpen } = useSidebar(); const { t } = useI18n(); const { enabled, providers, isLoading, error } = useChannelProviders(); const connectMutation = useConnectChannelProvider(); + const configureMutation = useConfigureChannelProvider(); + const [setupProvider, setSetupProvider] = useState( + null, + ); + const visibleProviders = providers.filter((provider) => provider.enabled); + + const startConnect = ( + provider: ChannelProvider, + preparedWindow?: Window | null, + ) => { + const connectWindow = + preparedWindow !== undefined + ? preparedWindow + : provider.auth_mode === "deep_link" + ? prepareConnectWindow() + : null; + void connectMutation + .mutateAsync(provider.provider) + .then((result) => { + if (result.url) { + openConnectUrl(result.url, connectWindow); + return; + } + closeConnectWindow(connectWindow); + toast.success(result.instruction); + }) + .catch((error) => { + closeConnectWindow(connectWindow); + toast.error( + error instanceof Error ? error.message : t.channels.unavailable, + ); + }); + }; if (!isSidebarOpen) { return null; @@ -73,7 +117,7 @@ export function WorkspaceChannelsList() { ); } - if (error || !enabled || providers.length === 0) { + if (error || !enabled || visibleProviders.length === 0) { return null; } @@ -81,11 +125,13 @@ export function WorkspaceChannelsList() { {t.sidebar.channels} - {providers.map((provider) => { + {visibleProviders.map((provider) => { const isConnected = provider.connection_status === "connected"; const isPending = - connectMutation.isPending && - connectMutation.variables === provider.provider; + (connectMutation.isPending && + connectMutation.variables === provider.provider) || + (configureMutation.isPending && + configureMutation.variables?.provider === provider.provider); const canConnect = providerCanConnect(provider); const unavailableReason = getProviderUnavailableReason(provider, t); @@ -110,33 +156,17 @@ export function WorkspaceChannelsList() { disabled={isConnected || isPending} title={unavailableReason} onClick={() => { + if (providerNeedsRuntimeConfig(provider)) { + setSetupProvider(provider); + return; + } + if (!canConnect) { toast.error(unavailableReason ?? t.channels.unavailable); return; } - const connectWindow = - provider.auth_mode === "deep_link" - ? prepareConnectWindow() - : null; - void connectMutation - .mutateAsync(provider.provider) - .then((result) => { - if (result.url) { - openConnectUrl(result.url, connectWindow); - return; - } - closeConnectWindow(connectWindow); - toast.success(result.instruction); - }) - .catch((error) => { - closeConnectWindow(connectWindow); - toast.error( - error instanceof Error - ? error.message - : t.channels.unavailable, - ); - }); + startConnect(provider); }} > {isPending ? ( @@ -153,6 +183,32 @@ export function WorkspaceChannelsList() { ); })} + { + if (!open) { + setSetupProvider(null); + } + }} + onSubmit={(provider, values) => { + const connectWindow = + provider.auth_mode === "deep_link" ? prepareConnectWindow() : null; + void configureMutation + .mutateAsync({ provider: provider.provider, values }) + .then((configuredProvider) => { + setSetupProvider(null); + startConnect(configuredProvider, connectWindow); + }) + .catch((error) => { + closeConnectWindow(connectWindow); + toast.error( + error instanceof Error ? error.message : t.channels.unavailable, + ); + }); + }} + /> ); } diff --git a/frontend/src/components/workspace/settings/channels-settings-page.tsx b/frontend/src/components/workspace/settings/channels-settings-page.tsx index 0b8bfe08b..f180ef050 100644 --- a/frontend/src/components/workspace/settings/channels-settings-page.tsx +++ b/frontend/src/components/workspace/settings/channels-settings-page.tsx @@ -7,6 +7,7 @@ import { PlugIcon, UnplugIcon, } from "lucide-react"; +import { useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; @@ -20,6 +21,7 @@ import { ItemTitle, } from "@/components/ui/item"; import { + useConfigureChannelProvider, useChannelConnections, useChannelProviders, useConnectChannelProvider, @@ -35,6 +37,7 @@ import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; import { ChannelProviderIcon } from "../channels/channel-provider-icon"; +import { ChannelRuntimeConfigDialog } from "../channels/channel-runtime-config-dialog"; import { SettingsSection } from "./settings-section"; @@ -97,6 +100,14 @@ function getProviderUnavailableReason( return provider.unavailable_reason ?? undefined; } +function providerNeedsRuntimeConfig(provider: ChannelProvider): boolean { + return ( + provider.enabled && + !provider.configured && + (provider.credential_fields?.length ?? 0) > 0 + ); +} + function ChannelProviderItem({ provider, connection, @@ -106,14 +117,18 @@ function ChannelProviderItem({ }) { const { t } = useI18n(); const connectMutation = useConnectChannelProvider(); + const configureMutation = useConfigureChannelProvider(); const disconnectMutation = useDisconnectChannelConnection(); + const [setupOpen, setSetupOpen] = useState(false); const isConnected = connection?.status === "connected"; const canConnect = (provider.connectable ?? (provider.enabled && provider.configured)) && !isConnected; const isConnecting = - connectMutation.isPending && - connectMutation.variables === provider.provider; + (connectMutation.isPending && + connectMutation.variables === provider.provider) || + (configureMutation.isPending && + configureMutation.variables?.provider === provider.provider); const isDisconnecting = disconnectMutation.isPending && disconnectMutation.variables === connection?.id; @@ -121,94 +136,137 @@ function ChannelProviderItem({ const statusLabel = getStatusLabel(provider, connection, t); const unavailableReason = getProviderUnavailableReason(provider, t); - return ( - - - - - - - {provider.display_name} - - {isConnected ? : } - {statusLabel} - - - - {getProviderDescription(provider, t.channels.descriptions)} - {connectionLabel ? ` ${t.channels.connectedAs(connectionLabel)}` : ""} - {!isConnected && provider.unavailable_reason - ? ` ${provider.unavailable_reason}` - : ""} - - - - {isConnected && connection ? ( - - ) : ( - - )} - - + return ( + <> + + + + + + + {provider.display_name} + + {isConnected ? : } + {statusLabel} + + + + {getProviderDescription(provider, t.channels.descriptions)} + {connectionLabel + ? ` ${t.channels.connectedAs(connectionLabel)}` + : ""} + {!isConnected && provider.unavailable_reason + ? ` ${provider.unavailable_reason}` + : ""} + + + + {isConnected && connection ? ( + + ) : ( + + )} + + + { + const connectWindow = + submitProvider.auth_mode === "deep_link" + ? prepareConnectWindow() + : null; + void configureMutation + .mutateAsync({ provider: submitProvider.provider, values }) + .then((configuredProvider) => { + setSetupOpen(false); + startConnect(configuredProvider, connectWindow); + }) + .catch((error) => { + closeConnectWindow(connectWindow); + toast.error( + error instanceof Error ? error.message : t.channels.unavailable, + ); + }); + }} + /> + ); } @@ -227,6 +285,7 @@ export function ChannelsSettingsPage() { } = useChannelConnections(); const isLoading = providersLoading || connectionsLoading; const error = providersError ?? connectionsError; + const visibleProviders = providers.filter((provider) => provider.enabled); const connectionByProvider = new Map(); for (const connection of connections) { @@ -249,9 +308,13 @@ export function ChannelsSettingsPage() {
{t.settings.channels.disabled}
+ ) : visibleProviders.length === 0 ? ( +
+ {t.settings.channels.disabled} +
) : (
- {providers.map((provider) => ( + {visibleProviders.map((provider) => ( ; } +export async function configureChannelProvider( + provider: ChannelProviderId, + values: ChannelRuntimeConfigValues, +): Promise { + const response = await fetch( + channelsUrl(`/${encodeURIComponent(provider)}/runtime-config`), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values }), + }, + ); + if (!response.ok) { + await throwChannelApiError( + response, + `Failed to configure ${provider}: ${response.statusText}`, + ); + } + return response.json() as Promise; +} + export async function disconnectChannelConnection( connectionId: string, ): Promise { diff --git a/frontend/src/core/channels/hooks.ts b/frontend/src/core/channels/hooks.ts index 1d8480109..69c19a6f9 100644 --- a/frontend/src/core/channels/hooks.ts +++ b/frontend/src/core/channels/hooks.ts @@ -1,12 +1,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + configureChannelProvider, connectChannelProvider, disconnectChannelConnection, listChannelConnections, listChannelProviders, } from "./api"; -import type { ChannelProviderId } from "./types"; +import type { ChannelProviderId, ChannelRuntimeConfigValues } from "./types"; export const channelProviderQueryKey = ["channelProviders"] as const; export const channelConnectionsQueryKey = ["channelConnections"] as const; @@ -46,6 +47,25 @@ export function useConnectChannelProvider() { }); } +export function useConfigureChannelProvider() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + provider, + values, + }: { + provider: ChannelProviderId; + values: ChannelRuntimeConfigValues; + }) => configureChannelProvider(provider, values), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey }); + void queryClient.invalidateQueries({ + queryKey: channelConnectionsQueryKey, + }); + }, + }); +} + export function useDisconnectChannelConnection() { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/core/channels/types.ts b/frontend/src/core/channels/types.ts index 2796bed84..557ac191d 100644 --- a/frontend/src/core/channels/types.ts +++ b/frontend/src/core/channels/types.ts @@ -1,5 +1,12 @@ export type ChannelProviderId = "telegram" | "slack" | "discord" | string; +export interface ChannelCredentialField { + name: string; + label: string; + type: string; + required: boolean; +} + export interface ChannelProvider { provider: ChannelProviderId; display_name: string; @@ -9,6 +16,7 @@ export interface ChannelProvider { unavailable_reason?: string | null; auth_mode: string; connection_status: string; + credential_fields: ChannelCredentialField[]; } export interface ChannelProvidersResponse { @@ -40,3 +48,5 @@ export interface ChannelConnectResponse { instruction: string; expires_in: number; } + +export type ChannelRuntimeConfigValues = Record; diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 835a8310e..c02a7df56 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -269,6 +269,10 @@ export const enUS: Translations = { unconfigured: "Not configured", unavailable: "Channel connections are unavailable right now.", unavailableShort: "Unavailable", + setupTitle: (name: string) => `Connect ${name}`, + setupDescription: + "Enter the values needed by this server process. They are not written to config.yaml.", + saveAndConnect: "Save and connect", descriptions: { telegram: "Telegram direct messages through your DeerFlow bot.", slack: "Slack workspace messages and mentions.", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 9a18450c1..88510a4c3 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -200,6 +200,9 @@ export interface Translations { unconfigured: string; unavailable: string; unavailableShort: string; + setupTitle: (name: string) => string; + setupDescription: string; + saveAndConnect: string; descriptions: Record; connectedAs: (name: string) => string; }; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index c2b649886..f942ad78f 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -257,6 +257,10 @@ export const zhCN: Translations = { unconfigured: "未配置", unavailable: "当前无法使用渠道连接。", unavailableShort: "不可用", + setupTitle: (name: string) => `连接 ${name}`, + setupDescription: + "填写当前服务进程需要的配置值。这些内容不会写入 config.yaml。", + saveAndConnect: "保存并连接", descriptions: { telegram: "通过 DeerFlow Bot 接收 Telegram 私聊消息。", slack: "接收 Slack 工作区消息和提及。", diff --git a/frontend/tests/e2e/channels.spec.ts b/frontend/tests/e2e/channels.spec.ts index f2b4c7e25..645a4f802 100644 --- a/frontend/tests/e2e/channels.spec.ts +++ b/frontend/tests/e2e/channels.spec.ts @@ -21,6 +21,12 @@ type MockChannelProvider = { auth_mode: string; connection_status: string; unavailable_reason?: string | null; + credential_fields?: Array<{ + name: string; + label: string; + type: string; + required: boolean; + }>; }; function defaultProviders(): MockChannelProvider[] { @@ -32,6 +38,7 @@ function defaultProviders(): MockChannelProvider[] { connectable: true, auth_mode: authMode, connection_status: "not_connected", + credential_fields: [], })); } @@ -121,41 +128,129 @@ test.describe("IM channels", () => { ).toBeVisible(); }); - test("unavailable providers stay clickable and explain what is missing", async ({ + test("only enabled providers are shown and setup runs before connect", async ({ page, }) => { mockLangGraphAPI(page); - const unavailableReason = - "Enable and configure channels.slack with channels.slack.bot_token and channels.slack.app_token."; + let slackConfigured = false; let connectRequests = 0; - mockChannelsAPI( - page, - [ - { + let submittedValues: Record | undefined; + + void page.route("**/api/channels/providers", (route) => { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + enabled: true, + providers: [ + { + provider: "slack", + display_name: "Slack", + enabled: true, + configured: slackConfigured, + connectable: slackConfigured, + auth_mode: "binding_code", + connection_status: "not_connected", + credential_fields: [ + { + name: "bot_token", + label: "Bot token", + type: "password", + required: true, + }, + { + name: "app_token", + label: "App token", + type: "password", + required: true, + }, + ], + }, + { + provider: "discord", + display_name: "Discord", + enabled: false, + configured: false, + connectable: false, + auth_mode: "binding_code", + connection_status: "not_connected", + credential_fields: [], + }, + ], + }), + }); + }); + + void page.route("**/api/channels/connections", (route) => { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ connections: [] }), + }); + }); + + void page.route("**/api/channels/slack/runtime-config", async (route) => { + const body = route.request().postDataJSON() as { + values: Record; + }; + submittedValues = body.values; + slackConfigured = true; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ provider: "slack", display_name: "Slack", enabled: true, - configured: false, - connectable: false, - unavailable_reason: unavailableReason, + configured: true, + connectable: true, auth_mode: "binding_code", connection_status: "not_connected", - }, - ], - () => { - connectRequests += 1; - }, - ); + credential_fields: [], + }), + }); + }); + + void page.route("**/api/channels/slack/connect", (route) => { + connectRequests += 1; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + provider: "slack", + mode: "binding_code", + url: null, + code: "abc123", + instruction: "Send /connect abc123 to the DeerFlow Slack bot.", + expires_in: 600, + }), + }); + }); await page.goto("/workspace/chats/new"); const sidebar = page.locator("[data-sidebar='sidebar']"); + await expect(sidebar.getByText("Slack")).toBeVisible({ timeout: 15_000 }); + await expect(sidebar.getByText("Discord")).toBeHidden(); const connectButton = sidebar.getByRole("button", { name: "Connect" }); - await expect(connectButton).toBeEnabled({ timeout: 15_000 }); + await expect(connectButton).toBeEnabled(); await connectButton.click(); - await expect(page.getByText(unavailableReason)).toBeVisible(); - expect(connectRequests).toBe(0); + const setupDialog = page.getByRole("dialog", { name: "Connect Slack" }); + await expect(setupDialog).toBeVisible(); + await setupDialog.getByLabel("Bot token").fill("xoxb-ui"); + await setupDialog.getByLabel("App token").fill("xapp-ui"); + await setupDialog.getByRole("button", { name: "Save and connect" }).click(); + + await expect(setupDialog).toBeHidden(); + await expect( + page.getByText("Send /connect abc123 to the DeerFlow Slack bot."), + ).toBeVisible(); + expect(submittedValues).toEqual({ + bot_token: "xoxb-ui", + app_token: "xapp-ui", + }); + expect(connectRequests).toBe(1); }); }); diff --git a/frontend/tests/unit/core/channels/api.test.ts b/frontend/tests/unit/core/channels/api.test.ts index 6c8a6a5f1..745daec4f 100644 --- a/frontend/tests/unit/core/channels/api.test.ts +++ b/frontend/tests/unit/core/channels/api.test.ts @@ -10,6 +10,7 @@ vi.mock("@/core/config", () => ({ import { fetch as fetcher } from "@/core/api/fetcher"; import { + configureChannelProvider, connectChannelProvider, disconnectChannelConnection, listChannelConnections, @@ -122,6 +123,41 @@ describe("channels api", () => { }); }); + test("submits runtime provider configuration", async () => { + mockedFetch.mockResolvedValueOnce( + jsonResponse(200, { + provider: "slack", + display_name: "Slack", + enabled: true, + configured: true, + connectable: true, + auth_mode: "binding_code", + connection_status: "not_connected", + }), + ); + + await expect( + configureChannelProvider("slack", { + bot_token: "xoxb-ui", + app_token: "xapp-ui", + }), + ).resolves.toMatchObject({ + provider: "slack", + configured: true, + connectable: true, + }); + expect(mockedFetch).toHaveBeenCalledWith( + "/backend/api/channels/slack/runtime-config", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + values: { bot_token: "xoxb-ui", app_token: "xapp-ui" }, + }), + }, + ); + }); + test("disconnects a channel connection", async () => { mockedFetch.mockResolvedValueOnce(new Response(null, { status: 204 }));