feat(agents): Integrations tab with Lark Bot bind entry + Lark Bot docs (MUL-2988) (#3751)

* feat(agents): add Integrations tab with Lark Bot bind entry

The agent detail page now has an Integrations tab alongside the inspector's
Integrations section. It reuses the shared LarkAgentBindButton so the
scan-to-bind / already-connected logic stays single-sourced, and adds the
not-configured / coming-soon / members-only states the sidebar has no room
for. The tab only appears once the deployment has Lark configured.

MUL-2988

Co-authored-by: multica-agent <github@multica.ai>

* docs: add Lark Bot integration guide

Covers binding a Multica agent to a Lark Bot (scan-to-install), using it
(DM / @-mention / /issue), management, permissions, and self-host setup.
Added in all four locales under the Integrations nav section.

MUL-2988

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): show bound Lark state when install_supported is false

install_supported governs only whether NEW scan-installs can complete;
already-installed bots stay manageable when the transport is unwired
(server/internal/handler/lark.go). LarkAgentBindButton checked the
install_supported gate before the existing-installation check, so a bound
agent on such a deployment showed 'coming soon' / nothing instead of
'Connected + Manage in Lark'. Reorder the guard (existing active install →
badge, before the install_supported gate) and mirror it in the new
Integrations tab. Adds regression tests for both surfaces.

MUL-2988

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-06-04 15:48:22 +08:00
committed by GitHub
parent 569b43136c
commit d98fc85088
18 changed files with 830 additions and 25 deletions

View File

@@ -0,0 +1,95 @@
---
title: Lark Bot 連携
description: Multica エージェントを Lark飞书Bot に紐づければ、Lark の DM やグループからそのまま対話できます——@ でメンションして自然に話しかけたり、/issue と入力して Lark を離れずに Multica イシューを起票したりできます。
---
import { Callout } from "fumadocs-ui/components/callout";
任意の[エージェント](/agents)を Lark飞书Bot に紐づければ、チームは Lark の中から直接それを使えます——Bot に DM したり、グループで @ メンションしたり、`/issue` と入力してアプリを開かずに [Multica イシュー](/issues)を起票したりできます。エージェントの返信は、作業の進行に合わせて更新されるライブカードとしてチャットに戻ってきます。
各 Bot は 1 つの Multica エージェントと **1 対 1** で紐づきます。2 つ目のエージェントを紐づけると 2 つ目の Bot が作られます。1 つのエージェントが 2 つの Bot を持つことはありません。
## この連携でできること
| 場所 | 動作 |
|---|---|
| **エージェント → 連携** | エージェント詳細ページには **連携Integrations** タブがあります左サイドバーにも対応する区画があります。owner と admin はそこに **Lark に紐づける** が表示され、紐づけると **Lark に接続済み** バッジと **Lark で管理** リンクに切り替わります。 |
| **Bot に DM** | ワークスペースメンバーが Lark の中で Bot に直接メッセージを送ります。各会話はそのエージェントとの Multica [chat](/chat) セッションになり、エージェントはスレッド内で返信します。 |
| **グループで @ メンション** | Bot を Lark グループに追加して @ メンションします。読み取られるのはメンションしたメッセージだけで、Bot はグループ全体を聞いているわけではありません。 |
| **`/issue` コマンド** | `/issue <タイトル>`(本文を続けてもよい)と入力すると、ワークスペースに新しい Multica イシューが作られ、あなたの名義になります。 |
| **ライブ返信カード** | Bot はインタラクティブなカードを投稿し、エージェントの実行に合わせて更新し続けます——進捗、最終的な回答、あるいはエラーが反映されます。 |
## エージェントを紐づけるowner / admin
紐づけはスキャンしてインストールするフローです——アプリのシークレットをコピーする必要も、開発者コンソールでの操作も不要です。
1. **Agents → あなたのエージェント** からそのエージェントを開きます。
2. **連携Integrations** タブ(または左サイドバーの **連携** 区画)を開き、**Lark に紐づける** をクリックします。
3. QR コードが表示されます。スマートフォンで **Lark → スキャン** を開き、新しい PersonalAgent Bot を認可します。
4. スキャンが完了するとダイアログが閉じ、エージェントに **Lark に接続済み** と表示されます。あなた自身の Lark アイデンティティは自動であなたの Multica アカウントに紐づくので、すぐに Bot と対話を始められます。
<Callout type="info">
QR は使い切りで、短い時間が過ぎると失効します。認可する前に失効してしまったら、**もう一度スキャン** をクリックして新しいコードを取得してください。
</Callout>
エージェントが接続されると、**Lark に紐づける** ボタンは **Lark で管理** リンクに置き換わります。スコープの調整、名前の変更、追加の権限の申請が必要なときは、これを使って Lark 内の Bot のアプリページを開いてください——再スキャンは意図的に無効化されており、既存の Bot を取り残してしまわないようにしています。
## Bot を使う(メンバー)
### 最初のメッセージLark アイデンティティを紐づける
初めて Bot にメッセージを送ると、Bot は **Lark アイデンティティを紐づける** よう促すカードで返信します。リンクをタップして Multica にサインインすると、あなたの Lark アカウントがあなたの Multica メンバーシップに紐づきます。これによって、エージェントがあなたとして振る舞えるようになります——たとえば `/issue` はあなたの名義でイシューを起票します。
<Callout type="warning">
Bot を使えるのは **ワークスペースのメンバー** だけです。メンバーでない場合や、アイデンティティの紐づけをスキップした場合、Bot は返信しません——あなたのメッセージは破棄されます(内容は保存せず、監査のために記録されます)。
</Callout>
### 対話と `/issue`
- **エージェントに何でも聞く** —— Bot に DM するか、グループで @ メンションします。会話は通常のエージェント chat セッションで、エージェントはカードの中で返信します。
- **イシューを起票する** —— `/issue Fix the login redirect` と送れば、Multica は新しいイシューを作るのと同じやり方でそのイシューをワークスペースに作ります。タイトルの後ろに行を足せば、それが説明になります。
- **作業を見守る** —— 返信カードはエージェントの実行に合わせて自身を更新するので、進捗と結果がその場で見えます。
エージェントが **オフライン**(ランタイムが接続されていない)または **アーカイブ済み** の場合、Bot はメッセージを黙って破棄するのではなく、短いステータス通知で返信します。
## 管理と切断
ワークスペース全体の管理は **設定 → 連携** にあります。
- **接続済みの Bot** は、ワークスペース内のすべての Bot と、それぞれが紐づくエージェントを一覧表示します。この一覧はすべてのメンバーから見えます。
- **切断** は **owner / admin 専用** です。切断すると Bot は Lark メッセージの受信を停止し、その接続が破棄されます。インストール記録は監査のために保持され、あとで同じエージェントを再び紐づけられます。
## 権限
- **紐づけ / 切断** にはワークスペースの **owner** または **admin** が必要です。member には接続済み Bot 一覧は見えますが、紐づけや切断の操作は見えません。
- **Bot との対話** には、Lark アイデンティティを紐づけたワークスペースメンバーであることが必要です。それ以外の人のメッセージは一律に破棄されます。
- この連携は破棄されたメッセージの本文を保存することはありません——監査のために破棄理由だけを記録します。
## セルフホストのセットアップ
Multica Cloud では連携はすでに利用可能です——このセクションは飛ばしてください。
セルフホストの場合、**保存時の暗号化キーを設定するまで Lark はオフ** です。このキーは、各 Bot の app secret がデータベースに触れる前にそれを暗号化します。
1. 32 バイトのキーを生成し、API サーバーに設定します。
```dotenv
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
```
2. API を再起動します。キーを設定するまで、**設定 → 連携** には「Lark integration not enabled」という通知が表示され、**Lark に紐づける** のエントリポイントは非表示のままになります。
<Callout type="info">
**国際版テナント。** 連携はデフォルトで中国大陸のホスト(`open.feishu.cn`)を使います。組織が Lark の国際版テナントにある場合は、トランスポートをそちらに向けてください。
```dotenv
MULTICA_LARK_HTTP_BASE_URL=https://open.larksuite.com
```
</Callout>
## 次に
- [エージェント](/agents) — 各 Bot はちょうど 1 つのエージェントに紐づきます
- [Chat](/chat) — Bot の会話が Multica 内で対応するもの
- [イシュー](/issues) — `/issue` が作るもの
- [環境変数](/environment-variables) — セルフホスト構成の完全なリファレンス

