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__)
|
||||
|
||||
_STATE_TTL_SECONDS = 600
|
||||
_MASKED_CREDENTIAL_VALUE = "********"
|
||||
|
||||
|
||||
class ChannelCredentialFieldResponse(BaseModel):
|
||||
@@ -42,6 +43,7 @@ class ChannelProviderResponse(BaseModel):
|
||||
auth_mode: str
|
||||
connection_status: str
|
||||
credential_fields: list[ChannelCredentialFieldResponse] = Field(default_factory=list)
|
||||
credential_values: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ChannelProvidersResponse(BaseModel):
|
||||
@@ -304,6 +306,20 @@ def _credential_fields(provider: str) -> list[ChannelCredentialFieldResponse]:
|
||||
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(
|
||||
config: ChannelConnectionsConfig,
|
||||
channels_config: dict[str, Any],
|
||||
@@ -318,6 +334,11 @@ def _provider_response(
|
||||
connection_status = "connected"
|
||||
else:
|
||||
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(
|
||||
provider=provider,
|
||||
display_name=meta["display_name"],
|
||||
@@ -328,15 +349,26 @@ def _provider_response(
|
||||
auth_mode=meta["auth_mode"],
|
||||
connection_status=connection_status,
|
||||
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)
|
||||
cleaned: dict[str, str] = {}
|
||||
missing: list[str] = []
|
||||
existing_config = existing_config or {}
|
||||
for field in fields:
|
||||
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()
|
||||
if field.required and not value:
|
||||
missing.append(field.label)
|
||||
@@ -509,10 +541,10 @@ async def configure_channel_provider_runtime(
|
||||
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 {}
|
||||
values = _required_runtime_values(provider, body.values, runtime_config)
|
||||
runtime_config["enabled"] = True
|
||||
|
||||
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 by_provider["telegram"]["configured"] is True
|
||||
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"]["auth_mode"] == "binding_code"
|
||||
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"]["auth_mode"] == "binding_code"
|
||||
assert by_provider["discord"]["credential_values"] == {"bot_token": "********"}
|
||||
assert by_provider["feishu"]["configured"] is True
|
||||
assert by_provider["feishu"]["auth_mode"] == "binding_code"
|
||||
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"]["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"]["auth_mode"] == "binding_code"
|
||||
assert by_provider["wechat"]["credential_values"] == {"bot_token": "********"}
|
||||
assert by_provider["wecom"]["configured"] is True
|
||||
assert by_provider["wecom"]["auth_mode"] == "binding_code"
|
||||
assert by_provider["wecom"]["credential_values"] == {
|
||||
"bot_id": "wecom-bot",
|
||||
"bot_secret": "********",
|
||||
}
|
||||
|
||||
anyio.run(repo.close)
|
||||
|
||||
@@ -459,6 +481,62 @@ def test_configure_provider_runtime_credentials_survive_local_restart(tmp_path):
|
||||
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):
|
||||
import anyio
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ export function ChannelRuntimeConfigDialog({
|
||||
() => provider?.credential_fields ?? [],
|
||||
[provider?.credential_fields],
|
||||
);
|
||||
const credentialValues = useMemo<ChannelRuntimeConfigValues>(
|
||||
() => provider?.credential_values ?? {},
|
||||
[provider?.credential_values],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !provider) {
|
||||
@@ -64,11 +68,14 @@ export function ChannelRuntimeConfigDialog({
|
||||
return;
|
||||
}
|
||||
setValues(
|
||||
Object.fromEntries(fields.map((field) => [field.name, ""])) as
|
||||
| ChannelRuntimeConfigValues
|
||||
| {},
|
||||
Object.fromEntries(
|
||||
fields.map((field) => [
|
||||
field.name,
|
||||
credentialValues[field.name] ?? "",
|
||||
]),
|
||||
) as ChannelRuntimeConfigValues,
|
||||
);
|
||||
}, [fields, open, provider]);
|
||||
}, [credentialValues, fields, open, provider]);
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface ChannelCredentialField {
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export type ChannelRuntimeConfigValues = Record<string, string>;
|
||||
|
||||
export interface ChannelProvider {
|
||||
provider: ChannelProviderId;
|
||||
display_name: string;
|
||||
@@ -17,6 +19,7 @@ export interface ChannelProvider {
|
||||
auth_mode: string;
|
||||
connection_status: string;
|
||||
credential_fields: ChannelCredentialField[];
|
||||
credential_values?: ChannelRuntimeConfigValues;
|
||||
}
|
||||
|
||||
export interface ChannelProvidersResponse {
|
||||
@@ -48,5 +51,3 @@ export interface ChannelConnectResponse {
|
||||
instruction: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export type ChannelRuntimeConfigValues = Record<string, string>;
|
||||
|
||||
@@ -27,6 +27,7 @@ type MockChannelProvider = {
|
||||
type: string;
|
||||
required: boolean;
|
||||
}>;
|
||||
credential_values?: Record<string, string>;
|
||||
};
|
||||
|
||||
function defaultProviders(): MockChannelProvider[] {
|
||||
@@ -166,6 +167,12 @@ test.describe("IM channels", () => {
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credential_values: slackConfigured
|
||||
? {
|
||||
bot_token: "********",
|
||||
app_token: "********",
|
||||
}
|
||||
: {},
|
||||
},
|
||||
{
|
||||
provider: "discord",
|
||||
@@ -208,6 +215,7 @@ test.describe("IM channels", () => {
|
||||
auth_mode: "binding_code",
|
||||
connection_status: "connected",
|
||||
credential_fields: [],
|
||||
credential_values: {},
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -244,9 +252,59 @@ test.describe("IM channels", () => {
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Modify Slack" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel("Bot token")).toHaveValue("********");
|
||||
await expect(page.getByLabel("App token")).toHaveValue("********");
|
||||
expect(submittedValues).toEqual({
|
||||
bot_token: "xoxb-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,
|
||||
auth_mode: "deep_link",
|
||||
connection_status: "not_connected",
|
||||
credential_values: {
|
||||
bot_token: "********",
|
||||
bot_username: "deerflow_bot",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -52,7 +56,16 @@ describe("channels api", () => {
|
||||
|
||||
await expect(listChannelProviders()).resolves.toMatchObject({
|
||||
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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user