mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
95
apps/docs/content/docs/lark-bot-integration.ja.mdx
Normal file
95
apps/docs/content/docs/lark-bot-integration.ja.mdx
Normal 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) — セルフホスト構成の完全なリファレンス
|
||||
95
apps/docs/content/docs/lark-bot-integration.ko.mdx
Normal file
95
apps/docs/content/docs/lark-bot-integration.ko.mdx
Normal 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) — 전체 자체 호스팅 구성 참조
|
||||
95
apps/docs/content/docs/lark-bot-integration.mdx
Normal file
95
apps/docs/content/docs/lark-bot-integration.mdx
Normal 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
|
||||
95
apps/docs/content/docs/lark-bot-integration.zh.mdx
Normal file
95
apps/docs/content/docs/lark-bot-integration.zh.mdx
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: 飞书 Bot 接入
|
||||
description: 把 Multica 智能体绑定到飞书(Lark)Bot,就能直接在飞书里和它对话——私聊、群里 @ 它,或者输入 /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) —— 完整的自部署配置参考
|
||||
@@ -31,6 +31,7 @@
|
||||
"inbox",
|
||||
"---連携---",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"---セルフホスト & 運用---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"inbox",
|
||||
"---Integrations---",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"---Self-hosting & ops---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"inbox",
|
||||
"---연동---",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"---자체 호스팅 & 운영---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"inbox",
|
||||
"---集成---",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"---自部署运维---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
172
packages/views/agents/components/tabs/integrations-tab.test.tsx
Normal file
172
packages/views/agents/components/tabs/integrations-tab.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
112
packages/views/agents/components/tabs/integrations-tab.tsx
Normal file
112
packages/views/agents/components/tabs/integrations-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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日",
|
||||
|
||||
@@ -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일",
|
||||
|
||||
@@ -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 天",
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user