View File

@@ -0,0 +1,95 @@
---
title: Lark Bot 연동
description: Multica 에이전트를 Lark(飞书) 봇에 바인딩하면, Lark에서 직접 대화할 수 있습니다 — 개인 메시지나 그룹에서 @로 멘션하거나, 자연스럽게 대화하거나, /issue를 입력해 Lark를 벗어나지 않고 Multica 이슈를 생성하세요.
---
import { Callout } from "fumadocs-ui/components/callout";
아무 [에이전트](/agents)나 Lark(飞书) 봇에 바인딩하면, 팀이 Lark 안에서 바로 그 에이전트를 사용할 수 있습니다 — 봇에게 개인 메시지를 보내거나, 그룹에서 `@`로 멘션하거나, `/issue`를 입력해 앱을 열지 않고도 [Multica 이슈](/issues)를 생성하세요. 에이전트의 답변은 실시간 카드로 채팅에 돌아오며, 작업이 진행되는 동안 계속 업데이트됩니다.
각 봇은 하나의 Multica 에이전트와 **일대일**로 바인딩됩니다. 두 번째 에이전트를 바인딩하면 두 번째 봇이 생성되며, 하나의 에이전트가 두 개의 봇을 갖는 일은 없습니다.
## 연동이 하는 일
| 위치 | 동작 |
|---|---|
| **에이전트 → Integrations** | 에이전트 상세 페이지에 **Integrations** 탭이 있습니다(왼쪽 사이드바에도 대응하는 섹션이 있습니다). owner와 admin에게는 여기에 **Bind to Lark**가 보이며, 바인딩되면 **Connected to Lark** 배지와 **Manage in Lark** 링크로 바뀝니다. |
| **봇에게 개인 메시지** | 워크스페이스 멤버가 Lark에서 봇에게 직접 메시지를 보냅니다. 각 대화는 그 에이전트와의 Multica [chat](/chat) 세션이 되며, 에이전트는 해당 스레드에서 답변합니다. |
| **그룹에서 `@` 멘션** | 봇을 Lark 그룹에 추가하고 `@`로 멘션하세요. 멘션한 메시지만 읽으며, 봇이 그룹 전체를 듣지는 않습니다. |
| **`/issue` 명령** | `/issue <제목>`(본문 추가 가능)을 입력하면 워크스페이스에 새 Multica 이슈가 생성되고, 당신 이름으로 귀속됩니다. |
| **실시간 답변 카드** | 봇은 인터랙티브 카드를 게시하고 에이전트가 실행되는 동안 계속 갱신합니다 — 진행 상황, 최종 답변, 또는 오류. |
## 에이전트 바인딩하기 (owner / admin)
바인딩은 스캔하여 설치하는 방식입니다 — 복사할 앱 시크릿도, 개발자 콘솔 작업도 없습니다.
1. **Agents → _당신의 에이전트_**에서 에이전트를 엽니다.
2. **Integrations** 탭으로 이동하거나(또는 왼쪽 사이드바의 **Integrations** 섹션 사용) **Bind to Lark**를 클릭합니다.
3. QR 코드가 나타납니다. 휴대폰에서 **Lark → 스캔**을 열고, 새로 생긴 PersonalAgent 봇을 인증하세요.
4. 스캔이 완료되면 대화상자가 닫히고 에이전트에 **Connected to Lark**가 표시됩니다. 당신의 Lark 신원이 자동으로 Multica 계정에 바인딩되므로, 곧바로 봇과 대화를 시작할 수 있습니다.
<Callout type="info">
QR 코드는 일회용이며 짧은 시간 후에 만료됩니다. 인증하기 전에 만료되면 **Scan again**을 클릭해 새 코드를 받으세요.
</Callout>
에이전트가 연결되면 **Bind to Lark** 버튼이 **Manage in Lark** 링크로 바뀝니다. 권한 범위를 조정하거나, 이름을 바꾸거나, 추가 권한을 요청해야 할 때 이 링크로 Lark에서 봇의 앱 페이지를 여세요 — 기존 봇이 고아가 되지 않도록 재스캔은 의도적으로 비활성화되어 있습니다.
## 봇 사용하기 (멤버)
### 첫 메시지: Lark 신원 바인딩하기
봇에게 처음 메시지를 보내면, **Lark 신원을 바인딩**하라는 카드로 답합니다. 링크를 탭하고 Multica에 로그인하면, 당신의 Lark 계정이 Multica 멤버십에 연결됩니다. 바로 이 단계가 에이전트로 하여금 당신을 대신해 행동하게 합니다 — 예를 들어 `/issue`는 이슈를 당신 이름으로 생성합니다.
<Callout type="warning">
**워크스페이스 멤버**만 봇을 사용할 수 있습니다. 멤버가 아니거나 신원 바인딩을 건너뛰면 봇은 응답하지 않으며, 메시지는 폐기됩니다(감사 목적으로 기록되며, 내용은 저장하지 않습니다).
</Callout>
### 대화와 `/issue`
- **무엇이든 에이전트에게 물어보기** — 봇에게 개인 메시지를 보내거나 그룹에서 `@`로 멘션하세요. 이 대화는 일반적인 에이전트 chat 세션이며, 에이전트는 카드에서 답변합니다.
- **이슈 생성** — `/issue 로그인 리디렉션 수정`을 보내면 Multica가 워크스페이스에 그 이슈를 생성하며, 새 이슈가 으레 할당되는 방식 그대로 처리됩니다. 제목 뒤에 줄을 더 추가하면 설명이 됩니다.
- **작업 지켜보기** — 답변 카드는 에이전트가 실행되는 동안 스스로 갱신되므로, 진행 상황과 결과를 그 자리에서 볼 수 있습니다.
에이전트가 **오프라인**(런타임이 연결되지 않음)이거나 **보관됨** 상태라면, 봇은 메시지를 조용히 폐기하는 대신 짧은 상태 안내로 답합니다.
## 관리 및 연결 해제
워크스페이스 전체 관리는 **설정 → Integrations**에 있습니다.
- **Connected bots**는 워크스페이스 내 모든 봇과 각 봇이 바인딩된 에이전트를 나열합니다. 이 목록은 모든 멤버에게 보입니다.
- **Disconnect**는 **owner / admin 전용**입니다. 연결을 해제하면 봇이 Lark 메시지 수신을 멈추고 연결이 해체됩니다. 설치 기록은 감사용으로 유지되며, 이후 같은 에이전트를 다시 바인딩할 수 있습니다.
## 권한
- **바인딩 / 연결 해제**에는 워크스페이스 **owner** 또는 **admin**이 필요합니다. 멤버에게는 connected-bots 목록은 보이지만 바인딩이나 연결 해제 컨트롤은 보이지 않습니다.
- **봇과 대화하기**에는 Lark 신원이 바인딩된 워크스페이스 멤버여야 합니다. 그 외의 사람은 모두 폐기됩니다.
- 연동은 폐기된 메시지의 본문을 절대 저장하지 않으며 — 감사용 폐기 사유만 기록합니다.
## 자체 호스팅 설정
Multica Cloud에서는 연동이 이미 사용 가능합니다 — 이 섹션은 건너뛰세요.
자체 호스팅의 경우, **at-rest 암호화 키를 설정하기 전까지 Lark는 꺼져 있습니다**. 이 키는 각 봇의 앱 시크릿이 데이터베이스에 닿기 전에 암호화합니다.
1. 32바이트 키를 생성해 API 서버에 설정합니다.
```dotenv
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
```
2. API를 재시작하세요. 키가 설정되기 전까지 **설정 → Integrations**에는 "Lark integration not enabled" 안내가 표시되고, **Bind to Lark** 진입점은 숨겨진 채로 유지됩니다.
<Callout type="info">
**국제판 테넌트.** 연동은 기본적으로 중국 본토 호스트(`open.feishu.cn`)를 사용합니다. 당신의 조직이 Lark 국제판 테넌트에 있다면, 전송 계층을 그쪽으로 가리키게 하세요.
```dotenv
MULTICA_LARK_HTTP_BASE_URL=https://open.larksuite.com
```
</Callout>
## 다음
- [에이전트](/agents) — 각 봇은 정확히 하나의 에이전트에 바인딩됩니다
- [Chat](/chat) — 봇 대화가 Multica 내부에서 무엇에 대응하는지
- [이슈](/issues) — `/issue`가 생성하는 것
- [환경 변수](/environment-variables) — 전체 자체 호스팅 구성 참조

