Prefill IM channel runtime config

This commit is contained in:
taohe
2026-06-11 17:02:02 +08:00
parent 4f56437030
commit dae7c7870e
6 changed files with 198 additions and 9 deletions
@@ -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;
+3 -2
View File
@@ -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>;
+58
View File
@@ -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("********");
});
}); });
+14 -1
View File
@@ -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");
}); });