mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 18:05:58 +00:00
Add runtime setup for enabled IM channels
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<ChannelRuntimeConfigValues>({});
|
||||
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<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onSubmit(provider, values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t.channels.setupTitle(provider.display_name)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t.channels.setupDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{fields.map((field) => {
|
||||
const inputId = `channel-${provider.provider}-${field.name}`;
|
||||
return (
|
||||
<div key={field.name} className="space-y-1.5">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="text-sm leading-none font-medium"
|
||||
>
|
||||
{field.label}
|
||||
</label>
|
||||
<Input
|
||||
id={inputId}
|
||||
type={field.type === "password" ? "password" : "text"}
|
||||
value={values[field.name] ?? ""}
|
||||
required={field.required}
|
||||
autoComplete="off"
|
||||
onChange={(event) => {
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field.name]: event.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={submitting}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : null}
|
||||
{t.channels.saveAndConnect}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<ChannelProvider | null>(
|
||||
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() {
|
||||
<SidebarGroup className="pt-0">
|
||||
<SidebarGroupLabel>{t.sidebar.channels}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{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() {
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
<ChannelRuntimeConfigDialog
|
||||
provider={setupProvider}
|
||||
open={setupProvider !== null}
|
||||
submitting={configureMutation.isPending}
|
||||
onOpenChange={(open) => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Item variant="outline" className="w-full items-start">
|
||||
<ItemMedia variant="icon" className="bg-background">
|
||||
<ChannelProviderIcon provider={provider.provider} className="size-5" />
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="w-full">
|
||||
<span className="truncate">{provider.display_name}</span>
|
||||
<Badge
|
||||
variant={isConnected ? "default" : "outline"}
|
||||
className={cn(!isConnected && "text-muted-foreground")}
|
||||
>
|
||||
{isConnected ? <CheckCircle2Icon /> : <AlertCircleIcon />}
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-none">
|
||||
{getProviderDescription(provider, t.channels.descriptions)}
|
||||
{connectionLabel ? ` ${t.channels.connectedAs(connectionLabel)}` : ""}
|
||||
{!isConnected && provider.unavailable_reason
|
||||
? ` ${provider.unavailable_reason}`
|
||||
: ""}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions className="ml-auto">
|
||||
{isConnected && connection ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isDisconnecting}
|
||||
onClick={() => disconnectMutation.mutate(connection.id)}
|
||||
>
|
||||
{isDisconnecting ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
<UnplugIcon />
|
||||
)}
|
||||
{t.channels.disconnect}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isConnecting}
|
||||
title={unavailableReason}
|
||||
onClick={() => {
|
||||
if (!canConnect) {
|
||||
toast.error(unavailableReason ?? t.channels.unavailable);
|
||||
return;
|
||||
}
|
||||
const startConnect = (
|
||||
connectProvider: ChannelProvider,
|
||||
preparedWindow?: Window | null,
|
||||
) => {
|
||||
const connectWindow =
|
||||
preparedWindow !== undefined
|
||||
? preparedWindow
|
||||
: connectProvider.auth_mode === "deep_link"
|
||||
? prepareConnectWindow()
|
||||
: null;
|
||||
void connectMutation
|
||||
.mutateAsync(connectProvider.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,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
<PlugIcon />
|
||||
)}
|
||||
{connection?.status === "revoked"
|
||||
? t.channels.reconnect
|
||||
: t.channels.connect}
|
||||
</Button>
|
||||
)}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
return (
|
||||
<>
|
||||
<Item variant="outline" className="w-full items-start">
|
||||
<ItemMedia variant="icon" className="bg-background">
|
||||
<ChannelProviderIcon
|
||||
provider={provider.provider}
|
||||
className="size-5"
|
||||
/>
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="w-full">
|
||||
<span className="truncate">{provider.display_name}</span>
|
||||
<Badge
|
||||
variant={isConnected ? "default" : "outline"}
|
||||
className={cn(!isConnected && "text-muted-foreground")}
|
||||
>
|
||||
{isConnected ? <CheckCircle2Icon /> : <AlertCircleIcon />}
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-none">
|
||||
{getProviderDescription(provider, t.channels.descriptions)}
|
||||
{connectionLabel
|
||||
? ` ${t.channels.connectedAs(connectionLabel)}`
|
||||
: ""}
|
||||
{!isConnected && provider.unavailable_reason
|
||||
? ` ${provider.unavailable_reason}`
|
||||
: ""}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions className="ml-auto">
|
||||
{isConnected && connection ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isDisconnecting}
|
||||
onClick={() => disconnectMutation.mutate(connection.id)}
|
||||
>
|
||||
{isDisconnecting ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
<UnplugIcon />
|
||||
)}
|
||||
{t.channels.disconnect}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isConnecting}
|
||||
title={unavailableReason}
|
||||
onClick={() => {
|
||||
if (providerNeedsRuntimeConfig(provider)) {
|
||||
setSetupOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canConnect) {
|
||||
toast.error(unavailableReason ?? t.channels.unavailable);
|
||||
return;
|
||||
}
|
||||
|
||||
startConnect(provider);
|
||||
}}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
<PlugIcon />
|
||||
)}
|
||||
{connection?.status === "revoked"
|
||||
? t.channels.reconnect
|
||||
: t.channels.connect}
|
||||
</Button>
|
||||
)}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<ChannelRuntimeConfigDialog
|
||||
provider={provider}
|
||||
open={setupOpen}
|
||||
submitting={configureMutation.isPending}
|
||||
onOpenChange={setSetupOpen}
|
||||
onSubmit={(submitProvider, values) => {
|
||||
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<string, ChannelConnection>();
|
||||
for (const connection of connections) {
|
||||
@@ -249,9 +308,13 @@ export function ChannelsSettingsPage() {
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.settings.channels.disabled}
|
||||
</div>
|
||||
) : visibleProviders.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.settings.channels.disabled}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{providers.map((provider) => (
|
||||
{visibleProviders.map((provider) => (
|
||||
<ChannelProviderItem
|
||||
key={provider.provider}
|
||||
provider={provider}
|
||||
|
||||
@@ -6,7 +6,9 @@ import type {
|
||||
ChannelConnection,
|
||||
ChannelConnectionsResponse,
|
||||
ChannelProviderId,
|
||||
ChannelProvider,
|
||||
ChannelProvidersResponse,
|
||||
ChannelRuntimeConfigValues,
|
||||
} from "./types";
|
||||
|
||||
function channelsUrl(path: string): string {
|
||||
@@ -62,6 +64,27 @@ export async function connectChannelProvider(
|
||||
return response.json() as Promise<ChannelConnectResponse>;
|
||||
}
|
||||
|
||||
export async function configureChannelProvider(
|
||||
provider: ChannelProviderId,
|
||||
values: ChannelRuntimeConfigValues,
|
||||
): Promise<ChannelProvider> {
|
||||
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<ChannelProvider>;
|
||||
}
|
||||
|
||||
export async function disconnectChannelConnection(
|
||||
connectionId: string,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, string>;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -200,6 +200,9 @@ export interface Translations {
|
||||
unconfigured: string;
|
||||
unavailable: string;
|
||||
unavailableShort: string;
|
||||
setupTitle: (name: string) => string;
|
||||
setupDescription: string;
|
||||
saveAndConnect: string;
|
||||
descriptions: Record<string, string>;
|
||||
connectedAs: (name: string) => string;
|
||||
};
|
||||
|
||||
@@ -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 工作区消息和提及。",
|
||||
|
||||
@@ -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<string, string> | 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<string, string>;
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user