Skip to content

Commit 4e046db

Browse files
committed
feat(studio): add reusable Feishu bot setup card
1 parent d1681f8 commit 4e046db

80 files changed

Lines changed: 1797 additions & 633 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,13 @@ Changing the Feishu channel on the deployment page regenerates the project so
270270
`app.py`, the `extensions` dependency, and the runtime environment variables
271271
stay aligned before deployment.
272272

273+
The deployment card supports automatic and manual credential setup without
274+
changing its footprint in the publish form. Automatic setup uses the reusable
275+
`frontend.server.feishu_bot_setup` provider interface and keeps App Secret
276+
values in the mounted Studio process. Set
277+
`VEADK_STUDIO_FEISHU_SETUP_MOCK=1` only for local UI testing; when no production
278+
provider is configured, Studio keeps manual credential entry available.
279+
273280
Insight Sandbox requires server-side `VOLCENGINE_ACCESS_KEY`,
274281
`VOLCENGINE_SECRET_KEY`, `MODEL_AGENT_API_KEY`, and `MODEL_AGENT_NAME` values.
275282
These credentials and the AgentKit session endpoint remain on the Studio server
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Reusable Feishu bot setup workflow for Studio deployment surfaces."""
16+
17+
from .routes import mount_feishu_bot_setup_routes
18+
from .service import FeishuBotSetupService, create_feishu_bot_setup_service
19+
20+
__all__ = [
21+
"FeishuBotSetupService",
22+
"create_feishu_bot_setup_service",
23+
"mount_feishu_bot_setup_routes",
24+
]
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""FastAPI transport for the reusable Feishu bot setup service."""
16+
17+
from __future__ import annotations
18+
19+
from collections.abc import Callable
20+
from typing import Any
21+
22+
from fastapi import HTTPException, Request
23+
from fastapi.concurrency import run_in_threadpool
24+
from pydantic import BaseModel, Field
25+
26+
from .service import (
27+
FeishuBotSetupNotFound,
28+
FeishuBotSetupService,
29+
FeishuBotSetupUnavailable,
30+
)
31+
32+
33+
class CreateFeishuBotSetupBody(BaseModel):
34+
agent_name: str = Field(alias="agentName", min_length=1, max_length=128)
35+
36+
37+
def mount_feishu_bot_setup_routes(
38+
app: Any,
39+
service: FeishuBotSetupService,
40+
owner_resolver: Callable[[Request], str],
41+
) -> None:
42+
async def invoke(call: Callable[[], dict[str, object]]) -> dict[str, object]:
43+
try:
44+
return await run_in_threadpool(call)
45+
except FeishuBotSetupUnavailable as error:
46+
raise HTTPException(status_code=503, detail=str(error)) from error
47+
except FeishuBotSetupNotFound as error:
48+
raise HTTPException(status_code=404, detail=str(error)) from error
49+
50+
@app.post("/web/feishu-bot-setup/sessions")
51+
async def create_session(
52+
body: CreateFeishuBotSetupBody, request: Request
53+
) -> dict[str, object]:
54+
owner = owner_resolver(request)
55+
return await invoke(
56+
lambda: service.create(owner=owner, agent_name=body.agent_name)
57+
)
58+
59+
@app.get("/web/feishu-bot-setup/sessions/{session_id}")
60+
async def get_session(session_id: str, request: Request) -> dict[str, object]:
61+
owner = owner_resolver(request)
62+
return await invoke(lambda: service.get(owner=owner, session_id=session_id))
63+
64+
@app.delete("/web/feishu-bot-setup/sessions/{session_id}")
65+
async def cancel_session(session_id: str, request: Request) -> dict[str, object]:
66+
owner = owner_resolver(request)
67+
return await invoke(lambda: service.cancel(owner=owner, session_id=session_id))
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Provider-independent lifecycle for creating a Feishu bot by QR authorization."""
16+
17+
from __future__ import annotations
18+
19+
import base64
20+
import os
21+
import threading
22+
import time
23+
from dataclasses import dataclass
24+
from datetime import datetime, timezone
25+
from typing import Protocol
26+
from uuid import uuid4
27+
28+
29+
class FeishuBotSetupError(RuntimeError):
30+
"""Base error returned by the Feishu setup workflow."""
31+
32+
33+
class FeishuBotSetupUnavailable(FeishuBotSetupError):
34+
"""Raised when no production QR provider has been configured."""
35+
36+
37+
class FeishuBotSetupNotFound(FeishuBotSetupError):
38+
"""Raised when a setup session does not exist for the current user."""
39+
40+
41+
@dataclass(frozen=True)
42+
class ProviderSession:
43+
provider_id: str
44+
qr_code_data_url: str
45+
expires_at: float
46+
47+
48+
@dataclass(frozen=True)
49+
class ProviderResult:
50+
status: str
51+
app_id: str = ""
52+
app_secret: str = ""
53+
message: str = ""
54+
55+
56+
class FeishuBotSetupProvider(Protocol):
57+
def create(self, *, agent_name: str) -> ProviderSession: ...
58+
59+
def poll(self, provider_id: str) -> ProviderResult: ...
60+
61+
def cancel(self, provider_id: str) -> None: ...
62+
63+
64+
@dataclass
65+
class _OwnedSession:
66+
owner: str
67+
provider_id: str
68+
qr_code_data_url: str
69+
expires_at: float
70+
71+
72+
class FeishuBotSetupService:
73+
"""Owns user-scoped sessions while delegating vendor calls to a provider."""
74+
75+
def __init__(self, provider: FeishuBotSetupProvider | None) -> None:
76+
self._provider = provider
77+
self._sessions: dict[str, _OwnedSession] = {}
78+
self._lock = threading.Lock()
79+
80+
def create(self, *, owner: str, agent_name: str) -> dict[str, object]:
81+
if self._provider is None:
82+
raise FeishuBotSetupUnavailable(
83+
"当前 Studio 尚未配置飞书自动建机器人服务,请使用手动配置。"
84+
)
85+
provider_session = self._provider.create(agent_name=agent_name)
86+
session_id = f"fsbot_{uuid4().hex}"
87+
with self._lock:
88+
self._sessions[session_id] = _OwnedSession(
89+
owner=owner,
90+
provider_id=provider_session.provider_id,
91+
qr_code_data_url=provider_session.qr_code_data_url,
92+
expires_at=provider_session.expires_at,
93+
)
94+
return self._payload(
95+
session_id, self._sessions[session_id], ProviderResult("waiting")
96+
)
97+
98+
def get(self, *, owner: str, session_id: str) -> dict[str, object]:
99+
session = self._owned(owner, session_id)
100+
if time.time() >= session.expires_at:
101+
return self._payload(
102+
session_id, session, ProviderResult("expired", message="二维码已失效。")
103+
)
104+
assert self._provider is not None
105+
return self._payload(
106+
session_id, session, self._provider.poll(session.provider_id)
107+
)
108+
109+
def cancel(self, *, owner: str, session_id: str) -> dict[str, object]:
110+
session = self._owned(owner, session_id)
111+
assert self._provider is not None
112+
self._provider.cancel(session.provider_id)
113+
with self._lock:
114+
self._sessions.pop(session_id, None)
115+
return self._payload(session_id, session, ProviderResult("cancelled"))
116+
117+
def _owned(self, owner: str, session_id: str) -> _OwnedSession:
118+
with self._lock:
119+
session = self._sessions.get(session_id)
120+
if session is None or session.owner != owner:
121+
raise FeishuBotSetupNotFound("飞书自动配置会话不存在或已结束。")
122+
return session
123+
124+
@staticmethod
125+
def _payload(
126+
session_id: str, session: _OwnedSession, result: ProviderResult
127+
) -> dict[str, object]:
128+
payload: dict[str, object] = {
129+
"id": session_id,
130+
"status": result.status,
131+
"expiresAt": datetime.fromtimestamp(
132+
session.expires_at, tz=timezone.utc
133+
).isoformat(),
134+
"message": result.message,
135+
}
136+
if result.status == "waiting":
137+
payload["qrCodeDataUrl"] = session.qr_code_data_url
138+
if result.status == "success":
139+
payload["credentials"] = {
140+
"appId": result.app_id,
141+
"appSecret": result.app_secret,
142+
}
143+
return payload
144+
145+
146+
class LocalPreviewProvider:
147+
"""Local-only adapter used to exercise the Studio UI without a vendor mutation."""
148+
149+
def __init__(self, *, completion_delay: float = 4.0) -> None:
150+
self._completion_delay = completion_delay
151+
self._created_at: dict[str, float] = {}
152+
153+
def create(self, *, agent_name: str) -> ProviderSession:
154+
provider_id = uuid4().hex
155+
self._created_at[provider_id] = time.time()
156+
return ProviderSession(
157+
provider_id=provider_id,
158+
qr_code_data_url=_preview_qr_data_url(provider_id),
159+
expires_at=time.time() + 600,
160+
)
161+
162+
def poll(self, provider_id: str) -> ProviderResult:
163+
created_at = self._created_at.get(provider_id)
164+
if created_at is None:
165+
return ProviderResult("failed", message="自动配置会话已结束。")
166+
if time.time() - created_at < self._completion_delay:
167+
return ProviderResult("waiting")
168+
suffix = provider_id[:12]
169+
return ProviderResult(
170+
"success",
171+
app_id=f"cli_preview_{suffix}",
172+
app_secret=f"preview_secret_{provider_id}",
173+
)
174+
175+
def cancel(self, provider_id: str) -> None:
176+
self._created_at.pop(provider_id, None)
177+
178+
179+
def create_feishu_bot_setup_service() -> FeishuBotSetupService:
180+
preview = os.getenv("VEADK_STUDIO_FEISHU_SETUP_MOCK", "").strip().lower()
181+
provider = LocalPreviewProvider() if preview in {"1", "true", "yes"} else None
182+
return FeishuBotSetupService(provider)
183+
184+
185+
def _preview_qr_data_url(seed: str) -> str:
186+
size = 21
187+
cells: list[str] = []
188+
for row in range(size):
189+
for column in range(size):
190+
finder = (
191+
_finder_cell(row, column, 0, 0)
192+
or _finder_cell(row, column, 0, size - 7)
193+
or _finder_cell(row, column, size - 7, 0)
194+
)
195+
reserved = (
196+
(row <= 7 and column <= 7)
197+
or (row <= 7 and column >= size - 8)
198+
or (row >= size - 8 and column <= 7)
199+
)
200+
marker = ord(seed[(row * size + column) % len(seed)])
201+
data = (
202+
not reserved and (row * 11 + column * 7 + row * column + marker) % 5 < 2
203+
)
204+
if finder or data:
205+
cells.append(f'<rect x="{column}" y="{row}" width="1" height="1"/>')
206+
svg = (
207+
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 {size + 4} {size + 4}">'
208+
'<rect x="-2" y="-2" width="25" height="25" fill="white"/>'
209+
f'<g fill="#111">{"".join(cells)}</g></svg>'
210+
)
211+
encoded = base64.b64encode(svg.encode()).decode()
212+
return f"data:image/svg+xml;base64,{encoded}"
213+
214+
215+
def _finder_cell(row: int, column: int, top: int, left: int) -> bool:
216+
local_row = row - top
217+
local_column = column - left
218+
if not (0 <= local_row <= 6 and 0 <= local_column <= 6):
219+
return False
220+
return (
221+
local_row in {0, 6}
222+
or local_column in {0, 6}
223+
or (2 <= local_row <= 4 and 2 <= local_column <= 4)
224+
)

frontend/src/adk/feishuBotSetup.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { httpErrorMessage, studioFetch } from "./client";
2+
3+
export type FeishuBotSetupStatus =
4+
| "waiting"
5+
| "success"
6+
| "failed"
7+
| "expired"
8+
| "cancelled";
9+
10+
export interface FeishuBotSetupSession {
11+
id: string;
12+
status: FeishuBotSetupStatus;
13+
expiresAt?: string;
14+
qrCodeDataUrl?: string;
15+
message?: string;
16+
credentials?: { appId: string; appSecret: string };
17+
}
18+
19+
async function request(
20+
path: string,
21+
init: RequestInit,
22+
): Promise<FeishuBotSetupSession> {
23+
const response = await studioFetch(path, {
24+
...init,
25+
headers: { accept: "application/json", ...init.headers },
26+
});
27+
if (!response.ok) {
28+
throw new Error(await httpErrorMessage(response, "飞书机器人自动配置失败"));
29+
}
30+
return response.json() as Promise<FeishuBotSetupSession>;
31+
}
32+
33+
export function createFeishuBotSetup(input: { agentName: string }) {
34+
return request("/web/feishu-bot-setup/sessions", {
35+
method: "POST",
36+
headers: { "content-type": "application/json" },
37+
body: JSON.stringify(input),
38+
});
39+
}
40+
41+
export function getFeishuBotSetup(sessionId: string) {
42+
return request(
43+
`/web/feishu-bot-setup/sessions/${encodeURIComponent(sessionId)}`,
44+
{ method: "GET" },
45+
);
46+
}
47+
48+
export function cancelFeishuBotSetup(sessionId: string) {
49+
return request(
50+
`/web/feishu-bot-setup/sessions/${encodeURIComponent(sessionId)}`,
51+
{ method: "DELETE" },
52+
);
53+
}

0 commit comments

Comments
 (0)