mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 09:55:59 +00:00
Prefill IM channel runtime config
This commit is contained in:
@@ -23,6 +23,7 @@ router = APIRouter(prefix="/api/channels", tags=["channel-connections"])
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_STATE_TTL_SECONDS = 600
|
_STATE_TTL_SECONDS = 600
|
||||||
|
_MASKED_CREDENTIAL_VALUE = "********"
|
||||||
|
|
||||||
|
|
||||||
class ChannelCredentialFieldResponse(BaseModel):
|
class ChannelCredentialFieldResponse(BaseModel):
|
||||||
@@ -42,6 +43,7 @@ class ChannelProviderResponse(BaseModel):
|
|||||||
auth_mode: str
|
auth_mode: str
|
||||||
connection_status: str
|
connection_status: str
|
||||||
credential_fields: list[ChannelCredentialFieldResponse] = Field(default_factory=list)
|
credential_fields: list[ChannelCredentialFieldResponse] = Field(default_factory=list)
|
||||||
|
credential_values: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class ChannelProvidersResponse(BaseModel):
|
class ChannelProvidersResponse(BaseModel):
|
||||||
@@ -304,6 +306,20 @@ def _credential_fields(provider: str) -> list[ChannelCredentialFieldResponse]:
|
|||||||
return [ChannelCredentialFieldResponse(**field) for field in fields]
|
return [ChannelCredentialFieldResponse(**field) for field in fields]
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_values(provider: str, channels_config: dict[str, Any]) -> dict[str, str]:
|
||||||
|
runtime_config = channels_config.get(provider)
|
||||||
|
if not isinstance(runtime_config, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for field in _credential_fields(provider):
|
||||||
|
value = str(runtime_config.get(field.name) or "").strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
values[field.name] = _MASKED_CREDENTIAL_VALUE if field.type == "password" else value
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
def _provider_response(
|
def _provider_response(
|
||||||
config: ChannelConnectionsConfig,
|
config: ChannelConnectionsConfig,
|
||||||
channels_config: dict[str, Any],
|
channels_config: dict[str, Any],
|
||||||
@@ -318,6 +334,11 @@ def _provider_response(
|
|||||||
connection_status = "connected"
|
connection_status = "connected"
|
||||||
else:
|
else:
|
||||||
connection_status = "not_connected"
|
connection_status = "not_connected"
|
||||||
|
credential_values = _credential_values(provider, channels_config)
|
||||||
|
if provider == "telegram" and not credential_values.get("bot_username"):
|
||||||
|
bot_username = str(_provider_config(config, provider).bot_username or "").strip()
|
||||||
|
if bot_username:
|
||||||
|
credential_values["bot_username"] = bot_username
|
||||||
return ChannelProviderResponse(
|
return ChannelProviderResponse(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
display_name=meta["display_name"],
|
display_name=meta["display_name"],
|
||||||
@@ -328,15 +349,26 @@ def _provider_response(
|
|||||||
auth_mode=meta["auth_mode"],
|
auth_mode=meta["auth_mode"],
|
||||||
connection_status=connection_status,
|
connection_status=connection_status,
|
||||||
credential_fields=_credential_fields(provider),
|
credential_fields=_credential_fields(provider),
|
||||||
|
credential_values=credential_values,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _required_runtime_values(provider: str, values: dict[str, str]) -> dict[str, str]:
|
def _required_runtime_values(
|
||||||
|
provider: str,
|
||||||
|
values: dict[str, str],
|
||||||
|
existing_config: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
fields = _credential_fields(provider)
|
fields = _credential_fields(provider)
|
||||||
cleaned: dict[str, str] = {}
|
cleaned: dict[str, str] = {}
|
||||||
missing: list[str] = []
|
missing: list[str] = []
|
||||||
|
existing_config = existing_config or {}
|
||||||
for field in fields:
|
for field in fields:
|
||||||
raw_value = values.get(field.name, "")
|
raw_value = values.get(field.name, "")
|
||||||
|
if field.type == "password" and raw_value == _MASKED_CREDENTIAL_VALUE:
|
||||||
|
existing_value = str(existing_config.get(field.name) or "").strip()
|
||||||
|
if existing_value:
|
||||||
|
cleaned[field.name] = existing_value
|
||||||
|
continue
|
||||||
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value or "").strip()
|
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value or "").strip()
|
||||||
if field.required and not value:
|
if field.required and not value:
|
||||||
missing.append(field.label)
|
missing.append(field.label)
|
||||||
@@ -509,10 +541,10 @@ async def configure_channel_provider_runtime(
|
|||||||
if not provider_config.enabled:
|
if not provider_config.enabled:
|
||||||
raise HTTPException(status_code=400, detail="Channel provider is not 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)
|
channels_config = _get_channels_config(request)
|
||||||
existing = channels_config.get(provider)
|
existing = channels_config.get(provider)
|
||||||
runtime_config = dict(existing) if isinstance(existing, dict) else {}
|
runtime_config = dict(existing) if isinstance(existing, dict) else {}
|
||||||
|
values = _required_runtime_values(provider, body.values, runtime_config)
|
||||||
runtime_config["enabled"] = True
|
runtime_config["enabled"] = True
|
||||||
|
|
||||||
for key in _RUNTIME_REQUIREMENTS[provider]:
|
for key in _RUNTIME_REQUIREMENTS[provider]:
|
||||||
|
|||||||
@@ -132,20 +132,42 @@ def test_get_providers_uses_existing_channels_config(tmp_path):
|
|||||||
assert set(by_provider) == {"telegram", "slack", "discord", "feishu", "dingtalk", "wechat", "wecom"}
|
assert set(by_provider) == {"telegram", "slack", "discord", "feishu", "dingtalk", "wechat", "wecom"}
|
||||||
assert by_provider["telegram"]["configured"] is True
|
assert by_provider["telegram"]["configured"] is True
|
||||||
assert by_provider["telegram"]["auth_mode"] == "deep_link"
|
assert by_provider["telegram"]["auth_mode"] == "deep_link"
|
||||||
|
assert by_provider["telegram"]["credential_values"] == {
|
||||||
|
"bot_token": "********",
|
||||||
|
"bot_username": "deerflow_bot",
|
||||||
|
}
|
||||||
assert by_provider["slack"]["configured"] is True
|
assert by_provider["slack"]["configured"] is True
|
||||||
assert by_provider["slack"]["auth_mode"] == "binding_code"
|
assert by_provider["slack"]["auth_mode"] == "binding_code"
|
||||||
assert by_provider["slack"]["connection_status"] == "connected"
|
assert by_provider["slack"]["connection_status"] == "connected"
|
||||||
|
assert by_provider["slack"]["credential_values"] == {
|
||||||
|
"bot_token": "********",
|
||||||
|
"app_token": "********",
|
||||||
|
}
|
||||||
assert by_provider["discord"]["configured"] is True
|
assert by_provider["discord"]["configured"] is True
|
||||||
assert by_provider["discord"]["auth_mode"] == "binding_code"
|
assert by_provider["discord"]["auth_mode"] == "binding_code"
|
||||||
|
assert by_provider["discord"]["credential_values"] == {"bot_token": "********"}
|
||||||
assert by_provider["feishu"]["configured"] is True
|
assert by_provider["feishu"]["configured"] is True
|
||||||
assert by_provider["feishu"]["auth_mode"] == "binding_code"
|
assert by_provider["feishu"]["auth_mode"] == "binding_code"
|
||||||
assert by_provider["feishu"]["connection_status"] == "connected"
|
assert by_provider["feishu"]["connection_status"] == "connected"
|
||||||
|
assert by_provider["feishu"]["credential_values"] == {
|
||||||
|
"app_id": "feishu-app",
|
||||||
|
"app_secret": "********",
|
||||||
|
}
|
||||||
assert by_provider["dingtalk"]["configured"] is True
|
assert by_provider["dingtalk"]["configured"] is True
|
||||||
assert by_provider["dingtalk"]["auth_mode"] == "binding_code"
|
assert by_provider["dingtalk"]["auth_mode"] == "binding_code"
|
||||||
|
assert by_provider["dingtalk"]["credential_values"] == {
|
||||||
|
"client_id": "dingtalk-client",
|
||||||
|
"client_secret": "********",
|
||||||
|
}
|
||||||
assert by_provider["wechat"]["configured"] is True
|
assert by_provider["wechat"]["configured"] is True
|
||||||
assert by_provider["wechat"]["auth_mode"] == "binding_code"
|
assert by_provider["wechat"]["auth_mode"] == "binding_code"
|
||||||
|
assert by_provider["wechat"]["credential_values"] == {"bot_token": "********"}
|
||||||
assert by_provider["wecom"]["configured"] is True
|
assert by_provider["wecom"]["configured"] is True
|
||||||
assert by_provider["wecom"]["auth_mode"] == "binding_code"
|
assert by_provider["wecom"]["auth_mode"] == "binding_code"
|
||||||
|
assert by_provider["wecom"]["credential_values"] == {
|
||||||
|
"bot_id": "wecom-bot",
|
||||||
|
"bot_secret": "********",
|
||||||
|
}
|
||||||
|
|
||||||
anyio.run(repo.close)
|
anyio.run(repo.close)
|
||||||
|
|
||||||
@@ -459,6 +481,62 @@ def test_configure_provider_runtime_credentials_survive_local_restart(tmp_path):
|
|||||||
anyio.run(repo.close)
|
anyio.run(repo.close)
|
||||||
|
|
||||||
|
|
||||||
|
def test_configure_provider_runtime_credentials_preserves_masked_secrets(tmp_path):
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
repo = anyio.run(_make_repo, tmp_path)
|
||||||
|
config = ChannelConnectionsConfig.model_validate(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"feishu": {"enabled": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
runtime_config_store = ChannelRuntimeConfigStore(tmp_path / "channels" / "runtime-config.json")
|
||||||
|
app = _make_app(
|
||||||
|
config,
|
||||||
|
repo,
|
||||||
|
{
|
||||||
|
"feishu": {
|
||||||
|
"enabled": True,
|
||||||
|
"app_id": "old-app-id",
|
||||||
|
"app_secret": "old-secret",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
runtime_config_store=runtime_config_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
configure_response = client.post(
|
||||||
|
"/api/channels/feishu/runtime-config",
|
||||||
|
json={
|
||||||
|
"values": {
|
||||||
|
"app_id": "new-app-id",
|
||||||
|
"app_secret": "********",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
providers_response = client.get("/api/channels/providers")
|
||||||
|
|
||||||
|
assert configure_response.status_code == 200
|
||||||
|
assert app.state.channels_config["feishu"] == {
|
||||||
|
"enabled": True,
|
||||||
|
"app_id": "new-app-id",
|
||||||
|
"app_secret": "old-secret",
|
||||||
|
}
|
||||||
|
assert runtime_config_store.get_provider_config("feishu") == {
|
||||||
|
"enabled": True,
|
||||||
|
"app_id": "new-app-id",
|
||||||
|
"app_secret": "old-secret",
|
||||||
|
}
|
||||||
|
by_provider = {item["provider"]: item for item in providers_response.json()["providers"]}
|
||||||
|
assert by_provider["feishu"]["credential_values"] == {
|
||||||
|
"app_id": "new-app-id",
|
||||||
|
"app_secret": "********",
|
||||||
|
}
|
||||||
|
|
||||||
|
anyio.run(repo.close)
|
||||||
|
|
||||||
|
|
||||||
def test_disconnect_provider_runtime_config_clears_connected_state(tmp_path):
|
def test_disconnect_provider_runtime_config_clears_connected_state(tmp_path):
|
||||||
import anyio
|
import anyio
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ export function ChannelRuntimeConfigDialog({
|
|||||||
() => provider?.credential_fields ?? [],
|
() => provider?.credential_fields ?? [],
|
||||||
[provider?.credential_fields],
|
[provider?.credential_fields],
|
||||||
);
|
);
|
||||||
|
const credentialValues = useMemo<ChannelRuntimeConfigValues>(
|
||||||
|
() => provider?.credential_values ?? {},
|
||||||
|
[provider?.credential_values],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !provider) {
|
if (!open || !provider) {
|
||||||
@@ -64,11 +68,14 @@ export function ChannelRuntimeConfigDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setValues(
|
setValues(
|
||||||
Object.fromEntries(fields.map((field) => [field.name, ""])) as
|
Object.fromEntries(
|
||||||
| ChannelRuntimeConfigValues
|
fields.map((field) => [
|
||||||
| {},
|
field.name,
|
||||||
|
credentialValues[field.name] ?? "",
|
||||||
|
]),
|
||||||
|
) as ChannelRuntimeConfigValues,
|
||||||
);
|
);
|
||||||
}, [fields, open, provider]);
|
}, [credentialValues, fields, open, provider]);
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface ChannelCredentialField {
|
|||||||
required: boolean;
|
required: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ChannelRuntimeConfigValues = Record<string, string>;
|
||||||
|
|
||||||
export interface ChannelProvider {
|
export interface ChannelProvider {
|
||||||
provider: ChannelProviderId;
|
provider: ChannelProviderId;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
@@ -17,6 +19,7 @@ export interface ChannelProvider {
|
|||||||
auth_mode: string;
|
auth_mode: string;
|
||||||
connection_status: string;
|
connection_status: string;
|
||||||
credential_fields: ChannelCredentialField[];
|
credential_fields: ChannelCredentialField[];
|
||||||
|
credential_values?: ChannelRuntimeConfigValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelProvidersResponse {
|
export interface ChannelProvidersResponse {
|
||||||
@@ -48,5 +51,3 @@ export interface ChannelConnectResponse {
|
|||||||
instruction: string;
|
instruction: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChannelRuntimeConfigValues = Record<string, string>;
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type MockChannelProvider = {
|
|||||||
type: string;
|
type: string;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
credential_values?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function defaultProviders(): MockChannelProvider[] {
|
function defaultProviders(): MockChannelProvider[] {
|
||||||
@@ -166,6 +167,12 @@ test.describe("IM channels", () => {
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
credential_values: slackConfigured
|
||||||
|
? {
|
||||||
|
bot_token: "********",
|
||||||
|
app_token: "********",
|
||||||
|
}
|
||||||
|
: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
@@ -208,6 +215,7 @@ test.describe("IM channels", () => {
|
|||||||
auth_mode: "binding_code",
|
auth_mode: "binding_code",
|
||||||
connection_status: "connected",
|
connection_status: "connected",
|
||||||
credential_fields: [],
|
credential_fields: [],
|
||||||
|
credential_values: {},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -244,9 +252,59 @@ test.describe("IM channels", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("dialog", { name: "Modify Slack" }),
|
page.getByRole("dialog", { name: "Modify Slack" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Bot token")).toHaveValue("********");
|
||||||
|
await expect(page.getByLabel("App token")).toHaveValue("********");
|
||||||
expect(submittedValues).toEqual({
|
expect(submittedValues).toEqual({
|
||||||
bot_token: "xoxb-ui",
|
bot_token: "xoxb-ui",
|
||||||
app_token: "xapp-ui",
|
app_token: "xapp-ui",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("runtime setup dialog prefills editable credential values", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
mockLangGraphAPI(page);
|
||||||
|
mockChannelsAPI(page, [
|
||||||
|
{
|
||||||
|
provider: "feishu",
|
||||||
|
display_name: "Feishu",
|
||||||
|
enabled: true,
|
||||||
|
configured: true,
|
||||||
|
connectable: true,
|
||||||
|
auth_mode: "binding_code",
|
||||||
|
connection_status: "connected",
|
||||||
|
credential_fields: [
|
||||||
|
{
|
||||||
|
name: "app_id",
|
||||||
|
label: "App ID",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "app_secret",
|
||||||
|
label: "App secret",
|
||||||
|
type: "password",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
credential_values: {
|
||||||
|
app_id: "cli_feishu_app",
|
||||||
|
app_secret: "********",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/workspace/chats/new");
|
||||||
|
|
||||||
|
const sidebar = page.locator("[data-sidebar='sidebar']");
|
||||||
|
await expect(sidebar.getByText("Feishu")).toBeVisible({ timeout: 15_000 });
|
||||||
|
await sidebar.getByRole("button", { name: "Connected" }).click();
|
||||||
|
|
||||||
|
const setupDialog = page.getByRole("dialog", { name: "Modify Feishu" });
|
||||||
|
await expect(setupDialog).toBeVisible();
|
||||||
|
await expect(setupDialog.getByLabel("App ID")).toHaveValue(
|
||||||
|
"cli_feishu_app",
|
||||||
|
);
|
||||||
|
await expect(setupDialog.getByLabel("App secret")).toHaveValue("********");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ describe("channels api", () => {
|
|||||||
configured: true,
|
configured: true,
|
||||||
auth_mode: "deep_link",
|
auth_mode: "deep_link",
|
||||||
connection_status: "not_connected",
|
connection_status: "not_connected",
|
||||||
|
credential_values: {
|
||||||
|
bot_token: "********",
|
||||||
|
bot_username: "deerflow_bot",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -52,7 +56,16 @@ describe("channels api", () => {
|
|||||||
|
|
||||||
await expect(listChannelProviders()).resolves.toMatchObject({
|
await expect(listChannelProviders()).resolves.toMatchObject({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
providers: [{ provider: "telegram", display_name: "Telegram" }],
|
providers: [
|
||||||
|
{
|
||||||
|
provider: "telegram",
|
||||||
|
display_name: "Telegram",
|
||||||
|
credential_values: {
|
||||||
|
bot_token: "********",
|
||||||
|
bot_username: "deerflow_bot",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
expect(mockedFetch).toHaveBeenCalledWith("/backend/api/channels/providers");
|
expect(mockedFetch).toHaveBeenCalledWith("/backend/api/channels/providers");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user