mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 09:55:59 +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)
|
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:
|
async def _start_channel(self, name: str, config: dict[str, Any]) -> bool:
|
||||||
"""Instantiate and start a single channel."""
|
"""Instantiate and start a single channel."""
|
||||||
import_path = _CHANNEL_REGISTRY.get(name)
|
import_path = _CHANNEL_REGISTRY.get(name)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -14,10 +15,18 @@ from deerflow.persistence.channel_connections import ChannelConnectionRepository
|
|||||||
from deerflow.persistence.engine import get_session_factory
|
from deerflow.persistence.engine import get_session_factory
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/channels", tags=["channel-connections"])
|
router = APIRouter(prefix="/api/channels", tags=["channel-connections"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_STATE_TTL_SECONDS = 600
|
_STATE_TTL_SECONDS = 600
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelCredentialFieldResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
label: str
|
||||||
|
type: str = "text"
|
||||||
|
required: bool = True
|
||||||
|
|
||||||
|
|
||||||
class ChannelProviderResponse(BaseModel):
|
class ChannelProviderResponse(BaseModel):
|
||||||
provider: str
|
provider: str
|
||||||
display_name: str
|
display_name: str
|
||||||
@@ -27,6 +36,7 @@ class ChannelProviderResponse(BaseModel):
|
|||||||
unavailable_reason: str | None = None
|
unavailable_reason: str | None = None
|
||||||
auth_mode: str
|
auth_mode: str
|
||||||
connection_status: str
|
connection_status: str
|
||||||
|
credential_fields: list[ChannelCredentialFieldResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ChannelProvidersResponse(BaseModel):
|
class ChannelProvidersResponse(BaseModel):
|
||||||
@@ -59,6 +69,10 @@ class ChannelConnectResponse(BaseModel):
|
|||||||
expires_in: int
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelRuntimeConfigRequest(BaseModel):
|
||||||
|
values: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
_PROVIDER_META: dict[str, dict[str, str]] = {
|
_PROVIDER_META: dict[str, dict[str, str]] = {
|
||||||
"telegram": {"display_name": "Telegram", "auth_mode": "deep_link"},
|
"telegram": {"display_name": "Telegram", "auth_mode": "deep_link"},
|
||||||
"slack": {"display_name": "Slack", "auth_mode": "binding_code"},
|
"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"},
|
"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, ...]] = {
|
_RUNTIME_REQUIREMENTS: dict[str, tuple[str, ...]] = {
|
||||||
"telegram": ("bot_token",),
|
"telegram": ("bot_token",),
|
||||||
"slack": ("bot_token", "app_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:
|
def _runtime_unavailable_reason(provider: str) -> str:
|
||||||
keys = " and ".join(f"channels.{provider}.{key}" for key in _RUNTIME_REQUIREMENTS[provider])
|
meta = _PROVIDER_META.get(provider)
|
||||||
return f"Enable and configure channels.{provider} with {keys}."
|
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(
|
def _provider_unavailable_reason(
|
||||||
@@ -153,9 +193,7 @@ def _provider_unavailable_reason(
|
|||||||
if not provider_config.enabled:
|
if not provider_config.enabled:
|
||||||
return None
|
return None
|
||||||
if not provider_config.configured:
|
if not provider_config.configured:
|
||||||
if provider == "telegram":
|
return _runtime_unavailable_reason(provider)
|
||||||
return "Configure channel_connections.telegram.bot_username for Telegram deep links."
|
|
||||||
return f"Configure channel_connections.{provider}."
|
|
||||||
if not _runtime_channel_configured(provider, channels_config):
|
if not _runtime_channel_configured(provider, channels_config):
|
||||||
return _runtime_unavailable_reason(provider)
|
return _runtime_unavailable_reason(provider)
|
||||||
return None
|
return None
|
||||||
@@ -231,6 +269,62 @@ def _newest_connection_by_provider(connections: list[dict[str, Any]]) -> dict[st
|
|||||||
return by_provider
|
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)
|
@router.get("/providers", response_model=ChannelProvidersResponse)
|
||||||
async def get_channel_providers(request: Request) -> ChannelProvidersResponse:
|
async def get_channel_providers(request: Request) -> ChannelProvidersResponse:
|
||||||
config = _get_channel_connections_config(request)
|
config = _get_channel_connections_config(request)
|
||||||
@@ -248,20 +342,10 @@ async def get_channel_providers(request: Request) -> ChannelProvidersResponse:
|
|||||||
|
|
||||||
providers: list[ChannelProviderResponse] = []
|
providers: list[ChannelProviderResponse] = []
|
||||||
for provider, meta in _PROVIDER_META.items():
|
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)
|
connection = by_provider.get(provider)
|
||||||
providers.append(
|
providers.append(_provider_response(config, channels_config, provider, meta, connection))
|
||||||
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",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return ChannelProvidersResponse(enabled=config.enabled, providers=providers)
|
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),
|
instruction=_connect_instruction(provider, code),
|
||||||
expires_in=_STATE_TTL_SECONDS,
|
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):
|
def test_get_providers_uses_existing_channels_config(tmp_path):
|
||||||
import anyio
|
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["telegram"]["configured"] is True
|
||||||
assert by_provider["slack"]["configured"] is False
|
assert by_provider["slack"]["configured"] is False
|
||||||
assert by_provider["slack"]["connectable"] 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 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 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 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 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 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)
|
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")
|
response = client.post("/api/channels/slack/connect")
|
||||||
|
|
||||||
assert response.status_code == 400
|
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)
|
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";
|
"use client";
|
||||||
|
|
||||||
import { CheckIcon, LoaderCircleIcon } from "lucide-react";
|
import { CheckIcon, LoaderCircleIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
|
useConfigureChannelProvider,
|
||||||
useChannelProviders,
|
useChannelProviders,
|
||||||
useConnectChannelProvider,
|
useConnectChannelProvider,
|
||||||
} from "@/core/channels/hooks";
|
} from "@/core/channels/hooks";
|
||||||
@@ -26,6 +28,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { ChannelProviderIcon } from "./channel-provider-icon";
|
import { ChannelProviderIcon } from "./channel-provider-icon";
|
||||||
|
import { ChannelRuntimeConfigDialog } from "./channel-runtime-config-dialog";
|
||||||
|
|
||||||
function providerCanConnect(provider: ChannelProvider): boolean {
|
function providerCanConnect(provider: ChannelProvider): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -50,11 +53,52 @@ function getProviderUnavailableReason(
|
|||||||
return provider.unavailable_reason ?? undefined;
|
return provider.unavailable_reason ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function providerNeedsRuntimeConfig(provider: ChannelProvider): boolean {
|
||||||
|
return (
|
||||||
|
provider.enabled &&
|
||||||
|
!provider.configured &&
|
||||||
|
(provider.credential_fields?.length ?? 0) > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkspaceChannelsList() {
|
export function WorkspaceChannelsList() {
|
||||||
const { open: isSidebarOpen } = useSidebar();
|
const { open: isSidebarOpen } = useSidebar();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { enabled, providers, isLoading, error } = useChannelProviders();
|
const { enabled, providers, isLoading, error } = useChannelProviders();
|
||||||
const connectMutation = useConnectChannelProvider();
|
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) {
|
if (!isSidebarOpen) {
|
||||||
return null;
|
return null;
|
||||||
@@ -73,7 +117,7 @@ export function WorkspaceChannelsList() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !enabled || providers.length === 0) {
|
if (error || !enabled || visibleProviders.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,11 +125,13 @@ export function WorkspaceChannelsList() {
|
|||||||
<SidebarGroup className="pt-0">
|
<SidebarGroup className="pt-0">
|
||||||
<SidebarGroupLabel>{t.sidebar.channels}</SidebarGroupLabel>
|
<SidebarGroupLabel>{t.sidebar.channels}</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{providers.map((provider) => {
|
{visibleProviders.map((provider) => {
|
||||||
const isConnected = provider.connection_status === "connected";
|
const isConnected = provider.connection_status === "connected";
|
||||||
const isPending =
|
const isPending =
|
||||||
connectMutation.isPending &&
|
(connectMutation.isPending &&
|
||||||
connectMutation.variables === provider.provider;
|
connectMutation.variables === provider.provider) ||
|
||||||
|
(configureMutation.isPending &&
|
||||||
|
configureMutation.variables?.provider === provider.provider);
|
||||||
const canConnect = providerCanConnect(provider);
|
const canConnect = providerCanConnect(provider);
|
||||||
const unavailableReason = getProviderUnavailableReason(provider, t);
|
const unavailableReason = getProviderUnavailableReason(provider, t);
|
||||||
|
|
||||||
@@ -110,33 +156,17 @@ export function WorkspaceChannelsList() {
|
|||||||
disabled={isConnected || isPending}
|
disabled={isConnected || isPending}
|
||||||
title={unavailableReason}
|
title={unavailableReason}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (providerNeedsRuntimeConfig(provider)) {
|
||||||
|
setSetupProvider(provider);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!canConnect) {
|
if (!canConnect) {
|
||||||
toast.error(unavailableReason ?? t.channels.unavailable);
|
toast.error(unavailableReason ?? t.channels.unavailable);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectWindow =
|
startConnect(provider);
|
||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
@@ -153,6 +183,32 @@ export function WorkspaceChannelsList() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</SidebarMenu>
|
</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>
|
</SidebarGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
PlugIcon,
|
PlugIcon,
|
||||||
UnplugIcon,
|
UnplugIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import {
|
import {
|
||||||
|
useConfigureChannelProvider,
|
||||||
useChannelConnections,
|
useChannelConnections,
|
||||||
useChannelProviders,
|
useChannelProviders,
|
||||||
useConnectChannelProvider,
|
useConnectChannelProvider,
|
||||||
@@ -35,6 +37,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { ChannelProviderIcon } from "../channels/channel-provider-icon";
|
import { ChannelProviderIcon } from "../channels/channel-provider-icon";
|
||||||
|
import { ChannelRuntimeConfigDialog } from "../channels/channel-runtime-config-dialog";
|
||||||
|
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
|
|
||||||
@@ -97,6 +100,14 @@ function getProviderUnavailableReason(
|
|||||||
return provider.unavailable_reason ?? undefined;
|
return provider.unavailable_reason ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function providerNeedsRuntimeConfig(provider: ChannelProvider): boolean {
|
||||||
|
return (
|
||||||
|
provider.enabled &&
|
||||||
|
!provider.configured &&
|
||||||
|
(provider.credential_fields?.length ?? 0) > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ChannelProviderItem({
|
function ChannelProviderItem({
|
||||||
provider,
|
provider,
|
||||||
connection,
|
connection,
|
||||||
@@ -106,14 +117,18 @@ function ChannelProviderItem({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const connectMutation = useConnectChannelProvider();
|
const connectMutation = useConnectChannelProvider();
|
||||||
|
const configureMutation = useConfigureChannelProvider();
|
||||||
const disconnectMutation = useDisconnectChannelConnection();
|
const disconnectMutation = useDisconnectChannelConnection();
|
||||||
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
const isConnected = connection?.status === "connected";
|
const isConnected = connection?.status === "connected";
|
||||||
const canConnect =
|
const canConnect =
|
||||||
(provider.connectable ?? (provider.enabled && provider.configured)) &&
|
(provider.connectable ?? (provider.enabled && provider.configured)) &&
|
||||||
!isConnected;
|
!isConnected;
|
||||||
const isConnecting =
|
const isConnecting =
|
||||||
connectMutation.isPending &&
|
(connectMutation.isPending &&
|
||||||
connectMutation.variables === provider.provider;
|
connectMutation.variables === provider.provider) ||
|
||||||
|
(configureMutation.isPending &&
|
||||||
|
configureMutation.variables?.provider === provider.provider);
|
||||||
const isDisconnecting =
|
const isDisconnecting =
|
||||||
disconnectMutation.isPending &&
|
disconnectMutation.isPending &&
|
||||||
disconnectMutation.variables === connection?.id;
|
disconnectMutation.variables === connection?.id;
|
||||||
@@ -121,94 +136,137 @@ function ChannelProviderItem({
|
|||||||
const statusLabel = getStatusLabel(provider, connection, t);
|
const statusLabel = getStatusLabel(provider, connection, t);
|
||||||
const unavailableReason = getProviderUnavailableReason(provider, t);
|
const unavailableReason = getProviderUnavailableReason(provider, t);
|
||||||
|
|
||||||
return (
|
const startConnect = (
|
||||||
<Item variant="outline" className="w-full items-start">
|
connectProvider: ChannelProvider,
|
||||||
<ItemMedia variant="icon" className="bg-background">
|
preparedWindow?: Window | null,
|
||||||
<ChannelProviderIcon provider={provider.provider} className="size-5" />
|
) => {
|
||||||
</ItemMedia>
|
const connectWindow =
|
||||||
<ItemContent className="min-w-0">
|
preparedWindow !== undefined
|
||||||
<ItemTitle className="w-full">
|
? preparedWindow
|
||||||
<span className="truncate">{provider.display_name}</span>
|
: connectProvider.auth_mode === "deep_link"
|
||||||
<Badge
|
? prepareConnectWindow()
|
||||||
variant={isConnected ? "default" : "outline"}
|
: null;
|
||||||
className={cn(!isConnected && "text-muted-foreground")}
|
void connectMutation
|
||||||
>
|
.mutateAsync(connectProvider.provider)
|
||||||
{isConnected ? <CheckCircle2Icon /> : <AlertCircleIcon />}
|
.then((result) => {
|
||||||
{statusLabel}
|
if (result.url) {
|
||||||
</Badge>
|
openConnectUrl(result.url, connectWindow);
|
||||||
</ItemTitle>
|
return;
|
||||||
<ItemDescription className="line-clamp-none">
|
}
|
||||||
{getProviderDescription(provider, t.channels.descriptions)}
|
closeConnectWindow(connectWindow);
|
||||||
{connectionLabel ? ` ${t.channels.connectedAs(connectionLabel)}` : ""}
|
toast.success(result.instruction);
|
||||||
{!isConnected && provider.unavailable_reason
|
})
|
||||||
? ` ${provider.unavailable_reason}`
|
.catch((error) => {
|
||||||
: ""}
|
closeConnectWindow(connectWindow);
|
||||||
</ItemDescription>
|
toast.error(
|
||||||
</ItemContent>
|
error instanceof Error ? error.message : t.channels.unavailable,
|
||||||
<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 connectWindow =
|
return (
|
||||||
provider.auth_mode === "deep_link"
|
<>
|
||||||
? prepareConnectWindow()
|
<Item variant="outline" className="w-full items-start">
|
||||||
: null;
|
<ItemMedia variant="icon" className="bg-background">
|
||||||
void connectMutation
|
<ChannelProviderIcon
|
||||||
.mutateAsync(provider.provider)
|
provider={provider.provider}
|
||||||
.then((result) => {
|
className="size-5"
|
||||||
if (result.url) {
|
/>
|
||||||
openConnectUrl(result.url, connectWindow);
|
</ItemMedia>
|
||||||
return;
|
<ItemContent className="min-w-0">
|
||||||
}
|
<ItemTitle className="w-full">
|
||||||
closeConnectWindow(connectWindow);
|
<span className="truncate">{provider.display_name}</span>
|
||||||
toast.success(result.instruction);
|
<Badge
|
||||||
})
|
variant={isConnected ? "default" : "outline"}
|
||||||
.catch((error) => {
|
className={cn(!isConnected && "text-muted-foreground")}
|
||||||
closeConnectWindow(connectWindow);
|
>
|
||||||
toast.error(
|
{isConnected ? <CheckCircle2Icon /> : <AlertCircleIcon />}
|
||||||
error instanceof Error
|
{statusLabel}
|
||||||
? error.message
|
</Badge>
|
||||||
: t.channels.unavailable,
|
</ItemTitle>
|
||||||
);
|
<ItemDescription className="line-clamp-none">
|
||||||
});
|
{getProviderDescription(provider, t.channels.descriptions)}
|
||||||
}}
|
{connectionLabel
|
||||||
>
|
? ` ${t.channels.connectedAs(connectionLabel)}`
|
||||||
{isConnecting ? (
|
: ""}
|
||||||
<LoaderCircleIcon className="animate-spin" />
|
{!isConnected && provider.unavailable_reason
|
||||||
) : (
|
? ` ${provider.unavailable_reason}`
|
||||||
<PlugIcon />
|
: ""}
|
||||||
)}
|
</ItemDescription>
|
||||||
{connection?.status === "revoked"
|
</ItemContent>
|
||||||
? t.channels.reconnect
|
<ItemActions className="ml-auto">
|
||||||
: t.channels.connect}
|
{isConnected && connection ? (
|
||||||
</Button>
|
<Button
|
||||||
)}
|
type="button"
|
||||||
</ItemActions>
|
variant="outline"
|
||||||
</Item>
|
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();
|
} = useChannelConnections();
|
||||||
const isLoading = providersLoading || connectionsLoading;
|
const isLoading = providersLoading || connectionsLoading;
|
||||||
const error = providersError ?? connectionsError;
|
const error = providersError ?? connectionsError;
|
||||||
|
const visibleProviders = providers.filter((provider) => provider.enabled);
|
||||||
|
|
||||||
const connectionByProvider = new Map<string, ChannelConnection>();
|
const connectionByProvider = new Map<string, ChannelConnection>();
|
||||||
for (const connection of connections) {
|
for (const connection of connections) {
|
||||||
@@ -249,9 +308,13 @@ export function ChannelsSettingsPage() {
|
|||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground text-sm">
|
||||||
{t.settings.channels.disabled}
|
{t.settings.channels.disabled}
|
||||||
</div>
|
</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">
|
<div className="flex w-full flex-col gap-4">
|
||||||
{providers.map((provider) => (
|
{visibleProviders.map((provider) => (
|
||||||
<ChannelProviderItem
|
<ChannelProviderItem
|
||||||
key={provider.provider}
|
key={provider.provider}
|
||||||
provider={provider}
|
provider={provider}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import type {
|
|||||||
ChannelConnection,
|
ChannelConnection,
|
||||||
ChannelConnectionsResponse,
|
ChannelConnectionsResponse,
|
||||||
ChannelProviderId,
|
ChannelProviderId,
|
||||||
|
ChannelProvider,
|
||||||
ChannelProvidersResponse,
|
ChannelProvidersResponse,
|
||||||
|
ChannelRuntimeConfigValues,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
function channelsUrl(path: string): string {
|
function channelsUrl(path: string): string {
|
||||||
@@ -62,6 +64,27 @@ export async function connectChannelProvider(
|
|||||||
return response.json() as Promise<ChannelConnectResponse>;
|
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(
|
export async function disconnectChannelConnection(
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
configureChannelProvider,
|
||||||
connectChannelProvider,
|
connectChannelProvider,
|
||||||
disconnectChannelConnection,
|
disconnectChannelConnection,
|
||||||
listChannelConnections,
|
listChannelConnections,
|
||||||
listChannelProviders,
|
listChannelProviders,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import type { ChannelProviderId } from "./types";
|
import type { ChannelProviderId, ChannelRuntimeConfigValues } from "./types";
|
||||||
|
|
||||||
export const channelProviderQueryKey = ["channelProviders"] as const;
|
export const channelProviderQueryKey = ["channelProviders"] as const;
|
||||||
export const channelConnectionsQueryKey = ["channelConnections"] 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() {
|
export function useDisconnectChannelConnection() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
export type ChannelProviderId = "telegram" | "slack" | "discord" | string;
|
export type ChannelProviderId = "telegram" | "slack" | "discord" | string;
|
||||||
|
|
||||||
|
export interface ChannelCredentialField {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChannelProvider {
|
export interface ChannelProvider {
|
||||||
provider: ChannelProviderId;
|
provider: ChannelProviderId;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
@@ -9,6 +16,7 @@ export interface ChannelProvider {
|
|||||||
unavailable_reason?: string | null;
|
unavailable_reason?: string | null;
|
||||||
auth_mode: string;
|
auth_mode: string;
|
||||||
connection_status: string;
|
connection_status: string;
|
||||||
|
credential_fields: ChannelCredentialField[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelProvidersResponse {
|
export interface ChannelProvidersResponse {
|
||||||
@@ -40,3 +48,5 @@ export interface ChannelConnectResponse {
|
|||||||
instruction: string;
|
instruction: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ChannelRuntimeConfigValues = Record<string, string>;
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ export const enUS: Translations = {
|
|||||||
unconfigured: "Not configured",
|
unconfigured: "Not configured",
|
||||||
unavailable: "Channel connections are unavailable right now.",
|
unavailable: "Channel connections are unavailable right now.",
|
||||||
unavailableShort: "Unavailable",
|
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: {
|
descriptions: {
|
||||||
telegram: "Telegram direct messages through your DeerFlow bot.",
|
telegram: "Telegram direct messages through your DeerFlow bot.",
|
||||||
slack: "Slack workspace messages and mentions.",
|
slack: "Slack workspace messages and mentions.",
|
||||||
|
|||||||
@@ -200,6 +200,9 @@ export interface Translations {
|
|||||||
unconfigured: string;
|
unconfigured: string;
|
||||||
unavailable: string;
|
unavailable: string;
|
||||||
unavailableShort: string;
|
unavailableShort: string;
|
||||||
|
setupTitle: (name: string) => string;
|
||||||
|
setupDescription: string;
|
||||||
|
saveAndConnect: string;
|
||||||
descriptions: Record<string, string>;
|
descriptions: Record<string, string>;
|
||||||
connectedAs: (name: string) => string;
|
connectedAs: (name: string) => string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -257,6 +257,10 @@ export const zhCN: Translations = {
|
|||||||
unconfigured: "未配置",
|
unconfigured: "未配置",
|
||||||
unavailable: "当前无法使用渠道连接。",
|
unavailable: "当前无法使用渠道连接。",
|
||||||
unavailableShort: "不可用",
|
unavailableShort: "不可用",
|
||||||
|
setupTitle: (name: string) => `连接 ${name}`,
|
||||||
|
setupDescription:
|
||||||
|
"填写当前服务进程需要的配置值。这些内容不会写入 config.yaml。",
|
||||||
|
saveAndConnect: "保存并连接",
|
||||||
descriptions: {
|
descriptions: {
|
||||||
telegram: "通过 DeerFlow Bot 接收 Telegram 私聊消息。",
|
telegram: "通过 DeerFlow Bot 接收 Telegram 私聊消息。",
|
||||||
slack: "接收 Slack 工作区消息和提及。",
|
slack: "接收 Slack 工作区消息和提及。",
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ type MockChannelProvider = {
|
|||||||
auth_mode: string;
|
auth_mode: string;
|
||||||
connection_status: string;
|
connection_status: string;
|
||||||
unavailable_reason?: string | null;
|
unavailable_reason?: string | null;
|
||||||
|
credential_fields?: Array<{
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
required: boolean;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function defaultProviders(): MockChannelProvider[] {
|
function defaultProviders(): MockChannelProvider[] {
|
||||||
@@ -32,6 +38,7 @@ function defaultProviders(): MockChannelProvider[] {
|
|||||||
connectable: true,
|
connectable: true,
|
||||||
auth_mode: authMode,
|
auth_mode: authMode,
|
||||||
connection_status: "not_connected",
|
connection_status: "not_connected",
|
||||||
|
credential_fields: [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,41 +128,129 @@ test.describe("IM channels", () => {
|
|||||||
).toBeVisible();
|
).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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
mockLangGraphAPI(page);
|
mockLangGraphAPI(page);
|
||||||
const unavailableReason =
|
let slackConfigured = false;
|
||||||
"Enable and configure channels.slack with channels.slack.bot_token and channels.slack.app_token.";
|
|
||||||
let connectRequests = 0;
|
let connectRequests = 0;
|
||||||
mockChannelsAPI(
|
let submittedValues: Record<string, string> | undefined;
|
||||||
page,
|
|
||||||
[
|
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",
|
provider: "slack",
|
||||||
display_name: "Slack",
|
display_name: "Slack",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
configured: false,
|
configured: true,
|
||||||
connectable: false,
|
connectable: true,
|
||||||
unavailable_reason: unavailableReason,
|
|
||||||
auth_mode: "binding_code",
|
auth_mode: "binding_code",
|
||||||
connection_status: "not_connected",
|
connection_status: "not_connected",
|
||||||
},
|
credential_fields: [],
|
||||||
],
|
}),
|
||||||
() => {
|
});
|
||||||
connectRequests += 1;
|
});
|
||||||
},
|
|
||||||
);
|
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");
|
await page.goto("/workspace/chats/new");
|
||||||
|
|
||||||
const sidebar = page.locator("[data-sidebar='sidebar']");
|
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" });
|
const connectButton = sidebar.getByRole("button", { name: "Connect" });
|
||||||
await expect(connectButton).toBeEnabled({ timeout: 15_000 });
|
await expect(connectButton).toBeEnabled();
|
||||||
|
|
||||||
await connectButton.click();
|
await connectButton.click();
|
||||||
|
|
||||||
await expect(page.getByText(unavailableReason)).toBeVisible();
|
const setupDialog = page.getByRole("dialog", { name: "Connect Slack" });
|
||||||
expect(connectRequests).toBe(0);
|
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 { fetch as fetcher } from "@/core/api/fetcher";
|
||||||
import {
|
import {
|
||||||
|
configureChannelProvider,
|
||||||
connectChannelProvider,
|
connectChannelProvider,
|
||||||
disconnectChannelConnection,
|
disconnectChannelConnection,
|
||||||
listChannelConnections,
|
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 () => {
|
test("disconnects a channel connection", async () => {
|
||||||
mockedFetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
mockedFetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user