View File

@@ -0,0 +1,95 @@
---
title: Lark Bot integration
description: Bind a Multica agent to a Lark (飞书) Bot, then talk to it from a Lark DM or group — @-mention it, chat naturally, or type /issue to file a Multica issue without leaving Lark.
---
import { Callout } from "fumadocs-ui/components/callout";
Bind any [agent](/agents) to a Lark (飞书) Bot and your team can work with it from inside Lark — DM the Bot, @-mention it in a group, or type `/issue` to file a [Multica issue](/issues) without opening the app. The agent's replies stream back into the chat as a live card that updates while it works.
Each Bot is bound **one-to-one** to a single Multica agent. Binding a second agent creates a second Bot; one agent never has two Bots.
## What the integration does
| Surface | Behavior |
|---|---|
| **Agent → Integrations** | The agent detail page has an **Integrations** tab (and a matching section in the left sidebar). Owners and admins see **Bind to Lark** there; once bound it flips to a **Connected to Lark** badge with a **Manage in Lark** link. |
| **DM the Bot** | A workspace member messages the Bot directly in Lark. Each conversation becomes a Multica [chat](/chat) session with the agent; the agent answers in-thread. |
| **@-mention in a group** | Add the Bot to a Lark group and @-mention it. Only the mentioning message is read — the Bot does not listen to the whole group. |
| **`/issue` command** | Typing `/issue <title>` (optionally with a body) creates a new Multica issue in the workspace, attributed to you. |
| **Live reply card** | The Bot posts an interactive card and keeps patching it as the agent runs — progress, the final answer, or an error. |
## Bind an agent (owner / admin)
Binding uses a scan-to-install flow — no app secrets to copy, no developer console steps.
1. Open the agent in **Agents → _your agent_**.
2. Go to the **Integrations** tab (or use the **Integrations** section in the left sidebar) and click **Bind to Lark**.
3. A QR code appears. On your phone, open **Lark → Scan**, then authorize the new PersonalAgent Bot.
4. When the scan completes the dialog closes and the agent shows **Connected to Lark**. Your own Lark identity is bound to your Multica account automatically, so you can start chatting with the Bot right away.
<Callout type="info">
The QR is single-use and expires after a short window. If it lapses before you authorize, click **Scan again** for a fresh code.
</Callout>
Once an agent is connected, the **Bind to Lark** button is replaced by a **Manage in Lark** link. Use it to open the Bot's app page in Lark when you need to adjust scopes, rename it, or request additional permissions — re-scanning is intentionally disabled so you don't strand the existing Bot.
## Use the Bot (members)
### First message: bind your Lark identity
The first time you message the Bot, it replies with a card asking you to **bind your Lark identity**. Tap the link, sign in to Multica, and your Lark account is linked to your Multica membership. This is what lets the agent act as you — for example, `/issue` files the issue under your name.
<Callout type="warning">
Only people who are **members of the workspace** can use the Bot. If you aren't a member, or you skip the identity bind, the Bot won't respond — your message is dropped (and recorded for audit, without its contents).
</Callout>
### Chat and `/issue`
- **Ask the agent anything** — DM the Bot or @-mention it in a group. The conversation is a normal agent chat session; the agent replies in the card.
- **File an issue** — send `/issue Fix the login redirect` and Multica creates that issue in the workspace, assigned the way any new issue would be. Add more lines after the title for a description.
- **Watch it work** — the reply card patches itself while the agent runs, so you see progress and the result in place.
If the agent is **offline** (its runtime isn't connected) or **archived**, the Bot replies with a short status notice instead of silently dropping your message.
## Manage and disconnect
Workspace-wide management lives in **Settings → Integrations**:
- **Connected bots** lists every Bot in the workspace and the agent each one is bound to. This list is visible to all members.
- **Disconnect** is **owner / admin only**. Disconnecting stops the Bot from receiving Lark messages and tears down its connection; the installation record is kept for audit, and you can re-bind the same agent later.
## Permissions
- **Bind / disconnect** require workspace **owner** or **admin**. Members see the connected-bots list but no bind or disconnect controls.
- **Talking to the Bot** requires being a workspace member with a bound Lark identity. Everyone else is dropped.
- The integration never stores message bodies for dropped messages — only a drop reason, for audit.
## Self-host setup
On Multica Cloud the integration is already available — skip this section.
For self-host, Lark is **off until you set an at-rest encryption key**. The key encrypts each Bot's app secret before it touches the database.
1. Generate a 32-byte key and set it on the API server:
```dotenv
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
```
2. Restart the API. Until the key is set, **Settings → Integrations** shows a "Lark integration not enabled" notice and the **Bind to Lark** entry points stay hidden.
<Callout type="info">
**International tenants.** The integration defaults to the mainland host (`open.feishu.cn`). If your organization is on Lark's international tenant, point the transport at it:
```dotenv
MULTICA_LARK_HTTP_BASE_URL=https://open.larksuite.com
```
</Callout>
## Next
- [Agents](/agents) — each Bot is bound to exactly one agent
- [Chat](/chat) — what a Bot conversation maps to inside Multica
- [Issues](/issues) — what `/issue` creates
- [Environment variables](/environment-variables) — full self-host configuration reference

View File

@@ -0,0 +1,95 @@
---
title: 飞书 Bot 接入
description: 把 Multica 智能体绑定到飞书LarkBot就能直接在飞书里和它对话——私聊、群里 @ 它,或者输入 /issue 直接创建 Multica issue全程不用离开飞书。
---
import { Callout } from "fumadocs-ui/components/callout";
把任意[智能体](/agents)绑定到飞书 Bot团队就能在飞书里直接使用它——私聊 Bot、在群里 @ 它,或者输入 `/issue` 直接创建一个 [Multica issue](/issues),不用打开应用。智能体的回复会以一张实时卡片的形式回到聊天里,随着它干活不断更新。
每个 Bot 与一个 Multica 智能体**一对一**绑定。再绑定一个智能体会创建另一个 Bot一个智能体永远不会有两个 Bot。
## 这个集成能做什么
| 入口 | 行为 |
|---|---|
| **智能体 → 集成** | 智能体详情页有一个 **集成Integrations** tab左侧栏也有对应的区块。所有者和管理员能在这里看到 **绑定到飞书**;绑定后会变成 **已连接到飞书** 徽标,并带一个 **在飞书中管理** 链接。 |
| **私聊 Bot** | 工作区成员在飞书里直接给 Bot 发消息。每段对话都会成为该智能体的一个 Multica [chat](/chat) 会话,智能体在会话里回复。 |
| **群里 @ 它** | 把 Bot 加进飞书群再 @ 它。Bot 只读取 @ 它的那条消息,不会监听整个群。 |
| **`/issue` 命令** | 输入 `/issue <标题>`(可附正文)会在工作区创建一个新的 Multica issue记在你名下。 |
| **实时回复卡片** | Bot 会发出一张可交互卡片,并随着智能体运行不断更新——进度、最终答复或报错。 |
## 绑定智能体(所有者 / 管理员)
绑定走的是扫码安装流程——不用复制任何应用密钥,也不用进开发者后台。
1. 在 **Agents → 你的智能体** 打开该智能体。
2. 进入 **集成Integrations** tab或使用左侧栏的 **集成** 区块),点击 **绑定到飞书**。
3. 弹出一个二维码。用手机打开 **飞书 → 扫一扫**,然后授权这个新的 PersonalAgent Bot。
4. 扫码完成后弹窗关闭,智能体显示 **已连接到飞书**。你自己的飞书身份会自动绑定到你的 Multica 账号,绑完即可开始和 Bot 对话。
<Callout type="info">
二维码是一次性的,并且会在较短时间后过期。如果在授权前就过期了,点 **重新扫码** 获取新码即可。
</Callout>
智能体连接后,**绑定到飞书** 按钮会替换成 **在飞书中管理** 链接。需要调整权限范围、改名或申请更多权限时,用它打开 Bot 在飞书里的应用页面——重新扫码被刻意禁用,以免把已有的 Bot 弄成孤儿。
## 使用 Bot成员
### 第一条消息:绑定你的飞书身份
第一次给 Bot 发消息时,它会回一张卡片,让你 **绑定飞书身份**。点开链接、登录 Multica你的飞书账号就会关联到你的 Multica 成员身份。正是这一步让智能体能以你的身份行事——比如 `/issue` 会把 issue 记在你名下。
<Callout type="warning">
只有**工作区成员**才能使用 Bot。如果你不是成员或者跳过了身份绑定Bot 不会回复——你的消息会被丢弃(仅出于审计目的记录,不保存消息内容)。
</Callout>
### 对话与 `/issue`
- **随便问智能体** —— 私聊 Bot或在群里 @ 它。对话就是一段普通的智能体 chat 会话,智能体在卡片里回复。
- **创建 issue** —— 发送 `/issue 修复登录跳转`Multica 会在工作区创建这个 issue和新建任何 issue 一样。标题后面再加几行就是描述。
- **看它干活** —— 回复卡片会随着智能体运行不断更新,进度和结果都在原处呈现。
如果智能体**离线**(运行时未连接)或**已归档**Bot 会回一条简短的状态提示,而不是悄悄丢掉你的消息。
## 管理与断开
工作区级别的管理在 **设置 → 集成**
- **已连接的 Bot** 列出工作区里每个 Bot 以及它绑定的智能体。这个列表所有成员都能看到。
- **断开连接** 仅限 **所有者 / 管理员**。断开后 Bot 停止接收飞书消息、连接被销毁;安装记录会保留以便审计,之后你可以重新绑定同一个智能体。
## 权限
- **绑定 / 断开** 需要工作区**所有者**或**管理员**。成员能看到已连接 Bot 列表,但看不到绑定或断开的操作。
- **和 Bot 对话** 需要你是工作区成员且已绑定飞书身份。其余的人一律被丢弃。
- 对于被丢弃的消息,集成不会保存消息内容——只记录一个丢弃原因,用于审计。
## 自部署配置
在 Multica Cloud 上这个集成已经可用——可跳过本节。
自部署时,**在你设置好静态加密密钥之前,飞书集成是关闭的**。这个密钥会在每个 Bot 的 app secret 落库之前对其加密。
1. 生成一个 32 字节的密钥并设置到 API 服务器:
```dotenv
MULTICA_LARK_SECRET_KEY=<base64 编码的 32 字节密钥>
```
2. 重启 API。在密钥设置好之前**设置 → 集成** 会显示「未启用飞书集成」提示,**绑定到飞书** 入口也会保持隐藏。
<Callout type="info">
**国际版租户。** 集成默认走中国大陆主机(`open.feishu.cn`。如果你的组织在飞书国际版Lark租户上把传输层指过去
```dotenv
MULTICA_LARK_HTTP_BASE_URL=https://open.larksuite.com
```
</Callout>
## 下一步
- [智能体](/agents) —— 每个 Bot 都绑定到恰好一个智能体
- [Chat](/chat) —— 一段 Bot 对话在 Multica 里对应什么
- [Issues](/issues) —— `/issue` 创建的是什么
- [环境变量](/environment-variables) —— 完整的自部署配置参考

View File

@@ -31,6 +31,7 @@
"inbox",
"---連携---",
"github-integration",
"lark-bot-integration",
"---セルフホスト & 運用---",
"environment-variables",
"auth-setup",

View File

@@ -31,6 +31,7 @@
"inbox",
"---Integrations---",
"github-integration",
"lark-bot-integration",
"---Self-hosting & ops---",
"environment-variables",
"auth-setup",

View File

@@ -31,6 +31,7 @@
"inbox",
"---연동---",
"github-integration",
"lark-bot-integration",
"---자체 호스팅 & 운영---",
"environment-variables",
"auth-setup",

View File

@@ -30,6 +30,7 @@
"inbox",
"---集成---",
"github-integration",
"lark-bot-integration",
"---自部署运维---",
"environment-variables",
"auth-setup",

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, AgentRuntime } from "@multica/core/types";
@@ -31,10 +31,30 @@ vi.mock("./tabs/custom-args-tab", () => ({
vi.mock("./tabs/mcp-config-tab", () => ({
McpConfigTab: () => <div>mcp-config-tab</div>,
}));
vi.mock("./tabs/integrations-tab", () => ({
IntegrationsTab: () => <div>integrations-tab</div>,
}));
vi.mock("../../common/actor-issues-panel", () => ({
ActorIssuesPanel: () => <div>actor-issues-panel</div>,
}));
// The pane now reads workspace context to decide whether the Integrations
// tab is worth showing (it queries Lark installations to learn whether the
// deployment has the feature configured). Provide a stable workspace id and
// a listing query backed by a ref so each test can flip `configured`.
const larkListingRef = vi.hoisted(() => ({
current: { installations: [] as unknown[], configured: false },
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
vi.mock("@multica/core/lark", () => ({
larkInstallationsOptions: () => ({
queryKey: ["lark", "installations"],
queryFn: () => Promise.resolve(larkListingRef.current),
}),
}));
import { AgentOverviewPane } from "./agent-overview-pane";
const baseAgent: Agent = {
@@ -97,6 +117,10 @@ function renderPane(runtimes: AgentRuntime[]) {
);
}
beforeEach(() => {
larkListingRef.current = { installations: [], configured: false };
});
describe("AgentOverviewPane MCP tab visibility", () => {
it.each([
["Claude", "claude"],
@@ -128,3 +152,22 @@ describe("AgentOverviewPane MCP tab visibility", () => {
expect(screen.getByRole("button", { name: /^MCP$/i })).toBeInTheDocument();
});
});
describe("AgentOverviewPane Integrations tab visibility", () => {
it("shows the Integrations tab once the deployment has Lark configured", async () => {
larkListingRef.current = { installations: [], configured: true };
renderPane([makeRuntime("claude")]);
expect(
await screen.findByRole("button", { name: /^Integrations$/i }),
).toBeInTheDocument();
});
it("hides the Integrations tab when Lark is not configured", () => {
// Default ref is configured:false; the tab must not appear on
// deployments without the integration, which are the common case.
renderPane([makeRuntime("claude")]);
expect(
screen.queryByRole("button", { name: /^Integrations$/i }),
).not.toBeInTheDocument();
});
});

View File

@@ -9,9 +9,13 @@ import {
ListTodo,
Plug,
Terminal,
Webhook,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import type { Agent, AgentRuntime } from "@multica/core/types";
import { providerSupportsMcpConfig } from "@multica/core/agents";
import { useWorkspaceId } from "@multica/core/hooks";
import { larkInstallationsOptions } from "@multica/core/lark";
import {
AlertDialog,
AlertDialogAction,
@@ -28,6 +32,7 @@ import { SkillsTab } from "./tabs/skills-tab";
import { EnvTab } from "./tabs/env-tab";
import { CustomArgsTab } from "./tabs/custom-args-tab";
import { McpConfigTab } from "./tabs/mcp-config-tab";
import { IntegrationsTab } from "./tabs/integrations-tab";
import { ActorIssuesPanel } from "../../common/actor-issues-panel";
import { useT } from "../../i18n";
@@ -38,9 +43,10 @@ type DetailTab =
| "skills"
| "env"
| "custom_args"
| "mcp_config";
| "mcp_config"
| "integrations";
const TAB_LABEL_KEY: Record<DetailTab, "activity" | "tasks" | "instructions" | "skills" | "environment" | "custom_args" | "mcp_config"> = {
const TAB_LABEL_KEY: Record<DetailTab, "activity" | "tasks" | "instructions" | "skills" | "environment" | "custom_args" | "mcp_config" | "integrations"> = {
activity: "activity",
tasks: "tasks",
instructions: "instructions",
@@ -48,6 +54,7 @@ const TAB_LABEL_KEY: Record<DetailTab, "activity" | "tasks" | "instructions" | "
env: "environment",
custom_args: "custom_args",
mcp_config: "mcp_config",
integrations: "integrations",
};
const detailTabs: {
@@ -61,6 +68,7 @@ const detailTabs: {
{ id: "env", icon: KeyRound },
{ id: "custom_args", icon: Terminal },
{ id: "mcp_config", icon: Plug },
{ id: "integrations", icon: Webhook },
];
interface AgentOverviewPaneProps {
@@ -98,6 +106,7 @@ export function AgentOverviewPane({
onUpdate,
}: AgentOverviewPaneProps) {
const { t } = useT("agents");
const wsId = useWorkspaceId();
const [activeTab, setActiveTab] = useState<DetailTab>("activity");
const [activeDirty, setActiveDirty] = useState(false);
// Holds the destination when a tab change is intercepted by the dirty
@@ -109,14 +118,32 @@ export function AgentOverviewPane({
? runtimes.find((r) => r.id === agent.runtime_id) ?? null
: null;
// Cached per-workspace and shared with the inspector's bind button, so this
// is at most one extra GET per workspace. We only read `configured` to
// decide whether the Integrations tab is worth showing at all.
const { data: larkListing } = useQuery({
...larkInstallationsOptions(wsId),
enabled: !!wsId,
});
const larkConfigured = larkListing?.configured === true;
// The MCP tab is only shown when the agent's runtime backend actually
// consumes mcp_config — see providerSupportsMcpConfig. We default to
// showing it when the runtime row hasn't loaded yet so a slow fetch
// can't transiently flicker the tab off and then on.
//
// The Integrations tab only appears once the deployment has Lark wired
// (configured). Unlike MCP we default to HIDING while the listing loads:
// deployments without Lark are the common case, so flashing the tab on
// then off would be the worse flicker.
const visibleTabs = useMemo(() => {
const showMcp = runtime ? providerSupportsMcpConfig(runtime.provider) : true;
return detailTabs.filter((tab) => tab.id !== "mcp_config" || showMcp);
}, [runtime]);
return detailTabs.filter((tab) => {
if (tab.id === "mcp_config") return showMcp;
if (tab.id === "integrations") return larkConfigured;
return true;
});
}, [runtime, larkConfigured]);
// If the active tab disappears (e.g. user just switched the agent's
// runtime to one that doesn't read mcp_config), fall back to Activity
@@ -218,6 +245,11 @@ export function AgentOverviewPane({
/>
</TabContent>
)}
{effectiveTab === "integrations" && (
<TabContent>
<IntegrationsTab agent={agent} />
</TabContent>
)}
</div>
{pendingTab !== null && (

View File

@@ -0,0 +1,172 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import type { Agent } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../../locales/en/common.json";
import enAgents from "../../../locales/en/agents.json";
import enSettings from "../../../locales/en/settings.json";
// IntegrationsTab's job is to pick which copy sits beside the bind entry
// based on (configured / install_supported / role). The bind entry itself
// is the shared LarkAgentBindButton, exhaustively covered in
// lark-tab.test.tsx — here we stub it to a marker so the tests assert the
// branch selection, not the install flow.
type MemberRole = "owner" | "admin" | "member" | "guest";
const membersRef = vi.hoisted(() => ({
current: [{ user_id: "user-1", role: "owner" as MemberRole }],
}));
const installationsRef = vi.hoisted(() => ({
current: {
installations: [] as unknown[],
configured: true,
install_supported: true,
},
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: { queryKey: unknown[]; enabled?: boolean }) => {
if (opts.enabled === false) return { data: undefined };
const key = JSON.stringify(opts.queryKey);
if (key.includes("members")) return { data: membersRef.current };
if (key.includes("installations")) return { data: installationsRef.current };
return { data: undefined };
},
queryOptions: <T,>(opts: T) => opts,
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
vi.mock("@multica/core/workspace/queries", () => ({
memberListOptions: () => ({ queryKey: ["members"], queryFn: vi.fn() }),
}));
vi.mock("@multica/core/lark", () => ({
larkInstallationsOptions: () => ({
queryKey: ["lark", "installations"],
queryFn: vi.fn(),
}),
}));
vi.mock("@multica/core/auth", () => {
const useAuthStore = Object.assign(
(sel?: (s: { user: { id: string } }) => unknown) =>
sel ? sel({ user: { id: "user-1" } }) : { user: { id: "user-1" } },
{ getState: () => ({ user: { id: "user-1" } }) },
);
return { useAuthStore };
});
vi.mock("../../../settings/components/lark-tab", () => ({
LarkAgentBindButton: ({ agentId }: { agentId: string }) => (
<div data-testid="lark-bind-button" data-agent-id={agentId} />
),
}));
import { IntegrationsTab } from "./integrations-tab";
const TEST_RESOURCES = {
en: { common: enCommon, agents: enAgents, settings: enSettings },
};
const agent: Agent = {
id: "agent-1",
workspace_id: "ws-1",
runtime_id: "runtime-1",
name: "Agent",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "local",
runtime_config: {},
custom_args: [],
visibility: "workspace",
status: "idle",
max_concurrent_tasks: 1,
model: "",
owner_id: "user-1",
skills: [],
created_at: "2026-04-16T00:00:00Z",
updated_at: "2026-04-16T00:00:00Z",
archived_at: null,
archived_by: null,
};
function renderTab(children: ReactNode) {
return render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
{children}
</I18nProvider>,
);
}
function resetFixtures() {
vi.clearAllMocks();
membersRef.current = [{ user_id: "user-1", role: "owner" }];
installationsRef.current = {
installations: [],
configured: true,
install_supported: true,
};
}
describe("IntegrationsTab", () => {
beforeEach(resetFixtures);
it("renders the shared bind entry for an owner when Lark is configured and supported", () => {
renderTab(<IntegrationsTab agent={agent} />);
expect(screen.getByText("Lark")).toBeTruthy();
const button = screen.getByTestId("lark-bind-button");
expect(button.getAttribute("data-agent-id")).toBe("agent-1");
});
it("shows the coming-soon notice when the install transport is not wired", () => {
installationsRef.current = {
installations: [],
configured: true,
install_supported: false,
};
renderTab(<IntegrationsTab agent={agent} />);
expect(screen.getByText(/installation coming soon/i)).toBeTruthy();
expect(screen.queryByTestId("lark-bind-button")).toBeNull();
});
it("shows the not-enabled notice when the deployment has no Lark key", () => {
installationsRef.current = {
installations: [],
configured: false,
install_supported: false,
};
renderTab(<IntegrationsTab agent={agent} />);
expect(screen.getByText(/Lark integration not enabled/i)).toBeTruthy();
expect(screen.queryByTestId("lark-bind-button")).toBeNull();
});
it("points members at Settings instead of a dead button when they can't manage", () => {
membersRef.current = [{ user_id: "user-1", role: "member" }];
renderTab(<IntegrationsTab agent={agent} />);
expect(
screen.getByText(/Only workspace owners and admins can bind a Lark Bot/i),
).toBeTruthy();
expect(screen.queryByTestId("lark-bind-button")).toBeNull();
});
it("renders the bind entry (not coming-soon) when installs are unavailable but the agent is already bound", () => {
// install_supported governs only NEW installs; an already-bound agent
// must still surface its connected state instead of "coming soon"
// (regression for the must-fix on MUL-2988).
installationsRef.current = {
installations: [{ agent_id: "agent-1", status: "active" }],
configured: true,
install_supported: false,
};
renderTab(<IntegrationsTab agent={agent} />);
expect(screen.getByTestId("lark-bind-button")).toBeTruthy();
expect(screen.queryByText(/installation coming soon/i)).toBeNull();
});
});

View File

@@ -0,0 +1,112 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Webhook } from "lucide-react";
import type { Agent } from "@multica/core/types";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { larkInstallationsOptions } from "@multica/core/lark";
import { memberListOptions } from "@multica/core/workspace/queries";
import { LarkAgentBindButton } from "../../../settings/components/lark-tab";
import { useT } from "../../../i18n";
/**
* Integrations tab on the agent detail page. Surfaces the same external-
* channel bind entry point as the inspector's "Integrations" section
* (Lark Bot today) — scan-to-bind when unbound, connected info when bound —
* but with the room a tab affords for a heading, a description, and the
* not-configured / coming-soon / members-only states the cramped sidebar
* section has no space for.
*
* The actionable affordance is the shared `LarkAgentBindButton`, the single
* source of truth for "scan to bind vs. already connected". This tab only
* adds the explanatory chrome around it, so the two entry points can never
* drift.
*/
export function IntegrationsTab({ agent }: { agent: Agent }) {
const { t } = useT("agents");
const { t: ts } = useT("settings");
const wsId = useWorkspaceId();
const user = useAuthStore((s) => s.user);
// Both queries are already issued by LarkAgentBindButton (and keyed per
// workspace), so re-reading them here is free — TanStack dedupes by key.
// We only need the derived booleans to pick which copy sits next to the
// button, mirroring the branch order LarkTab uses in Settings.
const { data: listing } = useQuery({
...larkInstallationsOptions(wsId),
enabled: !!wsId,
});
const { data: members = [] } = useQuery({
...memberListOptions(wsId),
enabled: !!wsId,
});
const configured = listing?.configured === true;
const installSupported = listing?.install_supported === true;
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
const canManage =
currentMember?.role === "owner" || currentMember?.role === "admin";
const hasActiveInstall =
listing?.installations.some(
(inst) => inst.agent_id === agent.id && inst.status === "active",
) ?? false;
return (
<div className="space-y-6">
<p className="text-xs text-muted-foreground">
{t(($) => $.tab_body.integrations.intro)}
</p>
<section className="rounded-lg border">
<div className="flex items-start gap-3 p-4">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border bg-muted/40 text-muted-foreground">
<Webhook className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1 space-y-1">
<h3 className="text-sm font-medium">{ts(($) => $.lark.section_title)}</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{ts(($) => $.lark.page_description)}
</p>
</div>
</div>
<div className="border-t px-4 py-3">
{!configured ? (
// No at-rest key on this deployment. The tab is only mounted
// when the feature is configured, so this is the rare "key was
// removed after an install existed" race.
<p className="text-xs text-muted-foreground">
{ts(($) => $.lark.not_enabled_title)}
</p>
) : !canManage ? (
// The backend gates install / manage on workspace owner/admin.
// Members can still view connected bots in the (member-visible)
// Settings listing, so point them there rather than show a dead
// button.
<p className="text-xs text-muted-foreground">
{t(($) => $.tab_body.integrations.members_note)}
</p>
) : !installSupported && !hasActiveInstall ? (
// Key is set but the device-flow transport isn't wired in this
// build — a fresh scan would fail at the post-poll bot-info step,
// so we surface the "coming soon" notice instead of a broken CTA.
// An agent that is ALREADY bound is exempt: install_supported only
// governs NEW installs, so the bound state must still render below
// (server/internal/handler/lark.go).
<div className="space-y-1">
<p className="text-xs font-medium">{ts(($) => $.lark.preview_title)}</p>
<p className="text-xs text-muted-foreground">
{ts(($) => $.lark.preview_description)}
</p>
</div>
) : (
// Owner/admin with either a supported transport or an existing
// bot: the shared button renders the scan-to-bind CTA or the
// already-connected "Manage in Lark" badge.
<LarkAgentBindButton agentId={agent.id} agentName={agent.name} />
)}
</div>
</section>
</div>
);
}

View File

@@ -209,6 +209,7 @@
"environment": "Environment",
"custom_args": "Custom Args",
"mcp_config": "MCP",
"integrations": "Integrations",
"discard_dialog_title": "Discard unsaved changes?",
"discard_dialog_description": "You have unsaved changes in this tab. Leaving now will discard them.",
"discard_keep": "Keep editing",
@@ -339,6 +340,10 @@
"add_dialog_cancel": "Cancel",
"add_failed_toast": "Failed to add skill"
},
"integrations": {
"intro": "Connect this agent to external chat platforms so people can work with it where they already are.",
"members_note": "Only workspace owners and admins can bind a Lark Bot to an agent. You can view connected bots in Settings → Integrations."
},
"activity": {
"section_now": "Now",
"section_last_30d": "Last 30 days",

View File

@@ -196,6 +196,7 @@
"environment": "環境",
"custom_args": "カスタム引数",
"mcp_config": "MCP",
"integrations": "連携",
"discard_dialog_title": "保存していない変更を破棄しますか?",
"discard_dialog_description": "このタブに保存していない変更があります。今移動すると変更は破棄されます。",
"discard_keep": "編集を続ける",
@@ -323,6 +324,10 @@
"add_dialog_cancel": "キャンセル",
"add_failed_toast": "スキルを追加できませんでした"
},
"integrations": {
"intro": "このエージェントを外部のチャットプラットフォームに接続し、普段使っているツールから直接やり取りできるようにします。",
"members_note": "エージェントに Lark Bot を紐付けできるのはワークスペースのオーナーと管理者のみです。接続済みの Bot は「設定 → 連携」で確認できます。"
},
"activity": {
"section_now": "現在",
"section_last_30d": "過去30日",

View File

@@ -209,6 +209,7 @@
"environment": "환경",
"custom_args": "사용자 지정 인자",
"mcp_config": "MCP",
"integrations": "연동",
"discard_dialog_title": "저장하지 않은 변경사항을 버릴까요?",
"discard_dialog_description": "이 탭에 저장하지 않은 변경사항이 있습니다. 지금 나가면 변경사항이 사라집니다.",
"discard_keep": "계속 편집",
@@ -339,6 +340,10 @@
"add_dialog_cancel": "취소",
"add_failed_toast": "스킬을 추가하지 못했습니다"
},
"integrations": {
"intro": "이 에이전트를 외부 채팅 플랫폼에 연결해 팀원이 평소 사용하는 도구에서 바로 함께 작업할 수 있도록 합니다.",
"members_note": "에이전트에 Lark 봇을 연결할 수 있는 사람은 워크스페이스 소유자와 관리자뿐입니다. 연결된 봇은 설정 → 연동에서 확인할 수 있습니다."
},
"activity": {
"section_now": "현재",
"section_last_30d": "최근 30일",

View File

@@ -204,6 +204,7 @@
"environment": "环境变量",
"custom_args": "自定义参数",
"mcp_config": "MCP",
"integrations": "集成",
"discard_dialog_title": "放弃未保存的修改?",
"discard_dialog_description": "当前 tab 有未保存的修改,离开会丢弃这些修改。",
"discard_keep": "继续编辑",
@@ -331,6 +332,10 @@
"add_dialog_cancel": "取消",
"add_failed_toast": "添加 skill 失败"
},
"integrations": {
"intro": "把这个智能体连接到外部聊天平台,让大家在自己熟悉的工具里直接与它协作。",
"members_note": "只有工作区的所有者和管理员才能为智能体绑定飞书 Bot。你可以在「设置 → 集成」中查看已连接的 Bot。"
},
"activity": {
"section_now": "当前",
"section_last_30d": "近 30 天",

View File

@@ -272,6 +272,38 @@ describe("LarkAgentBindButton (CTA gate)", () => {
expect(screen.getByRole("button", { name: /Bind to Lark/i })).toBeTruthy();
});
it("keeps the Connected + Manage badge for an already-installed agent even when new installs are unavailable (install_supported=false)", () => {
// install_supported governs only NEW scan-installs — an already-installed
// bot stays manageable when the device-flow transport is unwired
// (server/internal/handler/lark.go: "already-installed bots still appear
// and remain manageable"). Regression: the install_supported gate used to
// run before the existing-installation check and hid the bound state.
installationsRef.current.install_supported = false;
installationsRef.current.installations = [
{
id: "inst-1",
workspace_id: "ws-1",
agent_id: "agent-1",
app_id: "cli_existing_app",
bot_open_id: "ou_existing_bot",
installer_user_id: "user-1",
status: "active",
installed_at: "2026-06-03T00:00:00Z",
created_at: "2026-06-03T00:00:00Z",
updated_at: "2026-06-03T00:00:00Z",
},
];
const { container } = render(
<LarkAgentBindButton agentId="agent-1" agentName="Bot" />,
{ wrapper: I18nWrapper },
);
expect(container.querySelector("button")).toBeNull();
expect(screen.getByText(/Connected to Lark/i)).toBeTruthy();
expect(
screen.getByRole("link", { name: /Manage in Lark/i }),
).toBeTruthy();
});
it("still shows the bind CTA when this agent's only installation is revoked (treat as not-installed for re-bind)", () => {
installationsRef.current.installations = [
{

View File

@@ -259,17 +259,24 @@ function InstallationRow({
// detail page. The Settings panel above is the management view; this
// button is the entry point.
//
// The button hides itself when either:
// (a) the device-flow install path is not wired on the server
// (install_supported == false on the listing endpoint), or
// (b) the current viewer is not a workspace owner/admin — the backend
// gates `POST /lark/install/begin` and the status poll on those
// roles (see server/cmd/server/router.go:478-487), and
// `canEditAgent` lets agent owners through even when they're not
// workspace admins, so the parent's `canEdit` gate alone would
// expose a CTA that's guaranteed to 403.
// This is the "don't expose a flow that's guaranteed to fail"
// guarantee — both halves matter.
// Visibility rules, in order:
// 1. Non-owner/admin viewers see nothing — the backend gates
// `POST /lark/install/begin`, the status poll, AND disconnect on
// those roles (see server/cmd/server/router.go), and `canEditAgent`
// lets agent owners through even when they're not workspace admins,
// so the parent's `canEdit` gate alone would expose controls that
// are guaranteed to 403.
// 2. If this agent ALREADY has an active installation, owner/admins see
// the "Connected + Manage in Lark" badge — regardless of
// install_supported. install_supported governs only whether NEW
// scan-installs can complete; already-installed bots stay manageable
// when the device-flow transport is unwired (see
// server/internal/handler/lark.go — "already-installed bots still
// appear and remain manageable"). Gating the badge on it would hide a
// bound agent's connected state the moment the transport went away.
// 3. Otherwise the Bind CTA shows only when install_supported is true —
// a fresh scan against a stub transport would fail at the post-poll
// bot-info step, so we don't surface a flow that's guaranteed to fail.
export function LarkAgentBindButton({
agentId,
agentName,
@@ -298,16 +305,15 @@ export function LarkAgentBindButton({
const canManage =
currentMember?.role === "owner" || currentMember?.role === "admin";
if (!installSupported || !canManage) return null;
if (!canManage) return null;
// Re-scanning the same agent overwrites the existing installation row
// (lark_installation upserts on the (workspace_id, agent_id) UNIQUE)
// and leaves the previously-created PersonalAgent dangling on Lark's
// side as a zombie bot — users were getting trapped re-scanning when
// they wanted to manage scopes. When this agent already has an
// ACTIVE installation, we close the install entry point and surface
// a link to the Bot's Lark app page instead, where scopes / display
// name / additional permissions are managed.
// Existing-installation check runs BEFORE the install_supported gate:
// already-installed bots stay manageable even when new scan-installs are
// unavailable (server/internal/handler/lark.go). Surfacing the badge here
// also closes the re-scan zombie-bot trap re-scanning the same agent
// upserts the row and orphans the previously-created PersonalAgent, so we
// close the install entry point and link to the Bot's Lark app page where
// scopes / display name / additional permissions are actually managed.
const existing = listing?.installations.find(
(inst) => inst.agent_id === agentId && inst.status === "active",
);
@@ -317,6 +323,10 @@ export function LarkAgentBindButton({
);
}
// No existing bot and the device-flow transport isn't wired end-to-end:
// a fresh scan would fail at the post-poll bot-info step, so hide the CTA.
if (!installSupported) return null;
return (
<>
<Button