diff --git a/apps/docs/content/docs/lark-bot-integration.ja.mdx b/apps/docs/content/docs/lark-bot-integration.ja.mdx new file mode 100644 index 000000000..06da20bd1 --- /dev/null +++ b/apps/docs/content/docs/lark-bot-integration.ja.mdx @@ -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 と対話を始められます。 + + +QR は使い切りで、短い時間が過ぎると失効します。認可する前に失効してしまったら、**もう一度スキャン** をクリックして新しいコードを取得してください。 + + +エージェントが接続されると、**Lark に紐づける** ボタンは **Lark で管理** リンクに置き換わります。スコープの調整、名前の変更、追加の権限の申請が必要なときは、これを使って Lark 内の Bot のアプリページを開いてください——再スキャンは意図的に無効化されており、既存の Bot を取り残してしまわないようにしています。 + +## Bot を使う(メンバー) + +### 最初のメッセージ:Lark アイデンティティを紐づける + +初めて Bot にメッセージを送ると、Bot は **Lark アイデンティティを紐づける** よう促すカードで返信します。リンクをタップして Multica にサインインすると、あなたの Lark アカウントがあなたの Multica メンバーシップに紐づきます。これによって、エージェントがあなたとして振る舞えるようになります——たとえば `/issue` はあなたの名義でイシューを起票します。 + + +Bot を使えるのは **ワークスペースのメンバー** だけです。メンバーでない場合や、アイデンティティの紐づけをスキップした場合、Bot は返信しません——あなたのメッセージは破棄されます(内容は保存せず、監査のために記録されます)。 + + +### 対話と `/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= + ``` + +2. API を再起動します。キーを設定するまで、**設定 → 連携** には「Lark integration not enabled」という通知が表示され、**Lark に紐づける** のエントリポイントは非表示のままになります。 + + +**国際版テナント。** 連携はデフォルトで中国大陸のホスト(`open.feishu.cn`)を使います。組織が Lark の国際版テナントにある場合は、トランスポートをそちらに向けてください。 + +```dotenv +MULTICA_LARK_HTTP_BASE_URL=https://open.larksuite.com +``` + + +## 次に + +- [エージェント](/agents) — 各 Bot はちょうど 1 つのエージェントに紐づきます +- [Chat](/chat) — Bot の会話が Multica 内で対応するもの +- [イシュー](/issues) — `/issue` が作るもの +- [環境変数](/environment-variables) — セルフホスト構成の完全なリファレンス diff --git a/apps/docs/content/docs/lark-bot-integration.ko.mdx b/apps/docs/content/docs/lark-bot-integration.ko.mdx new file mode 100644 index 000000000..da9ae97bc --- /dev/null +++ b/apps/docs/content/docs/lark-bot-integration.ko.mdx @@ -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 계정에 바인딩되므로, 곧바로 봇과 대화를 시작할 수 있습니다. + + +QR 코드는 일회용이며 짧은 시간 후에 만료됩니다. 인증하기 전에 만료되면 **Scan again**을 클릭해 새 코드를 받으세요. + + +에이전트가 연결되면 **Bind to Lark** 버튼이 **Manage in Lark** 링크로 바뀝니다. 권한 범위를 조정하거나, 이름을 바꾸거나, 추가 권한을 요청해야 할 때 이 링크로 Lark에서 봇의 앱 페이지를 여세요 — 기존 봇이 고아가 되지 않도록 재스캔은 의도적으로 비활성화되어 있습니다. + +## 봇 사용하기 (멤버) + +### 첫 메시지: Lark 신원 바인딩하기 + +봇에게 처음 메시지를 보내면, **Lark 신원을 바인딩**하라는 카드로 답합니다. 링크를 탭하고 Multica에 로그인하면, 당신의 Lark 계정이 Multica 멤버십에 연결됩니다. 바로 이 단계가 에이전트로 하여금 당신을 대신해 행동하게 합니다 — 예를 들어 `/issue`는 이슈를 당신 이름으로 생성합니다. + + +**워크스페이스 멤버**만 봇을 사용할 수 있습니다. 멤버가 아니거나 신원 바인딩을 건너뛰면 봇은 응답하지 않으며, 메시지는 폐기됩니다(감사 목적으로 기록되며, 내용은 저장하지 않습니다). + + +### 대화와 `/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= + ``` + +2. API를 재시작하세요. 키가 설정되기 전까지 **설정 → Integrations**에는 "Lark integration not enabled" 안내가 표시되고, **Bind to Lark** 진입점은 숨겨진 채로 유지됩니다. + + +**국제판 테넌트.** 연동은 기본적으로 중국 본토 호스트(`open.feishu.cn`)를 사용합니다. 당신의 조직이 Lark 국제판 테넌트에 있다면, 전송 계층을 그쪽으로 가리키게 하세요. + +```dotenv +MULTICA_LARK_HTTP_BASE_URL=https://open.larksuite.com +``` + + +## 다음 + +- [에이전트](/agents) — 각 봇은 정확히 하나의 에이전트에 바인딩됩니다 +- [Chat](/chat) — 봇 대화가 Multica 내부에서 무엇에 대응하는지 +- [이슈](/issues) — `/issue`가 생성하는 것 +- [환경 변수](/environment-variables) — 전체 자체 호스팅 구성 참조 diff --git a/apps/docs/content/docs/lark-bot-integration.mdx b/apps/docs/content/docs/lark-bot-integration.mdx new file mode 100644 index 000000000..2fd881c28 --- /dev/null +++ b/apps/docs/content/docs/lark-bot-integration.mdx @@ -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 ` (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 diff --git a/apps/docs/content/docs/lark-bot-integration.zh.mdx b/apps/docs/content/docs/lark-bot-integration.zh.mdx new file mode 100644 index 000000000..0427efe77 --- /dev/null +++ b/apps/docs/content/docs/lark-bot-integration.zh.mdx @@ -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) —— 完整的自部署配置参考 diff --git a/apps/docs/content/docs/meta.ja.json b/apps/docs/content/docs/meta.ja.json index d11b05c6e..b82c667dd 100644 --- a/apps/docs/content/docs/meta.ja.json +++ b/apps/docs/content/docs/meta.ja.json @@ -31,6 +31,7 @@ "inbox", "---連携---", "github-integration", + "lark-bot-integration", "---セルフホスト & 運用---", "environment-variables", "auth-setup", diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index f9a46852d..f9e67d17e 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -31,6 +31,7 @@ "inbox", "---Integrations---", "github-integration", + "lark-bot-integration", "---Self-hosting & ops---", "environment-variables", "auth-setup", diff --git a/apps/docs/content/docs/meta.ko.json b/apps/docs/content/docs/meta.ko.json index 9ea0c0960..aa097377a 100644 --- a/apps/docs/content/docs/meta.ko.json +++ b/apps/docs/content/docs/meta.ko.json @@ -31,6 +31,7 @@ "inbox", "---연동---", "github-integration", + "lark-bot-integration", "---자체 호스팅 & 운영---", "environment-variables", "auth-setup", diff --git a/apps/docs/content/docs/meta.zh.json b/apps/docs/content/docs/meta.zh.json index 5af2eddcf..8516fc0dd 100644 --- a/apps/docs/content/docs/meta.zh.json +++ b/apps/docs/content/docs/meta.zh.json @@ -30,6 +30,7 @@ "inbox", "---集成---", "github-integration", + "lark-bot-integration", "---自部署运维---", "environment-variables", "auth-setup", diff --git a/packages/views/agents/components/agent-overview-pane.test.tsx b/packages/views/agents/components/agent-overview-pane.test.tsx index ba5e6fa67..86d46ea81 100644 --- a/packages/views/agents/components/agent-overview-pane.test.tsx +++ b/packages/views/agents/components/agent-overview-pane.test.tsx @@ -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(); + }); +}); diff --git a/packages/views/agents/components/agent-overview-pane.tsx b/packages/views/agents/components/agent-overview-pane.tsx index d4ce333e1..d850f4fbc 100644 --- a/packages/views/agents/components/agent-overview-pane.tsx +++ b/packages/views/agents/components/agent-overview-pane.tsx @@ -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 && ( diff --git a/packages/views/agents/components/tabs/integrations-tab.test.tsx b/packages/views/agents/components/tabs/integrations-tab.test.tsx new file mode 100644 index 000000000..42ef91dd0 --- /dev/null +++ b/packages/views/agents/components/tabs/integrations-tab.test.tsx @@ -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(); + }); +}); diff --git a/packages/views/agents/components/tabs/integrations-tab.tsx b/packages/views/agents/components/tabs/integrations-tab.tsx new file mode 100644 index 000000000..9a89cb7c9 --- /dev/null +++ b/packages/views/agents/components/tabs/integrations-tab.tsx @@ -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> + ); +} diff --git a/packages/views/locales/en/agents.json b/packages/views/locales/en/agents.json index f863c9326..a9ffc27f9 100644 --- a/packages/views/locales/en/agents.json +++ b/packages/views/locales/en/agents.json @@ -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", diff --git a/packages/views/locales/ja/agents.json b/packages/views/locales/ja/agents.json index f850cafff..44b05d2c7 100644 --- a/packages/views/locales/ja/agents.json +++ b/packages/views/locales/ja/agents.json @@ -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日", diff --git a/packages/views/locales/ko/agents.json b/packages/views/locales/ko/agents.json index d718b048f..caaac6d56 100644 --- a/packages/views/locales/ko/agents.json +++ b/packages/views/locales/ko/agents.json @@ -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일", diff --git a/packages/views/locales/zh-Hans/agents.json b/packages/views/locales/zh-Hans/agents.json index 88f3c0841..4bcc0e12e 100644 --- a/packages/views/locales/zh-Hans/agents.json +++ b/packages/views/locales/zh-Hans/agents.json @@ -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 天", diff --git a/packages/views/settings/components/lark-tab.test.tsx b/packages/views/settings/components/lark-tab.test.tsx index 14d93354a..cb6e8daa5 100644 --- a/packages/views/settings/components/lark-tab.test.tsx +++ b/packages/views/settings/components/lark-tab.test.tsx @@ -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 = [ { diff --git a/packages/views/settings/components/lark-tab.tsx b/packages/views/settings/components/lark-tab.tsx index fc70cb6f3..882d3c4e5 100644 --- a/packages/views/settings/components/lark-tab.tsx +++ b/packages/views/settings/components/lark-tab.tsx @@ -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