mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-01 03:19:13 +02:00
Compare commits
8 Commits
fix/commen
...
agent/j/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c90a4755de | ||
|
|
fed483fb7d | ||
|
|
e685ff343f | ||
|
|
a187665c5e | ||
|
|
11a3cf206b | ||
|
|
6e2d2c003c | ||
|
|
4fb6c0fb0e | ||
|
|
0c2f93bcd1 |
93
apps/docs/content/docs/channels.ja.mdx
Normal file
93
apps/docs/content/docs/channels.ja.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Chat 連携(channels)
|
||||
description: Multica がどのようにエージェントをチャットプラットフォームに接続するか——1 つのチャンネルエンジンと、Lark(飞书)および Slack 向けのプラットフォーム別アダプター——受信パイプライン、セッション、認可までを解説します。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
**チャンネル**は、Multica の[エージェント](/agents)をチャットプラットフォームに接続し、チームが普段やり取りしている場所でそのまま使えるようにします。現在チャンネルは 2 つあり——[Lark(飞书)](/lark-bot-integration)と [Slack](/slack-bot-integration)——どちらも**同じエンジン**で動いています。プラットフォームに依存しないコアと、薄いプラットフォーム別アダプターの組み合わせです。プラットフォームを追加するということは「アダプターを実装する」ことであり、「パイプラインを作り直す」ことではありません。
|
||||
|
||||
**インストール**は、それらを結びつける単位です。1 つの Bot が 1 つの `(workspace, agent)` に紐づきます。受信メッセージはまずインストールにルーティングされ、その後共有パイプラインを通り、エージェントの返信は同じチャットに送り返されます。
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
<Mermaid chart={`
|
||||
flowchart LR
|
||||
subgraph P["チャットプラットフォーム"]
|
||||
LK["Lark / 飞书"]
|
||||
SL["Slack"]
|
||||
end
|
||||
subgraph ENG["チャンネルエンジン(プラットフォーム非依存)"]
|
||||
direction TB
|
||||
SUP["Supervisor<br/>インストールごとに 1 本のライブ接続"]
|
||||
ROU["Router パイプライン:<br/>route → dedup → auth → session → trigger"]
|
||||
end
|
||||
LK -->|長時間接続| SUP
|
||||
SL -->|Socket Mode| SUP
|
||||
SUP -->|生イベント| ADP["プラットフォーム別アダプター<br/>変換 + ResolverSet"]
|
||||
ADP --> ROU
|
||||
ROU -->|エージェントタスク| RUN["デーモンがエージェントを実行"]
|
||||
RUN -->|返信| OUT["プラットフォーム別の送信<br/>(bot token → プラットフォーム API)"]
|
||||
OUT --> P
|
||||
`} />
|
||||
|
||||
## 受信パイプライン(共通)
|
||||
|
||||
すべての受信メッセージは——Lark でも Slack でも——エンジンの `Router` 内で同じ順序のステップを通ります。プラットフォームアダプターが供給するのはプラットフォーム別の部品(`ResolverSet`)だけで、ポリシーはエンジンの中にあります。
|
||||
|
||||
1. **インストールへのルーティング** —— イベントを `channel_installation`(→ ワークスペース + エージェント)に対応づけます。Lark は `app_id` でルーティングし、Slack はイベントに含まれる app id でルーティングします。
|
||||
2. **宛先フィルター** —— グループ/チャンネルでは、**Bot を @ メンション**したメッセージだけが先へ進みます。アイドル状態のグループの雑談は破棄されます(読み取られません)。
|
||||
3. **重複排除(dedup)** —— 2 フェーズの `(installation, message_id)` クレームにより、サーバーのレプリカをまたいでも厳密に 1 回だけ処理されることを保証します。
|
||||
4. **アイデンティティ + 認可** —— 送信者のプラットフォームユーザー id を Multica ユーザーに解決し([アカウントの紐づけ](#認可))、その上でワークスペースのメンバーシップを再チェックします。紐づいていない送信者には「アカウントを紐づける」プロンプトが返され、メンバーでない場合は破棄されます。
|
||||
5. **セッション** —— この会話に対応する[chat セッション](/chat)を見つけるか作成し、メッセージを追加します([セッション](#セッションとコンテキスト)を参照)。
|
||||
6. **トリガー** —— エージェントの[タスク](/tasks)をエンキューします。[デーモン](/daemon-runtimes)がエージェントを実行し、返信がチャットに送り返されます。
|
||||
|
||||
## セッションとコンテキスト
|
||||
|
||||
エージェントのコンテキストは、**chat セッションのトランスクリプト**——そのセッションに時間をかけて取り込まれてきたメッセージ——です。このトランスクリプトのモデルは共通です(すべてのチャンネルで共有されます)。プラットフォームごとに異なるのは、アダプターが組み立てる**セッション分離キー**です。
|
||||
|
||||
| プラットフォーム | 分離キー | 効果 |
|
||||
|---|---|---|
|
||||
| **Lark / 飞书** | チャット id | チャット/グループごとに 1 セッション——同じチャット内の連続したやり取りが 1 つのトランスクリプトに蓄積されます(複数ターンの記憶)。 |
|
||||
| **Slack** | DM: チャンネル/チャンネル: `channel + thread root` | 各 DM が 1 セッション。**各 @bot スレッドがそれぞれ独立したセッション**になるので、同じチャンネル内の 2 つのスレッドが混ざりません。 |
|
||||
|
||||
<Callout type="info">
|
||||
グループでは、**Bot を @ メンション**したメッセージだけが取り込まれます。どちらのチャンネルも、現時点ではチャンネルの他の(@ されていない)メッセージや過去ログを読まないため、エージェントは自分が宛先になっていないメッセージを見ることはありません。前後の履歴をコンテキストとして取得することは、今後の拡張として計画されています。
|
||||
</Callout>
|
||||
|
||||
## 認可
|
||||
|
||||
共有グループ内で Bot を守るために、2 つの独立したゲートがあります——どちらもエンジンであらゆるメッセージに対し、Lark と Slack で同一に適用されます。
|
||||
|
||||
- **アカウントの紐づけ(認証)** —— 送信者のプラットフォームユーザー id が Multica ユーザーにリンクされている必要があります。誰かが初めて Bot にメッセージを送ると、**自分自身の** Multica アカウントにアイデンティティを紐づけるための使い切りリンクを受け取ります。それまではエージェントは実行されません。
|
||||
- **ワークスペースのメンバーシップ(認可)** —— 紐づいた Multica ユーザーが、そのインストールのワークスペースのメンバーである必要があり、これはメッセージごとに再チェックされます。メンバーでない場合は黙って破棄されます。
|
||||
|
||||
そのため、Bot を公開チャンネルに追加しても安全です。アイデンティティを紐づけたワークスペースメンバーだけがエージェントを動かせ、各送信者は独立してチェックされます。ユーザー向けのプロンプトについては、各プラットフォームのページを参照してください。
|
||||
|
||||
## 2 つのチャンネル
|
||||
|
||||
<Callout type="info">
|
||||
**Lark(飞书) — スキャンしてインストール。** ワークスペースの admin が Lark アプリで QR をスキャンするだけでエージェントを紐づけられます。開発者コンソールでの操作は不要です。エージェントごとに 1 つの Bot。[Lark Bot 連携](/lark-bot-integration)を参照してください。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**Slack — 自分のアプリを持ち込む。** ワークスペースの admin が Slack アプリを作成し、自分の Slack ワークスペースにインストールして、その bot token + app-level token を Multica に貼り付けます。エージェントごとに専用の Slack アプリを持つため、1 つの Slack ワークスペース内で複数のエージェントがそれぞれ異なる Bot を持てます。マニフェストと手順は [Slack Bot 連携](/slack-bot-integration)を参照してください。
|
||||
</Callout>
|
||||
|
||||
## セルフホスト
|
||||
|
||||
各チャンネルは、**保存時の暗号化キーを設定するまでオフ**です(このキーは、各 Bot のトークンがデータベースに触れる前にそれを暗号化します)。
|
||||
|
||||
```dotenv
|
||||
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
Multica Cloud では両方ともすでに設定済みです。完全なリファレンスは[環境変数](/environment-variables)を参照してください。
|
||||
|
||||
## 次に
|
||||
|
||||
- [Lark Bot 連携](/lark-bot-integration) — スキャンしてインストール、DM / @ メンション / `/issue`
|
||||
- [Slack Bot 連携](/slack-bot-integration) — 自分のアプリを持ち込むセットアップ(マニフェスト + トークン)、エージェントごとの Bot
|
||||
- [エージェント](/agents) · [Chat](/chat) · [タスク](/tasks)
|
||||
93
apps/docs/content/docs/channels.ko.mdx
Normal file
93
apps/docs/content/docs/channels.ko.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Chat 연동 (channels)
|
||||
description: Multica가 에이전트를 채팅 플랫폼에 어떻게 연결하는지 — 하나의 channel 엔진과 Lark(飞书) 및 Slack을 위한 플랫폼별 어댑터 — 인바운드 파이프라인, 세션, 권한을 다룹니다.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
**channel**은 Multica [에이전트](/agents)를 채팅 플랫폼에 연결하여, 팀이 이미 대화하고 있는 곳에서 그 에이전트와 함께 일할 수 있게 합니다. 현재 두 개의 channel이 있습니다 — [Lark (飞书)](/lark-bot-integration)와 [Slack](/slack-bot-integration) — 그리고 둘 다 **같은 엔진** 위에서 동작합니다: 플랫폼 중립적인 코어에 얇은 플랫폼별 어댑터가 더해진 구조입니다. 플랫폼을 추가하는 일은 "어댑터를 구현하는 것"이지, "파이프라인을 다시 만드는 것"이 아닙니다.
|
||||
|
||||
**installation**은 이 모든 것을 하나로 묶는 단위입니다: 하나의 봇이 하나의 `(workspace, agent)`에 바인딩됩니다. 인바운드 메시지는 installation으로 라우팅된 다음 공유 파이프라인을 거치며, 에이전트의 답변은 동일한 채팅으로 돌아갑니다.
|
||||
|
||||
## 아키텍처
|
||||
|
||||
<Mermaid chart={`
|
||||
flowchart LR
|
||||
subgraph P["채팅 플랫폼"]
|
||||
LK["Lark / 飞书"]
|
||||
SL["Slack"]
|
||||
end
|
||||
subgraph ENG["Channel 엔진 (플랫폼 중립적)"]
|
||||
direction TB
|
||||
SUP["Supervisor<br/>installation당 하나의 활성 연결"]
|
||||
ROU["Router 파이프라인:<br/>route → dedup → auth → session → trigger"]
|
||||
end
|
||||
LK -->|long connection| SUP
|
||||
SL -->|Socket Mode| SUP
|
||||
SUP -->|raw event| ADP["플랫폼별 어댑터<br/>변환 + ResolverSet"]
|
||||
ADP --> ROU
|
||||
ROU -->|agent task| RUN["Daemon이 에이전트를 실행"]
|
||||
RUN -->|reply| OUT["플랫폼별 아웃바운드<br/>(bot token → platform API)"]
|
||||
OUT --> P
|
||||
`} />
|
||||
|
||||
## 인바운드 파이프라인 (공통)
|
||||
|
||||
모든 인바운드 메시지는 — Lark든 Slack이든 — 엔진의 `Router`에서 동일하게 정해진 순서의 단계를 거칩니다. 플랫폼 어댑터는 플랫폼별 조각(`ResolverSet`)만 공급하며, 정책은 엔진 안에 있습니다.
|
||||
|
||||
1. **Route to installation** — 이벤트를 `channel_installation`(→ workspace + agent)에 매핑합니다. Lark는 `app_id`로 라우팅하고, Slack은 이벤트에 실린 app id로 라우팅합니다.
|
||||
2. **Addressing filter** — 그룹/채널에서는 **봇을 @로 멘션한** 메시지만 계속 진행되며, 한가한 그룹 잡담은 폐기됩니다(읽지 않음).
|
||||
3. **Dedup** — 두 단계로 이루어진 `(installation, message_id)` 클레임이 서버 레플리카가 여러 개여도 정확히 한 번만 처리됨을 보장합니다.
|
||||
4. **Identity + authorization** — 보낸 사람의 플랫폼 사용자 id를 Multica 사용자([계정 바인딩](#권한))로 해석한 다음, 워크스페이스 멤버십을 다시 확인합니다. 바인딩되지 않은 발신자에게는 "계정을 연결하세요" 안내가 표시되고, 멤버가 아닌 사람은 폐기됩니다.
|
||||
5. **Session** — 이 대화에 대한 [chat 세션](/chat)을 찾거나 생성하고 메시지를 추가합니다([세션](#세션과-컨텍스트) 참조).
|
||||
6. **Trigger** — 에이전트 [task](/tasks)를 큐에 넣습니다. [daemon](/daemon-runtimes)이 에이전트를 실행하고 그 답변이 채팅으로 돌아갑니다.
|
||||
|
||||
## 세션과 컨텍스트
|
||||
|
||||
에이전트의 컨텍스트는 **chat 세션 트랜스크립트**입니다 — 시간이 지나며 그 세션에 수집된 메시지들입니다. 이 트랜스크립트 모델은 공통(모든 channel이 공유)입니다. 플랫폼마다 다른 것은 어댑터가 구성하는 **세션 격리 키**입니다:
|
||||
|
||||
| 플랫폼 | 격리 키 | 효과 |
|
||||
|---|---|---|
|
||||
| **Lark / 飞书** | 채팅 id | 채팅/그룹당 하나의 세션 — 같은 채팅에서의 연속된 턴이 하나의 트랜스크립트로 쌓입니다(멀티턴 메모리). |
|
||||
| **Slack** | DM: 채널; 채널: `channel + thread root` | 각 DM이 하나의 세션이고, **각 @bot 스레드가 자체 세션**이므로, 한 채널의 두 스레드는 섞이지 않습니다. |
|
||||
|
||||
<Callout type="info">
|
||||
그룹에서는 **봇을 @로 멘션한** 메시지만 수집됩니다. 어느 channel도 현재 채널의 다른(멘션되지 않은) 메시지나 스크롤백을 읽지 않으므로, 에이전트는 자신이 호출되지 않은 메시지를 보지 못합니다. 주변 기록을 컨텍스트로 가져오는 기능은 향후 개선 사항으로 계획되어 있습니다.
|
||||
</Callout>
|
||||
|
||||
## 권한
|
||||
|
||||
공유 그룹에서 봇을 보호하는 두 개의 독립적인 관문이 있으며 — 둘 다 모든 메시지에 대해 엔진에서, Lark와 Slack에 동일하게 적용됩니다:
|
||||
|
||||
- **계정 바인딩(인증)** — 보낸 사람의 플랫폼 사용자 id가 Multica 사용자에 연결되어 있어야 합니다. 누군가 봇에게 처음 메시지를 보내면 **자기 자신의** Multica 계정에 신원을 바인딩하는 일회용 링크를 받으며, 그 전까지는 어떤 에이전트도 실행되지 않습니다.
|
||||
- **워크스페이스 멤버십(권한)** — 바인딩된 Multica 사용자는 installation의 워크스페이스 멤버여야 하며, 이는 모든 메시지마다 다시 확인됩니다. 멤버가 아닌 사람은 조용히 폐기됩니다.
|
||||
|
||||
따라서 공개 채널에 봇을 추가해도 안전합니다: 신원을 바인딩한 워크스페이스 멤버만 에이전트를 움직일 수 있고, 각 발신자는 독립적으로 확인됩니다. 사용자에게 표시되는 안내는 플랫폼별 페이지를 참고하세요.
|
||||
|
||||
## 두 개의 channel
|
||||
|
||||
<Callout type="info">
|
||||
**Lark (飞书) — 스캔하여 설치.** 워크스페이스 admin이 Lark 앱으로 QR을 스캔하여 에이전트를 바인딩합니다. 개발자 콘솔 작업이 없습니다. 에이전트당 하나의 Bot. [Lark Bot 연동](/lark-bot-integration)을 참고하세요.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**Slack — 자체 앱 사용.** 워크스페이스 admin이 Slack 앱을 만들고, 자신의 Slack 워크스페이스에 설치한 다음, bot token과 app-level token을 Multica에 붙여넣습니다. 각 에이전트가 자체 Slack 앱을 갖기 때문에, 하나의 Slack 워크스페이스에서 여러 에이전트가 각각 별개의 봇을 가질 수 있습니다. 매니페스트와 단계별 설정은 [Slack Bot 연동](/slack-bot-integration)을 참고하세요.
|
||||
</Callout>
|
||||
|
||||
## 자체 호스팅
|
||||
|
||||
각 channel은 **at-rest 암호화 키를 설정하기 전까지 꺼져 있습니다**(이 키는 각 봇의 토큰이 데이터베이스에 닿기 전에 암호화합니다):
|
||||
|
||||
```dotenv
|
||||
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
Multica Cloud에서는 둘 다 이미 구성되어 있습니다. 전체 참조는 [환경 변수](/environment-variables)를 참고하세요.
|
||||
|
||||
## 다음
|
||||
|
||||
- [Lark Bot 연동](/lark-bot-integration) — 스캔하여 설치, DM / @-멘션 / `/issue`
|
||||
- [Slack Bot 연동](/slack-bot-integration) — 자체 앱 사용 설정(매니페스트 + 토큰), 에이전트별 봇
|
||||
- [에이전트](/agents) · [Chat](/chat) · [Tasks](/tasks)
|
||||
93
apps/docs/content/docs/channels.mdx
Normal file
93
apps/docs/content/docs/channels.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Chat integrations (channels)
|
||||
description: How Multica connects agents to chat platforms — one channel engine, per-platform adapters for Lark (飞书) and Slack — covering the inbound pipeline, sessions, and authorization.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
A **channel** connects a Multica [agent](/agents) to a chat platform so your team can work with it where they already talk. Today there are two channels — [Lark (飞书)](/lark-bot-integration) and [Slack](/slack-bot-integration) — and both run on the **same engine**: a platform-neutral core plus a thin per-platform adapter. Adding a platform is "implement the adapter," not "rebuild the pipeline."
|
||||
|
||||
An **installation** is the unit that ties it together: one bot bound to one `(workspace, agent)`. Inbound messages are routed to an installation, then through a shared pipeline; the agent's reply is sent back to the same chat.
|
||||
|
||||
## Architecture
|
||||
|
||||
<Mermaid chart={`
|
||||
flowchart LR
|
||||
subgraph P["Chat platforms"]
|
||||
LK["Lark / 飞书"]
|
||||
SL["Slack"]
|
||||
end
|
||||
subgraph ENG["Channel engine (platform-neutral)"]
|
||||
direction TB
|
||||
SUP["Supervisor<br/>one live connection per installation"]
|
||||
ROU["Router pipeline:<br/>route → dedup → auth → session → trigger"]
|
||||
end
|
||||
LK -->|long connection| SUP
|
||||
SL -->|Socket Mode| SUP
|
||||
SUP -->|raw event| ADP["Per-platform adapter<br/>translate + ResolverSet"]
|
||||
ADP --> ROU
|
||||
ROU -->|agent task| RUN["Daemon runs the agent"]
|
||||
RUN -->|reply| OUT["Per-platform outbound<br/>(bot token → platform API)"]
|
||||
OUT --> P
|
||||
`} />
|
||||
|
||||
## The inbound pipeline (generic)
|
||||
|
||||
Every inbound message — Lark or Slack — runs through the same ordered steps in the engine `Router`. A platform adapter only supplies the per-platform pieces (the `ResolverSet`); the policy lives in the engine.
|
||||
|
||||
1. **Route to installation** — map the event to a `channel_installation` (→ workspace + agent). Lark routes by `app_id`; Slack routes by the app id carried on the event.
|
||||
2. **Addressing filter** — in a group/channel, only messages that **@-mention the bot** continue; idle group chatter is dropped (not read).
|
||||
3. **Dedup** — a two-phase `(installation, message_id)` claim guarantees exactly-once processing, even across server replicas.
|
||||
4. **Identity + authorization** — resolve the sender's platform user id to a Multica user (the [account binding](#authorization)), then re-check workspace membership. Unbound senders get a "link your account" prompt; non-members are dropped.
|
||||
5. **Session** — find or create a [chat session](/chat) for this conversation and append the message (see [Sessions](#sessions-and-context)).
|
||||
6. **Trigger** — enqueue an agent [task](/tasks); a [daemon](/daemon-runtimes) runs the agent and the reply is sent back into the chat.
|
||||
|
||||
## Sessions and context
|
||||
|
||||
The agent's context is the **chat-session transcript** — the messages that have been ingested into that session over time. This transcript model is generic (shared by every channel). What differs per platform is the **session-isolation key** the adapter composes:
|
||||
|
||||
| Platform | Isolation key | Effect |
|
||||
|---|---|---|
|
||||
| **Lark / 飞书** | the chat id | One session per chat/group — consecutive turns in the same chat accumulate into one transcript (multi-turn memory). |
|
||||
| **Slack** | DM: the channel; channel: `channel + thread root` | Each DM is one session; **each @bot thread is its own session**, so two threads in one channel don't mix. |
|
||||
|
||||
<Callout type="info">
|
||||
In a group, only messages that **@-mention the bot** are ingested. Neither channel reads the channel's other (un-@'d) messages or scrollback today, so the agent won't see messages it wasn't addressed in. Fetching surrounding history as context is a planned enhancement.
|
||||
</Callout>
|
||||
|
||||
## Authorization
|
||||
|
||||
Two independent gates protect a bot in a shared group — both enforced in the engine for every message, identically for Lark and Slack:
|
||||
|
||||
- **Account binding (authentication)** — the sender's platform user id must be linked to a Multica user. The first time someone messages the bot they get a one-time link to bind their identity to **their own** Multica account; until then no agent runs.
|
||||
- **Workspace membership (authorization)** — the bound Multica user must be a member of the installation's workspace, re-checked on every message. Non-members are silently dropped.
|
||||
|
||||
So adding a bot to a public channel is safe: only workspace members who have bound their identity can drive the agent, and each sender is checked independently. See the per-platform pages for the user-facing prompts.
|
||||
|
||||
## The two channels
|
||||
|
||||
<Callout type="info">
|
||||
**Lark (飞书) — scan to install.** A workspace admin binds an agent by scanning a QR with the Lark app; no developer console steps. One Bot per agent. See [Lark Bot integration](/lark-bot-integration).
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**Slack — bring your own app.** A workspace admin creates a Slack app, installs it to their Slack workspace, and pastes its bot token + app-level token into Multica. Each agent gets its own Slack app, so several agents can each have a distinct bot in one Slack workspace. See [Slack Bot integration](/slack-bot-integration) for the manifest and step-by-step setup.
|
||||
</Callout>
|
||||
|
||||
## Self-host
|
||||
|
||||
Each channel is **off until you set its at-rest encryption key** (the key encrypts each bot's tokens before they touch the database):
|
||||
|
||||
```dotenv
|
||||
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
On Multica Cloud both are already configured. See [Environment variables](/environment-variables) for the full reference.
|
||||
|
||||
## Next
|
||||
|
||||
- [Lark Bot integration](/lark-bot-integration) — scan-to-install, DM / @-mention / `/issue`
|
||||
- [Slack Bot integration](/slack-bot-integration) — bring-your-own-app setup (manifest + tokens), per-agent bots
|
||||
- [Agents](/agents) · [Chat](/chat) · [Tasks](/tasks)
|
||||
93
apps/docs/content/docs/channels.zh.mdx
Normal file
93
apps/docs/content/docs/channels.zh.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: 聊天集成(channels)
|
||||
description: Multica 如何把智能体接入聊天平台——一个统一的 channel 引擎,加上针对飞书(Lark)和 Slack 的各平台适配器——涵盖入站流水线、会话与授权。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
**channel** 把一个 Multica [智能体](/agents)接入聊天平台,团队就能在他们日常沟通的地方直接使用它。目前有两个 channel——[Lark(飞书)](/lark-bot-integration) 和 [Slack](/slack-bot-integration)——两者都跑在**同一个引擎**上:一个平台无关的内核,加上一层很薄的各平台适配器。新增一个平台是「实现适配器」,而不是「重建流水线」。
|
||||
|
||||
**安装(installation)** 是把这一切串起来的单元:一个 Bot 绑定到一个 `(workspace, agent)`。入站消息被路由到某个安装,再经过共享的流水线;智能体的回复会被发回同一个聊天里。
|
||||
|
||||
## 架构
|
||||
|
||||
<Mermaid chart={`
|
||||
flowchart LR
|
||||
subgraph P["聊天平台"]
|
||||
LK["Lark / 飞书"]
|
||||
SL["Slack"]
|
||||
end
|
||||
subgraph ENG["Channel 引擎(平台无关)"]
|
||||
direction TB
|
||||
SUP["Supervisor<br/>每个安装一条实时连接"]
|
||||
ROU["路由流水线:<br/>路由 → 去重 → 鉴权 → 会话 → 触发"]
|
||||
end
|
||||
LK -->|长连接| SUP
|
||||
SL -->|Socket Mode| SUP
|
||||
SUP -->|原始事件| ADP["各平台适配器<br/>转换 + ResolverSet"]
|
||||
ADP --> ROU
|
||||
ROU -->|智能体任务| RUN["守护进程运行智能体"]
|
||||
RUN -->|回复| OUT["各平台出站<br/>(bot token → 平台 API)"]
|
||||
OUT --> P
|
||||
`} />
|
||||
|
||||
## 入站流水线(通用)
|
||||
|
||||
每一条入站消息——无论来自 Lark 还是 Slack——都会走引擎 `Router` 里同一套有序步骤。平台适配器只提供各平台特有的部分(即 `ResolverSet`);策略本身住在引擎里。
|
||||
|
||||
1. **路由到安装** —— 把事件映射到一个 `channel_installation`(→ workspace + agent)。Lark 按 `app_id` 路由;Slack 按事件携带的 app id 路由。
|
||||
2. **寻址过滤** —— 在群 / 频道里,只有 **@ 了 Bot** 的消息才会继续往下走;无关的群聊闲谈会被丢弃(不读取)。
|
||||
3. **去重** —— 一个两阶段的 `(installation, message_id)` 认领机制保证恰好处理一次,即便跨多个服务器副本也成立。
|
||||
4. **身份 + 授权** —— 把发送者的平台用户 id 解析成一个 Multica 用户(即[账号绑定](#账号绑定)),然后再次校验 workspace 成员身份。未绑定的发送者会收到一条「绑定你的账号」提示;非成员会被丢弃。
|
||||
5. **会话** —— 为这段对话找到或创建一个 [chat 会话](/chat),并把消息追加进去(见[会话](#会话与上下文))。
|
||||
6. **触发** —— 入队一个智能体[任务](/tasks);一个[守护进程](/daemon-runtimes)运行智能体,回复会被发回聊天里。
|
||||
|
||||
## 会话与上下文
|
||||
|
||||
智能体的上下文就是**这段 chat 会话的对话记录**——也就是随时间被纳入该会话的那些消息。这套对话记录模型是通用的(每个 channel 共用)。各平台不同的地方在于适配器拼出来的**会话隔离键**:
|
||||
|
||||
| 平台 | 隔离键 | 效果 |
|
||||
|---|---|---|
|
||||
| **Lark / 飞书** | 聊天 id | 每个聊天 / 群一个会话——同一个聊天里连续的几轮会累积成一份对话记录(多轮记忆)。 |
|
||||
| **Slack** | 私聊:频道;频道:`channel + thread root` | 每段私聊是一个会话;**每个 @bot 的 thread 是它自己的会话**,所以同一个频道里的两个 thread 不会混在一起。 |
|
||||
|
||||
<Callout type="info">
|
||||
在群里,只有 **@ 了 Bot** 的消息才会被纳入。目前两个 channel 都不会读取频道里其他(没 @ 的)消息或历史滚动记录,所以智能体看不到那些没有点名它的消息。把周边历史作为上下文拉取进来,是计划中的增强功能。
|
||||
</Callout>
|
||||
|
||||
## 账号绑定
|
||||
|
||||
在共享群里,有两道相互独立的关卡保护着 Bot——两者都在引擎里对每一条消息强制执行,且 Lark 和 Slack 一视同仁:
|
||||
|
||||
- **账号绑定(认证)** —— 发送者的平台用户 id 必须关联到一个 Multica 用户。某人第一次给 Bot 发消息时,会拿到一个一次性链接,把自己的身份绑定到**他自己的** Multica 账号;在那之前不会有任何智能体运行。
|
||||
- **Workspace 成员身份(授权)** —— 绑定后的 Multica 用户必须是该安装所属 workspace 的成员,每条消息都会重新校验。非成员会被静默丢弃。
|
||||
|
||||
所以把 Bot 加进一个公开频道是安全的:只有已绑定身份的 workspace 成员才能驱动智能体,而且每个发送者都会被独立校验。面向用户的提示文案请见各平台的页面。
|
||||
|
||||
## 两个 channel
|
||||
|
||||
<Callout type="info">
|
||||
**Lark(飞书)—— 扫码安装。** workspace 管理员用飞书 App 扫一个二维码就能绑定一个智能体;无需任何开发者后台步骤。一个智能体一个 Bot。见 [Lark Bot 接入](/lark-bot-integration)。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**Slack —— 自带应用。** workspace 管理员创建一个 Slack app,把它安装到自己的 Slack workspace,再把它的 bot token + app-level token 粘贴进 Multica。每个智能体都有自己的 Slack app,所以多个智能体可以在同一个 Slack workspace 里各自拥有一个独立的 Bot。manifest 和分步设置见 [Slack Bot 接入](/slack-bot-integration)。
|
||||
</Callout>
|
||||
|
||||
## 自部署
|
||||
|
||||
每个 channel 在**你设置好它的静态加密密钥之前都是关闭的**(这个密钥会在每个 Bot 的 token 落库之前对其加密):
|
||||
|
||||
```dotenv
|
||||
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
在 Multica Cloud 上两者都已配置好。完整参考见[环境变量](/environment-variables)。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Lark Bot 接入](/lark-bot-integration) —— 扫码安装,私聊 / @ 提及 / `/issue`
|
||||
- [Slack Bot 接入](/slack-bot-integration) —— 自带应用的设置(manifest + token),每个智能体一个 Bot
|
||||
- [智能体](/agents) · [Chat](/chat) · [任务](/tasks)
|
||||
@@ -30,8 +30,10 @@
|
||||
"---インボックス---",
|
||||
"inbox",
|
||||
"---連携---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---セルフホスト & 運用---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
"---Inbox---",
|
||||
"inbox",
|
||||
"---Integrations---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---Self-hosting & ops---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
"---인박스---",
|
||||
"inbox",
|
||||
"---연동---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---자체 호스팅 & 운영---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
"---收件箱---",
|
||||
"inbox",
|
||||
"---集成---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---自部署运维---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
179
apps/docs/content/docs/slack-bot-integration.ja.mdx
Normal file
179
apps/docs/content/docs/slack-bot-integration.ja.mdx
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
title: Slack Bot 連携
|
||||
description: Multica エージェントをあなた自身の Slack アプリに接続します——マニフェストからアプリを作成し、インストールして、bot トークンと app-level トークンを貼り付ければ、Slack の中から @ メンションしたり、DM したり、/issue と入力したりできます。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
任意の[エージェント](/agents)を Slack Bot に接続すれば、チームは Slack の中から直接それを使えます——Bot に DM したり、チャンネルで @ メンションしたり、`/issue` と入力してアプリを開かずに [Multica イシュー](/issues)を起票したりできます。
|
||||
|
||||
Slack は**自分のアプリを持ち込む(BYO: bring-your-own-app)**モデルを採用しています。ワークスペースの admin が Slack アプリを作成し、自分の Slack ワークスペースにインストールして、そのトークンを Multica に貼り付けます。エージェントごとに**専用の** Slack アプリを持つため、同じ Slack ワークスペース内で複数のエージェントがそれぞれ別個に @ メンションできる異なる Bot を持てます。(これは紐づけがスキャンしてインストールするフローである [Lark](/lark-bot-integration) とは異なります。)
|
||||
|
||||
セットアップ全体は以下のとおりで、所要時間は約 5 分です。最終的に、Multica に貼り付ける 2 つのトークンが得られます。
|
||||
|
||||
- **Bot トークン** —— `xoxb-` で始まります
|
||||
- **App-level トークン** —— `xapp-` で始まります
|
||||
|
||||
## Slack アプリをセットアップする
|
||||
|
||||
### 1. マニフェストからアプリを作成する
|
||||
|
||||
1. [https://api.slack.com/apps](https://api.slack.com/apps) を開き、**Create New App** をクリックします。
|
||||
2. **From a manifest** を選びます。
|
||||
3. アプリをインストールする Slack ワークスペースを選びます。
|
||||
4. **YAML** タブに切り替え、下記のマニフェストを貼り付けて、内容を確認しアプリを作成します。
|
||||
|
||||
```yaml
|
||||
display_information:
|
||||
name: Multica
|
||||
features:
|
||||
app_home:
|
||||
home_tab_enabled: false
|
||||
messages_tab_enabled: true
|
||||
messages_tab_read_only_enabled: false
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- app_mentions:read
|
||||
- channels:history
|
||||
- groups:history
|
||||
- im:history
|
||||
- mpim:history
|
||||
- chat:write
|
||||
- users:read
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
- app_mention
|
||||
- message.im
|
||||
- message.channels
|
||||
- message.groups
|
||||
- message.mpim
|
||||
interactivity:
|
||||
is_enabled: false
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: true
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
このマニフェストは Multica が必要とするものをすべて設定するので、手作業で何かを設定する必要はありません。
|
||||
|
||||
| セクション | なぜそこにあるか |
|
||||
|---|---|
|
||||
| `app_home.messages_tab_enabled: true` | メンバーが Bot を開いて **DM** できるようにします。これがないと、Bot に直接メッセージを送れません。 |
|
||||
| `bot_user` | @ メンションされ、返信を投稿する Bot のアイデンティティを作成します。 |
|
||||
| `chat:write` | エージェントの返信を Slack に投稿し返します。 |
|
||||
| `app_mentions:read` + `app_mention` イベント | チャンネルでの @ メンションを受け取ります。 |
|
||||
| `im:history` + `message.im` | Bot への **DM** を受け取ります(すべての DM メッセージが読み取られます)。 |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + 対応する `message.*` イベント | パブリックチャンネル、プライベートチャンネル、グループ DM のメッセージを受け取ります。これらの中では、Bot は自分を **@ メンション**したメッセージにのみ反応します。 |
|
||||
| `users:read` | Multica が(`bots.info` を介して)あなたの 2 つのトークンが同じアプリのものであることを検証するために必要です。 |
|
||||
| `socket_mode_enabled: true` | Bot は Socket Mode 経由で外向きに接続します——**公開 URL/リクエスト URL は不要**です。 |
|
||||
| `interactivity.is_enabled: false` | Multica のプロンプトはボタンではなくプレーンなリンクなので、インタラクティビティは不要です。 |
|
||||
|
||||
**OAuth リダイレクト URL はありません**。BYO は OAuth を使わないからです。
|
||||
|
||||
<Callout type="warning">
|
||||
スコープに `users:read` を残しておいてください。接続時に Multica は `bots.info` を呼び出し、bot トークンと app-level トークンが**同じ**アプリのものであることを確認します——この呼び出しには `users:read` が必要です。これがないと、**Connect** は失敗します。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
Slack で特定の名前を表示したいですか? 作成前に `display_information.name` と `features.bot_user.display_name`(たとえばエージェントの名前に)を変更するか、あとで **App Home** で編集してください。Slack は Bot をその **bot display name** で表示しますが、これはアプリ名と異なる場合があります。
|
||||
</Callout>
|
||||
|
||||
### 2. アプリをインストールして Bot トークンをコピーする
|
||||
|
||||
1. アプリの左ナビで **Install App**(または **OAuth & Permissions**)を開きます。
|
||||
2. **Install to Workspace** をクリックして承認します。
|
||||
3. **Bot User OAuth Token** をコピーします——`xoxb-` で始まります。これがあなたの **Bot トークン**です。
|
||||
|
||||
### 3. App-level トークンを作成する
|
||||
|
||||
app-level トークンは Socket Mode 接続を認可します。これはコンソールでしか作成できません(OAuth の一部ではありません)。
|
||||
|
||||
1. **Basic Information → App-Level Tokens** を開き、**Generate Token and Scopes** をクリックします。
|
||||
2. 任意の名前を付けます。
|
||||
3. **Add Scope** をクリックし、リストから **`connections:write`** を選びます(これはピッカーなので、入力せずに選択してください)。
|
||||
4. **Generate** をクリックし、トークンをコピーします——`xapp-` で始まります。これがあなたの **App-level トークン**です。
|
||||
|
||||
### 4. Multica で接続する
|
||||
|
||||
1. **Agents → _あなたのエージェント_** からそのエージェントを開き、**Integrations** タブ(または左サイドバーの **Integrations** 区画)を開きます。
|
||||
2. **Connect Slack** をクリックします。
|
||||
3. **Bot トークン**(`xoxb-`)と **App-level トークン**(`xapp-`)を貼り付け、**Connect** をクリックします。
|
||||
4. エージェントに **Connected to Slack** と表示されます。Bot はこれで、自身の Socket Mode 接続を通じて待ち受けています。
|
||||
|
||||
<Callout type="warning">
|
||||
2 つのトークンは**同じ** Slack アプリのものでなければならず、そのアプリはちょうど **1 つ**のエージェントに対応します。すでに別のエージェントやワークスペースに接続されているアプリを接続しようとすると拒否されます。アプリを別のエージェントへ移すには、まず切断してください。**新しい**アプリでエージェントを再接続すると、そのエージェントの Bot がその場で更新されます。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**複数のエージェント**でこれを設定しますか? フロー全体をエージェントごとに 1 回ずつ繰り返してください——各エージェントが専用の Slack アプリと専用のトークンのペアを持ち、Slack ワークスペース内で別々の Bot として表示されます。
|
||||
</Callout>
|
||||
|
||||
## この連携でできること
|
||||
|
||||
| 場所 | 動作 |
|
||||
|---|---|
|
||||
| **エージェント → Integrations** | owner と admin には **Connect Slack** が表示され、接続すると **Connected to Slack** バッジと **Disconnect** コントロールに切り替わります。 |
|
||||
| **Bot に DM** | ワークスペースメンバーが Bot に直接メッセージを送ります。会話はそのエージェントとの Multica [chat](/chat) セッションになり、すべての DM メッセージが読み取られます。 |
|
||||
| **チャンネルで @ メンション** | Bot を招待し(`/invite @your-bot`)、@ メンションします。読み取られるのはメンションしたメッセージだけで、Bot はチャンネル全体を聞いているわけではありません。各 @bot **スレッド**がそれぞれ独立したセッションになります。 |
|
||||
| **`/issue` コマンド** | `/issue <タイトル>`(続く行に本文を足してもよい)でメッセージを始めると、ワークスペースに新しい Multica イシューが作られ、あなたの名義になります。 |
|
||||
| **返信** | エージェントの回答は、同じ DM またはスレッドに投稿し返されます。 |
|
||||
|
||||
## Bot を使う(メンバー)
|
||||
|
||||
### 最初のメッセージ:アカウントを紐づける
|
||||
|
||||
初めて Bot を @ メンションするか DM すると、Bot は **アカウントを紐づける** プロンプトで返信します。リンクをタップして Multica にサインインすると、あなたの Slack アイデンティティがあなたの Multica メンバーシップに紐づきます——これによって、エージェントがあなたとして振る舞えるようになります(たとえば `/issue` はあなたの名義でイシューを起票します)。このリンクは使い切りで、約 15 分で失効します。新しいものが必要なら、もう一度 Bot にメッセージを送るだけです。
|
||||
|
||||
<Callout type="warning">
|
||||
Bot を使えるのは **ワークスペースのメンバー** だけです。メンバーでない場合や、アイデンティティの紐づけをスキップした場合、Bot は実行されません——あなたのメッセージは破棄されます(内容は保存せず、監査のために記録されます)。
|
||||
</Callout>
|
||||
|
||||
### 対話と `/issue`
|
||||
|
||||
- **チャンネルで** —— Bot は自動では参加しません。一度 `/invite @your-bot` を実行してから、`@your-bot <あなたのメッセージ>` とします。フォローアップのたびに再度メンションしてください(Bot は自分をメンションしたメッセージだけを読みます)。
|
||||
- **DM で** —— Slack サイドバーの **Apps** 区画から Bot を開いて直接メッセージを送ります。メンションは不要です。
|
||||
- **イシューを起票する** —— `/issue Fix the login redirect` と送ります。タイトルの後ろに行を足せば、それが説明になります。
|
||||
|
||||
## 管理と切断
|
||||
|
||||
ワークスペース全体の管理は **Settings → Integrations** にあります。
|
||||
|
||||
- **Connected bots** は、ワークスペース内のすべての Bot と、それぞれが紐づくエージェントを一覧表示します(すべてのメンバーから見えます)。
|
||||
- **Disconnect** は **owner / admin 専用** です。切断すると Bot は Slack メッセージの受信を停止し、その接続が破棄されます。インストール記録は監査のために保持され、あとで再接続できます。
|
||||
|
||||
## 権限
|
||||
|
||||
- **接続 / 切断** にはワークスペースの **owner** または **admin** が必要です。
|
||||
- **Bot との対話** には、Slack アイデンティティを紐づけたワークスペースメンバーであることが必要です。それ以外の人は一律に破棄されます。
|
||||
- 破棄されたメッセージの本文が保存されることはありません——監査のために破棄理由だけが記録されます。
|
||||
|
||||
## セルフホストのセットアップ
|
||||
|
||||
Multica Cloud では連携はすでに利用可能です——このセクションは飛ばしてください。
|
||||
|
||||
セルフホストの場合、Slack は**保存時の暗号化キーを設定するまでオフ**です。このキーは、各アプリの bot トークン + app-level トークンがデータベースに触れる前にそれを暗号化します。BYO には OAuth の client id/secret は**不要**で、デプロイレベルの app トークンも**不要**です——各インストールは admin が貼り付けたトークンを使います。
|
||||
|
||||
1. 32 バイトのキーを生成し、API サーバーに設定します。
|
||||
|
||||
```dotenv
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
たとえば: `openssl rand -base64 32`。
|
||||
|
||||
2. API を再起動します。キーを設定するまで、**Settings → Integrations** には「Slack integration not enabled」という通知が表示され、**Connect Slack** のエントリポイントは非表示のままになります。
|
||||
|
||||
<Callout type="info">
|
||||
キーはちょうど 32 バイトにデコードされなければなりません——`openssl rand -base64 32` はそれを満たします。これは長く使い続けるシークレットとして扱ってください。ローテーションしたり紛失したりすると、すでに保存済みのトークンが復号できなくなり、すべての Bot を再接続せざるを得なくなります。「アカウントを紐づける」リンクは、Web アプリの URL(`MULTICA_APP_URL`、未設定時は `FRONTEND_ORIGIN` にフォールバック)から生成されます。通常のデプロイではこれは既に設定されているため、追加で設定するものはありません。
|
||||
</Callout>
|
||||
|
||||
## 次に
|
||||
|
||||
- [Chat 連携](/channels) — チャンネルエンジン、セッション、認可の仕組み
|
||||
- [エージェント](/agents) · [Chat](/chat) · [イシュー](/issues)
|
||||
- [環境変数](/environment-variables) — セルフホスト構成の完全なリファレンス
|
||||
179
apps/docs/content/docs/slack-bot-integration.ko.mdx
Normal file
179
apps/docs/content/docs/slack-bot-integration.ko.mdx
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
title: Slack Bot 연동
|
||||
description: Multica 에이전트를 자체 Slack 앱에 연결하세요 — 매니페스트로 앱을 만들고, 설치한 다음, bot + app-level 토큰을 붙여넣고, Slack 안에서 @로 멘션하거나 DM하거나 /issue를 입력하세요.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
아무 [에이전트](/agents)나 Slack 봇에 연결하면, 팀이 Slack 안에서 바로 그 에이전트와 함께 일할 수 있습니다 — 봇에게 DM을 보내거나, 채널에서 `@`로 멘션하거나, `/issue`를 입력해 앱을 열지 않고도 [Multica 이슈](/issues)를 생성하세요.
|
||||
|
||||
Slack은 **자체 앱 사용(BYO)** 모델을 따릅니다: 워크스페이스 admin이 Slack 앱을 만들고, 자신의 Slack 워크스페이스에 설치한 다음, 토큰을 Multica에 붙여넣습니다. 각 에이전트가 **자체** Slack 앱을 갖습니다 — 그래서 하나의 Slack 워크스페이스 안에서 여러 에이전트가 각각 별개로 `@`로 멘션할 수 있는 봇을 가질 수 있습니다. (바인딩이 스캔하여 설치하는 방식인 [Lark](/lark-bot-integration)와는 다릅니다.)
|
||||
|
||||
전체 설정은 아래에 있으며 약 5분이 걸립니다. 마지막에는 Multica에 붙여넣을 두 개의 토큰을 얻게 됩니다:
|
||||
|
||||
- **Bot token** — `xoxb-`로 시작
|
||||
- **App-level token** — `xapp-`로 시작
|
||||
|
||||
## Slack 앱 설정하기
|
||||
|
||||
### 1. 매니페스트로 앱 만들기
|
||||
|
||||
1. [https://api.slack.com/apps](https://api.slack.com/apps)로 이동해 **Create New App**을 클릭합니다.
|
||||
2. **From a manifest**를 선택합니다.
|
||||
3. 앱을 설치할 Slack 워크스페이스를 고릅니다.
|
||||
4. **YAML** 탭으로 전환해 아래 매니페스트를 붙여넣고, 검토한 뒤 앱을 생성합니다.
|
||||
|
||||
```yaml
|
||||
display_information:
|
||||
name: Multica
|
||||
features:
|
||||
app_home:
|
||||
home_tab_enabled: false
|
||||
messages_tab_enabled: true
|
||||
messages_tab_read_only_enabled: false
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- app_mentions:read
|
||||
- channels:history
|
||||
- groups:history
|
||||
- im:history
|
||||
- mpim:history
|
||||
- chat:write
|
||||
- users:read
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
- app_mention
|
||||
- message.im
|
||||
- message.channels
|
||||
- message.groups
|
||||
- message.mpim
|
||||
interactivity:
|
||||
is_enabled: false
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: true
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
이 매니페스트는 Multica에 필요한 모든 것을 구성하므로, 직접 손으로 설정할 것이 없습니다:
|
||||
|
||||
| 섹션 | 이유 |
|
||||
|---|---|
|
||||
| `app_home.messages_tab_enabled: true` | 멤버가 봇을 열어 **DM**할 수 있게 합니다. 이것이 없으면 봇에게 직접 메시지를 보낼 수 없습니다. |
|
||||
| `bot_user` | `@`로 멘션되고 답변을 게시하는 봇 신원을 생성합니다. |
|
||||
| `chat:write` | 에이전트의 답변을 Slack으로 다시 게시합니다. |
|
||||
| `app_mentions:read` + `app_mention` 이벤트 | 채널에서 `@`-멘션을 받습니다. |
|
||||
| `im:history` + `message.im` | 봇에게 보내는 **DM**을 받습니다(모든 DM 메시지를 읽습니다). |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + 대응하는 `message.*` 이벤트 | 공개 채널, 비공개 채널, 그룹 DM의 메시지를 받습니다. 이런 곳에서 봇은 자신을 **@로 멘션한** 메시지에만 반응합니다. |
|
||||
| `users:read` | Multica가 두 토큰이 같은 앱에 속하는지 (`bots.info`를 통해) 확인하는 데 필요합니다. |
|
||||
| `socket_mode_enabled: true` | 봇이 Socket Mode로 밖으로 연결합니다 — **공개 URL / request URL이 필요 없습니다**. |
|
||||
| `interactivity.is_enabled: false` | Multica의 안내는 버튼이 아니라 일반 링크라서, interactivity가 필요 없습니다. |
|
||||
|
||||
**OAuth redirect URL은 없습니다.** BYO는 OAuth를 사용하지 않기 때문입니다.
|
||||
|
||||
<Callout type="warning">
|
||||
스코프에 `users:read`를 유지하세요. 연결 시점에 Multica는 bot token과 app-level token이 **같은** 앱에서 왔는지 확인하기 위해 `bots.info`를 호출하는데 — 이 호출에는 `users:read`가 필요합니다. 이것이 없으면 **Connect**가 실패합니다.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
Slack에서 특정 이름을 쓰고 싶나요? 생성하기 전에 `display_information.name`과 `features.bot_user.display_name`을 (예: 에이전트 이름으로) 변경하거나, 나중에 **App Home**에서 편집하세요. Slack은 봇을 **bot display name**으로 표시하며, 이는 앱 이름과 다를 수 있습니다.
|
||||
</Callout>
|
||||
|
||||
### 2. 앱 설치하고 Bot token 복사하기
|
||||
|
||||
1. 앱의 왼쪽 내비게이션에서 **Install App**(또는 **OAuth & Permissions**)을 엽니다.
|
||||
2. **Install to Workspace**를 클릭하고 승인합니다.
|
||||
3. **Bot User OAuth Token**을 복사합니다 — `xoxb-`로 시작합니다. 이것이 당신의 **Bot token**입니다.
|
||||
|
||||
### 3. App-level token 생성하기
|
||||
|
||||
app-level token은 Socket Mode 연결을 인가합니다. 콘솔에서만 생성할 수 있습니다(OAuth의 일부가 아닙니다).
|
||||
|
||||
1. **Basic Information → App-Level Tokens**를 열고 **Generate Token and Scopes**를 클릭합니다.
|
||||
2. 아무 이름이나 지정합니다.
|
||||
3. **Add Scope**를 클릭하고 목록에서 **`connections:write`**를 고릅니다(선택기이므로 — 입력하지 말고 선택하세요).
|
||||
4. **Generate**를 클릭한 다음 토큰을 복사합니다 — `xapp-`로 시작합니다. 이것이 당신의 **App-level token**입니다.
|
||||
|
||||
### 4. Multica에서 연결하기
|
||||
|
||||
1. **Agents → _당신의 에이전트_** → **Integrations** 탭(또는 왼쪽 사이드바의 **Integrations** 섹션)에서 에이전트를 엽니다.
|
||||
2. **Connect Slack**을 클릭합니다.
|
||||
3. **Bot token**(`xoxb-`)과 **App-level token**(`xapp-`)을 붙여넣은 다음 **Connect**를 클릭합니다.
|
||||
4. 에이전트에 **Connected to Slack**이 표시됩니다. 봇은 이제 자체 Socket Mode 연결로 수신 대기합니다.
|
||||
|
||||
<Callout type="warning">
|
||||
두 토큰은 **같은** Slack 앱에서 와야 하며, 그 앱은 정확히 **하나의** 에이전트에 매핑됩니다. 이미 다른 에이전트나 워크스페이스에 연결된 앱을 연결하는 것은 거부됩니다. 앱을 다른 에이전트로 옮기려면 먼저 연결을 해제하세요. **새** 앱으로 에이전트를 다시 연결하면 그 에이전트의 봇이 그 자리에서 갱신됩니다.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**여러 에이전트**에 설정하나요? 에이전트당 전체 과정을 한 번씩 반복하세요 — 각 에이전트가 자체 Slack 앱과 자체 토큰 한 쌍을 가지며, Slack 워크스페이스에 별개의 봇으로 나타납니다.
|
||||
</Callout>
|
||||
|
||||
## 연동이 하는 일
|
||||
|
||||
| 위치 | 동작 |
|
||||
|---|---|
|
||||
| **Agent → Integrations** | owner와 admin에게는 **Connect Slack**이 보이며, 연결되면 **Connected to Slack** 배지와 **Disconnect** 컨트롤로 바뀝니다. |
|
||||
| **봇에게 DM** | 워크스페이스 멤버가 봇에게 직접 메시지를 보냅니다. 그 대화는 에이전트와의 Multica [chat](/chat) 세션이 되며, 모든 DM 메시지를 읽습니다. |
|
||||
| **채널에서 `@`-멘션** | 봇을 초대(`/invite @your-bot`)하고 `@`로 멘션하세요. 멘션한 메시지만 읽으며, 봇이 채널 전체를 듣지는 않습니다. 각 @bot **스레드**가 자체 세션입니다. |
|
||||
| **`/issue` 명령** | `/issue <제목>`(다음 줄에 본문 추가 가능)으로 메시지를 시작하면 워크스페이스에 새 Multica 이슈가 생성되고, 당신 이름으로 귀속됩니다. |
|
||||
| **답변** | 에이전트의 답변은 같은 DM 또는 스레드로 다시 게시됩니다. |
|
||||
|
||||
## 봇 사용하기 (멤버)
|
||||
|
||||
### 첫 메시지: 계정 연결하기
|
||||
|
||||
봇을 처음 `@`로 멘션하거나 DM하면, **계정을 연결하라**는 안내로 답합니다. 링크를 탭하고 Multica에 로그인하면, 당신의 Slack 신원이 Multica 멤버십에 바인딩됩니다 — 바로 이 단계가 에이전트로 하여금 당신을 대신해 행동하게 합니다(예: `/issue`는 당신 이름으로 이슈를 생성합니다). 이 링크는 일회용이며 약 15분 후에 만료됩니다. 새 링크가 필요하면 봇에게 다시 메시지를 보내세요.
|
||||
|
||||
<Callout type="warning">
|
||||
**워크스페이스 멤버**만 봇을 사용할 수 있습니다. 멤버가 아니거나 신원 연결을 건너뛰면 봇은 실행되지 않으며, 메시지는 폐기됩니다(감사 목적으로 기록되며, 내용은 저장하지 않습니다).
|
||||
</Callout>
|
||||
|
||||
### 대화와 `/issue`
|
||||
|
||||
- **채널에서** — 봇은 자동으로 참여하지 않습니다. `/invite @your-bot`을 한 번 실행한 다음 `@your-bot <당신의 메시지>`로 보내세요. 후속 메시지마다 다시 멘션하세요(봇은 자신을 멘션한 메시지만 읽습니다).
|
||||
- **DM에서** — Slack 사이드바의 **Apps** 섹션에서 봇을 열고 직접 메시지를 보내세요. 멘션이 필요 없습니다.
|
||||
- **이슈 생성** — `/issue Fix the login redirect`를 보내세요. 제목 뒤에 줄을 더 추가하면 설명이 됩니다.
|
||||
|
||||
## 관리 및 연결 해제
|
||||
|
||||
워크스페이스 전체 관리는 **Settings → Integrations**에 있습니다:
|
||||
|
||||
- **Connected bots**는 워크스페이스 내 모든 봇과 각 봇이 바인딩된 에이전트를 나열합니다(모든 멤버에게 보입니다).
|
||||
- **Disconnect**는 **owner / admin 전용**입니다. 봇이 Slack 메시지 수신을 멈추고 연결이 해체됩니다. 설치 기록은 감사용으로 유지되며, 이후 다시 연결할 수 있습니다.
|
||||
|
||||
## 권한
|
||||
|
||||
- **연결 / 연결 해제**에는 워크스페이스 **owner** 또는 **admin**이 필요합니다.
|
||||
- **봇과 대화하기**에는 Slack 신원이 연결된 워크스페이스 멤버여야 합니다. 그 외의 사람은 모두 폐기됩니다.
|
||||
- 폐기된 메시지의 본문은 절대 저장되지 않으며 — 감사용 폐기 사유만 기록됩니다.
|
||||
|
||||
## 자체 호스팅 설정
|
||||
|
||||
Multica Cloud에서는 연동이 이미 사용 가능합니다 — 이 섹션은 건너뛰세요.
|
||||
|
||||
자체 호스팅의 경우, Slack은 **at-rest 암호화 키를 설정하기 전까지 꺼져 있습니다**. 이 키는 각 앱의 bot + app-level 토큰이 데이터베이스에 닿기 전에 암호화합니다. BYO에는 OAuth client id/secret이 **필요 없고**, 배포 수준의 app token도 **필요 없습니다** — 각 installation은 admin이 붙여넣은 토큰을 사용합니다.
|
||||
|
||||
1. 32바이트 키를 생성해 API 서버에 설정합니다:
|
||||
|
||||
```dotenv
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
예를 들면: `openssl rand -base64 32`.
|
||||
|
||||
2. API를 재시작하세요. 키가 설정되기 전까지 **Settings → Integrations**에는 "Slack integration not enabled" 안내가 표시되고, **Connect Slack** 진입점은 숨겨진 채로 유지됩니다.
|
||||
|
||||
<Callout type="info">
|
||||
키는 정확히 32바이트로 디코딩되어야 하며 — `openssl rand -base64 32`가 이를 충족합니다. 오래 유지되는 시크릿으로 다루세요: 키를 회전하거나 잃으면 이미 저장된 토큰을 복호화할 수 없게 되어, 모든 봇이 다시 연결해야 합니다. "계정을 연결하세요" 링크는 웹 앱 URL(`MULTICA_APP_URL`, 없으면 `FRONTEND_ORIGIN`으로 폴백)에서 만들어집니다. 일반적인 배포에서는 이미 설정되어 있으므로 추가로 구성할 것은 없습니다.
|
||||
</Callout>
|
||||
|
||||
## 다음
|
||||
|
||||
- [Chat 연동](/channels) — channel 엔진, 세션, 권한이 어떻게 동작하는지
|
||||
- [에이전트](/agents) · [Chat](/chat) · [이슈](/issues)
|
||||
- [환경 변수](/environment-variables) — 전체 자체 호스팅 구성 참조
|
||||
179
apps/docs/content/docs/slack-bot-integration.mdx
Normal file
179
apps/docs/content/docs/slack-bot-integration.mdx
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
title: Slack Bot integration
|
||||
description: Connect a Multica agent to your own Slack app — create the app from a manifest, install it, paste the bot + app-level tokens, then @-mention it, DM it, or type /issue from inside Slack.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Connect any [agent](/agents) to a Slack bot and your team can work with it from inside Slack — DM the bot, @-mention it in a channel, or type `/issue` to file a [Multica issue](/issues) without opening the app.
|
||||
|
||||
Slack uses a **bring-your-own-app (BYO)** model: a workspace admin creates a Slack app, installs it to their Slack workspace, and pastes its tokens into Multica. Each agent gets **its own** Slack app — so several agents can each have a distinct, separately @-mentionable bot in the same Slack workspace. (This differs from [Lark](/lark-bot-integration), where binding is a scan-to-install flow.)
|
||||
|
||||
The whole setup is below and takes about five minutes. You'll end up with two tokens to paste into Multica:
|
||||
|
||||
- a **Bot token** — starts with `xoxb-`
|
||||
- an **App-level token** — starts with `xapp-`
|
||||
|
||||
## Set up your Slack app
|
||||
|
||||
### 1. Create the app from a manifest
|
||||
|
||||
1. Go to [https://api.slack.com/apps](https://api.slack.com/apps) and click **Create New App**.
|
||||
2. Choose **From a manifest**.
|
||||
3. Pick the Slack workspace to install the app into.
|
||||
4. Switch to the **YAML** tab, paste the manifest below, review, and create the app.
|
||||
|
||||
```yaml
|
||||
display_information:
|
||||
name: Multica
|
||||
features:
|
||||
app_home:
|
||||
home_tab_enabled: false
|
||||
messages_tab_enabled: true
|
||||
messages_tab_read_only_enabled: false
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- app_mentions:read
|
||||
- channels:history
|
||||
- groups:history
|
||||
- im:history
|
||||
- mpim:history
|
||||
- chat:write
|
||||
- users:read
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
- app_mention
|
||||
- message.im
|
||||
- message.channels
|
||||
- message.groups
|
||||
- message.mpim
|
||||
interactivity:
|
||||
is_enabled: false
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: true
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
This manifest configures everything Multica needs, so you don't set anything by hand:
|
||||
|
||||
| Section | Why it's there |
|
||||
|---|---|
|
||||
| `app_home.messages_tab_enabled: true` | Lets members open the bot and **DM** it. Without it, the bot can't be messaged directly. |
|
||||
| `bot_user` | Creates the bot identity that gets @-mentioned and posts replies. |
|
||||
| `chat:write` | Post the agent's replies back into Slack. |
|
||||
| `app_mentions:read` + `app_mention` event | Receive @-mentions in channels. |
|
||||
| `im:history` + `message.im` | Receive **DMs** to the bot (every DM message is read). |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + the matching `message.*` events | Receive messages in public channels, private channels, and group DMs. In these, the bot only acts on messages that **@-mention** it. |
|
||||
| `users:read` | Required so Multica can verify (via `bots.info`) that your two tokens belong to the same app. |
|
||||
| `socket_mode_enabled: true` | The bot connects out over Socket Mode — **no public URL / request URL needed**. |
|
||||
| `interactivity.is_enabled: false` | Multica's prompts are plain links, not buttons, so interactivity isn't needed. |
|
||||
|
||||
There is **no OAuth redirect URL**, because BYO doesn't use OAuth.
|
||||
|
||||
<Callout type="warning">
|
||||
Keep `users:read` in the scopes. At connect time Multica calls `bots.info` to confirm the bot token and app-level token come from the **same** app — that call needs `users:read`. Without it, **Connect** fails.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
Want a specific name in Slack? Change `display_information.name` and `features.bot_user.display_name` (e.g. to your agent's name) before creating, or edit it later under **App Home**. Slack shows the bot by its **bot display name**, which can differ from the app name.
|
||||
</Callout>
|
||||
|
||||
### 2. Install the app and copy the Bot token
|
||||
|
||||
1. In the app's left nav, open **Install App** (or **OAuth & Permissions**).
|
||||
2. Click **Install to Workspace** and approve.
|
||||
3. Copy the **Bot User OAuth Token** — it starts with `xoxb-`. This is your **Bot token**.
|
||||
|
||||
### 3. Create the App-level token
|
||||
|
||||
The app-level token authorizes the Socket Mode connection. It can only be created in the console (it isn't part of OAuth).
|
||||
|
||||
1. Open **Basic Information → App-Level Tokens** and click **Generate Token and Scopes**.
|
||||
2. Give it any name.
|
||||
3. Click **Add Scope** and pick **`connections:write`** from the list (it's a picker — select it, don't type it).
|
||||
4. Click **Generate**, then copy the token — it starts with `xapp-`. This is your **App-level token**.
|
||||
|
||||
### 4. Connect it in Multica
|
||||
|
||||
1. Open the agent in **Agents → _your agent_** → the **Integrations** tab (or the **Integrations** section in the left sidebar).
|
||||
2. Click **Connect Slack**.
|
||||
3. Paste the **Bot token** (`xoxb-`) and the **App-level token** (`xapp-`), then click **Connect**.
|
||||
4. The agent shows **Connected to Slack**. The bot is now listening over its own Socket Mode connection.
|
||||
|
||||
<Callout type="warning">
|
||||
The two tokens must be from the **same** Slack app, and that app maps to exactly **one** agent. Connecting an app that's already connected to a different agent or workspace is refused. To move an app to another agent, disconnect it first; re-connecting an agent with a **new** app updates that agent's bot in place.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
Setting this up for **multiple agents**? Repeat the whole flow once per agent — each agent gets its own Slack app and its own pair of tokens, and they show up as separate bots in your Slack workspace.
|
||||
</Callout>
|
||||
|
||||
## What the integration does
|
||||
|
||||
| Surface | Behavior |
|
||||
|---|---|
|
||||
| **Agent → Integrations** | Owners and admins see **Connect Slack**; once connected it flips to a **Connected to Slack** badge with a **Disconnect** control. |
|
||||
| **DM the bot** | A workspace member messages the bot directly. The conversation becomes a Multica [chat](/chat) session with the agent; every DM message is read. |
|
||||
| **@-mention in a channel** | Invite the bot (`/invite @your-bot`) and @-mention it. Only the mentioning message is read — the bot does not listen to the whole channel. Each @bot **thread** is its own session. |
|
||||
| **`/issue` command** | Starting a message with `/issue <title>` (optionally with a body on the next lines) creates a new Multica issue in the workspace, attributed to you. |
|
||||
| **Reply** | The agent's answer is posted back into the same DM or thread. |
|
||||
|
||||
## Use the bot (members)
|
||||
|
||||
### First message: link your account
|
||||
|
||||
The first time you @-mention or DM the bot, it replies with a **link your account** prompt. Tap the link, sign in to Multica, and your Slack identity is bound to your Multica membership — this is what lets the agent act as you (e.g. `/issue` files under your name). The link is single-use and expires in about 15 minutes; just message the bot again for a fresh one.
|
||||
|
||||
<Callout type="warning">
|
||||
Only **members of the workspace** can use the bot. If you aren't a member, or you skip the identity link, the bot won't run — your message is dropped (recorded for audit, without its contents).
|
||||
</Callout>
|
||||
|
||||
### Chat and `/issue`
|
||||
|
||||
- **In a channel** — the bot isn't auto-joined. Run `/invite @your-bot` once, then `@your-bot <your message>`. Re-mention it for each follow-up (the bot only reads messages that mention it).
|
||||
- **In a DM** — open the bot from the Slack sidebar's **Apps** section and message it directly; no mention needed.
|
||||
- **File an issue** — send `/issue Fix the login redirect`; add more lines after the title for a description.
|
||||
|
||||
## Manage and disconnect
|
||||
|
||||
Workspace-wide management lives in **Settings → Integrations**:
|
||||
|
||||
- **Connected bots** lists every bot in the workspace and the agent each is bound to (visible to all members).
|
||||
- **Disconnect** is **owner / admin only**. It stops the bot from receiving Slack messages and tears down its connection; the installation record is kept for audit, and you can re-connect later.
|
||||
|
||||
## Permissions
|
||||
|
||||
- **Connect / disconnect** require workspace **owner** or **admin**.
|
||||
- **Talking to the bot** requires being a workspace member with a linked Slack identity. Everyone else is dropped.
|
||||
- Message bodies for dropped messages are never stored — only a drop reason, for audit.
|
||||
|
||||
## Self-host setup
|
||||
|
||||
On Multica Cloud the integration is already available — skip this section.
|
||||
|
||||
For self-host, Slack is **off until you set an at-rest encryption key**. The key encrypts each app's bot + app-level tokens before they touch the database. BYO needs **no** OAuth client id/secret and **no** deployment-level app token — each installation uses the tokens the admin pastes.
|
||||
|
||||
1. Generate a 32-byte key and set it on the API server:
|
||||
|
||||
```dotenv
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
For example: `openssl rand -base64 32`.
|
||||
|
||||
2. Restart the API. Until the key is set, **Settings → Integrations** shows a "Slack integration not enabled" notice and the **Connect Slack** entry points stay hidden.
|
||||
|
||||
<Callout type="info">
|
||||
The key must decode to exactly 32 bytes — `openssl rand -base64 32` does this. Treat it as a long-lived secret: rotating or losing it makes already-stored tokens undecryptable, forcing every bot to reconnect. The "link your account" link is built from your web app URL (`MULTICA_APP_URL`, falling back to `FRONTEND_ORIGIN`) — a normal deployment already sets this, so there's nothing extra to configure.
|
||||
</Callout>
|
||||
|
||||
## Next
|
||||
|
||||
- [Chat integrations](/channels) — how the channel engine, sessions, and authorization work
|
||||
- [Agents](/agents) · [Chat](/chat) · [Issues](/issues)
|
||||
- [Environment variables](/environment-variables) — full self-host configuration reference
|
||||
179
apps/docs/content/docs/slack-bot-integration.zh.mdx
Normal file
179
apps/docs/content/docs/slack-bot-integration.zh.mdx
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
title: Slack Bot 接入
|
||||
description: 把 Multica 智能体接入你自己的 Slack app——用 manifest 创建 app、安装它、粘贴 bot token 与 app-level token,然后就能在 Slack 里 @ 它、私聊它,或输入 /issue。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
把任意[智能体](/agents)接入一个 Slack Bot,团队就能在 Slack 里直接使用它——私聊 Bot、在频道里 @ 它,或者输入 `/issue` 直接创建一个 [Multica issue](/issues),不用打开应用。
|
||||
|
||||
Slack 走的是**自带应用(bring-your-own-app,BYO)**模式:workspace 管理员创建一个 Slack app,把它安装到自己的 Slack workspace,再把它的 token 粘贴进 Multica。每个智能体都有**它自己的** Slack app——所以多个智能体可以在同一个 Slack workspace 里各自拥有一个独立、可单独 @ 的 Bot。(这一点和 [Lark](/lark-bot-integration) 不同,Lark 的绑定是扫码安装流程。)
|
||||
|
||||
整个设置流程在下面,大约五分钟。最后你会得到两个 token 粘贴进 Multica:
|
||||
|
||||
- 一个 **Bot token** —— 以 `xoxb-` 开头
|
||||
- 一个 **App-level token** —— 以 `xapp-` 开头
|
||||
|
||||
## 设置你的 Slack app
|
||||
|
||||
### 1. 用 manifest 创建 app
|
||||
|
||||
1. 打开 [https://api.slack.com/apps](https://api.slack.com/apps),点击 **Create New App**。
|
||||
2. 选择 **From a manifest**。
|
||||
3. 选定要把 app 安装进去的那个 Slack workspace。
|
||||
4. 切到 **YAML** tab,粘贴下面的 manifest,检查一遍,然后创建这个 app。
|
||||
|
||||
```yaml
|
||||
display_information:
|
||||
name: Multica
|
||||
features:
|
||||
app_home:
|
||||
home_tab_enabled: false
|
||||
messages_tab_enabled: true
|
||||
messages_tab_read_only_enabled: false
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- app_mentions:read
|
||||
- channels:history
|
||||
- groups:history
|
||||
- im:history
|
||||
- mpim:history
|
||||
- chat:write
|
||||
- users:read
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
- app_mention
|
||||
- message.im
|
||||
- message.channels
|
||||
- message.groups
|
||||
- message.mpim
|
||||
interactivity:
|
||||
is_enabled: false
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: true
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
这个 manifest 已经把 Multica 所需的一切都配好了,所以你不用手动设置任何东西:
|
||||
|
||||
| 配置项 | 为什么需要它 |
|
||||
|---|---|
|
||||
| `app_home.messages_tab_enabled: true` | 让成员能打开 Bot 并**私聊**它。没有它,Bot 就无法被直接发消息。 |
|
||||
| `bot_user` | 创建被 @ 和发回复用的那个 Bot 身份。 |
|
||||
| `chat:write` | 把智能体的回复发回 Slack。 |
|
||||
| `app_mentions:read` + `app_mention` 事件 | 接收频道里的 @ 提及。 |
|
||||
| `im:history` + `message.im` | 接收发给 Bot 的**私聊**(每一条私聊消息都会被读取)。 |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + 对应的 `message.*` 事件 | 接收公开频道、私有频道和群组私聊里的消息。在这些场景里,Bot 只对 **@ 了**它的消息做出响应。 |
|
||||
| `users:read` | 必需,这样 Multica 才能(通过 `bots.info`)核实你的两个 token 属于同一个 app。 |
|
||||
| `socket_mode_enabled: true` | Bot 通过 Socket Mode 向外连接——**无需任何公网 URL / request URL**。 |
|
||||
| `interactivity.is_enabled: false` | Multica 的提示是纯链接,不是按钮,所以不需要交互性。 |
|
||||
|
||||
这里**没有 OAuth 重定向 URL**,因为 BYO 不使用 OAuth。
|
||||
|
||||
<Callout type="warning">
|
||||
请保留 scopes 里的 `users:read`。连接时 Multica 会调用 `bots.info` 来确认 bot token 和 app-level token 来自**同一个** app——这个调用需要 `users:read`。没有它,**Connect** 会失败。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
想在 Slack 里用一个特定的名字?在创建之前改 `display_information.name` 和 `features.bot_user.display_name`(比如改成你智能体的名字),或者之后在 **App Home** 里编辑。Slack 是按 Bot 的**显示名(bot display name)**来展示它的,这个名字可以和 app 名不一样。
|
||||
</Callout>
|
||||
|
||||
### 2. 安装 app 并复制 Bot token
|
||||
|
||||
1. 在 app 的左侧导航里,打开 **Install App**(或 **OAuth & Permissions**)。
|
||||
2. 点击 **Install to Workspace** 并批准。
|
||||
3. 复制 **Bot User OAuth Token**——它以 `xoxb-` 开头。这就是你的 **Bot token**。
|
||||
|
||||
### 3. 创建 App-level token
|
||||
|
||||
App-level token 用来授权 Socket Mode 连接。它只能在控制台里创建(它不属于 OAuth)。
|
||||
|
||||
1. 打开 **Basic Information → App-Level Tokens**,点击 **Generate Token and Scopes**。
|
||||
2. 随便起个名字。
|
||||
3. 点击 **Add Scope**,从列表里选 **`connections:write`**(这是一个选择器——选中它,不要手打)。
|
||||
4. 点击 **Generate**,然后复制这个 token——它以 `xapp-` 开头。这就是你的 **App-level token**。
|
||||
|
||||
### 4. 在 Multica 里连接它
|
||||
|
||||
1. 在 **Agents → _你的智能体_** 打开该智能体 → **Integrations** tab(或左侧栏的 **Integrations** 区块)。
|
||||
2. 点击 **Connect Slack**。
|
||||
3. 粘贴 **Bot token**(`xoxb-`)和 **App-level token**(`xapp-`),然后点击 **Connect**。
|
||||
4. 智能体显示 **Connected to Slack**。Bot 现在通过它自己的 Socket Mode 连接在监听了。
|
||||
|
||||
<Callout type="warning">
|
||||
这两个 token 必须来自**同一个** Slack app,而那个 app 恰好对应**一个**智能体。连接一个已经连到别的智能体或 workspace 的 app 会被拒绝。要把一个 app 挪到另一个智能体,先断开它;用一个**新的** app 重新连接某个智能体,会就地更新那个智能体的 Bot。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
要给**多个智能体**做这套设置?每个智能体都把整套流程走一遍——每个智能体都有自己的 Slack app 和自己的一对 token,它们会在你的 Slack workspace 里显示成各自独立的 Bot。
|
||||
</Callout>
|
||||
|
||||
## 这个集成能做什么
|
||||
|
||||
| 入口 | 行为 |
|
||||
|---|---|
|
||||
| **智能体 → Integrations** | 所有者和管理员能看到 **Connect Slack**;连接后它会变成一个 **Connected to Slack** 徽标,并带一个 **Disconnect** 操作。 |
|
||||
| **私聊 Bot** | 工作区成员直接给 Bot 发消息。这段对话会成为该智能体的一个 Multica [chat](/chat) 会话;每一条私聊消息都会被读取。 |
|
||||
| **频道里 @ 它** | 把 Bot 邀请进来(`/invite @your-bot`)再 @ 它。只有 @ 它的那条消息会被读取——Bot 不会监听整个频道。每个 @bot 的 **thread** 都是它自己的会话。 |
|
||||
| **`/issue` 命令** | 以 `/issue <标题>` 开头的消息(可在后面几行附上正文)会在工作区创建一个新的 Multica issue,记在你名下。 |
|
||||
| **回复** | 智能体的答复会被发回同一段私聊或 thread 里。 |
|
||||
|
||||
## 使用 Bot(成员)
|
||||
|
||||
### 第一条消息:绑定你的账号
|
||||
|
||||
第一次 @ 或私聊 Bot 时,它会回一条 **绑定你的账号** 提示。点开链接、登录 Multica,你的 Slack 身份就会绑定到你的 Multica 成员身份——正是这一步让智能体能以你的身份行事(比如 `/issue` 会把 issue 记在你名下)。这个链接是一次性的,大约 15 分钟后过期;再给 Bot 发条消息就能拿到一个新的。
|
||||
|
||||
<Callout type="warning">
|
||||
只有**工作区成员**才能使用 Bot。如果你不是成员,或者跳过了身份绑定,Bot 不会运行——你的消息会被丢弃(仅出于审计目的记录,不保存消息内容)。
|
||||
</Callout>
|
||||
|
||||
### 对话与 `/issue`
|
||||
|
||||
- **在频道里** —— Bot 不会自动加入。先运行一次 `/invite @your-bot`,然后 `@your-bot <你的消息>`。每次追问都要重新 @ 它一下(Bot 只读取 @ 了它的消息)。
|
||||
- **在私聊里** —— 从 Slack 侧栏的 **Apps** 区块打开 Bot 并直接给它发消息;不用 @。
|
||||
- **创建 issue** —— 发送 `/issue Fix the login redirect`;在标题后面再加几行就是描述。
|
||||
|
||||
## 管理与断开
|
||||
|
||||
工作区级别的管理在 **Settings → Integrations**:
|
||||
|
||||
- **Connected bots** 列出工作区里每个 Bot 以及它各自绑定的智能体(所有成员都能看到)。
|
||||
- **Disconnect** 仅限 **所有者 / 管理员**。它会让 Bot 停止接收 Slack 消息并拆掉它的连接;安装记录会保留以便审计,之后你可以重新连接。
|
||||
|
||||
## 权限
|
||||
|
||||
- **连接 / 断开** 需要工作区**所有者**或**管理员**。
|
||||
- **和 Bot 对话** 需要你是工作区成员且已绑定 Slack 身份。其余的人一律被丢弃。
|
||||
- 对于被丢弃的消息,绝不保存消息内容——只记录一个丢弃原因,用于审计。
|
||||
|
||||
## 自部署配置
|
||||
|
||||
在 Multica Cloud 上这个集成已经可用——可跳过本节。
|
||||
|
||||
自部署时,**在你设置好静态加密密钥之前,Slack 是关闭的**。这个密钥会在每个 app 的 bot token + app-level token 落库之前对其加密。BYO **不需要** OAuth client id/secret,也**不需要**部署级的 app token——每个安装用的都是管理员粘贴进来的那对 token。
|
||||
|
||||
1. 生成一个 32 字节的密钥并设置到 API 服务器:
|
||||
|
||||
```dotenv
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
例如:`openssl rand -base64 32`。
|
||||
|
||||
2. 重启 API。在密钥设置好之前,**Settings → Integrations** 会显示一条「Slack integration not enabled」提示,**Connect Slack** 入口也会保持隐藏。
|
||||
|
||||
<Callout type="info">
|
||||
这个密钥必须正好解码出 32 字节——`openssl rand -base64 32` 就能做到。把它当成一个长期有效的密钥:轮换或丢失它会让已存储的 token 无法解密,迫使每个 Bot 重新连接。「绑定你的账号」链接是用你的 Web 应用地址(`MULTICA_APP_URL`,未设置时回退到 `FRONTEND_ORIGIN`)拼出来的——正常部署里这个值本来就有,不需要额外配置。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [聊天集成](/channels) —— channel 引擎、会话与授权是怎么运作的
|
||||
- [智能体](/agents) · [Chat](/chat) · [Issues](/issues)
|
||||
- [环境变量](/environment-variables) —— 完整的自部署配置参考
|
||||
23
apps/web/app/slack/bind/page.tsx
Normal file
23
apps/web/app/slack/bind/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { SlackBindPage } from "@multica/views/slack";
|
||||
|
||||
// /slack/bind?token=<raw> is the bot's "link your account" destination. Suspense
|
||||
// wraps useSearchParams per Next.js 15's CSR-bailout rule; the loading text
|
||||
// never paints in practice because the redemption page itself renders the
|
||||
// "redeeming…" state immediately.
|
||||
function SlackBindPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
return <SlackBindPage token={token} />;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SlackBindPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +112,10 @@ import type {
|
||||
BeginLarkInstallResponse,
|
||||
LarkInstallStatusResponse,
|
||||
RedeemLarkBindingTokenResponse,
|
||||
SlackInstallation,
|
||||
ListSlackInstallationsResponse,
|
||||
RegisterSlackBYORequest,
|
||||
RedeemSlackBindingTokenResponse,
|
||||
Squad,
|
||||
SquadMember,
|
||||
SquadMemberStatusListResponse,
|
||||
@@ -2270,4 +2274,37 @@ export class ApiClient {
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
|
||||
// Slack integration (MUL-3666)
|
||||
async listSlackInstallations(workspaceId: string): Promise<ListSlackInstallationsResponse> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/slack/installations`);
|
||||
}
|
||||
|
||||
// registerSlackBYO performs a bring-your-own-app install: the admin pastes the
|
||||
// bot token (xoxb-) + app-level token (xapp-) of the Slack app they created,
|
||||
// and the backend validates + persists it, returning the new installation.
|
||||
async registerSlackBYO(
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
body: RegisterSlackBYORequest,
|
||||
): Promise<SlackInstallation> {
|
||||
const search = new URLSearchParams({ agent_id: agentId });
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/slack/install/byo?${search.toString()}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSlackInstallation(workspaceId: string, installationId: string): Promise<void> {
|
||||
await this.fetch(`/api/workspaces/${workspaceId}/slack/installations/${installationId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async redeemSlackBindingToken(token: string): Promise<RedeemSlackBindingTokenResponse> {
|
||||
return this.fetch(`/api/slack/binding/redeem`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@
|
||||
"./github/queries": "./github/queries.ts",
|
||||
"./lark": "./lark/index.ts",
|
||||
"./lark/queries": "./lark/queries.ts",
|
||||
"./slack": "./slack/index.ts",
|
||||
"./slack/queries": "./slack/queries.ts",
|
||||
"./feedback": "./feedback/index.ts",
|
||||
"./feedback/mutations": "./feedback/mutations.ts",
|
||||
"./realtime": "./realtime/index.ts",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "../agents/queries";
|
||||
import { githubKeys } from "../github/queries";
|
||||
import { larkKeys } from "../lark/queries";
|
||||
import { slackKeys } from "../slack/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
@@ -484,6 +485,10 @@ export function useRealtimeSync(
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: larkKeys.installations(wsId) });
|
||||
},
|
||||
slack_installation: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: slackKeys.installations(wsId) });
|
||||
},
|
||||
pull_request: () => {
|
||||
// PR list is keyed by issue id, not workspace, so we invalidate all
|
||||
// PR queries — the open issue detail page will refetch its own list.
|
||||
|
||||
1
packages/core/slack/index.ts
Normal file
1
packages/core/slack/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { slackKeys, slackInstallationsOptions } from "./queries";
|
||||
18
packages/core/slack/queries.ts
Normal file
18
packages/core/slack/queries.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
/** Query key namespace for everything Slack-installation-related. Realtime
|
||||
* sync invalidates `installations(wsId)` on `slack_installation:*` events so
|
||||
* the Settings panel updates without a manual refetch (e.g. after the OAuth
|
||||
* callback lands the install in another tab / the system browser). */
|
||||
export const slackKeys = {
|
||||
all: (wsId: string) => ["slack", wsId] as const,
|
||||
installations: (wsId: string) => [...slackKeys.all(wsId), "installations"] as const,
|
||||
};
|
||||
|
||||
export const slackInstallationsOptions = (wsId: string) =>
|
||||
queryOptions({
|
||||
queryKey: slackKeys.installations(wsId),
|
||||
queryFn: () => api.listSlackInstallations(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
@@ -119,6 +119,12 @@ export type {
|
||||
LarkInstallStatusResponse,
|
||||
RedeemLarkBindingTokenResponse,
|
||||
} from "./lark";
|
||||
export type {
|
||||
SlackInstallation,
|
||||
ListSlackInstallationsResponse,
|
||||
RegisterSlackBYORequest,
|
||||
RedeemSlackBindingTokenResponse,
|
||||
} from "./slack";
|
||||
export type {
|
||||
Autopilot,
|
||||
AutopilotStatus,
|
||||
|
||||
50
packages/core/types/slack.ts
Normal file
50
packages/core/types/slack.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/** A Slack bot installation bound to a single Multica agent (MUL-3666).
|
||||
*
|
||||
* Wire shape mirrors `SlackInstallationResponse` in
|
||||
* `server/internal/handler/slack.go`. New fields the backend adds in the
|
||||
* future MUST default to optional so older desktop builds keep parsing the
|
||||
* response — see CLAUDE.md → API Compatibility. */
|
||||
export interface SlackInstallation {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
agent_id: string;
|
||||
/** The Slack workspace (team) id this bot is installed in. */
|
||||
team_id: string;
|
||||
/** The installed bot's Slack user id. */
|
||||
bot_user_id: string;
|
||||
installer_user_id: string;
|
||||
status: "active" | "revoked" | string;
|
||||
installed_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ListSlackInstallationsResponse {
|
||||
installations: SlackInstallation[];
|
||||
/** Whether the deployment has the at-rest secret key configured. When false
|
||||
* the connect entry points are hidden and the panel renders an "ask the
|
||||
* operator to enable Slack" state. */
|
||||
configured: boolean;
|
||||
/** Whether the install path is available (true whenever Slack is configured,
|
||||
* i.e. the at-rest key is set — a bring-your-own-app install needs no hosted
|
||||
* OAuth credentials). Kept as a separate flag for forward/backward compat;
|
||||
* optional so an older desktop build that predates it treats it as off. */
|
||||
install_supported?: boolean;
|
||||
}
|
||||
|
||||
/** Request body for a bring-your-own-app (BYO) install: the two tokens the
|
||||
* admin pastes from the Slack app they created. The backend validates that both
|
||||
* belong to the same Slack app (and that the app token is live) before
|
||||
* persisting, then returns the created SlackInstallation. */
|
||||
export interface RegisterSlackBYORequest {
|
||||
bot_token: string;
|
||||
app_token: string;
|
||||
}
|
||||
|
||||
/** Post-redemption echo: the Slack user id the token carried is now bound to
|
||||
* the logged-in Multica user in this workspace/installation. */
|
||||
export interface RedeemSlackBindingTokenResponse {
|
||||
workspace_id: string;
|
||||
installation_id: string;
|
||||
slack_user_id: string;
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { SkillAttach } from "./inspector/skill-attach";
|
||||
import { ThinkingPropRow } from "./inspector/thinking-prop-row";
|
||||
import { VisibilityPicker } from "./inspector/visibility-picker";
|
||||
import { LarkAgentBindButton } from "../../settings/components/lark-tab";
|
||||
import { SlackAgentBindButton } from "../../settings/components/slack-tab";
|
||||
|
||||
interface InspectorProps {
|
||||
agent: Agent;
|
||||
@@ -215,13 +216,12 @@ export function AgentDetailInspector({
|
||||
</div>
|
||||
|
||||
{/* Integrations — surfaces external-channel bind entry points
|
||||
(Lark Bot today; Slack / Discord in the future). The bind
|
||||
button self-hides when the server-side device-flow install
|
||||
capability gate is closed, so this section may render empty
|
||||
on deployments without a configured Lark app — that's
|
||||
intentional and matches the "don't surface a flow that will
|
||||
fail" guarantee. We only mount it for editors: viewers
|
||||
shouldn't see a CTA they can't action. */}
|
||||
(Lark + Slack today; Discord in the future). Each bind button
|
||||
self-hides when its server-side install capability gate is
|
||||
closed, so this section may render empty on deployments without
|
||||
a configured channel — that's intentional and matches the
|
||||
"don't surface a flow that will fail" guarantee. We only mount
|
||||
it for editors: viewers shouldn't see a CTA they can't action. */}
|
||||
{canEdit && (
|
||||
<div className="flex flex-col px-5 py-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
@@ -235,6 +235,11 @@ export function AgentDetailInspector({
|
||||
agentName={agent.name}
|
||||
onShowConnectedDetails={onShowIntegrations}
|
||||
/>
|
||||
<SlackAgentBindButton
|
||||
agentId={agent.id}
|
||||
agentName={agent.name}
|
||||
onShowConnectedDetails={onShowIntegrations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -45,6 +45,9 @@ vi.mock("../../common/actor-issues-panel", () => ({
|
||||
const larkListingRef = vi.hoisted(() => ({
|
||||
current: { installations: [] as unknown[], configured: false },
|
||||
}));
|
||||
const slackListingRef = vi.hoisted(() => ({
|
||||
current: { installations: [] as unknown[], configured: false },
|
||||
}));
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
@@ -54,6 +57,12 @@ vi.mock("@multica/core/lark", () => ({
|
||||
queryFn: () => Promise.resolve(larkListingRef.current),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@multica/core/slack", () => ({
|
||||
slackInstallationsOptions: () => ({
|
||||
queryKey: ["slack", "installations"],
|
||||
queryFn: () => Promise.resolve(slackListingRef.current),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { AgentOverviewPane } from "./agent-overview-pane";
|
||||
|
||||
@@ -119,6 +128,7 @@ function renderPane(runtimes: AgentRuntime[]) {
|
||||
|
||||
beforeEach(() => {
|
||||
larkListingRef.current = { installations: [], configured: false };
|
||||
slackListingRef.current = { installations: [], configured: false };
|
||||
});
|
||||
|
||||
describe("AgentOverviewPane MCP tab visibility", () => {
|
||||
@@ -163,9 +173,19 @@ describe("AgentOverviewPane Integrations tab visibility", () => {
|
||||
).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.
|
||||
it("shows the Integrations tab when only Slack is configured (Lark off)", async () => {
|
||||
// Regression: the tab gate must consider Slack too, not just Lark —
|
||||
// a Slack-only deployment was hiding the tab (and its bind entry).
|
||||
slackListingRef.current = { installations: [], configured: true };
|
||||
renderPane([makeRuntime("claude")]);
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /^Integrations$/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Integrations tab when neither Lark nor Slack is configured", () => {
|
||||
// Default refs are configured:false; the tab must not appear on
|
||||
// deployments without either integration, the common case.
|
||||
renderPane([makeRuntime("claude")]);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /^Integrations$/i }),
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 { slackInstallationsOptions } from "@multica/core/slack";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -141,16 +142,24 @@ export function AgentOverviewPane({
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const larkConfigured = larkListing?.configured === true;
|
||||
const { data: slackListing } = useQuery({
|
||||
...slackInstallationsOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const slackConfigured = slackListing?.configured === true;
|
||||
// The Integrations tab appears once EITHER channel is wired on the
|
||||
// deployment, so a Slack-only deployment (no Lark) still surfaces it.
|
||||
const integrationsConfigured = larkConfigured || slackConfigured;
|
||||
|
||||
// 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
|
||||
// The Integrations tab appears once the deployment has Lark OR Slack 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.
|
||||
// deployments without either channel are the common case, so flashing the
|
||||
// tab on then off would be the worse flicker.
|
||||
//
|
||||
// The Runtime Config tab is openclaw-only today (gateway mode lives there,
|
||||
// issue #3260). Other providers' runtime_config is freeform JSONB that no
|
||||
@@ -161,11 +170,11 @@ export function AgentOverviewPane({
|
||||
const showRuntimeConfig = runtime ? runtime.provider === "openclaw" : false;
|
||||
return detailTabs.filter((tab) => {
|
||||
if (tab.id === "mcp_config") return showMcp;
|
||||
if (tab.id === "integrations") return larkConfigured;
|
||||
if (tab.id === "integrations") return integrationsConfigured;
|
||||
if (tab.id === "runtime_config") return showRuntimeConfig;
|
||||
return true;
|
||||
});
|
||||
}, [runtime, larkConfigured]);
|
||||
}, [runtime, integrationsConfigured]);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
if (key.includes("installations")) return { data: installationsRef.current };
|
||||
return { data: undefined };
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
queryOptions: <T,>(opts: T) => opts,
|
||||
}));
|
||||
|
||||
@@ -53,6 +54,13 @@ vi.mock("@multica/core/lark", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/slack", () => ({
|
||||
slackInstallationsOptions: () => ({
|
||||
queryKey: ["slack", "installations"],
|
||||
queryFn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const useAuthStore = Object.assign(
|
||||
(sel?: (s: { user: { id: string } }) => unknown) =>
|
||||
@@ -68,6 +76,14 @@ vi.mock("../../../settings/components/lark-tab", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// SlackAgentBindButton is the shared bind entry covered in slack-tab.test.tsx;
|
||||
// here it is a marker so the tests assert branch selection, not the OAuth flow.
|
||||
vi.mock("../../../settings/components/slack-tab", () => ({
|
||||
SlackAgentBindButton: ({ agentId }: { agentId: string }) => (
|
||||
<div data-testid="slack-bind-button" data-agent-id={agentId} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { IntegrationsTab } from "./integrations-tab";
|
||||
|
||||
const TEST_RESOURCES = {
|
||||
@@ -118,11 +134,12 @@ function resetFixtures() {
|
||||
describe("IntegrationsTab", () => {
|
||||
beforeEach(resetFixtures);
|
||||
|
||||
it("renders the shared bind entry for an owner when Lark is configured and supported", () => {
|
||||
it("renders the shared bind entry for both platforms for an owner when 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");
|
||||
expect(screen.getByText("Slack")).toBeTruthy();
|
||||
expect(screen.getByTestId("lark-bind-button").getAttribute("data-agent-id")).toBe("agent-1");
|
||||
expect(screen.getByTestId("slack-bind-button").getAttribute("data-agent-id")).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("shows the coming-soon notice when the install transport is not wired", () => {
|
||||
@@ -147,13 +164,16 @@ describe("IntegrationsTab", () => {
|
||||
expect(screen.queryByTestId("lark-bind-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("points members at Settings instead of a dead button when they can't manage", () => {
|
||||
it("points members at Settings with one role notice (not per-platform) when they can't manage", () => {
|
||||
membersRef.current = [{ user_id: "user-1", role: "member" }];
|
||||
renderTab(<IntegrationsTab agent={agent} />);
|
||||
// The role gate is hoisted above the per-platform sections, so the notice
|
||||
// appears exactly once and neither bind entry renders.
|
||||
expect(
|
||||
screen.getByText(/Only workspace owners and admins can bind a Lark Bot/i),
|
||||
screen.getByText(/Only workspace owners and admins can connect an agent/i),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByTestId("lark-bind-button")).toBeNull();
|
||||
expect(screen.queryByTestId("slack-bind-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the bind entry (not coming-soon) when installs are unavailable but the agent is already bound", () => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Webhook } from "lucide-react";
|
||||
import { MessagesSquare, 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 { slackInstallationsOptions } from "@multica/core/slack";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { LarkAgentBindButton } from "../../../settings/components/lark-tab";
|
||||
import { SlackAgentBindButton } from "../../../settings/components/slack-tab";
|
||||
import { useT } from "../../../i18n";
|
||||
|
||||
/**
|
||||
@@ -37,6 +39,10 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
|
||||
...larkInstallationsOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: slackListing } = useQuery({
|
||||
...slackInstallationsOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: members = [] } = useQuery({
|
||||
...memberListOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
@@ -52,6 +58,30 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
|
||||
(inst) => inst.agent_id === agent.id && inst.status === "active",
|
||||
) ?? false;
|
||||
|
||||
const slackConfigured = slackListing?.configured === true;
|
||||
const slackInstallSupported = slackListing?.install_supported === true;
|
||||
const slackHasActiveInstall =
|
||||
slackListing?.installations.some(
|
||||
(inst) => inst.agent_id === agent.id && inst.status === "active",
|
||||
) ?? false;
|
||||
|
||||
// Install / manage is gated on workspace owner/admin for every platform, so
|
||||
// the role notice is hoisted above the per-platform sections — one note
|
||||
// instead of repeating it under each integration. Members can still view
|
||||
// connected bots in the (member-visible) Settings → Integrations listing.
|
||||
if (!canManage) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.integrations.intro)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.integrations.members_note)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -78,14 +108,6 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
|
||||
<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,
|
||||
@@ -107,6 +129,39 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<MessagesSquare className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h3 className="text-sm font-medium">{ts(($) => $.slack.section_title)}</h3>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{ts(($) => $.slack.page_description)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t px-4 py-3">
|
||||
{!slackConfigured ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ts(($) => $.slack.not_enabled_title)}
|
||||
</p>
|
||||
) : !slackInstallSupported && !slackHasActiveInstall ? (
|
||||
// Secret key is set but the OAuth client credentials aren't, so a
|
||||
// fresh "Connect Slack" would 503. Surface the "coming soon" notice
|
||||
// instead of a broken CTA; an already-bound agent still renders.
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">{ts(($) => $.slack.preview_title)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ts(($) => $.slack.preview_description)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<SlackAgentBindButton agentId={agent.id} agentName={agent.name} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@
|
||||
},
|
||||
"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."
|
||||
"members_note": "Only workspace owners and admins can connect an agent to an external chat platform. You can view connected bots in Settings → Integrations."
|
||||
},
|
||||
"activity": {
|
||||
"section_now": "Now",
|
||||
|
||||
@@ -24,5 +24,20 @@
|
||||
"error_already_bound": "This Lark account is already bound to a different Multica user. Account transfers must go through an explicit unbind first.",
|
||||
"error_not_member": "You're signed in to a Multica account that isn't a member of this workspace.",
|
||||
"error_unknown": "Something went wrong. Try again, and if the problem persists, contact the workspace admin."
|
||||
},
|
||||
"slack_bind": {
|
||||
"page_title": "Link your Slack account",
|
||||
"redeeming": "Linking your account…",
|
||||
"needs_auth_description": "Sign in to Multica to complete the link. The token in the link binds your Slack account to this Multica user, so you must be logged in first.",
|
||||
"sign_in": "Sign in",
|
||||
"done_title": "You're linked.",
|
||||
"done_description": "Your next message to the bot in Slack will go straight to the agent. You can close this tab.",
|
||||
"error_title": "Couldn't complete the link",
|
||||
"error_admin_hint": "If this keeps happening, message the bot again in Slack to get a fresh link.",
|
||||
"error_missing_token": "The link is missing its token. Message the bot again in Slack to get a new one.",
|
||||
"error_expired": "This link is invalid or expired (links are valid for 15 minutes). Message the bot again to get a new one.",
|
||||
"error_already_bound": "This Slack account is already linked to a different Multica user. Account transfers must go through an explicit unbind first.",
|
||||
"error_not_member": "You're signed in to a Multica account that isn't a member of this workspace.",
|
||||
"error_unknown": "Something went wrong. Try again, and if the problem persists, contact the workspace admin."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +300,52 @@
|
||||
"install_error_forbidden": "You no longer have permission to install Lark Bots in this workspace. Ask a workspace admin to continue.",
|
||||
"install_error_generic": "Install failed. Try again."
|
||||
},
|
||||
"slack": {
|
||||
"section_title": "Slack",
|
||||
"page_description": "Connect each Multica Agent to its own Slack bot. A workspace admin creates a Slack app and pastes its bot + app-level tokens; members can then DM the bot or @mention it in a channel, and start a message with /issue (e.g. \"@bot /issue Fix the login bug\") to spin up a new Multica issue.",
|
||||
"not_enabled_title": "Slack integration not enabled",
|
||||
"not_enabled_description_prefix": "Set",
|
||||
"not_enabled_description_suffix": "on the server to enable Slack bot installations.",
|
||||
"not_enabled_self_host_hint": "Self-hosters: see the project README for details.",
|
||||
"preview_title": "Slack install coming soon",
|
||||
"preview_description": "The at-rest key is set, but the hosted Slack app's OAuth credentials are not configured in this deployment. The Connect button will appear here once they are set.",
|
||||
"connected_bots": "Connected bots",
|
||||
"loading": "Loading…",
|
||||
"empty_title": "No bots connected yet",
|
||||
"empty_description_prefix": "Open an Agent in this workspace and click",
|
||||
"empty_description_cta": "Connect Slack",
|
||||
"empty_description_suffix": "to install a bot for it.",
|
||||
"revoked_badge": "revoked",
|
||||
"installed_at_label": "Installed {{when}}",
|
||||
"disconnect": "Disconnect",
|
||||
"disconnecting": "Disconnecting…",
|
||||
"disconnect_confirm_title": "Disconnect this Slack bot?",
|
||||
"disconnect_confirm_description": "The bot will stop receiving Slack messages for this workspace. The installation row is kept for audit; you can re-install later from the same Agent.",
|
||||
"disconnect_confirm_cancel": "Cancel",
|
||||
"toast_disconnected": "Disconnected Slack bot",
|
||||
"toast_disconnect_failed": "Disconnect failed",
|
||||
"bind_button": "Connect Slack",
|
||||
"bind_button_title": "Connect {{agent}} to a Slack bot",
|
||||
"connecting": "Opening Slack…",
|
||||
"connect_failed_toast": "Could not start the Slack install",
|
||||
"agent_bot_connected_label": "Connected to Slack",
|
||||
"agent_bot_disconnect_tooltip": "Unbind this Slack bot from the Agent. The bot will stop receiving Slack messages.",
|
||||
"agent_bot_manage_link": "Open in Slack",
|
||||
"agent_bot_manage_tooltip": "Open this bot's Slack workspace.",
|
||||
"byo_dialog_title": "Connect a Slack bot",
|
||||
"byo_dialog_intro": "Create your own Slack app, install it to your workspace, then paste its two tokens below. You can connect a different app for each agent in the same workspace.",
|
||||
"byo_video_cta": "Watch the setup walkthrough",
|
||||
"byo_bot_token_label": "Bot token (xoxb-)",
|
||||
"byo_bot_token_hint": "Slack app → OAuth & Permissions → Bot User OAuth Token.",
|
||||
"byo_app_token_label": "App-level token (xapp-)",
|
||||
"byo_app_token_hint": "Slack app → Basic Information → App-Level Tokens (scope connections:write).",
|
||||
"byo_scopes_hint": "Required bot scopes: app_mentions:read, channels:history, chat:write, groups:history, im:history, mpim:history, users:read.",
|
||||
"byo_submit": "Connect",
|
||||
"byo_submitting": "Connecting…",
|
||||
"byo_cancel": "Cancel",
|
||||
"byo_success_toast": "Slack bot connected",
|
||||
"byo_failed_toast": "Could not connect the Slack bot"
|
||||
},
|
||||
"repositories": {
|
||||
"section_title": "Repositories",
|
||||
"description": "Git repositories associated with this workspace. Agents use these to clone and work on code.",
|
||||
|
||||
@@ -354,7 +354,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"intro": "このエージェントを外部のチャットプラットフォームに接続し、普段使っているツールから直接やり取りできるようにします。",
|
||||
"members_note": "エージェントに Lark Bot を紐付けできるのはワークスペースのオーナーと管理者のみです。接続済みの Bot は「設定 → 連携」で確認できます。"
|
||||
"members_note": "エージェントを外部チャットプラットフォームに接続できるのはワークスペースのオーナーと管理者のみです。接続済みの Bot は「設定 → 連携」で確認できます。"
|
||||
},
|
||||
"activity": {
|
||||
"section_now": "現在",
|
||||
|
||||
@@ -24,5 +24,20 @@
|
||||
"error_already_bound": "この Lark アカウントはすでに別の Multica ユーザーに連携されています。アカウントを移すには、まず明示的に連携を解除する必要があります。",
|
||||
"error_not_member": "現在ログイン中の Multica アカウントは、このワークスペースのメンバーではありません。",
|
||||
"error_unknown": "問題が発生しました。もう一度試し、それでも解決しない場合はワークスペース管理者にお問い合わせください。"
|
||||
},
|
||||
"slack_bind": {
|
||||
"page_title": "Slack アカウントを連携",
|
||||
"redeeming": "アカウントを連携しています…",
|
||||
"needs_auth_description": "連携を完了するには Multica にサインインしてください。リンク内のトークンが、あなたの Slack アカウントをこの Multica ユーザーに紐付けるため、先にログインが必要です。",
|
||||
"sign_in": "サインイン",
|
||||
"done_title": "連携が完了しました。",
|
||||
"done_description": "次に Slack でボットへ送るメッセージは、そのままエージェントに届きます。このタブは閉じて構いません。",
|
||||
"error_title": "連携を完了できませんでした",
|
||||
"error_admin_hint": "繰り返し発生する場合は、Slack でボットにもう一度メッセージを送って新しいリンクを取得してください。",
|
||||
"error_missing_token": "リンクにトークンがありません。Slack でボットにもう一度メッセージを送って新しいリンクを取得してください。",
|
||||
"error_expired": "このリンクは無効か期限切れです(有効期限は 15 分)。ボットにもう一度メッセージを送って新しいリンクを取得してください。",
|
||||
"error_already_bound": "この Slack アカウントは別の Multica ユーザーに連携済みです。移行するにはまず明示的に解除する必要があります。",
|
||||
"error_not_member": "サインインしている Multica アカウントはこのワークスペースのメンバーではありません。",
|
||||
"error_unknown": "問題が発生しました。もう一度試し、それでも解決しない場合はワークスペース管理者にお問い合わせください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +300,52 @@
|
||||
"install_error_forbidden": "このワークスペースに Lark ボットを設置する権限がなくなりました。ワークスペース管理者にお問い合わせください。",
|
||||
"install_error_generic": "設置に失敗しました。もう一度お試しください。"
|
||||
},
|
||||
"slack": {
|
||||
"section_title": "Slack",
|
||||
"page_description": "各 Multica エージェントを専用の Slack ボットに接続します。ワークスペース管理者が Slack アプリを作成し、その bot トークンと app レベルトークンを貼り付けます。メンバーはボットに DM したりチャンネルで @メンションしたりでき、/issue で始まるメッセージ(例:「@bot /issue ログインの不具合を修正」)で新しい Multica issue を作成できます。",
|
||||
"not_enabled_title": "Slack 連携が有効になっていません",
|
||||
"not_enabled_description_prefix": "サーバーで",
|
||||
"not_enabled_description_suffix": "を設定すると Slack ボットのインストールが有効になります。",
|
||||
"not_enabled_self_host_hint": "セルフホストの場合: 詳細はプロジェクトの README を参照してください。",
|
||||
"preview_title": "Slack インストールは近日対応",
|
||||
"preview_description": "保存用キーは設定済みですが、このデプロイではホスト型 Slack アプリの OAuth 認証情報が未設定です。設定すると接続ボタンがここに表示されます。",
|
||||
"connected_bots": "接続済みのボット",
|
||||
"loading": "読み込み中…",
|
||||
"empty_title": "まだボットが接続されていません",
|
||||
"empty_description_prefix": "このワークスペースのエージェントを開き、",
|
||||
"empty_description_cta": "Slack を接続",
|
||||
"empty_description_suffix": "をクリックしてボットをインストールします。",
|
||||
"revoked_badge": "取り消し済み",
|
||||
"installed_at_label": "{{when}} にインストール",
|
||||
"disconnect": "切断",
|
||||
"disconnecting": "切断中…",
|
||||
"disconnect_confirm_title": "この Slack ボットを切断しますか?",
|
||||
"disconnect_confirm_description": "このボットはこのワークスペースの Slack メッセージを受信しなくなります。インストール記録は監査のため保持され、同じエージェントから再インストールできます。",
|
||||
"disconnect_confirm_cancel": "キャンセル",
|
||||
"toast_disconnected": "Slack ボットを切断しました",
|
||||
"toast_disconnect_failed": "切断に失敗しました",
|
||||
"bind_button": "Slack を接続",
|
||||
"bind_button_title": "{{agent}} を Slack ボットに接続",
|
||||
"connecting": "Slack を開いています…",
|
||||
"connect_failed_toast": "Slack のインストールを開始できませんでした",
|
||||
"agent_bot_connected_label": "Slack に接続済み",
|
||||
"agent_bot_disconnect_tooltip": "この Slack ボットをエージェントから解除します。ボットは Slack メッセージを受信しなくなります。",
|
||||
"agent_bot_manage_link": "Slack で開く",
|
||||
"agent_bot_manage_tooltip": "このボットの Slack ワークスペースを開きます。",
|
||||
"byo_dialog_title": "Slack ボットを接続",
|
||||
"byo_dialog_intro": "自分の Slack アプリを作成してワークスペースにインストールし、その 2 つのトークンを下に貼り付けてください。同じワークスペース内でエージェントごとに別のアプリを接続できます。",
|
||||
"byo_video_cta": "セットアップ手順の動画を見る",
|
||||
"byo_bot_token_label": "Bot トークン(xoxb-)",
|
||||
"byo_bot_token_hint": "Slack アプリ → OAuth & Permissions → Bot User OAuth Token。",
|
||||
"byo_app_token_label": "App レベルトークン(xapp-)",
|
||||
"byo_app_token_hint": "Slack アプリ → Basic Information → App-Level Tokens(スコープ connections:write)。",
|
||||
"byo_scopes_hint": "必要な Bot スコープ:app_mentions:read、channels:history、chat:write、groups:history、im:history、mpim:history、users:read。",
|
||||
"byo_submit": "接続",
|
||||
"byo_submitting": "接続中…",
|
||||
"byo_cancel": "キャンセル",
|
||||
"byo_success_toast": "Slack ボットを接続しました",
|
||||
"byo_failed_toast": "Slack ボットを接続できませんでした"
|
||||
},
|
||||
"repositories": {
|
||||
"section_title": "リポジトリ",
|
||||
"description": "このワークスペースに関連付けられた Git リポジトリです。エージェントはこれらをクローンしてコードを作業します。",
|
||||
|
||||
@@ -370,7 +370,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"intro": "이 에이전트를 외부 채팅 플랫폼에 연결해 팀원이 평소 사용하는 도구에서 바로 함께 작업할 수 있도록 합니다.",
|
||||
"members_note": "에이전트에 Lark 봇을 연결할 수 있는 사람은 워크스페이스 소유자와 관리자뿐입니다. 연결된 봇은 설정 → 연동에서 확인할 수 있습니다."
|
||||
"members_note": "에이전트를 외부 채팅 플랫폼에 연결할 수 있는 사람은 워크스페이스 소유자와 관리자뿐입니다. 연결된 봇은 설정 → 연동에서 확인할 수 있습니다."
|
||||
},
|
||||
"activity": {
|
||||
"section_now": "현재",
|
||||
|
||||
@@ -24,5 +24,20 @@
|
||||
"error_already_bound": "이 Lark 계정은 이미 다른 Multica 사용자에 연결되어 있습니다. 계정 이전은 먼저 명시적으로 연결을 해제해야 합니다.",
|
||||
"error_not_member": "현재 로그인한 Multica 계정이 이 워크스페이스의 멤버가 아닙니다.",
|
||||
"error_unknown": "문제가 발생했어요. 다시 시도해 보고, 계속되면 워크스페이스 관리자에게 문의하세요."
|
||||
},
|
||||
"slack_bind": {
|
||||
"page_title": "Slack 계정 연결",
|
||||
"redeeming": "계정을 연결하는 중…",
|
||||
"needs_auth_description": "연결을 완료하려면 Multica에 로그인하세요. 링크의 토큰이 Slack 계정을 이 Multica 사용자와 연결하므로 먼저 로그인해야 해요.",
|
||||
"sign_in": "로그인",
|
||||
"done_title": "연결되었어요.",
|
||||
"done_description": "이제 Slack에서 봇에게 보내는 다음 메시지는 바로 에이전트로 전달돼요. 이 탭은 닫아도 됩니다.",
|
||||
"error_title": "연결을 완료하지 못했어요",
|
||||
"error_admin_hint": "계속 발생하면 Slack에서 봇에게 다시 메시지를 보내 새 링크를 받으세요.",
|
||||
"error_missing_token": "링크에 토큰이 없어요. Slack에서 봇에게 다시 메시지를 보내 새 링크를 받으세요.",
|
||||
"error_expired": "이 링크는 유효하지 않거나 만료됐어요(유효 기간 15분). 봇에게 다시 메시지를 보내 새 링크를 받으세요.",
|
||||
"error_already_bound": "이 Slack 계정은 이미 다른 Multica 사용자에 연결되어 있어요. 이전하려면 먼저 명시적으로 연결을 해제해야 합니다.",
|
||||
"error_not_member": "로그인한 Multica 계정이 이 워크스페이스의 멤버가 아니에요.",
|
||||
"error_unknown": "문제가 발생했어요. 다시 시도해 보고, 계속되면 워크스페이스 관리자에게 문의하세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,5 +376,51 @@
|
||||
"install_error_session_lost": "설치 세션이 만료되었거나 유실되었어요. 다시 스캔해 처음부터 진행하세요.",
|
||||
"install_error_forbidden": "이 워크스페이스에 Lark 봇을 설치할 권한이 더 이상 없어요. 워크스페이스 관리자에게 문의하세요.",
|
||||
"install_error_generic": "설치에 실패했어요. 다시 시도하세요."
|
||||
},
|
||||
"slack": {
|
||||
"section_title": "Slack",
|
||||
"page_description": "각 Multica 에이전트를 전용 Slack 봇에 연결합니다. 워크스페이스 관리자가 Slack 앱을 만들고 봇 토큰과 app 레벨 토큰을 붙여넣으면, 멤버는 봇에게 DM하거나 채널에서 @멘션할 수 있습니다. /issue로 시작하는 메시지(예: \"@bot /issue 로그인 버그 수정\")로 새 Multica 이슈를 만들 수 있어요.",
|
||||
"not_enabled_title": "Slack 연동이 활성화되지 않았어요",
|
||||
"not_enabled_description_prefix": "서버에서",
|
||||
"not_enabled_description_suffix": "를 설정하면 Slack 봇 설치가 활성화됩니다.",
|
||||
"not_enabled_self_host_hint": "셀프 호스팅: 자세한 내용은 프로젝트 README를 참고하세요.",
|
||||
"preview_title": "Slack 설치 곧 지원 예정",
|
||||
"preview_description": "저장용 키는 설정되어 있지만, 이 배포에는 호스팅 Slack 앱의 OAuth 자격 증명이 설정되지 않았어요. 설정하면 연결 버튼이 여기에 표시됩니다.",
|
||||
"connected_bots": "연결된 봇",
|
||||
"loading": "불러오는 중…",
|
||||
"empty_title": "아직 연결된 봇이 없어요",
|
||||
"empty_description_prefix": "이 워크스페이스의 에이전트를 열고",
|
||||
"empty_description_cta": "Slack 연결",
|
||||
"empty_description_suffix": "을(를) 클릭해 봇을 설치하세요.",
|
||||
"revoked_badge": "해제됨",
|
||||
"installed_at_label": "{{when}}에 설치됨",
|
||||
"disconnect": "연결 해제",
|
||||
"disconnecting": "연결 해제 중…",
|
||||
"disconnect_confirm_title": "이 Slack 봇을 연결 해제할까요?",
|
||||
"disconnect_confirm_description": "봇이 이 워크스페이스의 Slack 메시지를 더 이상 받지 않습니다. 설치 기록은 감사를 위해 보관되며, 같은 에이전트에서 다시 설치할 수 있어요.",
|
||||
"disconnect_confirm_cancel": "취소",
|
||||
"toast_disconnected": "Slack 봇을 연결 해제했어요",
|
||||
"toast_disconnect_failed": "연결 해제에 실패했어요",
|
||||
"bind_button": "Slack 연결",
|
||||
"bind_button_title": "{{agent}}을(를) Slack 봇에 연결",
|
||||
"connecting": "Slack 여는 중…",
|
||||
"connect_failed_toast": "Slack 설치를 시작할 수 없었어요",
|
||||
"agent_bot_connected_label": "Slack에 연결됨",
|
||||
"agent_bot_disconnect_tooltip": "이 Slack 봇을 에이전트에서 연결 해제합니다. 봇이 Slack 메시지를 받지 않게 됩니다.",
|
||||
"agent_bot_manage_link": "Slack에서 열기",
|
||||
"agent_bot_manage_tooltip": "이 봇의 Slack 워크스페이스를 엽니다.",
|
||||
"byo_dialog_title": "Slack 봇 연결",
|
||||
"byo_dialog_intro": "직접 만든 Slack 앱을 워크스페이스에 설치한 뒤, 두 개의 토큰을 아래에 붙여넣으세요. 같은 워크스페이스에서 에이전트마다 다른 앱을 연결할 수 있습니다.",
|
||||
"byo_video_cta": "설정 안내 영상 보기",
|
||||
"byo_bot_token_label": "Bot 토큰(xoxb-)",
|
||||
"byo_bot_token_hint": "Slack 앱 → OAuth & Permissions → Bot User OAuth Token.",
|
||||
"byo_app_token_label": "App 레벨 토큰(xapp-)",
|
||||
"byo_app_token_hint": "Slack 앱 → Basic Information → App-Level Tokens(스코프 connections:write).",
|
||||
"byo_scopes_hint": "필요한 Bot 스코프: app_mentions:read, channels:history, chat:write, groups:history, im:history, mpim:history, users:read.",
|
||||
"byo_submit": "연결",
|
||||
"byo_submitting": "연결 중…",
|
||||
"byo_cancel": "취소",
|
||||
"byo_success_toast": "Slack 봇을 연결했어요",
|
||||
"byo_failed_toast": "Slack 봇을 연결하지 못했어요"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"intro": "把这个智能体连接到外部聊天平台,让大家在自己熟悉的工具里直接与它协作。",
|
||||
"members_note": "只有工作区的所有者和管理员才能为智能体绑定飞书 Bot。你可以在「设置 → 集成」中查看已连接的 Bot。"
|
||||
"members_note": "只有工作区的所有者和管理员才能把智能体连接到外部聊天平台。你可以在「设置 → 集成」中查看已连接的 Bot。"
|
||||
},
|
||||
"activity": {
|
||||
"section_now": "当前",
|
||||
|
||||
@@ -24,5 +24,20 @@
|
||||
"error_already_bound": "该飞书账号已绑定到其他 Multica 用户。账户转移需要先显式解绑。",
|
||||
"error_not_member": "你登录的 Multica 账号不是当前工作区成员。",
|
||||
"error_unknown": "出现未知错误。请稍后再试,如反复失败请联系工作区管理员。"
|
||||
},
|
||||
"slack_bind": {
|
||||
"page_title": "关联 Slack 账号",
|
||||
"redeeming": "正在关联账号…",
|
||||
"needs_auth_description": "需要登录 Multica 才能完成关联。链接中的 token 会将你的 Slack 账号绑定到当前登录的 Multica 用户。",
|
||||
"sign_in": "登录",
|
||||
"done_title": "已关联。",
|
||||
"done_description": "下次在 Slack 向机器人发送消息时,会直接送达绑定的智能体。可以关闭此页面。",
|
||||
"error_title": "关联未完成",
|
||||
"error_admin_hint": "如果反复失败,请在 Slack 重新向机器人发消息以获取新的链接。",
|
||||
"error_missing_token": "链接缺少 token。请在 Slack 重新向机器人发消息以获取新的链接。",
|
||||
"error_expired": "链接无效或已过期(有效期 15 分钟)。请重新向机器人发消息获取新的链接。",
|
||||
"error_already_bound": "该 Slack 账号已关联到其他 Multica 用户。账户转移需要先显式解绑。",
|
||||
"error_not_member": "你登录的 Multica 账号不是当前工作区成员。",
|
||||
"error_unknown": "出现未知错误。请稍后再试,如反复失败请联系工作区管理员。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +300,52 @@
|
||||
"install_error_forbidden": "你已没有在此工作区安装飞书 Bot 的权限,请联系工作区管理员。",
|
||||
"install_error_generic": "安装失败,请重试。"
|
||||
},
|
||||
"slack": {
|
||||
"section_title": "Slack",
|
||||
"page_description": "把每个 Multica Agent 连接到它自己的 Slack 机器人。工作区管理员创建一个 Slack app 并粘贴它的 bot 和 app-level token;成员之后即可私聊机器人,或在频道中 @ 它,并以 /issue 开头发消息(例如「@机器人 /issue 修复登录问题」)来创建新的 Multica issue。",
|
||||
"not_enabled_title": "Slack 集成未启用",
|
||||
"not_enabled_description_prefix": "在服务器上设置",
|
||||
"not_enabled_description_suffix": "以启用 Slack 机器人安装。",
|
||||
"not_enabled_self_host_hint": "自部署用户:详见项目 README。",
|
||||
"preview_title": "Slack 安装即将上线",
|
||||
"preview_description": "静态加密密钥已设置,但本部署尚未配置托管 Slack 应用的 OAuth 凭据。配置后,连接按钮会出现在这里。",
|
||||
"connected_bots": "已连接的机器人",
|
||||
"loading": "加载中…",
|
||||
"empty_title": "尚未连接机器人",
|
||||
"empty_description_prefix": "在本工作区打开一个 Agent,点击",
|
||||
"empty_description_cta": "连接 Slack",
|
||||
"empty_description_suffix": "为其安装机器人。",
|
||||
"revoked_badge": "已撤销",
|
||||
"installed_at_label": "安装于 {{when}}",
|
||||
"disconnect": "断开连接",
|
||||
"disconnecting": "正在断开…",
|
||||
"disconnect_confirm_title": "断开此 Slack 机器人?",
|
||||
"disconnect_confirm_description": "该机器人将停止接收此工作区的 Slack 消息。安装记录会保留以备审计;你之后可以从同一个 Agent 重新安装。",
|
||||
"disconnect_confirm_cancel": "取消",
|
||||
"toast_disconnected": "已断开 Slack 机器人",
|
||||
"toast_disconnect_failed": "断开失败",
|
||||
"bind_button": "连接 Slack",
|
||||
"bind_button_title": "把 {{agent}} 连接到 Slack 机器人",
|
||||
"connecting": "正在打开 Slack…",
|
||||
"connect_failed_toast": "无法开始 Slack 安装",
|
||||
"agent_bot_connected_label": "已连接到 Slack",
|
||||
"agent_bot_disconnect_tooltip": "将此 Slack 机器人从 Agent 解绑。机器人将停止接收 Slack 消息。",
|
||||
"agent_bot_manage_link": "在 Slack 中打开",
|
||||
"agent_bot_manage_tooltip": "打开此机器人所在的 Slack 工作区。",
|
||||
"byo_dialog_title": "连接 Slack 机器人",
|
||||
"byo_dialog_intro": "创建你自己的 Slack app,安装到你的工作区,然后把它的两个 token 粘贴到下面。同一个工作区里,每个 agent 可以连接不同的 app。",
|
||||
"byo_video_cta": "观看配置教程视频",
|
||||
"byo_bot_token_label": "Bot token(xoxb-)",
|
||||
"byo_bot_token_hint": "Slack app → OAuth & Permissions → Bot User OAuth Token。",
|
||||
"byo_app_token_label": "App-level token(xapp-)",
|
||||
"byo_app_token_hint": "Slack app → Basic Information → App-Level Tokens(scope 选 connections:write)。",
|
||||
"byo_scopes_hint": "需要的 Bot scopes:app_mentions:read、channels:history、chat:write、groups:history、im:history、mpim:history、users:read。",
|
||||
"byo_submit": "连接",
|
||||
"byo_submitting": "连接中…",
|
||||
"byo_cancel": "取消",
|
||||
"byo_success_toast": "Slack 机器人已连接",
|
||||
"byo_failed_toast": "无法连接 Slack 机器人"
|
||||
},
|
||||
"repositories": {
|
||||
"section_title": "代码仓库",
|
||||
"description": "与该工作区关联的 Git 仓库。智能体会从这里 clone 代码并完成工作。",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"./settings": "./settings/index.ts",
|
||||
"./settings/lark-tab": "./settings/components/lark-tab.tsx",
|
||||
"./lark": "./lark/index.ts",
|
||||
"./slack": "./slack/index.ts",
|
||||
"./invite": "./invite/index.ts",
|
||||
"./invitations": "./invitations/index.ts",
|
||||
"./onboarding": "./onboarding/index.ts",
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { LarkTab } from "./lark-tab";
|
||||
import { SlackTab } from "./slack-tab";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// Integrations is the umbrella tab for third-party platform connections.
|
||||
// GitHub has its own top-level tab (see github-tab.tsx); everything else
|
||||
// — currently just Lark, with Slack/Linear etc. to follow — lives in
|
||||
// here under its own section heading so additional integrations slot in
|
||||
// without changing the IA. IntegrationsTab is just the host; each
|
||||
// integration owns its own description and install flow.
|
||||
// — currently Lark and Slack, with Linear etc. to follow — lives in here
|
||||
// under its own section heading so additional integrations slot in without
|
||||
// changing the IA. IntegrationsTab is just the host; each integration owns
|
||||
// its own description and install flow.
|
||||
export function IntegrationsTab() {
|
||||
const { t } = useT("settings");
|
||||
return (
|
||||
@@ -17,6 +18,10 @@ export function IntegrationsTab() {
|
||||
<h2 className="text-sm font-semibold">{t(($) => $.lark.section_title)}</h2>
|
||||
<LarkTab />
|
||||
</section>
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold">{t(($) => $.slack.section_title)}</h2>
|
||||
<SlackTab />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
181
packages/views/settings/components/slack-tab.test.tsx
Normal file
181
packages/views/settings/components/slack-tab.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enSettings from "../../locales/en/settings.json";
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
const mockRegisterBYO = vi.hoisted(() => vi.fn());
|
||||
const mockDeleteInstallation = vi.hoisted(() => vi.fn());
|
||||
const mockOpenExternal = vi.hoisted(() => vi.fn());
|
||||
const mockInvalidate = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (opts: { queryKey: unknown[]; enabled?: boolean }) => {
|
||||
if (opts.enabled === false) return { data: undefined, isLoading: false };
|
||||
const key = JSON.stringify(opts.queryKey);
|
||||
if (key.includes("members")) return { data: membersRef.current, isLoading: false };
|
||||
if (key.includes("installations")) return { data: installationsRef.current, isLoading: false };
|
||||
return { data: undefined, isLoading: false };
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: mockInvalidate }),
|
||||
queryOptions: <T,>(opts: T) => opts,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "workspace-1" }));
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
memberListOptions: () => ({ queryKey: ["members"], queryFn: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/hooks", () => ({
|
||||
useActorName: () => ({
|
||||
getAgentName: (agentId: string) => `Agent ${agentId}`,
|
||||
getMemberName: () => "Unknown",
|
||||
getSquadName: () => "Unknown Squad",
|
||||
getActorName: () => "Unknown",
|
||||
getActorInitials: () => "??",
|
||||
getActorAvatarUrl: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../common/actor-avatar", () => ({
|
||||
ActorAvatar: ({ actorId }: { actorId: string }) => (
|
||||
<span data-testid="actor-avatar" data-actor-id={actorId} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/slack", () => ({
|
||||
slackInstallationsOptions: () => ({
|
||||
queryKey: ["slack", "installations"],
|
||||
queryFn: vi.fn(),
|
||||
}),
|
||||
slackKeys: { installations: (wsId: string) => ["slack", "installations", wsId] },
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
registerSlackBYO: mockRegisterBYO,
|
||||
deleteSlackInstallation: mockDeleteInstallation,
|
||||
},
|
||||
}));
|
||||
|
||||
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("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), message: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../platform", () => ({ openExternal: mockOpenExternal }));
|
||||
|
||||
import { SlackAgentBindButton, SlackTab } from "./slack-tab";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, settings: enSettings } };
|
||||
|
||||
function renderUI(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("SlackAgentBindButton", () => {
|
||||
beforeEach(resetFixtures);
|
||||
|
||||
it("opens the BYO dialog and submits the pasted bot + app tokens", async () => {
|
||||
mockRegisterBYO.mockResolvedValue({ id: "i1", agent_id: "agent-1", status: "active" });
|
||||
renderUI(<SlackAgentBindButton agentId="agent-1" agentName="Bot" />);
|
||||
await userEvent.click(screen.getByTestId("slack-agent-connect"));
|
||||
const botInput = await screen.findByTestId("slack-byo-bot-token");
|
||||
await userEvent.type(botInput, "xoxb-bot");
|
||||
await userEvent.type(screen.getByTestId("slack-byo-app-token"), "xapp-1-A0X-1-secret");
|
||||
await userEvent.click(screen.getByTestId("slack-byo-submit"));
|
||||
await waitFor(() =>
|
||||
expect(mockRegisterBYO).toHaveBeenCalledWith("workspace-1", "agent-1", {
|
||||
bot_token: "xoxb-bot",
|
||||
app_token: "xapp-1-A0X-1-secret",
|
||||
}),
|
||||
);
|
||||
// No OAuth redirect anymore — install is a direct API call.
|
||||
expect(mockOpenExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the connected badge (not the CTA) when the agent already has an active install", () => {
|
||||
installationsRef.current = {
|
||||
installations: [{ id: "i1", agent_id: "agent-1", status: "active", team_id: "T1" }],
|
||||
configured: true,
|
||||
install_supported: true,
|
||||
};
|
||||
renderUI(<SlackAgentBindButton agentId="agent-1" />);
|
||||
expect(screen.getByTestId("slack-agent-bot-connected")).toBeTruthy();
|
||||
expect(screen.getByTestId("slack-agent-bot-disconnect")).toBeTruthy();
|
||||
expect(screen.queryByTestId("slack-agent-connect")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing for a non-manager", () => {
|
||||
membersRef.current = [{ user_id: "user-1", role: "member" }];
|
||||
const { container } = renderUI(<SlackAgentBindButton agentId="agent-1" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing when install is unavailable and the agent is unbound", () => {
|
||||
installationsRef.current = { installations: [], configured: true, install_supported: false };
|
||||
const { container } = renderUI(<SlackAgentBindButton agentId="agent-1" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlackTab", () => {
|
||||
beforeEach(resetFixtures);
|
||||
|
||||
it("surfaces the not-enabled notice when the deployment has no Slack key", () => {
|
||||
installationsRef.current = { installations: [], configured: false, install_supported: false };
|
||||
renderUI(<SlackTab />);
|
||||
expect(screen.getByText(/Slack integration not enabled/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the empty state when configured but nothing is connected", () => {
|
||||
renderUI(<SlackTab />);
|
||||
expect(screen.getByText(/No bots connected yet/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lists a connected installation with its agent name and a disconnect control", () => {
|
||||
installationsRef.current = {
|
||||
installations: [{ id: "i1", agent_id: "agent-7", status: "active", team_id: "T1" }],
|
||||
configured: true,
|
||||
install_supported: true,
|
||||
};
|
||||
renderUI(<SlackTab />);
|
||||
expect(screen.getByText("Agent agent-7")).toBeTruthy();
|
||||
expect(screen.getByText(/Disconnect/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
591
packages/views/settings/components/slack-tab.tsx
Normal file
591
packages/views/settings/components/slack-tab.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronRight, ExternalLink, MessagesSquare, Trash2 } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { slackInstallationsOptions, slackKeys } from "@multica/core/slack";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { SlackInstallation } from "@multica/core/types";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { openExternal } from "../../platform";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// SlackTab is the workspace settings panel for Slack bot installations.
|
||||
// Listing is member-visible; the disconnect action is admin-only (the backend
|
||||
// enforces it; the UI hides the button for non-admins to match).
|
||||
//
|
||||
// Adding a new installation flows through the Agent detail page: the install
|
||||
// path is per-agent (each Multica agent gets exactly one bot — the
|
||||
// (workspace_id, agent_id, channel_type) UNIQUE in channel_installation), so
|
||||
// asking the user to pick an agent here would re-create that page's picker.
|
||||
export function SlackTab() {
|
||||
const { t } = useT("settings");
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
|
||||
const canManage =
|
||||
currentMember?.role === "owner" || currentMember?.role === "admin";
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
...slackInstallationsOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const installations = data?.installations ?? [];
|
||||
const configured = data?.configured === true;
|
||||
// install_supported tracks whether the OAuth client credentials are wired on
|
||||
// the server. When false, "Connect Slack" would 503, so we hide the connect
|
||||
// entry points and surface a "coming soon" notice. Already-installed bots
|
||||
// still appear below and remain manageable.
|
||||
const installSupported = data?.install_supported === true;
|
||||
|
||||
const [disconnectTarget, setDisconnectTarget] = useState<string | null>(null);
|
||||
const [disconnecting, setDisconnecting] = useState(false);
|
||||
|
||||
async function handleDisconnect() {
|
||||
if (!disconnectTarget || disconnecting) return;
|
||||
setDisconnecting(true);
|
||||
try {
|
||||
await api.deleteSlackInstallation(wsId, disconnectTarget);
|
||||
await qc.invalidateQueries({ queryKey: slackKeys.installations(wsId) });
|
||||
toast.success(t(($) => $.slack.toast_disconnected));
|
||||
setDisconnectTarget(null);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t(($) => $.slack.toast_disconnect_failed),
|
||||
);
|
||||
} finally {
|
||||
setDisconnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(($) => $.slack.page_description)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{!configured ? (
|
||||
<Card>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm font-medium">{t(($) => $.slack.not_enabled_title)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.slack.not_enabled_description_prefix)}{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[10px]">
|
||||
MULTICA_SLACK_SECRET_KEY
|
||||
</code>{" "}
|
||||
{t(($) => $.slack.not_enabled_description_suffix)}{" "}
|
||||
{t(($) => $.slack.not_enabled_self_host_hint)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : !installSupported && installations.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm font-medium">{t(($) => $.slack.preview_title)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.slack.preview_description)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">{t(($) => $.slack.connected_bots)}</h2>
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{t(($) => $.slack.loading)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : installations.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm font-medium">{t(($) => $.slack.empty_title)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.slack.empty_description_prefix)}{" "}
|
||||
<strong>{t(($) => $.slack.empty_description_cta)}</strong>{" "}
|
||||
{t(($) => $.slack.empty_description_suffix)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="divide-y">
|
||||
{installations.map((inst) => (
|
||||
<InstallationRow
|
||||
key={inst.id}
|
||||
installation={inst}
|
||||
canManage={canManage}
|
||||
onDisconnect={() => setDisconnectTarget(inst.id)}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={!!disconnectTarget}
|
||||
onOpenChange={(v) => {
|
||||
if (!v && !disconnecting) setDisconnectTarget(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t(($) => $.slack.disconnect_confirm_title)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(($) => $.slack.disconnect_confirm_description)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={disconnecting}>
|
||||
{t(($) => $.slack.disconnect_confirm_cancel)}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDisconnect} disabled={disconnecting}>
|
||||
{disconnecting
|
||||
? t(($) => $.slack.disconnecting)
|
||||
: t(($) => $.slack.disconnect)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallationRow({
|
||||
installation,
|
||||
canManage,
|
||||
onDisconnect,
|
||||
}: {
|
||||
installation: SlackInstallation;
|
||||
canManage: boolean;
|
||||
onDisconnect: () => void;
|
||||
}) {
|
||||
const { t } = useT("settings");
|
||||
const { getAgentName } = useActorName();
|
||||
const isActive = installation.status === "active";
|
||||
const agentName = getAgentName(installation.agent_id);
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={installation.agent_id}
|
||||
size={32}
|
||||
enableHoverCard
|
||||
profileLink
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{agentName}
|
||||
{!isActive && (
|
||||
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{t(($) => $.slack.revoked_badge)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t(($) => $.slack.installed_at_label, {
|
||||
when: new Date(installation.installed_at).toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && isActive && (
|
||||
<Button variant="outline" size="sm" onClick={onDisconnect}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t(($) => $.slack.disconnect)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SLACK_BYO_VIDEO_URL is the optional setup-tutorial video linked from the
|
||||
// connect dialog. Leave "" to hide the link; set it once the walkthrough that
|
||||
// shows how to create the Slack app + copy its two tokens is recorded.
|
||||
const SLACK_BYO_VIDEO_URL = "";
|
||||
|
||||
// SlackAgentBindButton is the per-agent CTA exposed from the agent detail page.
|
||||
// Slack uses the bring-your-own-app model: the button opens a dialog where the
|
||||
// admin pastes the bot token (xoxb-) + app-level token (xapp-) of the Slack app
|
||||
// they created (the backend validates both belong to the same app). Visibility:
|
||||
// 1. Non-owner/admin viewers see nothing (the backend gates install/revoke).
|
||||
// 2. If this agent already has an active installation, show the connected
|
||||
// badge (already-installed bots stay manageable).
|
||||
// 3. Otherwise the Connect CTA shows whenever install is available.
|
||||
export function SlackAgentBindButton({
|
||||
agentId,
|
||||
agentName,
|
||||
className,
|
||||
onShowConnectedDetails,
|
||||
}: {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
className?: string;
|
||||
/**
|
||||
* When set, the connected state renders as a compact read-only status row
|
||||
* that invokes this callback on click instead of the full badge with inline
|
||||
* actions — the agent inspector passes a "jump to the Integrations tab"
|
||||
* handler so management actions live in one place.
|
||||
*/
|
||||
onShowConnectedDetails?: () => void;
|
||||
}) {
|
||||
const { t } = useT("settings");
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [botToken, setBotToken] = useState("");
|
||||
const [appToken, setAppToken] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const { data: listing } = useQuery({
|
||||
...slackInstallationsOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const installSupported = listing?.install_supported === true;
|
||||
|
||||
const { data: members = [] } = useQuery({
|
||||
...memberListOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
|
||||
const canManage =
|
||||
currentMember?.role === "owner" || currentMember?.role === "admin";
|
||||
|
||||
if (!canManage) return null;
|
||||
|
||||
const existing = listing?.installations.find(
|
||||
(inst) => inst.agent_id === agentId && inst.status === "active",
|
||||
);
|
||||
if (existing) {
|
||||
return onShowConnectedDetails ? (
|
||||
<SlackAgentBotStatusRow
|
||||
onClick={onShowConnectedDetails}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<SlackAgentBotConnectedBadge installation={existing} className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!installSupported) return null;
|
||||
|
||||
function closeDialog() {
|
||||
if (submitting) return;
|
||||
setDialogOpen(false);
|
||||
setBotToken("");
|
||||
setAppToken("");
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const bot_token = botToken.trim();
|
||||
const app_token = appToken.trim();
|
||||
if (submitting || !agentId || !bot_token || !app_token) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.registerSlackBYO(wsId, agentId, { bot_token, app_token });
|
||||
// The slack_installation realtime event also refreshes this list, but
|
||||
// invalidate explicitly so the connected badge appears immediately.
|
||||
await qc.invalidateQueries({ queryKey: slackKeys.installations(wsId) });
|
||||
toast.success(t(($) => $.slack.byo_success_toast));
|
||||
setDialogOpen(false);
|
||||
setBotToken("");
|
||||
setAppToken("");
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t(($) => $.slack.byo_failed_toast),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
botToken.trim() !== "" && appToken.trim() !== "" && !submitting;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-wrap items-center gap-2", className)}
|
||||
data-testid="slack-agent-bind-buttons"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
disabled={!agentId}
|
||||
title={
|
||||
agentName
|
||||
? t(($) => $.slack.bind_button_title, { agent: agentName })
|
||||
: undefined
|
||||
}
|
||||
data-testid="slack-agent-connect"
|
||||
>
|
||||
<MessagesSquare className="h-3 w-3" />
|
||||
{t(($) => $.slack.bind_button)}
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(v) => (v ? setDialogOpen(true) : closeDialog())}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg" data-testid="slack-byo-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(($) => $.slack.byo_dialog_title)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(($) => $.slack.byo_dialog_intro)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{SLACK_BYO_VIDEO_URL ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openExternal(SLACK_BYO_VIDEO_URL)}
|
||||
className="inline-flex w-fit items-center gap-1.5 text-xs font-medium text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t(($) => $.slack.byo_video_cta)}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<p className="rounded-md bg-muted px-3 py-2 text-[11px] text-muted-foreground">
|
||||
{t(($) => $.slack.byo_scopes_hint)}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="slack-byo-bot-token">
|
||||
{t(($) => $.slack.byo_bot_token_label)}
|
||||
</Label>
|
||||
<Input
|
||||
id="slack-byo-bot-token"
|
||||
data-testid="slack-byo-bot-token"
|
||||
value={botToken}
|
||||
onChange={(e) => setBotToken(e.target.value)}
|
||||
placeholder="xoxb-…"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t(($) => $.slack.byo_bot_token_hint)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="slack-byo-app-token">
|
||||
{t(($) => $.slack.byo_app_token_label)}
|
||||
</Label>
|
||||
<Input
|
||||
id="slack-byo-app-token"
|
||||
data-testid="slack-byo-app-token"
|
||||
value={appToken}
|
||||
onChange={(e) => setAppToken(e.target.value)}
|
||||
placeholder="xapp-…"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t(($) => $.slack.byo_app_token_hint)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={closeDialog}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t(($) => $.slack.byo_cancel)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
data-testid="slack-byo-submit"
|
||||
>
|
||||
{submitting
|
||||
? t(($) => $.slack.byo_submitting)
|
||||
: t(($) => $.slack.byo_submit)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SlackAgentBotStatusRow is the compact, read-only connected affordance the
|
||||
// agent inspector renders instead of the full badge; it deep-links into the
|
||||
// Integrations tab where Manage / Disconnect live.
|
||||
function SlackAgentBotStatusRow({
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useT("settings");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
className,
|
||||
)}
|
||||
data-testid="slack-agent-bot-status"
|
||||
>
|
||||
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
|
||||
<span className="truncate">{t(($) => $.slack.agent_bot_connected_label)}</span>
|
||||
<ChevronRight className="ml-auto h-3.5 w-3.5 shrink-0" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// SlackAgentBotConnectedBadge is the full "already connected" affordance the
|
||||
// Integrations tab renders in place of the Connect button. Two rows: status +
|
||||
// soft-destructive Disconnect, then a secondary "Open in Slack" link to the
|
||||
// installed workspace. Only owners/admins ever reach this component.
|
||||
function SlackAgentBotConnectedBadge({
|
||||
installation,
|
||||
className,
|
||||
}: {
|
||||
installation: SlackInstallation;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useT("settings");
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [disconnecting, setDisconnecting] = useState(false);
|
||||
|
||||
async function handleDisconnect() {
|
||||
if (disconnecting) return;
|
||||
setDisconnecting(true);
|
||||
try {
|
||||
await api.deleteSlackInstallation(wsId, installation.id);
|
||||
await qc.invalidateQueries({ queryKey: slackKeys.installations(wsId) });
|
||||
toast.success(t(($) => $.slack.toast_disconnected));
|
||||
setConfirmOpen(false);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t(($) => $.slack.toast_disconnect_failed),
|
||||
);
|
||||
} finally {
|
||||
setDisconnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("space-y-2", className)}
|
||||
data-testid="slack-agent-bot-connected"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="inline-flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
|
||||
<span className="truncate">{t(($) => $.slack.agent_bot_connected_label)}</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={disconnecting}
|
||||
title={t(($) => $.slack.agent_bot_disconnect_tooltip)}
|
||||
aria-label={t(($) => $.slack.disconnect)}
|
||||
data-testid="slack-agent-bot-disconnect"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{disconnecting
|
||||
? t(($) => $.slack.disconnecting)
|
||||
: t(($) => $.slack.disconnect)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{installation.team_id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openExternal(`https://app.slack.com/client/${installation.team_id}`)
|
||||
}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
|
||||
title={t(($) => $.slack.agent_bot_manage_tooltip)}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t(($) => $.slack.agent_bot_manage_link)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(v) => {
|
||||
if (!v && !disconnecting) setConfirmOpen(false);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t(($) => $.slack.disconnect_confirm_title)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(($) => $.slack.disconnect_confirm_description)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={disconnecting}>
|
||||
{t(($) => $.slack.disconnect_confirm_cancel)}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDisconnect} disabled={disconnecting}>
|
||||
{disconnecting
|
||||
? t(($) => $.slack.disconnecting)
|
||||
: t(($) => $.slack.disconnect)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
packages/views/slack/bind-page.tsx
Normal file
139
packages/views/slack/bind-page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
type RedeemState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "redeeming" }
|
||||
| { kind: "done"; workspaceId: string; installationId: string }
|
||||
| { kind: "needs-auth" }
|
||||
| { kind: "error"; reason: string };
|
||||
|
||||
// SlackBindPage is the destination the bot's "link your account" prompt points
|
||||
// at (MUL-3666). The user lands here logged out OR logged in; we require auth
|
||||
// before redeeming because the redeemer's Multica identity is taken from the
|
||||
// session (the token alone never proves who is binding — see
|
||||
// slack.BindingTokenService.RedeemAndBind).
|
||||
//
|
||||
// The token comes in via `?token=<raw>`. We POST it to /api/slack/binding/redeem;
|
||||
// the backend returns 410 (invalid/expired), 409 (already bound to another
|
||||
// user), 403 (not a workspace member) or 200 with the bound installation. Each
|
||||
// maps to distinct copy via slack_bind in common.json.
|
||||
export function SlackBindPage({ token }: { token: string | null }) {
|
||||
const { t } = useT("common");
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAuthLoading = useAuthStore((s) => s.isLoading);
|
||||
const navigation = useNavigation();
|
||||
const [state, setState] = useState<RedeemState>({ kind: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setState({ kind: "error", reason: "missing_token" });
|
||||
return;
|
||||
}
|
||||
if (isAuthLoading) return;
|
||||
if (!user) {
|
||||
setState({ kind: "needs-auth" });
|
||||
return;
|
||||
}
|
||||
if (state.kind !== "idle" && state.kind !== "needs-auth") return;
|
||||
setState({ kind: "redeeming" });
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await api.redeemSlackBindingToken(token);
|
||||
setState({
|
||||
kind: "done",
|
||||
workspaceId: resp.workspace_id,
|
||||
installationId: resp.installation_id,
|
||||
});
|
||||
} catch (e) {
|
||||
setState({
|
||||
kind: "error",
|
||||
reason: redemptionFailureReason(e),
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [token, user, isAuthLoading, state.kind]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center p-6">
|
||||
<Card className="w-full">
|
||||
<CardContent className="space-y-4">
|
||||
<h1 className="text-lg font-semibold">{t(($) => $.slack_bind.page_title)}</h1>
|
||||
{state.kind === "idle" || state.kind === "redeeming" ? (
|
||||
<p className="text-sm text-muted-foreground">{t(($) => $.slack_bind.redeeming)}</p>
|
||||
) : state.kind === "needs-auth" ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(($) => $.slack_bind.needs_auth_description)}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigation.push(
|
||||
`/login?next=${encodeURIComponent(
|
||||
`/slack/bind?token=${encodeURIComponent(token ?? "")}`,
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(($) => $.slack_bind.sign_in)}
|
||||
</Button>
|
||||
</>
|
||||
) : state.kind === "done" ? (
|
||||
<>
|
||||
<p className="text-sm font-medium">{t(($) => $.slack_bind.done_title)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.slack_bind.done_description)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium">{t(($) => $.slack_bind.error_title)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(() => {
|
||||
switch (state.reason) {
|
||||
case "missing_token":
|
||||
return t(($) => $.slack_bind.error_missing_token);
|
||||
case "expired":
|
||||
return t(($) => $.slack_bind.error_expired);
|
||||
case "already_bound":
|
||||
return t(($) => $.slack_bind.error_already_bound);
|
||||
case "not_member":
|
||||
return t(($) => $.slack_bind.error_not_member);
|
||||
default:
|
||||
return t(($) => $.slack_bind.error_unknown);
|
||||
}
|
||||
})()}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t(($) => $.slack_bind.error_admin_hint)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function redemptionFailureReason(err: unknown): string {
|
||||
const msg = err instanceof Error ? err.message : "";
|
||||
const lower = msg.toLowerCase();
|
||||
if (lower.includes("invalid") || lower.includes("expired") || lower.includes("410")) {
|
||||
return "expired";
|
||||
}
|
||||
if (lower.includes("already bound") || lower.includes("409")) {
|
||||
return "already_bound";
|
||||
}
|
||||
if (lower.includes("workspace member") || lower.includes("403")) {
|
||||
return "not_member";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
1
packages/views/slack/index.ts
Normal file
1
packages/views/slack/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SlackBindPage } from "./bind-page";
|
||||
@@ -210,12 +210,11 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
// Slack-only deployment has no Lark key). Platform adapters register a
|
||||
// Factory + ResolverSet into it below; the Supervisor enumerates active
|
||||
// installations across ALL channel types and routes each to its
|
||||
// registered platform's Factory. With no platform registered the store
|
||||
// still lists any active installation rows, but Registry.Build returns
|
||||
// ErrUnknownType for them, so the supervisor logs and backs off without
|
||||
// opening a connection (the normal state is simply that no rows exist
|
||||
// for an unregistered platform). The Router is the single shared inbound
|
||||
// handler injected into every Channel.
|
||||
// registered platform's Factory. Installations whose channel_type has no
|
||||
// registered Factory are skipped by the Supervisor — either no platform is
|
||||
// configured, or (Slack/B2) the platform drives ONE deployment-level
|
||||
// connection of its own outside the per-installation supervisor. The Router
|
||||
// is the single shared inbound handler injected into every Channel.
|
||||
channelRegistry := channel.NewRegistry()
|
||||
channelRouter := engine.NewRouter(h.IssueService, h.TaskService, queries, engine.RouterConfig{Logger: slog.Default()})
|
||||
// Debounce the per-session run trigger so a burst of messages collapses
|
||||
@@ -410,26 +409,65 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
slog.Info("lark integration disabled (MULTICA_LARK_SECRET_KEY not set)")
|
||||
}
|
||||
|
||||
// Slack integration (MUL-3516). Gated by MULTICA_SLACK_SECRET_KEY — the key
|
||||
// that decrypts the bot/app tokens stored on the channel_installation row.
|
||||
// When unset the whole block is skipped, so existing deployments are
|
||||
// unaffected; an operator opts in by setting the key and creating a
|
||||
// channel_type='slack' installation (config: app_id=team_id, bot_user_id,
|
||||
// bot_token_encrypted, app_token_encrypted). Registering the Factory
|
||||
// (Socket Mode connect/send) + ResolverSet (inbound pipeline) + the outbound
|
||||
// subscriber (agent reply -> Slack) is all it takes — no engine or core edit,
|
||||
// and Feishu is untouched. The Slack ResolverSet/Outbound share the same
|
||||
// engine.ChatSession, channel_* tables, IssueService and TaskService as
|
||||
// Feishu, so /issue, dedup, and run-triggering behave identically.
|
||||
// Slack integration. Multi-tenant B2 model (MUL-3666): Multica hosts ONE
|
||||
// Slack app, workspaces self-install via OAuth, and inbound runs on a single
|
||||
// deployment-level Socket Mode connection routed by team_id — replacing the
|
||||
// stage-3 per-installation connection model (MUL-3516).
|
||||
//
|
||||
// Two deployment-level env vars gate the two halves:
|
||||
// - MULTICA_SLACK_SECRET_KEY decrypts the per-installation bot token
|
||||
// (xoxb-) stored on the channel_installation row. It gates the inbound
|
||||
// ResolverSet + the outbound reply subscriber, so without it there is no
|
||||
// Slack at all.
|
||||
// - MULTICA_SLACK_APP_TOKEN is the app-level token (xapp-) authorizing the
|
||||
// single Socket Mode connection. It cannot be obtained via OAuth, so it
|
||||
// is a one-time operator config. Without it, inbound is disabled (the
|
||||
// ResolverSet + outbound are still wired so an existing install's replies
|
||||
// keep flowing, but no new events are received).
|
||||
//
|
||||
// The ResolverSet/Outbound share the same engine.ChatSession, channel_*
|
||||
// tables, IssueService and TaskService as Feishu, so /issue, dedup, and
|
||||
// run-triggering behave identically. Feishu is untouched. Each Slack
|
||||
// installation is a bring-your-own-app (BYO) install carrying its OWN
|
||||
// app-level token, so a per-installation Slack Factory is registered and the
|
||||
// Supervisor drives one Socket Mode connection per installation (like Feishu).
|
||||
if slackKey, err := secretbox.LoadKey("MULTICA_SLACK_SECRET_KEY"); err == nil {
|
||||
box, err := secretbox.New(slackKey)
|
||||
if err != nil {
|
||||
slog.Error("slack: secretbox.New failed; slack integration disabled", "error", err)
|
||||
} else {
|
||||
slack.RegisterSlack(channelRegistry, slack.SlackChannelDeps{Decrypt: box.Open, Logger: slog.Default()})
|
||||
channelRouter.Register(slack.TypeSlack, slack.NewSlackResolverSet(queries, pool))
|
||||
// Outbound replier (MUL-3666): delivers NeedsBinding prompt /
|
||||
// AgentOffline / AgentArchived / issue-created notices. The binding
|
||||
// token service mints the single-use token embedded in the prompt's
|
||||
// redeem link; the redeem endpoint (registered below, public) binds
|
||||
// the Slack user to their Multica account.
|
||||
slackBindingSvc := slack.NewBindingTokenService(queries, pool)
|
||||
h.SlackBindingTokens = slackBindingSvc
|
||||
slackReplier := slack.NewOutboundReplier(slack.OutboundReplierConfig{
|
||||
Binding: slackBindingSvc,
|
||||
Decrypt: box.Open,
|
||||
PublicURL: signupConfig.PublicURL,
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
channelRouter.Register(slack.TypeSlack, slack.NewSlackResolverSet(queries, pool, slackReplier))
|
||||
slack.NewOutbound(queries, box.Open, slog.Default()).Register(bus)
|
||||
slog.Info("slack integration enabled")
|
||||
|
||||
// Per-installation inbound: the Supervisor builds + supervises one
|
||||
// Socket Mode connection per active Slack installation, authenticated
|
||||
// with that installation's OWN app-level token (xapp-, pasted at BYO
|
||||
// install) — no deployment-level app token, no single connection.
|
||||
slack.RegisterSlack(channelRegistry, slack.ChannelDeps{Decrypt: box.Open, Logger: slog.Default()})
|
||||
|
||||
// BYO self-serve install (paste bot token + app-level token). The
|
||||
// InstallService needs only the at-rest encryption key — there is no
|
||||
// hosted OAuth client credential.
|
||||
installSvc, ierr := slack.NewInstallService(queries, pool, box, slog.Default())
|
||||
if ierr != nil {
|
||||
slog.Error("slack: InstallService init failed; install disabled", "error", ierr)
|
||||
} else {
|
||||
h.SlackInstall = installSvc
|
||||
}
|
||||
slog.Info("slack integration enabled (BYO per-installation socket mode)")
|
||||
}
|
||||
} else {
|
||||
slog.Info("slack integration disabled (MULTICA_SLACK_SECRET_KEY not set)")
|
||||
@@ -562,6 +600,10 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
// HMAC-SHA256 signature in the handler) and post-install setup callback.
|
||||
r.Post("/api/webhooks/github", h.HandleGitHubWebhook)
|
||||
r.Get("/api/github/setup", h.GitHubSetupCallback)
|
||||
// Slack OAuth callback (no Multica auth in the path — it is hit by Slack's
|
||||
// browser redirect; the workspace/agent/initiator are recovered from the
|
||||
// sealed state). It exchanges the code, upserts the install, then bounces
|
||||
// the browser back to Settings → Integrations.
|
||||
// Stripe webhook (no Multica auth — Stripe signs the raw body
|
||||
// with a shared secret, the multica-cloud upstream verifies. We
|
||||
// only forward the bytes + the Stripe-Signature header; see
|
||||
@@ -714,6 +756,21 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
r.Post("/lark/install/begin", h.BeginLarkInstall)
|
||||
r.Get("/lark/install/{sessionId}/status", h.GetLarkInstallStatus)
|
||||
})
|
||||
|
||||
// Slack integration (MUL-3666). Same admin/member split as
|
||||
// Lark: listing is member-visible; OAuth begin + revoke are
|
||||
// admin-only. The OAuth callback itself is a public route (it is
|
||||
// hit by Slack's browser redirect with no workspace in the path)
|
||||
// and is registered outside this workspace group.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireWorkspaceMemberFromURL(queries, "id"))
|
||||
r.Get("/slack/installations", h.ListSlackInstallations)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner", "admin"))
|
||||
r.Delete("/slack/installations/{installationId}", h.RevokeSlackInstallation)
|
||||
r.Post("/slack/install/byo", h.RegisterSlackBYO)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -724,6 +781,12 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
// the token only proves "this open_id requested binding," and
|
||||
// is combined with the logged-in user to create the mapping.
|
||||
r.Post("/api/lark/binding/redeem", h.RedeemLarkBindingToken)
|
||||
// Slack binding-token redemption. Same rationale as Lark: NOT
|
||||
// workspace-scoped because the redeemer hits this before they have any
|
||||
// workspace context — the redemption itself mints their binding row. The
|
||||
// logged-in user (from the session) is bound to the Slack id the token
|
||||
// carries.
|
||||
r.Post("/api/slack/binding/redeem", h.RedeemSlackBindingToken)
|
||||
|
||||
// User-scoped invitation routes (no workspace context required)
|
||||
r.Get("/api/invitations", h.ListMyInvitations)
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/multica-ai/multica/server/internal/featureflagdispatch"
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
|
||||
"github.com/multica-ai/multica/server/internal/integrations/lark"
|
||||
"github.com/multica-ai/multica/server/internal/integrations/slack"
|
||||
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
|
||||
"github.com/multica-ai/multica/server/internal/middleware"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
@@ -176,7 +177,15 @@ type Handler struct {
|
||||
// delivering events, to flush debounced run triggers and join in-flight
|
||||
// reply goroutines. Built unconditionally (even without Lark).
|
||||
ChannelRouter *engine.Router
|
||||
cfg Config
|
||||
// SlackInstall owns the bring-your-own-app Slack install lifecycle (register
|
||||
// pasted tokens / list / revoke) and the at-rest encryption of each app's bot
|
||||
// + app tokens (MUL-3666). Nil unless MULTICA_SLACK_SECRET_KEY is set.
|
||||
SlackInstall *slack.InstallService
|
||||
// SlackBindingTokens mints/redeems the user-binding tokens behind the
|
||||
// "link your Slack account" prompt (MUL-3666). Nil unless Slack is
|
||||
// configured (MULTICA_SLACK_SECRET_KEY set).
|
||||
SlackBindingTokens *slack.BindingTokenService
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner, analyticsClient analytics.Client, cfg Config, daemonHubs ...*daemonws.Hub) *Handler {
|
||||
|
||||
278
server/internal/handler/slack.go
Normal file
278
server/internal/handler/slack.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/slack"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// SlackInstallationResponse is the wire shape for a Slack installation row. The
|
||||
// encrypted bot token in config is INTENTIONALLY absent — it is server-internal
|
||||
// (only the outbound sender decrypts it). WS lease columns are runtime state,
|
||||
// not API surface, so they are omitted too.
|
||||
type SlackInstallationResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
BotUserID string `json:"bot_user_id"`
|
||||
InstallerUserID string `json:"installer_user_id"`
|
||||
Status string `json:"status"`
|
||||
InstalledAt string `json:"installed_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func slackInstallationToResponse(row db.ChannelInstallation) SlackInstallationResponse {
|
||||
info := slack.DecodePublicConfig(row.Config)
|
||||
return SlackInstallationResponse{
|
||||
ID: uuidToString(row.ID),
|
||||
WorkspaceID: uuidToString(row.WorkspaceID),
|
||||
AgentID: uuidToString(row.AgentID),
|
||||
TeamID: info.TeamID,
|
||||
BotUserID: info.BotUserID,
|
||||
InstallerUserID: uuidToString(row.InstallerUserID),
|
||||
Status: row.Status,
|
||||
InstalledAt: row.InstalledAt.Time.UTC().Format(time.RFC3339),
|
||||
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: row.UpdatedAt.Time.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// ListSlackInstallations (GET /api/workspaces/{id}/slack/installations) is
|
||||
// member-visible so the Integrations tab renders for non-admins. Response
|
||||
// flags mirror Lark:
|
||||
// - configured: at-rest encryption key is set (SlackInstall != nil).
|
||||
// - install_supported: kept for the management UI; true whenever configured,
|
||||
// since a BYO install needs only the at-rest key (no hosted OAuth creds).
|
||||
func (h *Handler) ListSlackInstallations(w http.ResponseWriter, r *http.Request) {
|
||||
if h.SlackInstall == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"installations": []SlackInstallationResponse{},
|
||||
"configured": false,
|
||||
"install_supported": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := h.SlackInstall.ListByWorkspace(r.Context(), wsUUID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list slack installations")
|
||||
return
|
||||
}
|
||||
out := make([]SlackInstallationResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, slackInstallationToResponse(row))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"installations": out,
|
||||
"configured": true,
|
||||
"install_supported": true,
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterSlackBYORequest is the body for a bring-your-own-app install: the two
|
||||
// tokens the user pasted from their own Slack app.
|
||||
type RegisterSlackBYORequest struct {
|
||||
BotToken string `json:"bot_token"`
|
||||
AppToken string `json:"app_token"`
|
||||
}
|
||||
|
||||
// RegisterSlackBYO (POST /api/workspaces/{id}/slack/install/byo?agent_id=…)
|
||||
// installs a user-supplied ("bring your own") Slack app for an agent, so several
|
||||
// agents can each have their own bot identity in the SAME Slack workspace.
|
||||
// Admin-only at the router. Unlike the hosted OAuth path this needs only the
|
||||
// at-rest key configured (SlackInstall != nil), NOT the hosted OAuth client
|
||||
// credentials — BYO is exactly the path for deployments without a hosted app.
|
||||
func (h *Handler) RegisterSlackBYO(w http.ResponseWriter, r *http.Request) {
|
||||
if h.SlackInstall == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "slack integration not enabled")
|
||||
return
|
||||
}
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
agentIDStr := strings.TrimSpace(r.URL.Query().Get("agent_id"))
|
||||
if agentIDStr == "" {
|
||||
writeError(w, http.StatusBadRequest, "agent_id is required")
|
||||
return
|
||||
}
|
||||
agentUUID, ok := parseUUIDOrBadRequest(w, agentIDStr, "agent_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Ownership pre-check at the boundary so a wrong agent_id is a clear 404.
|
||||
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
||||
ID: agentUUID,
|
||||
WorkspaceID: wsUUID,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusNotFound, "agent not found in this workspace")
|
||||
return
|
||||
}
|
||||
initiatorUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body RegisterSlackBYORequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
row, err := h.SlackInstall.RegisterBYO(r.Context(), slack.RegisterBYOParams{
|
||||
WorkspaceID: wsUUID,
|
||||
AgentID: agentUUID,
|
||||
InitiatorID: initiatorUUID,
|
||||
BotToken: body.BotToken,
|
||||
AppToken: body.AppToken,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, slack.ErrInvalidBotToken), errors.Is(err, slack.ErrInvalidAppToken), errors.Is(err, slack.ErrTokenAppMismatch):
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, slack.ErrTeamOwnedByAnotherWorkspace):
|
||||
writeError(w, http.StatusConflict, "this Slack app is already connected to a different Multica workspace")
|
||||
default:
|
||||
// The dominant non-sentinel failure here is auth.test rejecting the
|
||||
// pasted bot token (a user error), so guide the user to recheck the
|
||||
// tokens rather than surfacing an opaque 500.
|
||||
writeError(w, http.StatusBadRequest, "could not verify the Slack tokens — check the bot token and app-level token, that the app is installed to your workspace, and that it has the users:read scope")
|
||||
}
|
||||
return
|
||||
}
|
||||
// Broadcast so every open client (Settings, Agent Integrations, other tabs)
|
||||
// invalidates its installations query and shows the new bot — matching the
|
||||
// revoke event and Lark's install semantics. The installer's own tab also
|
||||
// invalidates locally, but other clients rely on this event.
|
||||
h.publishSlackInstallationCreated(row, userID)
|
||||
writeJSON(w, http.StatusOK, slackInstallationToResponse(row))
|
||||
}
|
||||
|
||||
// publishSlackInstallationCreated emits slack_installation:created for a newly
|
||||
// connected bot. The realtime layer fans it out to the workspace; the web app
|
||||
// listens on slack_installation:* to invalidate the installations query.
|
||||
func (h *Handler) publishSlackInstallationCreated(row db.ChannelInstallation, actorID string) {
|
||||
h.publish(protocol.EventSlackInstallationCreated, uuidToString(row.WorkspaceID), "user", actorID, map[string]any{
|
||||
"id": uuidToString(row.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeSlackInstallation (DELETE /api/workspaces/{id}/slack/installations/{installationId})
|
||||
// flips status to 'revoked'. Admin-only at the router. The row is preserved for
|
||||
// audit; a re-install (re-pasting the app's tokens) flips status back to 'active'.
|
||||
func (h *Handler) RevokeSlackInstallation(w http.ResponseWriter, r *http.Request) {
|
||||
if h.SlackInstall == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "slack integration not configured")
|
||||
return
|
||||
}
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
instUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "installationId"), "installation id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Workspace-scoped lookup so one workspace cannot revoke another's
|
||||
// installation by guessing the UUID.
|
||||
if _, err := h.SlackInstall.GetInWorkspace(r.Context(), instUUID, wsUUID); err != nil {
|
||||
if errors.Is(err, slack.ErrInstallationNotFound) {
|
||||
writeError(w, http.StatusNotFound, "slack installation not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to load installation")
|
||||
return
|
||||
}
|
||||
if err := h.SlackInstall.Revoke(r.Context(), instUUID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to revoke installation")
|
||||
return
|
||||
}
|
||||
h.publish(protocol.EventSlackInstallationRevoked, uuidToString(wsUUID), "user", userID, map[string]any{
|
||||
"id": uuidToString(instUUID),
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RedeemSlackBindingTokenRequest carries the raw token the user clicked through
|
||||
// from the bot's "link your account" prompt.
|
||||
type RedeemSlackBindingTokenRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// RedeemSlackBindingTokenResponse echoes the bound workspace/installation/user
|
||||
// so the frontend can confirm without a second fetch.
|
||||
type RedeemSlackBindingTokenResponse struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
InstallationID string `json:"installation_id"`
|
||||
SlackUserID string `json:"slack_user_id"`
|
||||
}
|
||||
|
||||
// RedeemSlackBindingToken (POST /api/slack/binding/redeem) binds the Slack user
|
||||
// id carried by the token to the logged-in Multica user. The redeemer's identity
|
||||
// comes from the session, not the token, so a stolen token cannot bind a Slack
|
||||
// id to an attacker's account. Failure modes map to distinct status codes:
|
||||
// - 410 Gone: token unknown / consumed / expired
|
||||
// - 409 Conflict: this Slack id is already bound to a different user
|
||||
// - 403 Forbidden: redeemer is not a workspace member
|
||||
func (h *Handler) RedeemSlackBindingToken(w http.ResponseWriter, r *http.Request) {
|
||||
if h.SlackBindingTokens == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "slack integration not configured")
|
||||
return
|
||||
}
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req RedeemSlackBindingTokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Token == "" {
|
||||
writeError(w, http.StatusBadRequest, "token is required")
|
||||
return
|
||||
}
|
||||
userUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
redeemed, err := h.SlackBindingTokens.RedeemAndBind(r.Context(), req.Token, userUUID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, slack.ErrBindingTokenInvalid):
|
||||
writeError(w, http.StatusGone, "binding token invalid or expired")
|
||||
case errors.Is(err, slack.ErrBindingAlreadyAssigned):
|
||||
writeError(w, http.StatusConflict, "this Slack account is already bound to a different Multica user")
|
||||
case errors.Is(err, slack.ErrBindingNotWorkspaceMember):
|
||||
writeError(w, http.StatusForbidden, "binding refused (are you a workspace member?)")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "failed to redeem token")
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, RedeemSlackBindingTokenResponse{
|
||||
WorkspaceID: uuidToString(redeemed.WorkspaceID),
|
||||
InstallationID: uuidToString(redeemed.InstallationID),
|
||||
SlackUserID: redeemed.SlackUserID,
|
||||
})
|
||||
}
|
||||
46
server/internal/handler/slack_test.go
Normal file
46
server/internal/handler/slack_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// A successful BYO install must broadcast slack_installation:created so all open
|
||||
// clients (not just the installer's tab) invalidate the installations query —
|
||||
// the regression Niko's review caught (RegisterSlackBYO previously only wrote
|
||||
// the response). Bus.Publish is synchronous, so the subscriber fires inline.
|
||||
func TestPublishSlackInstallationCreated(t *testing.T) {
|
||||
bus := events.New()
|
||||
h := &Handler{Bus: bus}
|
||||
|
||||
const (
|
||||
wsID = "11111111-1111-1111-1111-111111111111"
|
||||
instID = "22222222-2222-2222-2222-222222222222"
|
||||
)
|
||||
|
||||
var got events.Event
|
||||
fired := 0
|
||||
bus.Subscribe(protocol.EventSlackInstallationCreated, func(e events.Event) {
|
||||
got = e
|
||||
fired++
|
||||
})
|
||||
|
||||
h.publishSlackInstallationCreated(db.ChannelInstallation{
|
||||
ID: parseUUID(instID),
|
||||
WorkspaceID: parseUUID(wsID),
|
||||
}, "user-1")
|
||||
|
||||
if fired != 1 {
|
||||
t.Fatalf("expected slack_installation:created published once, got %d", fired)
|
||||
}
|
||||
if got.WorkspaceID != wsID || got.ActorType != "user" || got.ActorID != "user-1" {
|
||||
t.Errorf("event envelope = %+v", got)
|
||||
}
|
||||
payload, ok := got.Payload.(map[string]any)
|
||||
if !ok || payload["id"] != instID {
|
||||
t.Errorf("payload = %v, want installation id %s", got.Payload, instID)
|
||||
}
|
||||
}
|
||||
@@ -345,6 +345,16 @@ func (s *Supervisor) sweep(ctx context.Context) {
|
||||
}
|
||||
active := make(map[string]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
// Skip channel types with no registered per-installation Factory. Such
|
||||
// rows are driven outside the Supervisor (e.g. Slack's app-level Socket
|
||||
// Mode connector owns ONE deployment connection for all its
|
||||
// installations, so each Slack row carries only outbound creds + routing,
|
||||
// not its own connection). Without this guard the supervise loop would
|
||||
// acquire the lease, hit ErrUnknownType from Registry.Build, release, and
|
||||
// back off forever — churning the lease and the log on every such row.
|
||||
if _, ok := s.registry.Lookup(row.ChannelType); !ok {
|
||||
continue
|
||||
}
|
||||
id := uuidString(row.ID)
|
||||
active[id] = struct{}{}
|
||||
s.maybeRestartOnRotation(id, row)
|
||||
|
||||
@@ -255,6 +255,42 @@ func TestSupervisorAcquiresLeaseAndConnects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupervisorSkipsUnregisteredChannelType covers the B2 (MUL-3666) guard:
|
||||
// an active installation whose channel_type has no registered Factory must be
|
||||
// left alone — never leased, never Built — because it is driven outside the
|
||||
// Supervisor (Slack's app-level connector owns one shared connection for all
|
||||
// its installations). A registered type alongside it still connects normally.
|
||||
func TestSupervisorSkipsUnregisteredChannelType(t *testing.T) {
|
||||
q := newFakeStore()
|
||||
feishuID := uuidFromString(t, "2a111111-1111-1111-1111-111111111111")
|
||||
slackID := uuidFromString(t, "2b222222-2222-2222-2222-222222222222")
|
||||
q.installations = []Installation{
|
||||
activeInst(feishuID, "fp1"),
|
||||
{ID: slackID, ChannelType: channel.Type("slack"), Fingerprint: "fp2", Config: []byte(`{}`)},
|
||||
}
|
||||
|
||||
fc := &fakeChannel{typ: channel.TypeFeishu}
|
||||
var builds int32
|
||||
reg := fakeRegistry(fc, &builds, nil) // registers ONLY TypeFeishu
|
||||
|
||||
sup := NewSupervisor(q, reg, nil, fastConfig())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go sup.Run(ctx)
|
||||
|
||||
if !waitFor(300*time.Millisecond, func() bool { return fc.Connects() >= 1 }) {
|
||||
t.Fatalf("registered feishu installation should connect; connects=%d", fc.Connects())
|
||||
}
|
||||
// Give the supervisor a few sweep cycles to (not) act on the slack row.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if owner, ok := q.leaseHolder(slackID); ok {
|
||||
t.Fatalf("unregistered channel type must never be leased, got owner %q", owner)
|
||||
}
|
||||
if got := atomic.LoadInt32(&builds); got != 1 {
|
||||
t.Fatalf("only the registered feishu channel should be built, builds=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupervisorInjectsHandler(t *testing.T) {
|
||||
q := newFakeStore()
|
||||
instID := uuidFromString(t, "1a111111-1111-1111-1111-111111111111")
|
||||
|
||||
165
server/internal/integrations/slack/binding.go
Normal file
165
server/internal/integrations/slack/binding.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// This file is the Slack user-binding token flow: an unbound Slack user who
|
||||
// messages the bot gets a "link your account" prompt (minted here, delivered by
|
||||
// the OutboundReplier), clicks through to the in-product redeem page, and their
|
||||
// Slack user id is bound to their Multica account. It mirrors
|
||||
// lark.BindingTokenService but runs on the generic channel_* queries with
|
||||
// channel_type='slack' (lark's ChannelStore hardcodes 'feishu').
|
||||
|
||||
// BindingTokenTTL bounds a token's life. The channel_binding_token CHECK
|
||||
// enforces the same 15-minute cap so a misconfigured caller cannot mint longer.
|
||||
const BindingTokenTTL = 15 * time.Minute
|
||||
|
||||
var (
|
||||
// ErrBindingTokenInvalid: token unknown / already consumed / expired. One
|
||||
// opaque error for all three avoids a replay timing oracle.
|
||||
ErrBindingTokenInvalid = errors.New("slack: binding token invalid or expired")
|
||||
// ErrBindingAlreadyAssigned: this Slack user id is already bound to a
|
||||
// different Multica user (account transfer must go through explicit unbind).
|
||||
ErrBindingAlreadyAssigned = errors.New("slack: user id is already bound to a different user")
|
||||
// ErrBindingNotWorkspaceMember: the redeemer is not a member of the token's
|
||||
// workspace. Translated to 403 at the HTTP boundary.
|
||||
ErrBindingNotWorkspaceMember = errors.New("slack: redeemer is not a workspace member")
|
||||
)
|
||||
|
||||
// BindingToken is a freshly minted token. The raw value is returned exactly
|
||||
// once (embedded in the binding URL); only its hash is persisted.
|
||||
type BindingToken struct {
|
||||
Raw string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// RedeemedBindingToken is returned after a successful redemption.
|
||||
type RedeemedBindingToken struct {
|
||||
WorkspaceID pgtype.UUID
|
||||
InstallationID pgtype.UUID
|
||||
SlackUserID string
|
||||
}
|
||||
|
||||
// BindingTokenService mints and redeems Slack binding tokens. Redemption is
|
||||
// transactional: consuming the token and inserting the channel_user_binding row
|
||||
// commit together, so a failed bind never burns a token.
|
||||
type BindingTokenService struct {
|
||||
q *db.Queries
|
||||
tx engine.TxStarter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewBindingTokenService constructs the service. tx (a *pgxpool.Pool) is needed
|
||||
// for the transactional redeem path.
|
||||
func NewBindingTokenService(q *db.Queries, tx engine.TxStarter) *BindingTokenService {
|
||||
return &BindingTokenService{q: q, tx: tx, now: time.Now}
|
||||
}
|
||||
|
||||
// Mint creates a single-use binding token for (installation, slackUserID) and
|
||||
// returns the raw secret + expiry. The raw value must be delivered over Slack
|
||||
// (encrypted in transit by the platform) and never logged.
|
||||
func (s *BindingTokenService) Mint(ctx context.Context, workspaceID, installationID pgtype.UUID, slackUserID string) (BindingToken, error) {
|
||||
raw, err := randomBindingToken(32)
|
||||
if err != nil {
|
||||
return BindingToken{}, fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
expiresAt := s.now().Add(BindingTokenTTL)
|
||||
if _, err := s.q.CreateChannelBindingToken(ctx, db.CreateChannelBindingTokenParams{
|
||||
TokenHash: hashBindingToken(raw),
|
||||
WorkspaceID: workspaceID,
|
||||
InstallationID: installationID,
|
||||
ChannelType: string(TypeSlack),
|
||||
ChannelUserID: slackUserID,
|
||||
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
||||
}); err != nil {
|
||||
return BindingToken{}, fmt.Errorf("persist token: %w", err)
|
||||
}
|
||||
return BindingToken{Raw: raw, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
// RedeemAndBind atomically consumes a raw token and binds the Slack user id to
|
||||
// multicaUserID (taken from the session, never from the token). Returns
|
||||
// ErrBindingTokenInvalid / ErrBindingAlreadyAssigned / ErrBindingNotWorkspaceMember.
|
||||
func (s *BindingTokenService) RedeemAndBind(ctx context.Context, raw string, multicaUserID pgtype.UUID) (RedeemedBindingToken, error) {
|
||||
if s.tx == nil {
|
||||
return RedeemedBindingToken{}, errors.New("slack: BindingTokenService missing TxStarter")
|
||||
}
|
||||
tx, err := s.tx.Begin(ctx)
|
||||
if err != nil {
|
||||
return RedeemedBindingToken{}, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
qtx := s.q.WithTx(tx)
|
||||
|
||||
row, err := qtx.ConsumeChannelBindingToken(ctx, hashBindingToken(raw))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return RedeemedBindingToken{}, ErrBindingTokenInvalid
|
||||
}
|
||||
return RedeemedBindingToken{}, fmt.Errorf("consume token: %w", err)
|
||||
}
|
||||
|
||||
// Explicit membership gate (no member FK): returning before Commit rolls the
|
||||
// consume back, so a non-member's attempt does not burn the token.
|
||||
if _, err := qtx.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: multicaUserID,
|
||||
WorkspaceID: row.WorkspaceID,
|
||||
}); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return RedeemedBindingToken{}, ErrBindingNotWorkspaceMember
|
||||
}
|
||||
return RedeemedBindingToken{}, fmt.Errorf("check membership: %w", err)
|
||||
}
|
||||
|
||||
if _, err := qtx.CreateChannelUserBinding(ctx, db.CreateChannelUserBindingParams{
|
||||
WorkspaceID: row.WorkspaceID,
|
||||
MulticaUserID: multicaUserID,
|
||||
InstallationID: row.InstallationID,
|
||||
ChannelType: string(TypeSlack),
|
||||
ChannelUserID: row.ChannelUserID,
|
||||
Config: []byte(`{}`),
|
||||
}); err != nil {
|
||||
// pgx.ErrNoRows means the existing binding points at a different user —
|
||||
// the ON CONFLICT DO UPDATE WHERE multica_user_id=… gating rejected it.
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return RedeemedBindingToken{}, ErrBindingAlreadyAssigned
|
||||
}
|
||||
return RedeemedBindingToken{}, fmt.Errorf("create binding: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return RedeemedBindingToken{}, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return RedeemedBindingToken{
|
||||
WorkspaceID: row.WorkspaceID,
|
||||
InstallationID: row.InstallationID,
|
||||
SlackUserID: row.ChannelUserID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func randomBindingToken(n int) (string, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func hashBindingToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
189
server/internal/integrations/slack/byo_install.go
Normal file
189
server/internal/integrations/slack/byo_install.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/slack-go/slack"
|
||||
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// ErrInvalidBotToken / ErrInvalidAppToken are returned by RegisterBYO when a
|
||||
// pasted token is malformed (wrong prefix, or an app token whose app id cannot
|
||||
// be parsed). The handler maps them to 400 so the dialog can show a precise hint
|
||||
// instead of a generic failure.
|
||||
var (
|
||||
ErrInvalidBotToken = errors.New("slack: bot token must start with xoxb-")
|
||||
ErrInvalidAppToken = errors.New("slack: app-level token must start with xapp- and embed an app id")
|
||||
// ErrTokenAppMismatch is returned when the pasted bot token and app-level
|
||||
// token belong to DIFFERENT Slack apps. Persisting that pair would "connect"
|
||||
// but be broken: inbound arrives on the app token's socket (routed by its
|
||||
// app id) while mention detection + outbound use the bot token's identity.
|
||||
ErrTokenAppMismatch = errors.New("slack: the bot token and app-level token are from different Slack apps")
|
||||
)
|
||||
|
||||
// RegisterBYOParams are the inputs for a bring-your-own-app install: the agent
|
||||
// this bot represents, who is installing, and the two tokens the user pasted
|
||||
// from their own Slack app.
|
||||
type RegisterBYOParams struct {
|
||||
WorkspaceID pgtype.UUID
|
||||
AgentID pgtype.UUID
|
||||
InitiatorID pgtype.UUID
|
||||
BotToken string // xoxb-… — outbound Web API (chat.postMessage)
|
||||
AppToken string // xapp-… — this app's OWN Socket Mode connection (inbound)
|
||||
}
|
||||
|
||||
// RegisterBYO installs a user-supplied ("bring your own") Slack app for an agent.
|
||||
// The user creates their own Slack app, installs it to their workspace, and
|
||||
// pastes its bot token (xoxb-) + app-level token (xapp-). There is NO OAuth code
|
||||
// exchange: we validate the bot token live via auth.test (which also yields the
|
||||
// team id + bot user id), prove the bot + app tokens belong to the SAME app,
|
||||
// parse the real Slack app id out of the app-level token, encrypt BOTH tokens at
|
||||
// rest, and persist the installation.
|
||||
//
|
||||
// Because each BYO app is a distinct Slack app — a distinct bot identity — the
|
||||
// SAME Slack workspace can host several of them, one per agent. The stored
|
||||
// config carries the real app id for inbound routing; persistInstall keys the
|
||||
// row by (workspace, agent) and refuses the pair if that app id is already
|
||||
// connected to another agent/workspace. The dedicated Socket Mode connection
|
||||
// that consumes the stored app token lives in slack_channel.go; this method
|
||||
// only persists the installation.
|
||||
func (s *InstallService) RegisterBYO(ctx context.Context, p RegisterBYOParams) (db.ChannelInstallation, error) {
|
||||
botToken := strings.TrimSpace(p.BotToken)
|
||||
appToken := strings.TrimSpace(p.AppToken)
|
||||
if !strings.HasPrefix(botToken, "xoxb-") {
|
||||
return db.ChannelInstallation{}, ErrInvalidBotToken
|
||||
}
|
||||
appID, err := parseSlackAppID(appToken)
|
||||
if err != nil {
|
||||
return db.ChannelInstallation{}, err
|
||||
}
|
||||
|
||||
// Validate the bot token live and learn the team + bot user id. auth.test
|
||||
// authenticates with the bot token and returns the bot's OWN user id, which
|
||||
// is the @-mention identity inbound translation strips.
|
||||
auth, err := s.authTest(ctx, botToken)
|
||||
if err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("slack auth.test: %w", err)
|
||||
}
|
||||
if auth.TeamID == "" || auth.UserID == "" || auth.BotID == "" {
|
||||
return db.ChannelInstallation{}, errors.New("slack auth.test: response missing team_id / user_id / bot_id")
|
||||
}
|
||||
|
||||
// Prove the two tokens belong to the SAME Slack app: resolve the bot's
|
||||
// OWNING app id (bots.info on the bot id auth.test returned) and require it to
|
||||
// equal the app id embedded in the app-level token. Without this, pasting app
|
||||
// A's bot token with app B's app token would "connect" but be broken —
|
||||
// inbound arrives on app B's socket (routed by api_app_id=B) while mention
|
||||
// detection + outbound use app A's bot identity / token (Niko review).
|
||||
botAppID, err := s.botAppID(ctx, botToken, auth.BotID)
|
||||
if err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("slack bots.info: %w", err)
|
||||
}
|
||||
if botAppID != appID {
|
||||
return db.ChannelInstallation{}, ErrTokenAppMismatch
|
||||
}
|
||||
|
||||
// Validate the app-level token is live (Socket Mode can actually open) so we
|
||||
// never persist a token that will silently never receive events.
|
||||
if err := s.validateAppToken(ctx, appToken); err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("slack apps.connections.open: %w", err)
|
||||
}
|
||||
|
||||
sealedBot, err := s.box.Seal([]byte(botToken))
|
||||
if err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("encrypt slack bot token: %w", err)
|
||||
}
|
||||
sealedApp, err := s.box.Seal([]byte(appToken))
|
||||
if err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("encrypt slack app token: %w", err)
|
||||
}
|
||||
cfgJSON, err := json.Marshal(installConfig{
|
||||
AppID: appID,
|
||||
TeamID: auth.TeamID,
|
||||
BotUserID: auth.UserID,
|
||||
BotTokenEncrypted: base64.StdEncoding.EncodeToString(sealedBot),
|
||||
AppTokenEncrypted: base64.StdEncoding.EncodeToString(sealedApp),
|
||||
})
|
||||
if err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("encode slack installation config: %w", err)
|
||||
}
|
||||
|
||||
// Persist one bot per agent (the row is keyed by workspace + agent). The
|
||||
// stored config carries the real app id for inbound routing; persistInstall
|
||||
// refuses the pair if that app is already connected to another agent/workspace.
|
||||
return s.persistInstall(ctx, installPersist{
|
||||
wsID: p.WorkspaceID,
|
||||
agentID: p.AgentID,
|
||||
installerID: p.InitiatorID,
|
||||
configJSON: cfgJSON,
|
||||
})
|
||||
}
|
||||
|
||||
// slackOpts builds the slack.Client options shared by the install-time Web API
|
||||
// calls, honoring the apiURL override so tests can point them at an httptest
|
||||
// server. The Slack SDK appends the method name to the endpoint, so the base
|
||||
// must end in a slash. A fresh slice is returned each call (safe to append to).
|
||||
func (s *InstallService) slackOpts() []slack.Option {
|
||||
httpClient := s.httpClient
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
opts := []slack.Option{slack.OptionHTTPClient(httpClient)}
|
||||
if s.apiURL != "" {
|
||||
base := s.apiURL
|
||||
if !strings.HasSuffix(base, "/") {
|
||||
base += "/"
|
||||
}
|
||||
opts = append(opts, slack.OptionAPIURL(base))
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// authTest calls Slack auth.test with the bot token: validates it and returns
|
||||
// the team id, the bot's own user id, and the bot id (for the bots.info lookup).
|
||||
func (s *InstallService) authTest(ctx context.Context, botToken string) (*slack.AuthTestResponse, error) {
|
||||
return slack.New(botToken, s.slackOpts()...).AuthTestContext(ctx)
|
||||
}
|
||||
|
||||
// botAppID resolves the Slack app that OWNS the bot, via bots.info on the bot id
|
||||
// from auth.test. It is the only token→app_id path for a bot token, so it is how
|
||||
// we prove the pasted bot + app tokens belong to the same app.
|
||||
func (s *InstallService) botAppID(ctx context.Context, botToken, botID string) (string, error) {
|
||||
bot, err := slack.New(botToken, s.slackOpts()...).GetBotInfoContext(ctx, slack.GetBotInfoParameters{Bot: botID})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return bot.AppID, nil
|
||||
}
|
||||
|
||||
// validateAppToken confirms the app-level token can open a Socket Mode
|
||||
// connection (apps.connections.open) — a live check that the xapp is valid for
|
||||
// THIS app, so we never store a token that will silently receive nothing.
|
||||
func (s *InstallService) validateAppToken(ctx context.Context, appToken string) error {
|
||||
api := slack.New("", append(s.slackOpts(), slack.OptionAppLevelToken(appToken))...)
|
||||
_, _, err := api.StartSocketModeContext(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// parseSlackAppID extracts the real Slack app id from an app-level token. The
|
||||
// token format is `xapp-1-<APP_ID>-<gen>-<secret>` (e.g. xapp-1-A0BCXGVCS7R-…),
|
||||
// so the app id is the third dash-segment. It is the per-app storage / routing
|
||||
// key that lets multiple BYO apps coexist in one Slack workspace.
|
||||
func parseSlackAppID(appToken string) (string, error) {
|
||||
if !strings.HasPrefix(appToken, "xapp-") {
|
||||
return "", ErrInvalidAppToken
|
||||
}
|
||||
parts := strings.SplitN(appToken, "-", 5)
|
||||
if len(parts) < 4 || parts[2] == "" || !strings.HasPrefix(parts[2], "A") {
|
||||
return "", ErrInvalidAppToken
|
||||
}
|
||||
return parts[2], nil
|
||||
}
|
||||
290
server/internal/integrations/slack/byo_install_test.go
Normal file
290
server/internal/integrations/slack/byo_install_test.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// slackMock parameterizes the install-time Slack API stub. botAppID defaults to
|
||||
// the app id embedded in byoParams' xapp token (so the same-app check passes).
|
||||
type slackMock struct {
|
||||
authOK bool // auth.test result
|
||||
botAppID string // bots.info -> bot.app_id
|
||||
appTokenOK bool // apps.connections.open result
|
||||
}
|
||||
|
||||
// slackMockServer stubs the three Web API calls RegisterBYO makes: auth.test
|
||||
// (bot token), bots.info (bot id -> owning app id), apps.connections.open (app
|
||||
// token live check).
|
||||
func slackMockServer(t *testing.T, m slackMock) *httptest.Server {
|
||||
t.Helper()
|
||||
if m.botAppID == "" {
|
||||
m.botAppID = "A0BCXGVCS7R"
|
||||
}
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/auth.test":
|
||||
if !m.authOK {
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"team_id":"T999","user_id":"UBOTBYO","bot_id":"B0BOT","team":"Acme Inc","url":"https://acme.slack.com/"}`))
|
||||
case "/bots.info":
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"bot":{"id":"B0BOT","app_id":%q,"user_id":"UBOTBYO"}}`, m.botAppID)))
|
||||
case "/apps.connections.open":
|
||||
if !m.appTokenOK {
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"url":"wss://example.test/link"}`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error":"unknown_method"}`))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// authTestServer is the happy-path stub (valid bot token, matching app id, live
|
||||
// app token) unless ok=false, which makes auth.test reject the bot token.
|
||||
func authTestServer(t *testing.T, ok bool) *httptest.Server {
|
||||
return slackMockServer(t, slackMock{authOK: ok, appTokenOK: true})
|
||||
}
|
||||
|
||||
func byoParams(ws, agent string) RegisterBYOParams {
|
||||
return RegisterBYOParams{
|
||||
WorkspaceID: pgtypeUUID(ws),
|
||||
AgentID: pgtypeUUID(agent),
|
||||
InitiatorID: pgtypeUUID("33333333-3333-3333-3333-333333333333"),
|
||||
BotToken: "xoxb-real-bot-token",
|
||||
AppToken: "xapp-1-A0BCXGVCS7R-111-appsecret",
|
||||
}
|
||||
}
|
||||
|
||||
// pgtypeUUID is a test-local UUID parse that panics on bad input (test data is
|
||||
// always valid), so byoParams stays a plain literal.
|
||||
func pgtypeUUID(s string) pgtype.UUID {
|
||||
var u pgtype.UUID
|
||||
if err := u.Scan(s); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestParseSlackAppID(t *testing.T) {
|
||||
cases := []struct {
|
||||
token string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"xapp-1-A0BCXGVCS7R-111-secret", "A0BCXGVCS7R", false},
|
||||
{"xapp-1-A12345-9-abc", "A12345", false},
|
||||
{"xoxb-not-an-app-token", "", true},
|
||||
{"xapp-1-", "", true},
|
||||
{"xapp-1-B123-9-abc", "", true}, // app ids start with A
|
||||
{"", "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := parseSlackAppID(c.token)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("parseSlackAppID(%q) = %q, want error", c.token, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || got != c.want {
|
||||
t.Errorf("parseSlackAppID(%q) = %q, %v; want %q", c.token, got, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBYO_PersistsEncryptedTokensKeyedByAppID(t *testing.T) {
|
||||
srv := authTestServer(t, true)
|
||||
defer srv.Close()
|
||||
|
||||
q := &fakeInstallQueries{rowID: mustUUID(t, "44444444-4444-4444-4444-444444444444")}
|
||||
svc := newTestInstallService(t, q) // BYO needs NO OAuth creds
|
||||
svc.apiURL = srv.URL + "/"
|
||||
|
||||
row, err := svc.RegisterBYO(context.Background(), byoParams(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterBYO: %v", err)
|
||||
}
|
||||
if row.ID != q.rowID {
|
||||
t.Errorf("row id = %v, want %v", row.ID, q.rowID)
|
||||
}
|
||||
if !q.upsertCalled || q.upsertParams.ChannelType != string(TypeSlack) {
|
||||
t.Fatalf("upsert not called for slack: %+v", q.upsertParams)
|
||||
}
|
||||
|
||||
var cfg installConfig
|
||||
if err := json.Unmarshal(q.upsertParams.Config, &cfg); err != nil {
|
||||
t.Fatalf("decode upserted config: %v", err)
|
||||
}
|
||||
// Keyed by the REAL app id (parsed from the xapp token), NOT the team id —
|
||||
// this is what lets several BYO apps share one Slack workspace.
|
||||
if cfg.AppID != "A0BCXGVCS7R" {
|
||||
t.Errorf("config app_id = %q, want the real app id A0BCXGVCS7R", cfg.AppID)
|
||||
}
|
||||
if cfg.TeamID != "T999" || cfg.BotUserID != "UBOTBYO" {
|
||||
t.Errorf("config team/bot = %q/%q, want T999/UBOTBYO", cfg.TeamID, cfg.BotUserID)
|
||||
}
|
||||
// Both tokens stored encrypted (never plaintext) and both decrypt back.
|
||||
if cfg.BotTokenEncrypted == "" || cfg.AppTokenEncrypted == "" {
|
||||
t.Fatalf("both tokens must be stored: %+v", cfg)
|
||||
}
|
||||
if strings.Contains(cfg.BotTokenEncrypted, "xoxb-") || strings.Contains(cfg.AppTokenEncrypted, "xapp-") {
|
||||
t.Error("tokens must be stored encrypted, not plaintext")
|
||||
}
|
||||
botTok, err := decryptToken(cfg.BotTokenEncrypted, svc.box.Open)
|
||||
if err != nil || botTok != "xoxb-real-bot-token" {
|
||||
t.Errorf("decrypted bot token = %q, %v", botTok, err)
|
||||
}
|
||||
appTok, err := decryptToken(cfg.AppTokenEncrypted, svc.box.Open)
|
||||
if err != nil || appTok != "xapp-1-A0BCXGVCS7R-111-appsecret" {
|
||||
t.Errorf("decrypted app token = %q, %v", appTok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBYO_InvalidTokens(t *testing.T) {
|
||||
q := &fakeInstallQueries{}
|
||||
svc := newTestInstallService(t, q)
|
||||
|
||||
// Bad bot token prefix — rejected before any network call or upsert.
|
||||
p := byoParams("11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222")
|
||||
p.BotToken = "nope-not-a-bot-token"
|
||||
if _, err := svc.RegisterBYO(context.Background(), p); err != ErrInvalidBotToken {
|
||||
t.Errorf("bad bot token = %v, want ErrInvalidBotToken", err)
|
||||
}
|
||||
// Bad app token.
|
||||
p = byoParams("11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222")
|
||||
p.AppToken = "xapp-broken"
|
||||
if _, err := svc.RegisterBYO(context.Background(), p); err != ErrInvalidAppToken {
|
||||
t.Errorf("bad app token = %v, want ErrInvalidAppToken", err)
|
||||
}
|
||||
if q.upsertCalled {
|
||||
t.Error("malformed tokens must be rejected before the upsert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBYO_AuthTestFailure(t *testing.T) {
|
||||
srv := authTestServer(t, false) // Slack rejects the bot token
|
||||
defer srv.Close()
|
||||
q := &fakeInstallQueries{}
|
||||
svc := newTestInstallService(t, q)
|
||||
svc.apiURL = srv.URL + "/"
|
||||
|
||||
if _, err := svc.RegisterBYO(context.Background(), byoParams(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
)); err == nil {
|
||||
t.Fatal("expected an error when auth.test rejects the bot token")
|
||||
}
|
||||
if q.upsertCalled {
|
||||
t.Error("a failed auth.test must not persist an installation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBYO_AppAlreadyConnected_Rejected(t *testing.T) {
|
||||
srv := authTestServer(t, true)
|
||||
defer srv.Close()
|
||||
// The pasted app is already connected to another agent / workspace, so the
|
||||
// (channel_type, app_id) routing index rejects the upsert (unique violation).
|
||||
// We must refuse, not steal it.
|
||||
q := &fakeInstallQueries{
|
||||
rowID: mustUUID(t, "44444444-4444-4444-4444-444444444444"),
|
||||
appIDTaken: true,
|
||||
}
|
||||
svc := newTestInstallService(t, q)
|
||||
svc.apiURL = srv.URL + "/"
|
||||
|
||||
if _, err := svc.RegisterBYO(context.Background(), byoParams(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
)); err != ErrTeamOwnedByAnotherWorkspace {
|
||||
t.Fatalf("app already connected = %v, want ErrTeamOwnedByAnotherWorkspace", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBYO_ReconnectSameAgent_UpdatesRowInPlace(t *testing.T) {
|
||||
srv := authTestServer(t, true)
|
||||
defer srv.Close()
|
||||
// The agent already has a Slack row (e.g. a previously-disconnected app).
|
||||
// Re-connecting it — even with a NEW app — must UPDATE that same row in place
|
||||
// (keyed by workspace+agent), not error on the (workspace, agent, channel)
|
||||
// unique. The fake returns the existing row id on the upsert.
|
||||
existingID := mustUUID(t, "55555555-5555-5555-5555-555555555555")
|
||||
q := &fakeInstallQueries{
|
||||
rowID: mustUUID(t, "44444444-4444-4444-4444-444444444444"),
|
||||
existing: &db.ChannelInstallation{
|
||||
ID: existingID,
|
||||
WorkspaceID: mustUUID(t, "11111111-1111-1111-1111-111111111111"),
|
||||
AgentID: mustUUID(t, "22222222-2222-2222-2222-222222222222"),
|
||||
},
|
||||
}
|
||||
svc := newTestInstallService(t, q)
|
||||
svc.apiURL = srv.URL + "/"
|
||||
|
||||
row, err := svc.RegisterBYO(context.Background(), byoParams(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterBYO: %v", err)
|
||||
}
|
||||
if row.ID != existingID {
|
||||
t.Errorf("reconnect should reuse the agent's existing row %v, got %v", existingID, row.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBYO_TokenAppMismatch(t *testing.T) {
|
||||
// The bot token belongs to a DIFFERENT app (bots.info -> A0OTHER) than the
|
||||
// app id embedded in the xapp token (A0BCXGVCS7R) — must be rejected so we
|
||||
// never persist a broken installation (Niko review).
|
||||
srv := slackMockServer(t, slackMock{authOK: true, botAppID: "A0OTHERAPP", appTokenOK: true})
|
||||
defer srv.Close()
|
||||
q := &fakeInstallQueries{}
|
||||
svc := newTestInstallService(t, q)
|
||||
svc.apiURL = srv.URL + "/"
|
||||
|
||||
if _, err := svc.RegisterBYO(context.Background(), byoParams(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
)); err != ErrTokenAppMismatch {
|
||||
t.Fatalf("mismatched tokens = %v, want ErrTokenAppMismatch", err)
|
||||
}
|
||||
if q.upsertCalled {
|
||||
t.Error("mismatched bot/app tokens must be rejected before the upsert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBYO_AppTokenNotLive(t *testing.T) {
|
||||
// auth.test + same-app check pass, but apps.connections.open rejects the app
|
||||
// token — we must not persist a token that will never receive events.
|
||||
srv := slackMockServer(t, slackMock{authOK: true, appTokenOK: false})
|
||||
defer srv.Close()
|
||||
q := &fakeInstallQueries{}
|
||||
svc := newTestInstallService(t, q)
|
||||
svc.apiURL = srv.URL + "/"
|
||||
|
||||
if _, err := svc.RegisterBYO(context.Background(), byoParams(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
)); err == nil {
|
||||
t.Fatal("expected an error when the app-level token is not live")
|
||||
}
|
||||
if q.upsertCalled {
|
||||
t.Error("an invalid app token must not persist an installation")
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,11 @@ package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/slack-go/slack"
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
"github.com/slack-go/slack/socketmode"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
||||
)
|
||||
@@ -25,89 +20,22 @@ const TypeSlack channel.Type = "slack"
|
||||
// a message around 40k characters; we chunk below that with headroom.
|
||||
const maxMessageRunes = 38000
|
||||
|
||||
// slackChannel is the Slack implementation of channel.Channel. One instance is
|
||||
// built per channel_installation by the registered Factory. It holds only what
|
||||
// Connect/Send need (the decoded credentials + an API client); the installation
|
||||
// identity is resolved per message by the Router, so it is absent here — the
|
||||
// same split the Feishu adapter uses.
|
||||
type slackChannel struct {
|
||||
creds credentials
|
||||
api *slack.Client
|
||||
handler channel.InboundHandler
|
||||
logger *slog.Logger
|
||||
mentionRe *regexp.Regexp
|
||||
// slackSender posts agent replies back to Slack via chat.postMessage. It is the
|
||||
// OUTBOUND half: it holds the per-installation bot token (xoxb-) the reply must
|
||||
// be sent with (inbound runs on the per-installation Socket Mode connection in
|
||||
// slack_channel.go). The installation identity (workspace / agent / installer)
|
||||
// is resolved per message by the Router, so it is absent here.
|
||||
type slackSender struct {
|
||||
creds credentials
|
||||
api *slack.Client
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
var _ channel.Channel = (*slackChannel)(nil)
|
||||
|
||||
func (c *slackChannel) Type() channel.Type { return TypeSlack }
|
||||
|
||||
// Connect opens the Slack Socket Mode WebSocket and runs the receive loop,
|
||||
// blocking until ctx is cancelled or the connection drops — the contract
|
||||
// engine.Supervisor relies on to tie lease renewal to connection liveness
|
||||
// (matching feishuChannel.Connect). Each decoded Events API message is
|
||||
// normalized to a channel.InboundMessage and handed to the engine handler. The
|
||||
// envelope is ACKed immediately on receipt (Slack expires un-ACKed envelopes in
|
||||
// ~3s) so the handler's slower DB work never races the ACK.
|
||||
func (c *slackChannel) Connect(ctx context.Context) error {
|
||||
if c.handler == nil {
|
||||
return errors.New("slack: inbound handler not configured")
|
||||
}
|
||||
if c.api == nil {
|
||||
return errors.New("slack: api client not configured")
|
||||
}
|
||||
sm := socketmode.New(c.api)
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() { runErr <- sm.RunContext(ctx) }()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Graceful teardown: the Supervisor cancelled the run context.
|
||||
return nil
|
||||
case err := <-runErr:
|
||||
// The managed connection loop ended. On ctx cancellation this is a
|
||||
// graceful stop; otherwise it is a real failure the Supervisor
|
||||
// retries under backoff.
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("slack: socket mode connection closed")
|
||||
case evt, ok := <-sm.Events:
|
||||
if !ok {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New("slack: socket mode event stream closed")
|
||||
}
|
||||
if err := c.handleSocketEvent(ctx, sm, evt); err != nil {
|
||||
// A handler error is an infrastructure failure (InboundHandler
|
||||
// contract): surface it so the Supervisor tears the connection
|
||||
// down and reconnects under backoff, instead of silently
|
||||
// dropping every subsequent event. ctx cancellation is a
|
||||
// graceful stop, not a failure.
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect is a no-op: the Socket Mode loop is torn down by ctx cancellation
|
||||
// (the Supervisor cancels the run context), mirroring feishuChannel.Disconnect.
|
||||
func (c *slackChannel) Disconnect(ctx context.Context) error { return nil }
|
||||
|
||||
// Send delivers a minimal text reply via chat.postMessage, threading into
|
||||
// out.ThreadID when set so a decoupled reply lands back in the originating
|
||||
// thread. Long bodies are chunked under Slack's per-message cap; the returned
|
||||
// SendResult carries the timestamp of the LAST posted chunk.
|
||||
func (c *slackChannel) Send(ctx context.Context, out channel.OutboundMessage) (channel.SendResult, error) {
|
||||
func (c *slackSender) Send(ctx context.Context, out channel.OutboundMessage) (channel.SendResult, error) {
|
||||
if c.api == nil {
|
||||
return channel.SendResult{}, errors.New("slack: api client not configured")
|
||||
}
|
||||
@@ -132,237 +60,14 @@ func (c *slackChannel) Send(ctx context.Context, out channel.OutboundMessage) (c
|
||||
return channel.SendResult{MessageID: lastTS}, nil
|
||||
}
|
||||
|
||||
// Capabilities declares what the Slack adapter supports TODAY. Declaration
|
||||
// only — the engine performs no degradation, and callers pick a rendering from
|
||||
// these bits, so declaring a capability the Send path cannot fulfil would
|
||||
// mislead them. The minimal Send delivers text into a chat or thread, so only
|
||||
// CapText | CapThreadReply are declared. Block Kit (CapRichCard), file
|
||||
// attachments (CapAttachment) and chat.update edits (CapMessageEdit) are
|
||||
// deferred until those paths are actually wired.
|
||||
func (c *slackChannel) Capabilities() channel.Capability {
|
||||
return channel.CapText | channel.CapThreadReply
|
||||
}
|
||||
|
||||
// ---- inbound ----
|
||||
|
||||
func (c *slackChannel) handleSocketEvent(ctx context.Context, sm *socketmode.Client, evt socketmode.Event) error {
|
||||
switch evt.Type {
|
||||
case socketmode.EventTypeEventsAPI:
|
||||
eventsAPI, ok := evt.Data.(slackevents.EventsAPIEvent)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// ACK first: Slack expires un-ACKed envelopes in ~3s, far below the
|
||||
// handler's DB work. The ACK is independent of the handler outcome —
|
||||
// a handler error is surfaced to the Supervisor (reconnect/backoff),
|
||||
// not retried through the un-ACK path.
|
||||
if evt.Request != nil {
|
||||
if err := sm.Ack(*evt.Request); err != nil {
|
||||
c.logger.WarnContext(ctx, "slack: ack failed", "error", err)
|
||||
}
|
||||
}
|
||||
return c.dispatchEventsAPI(ctx, eventsAPI)
|
||||
case socketmode.EventTypeConnecting, socketmode.EventTypeConnected, socketmode.EventTypeHello:
|
||||
c.logger.DebugContext(ctx, "slack: socket mode", "event", evt.Type)
|
||||
case socketmode.EventTypeIncomingError, socketmode.EventTypeErrorBadMessage:
|
||||
c.logger.WarnContext(ctx, "slack: socket mode error", "event", evt.Type)
|
||||
default:
|
||||
// Interactive / slash-command / other events are out of scope for the
|
||||
// minimal adapter; ACK so Slack does not retry, then ignore.
|
||||
if evt.Request != nil {
|
||||
_ = sm.Ack(*evt.Request)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *slackChannel) dispatchEventsAPI(ctx context.Context, e slackevents.EventsAPIEvent) error {
|
||||
var (
|
||||
msg channel.InboundMessage
|
||||
ok bool
|
||||
)
|
||||
switch inner := e.InnerEvent.Data.(type) {
|
||||
case *slackevents.AppMentionEvent:
|
||||
msg, ok = c.inboundFromAppMention(e, inner)
|
||||
case *slackevents.MessageEvent:
|
||||
msg, ok = c.inboundFromMessage(e, inner)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// A non-nil handler error is an infrastructure failure; propagate it so the
|
||||
// Supervisor reconnects (InboundHandler contract). A legitimate product
|
||||
// drop (dedup hit / unbound sender / group filter) returns nil — not an
|
||||
// error — so it does not tear the connection down.
|
||||
return c.handler(ctx, msg)
|
||||
}
|
||||
|
||||
// inboundFromMessage normalizes a Slack message event. It returns ok=false for
|
||||
// events that must not reach the core: the bot's own messages and other bots'
|
||||
// messages (loop guard), and edits/deletes/joins and similar subtyped system
|
||||
// messages (only brand-new user messages are ingested).
|
||||
//
|
||||
// Group addressing policy (v1, deliberate): a group message is addressed to the
|
||||
// bot only when it carries an explicit <@bot> mention. Mention-free follow-ups
|
||||
// inside a thread the bot is already engaged in are NOT auto-addressed here:
|
||||
// "reply to a bot message" is session state, so it belongs in the session-aware
|
||||
// shared service / resolver layer (which can detect an existing bound session
|
||||
// for the thread and survive reconnects) rather than in per-connection adapter
|
||||
// memory. Until that lands, channel/thread continuation requires re-mentioning
|
||||
// the bot. P2P (DM) ingests every message, unchanged.
|
||||
func (c *slackChannel) inboundFromMessage(e slackevents.EventsAPIEvent, m *slackevents.MessageEvent) (channel.InboundMessage, bool) {
|
||||
if m.BotID != "" || m.SubType == "bot_message" {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
if m.User == "" || (c.creds.BotUserID != "" && m.User == c.creds.BotUserID) {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
if !isIngestableSubtype(m.SubType) {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
|
||||
chatType := slackChatType(m.Channel, m.ChannelType)
|
||||
addressed := chatType == channel.ChatTypeP2P || c.mentionsBot(m.Text)
|
||||
return c.buildInbound(e, buildInboundParams{
|
||||
eventType: "message",
|
||||
subType: m.SubType,
|
||||
channelID: m.Channel,
|
||||
userID: m.User,
|
||||
text: m.Text,
|
||||
ts: m.TimeStamp,
|
||||
threadTS: m.ThreadTimeStamp,
|
||||
chatType: chatType,
|
||||
addressed: addressed,
|
||||
}), true
|
||||
}
|
||||
|
||||
// inboundFromAppMention normalizes an app_mention event. An app_mention is, by
|
||||
// definition, addressed to the bot and occurs in a channel (group). The same
|
||||
// channel @mention also arrives as a message event with the identical ts, so
|
||||
// the engine's (installation, message_id=ts) dedup collapses the pair — no
|
||||
// special-casing needed here.
|
||||
func (c *slackChannel) inboundFromAppMention(e slackevents.EventsAPIEvent, m *slackevents.AppMentionEvent) (channel.InboundMessage, bool) {
|
||||
if m.BotID != "" || m.User == "" || (c.creds.BotUserID != "" && m.User == c.creds.BotUserID) {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
return c.buildInbound(e, buildInboundParams{
|
||||
eventType: "app_mention",
|
||||
channelID: m.Channel,
|
||||
userID: m.User,
|
||||
text: m.Text,
|
||||
ts: m.TimeStamp,
|
||||
threadTS: m.ThreadTimeStamp,
|
||||
chatType: channel.ChatTypeGroup,
|
||||
addressed: true,
|
||||
}), true
|
||||
}
|
||||
|
||||
type buildInboundParams struct {
|
||||
eventType string
|
||||
subType string
|
||||
channelID string
|
||||
userID string
|
||||
text string
|
||||
ts string
|
||||
threadTS string
|
||||
chatType channel.ChatType
|
||||
addressed bool
|
||||
}
|
||||
|
||||
func (c *slackChannel) buildInbound(e slackevents.EventsAPIEvent, p buildInboundParams) channel.InboundMessage {
|
||||
teamID := e.TeamID
|
||||
if teamID == "" {
|
||||
teamID = c.creds.TeamID
|
||||
}
|
||||
raw, _ := json.Marshal(slackRawEvent{
|
||||
TeamID: teamID,
|
||||
APIAppID: e.APIAppID,
|
||||
EventType: p.eventType,
|
||||
SubType: p.subType,
|
||||
ChannelType: string(p.chatType),
|
||||
})
|
||||
var reply *channel.ReplyCtx
|
||||
if p.threadTS != "" && p.threadTS != p.ts {
|
||||
reply = &channel.ReplyCtx{MessageID: p.threadTS, RootID: p.threadTS}
|
||||
}
|
||||
return channel.InboundMessage{
|
||||
EventID: p.ts,
|
||||
MessageID: p.ts,
|
||||
Type: channel.MsgTypeText,
|
||||
Text: c.cleanText(p.text),
|
||||
ReplyTo: reply,
|
||||
AddressedToBot: p.addressed,
|
||||
Source: channel.Source{
|
||||
ChannelType: TypeSlack,
|
||||
ChatID: p.channelID,
|
||||
ChatType: p.chatType,
|
||||
SenderID: p.userID,
|
||||
ThreadID: p.threadTS,
|
||||
},
|
||||
Raw: raw,
|
||||
}
|
||||
}
|
||||
|
||||
// slackRawEvent carries the Slack-specific fields the cross-platform envelope
|
||||
// does not — read back only inside the Slack resolvers (team_id routes the
|
||||
// installation; the core never reads Raw).
|
||||
type slackRawEvent struct {
|
||||
TeamID string `json:"team_id"`
|
||||
APIAppID string `json:"api_app_id,omitempty"`
|
||||
EventType string `json:"event_type"`
|
||||
SubType string `json:"subtype,omitempty"`
|
||||
ChannelType string `json:"channel_type,omitempty"`
|
||||
}
|
||||
|
||||
// cleanText strips a leading/embedded bot mention token and trims surrounding
|
||||
// whitespace so the core sees the user's actual prompt, not "<@U123> hi".
|
||||
func (c *slackChannel) cleanText(text string) string {
|
||||
if c.mentionRe != nil {
|
||||
text = c.mentionRe.ReplaceAllString(text, "")
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// mentionsBot reports whether text contains an @-mention of this bot. Slack
|
||||
// renders a mention as <@U123> or <@U123|name>.
|
||||
func (c *slackChannel) mentionsBot(text string) bool {
|
||||
return c.mentionRe != nil && c.mentionRe.MatchString(text)
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// slackChatType maps a Slack channel id / channel_type to the normalized
|
||||
// ChatType. Only a 1:1 direct message ("im", or a "D…" channel id) is p2p;
|
||||
// everything else — public/private channels AND multi-party DMs ("mpim", which
|
||||
// are multi-person conversations) — is a group. A group routes through the
|
||||
// engine's "must address the bot" filter, so plain chatter in a multi-party DM
|
||||
// is not mistaken for a prompt to the bot.
|
||||
func slackChatType(channelID, channelType string) channel.ChatType {
|
||||
switch channelType {
|
||||
case "im":
|
||||
return channel.ChatTypeP2P
|
||||
case "mpim", "channel", "group", "private_channel":
|
||||
return channel.ChatTypeGroup
|
||||
}
|
||||
if strings.HasPrefix(channelID, "D") {
|
||||
return channel.ChatTypeP2P
|
||||
}
|
||||
return channel.ChatTypeGroup
|
||||
}
|
||||
|
||||
// isIngestableSubtype reports whether a message subtype is a brand-new user
|
||||
// message the core should ingest. Empty subtype is the normal case;
|
||||
// thread_broadcast and file_share are real user messages; everything else
|
||||
// (message_changed, message_deleted, channel_join, …) is a system/edit event.
|
||||
func isIngestableSubtype(subType string) bool {
|
||||
switch subType {
|
||||
case "", "thread_broadcast", "file_share":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
// newSlackSender builds a Send-only client from decoded credentials and a
|
||||
// configured API client. Kept separate from the outbound subscriber so tests
|
||||
// can inject a client pointed at an httptest server.
|
||||
func newSlackSender(creds credentials, api *slack.Client, logger *slog.Logger) *slackSender {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &slackSender{creds: creds, api: api, logger: logger}
|
||||
}
|
||||
|
||||
// outboundThreadTS picks the Slack thread_ts for an outbound reply: an explicit
|
||||
@@ -394,59 +99,3 @@ func chunkMessage(text string, maxRunes int) []string {
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
// ---- registration ----
|
||||
|
||||
// SlackChannelDeps bundles the shared dependencies the Slack Factory closes
|
||||
// over. The inbound handler is supplied per-build by the engine via
|
||||
// channel.Config.Handler, mirroring FeishuChannelDeps.
|
||||
type SlackChannelDeps struct {
|
||||
// Decrypt turns the stored bot/app token ciphertext into plaintext. A nil
|
||||
// Decrypter treats stored tokens as plaintext (tests / un-encrypted dev).
|
||||
Decrypt Decrypter
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// RegisterSlack registers the Slack Factory on reg under TypeSlack so the
|
||||
// engine.Supervisor can build a slackChannel per installation. "Adding a
|
||||
// channel" is this call plus the adapter — no engine edit.
|
||||
func RegisterSlack(reg *channel.Registry, deps SlackChannelDeps) {
|
||||
reg.Register(TypeSlack, newSlackFactory(deps))
|
||||
}
|
||||
|
||||
func newSlackFactory(deps SlackChannelDeps) channel.Factory {
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return func(cfg channel.Config) (channel.Channel, error) {
|
||||
creds, err := decodeCredentials(cfg.Raw, deps.Decrypt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if creds.BotToken == "" || creds.AppToken == "" {
|
||||
return nil, errors.New("slack: installation config missing bot or app token")
|
||||
}
|
||||
return newSlackChannel(creds, slack.New(creds.BotToken, slack.OptionAppLevelToken(creds.AppToken)), cfg.Handler, logger), nil
|
||||
}
|
||||
}
|
||||
|
||||
// newSlackChannel builds a slackChannel from decoded credentials and a
|
||||
// configured API client. Kept separate from the Factory so tests can inject a
|
||||
// client pointed at an httptest server.
|
||||
func newSlackChannel(creds credentials, api *slack.Client, handler channel.InboundHandler, logger *slog.Logger) *slackChannel {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
var mentionRe *regexp.Regexp
|
||||
if creds.BotUserID != "" {
|
||||
mentionRe = regexp.MustCompile(`<@` + regexp.QuoteMeta(creds.BotUserID) + `(\|[^>]*)?>`)
|
||||
}
|
||||
return &slackChannel{
|
||||
creds: creds,
|
||||
api: api,
|
||||
handler: handler,
|
||||
logger: logger,
|
||||
mentionRe: mentionRe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,285 +2,16 @@ package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/slack-go/slack"
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
||||
)
|
||||
|
||||
func testChannel(botUserID string) *slackChannel {
|
||||
return newSlackChannel(credentials{TeamID: "T1", BotUserID: botUserID}, nil, nil, nil)
|
||||
}
|
||||
|
||||
func eventsAPI(inner any) slackevents.EventsAPIEvent {
|
||||
return slackevents.EventsAPIEvent{
|
||||
TeamID: "T1",
|
||||
APIAppID: "A1",
|
||||
InnerEvent: slackevents.EventsAPIInnerEvent{
|
||||
Data: inner,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_DM(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
e := eventsAPI(nil)
|
||||
msg, ok := c.inboundFromMessage(e, &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "hello bot",
|
||||
Channel: "D123",
|
||||
ChannelType: "im",
|
||||
TimeStamp: "1700000000.000100",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected DM message to be ingestable")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeP2P {
|
||||
t.Errorf("ChatType = %q, want p2p", msg.Source.ChatType)
|
||||
}
|
||||
if !msg.AddressedToBot {
|
||||
t.Error("DM should always be addressed to bot")
|
||||
}
|
||||
if msg.Source.ChannelType != TypeSlack {
|
||||
t.Errorf("ChannelType = %q, want slack", msg.Source.ChannelType)
|
||||
}
|
||||
if msg.MessageID != "1700000000.000100" || msg.EventID != msg.MessageID {
|
||||
t.Errorf("MessageID/EventID = %q/%q, want the ts", msg.MessageID, msg.EventID)
|
||||
}
|
||||
if msg.Source.SenderID != "UALICE" || msg.Source.ChatID != "D123" {
|
||||
t.Errorf("sender/chat = %q/%q", msg.Source.SenderID, msg.Source.ChatID)
|
||||
}
|
||||
if msg.Text != "hello bot" {
|
||||
t.Errorf("Text = %q", msg.Text)
|
||||
}
|
||||
// team_id must be in Raw so the installation resolver can route.
|
||||
var raw slackRawEvent
|
||||
if err := json.Unmarshal(msg.Raw, &raw); err != nil {
|
||||
t.Fatalf("decode raw: %v", err)
|
||||
}
|
||||
if raw.TeamID != "T1" || raw.EventType != "message" {
|
||||
t.Errorf("raw = %+v", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_ChannelMention(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "<@UBOT> create an issue",
|
||||
Channel: "C123",
|
||||
ChannelType: "channel",
|
||||
TimeStamp: "1700000000.000200",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected channel message to be ingestable")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeGroup {
|
||||
t.Errorf("ChatType = %q, want group", msg.Source.ChatType)
|
||||
}
|
||||
if !msg.AddressedToBot {
|
||||
t.Error("channel message mentioning the bot should be addressed to bot")
|
||||
}
|
||||
if msg.Text != "create an issue" {
|
||||
t.Errorf("Text = %q, want mention stripped", msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_ChannelNoMention(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "just chatting with the team",
|
||||
Channel: "C123",
|
||||
ChannelType: "channel",
|
||||
TimeStamp: "1700000000.000300",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("a non-mention channel message is still ingested; the engine group filter drops it")
|
||||
}
|
||||
if msg.AddressedToBot {
|
||||
t.Error("channel message without a mention must not be addressed to bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_ThreadReply(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "<@UBOT> follow up",
|
||||
Channel: "C123",
|
||||
ChannelType: "channel",
|
||||
TimeStamp: "1700000000.000500",
|
||||
ThreadTimeStamp: "1700000000.000400",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("thread reply should be ingestable")
|
||||
}
|
||||
if msg.Source.ThreadID != "1700000000.000400" {
|
||||
t.Errorf("ThreadID = %q", msg.Source.ThreadID)
|
||||
}
|
||||
if msg.ReplyTo == nil || msg.ReplyTo.MessageID != "1700000000.000400" {
|
||||
t.Errorf("ReplyTo = %+v, want the thread root", msg.ReplyTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_SkipsBotAndOwnAndEdits(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
cases := []struct {
|
||||
name string
|
||||
m *slackevents.MessageEvent
|
||||
}{
|
||||
{"own message", &slackevents.MessageEvent{User: "UBOT", Text: "hi", Channel: "D1", ChannelType: "im", TimeStamp: "1.1"}},
|
||||
{"other bot", &slackevents.MessageEvent{User: "UX", BotID: "B1", Text: "hi", Channel: "C1", TimeStamp: "1.2"}},
|
||||
{"bot_message subtype", &slackevents.MessageEvent{SubType: "bot_message", Text: "hi", Channel: "C1", TimeStamp: "1.3"}},
|
||||
{"edit", &slackevents.MessageEvent{User: "UALICE", SubType: "message_changed", Text: "hi", Channel: "C1", TimeStamp: "1.4"}},
|
||||
{"delete", &slackevents.MessageEvent{User: "UALICE", SubType: "message_deleted", Channel: "C1", TimeStamp: "1.5"}},
|
||||
{"empty user", &slackevents.MessageEvent{Text: "hi", Channel: "C1", TimeStamp: "1.6"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, ok := c.inboundFromMessage(eventsAPI(nil), tc.m); ok {
|
||||
t.Errorf("%s should not be ingested", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromAppMention(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
msg, ok := c.inboundFromAppMention(eventsAPI(nil), &slackevents.AppMentionEvent{
|
||||
User: "UALICE",
|
||||
Text: "<@UBOT> hi",
|
||||
Channel: "C123",
|
||||
TimeStamp: "1700000000.000700",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("app_mention should be ingestable")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeGroup || !msg.AddressedToBot {
|
||||
t.Errorf("app_mention must be a group message addressed to bot: %+v", msg.Source)
|
||||
}
|
||||
if msg.Text != "hi" {
|
||||
t.Errorf("Text = %q, want mention stripped", msg.Text)
|
||||
}
|
||||
// The bot's own app_mention echo (BotID set) must be skipped.
|
||||
if _, ok := c.inboundFromAppMention(eventsAPI(nil), &slackevents.AppMentionEvent{User: "UBOT", Channel: "C1", TimeStamp: "1.9"}); ok {
|
||||
t.Error("bot's own mention should be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitiesAndType(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
if c.Type() != TypeSlack {
|
||||
t.Errorf("Type = %q", c.Type())
|
||||
}
|
||||
caps := c.Capabilities()
|
||||
if !caps.Has(channel.CapText) || !caps.Has(channel.CapThreadReply) {
|
||||
t.Errorf("capabilities = %s, want text + thread_reply", caps)
|
||||
}
|
||||
// Capabilities the Send path cannot fulfil yet must NOT be declared.
|
||||
for _, cap := range []channel.Capability{channel.CapRichCard, channel.CapAttachment, channel.CapMessageEdit} {
|
||||
if caps.Has(cap) {
|
||||
t.Errorf("capability %s must not be declared until implemented", cap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlackChatType(t *testing.T) {
|
||||
cases := []struct {
|
||||
channelID, channelType string
|
||||
want channel.ChatType
|
||||
}{
|
||||
{"D123", "im", channel.ChatTypeP2P},
|
||||
{"G123", "mpim", channel.ChatTypeGroup}, // multi-party DM is a group
|
||||
{"C123", "channel", channel.ChatTypeGroup},
|
||||
{"C123", "private_channel", channel.ChatTypeGroup},
|
||||
{"D999", "", channel.ChatTypeP2P}, // fallback by id prefix
|
||||
{"C999", "", channel.ChatTypeGroup},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := slackChatType(tc.channelID, tc.channelType); got != tc.want {
|
||||
t.Errorf("slackChatType(%q,%q) = %q, want %q", tc.channelID, tc.channelType, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMpimRequiresMention(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
// A multi-party DM is a group: plain chatter must NOT be addressed to bot.
|
||||
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE", Text: "team lunch?", Channel: "G123", ChannelType: "mpim", TimeStamp: "1.1",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("mpim message should still be ingested (engine group filter decides)")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeGroup {
|
||||
t.Errorf("mpim ChatType = %q, want group", msg.Source.ChatType)
|
||||
}
|
||||
if msg.AddressedToBot {
|
||||
t.Error("plain mpim chatter must not be addressed to bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchEventsAPI_PropagatesHandlerError(t *testing.T) {
|
||||
wantErr := errors.New("db down")
|
||||
calls := 0
|
||||
c := newSlackChannel(credentials{TeamID: "T1", BotUserID: "UBOT"}, nil, func(_ context.Context, _ channel.InboundMessage) error {
|
||||
calls++
|
||||
return wantErr
|
||||
}, nil)
|
||||
|
||||
e := eventsAPI(&slackevents.MessageEvent{User: "UALICE", Text: "hi", Channel: "D1", ChannelType: "im", TimeStamp: "1.1"})
|
||||
if err := c.dispatchEventsAPI(context.Background(), e); !errors.Is(err, wantErr) {
|
||||
t.Errorf("dispatchEventsAPI error = %v, want %v (infra error must propagate to Connect→Supervisor)", err, wantErr)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("handler called %d times, want 1", calls)
|
||||
}
|
||||
|
||||
// A non-ingestable event (the bot's own message) must not reach the handler
|
||||
// and must not error.
|
||||
calls = 0
|
||||
skip := eventsAPI(&slackevents.MessageEvent{User: "UBOT", Text: "echo", Channel: "D1", ChannelType: "im", TimeStamp: "1.2"})
|
||||
if err := c.dispatchEventsAPI(context.Background(), skip); err != nil {
|
||||
t.Errorf("skipped event should not error: %v", err)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Errorf("handler called %d times for skipped event, want 0", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCredentials(t *testing.T) {
|
||||
// app_id holds the team_id routing key; tokens stored as base64 plaintext
|
||||
// here (nil Decrypter = identity).
|
||||
raw := json.RawMessage(`{
|
||||
"app_id": "T1",
|
||||
"bot_user_id": "UBOT",
|
||||
"bot_token_encrypted": "eG94Yi1ib3Q=",
|
||||
"app_token_encrypted": "eGFwcC1hcHA="
|
||||
}`)
|
||||
creds, err := decodeCredentials(raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeCredentials: %v", err)
|
||||
}
|
||||
if creds.TeamID != "T1" || creds.BotUserID != "UBOT" {
|
||||
t.Errorf("creds = %+v", creds)
|
||||
}
|
||||
if creds.BotToken != "xoxb-bot" || creds.AppToken != "xapp-app" {
|
||||
t.Errorf("tokens = %q / %q", creds.BotToken, creds.AppToken)
|
||||
}
|
||||
if _, err := decodeCredentials(nil, nil); err == nil {
|
||||
t.Error("empty config should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkMessage(t *testing.T) {
|
||||
if got := chunkMessage("short", 100); len(got) != 1 || got[0] != "short" {
|
||||
t.Errorf("short message should be one chunk: %v", got)
|
||||
@@ -309,7 +40,7 @@ func TestSend(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
api := slack.New("xoxb-test", slack.OptionAPIURL(srv.URL+"/"))
|
||||
c := newSlackChannel(credentials{TeamID: "T1"}, api, nil, nil)
|
||||
c := newSlackSender(credentials{TeamID: "T1"}, api, nil)
|
||||
|
||||
res, err := c.Send(context.Background(), channel.OutboundMessage{
|
||||
ChatID: "C123",
|
||||
@@ -344,7 +75,7 @@ func TestSend_AppliesMrkdwn(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
api := slack.New("xoxb-test", slack.OptionAPIURL(srv.URL+"/"))
|
||||
c := newSlackChannel(credentials{TeamID: "T1"}, api, nil, nil)
|
||||
c := newSlackSender(credentials{TeamID: "T1"}, api, nil)
|
||||
|
||||
if _, err := c.Send(context.Background(), channel.OutboundMessage{
|
||||
ChatID: "C1",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// Package slack is the Slack implementation of channel.Channel — the second
|
||||
// adapter driven by the channel-agnostic engine (MUL-3516), proving the
|
||||
// MUL-3506 thesis that adding an IM is "implement Channel + register" with no
|
||||
// engine, core, or channel_* schema change. It mirrors the Feishu reference
|
||||
// adapter (server/internal/integrations/lark/feishu_channel.go): Connect runs
|
||||
// the platform receive loop (here Slack Socket Mode, an outbound WebSocket
|
||||
// long-conn that needs no public inbound URL) and hands every decoded event to
|
||||
// the engine's shared inbound handler as a normalized channel.InboundMessage;
|
||||
// Send posts a text reply via chat.postMessage. The design references the
|
||||
// proven Slack adapter in Nous Research's Hermes Agent.
|
||||
// Package slack is the Slack integration for the channel-agnostic engine. It
|
||||
// uses the bring-your-own-app (BYO) model (MUL-3666): each agent's Slack app is
|
||||
// created and installed by the workspace admin, who pastes its bot token (xoxb-)
|
||||
// and app-level token (xapp-) into Multica. Each channel_installation therefore
|
||||
// carries its OWN app-level token and gets its OWN Socket Mode connection,
|
||||
// supervised per-installation by the engine like Feishu (slack_channel.go) — so
|
||||
// several agents can each have a distinct bot identity in one Slack workspace.
|
||||
// Installations are keyed and routed by the real Slack app id
|
||||
// (config->>'app_id' == the inbound event's api_app_id). The inbound translation
|
||||
// (Events API payload -> channel.InboundMessage) lives in inbound.go; the
|
||||
// outbound reply path (chat.postMessage with Markdown->mrkdwn + threading) lives
|
||||
// in channel.go. The design references the proven Slack adapter in Nous
|
||||
// Research's Hermes Agent.
|
||||
package slack
|
||||
|
||||
import (
|
||||
@@ -22,25 +25,26 @@ import (
|
||||
// Slack installation. The cross-platform columns stay flat; everything
|
||||
// Slack-specific lives in this opaque blob (the documented config boundary).
|
||||
//
|
||||
// app_id holds the Slack team_id — the per-installation routing key — so the
|
||||
// generic GetChannelInstallationByAppID query (which reads config->>'app_id')
|
||||
// and the (channel_type, config->>'app_id') unique index route Slack inbound
|
||||
// events with NO new query and NO schema change. team_id is also kept as its
|
||||
// own field for readability; the two carry the same value.
|
||||
// app_id holds the REAL Slack app id (parsed from the xapp- token). It is the
|
||||
// per-installation routing key: the generic GetChannelInstallationByAppID query
|
||||
// (config->>'app_id') and the (channel_type, app_id) unique index map an inbound
|
||||
// event's api_app_id to its installation, so several apps — several agents — in
|
||||
// one Slack workspace stay distinct. team_id is kept for display only.
|
||||
//
|
||||
// Tokens are stored as base64-encoded secretbox ciphertext (never plaintext),
|
||||
// mirroring Feishu's app_secret_encrypted. The bot token (xoxb-…) authorizes
|
||||
// Web API calls (chat.postMessage); the app-level token (xapp-…) authorizes the
|
||||
// Socket Mode connection.
|
||||
// bot_token_encrypted (xoxb-, outbound Web API: chat.postMessage) and
|
||||
// app_token_encrypted (xapp-, this installation's own Socket Mode connection)
|
||||
// are both stored as base64-encoded secretbox ciphertext, never plaintext
|
||||
// (mirroring Feishu's app_secret_encrypted). Both are pasted by the admin at
|
||||
// BYO install time.
|
||||
type installConfig struct {
|
||||
AppID string `json:"app_id"`
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
BotUserID string `json:"bot_user_id,omitempty"`
|
||||
BotTokenEncrypted string `json:"bot_token_encrypted"`
|
||||
AppTokenEncrypted string `json:"app_token_encrypted"`
|
||||
AppTokenEncrypted string `json:"app_token_encrypted,omitempty"`
|
||||
}
|
||||
|
||||
// credentials is the decoded, decrypted form the adapter runs on. The
|
||||
// credentials is the decoded, decrypted form the outbound sender runs on. The
|
||||
// installation IDENTITY (workspace / agent / installer) is deliberately absent:
|
||||
// it is resolved per message by the Router's InstallationResolver, exactly as
|
||||
// the Feishu adapter does.
|
||||
@@ -48,7 +52,6 @@ type credentials struct {
|
||||
TeamID string
|
||||
BotUserID string
|
||||
BotToken string
|
||||
AppToken string
|
||||
}
|
||||
|
||||
// Decrypter turns stored ciphertext into plaintext. The wiring injects a
|
||||
@@ -70,10 +73,6 @@ func decodeCredentials(raw json.RawMessage, decrypt Decrypter) (credentials, err
|
||||
if err != nil {
|
||||
return credentials{}, fmt.Errorf("decrypt bot token: %w", err)
|
||||
}
|
||||
appToken, err := decryptToken(cfg.AppTokenEncrypted, decrypt)
|
||||
if err != nil {
|
||||
return credentials{}, fmt.Errorf("decrypt app token: %w", err)
|
||||
}
|
||||
teamID := cfg.TeamID
|
||||
if teamID == "" {
|
||||
teamID = cfg.AppID
|
||||
@@ -82,10 +81,30 @@ func decodeCredentials(raw json.RawMessage, decrypt Decrypter) (credentials, err
|
||||
TeamID: teamID,
|
||||
BotUserID: cfg.BotUserID,
|
||||
BotToken: botToken,
|
||||
AppToken: appToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PublicConfig is the non-secret subset of an installation config, safe to
|
||||
// surface on the management API (the encrypted bot token is never included).
|
||||
type PublicConfig struct {
|
||||
AppID string
|
||||
TeamID string
|
||||
BotUserID string
|
||||
}
|
||||
|
||||
// DecodePublicConfig extracts the display-safe fields from a stored config blob.
|
||||
// A decode miss yields a zero-value PublicConfig rather than an error: the
|
||||
// management list should still render the row's identity columns.
|
||||
func DecodePublicConfig(raw json.RawMessage) PublicConfig {
|
||||
var cfg installConfig
|
||||
_ = json.Unmarshal(raw, &cfg)
|
||||
teamID := cfg.TeamID
|
||||
if teamID == "" {
|
||||
teamID = cfg.AppID
|
||||
}
|
||||
return PublicConfig{AppID: cfg.AppID, TeamID: teamID, BotUserID: cfg.BotUserID}
|
||||
}
|
||||
|
||||
// decryptToken base64-decodes the stored ciphertext (tolerating the MIME
|
||||
// newline wrapping PostgreSQL's encode(...,'base64') emits) and runs it through
|
||||
// the injected Decrypter. An empty stored value decodes to an empty token; a
|
||||
|
||||
187
server/internal/integrations/slack/inbound.go
Normal file
187
server/internal/integrations/slack/inbound.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
||||
)
|
||||
|
||||
// This file holds the platform-neutral translation from a Slack Events API
|
||||
// payload to the engine's normalized channel.InboundMessage. These are free
|
||||
// functions parameterized by the bot identity rather than methods on the
|
||||
// channel, so the per-installation Socket Mode connection (slack_channel.go)
|
||||
// threads in its own installed bot's user id when translating each event.
|
||||
|
||||
// slackRawEvent carries the Slack-specific fields the cross-platform envelope
|
||||
// does not — read back only inside the Slack resolvers (team_id routes the
|
||||
// installation; the core never reads Raw).
|
||||
type slackRawEvent struct {
|
||||
TeamID string `json:"team_id"`
|
||||
APIAppID string `json:"api_app_id,omitempty"`
|
||||
EventType string `json:"event_type"`
|
||||
SubType string `json:"subtype,omitempty"`
|
||||
ChannelType string `json:"channel_type,omitempty"`
|
||||
}
|
||||
|
||||
// compileMentionRe builds the regexp that matches an @-mention of botUserID.
|
||||
// Slack renders a mention as <@U123> or <@U123|name>. An empty botUserID
|
||||
// (installation not found / not yet known) yields nil — mention detection is
|
||||
// then a no-op, which is safe: DMs and app_mention events do not rely on it,
|
||||
// and an un-routable team is dropped at installation resolution anyway.
|
||||
func compileMentionRe(botUserID string) *regexp.Regexp {
|
||||
if botUserID == "" {
|
||||
return nil
|
||||
}
|
||||
return regexp.MustCompile(`<@` + regexp.QuoteMeta(botUserID) + `(\|[^>]*)?>`)
|
||||
}
|
||||
|
||||
// inboundFromMessage normalizes a Slack message event. It returns ok=false for
|
||||
// events that must not reach the core: the bot's own messages and other bots'
|
||||
// messages (loop guard), and edits/deletes/joins and similar subtyped system
|
||||
// messages (only brand-new user messages are ingested).
|
||||
//
|
||||
// Group addressing policy (v1, deliberate): a group message is addressed to the
|
||||
// bot only when it carries an explicit <@bot> mention. Mention-free follow-ups
|
||||
// inside a thread the bot is already engaged in are NOT auto-addressed here:
|
||||
// "reply to a bot message" is session state, so it belongs in the session-aware
|
||||
// shared service / resolver layer rather than in per-connection adapter memory.
|
||||
// Until that lands, channel/thread continuation requires re-mentioning the bot.
|
||||
// P2P (DM) ingests every message, unchanged.
|
||||
func inboundFromMessage(e slackevents.EventsAPIEvent, m *slackevents.MessageEvent, botUserID string, mentionRe *regexp.Regexp) (channel.InboundMessage, bool) {
|
||||
if m.BotID != "" || m.SubType == "bot_message" {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
if m.User == "" || (botUserID != "" && m.User == botUserID) {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
if !isIngestableSubtype(m.SubType) {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
|
||||
chatType := slackChatType(m.Channel, m.ChannelType)
|
||||
addressed := chatType == channel.ChatTypeP2P || mentionsBot(m.Text, mentionRe)
|
||||
return buildInbound(e, buildInboundParams{
|
||||
eventType: "message",
|
||||
subType: m.SubType,
|
||||
channelID: m.Channel,
|
||||
userID: m.User,
|
||||
text: m.Text,
|
||||
ts: m.TimeStamp,
|
||||
threadTS: m.ThreadTimeStamp,
|
||||
chatType: chatType,
|
||||
addressed: addressed,
|
||||
}, mentionRe), true
|
||||
}
|
||||
|
||||
// inboundFromAppMention normalizes an app_mention event. An app_mention is, by
|
||||
// definition, addressed to the bot and occurs in a channel (group). The same
|
||||
// channel @mention also arrives as a message event with the identical ts, so
|
||||
// the engine's (installation, message_id=ts) dedup collapses the pair — no
|
||||
// special-casing needed here.
|
||||
func inboundFromAppMention(e slackevents.EventsAPIEvent, m *slackevents.AppMentionEvent, botUserID string, mentionRe *regexp.Regexp) (channel.InboundMessage, bool) {
|
||||
if m.BotID != "" || m.User == "" || (botUserID != "" && m.User == botUserID) {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
return buildInbound(e, buildInboundParams{
|
||||
eventType: "app_mention",
|
||||
channelID: m.Channel,
|
||||
userID: m.User,
|
||||
text: m.Text,
|
||||
ts: m.TimeStamp,
|
||||
threadTS: m.ThreadTimeStamp,
|
||||
chatType: channel.ChatTypeGroup,
|
||||
addressed: true,
|
||||
}, mentionRe), true
|
||||
}
|
||||
|
||||
type buildInboundParams struct {
|
||||
eventType string
|
||||
subType string
|
||||
channelID string
|
||||
userID string
|
||||
text string
|
||||
ts string
|
||||
threadTS string
|
||||
chatType channel.ChatType
|
||||
addressed bool
|
||||
}
|
||||
|
||||
func buildInbound(e slackevents.EventsAPIEvent, p buildInboundParams, mentionRe *regexp.Regexp) channel.InboundMessage {
|
||||
raw, _ := json.Marshal(slackRawEvent{
|
||||
TeamID: e.TeamID,
|
||||
APIAppID: e.APIAppID,
|
||||
EventType: p.eventType,
|
||||
SubType: p.subType,
|
||||
ChannelType: string(p.chatType),
|
||||
})
|
||||
var reply *channel.ReplyCtx
|
||||
if p.threadTS != "" && p.threadTS != p.ts {
|
||||
reply = &channel.ReplyCtx{MessageID: p.threadTS, RootID: p.threadTS}
|
||||
}
|
||||
return channel.InboundMessage{
|
||||
EventID: p.ts,
|
||||
MessageID: p.ts,
|
||||
Type: channel.MsgTypeText,
|
||||
Text: cleanText(p.text, mentionRe),
|
||||
ReplyTo: reply,
|
||||
AddressedToBot: p.addressed,
|
||||
Source: channel.Source{
|
||||
ChannelType: TypeSlack,
|
||||
ChatID: p.channelID,
|
||||
ChatType: p.chatType,
|
||||
SenderID: p.userID,
|
||||
ThreadID: p.threadTS,
|
||||
},
|
||||
Raw: raw,
|
||||
}
|
||||
}
|
||||
|
||||
// cleanText strips a leading/embedded bot mention token and trims surrounding
|
||||
// whitespace so the core sees the user's actual prompt, not "<@U123> hi".
|
||||
func cleanText(text string, mentionRe *regexp.Regexp) string {
|
||||
if mentionRe != nil {
|
||||
text = mentionRe.ReplaceAllString(text, "")
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// mentionsBot reports whether text contains an @-mention of this bot.
|
||||
func mentionsBot(text string, mentionRe *regexp.Regexp) bool {
|
||||
return mentionRe != nil && mentionRe.MatchString(text)
|
||||
}
|
||||
|
||||
// slackChatType maps a Slack channel id / channel_type to the normalized
|
||||
// ChatType. Only a 1:1 direct message ("im", or a "D…" channel id) is p2p;
|
||||
// everything else — public/private channels AND multi-party DMs ("mpim", which
|
||||
// are multi-person conversations) — is a group. A group routes through the
|
||||
// engine's "must address the bot" filter, so plain chatter in a multi-party DM
|
||||
// is not mistaken for a prompt to the bot.
|
||||
func slackChatType(channelID, channelType string) channel.ChatType {
|
||||
switch channelType {
|
||||
case "im":
|
||||
return channel.ChatTypeP2P
|
||||
case "mpim", "channel", "group", "private_channel":
|
||||
return channel.ChatTypeGroup
|
||||
}
|
||||
if strings.HasPrefix(channelID, "D") {
|
||||
return channel.ChatTypeP2P
|
||||
}
|
||||
return channel.ChatTypeGroup
|
||||
}
|
||||
|
||||
// isIngestableSubtype reports whether a message subtype is a brand-new user
|
||||
// message the core should ingest. Empty subtype is the normal case;
|
||||
// thread_broadcast and file_share are real user messages; everything else
|
||||
// (message_changed, message_deleted, channel_join, …) is a system/edit event.
|
||||
func isIngestableSubtype(subType string) bool {
|
||||
switch subType {
|
||||
case "", "thread_broadcast", "file_share":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
228
server/internal/integrations/slack/inbound_test.go
Normal file
228
server/internal/integrations/slack/inbound_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
||||
)
|
||||
|
||||
func eventsAPI(inner any) slackevents.EventsAPIEvent {
|
||||
return slackevents.EventsAPIEvent{
|
||||
TeamID: "T1",
|
||||
APIAppID: "A1",
|
||||
InnerEvent: slackevents.EventsAPIInnerEvent{
|
||||
Data: inner,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// translateMessage runs the message-event translation as the AppConnector does:
|
||||
// resolve the team's bot user id, then normalize.
|
||||
func translateMessage(botUserID string, e slackevents.EventsAPIEvent, m *slackevents.MessageEvent) (channel.InboundMessage, bool) {
|
||||
return inboundFromMessage(e, m, botUserID, compileMentionRe(botUserID))
|
||||
}
|
||||
|
||||
func translateAppMention(botUserID string, e slackevents.EventsAPIEvent, m *slackevents.AppMentionEvent) (channel.InboundMessage, bool) {
|
||||
return inboundFromAppMention(e, m, botUserID, compileMentionRe(botUserID))
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_DM(t *testing.T) {
|
||||
msg, ok := translateMessage("UBOT", eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "hello bot",
|
||||
Channel: "D123",
|
||||
ChannelType: "im",
|
||||
TimeStamp: "1700000000.000100",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected DM message to be ingestable")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeP2P {
|
||||
t.Errorf("ChatType = %q, want p2p", msg.Source.ChatType)
|
||||
}
|
||||
if !msg.AddressedToBot {
|
||||
t.Error("DM should always be addressed to bot")
|
||||
}
|
||||
if msg.Source.ChannelType != TypeSlack {
|
||||
t.Errorf("ChannelType = %q, want slack", msg.Source.ChannelType)
|
||||
}
|
||||
if msg.MessageID != "1700000000.000100" || msg.EventID != msg.MessageID {
|
||||
t.Errorf("MessageID/EventID = %q/%q, want the ts", msg.MessageID, msg.EventID)
|
||||
}
|
||||
if msg.Source.SenderID != "UALICE" || msg.Source.ChatID != "D123" {
|
||||
t.Errorf("sender/chat = %q/%q", msg.Source.SenderID, msg.Source.ChatID)
|
||||
}
|
||||
if msg.Text != "hello bot" {
|
||||
t.Errorf("Text = %q", msg.Text)
|
||||
}
|
||||
// team_id must be in Raw so the installation resolver can route.
|
||||
var raw slackRawEvent
|
||||
if err := json.Unmarshal(msg.Raw, &raw); err != nil {
|
||||
t.Fatalf("decode raw: %v", err)
|
||||
}
|
||||
if raw.TeamID != "T1" || raw.EventType != "message" {
|
||||
t.Errorf("raw = %+v", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_ChannelMention(t *testing.T) {
|
||||
msg, ok := translateMessage("UBOT", eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "<@UBOT> create an issue",
|
||||
Channel: "C123",
|
||||
ChannelType: "channel",
|
||||
TimeStamp: "1700000000.000200",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected channel message to be ingestable")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeGroup {
|
||||
t.Errorf("ChatType = %q, want group", msg.Source.ChatType)
|
||||
}
|
||||
if !msg.AddressedToBot {
|
||||
t.Error("channel message mentioning the bot should be addressed to bot")
|
||||
}
|
||||
if msg.Text != "create an issue" {
|
||||
t.Errorf("Text = %q, want mention stripped", msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_ChannelNoMention(t *testing.T) {
|
||||
msg, ok := translateMessage("UBOT", eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "just chatting with the team",
|
||||
Channel: "C123",
|
||||
ChannelType: "channel",
|
||||
TimeStamp: "1700000000.000300",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("a non-mention channel message is still ingested; the engine group filter drops it")
|
||||
}
|
||||
if msg.AddressedToBot {
|
||||
t.Error("channel message without a mention must not be addressed to bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_ThreadReply(t *testing.T) {
|
||||
msg, ok := translateMessage("UBOT", eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "<@UBOT> follow up",
|
||||
Channel: "C123",
|
||||
ChannelType: "channel",
|
||||
TimeStamp: "1700000000.000500",
|
||||
ThreadTimeStamp: "1700000000.000400",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("thread reply should be ingestable")
|
||||
}
|
||||
if msg.Source.ThreadID != "1700000000.000400" {
|
||||
t.Errorf("ThreadID = %q", msg.Source.ThreadID)
|
||||
}
|
||||
if msg.ReplyTo == nil || msg.ReplyTo.MessageID != "1700000000.000400" {
|
||||
t.Errorf("ReplyTo = %+v, want the thread root", msg.ReplyTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_SkipsBotAndOwnAndEdits(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
m *slackevents.MessageEvent
|
||||
}{
|
||||
{"own message", &slackevents.MessageEvent{User: "UBOT", Text: "hi", Channel: "D1", ChannelType: "im", TimeStamp: "1.1"}},
|
||||
{"other bot", &slackevents.MessageEvent{User: "UX", BotID: "B1", Text: "hi", Channel: "C1", TimeStamp: "1.2"}},
|
||||
{"bot_message subtype", &slackevents.MessageEvent{SubType: "bot_message", Text: "hi", Channel: "C1", TimeStamp: "1.3"}},
|
||||
{"edit", &slackevents.MessageEvent{User: "UALICE", SubType: "message_changed", Text: "hi", Channel: "C1", TimeStamp: "1.4"}},
|
||||
{"delete", &slackevents.MessageEvent{User: "UALICE", SubType: "message_deleted", Channel: "C1", TimeStamp: "1.5"}},
|
||||
{"empty user", &slackevents.MessageEvent{Text: "hi", Channel: "C1", TimeStamp: "1.6"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, ok := translateMessage("UBOT", eventsAPI(nil), tc.m); ok {
|
||||
t.Errorf("%s should not be ingested", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromAppMention(t *testing.T) {
|
||||
msg, ok := translateAppMention("UBOT", eventsAPI(nil), &slackevents.AppMentionEvent{
|
||||
User: "UALICE",
|
||||
Text: "<@UBOT> hi",
|
||||
Channel: "C123",
|
||||
TimeStamp: "1700000000.000700",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("app_mention should be ingestable")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeGroup || !msg.AddressedToBot {
|
||||
t.Errorf("app_mention must be a group message addressed to bot: %+v", msg.Source)
|
||||
}
|
||||
if msg.Text != "hi" {
|
||||
t.Errorf("Text = %q, want mention stripped", msg.Text)
|
||||
}
|
||||
// The bot's own app_mention echo (BotID set) must be skipped.
|
||||
if _, ok := translateAppMention("UBOT", eventsAPI(nil), &slackevents.AppMentionEvent{User: "UBOT", Channel: "C1", TimeStamp: "1.9"}); ok {
|
||||
t.Error("bot's own mention should be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlackChatType(t *testing.T) {
|
||||
cases := []struct {
|
||||
channelID, channelType string
|
||||
want channel.ChatType
|
||||
}{
|
||||
{"D123", "im", channel.ChatTypeP2P},
|
||||
{"G123", "mpim", channel.ChatTypeGroup}, // multi-party DM is a group
|
||||
{"C123", "channel", channel.ChatTypeGroup},
|
||||
{"C123", "private_channel", channel.ChatTypeGroup},
|
||||
{"D999", "", channel.ChatTypeP2P}, // fallback by id prefix
|
||||
{"C999", "", channel.ChatTypeGroup},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := slackChatType(tc.channelID, tc.channelType); got != tc.want {
|
||||
t.Errorf("slackChatType(%q,%q) = %q, want %q", tc.channelID, tc.channelType, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMpimRequiresMention(t *testing.T) {
|
||||
// A multi-party DM is a group: plain chatter must NOT be addressed to bot.
|
||||
msg, ok := translateMessage("UBOT", eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE", Text: "team lunch?", Channel: "G123", ChannelType: "mpim", TimeStamp: "1.1",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("mpim message should still be ingested (engine group filter decides)")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeGroup {
|
||||
t.Errorf("mpim ChatType = %q, want group", msg.Source.ChatType)
|
||||
}
|
||||
if msg.AddressedToBot {
|
||||
t.Error("plain mpim chatter must not be addressed to bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCredentials(t *testing.T) {
|
||||
// app_id holds the team_id routing key; the bot token is stored as base64
|
||||
// plaintext here (nil Decrypter = identity).
|
||||
raw := json.RawMessage(`{
|
||||
"app_id": "T1",
|
||||
"bot_user_id": "UBOT",
|
||||
"bot_token_encrypted": "eG94Yi1ib3Q="
|
||||
}`)
|
||||
creds, err := decodeCredentials(raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeCredentials: %v", err)
|
||||
}
|
||||
if creds.TeamID != "T1" || creds.BotUserID != "UBOT" {
|
||||
t.Errorf("creds = %+v", creds)
|
||||
}
|
||||
if creds.BotToken != "xoxb-bot" {
|
||||
t.Errorf("bot token = %q", creds.BotToken)
|
||||
}
|
||||
if _, err := decodeCredentials(nil, nil); err == nil {
|
||||
t.Error("empty config should error")
|
||||
}
|
||||
}
|
||||
200
server/internal/integrations/slack/install.go
Normal file
200
server/internal/integrations/slack/install.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
|
||||
"github.com/multica-ai/multica/server/internal/util/secretbox"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// This file is the Slack install backend (MUL-3666). Slack uses the
|
||||
// bring-your-own-app (BYO) model: the workspace admin creates their own Slack
|
||||
// app, installs it to their Slack workspace, and pastes its bot token (xoxb-) +
|
||||
// app-level token (xapp-) into Multica (the paste path lives in byo_install.go).
|
||||
// The InstallService owns the at-rest encryption of those tokens — so no caller
|
||||
// can write a channel_installation with a plaintext token — plus the shared
|
||||
// persistInstall transaction and the list / get / revoke management surface.
|
||||
|
||||
var (
|
||||
// ErrInstallationNotFound surfaces "no row matches in this workspace".
|
||||
ErrInstallationNotFound = errors.New("slack installation not found")
|
||||
// ErrTeamOwnedByAnotherWorkspace is returned when the pasted Slack app is
|
||||
// already connected to a DIFFERENT agent or Multica workspace — it would
|
||||
// collide with the (channel_type, app_id) routing index. A Slack app is one
|
||||
// bot identity and maps to one agent; reusing it elsewhere requires
|
||||
// disconnecting it there first.
|
||||
ErrTeamOwnedByAnotherWorkspace = errors.New("slack: this Slack app is already connected to another agent or Multica workspace")
|
||||
)
|
||||
|
||||
// installQueries is the slice of generated queries InstallService needs. WithTx
|
||||
// returns the same interface bound to a transaction so persistInstall runs its
|
||||
// upsert atomically (and so tests can inject a fake without a real DB).
|
||||
type installQueries interface {
|
||||
WithTx(tx pgx.Tx) installQueries
|
||||
UpsertChannelInstallation(ctx context.Context, arg db.UpsertChannelInstallationParams) (db.ChannelInstallation, error)
|
||||
ListChannelInstallationsByWorkspace(ctx context.Context, arg db.ListChannelInstallationsByWorkspaceParams) ([]db.ChannelInstallation, error)
|
||||
GetChannelInstallationInWorkspace(ctx context.Context, arg db.GetChannelInstallationInWorkspaceParams) (db.ChannelInstallation, error)
|
||||
SetChannelInstallationStatus(ctx context.Context, arg db.SetChannelInstallationStatusParams) error
|
||||
}
|
||||
|
||||
// dbInstallQueries adapts *db.Queries to installQueries — the generated WithTx
|
||||
// returns *db.Queries, so we wrap it to return the interface (the same adapter
|
||||
// pattern engine.ChatSession uses).
|
||||
type dbInstallQueries struct{ *db.Queries }
|
||||
|
||||
func (q dbInstallQueries) WithTx(tx pgx.Tx) installQueries {
|
||||
return dbInstallQueries{q.Queries.WithTx(tx)}
|
||||
}
|
||||
|
||||
// InstallService owns the at-rest encryption of the bot + app tokens (so no
|
||||
// caller can write a channel_installation with a plaintext token) and the shared
|
||||
// install transaction. The box MUST be non-nil (we refuse plaintext storage even
|
||||
// in dev).
|
||||
type InstallService struct {
|
||||
box *secretbox.Box
|
||||
q installQueries
|
||||
tx engine.TxStarter
|
||||
httpClient *http.Client
|
||||
logger *slog.Logger
|
||||
|
||||
// apiURL overrides the Slack API base for the BYO auth.test call (tests point
|
||||
// it at an httptest server). Empty uses the real Slack API.
|
||||
apiURL string
|
||||
}
|
||||
|
||||
// NewInstallService binds the service to queries, a tx starter (*pgxpool.Pool),
|
||||
// and an encryption box. Listing / revoking and BYO register all require only
|
||||
// the box (the at-rest key); there is no hosted OAuth credential.
|
||||
func NewInstallService(q *db.Queries, tx engine.TxStarter, box *secretbox.Box, logger *slog.Logger) (*InstallService, error) {
|
||||
if q == nil {
|
||||
return nil, errors.New("slack: InstallService requires queries")
|
||||
}
|
||||
return newInstallService(dbInstallQueries{q}, tx, box, logger)
|
||||
}
|
||||
|
||||
// newInstallService is the testable core: it takes the installQueries interface
|
||||
// so tests can inject a fake (with a fake TxStarter) without a real DB.
|
||||
func newInstallService(q installQueries, tx engine.TxStarter, box *secretbox.Box, logger *slog.Logger) (*InstallService, error) {
|
||||
if box == nil {
|
||||
return nil, errors.New("slack: InstallService requires a non-nil secretbox.Box")
|
||||
}
|
||||
if q == nil {
|
||||
return nil, errors.New("slack: InstallService requires queries")
|
||||
}
|
||||
if tx == nil {
|
||||
return nil, errors.New("slack: InstallService requires a tx starter")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &InstallService{
|
||||
box: box,
|
||||
q: q,
|
||||
tx: tx,
|
||||
httpClient: http.DefaultClient,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// installPersist carries the resolved fields persistInstall writes. appIDKey is
|
||||
// the value stored at config->>'app_id' — the real Slack app id — and MUST equal
|
||||
// the app_id inside configJSON; it is the lookup / ON CONFLICT key. installerSlackID
|
||||
// is the installer's Slack user id to auto-bind, or "" to skip (a BYO paste
|
||||
// carries no authed_user, so the installer binds via the normal token flow on
|
||||
// first message).
|
||||
type installPersist struct {
|
||||
wsID pgtype.UUID
|
||||
agentID pgtype.UUID
|
||||
installerID pgtype.UUID
|
||||
// configJSON holds the Slack app id (config->>'app_id') used for inbound
|
||||
// routing; the ROW itself is keyed by (workspace, agent) — one bot per agent.
|
||||
configJSON []byte
|
||||
}
|
||||
|
||||
// pgUniqueViolation is the Postgres SQLSTATE for a unique-constraint violation.
|
||||
const pgUniqueViolation = "23505"
|
||||
|
||||
// persistInstall upserts the installation keyed by (workspace_id, agent_id,
|
||||
// channel_type): ONE Slack bot per agent. Re-connecting an agent — including
|
||||
// swapping it to a NEW Slack app after a disconnect — UPDATES that agent's row
|
||||
// in place instead of colliding with the (workspace, agent, channel) unique.
|
||||
//
|
||||
// The (channel_type, app_id) routing index is the only OTHER unique constraint,
|
||||
// and it is NOT this upsert's conflict target, so a unique violation here means
|
||||
// the pasted Slack app is already connected to a DIFFERENT agent or Multica
|
||||
// workspace — refuse it (ErrTeamOwnedByAnotherWorkspace) rather than steal it.
|
||||
// No chat-session retire is needed: a row's agent_id never changes (it is part
|
||||
// of the key), so existing sessions stay valid for the same agent.
|
||||
func (s *InstallService) persistInstall(ctx context.Context, p installPersist) (db.ChannelInstallation, error) {
|
||||
tx, err := s.tx.Begin(ctx)
|
||||
if err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("begin install tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
qtx := s.q.WithTx(tx)
|
||||
|
||||
inst, err := qtx.UpsertChannelInstallation(ctx, db.UpsertChannelInstallationParams{
|
||||
WorkspaceID: p.wsID,
|
||||
AgentID: p.agentID,
|
||||
ChannelType: string(TypeSlack),
|
||||
Config: p.configJSON,
|
||||
InstallerUserID: p.installerID,
|
||||
})
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == pgUniqueViolation {
|
||||
return db.ChannelInstallation{}, ErrTeamOwnedByAnotherWorkspace
|
||||
}
|
||||
return db.ChannelInstallation{}, fmt.Errorf("upsert slack installation: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("commit slack install: %w", err)
|
||||
}
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
// ListByWorkspace returns every Slack installation in the workspace (active and
|
||||
// revoked), for the management surface.
|
||||
func (s *InstallService) ListByWorkspace(ctx context.Context, wsID pgtype.UUID) ([]db.ChannelInstallation, error) {
|
||||
return s.q.ListChannelInstallationsByWorkspace(ctx, db.ListChannelInstallationsByWorkspaceParams{
|
||||
WorkspaceID: wsID,
|
||||
ChannelType: string(TypeSlack),
|
||||
})
|
||||
}
|
||||
|
||||
// GetInWorkspace is the workspace-scoped lookup so a forged installation id from
|
||||
// another workspace returns NotFound instead of leaking existence.
|
||||
func (s *InstallService) GetInWorkspace(ctx context.Context, id, wsID pgtype.UUID) (db.ChannelInstallation, error) {
|
||||
inst, err := s.q.GetChannelInstallationInWorkspace(ctx, db.GetChannelInstallationInWorkspaceParams{
|
||||
ID: id,
|
||||
WorkspaceID: wsID,
|
||||
ChannelType: string(TypeSlack),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return db.ChannelInstallation{}, ErrInstallationNotFound
|
||||
}
|
||||
return db.ChannelInstallation{}, err
|
||||
}
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
// Revoke flips status to 'revoked'. The row is preserved for audit; a re-install
|
||||
// flips it back to 'active'. The Supervisor stops supervising the installation
|
||||
// (ListActiveInstallations filters to active), so its Socket Mode connection
|
||||
// winds down, and outbound drops too.
|
||||
func (s *InstallService) Revoke(ctx context.Context, id pgtype.UUID) error {
|
||||
return s.q.SetChannelInstallationStatus(ctx, db.SetChannelInstallationStatusParams{
|
||||
ID: id,
|
||||
Status: "revoked",
|
||||
})
|
||||
}
|
||||
109
server/internal/integrations/slack/install_test.go
Normal file
109
server/internal/integrations/slack/install_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
"github.com/multica-ai/multica/server/internal/util/secretbox"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
func testBox(t *testing.T) *secretbox.Box {
|
||||
t.Helper()
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 1)
|
||||
}
|
||||
box, err := secretbox.New(key)
|
||||
if err != nil {
|
||||
t.Fatalf("secretbox.New: %v", err)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
func mustUUID(t *testing.T, s string) pgtype.UUID {
|
||||
t.Helper()
|
||||
u, err := util.ParseUUID(s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse uuid %q: %v", s, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
type fakeInstallQueries struct {
|
||||
// existing, when set, is the agent's current row; UpsertChannelInstallation
|
||||
// returns it (an UPDATE) so a reconnect reuses the same row id.
|
||||
existing *db.ChannelInstallation
|
||||
// appIDTaken makes UpsertChannelInstallation report a unique-constraint
|
||||
// violation on the (channel_type, app_id) routing index — i.e. the pasted app
|
||||
// is already connected to another agent / workspace.
|
||||
appIDTaken bool
|
||||
upsertParams db.UpsertChannelInstallationParams
|
||||
upsertCalled bool
|
||||
rowID pgtype.UUID
|
||||
}
|
||||
|
||||
// WithTx returns the same fake — the fake tx is a no-op token.
|
||||
func (f *fakeInstallQueries) WithTx(_ pgx.Tx) installQueries { return f }
|
||||
|
||||
func (f *fakeInstallQueries) UpsertChannelInstallation(_ context.Context, arg db.UpsertChannelInstallationParams) (db.ChannelInstallation, error) {
|
||||
f.upsertCalled = true
|
||||
f.upsertParams = arg
|
||||
if f.appIDTaken {
|
||||
return db.ChannelInstallation{}, &pgconn.PgError{Code: "23505"}
|
||||
}
|
||||
id := f.rowID
|
||||
if f.existing != nil {
|
||||
id = f.existing.ID // reconnect updates the agent's existing row in place
|
||||
}
|
||||
return db.ChannelInstallation{
|
||||
ID: id,
|
||||
WorkspaceID: arg.WorkspaceID,
|
||||
AgentID: arg.AgentID,
|
||||
ChannelType: arg.ChannelType,
|
||||
Config: arg.Config,
|
||||
InstallerUserID: arg.InstallerUserID,
|
||||
Status: "active",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeInstallQueries) ListChannelInstallationsByWorkspace(_ context.Context, _ db.ListChannelInstallationsByWorkspaceParams) ([]db.ChannelInstallation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeInstallQueries) GetChannelInstallationInWorkspace(_ context.Context, _ db.GetChannelInstallationInWorkspaceParams) (db.ChannelInstallation, error) {
|
||||
return db.ChannelInstallation{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeInstallQueries) SetChannelInstallationStatus(_ context.Context, _ db.SetChannelInstallationStatusParams) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeTx is a no-op pgx.Tx: embedding the interface satisfies it, and the
|
||||
// install paths only ever call Commit / Rollback. committed records whether the
|
||||
// install committed (the happy path) vs rolled back (a rejected install).
|
||||
type fakeTx struct {
|
||||
pgx.Tx
|
||||
committed bool
|
||||
}
|
||||
|
||||
func (t *fakeTx) Commit(context.Context) error { t.committed = true; return nil }
|
||||
func (t *fakeTx) Rollback(context.Context) error { return nil }
|
||||
|
||||
type fakeTxStarter struct{ tx *fakeTx }
|
||||
|
||||
func (f *fakeTxStarter) Begin(context.Context) (pgx.Tx, error) { return f.tx, nil }
|
||||
|
||||
func newTestInstallService(t *testing.T, q installQueries) *InstallService {
|
||||
t.Helper()
|
||||
svc, err := newInstallService(q, &fakeTxStarter{tx: &fakeTx{}}, testBox(t), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newInstallService: %v", err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
@@ -25,7 +25,7 @@ type outboundQueries interface {
|
||||
GetChannelInstallation(ctx context.Context, arg db.GetChannelInstallationParams) (db.ChannelInstallation, error)
|
||||
}
|
||||
|
||||
// replySender posts one reply. Satisfied by *slackChannel, so the outbound path
|
||||
// replySender posts one reply. Satisfied by *slackSender, so the outbound path
|
||||
// reuses Send's Markdown->mrkdwn conversion, chunking, and threading.
|
||||
type replySender interface {
|
||||
Send(ctx context.Context, out channel.OutboundMessage) (channel.SendResult, error)
|
||||
@@ -52,9 +52,9 @@ func NewOutbound(q outboundQueries, decrypt Decrypter, logger *slog.Logger) *Out
|
||||
}
|
||||
o := &Outbound{q: q, decrypt: decrypt, logger: logger}
|
||||
o.newSender = func(c credentials) replySender {
|
||||
// Only the bot token is needed to post; the app token is a Socket Mode
|
||||
// (inbound) credential.
|
||||
return newSlackChannel(c, slack.New(c.BotToken), nil, logger)
|
||||
// Only the bot token is needed to post; inbound Socket Mode uses the
|
||||
// installation's separate app-level token (see slack_channel.go).
|
||||
return newSlackSender(c, slack.New(c.BotToken), logger)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ func slackInstallConfigJSON() []byte {
|
||||
"app_id": "T1",
|
||||
"bot_user_id": "UBOT",
|
||||
"bot_token_encrypted": base64.StdEncoding.EncodeToString([]byte("xoxb-test")),
|
||||
"app_token_encrypted": base64.StdEncoding.EncodeToString([]byte("xapp-test")),
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
183
server/internal/integrations/slack/replier.go
Normal file
183
server/internal/integrations/slack/replier.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/slack-go/slack"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// This file is the Slack OutboundReplier — the engine seam that delivers a
|
||||
// verdict-driven reply back to the user (MUL-3666, completing the stage-3
|
||||
// Replier=nil tail). It posts through the same bot-token Send path as the
|
||||
// EventChatDone outbound subscriber, so it needs no new transport.
|
||||
//
|
||||
// Outcomes handled:
|
||||
// - NeedsBinding: the sender is unbound. Mint a single-use binding token and
|
||||
// reply with a "link your account" prompt pointing at the in-product redeem
|
||||
// page. After they bind, their next message reaches the agent.
|
||||
// - AgentOffline / AgentArchived: a status notice so the user is not left
|
||||
// wondering why nothing happened.
|
||||
// - Ingested with an /issue created: a confirmation of the new issue.
|
||||
|
||||
const (
|
||||
agentOfflineText = "⚠️ The agent is offline right now. Your message was received and will be handled once it's back online."
|
||||
agentArchivedText = "⚠️ This agent has been archived and can't respond. Please contact your workspace admin."
|
||||
)
|
||||
|
||||
// bindingMinter is the binding-token surface the replier needs.
|
||||
// *BindingTokenService satisfies it.
|
||||
type bindingMinter interface {
|
||||
Mint(ctx context.Context, workspaceID, installationID pgtype.UUID, slackUserID string) (BindingToken, error)
|
||||
}
|
||||
|
||||
// OutboundReplier implements engine.OutboundReplier for Slack.
|
||||
type OutboundReplier struct {
|
||||
binding bindingMinter
|
||||
decrypt Decrypter
|
||||
newSender func(creds credentials) replySender
|
||||
publicURL string
|
||||
bindingPath string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// OutboundReplierConfig configures the replier. Binding + PublicURL are required
|
||||
// for the NeedsBinding prompt to work; without them the prompt is skipped (the
|
||||
// offline/archived/issue notices still fire).
|
||||
type OutboundReplierConfig struct {
|
||||
Binding bindingMinter
|
||||
Decrypt Decrypter
|
||||
PublicURL string
|
||||
BindingPath string // default "/slack/bind"
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
var _ engine.OutboundReplier = (*OutboundReplier)(nil)
|
||||
|
||||
// NewOutboundReplier builds the replier. The sender factory mirrors the outbound
|
||||
// subscriber: only the bot token is needed to post.
|
||||
func NewOutboundReplier(cfg OutboundReplierConfig) *OutboundReplier {
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
bindingPath := cfg.BindingPath
|
||||
if bindingPath == "" {
|
||||
bindingPath = "/slack/bind"
|
||||
}
|
||||
if !strings.HasPrefix(bindingPath, "/") {
|
||||
bindingPath = "/" + bindingPath
|
||||
}
|
||||
r := &OutboundReplier{
|
||||
binding: cfg.Binding,
|
||||
decrypt: cfg.Decrypt,
|
||||
publicURL: strings.TrimRight(cfg.PublicURL, "/"),
|
||||
bindingPath: bindingPath,
|
||||
logger: logger,
|
||||
}
|
||||
r.newSender = func(c credentials) replySender {
|
||||
return newSlackSender(c, slack.New(c.BotToken), logger)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Reply routes each outcome to its user-visible message. Errors are logged, not
|
||||
// propagated: the replier runs detached from the inbound ACK path.
|
||||
func (r *OutboundReplier) Reply(ctx context.Context, inst engine.ResolvedInstallation, msg channel.InboundMessage, res engine.Result) {
|
||||
switch res.Outcome {
|
||||
case engine.OutcomeNeedsBinding:
|
||||
if err := r.sendBindingPrompt(ctx, inst, msg, res); err != nil {
|
||||
r.logger.WarnContext(ctx, "slack replier: binding prompt failed",
|
||||
"installation_id", util.UUIDToString(inst.ID), "error", err)
|
||||
}
|
||||
case engine.OutcomeAgentOffline:
|
||||
if err := r.post(ctx, inst, msg, agentOfflineText); err != nil {
|
||||
r.logger.WarnContext(ctx, "slack replier: offline notice failed",
|
||||
"installation_id", util.UUIDToString(inst.ID), "error", err)
|
||||
}
|
||||
case engine.OutcomeAgentArchived:
|
||||
if err := r.post(ctx, inst, msg, agentArchivedText); err != nil {
|
||||
r.logger.WarnContext(ctx, "slack replier: archived notice failed",
|
||||
"installation_id", util.UUIDToString(inst.ID), "error", err)
|
||||
}
|
||||
case engine.OutcomeIngested:
|
||||
// Only a /issue-created message warrants a confirmation; a plain chat
|
||||
// message stays silent (the agent's own reply lands via EventChatDone).
|
||||
if res.IssueID.Valid {
|
||||
if err := r.post(ctx, inst, msg, issueCreatedText(res)); err != nil {
|
||||
r.logger.WarnContext(ctx, "slack replier: issue-created confirmation failed",
|
||||
"installation_id", util.UUIDToString(inst.ID), "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *OutboundReplier) sendBindingPrompt(ctx context.Context, inst engine.ResolvedInstallation, msg channel.InboundMessage, res engine.Result) error {
|
||||
sender := res.Sender
|
||||
if sender == "" {
|
||||
sender = msg.Source.SenderID
|
||||
}
|
||||
if sender == "" {
|
||||
return errors.New("missing sender id")
|
||||
}
|
||||
if r.binding == nil {
|
||||
return errors.New("binding service not configured")
|
||||
}
|
||||
if r.publicURL == "" {
|
||||
return errors.New("public url not configured")
|
||||
}
|
||||
token, err := r.binding.Mint(ctx, inst.WorkspaceID, inst.ID, sender)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mint binding token: %w", err)
|
||||
}
|
||||
bindURL := r.publicURL + r.bindingPath + "?token=" + url.QueryEscape(token.Raw)
|
||||
// Wrap the URL as an explicit Slack link <url|label>: formatMrkdwn protects
|
||||
// these from its markdown passes, so the base64url token's `_`/`-` chars are
|
||||
// not mangled into italics.
|
||||
text := "👋 To start chatting with me, link your Slack account to Multica: <" +
|
||||
bindURL + "|link your account>\n(This link expires in 15 minutes.)"
|
||||
return r.post(ctx, inst, msg, text)
|
||||
}
|
||||
|
||||
// post resolves the installation's bot token from the carried platform row and
|
||||
// sends text back into the originating channel / thread.
|
||||
func (r *OutboundReplier) post(ctx context.Context, inst engine.ResolvedInstallation, msg channel.InboundMessage, text string) error {
|
||||
row, ok := inst.Platform.(db.ChannelInstallation)
|
||||
if !ok {
|
||||
return errors.New("installation platform row unavailable")
|
||||
}
|
||||
creds, err := decodeCredentials(row.Config, r.decrypt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode credentials: %w", err)
|
||||
}
|
||||
if _, err := r.newSender(creds).Send(ctx, channel.OutboundMessage{
|
||||
ChatID: msg.Source.ChatID,
|
||||
Text: text,
|
||||
ThreadID: msg.Source.ThreadID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("post slack reply: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func issueCreatedText(res engine.Result) string {
|
||||
id := res.IssueIdentifier
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("#%d", res.IssueNumber)
|
||||
}
|
||||
title := strings.TrimSpace(res.IssueTitle)
|
||||
if title == "" {
|
||||
return "✅ Created " + id
|
||||
}
|
||||
return "✅ Created " + id + " — " + title
|
||||
}
|
||||
181
server/internal/integrations/slack/replier_test.go
Normal file
181
server/internal/integrations/slack/replier_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
type fakeReplySender struct {
|
||||
sent *channel.OutboundMessage
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeReplySender) Send(_ context.Context, out channel.OutboundMessage) (channel.SendResult, error) {
|
||||
f.calls++
|
||||
cp := out
|
||||
f.sent = &cp
|
||||
return channel.SendResult{MessageID: "1.1"}, nil
|
||||
}
|
||||
|
||||
type fakeBindingMinter struct {
|
||||
raw string
|
||||
gotWS pgtype.UUID
|
||||
gotInst pgtype.UUID
|
||||
gotUser string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeBindingMinter) Mint(_ context.Context, ws, inst pgtype.UUID, user string) (BindingToken, error) {
|
||||
f.calls++
|
||||
f.gotWS, f.gotInst, f.gotUser = ws, inst, user
|
||||
return BindingToken{Raw: f.raw, ExpiresAt: time.Unix(0, 0)}, nil
|
||||
}
|
||||
|
||||
func newTestReplier(binding bindingMinter, sender replySender) *OutboundReplier {
|
||||
r := NewOutboundReplier(OutboundReplierConfig{
|
||||
Binding: binding,
|
||||
Decrypt: nil, // identity: stored bot token is base64 plaintext
|
||||
PublicURL: "https://multica.example",
|
||||
})
|
||||
r.newSender = func(credentials) replySender { return sender }
|
||||
return r
|
||||
}
|
||||
|
||||
// installConfigJSON with a base64 (identity-decryptable) bot token so
|
||||
// decodeCredentials succeeds inside post().
|
||||
const replierConfigJSON = `{"app_id":"T1","bot_user_id":"UBOT","bot_token_encrypted":"eG94Yi10ZXN0"}`
|
||||
|
||||
func testResolvedInstallation(t *testing.T) engine.ResolvedInstallation {
|
||||
return engine.ResolvedInstallation{
|
||||
ID: mustUUID(t, "44444444-4444-4444-4444-444444444444"),
|
||||
WorkspaceID: mustUUID(t, "11111111-1111-1111-1111-111111111111"),
|
||||
AgentID: mustUUID(t, "22222222-2222-2222-2222-222222222222"),
|
||||
Active: true,
|
||||
Platform: db.ChannelInstallation{Config: []byte(replierConfigJSON)},
|
||||
}
|
||||
}
|
||||
|
||||
func testInboundForReply() channel.InboundMessage {
|
||||
return channel.InboundMessage{
|
||||
MessageID: "1700000000.000300",
|
||||
Source: channel.Source{
|
||||
ChannelType: TypeSlack,
|
||||
ChatID: "C1",
|
||||
ChatType: channel.ChatTypeGroup,
|
||||
SenderID: "UALICE",
|
||||
ThreadID: "1700000000.000200",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestReply_NeedsBinding_MintsAndPostsPrompt(t *testing.T) {
|
||||
sender := &fakeReplySender{}
|
||||
minter := &fakeBindingMinter{raw: "tok_RAW-123"}
|
||||
r := newTestReplier(minter, sender)
|
||||
inst := testResolvedInstallation(t)
|
||||
msg := testInboundForReply()
|
||||
|
||||
r.Reply(context.Background(), inst, msg, engine.Result{
|
||||
Outcome: engine.OutcomeNeedsBinding,
|
||||
Sender: "UALICE",
|
||||
})
|
||||
|
||||
if minter.calls != 1 || minter.gotUser != "UALICE" {
|
||||
t.Fatalf("Mint called %d times for user %q", minter.calls, minter.gotUser)
|
||||
}
|
||||
if minter.gotWS != inst.WorkspaceID || minter.gotInst != inst.ID {
|
||||
t.Error("Mint must receive the resolved workspace + installation ids")
|
||||
}
|
||||
if sender.calls != 1 || sender.sent == nil {
|
||||
t.Fatalf("expected one reply, got %d", sender.calls)
|
||||
}
|
||||
if sender.sent.ChatID != "C1" || sender.sent.ThreadID != "1700000000.000200" {
|
||||
t.Errorf("reply target = %+v", sender.sent)
|
||||
}
|
||||
// The prompt must carry the redeem URL with the minted token, wrapped as a
|
||||
// Slack link so formatMrkdwn does not mangle the base64url token.
|
||||
wantLink := "<https://multica.example/slack/bind?token=tok_RAW-123|link your account>"
|
||||
if !strings.Contains(sender.sent.Text, wantLink) {
|
||||
t.Errorf("prompt text = %q, want it to contain %q", sender.sent.Text, wantLink)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReply_AgentOfflineAndArchived_PostNotices(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
outcome engine.Outcome
|
||||
want string
|
||||
}{
|
||||
{engine.OutcomeAgentOffline, agentOfflineText},
|
||||
{engine.OutcomeAgentArchived, agentArchivedText},
|
||||
} {
|
||||
sender := &fakeReplySender{}
|
||||
r := newTestReplier(&fakeBindingMinter{}, sender)
|
||||
r.Reply(context.Background(), testResolvedInstallation(t), testInboundForReply(), engine.Result{Outcome: tc.outcome})
|
||||
if sender.calls != 1 || sender.sent == nil || sender.sent.Text != tc.want {
|
||||
t.Errorf("outcome %s: got %d sends, text %q, want %q", tc.outcome, sender.calls, textOrEmpty(sender.sent), tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReply_IngestedWithIssue_Confirms(t *testing.T) {
|
||||
sender := &fakeReplySender{}
|
||||
r := newTestReplier(&fakeBindingMinter{}, sender)
|
||||
r.Reply(context.Background(), testResolvedInstallation(t), testInboundForReply(), engine.Result{
|
||||
Outcome: engine.OutcomeIngested,
|
||||
IssueID: mustUUID(t, "55555555-5555-5555-5555-555555555555"),
|
||||
IssueIdentifier: "MUL-42",
|
||||
IssueTitle: "Fix the thing",
|
||||
})
|
||||
if sender.calls != 1 || sender.sent == nil {
|
||||
t.Fatalf("expected one confirmation, got %d", sender.calls)
|
||||
}
|
||||
if !strings.Contains(sender.sent.Text, "MUL-42") || !strings.Contains(sender.sent.Text, "Fix the thing") {
|
||||
t.Errorf("confirmation text = %q", sender.sent.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReply_IngestedWithoutIssue_Silent(t *testing.T) {
|
||||
sender := &fakeReplySender{}
|
||||
r := newTestReplier(&fakeBindingMinter{}, sender)
|
||||
// A plain chat message (no /issue) must NOT post — the agent's own reply
|
||||
// lands via the EventChatDone outbound subscriber.
|
||||
r.Reply(context.Background(), testResolvedInstallation(t), testInboundForReply(), engine.Result{
|
||||
Outcome: engine.OutcomeIngested,
|
||||
})
|
||||
if sender.calls != 0 {
|
||||
t.Errorf("plain ingested message must stay silent, got %d sends", sender.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReply_Dropped_Silent(t *testing.T) {
|
||||
sender := &fakeReplySender{}
|
||||
r := newTestReplier(&fakeBindingMinter{}, sender)
|
||||
r.Reply(context.Background(), testResolvedInstallation(t), testInboundForReply(), engine.Result{Outcome: engine.OutcomeDropped})
|
||||
if sender.calls != 0 {
|
||||
t.Errorf("dropped outcome must stay silent, got %d sends", sender.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCreatedText(t *testing.T) {
|
||||
if got := issueCreatedText(engine.Result{IssueIdentifier: "MUL-7", IssueTitle: "Title"}); got != "✅ Created MUL-7 — Title" {
|
||||
t.Errorf("with title = %q", got)
|
||||
}
|
||||
if got := issueCreatedText(engine.Result{IssueNumber: 9}); got != "✅ Created #9" {
|
||||
t.Errorf("fallback to number = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func textOrEmpty(m *channel.OutboundMessage) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m.Text
|
||||
}
|
||||
@@ -25,11 +25,12 @@ import (
|
||||
const originSlackChat = "slack_chat"
|
||||
|
||||
// NewSlackResolverSet assembles the Slack ResolverSet over the generated
|
||||
// queries + a tx starter (for the shared session service). Replier/Typing are
|
||||
// left nil for now: the outbound binding-prompt / notice path is a later step
|
||||
// (the inbound pipeline — route, identity, dedup, session, /issue, run trigger
|
||||
// — is fully functional without them).
|
||||
func NewSlackResolverSet(q *db.Queries, tx engine.TxStarter) engine.ResolverSet {
|
||||
// queries + a tx starter (for the shared session service). The replier delivers
|
||||
// the outbound binding-prompt / status / issue-created notices; pass a nil
|
||||
// engine.OutboundReplier to disable them (the inbound pipeline — route,
|
||||
// identity, dedup, session, /issue, run trigger — is fully functional without
|
||||
// it). Typing is left nil. (MUL-3666 wired the replier; stage 3 had it nil.)
|
||||
func NewSlackResolverSet(q *db.Queries, tx engine.TxStarter, replier engine.OutboundReplier) engine.ResolverSet {
|
||||
return engine.ResolverSet{
|
||||
Installation: &installationResolver{q: q},
|
||||
Identity: &identityResolver{q: q},
|
||||
@@ -40,6 +41,7 @@ func NewSlackResolverSet(q *db.Queries, tx engine.TxStarter) engine.ResolverSet
|
||||
Fallback: "Slack chat",
|
||||
})},
|
||||
Audit: &auditor{q: q},
|
||||
Replier: replier,
|
||||
OriginType: originSlackChat,
|
||||
}
|
||||
}
|
||||
@@ -105,6 +107,21 @@ func nullText(s string) pgtype.Text {
|
||||
return pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
|
||||
// installationServesTeam reports whether an installation (its stored config) may
|
||||
// serve events from eventTeamID. Inbound routing keys on api_app_id, which
|
||||
// identifies the Slack APP, not the Slack workspace: a BYO app distributed /
|
||||
// installed into another Slack workspace emits events carrying the SAME app id.
|
||||
// So we additionally require the event's team to match the team the installed
|
||||
// bot belongs to. An installation with no recorded team (legacy) is permissive.
|
||||
func installationServesTeam(installConfigJSON json.RawMessage, eventTeamID string) bool {
|
||||
// Read team_id directly (NOT via DecodePublicConfig, which falls back to
|
||||
// app_id when team_id is absent — a hosted-era convenience that would defeat
|
||||
// this check for BYO where app_id != team_id).
|
||||
var cfg installConfig
|
||||
_ = json.Unmarshal(installConfigJSON, &cfg)
|
||||
return cfg.TeamID == "" || cfg.TeamID == eventTeamID
|
||||
}
|
||||
|
||||
// ---- installation routing ----
|
||||
|
||||
type installationResolver struct{ q *db.Queries }
|
||||
@@ -116,7 +133,11 @@ func (r *installationResolver) ResolveInstallation(ctx context.Context, msg chan
|
||||
}
|
||||
inst, err := r.q.GetChannelInstallationByAppID(ctx, db.GetChannelInstallationByAppIDParams{
|
||||
ChannelType: string(TypeSlack),
|
||||
AppID: raw.TeamID, // Slack team_id is stored in the routing-key slot
|
||||
// Route by the event's api_app_id: each BYO installation stores its real
|
||||
// Slack app id in the routing-key slot (config->>'app_id'), and the
|
||||
// per-installation Socket Mode connection only ever delivers events for
|
||||
// its own app, so api_app_id uniquely identifies the installation.
|
||||
AppID: raw.APIAppID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -124,6 +145,9 @@ func (r *installationResolver) ResolveInstallation(ctx context.Context, msg chan
|
||||
}
|
||||
return engine.ResolvedInstallation{}, err
|
||||
}
|
||||
if !installationServesTeam(inst.Config, raw.TeamID) {
|
||||
return engine.ResolvedInstallation{}, engine.ErrInstallationNotFound
|
||||
}
|
||||
return engine.ResolvedInstallation{
|
||||
ID: inst.ID,
|
||||
WorkspaceID: inst.WorkspaceID,
|
||||
|
||||
@@ -93,11 +93,46 @@ func TestSlackThreadIsolation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewSlackResolverSet(t *testing.T) {
|
||||
set := NewSlackResolverSet(nil, nil)
|
||||
set := NewSlackResolverSet(nil, nil, nil)
|
||||
if set.Installation == nil || set.Identity == nil || set.Dedup == nil || set.Session == nil || set.Audit == nil {
|
||||
t.Error("resolver set must populate all required resolvers")
|
||||
}
|
||||
if set.OriginType != "slack_chat" {
|
||||
t.Errorf("OriginType = %q, want slack_chat", set.OriginType)
|
||||
}
|
||||
if set.Replier != nil {
|
||||
t.Error("a nil replier arg must leave Replier nil (not a typed-nil interface)")
|
||||
}
|
||||
|
||||
// A real replier threads through.
|
||||
set = NewSlackResolverSet(nil, nil, NewOutboundReplier(OutboundReplierConfig{}))
|
||||
if set.Replier == nil {
|
||||
t.Error("a non-nil replier must populate ResolverSet.Replier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallationServesTeam(t *testing.T) {
|
||||
cfg := func(team string) json.RawMessage {
|
||||
b, _ := json.Marshal(installConfig{AppID: "A0BCXGVCS7R", TeamID: team})
|
||||
return b
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfgTeam string
|
||||
eventTeam string
|
||||
want bool
|
||||
}{
|
||||
{"matching team", "T999", "T999", true},
|
||||
// api_app_id alone is not enough: the same app installed into another Slack
|
||||
// workspace emits the same app id but a different team — must not route here.
|
||||
{"different team", "T999", "TOTHER", false},
|
||||
{"empty event team", "T999", "", false},
|
||||
{"legacy row without a team is permissive", "", "TANY", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := installationServesTeam(cfg(c.cfgTeam), c.eventTeam); got != c.want {
|
||||
t.Errorf("%s: installationServesTeam(cfg=%q, event=%q) = %v, want %v",
|
||||
c.name, c.cfgTeam, c.eventTeam, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
218
server/internal/integrations/slack/slack_channel.go
Normal file
218
server/internal/integrations/slack/slack_channel.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
|
||||
"github.com/slack-go/slack"
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
"github.com/slack-go/slack/socketmode"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
||||
)
|
||||
|
||||
// slackChannel is ONE installation's Socket Mode connection. Under the
|
||||
// bring-your-own-app (BYO) model every Slack installation carries its own Slack
|
||||
// app — its own app-level token (xapp-, stored encrypted in the installation
|
||||
// config) — so it gets its own connection, exactly like the stage-3
|
||||
// per-installation model and like Feishu today. The engine.Supervisor builds
|
||||
// one slackChannel per active Slack installation (via the registered Factory)
|
||||
// and owns the lease / reconnect lifecycle; Connect blocks on the receive loop.
|
||||
//
|
||||
// Inbound events are translated by the shared inbound.go helpers, parameterized
|
||||
// by THIS installation's bot user id, and handed to the engine router, which
|
||||
// resolves the installation by the event's api_app_id — equal to this app's id,
|
||||
// the per-app routing key. Outbound replies primarily flow through the
|
||||
// EventChatDone subscriber (NewOutbound); Send satisfies the Channel contract
|
||||
// and posts with this installation's bot token.
|
||||
type slackChannel struct {
|
||||
appID string
|
||||
botUserID string
|
||||
appToken string // decrypted xapp- — authorizes the Socket Mode connection
|
||||
botAPI *slack.Client // bot-token client for outbound Send
|
||||
handler channel.InboundHandler
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (c *slackChannel) Type() channel.Type { return TypeSlack }
|
||||
|
||||
func (c *slackChannel) Capabilities() channel.Capability {
|
||||
return channel.CapText | channel.CapThreadReply
|
||||
}
|
||||
|
||||
// Disconnect is a no-op: the Socket Mode connection's whole lifetime is scoped
|
||||
// to Connect (it returns when the run context is cancelled), so there is no
|
||||
// long-lived resource to release here. Mirrors feishuChannel.Disconnect.
|
||||
func (c *slackChannel) Disconnect(ctx context.Context) error { return nil }
|
||||
|
||||
// Send posts an outbound reply with this installation's bot token, reusing the
|
||||
// shared slackSender (Markdown→mrkdwn, chunking, threading).
|
||||
func (c *slackChannel) Send(ctx context.Context, out channel.OutboundMessage) (channel.SendResult, error) {
|
||||
return newSlackSender(credentials{BotUserID: c.botUserID}, c.botAPI, c.logger).Send(ctx, out)
|
||||
}
|
||||
|
||||
// Connect opens this installation's Socket Mode connection (authenticated with
|
||||
// its OWN app-level token) and runs the receive loop until ctx is cancelled or
|
||||
// the link drops. It mirrors the removed AppConnector.connectOnce but is
|
||||
// per-installation: the bot identity is fixed (this install's bot user id)
|
||||
// rather than resolved per event by team_id.
|
||||
func (c *slackChannel) Connect(ctx context.Context) error {
|
||||
if c.handler == nil {
|
||||
return errors.New("slack: inbound handler not configured")
|
||||
}
|
||||
if c.appToken == "" {
|
||||
return errors.New("slack: app-level token not configured")
|
||||
}
|
||||
// The Socket Mode connection authenticates with the app-level token alone;
|
||||
// the bot token is only for outbound Web API calls.
|
||||
api := slack.New("", slack.OptionAppLevelToken(c.appToken))
|
||||
sm := socketmode.New(api)
|
||||
|
||||
// Each connection runs under its OWN cancellable context. Every exit path
|
||||
// (handler error, event-stream close, ctx cancellation) cancels runCtx and
|
||||
// waits for the run goroutine to observe it and exit, so a transient failure
|
||||
// tears the live connection down before the supervisor reconnects — no
|
||||
// leaked socket goroutine consuming events into an unread channel.
|
||||
runCtx, runCancel := context.WithCancel(ctx)
|
||||
runErr := make(chan error, 1)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runErr <- sm.RunContext(runCtx)
|
||||
close(done)
|
||||
}()
|
||||
defer func() {
|
||||
runCancel()
|
||||
<-done
|
||||
}()
|
||||
|
||||
mentionRe := compileMentionRe(c.botUserID)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case err := <-runErr:
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("slack: socket mode connection closed")
|
||||
case evt, ok := <-sm.Events:
|
||||
if !ok {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New("slack: socket mode event stream closed")
|
||||
}
|
||||
if err := c.handleSocketEvent(ctx, sm, evt, mentionRe); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *slackChannel) handleSocketEvent(ctx context.Context, sm *socketmode.Client, evt socketmode.Event, mentionRe *regexp.Regexp) error {
|
||||
switch evt.Type {
|
||||
case socketmode.EventTypeEventsAPI:
|
||||
eventsAPI, ok := evt.Data.(slackevents.EventsAPIEvent)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// ACK first: Slack expires un-ACKed envelopes in ~3s, far below the
|
||||
// handler's DB work. The ACK is independent of the handler outcome.
|
||||
if evt.Request != nil {
|
||||
if err := sm.Ack(*evt.Request); err != nil {
|
||||
c.logger.WarnContext(ctx, "slack: ack failed", "error", err)
|
||||
}
|
||||
}
|
||||
return c.dispatchEventsAPI(ctx, eventsAPI, mentionRe)
|
||||
case socketmode.EventTypeConnecting, socketmode.EventTypeConnected, socketmode.EventTypeHello:
|
||||
c.logger.DebugContext(ctx, "slack: socket mode", "event", evt.Type, "app_id", c.appID)
|
||||
case socketmode.EventTypeIncomingError, socketmode.EventTypeErrorBadMessage:
|
||||
c.logger.WarnContext(ctx, "slack: socket mode error", "event", evt.Type, "app_id", c.appID)
|
||||
default:
|
||||
if evt.Request != nil {
|
||||
_ = sm.Ack(*evt.Request)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatchEventsAPI translates one Events API envelope to a normalized inbound
|
||||
// message and hands it to the engine. A non-nil handler error is an
|
||||
// infrastructure failure; it propagates so the supervisor reconnects. A
|
||||
// legitimate product drop returns nil.
|
||||
func (c *slackChannel) dispatchEventsAPI(ctx context.Context, e slackevents.EventsAPIEvent, mentionRe *regexp.Regexp) error {
|
||||
var (
|
||||
msg channel.InboundMessage
|
||||
ok bool
|
||||
)
|
||||
switch inner := e.InnerEvent.Data.(type) {
|
||||
case *slackevents.AppMentionEvent:
|
||||
msg, ok = inboundFromAppMention(e, inner, c.botUserID, mentionRe)
|
||||
case *slackevents.MessageEvent:
|
||||
msg, ok = inboundFromMessage(e, inner, c.botUserID, mentionRe)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return c.handler(ctx, msg)
|
||||
}
|
||||
|
||||
// ChannelDeps are the shared dependencies the Slack Factory closes over. The
|
||||
// engine inbound handler is supplied per-build via channel.Config.Handler; the
|
||||
// Decrypter turns the installation's stored ciphertext tokens into plaintext.
|
||||
type ChannelDeps struct {
|
||||
Decrypt Decrypter
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// RegisterSlack registers the per-installation Slack Factory so the
|
||||
// engine.Supervisor builds + supervises one slackChannel per active Slack
|
||||
// installation. "Adding Slack inbound" is this call plus the adapter — no engine
|
||||
// edit (the same contract as lark.RegisterFeishu).
|
||||
func RegisterSlack(reg *channel.Registry, deps ChannelDeps) {
|
||||
reg.Register(TypeSlack, newSlackFactory(deps))
|
||||
}
|
||||
|
||||
func newSlackFactory(deps ChannelDeps) channel.Factory {
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return func(cfg channel.Config) (channel.Channel, error) {
|
||||
var ic installConfig
|
||||
if err := json.Unmarshal(cfg.Raw, &ic); err != nil {
|
||||
return nil, fmt.Errorf("slack: decode installation config: %w", err)
|
||||
}
|
||||
appToken, err := decryptToken(ic.AppTokenEncrypted, deps.Decrypt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("slack: decrypt app token: %w", err)
|
||||
}
|
||||
if appToken == "" {
|
||||
return nil, errors.New("slack: installation has no app-level token")
|
||||
}
|
||||
botToken, err := decryptToken(ic.BotTokenEncrypted, deps.Decrypt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("slack: decrypt bot token: %w", err)
|
||||
}
|
||||
return &slackChannel{
|
||||
appID: ic.AppID,
|
||||
botUserID: ic.BotUserID,
|
||||
appToken: appToken,
|
||||
botAPI: slack.New(botToken),
|
||||
handler: cfg.Handler,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,43 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const cspHeader = "default-src 'self'; " +
|
||||
const cspBaseHeader = "default-src 'self'; " +
|
||||
"script-src 'self'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' https: data:; " +
|
||||
"connect-src 'self' wss:; " +
|
||||
"connect-src 'self' wss:; "
|
||||
|
||||
const cspHeader = cspBaseHeader +
|
||||
"frame-ancestors 'none'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'"
|
||||
|
||||
const attachmentPreviewCSPHeader = cspBaseHeader +
|
||||
"frame-ancestors 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'"
|
||||
|
||||
func ContentSecurityPolicy(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", cspHeader)
|
||||
w.Header().Set("Content-Security-Policy", contentSecurityPolicyForRequest(r))
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func contentSecurityPolicyForRequest(r *http.Request) string {
|
||||
if isAttachmentPreviewDocumentPath(r.URL.Path) {
|
||||
return attachmentPreviewCSPHeader
|
||||
}
|
||||
return cspHeader
|
||||
}
|
||||
|
||||
func isAttachmentPreviewDocumentPath(path string) bool {
|
||||
return strings.HasPrefix(path, "/api/attachments/") &&
|
||||
(strings.HasSuffix(path, "/download") || strings.HasSuffix(path, "/content"))
|
||||
}
|
||||
|
||||
@@ -17,16 +17,48 @@ func TestContentSecurityPolicy(t *testing.T) {
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
if csp == "" {
|
||||
t.Fatal("Content-Security-Policy header is missing")
|
||||
}
|
||||
|
||||
required := []string{
|
||||
assertCSPDirectives(t, csp, []string{
|
||||
"script-src 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
})
|
||||
}
|
||||
|
||||
func TestContentSecurityPolicyAllowsSameOriginAttachmentPreviews(t *testing.T) {
|
||||
handler := ContentSecurityPolicy(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/attachments/019f0dae-0315-79b7-b653-f55d6af90403/download",
|
||||
"/api/attachments/019f0dae-0315-79b7-b653-f55d6af90403/content",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
assertCSPDirectives(t, csp, []string{
|
||||
"script-src 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'self'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
})
|
||||
if strings.Contains(csp, "frame-ancestors 'none'") {
|
||||
t.Fatalf("attachment preview CSP must not block same-origin iframe embedding; got: %s", csp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertCSPDirectives(t *testing.T, csp string, required []string) {
|
||||
t.Helper()
|
||||
if csp == "" {
|
||||
t.Fatal("Content-Security-Policy header is missing")
|
||||
}
|
||||
for _, directive := range required {
|
||||
if !strings.Contains(csp, directive) {
|
||||
|
||||
@@ -2,6 +2,11 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -98,6 +103,69 @@ func TestDetectVersionFailsForMissingBinary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectVersionTimesOutOnHang guards MUL-3812: a CLI whose `--version`
|
||||
// never returns (e.g. a brew-installed claude wedged by a bun regression) must
|
||||
// not stall version detection forever. The daemon detects every runtime's
|
||||
// version sequentially inside its blocking preflight, so an unbounded probe
|
||||
// would leave the daemon stuck "starting" and *every* runtime on the host
|
||||
// disconnected. detectCLIVersion must bound the probe and return an error so
|
||||
// the registration loop isolates the broken runtime and the rest still
|
||||
// register. The script also leaves an orphaned child holding the stdout pipe
|
||||
// open after the parent is killed, exercising the cmd.WaitDelay path.
|
||||
func TestDetectVersionTimesOutOnHang(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("relies on a /bin/sh hang script")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "hang.sh")
|
||||
pidFile := filepath.Join(dir, "child.pid")
|
||||
// The CLI hangs forever (`wait`) and backgrounds a child that inherits and
|
||||
// holds our stdout pipe open even after the parent is killed on timeout —
|
||||
// the exact case cmd.WaitDelay must cover. The child records its PID so we
|
||||
// can reap it in Cleanup instead of leaking a 60s `sleep` into CI.
|
||||
body := fmt.Sprintf("#!/bin/sh\nsleep 60 &\necho $! > %q\nwait\n", pidFile)
|
||||
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
|
||||
t.Fatalf("write hang script: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
data, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
return // child never recorded its PID; nothing to reap
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if proc, err := os.FindProcess(pid); err == nil {
|
||||
_ = proc.Kill()
|
||||
}
|
||||
})
|
||||
|
||||
orig := detectVersionTimeout
|
||||
detectVersionTimeout = 200 * time.Millisecond
|
||||
t.Cleanup(func() { detectVersionTimeout = orig })
|
||||
|
||||
done := make(chan error, 1)
|
||||
start := time.Now()
|
||||
go func() {
|
||||
_, err := DetectVersion(context.Background(), script)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("expected an error from a hanging --version probe, got nil")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||
t.Fatalf("detection took %v; expected it to be bounded by the timeout", elapsed)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("DetectVersion did not return: version probe is unbounded (regression of MUL-3812)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -804,9 +804,30 @@ func writeMcpConfigToTemp(raw json.RawMessage) (string, error) {
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
// detectVersionTimeout bounds a single `<cli> --version` probe. Version
|
||||
// detection runs inside the daemon's blocking preflight (registerRuntimesForWorkspace),
|
||||
// so a CLI that never returns from `--version` — e.g. a brew-installed claude
|
||||
// wedged by a bun regression (MUL-3812) — would otherwise stall the whole
|
||||
// registration loop, the daemon would never flip /health from "starting" to
|
||||
// "running", and *every* runtime on the host would appear disconnected. A real
|
||||
// `--version` returns well under this bound even on a cold cache or with
|
||||
// Windows AV scanning; the timeout exists only to fail a wedged probe fast and
|
||||
// in isolation so the remaining runtimes still register. A var (not const) so
|
||||
// tests can shrink it without waiting out the real bound.
|
||||
var detectVersionTimeout = 10 * time.Second
|
||||
|
||||
func detectCLIVersion(ctx context.Context, execPath string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, detectVersionTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, execPath, "--version")
|
||||
hideAgentWindow(cmd)
|
||||
// exec.CommandContext only kills the direct child on timeout. A broken CLI
|
||||
// (node/bun shim) can leave grandchildren that inherited and still hold our
|
||||
// stdout pipe open, and cmd.Output() blocks in Wait() until that pipe
|
||||
// closes — defeating the timeout above. WaitDelay forces the pipes shut and
|
||||
// reaps shortly after the context fires so this call always returns.
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
data, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("detect version for %s: %w", execPath, err)
|
||||
|
||||
@@ -362,6 +362,28 @@ func (q *Queries) DeleteChannelChatSessionBindingBySession(ctx context.Context,
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteChannelChatSessionBindingsByInstallation = `-- name: DeleteChannelChatSessionBindingsByInstallation :exec
|
||||
DELETE FROM channel_chat_session_binding
|
||||
WHERE installation_id = $1 AND channel_type = $2
|
||||
`
|
||||
|
||||
type DeleteChannelChatSessionBindingsByInstallationParams struct {
|
||||
InstallationID pgtype.UUID `json:"installation_id"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
}
|
||||
|
||||
// Retire every chat-session binding for an installation. Used when an
|
||||
// installation is re-pointed to a different agent (Slack re-connect): each
|
||||
// existing chat_session is permanently tied to the agent it was created under,
|
||||
// so reusing it would keep routing the conversation to the OLD agent. Dropping
|
||||
// the bindings forces the next inbound message to create a fresh session under
|
||||
// the new agent. The chat_session rows are preserved for history; only the
|
||||
// channel binding is removed.
|
||||
func (q *Queries) DeleteChannelChatSessionBindingsByInstallation(ctx context.Context, arg DeleteChannelChatSessionBindingsByInstallationParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteChannelChatSessionBindingsByInstallation, arg.InstallationID, arg.ChannelType)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteChannelUserBindingsByWorkspaceMember = `-- name: DeleteChannelUserBindingsByWorkspaceMember :exec
|
||||
DELETE FROM channel_user_binding
|
||||
WHERE workspace_id = $1 AND multica_user_id = $2
|
||||
@@ -1085,3 +1107,73 @@ func (q *Queries) UpsertChannelInstallation(ctx context.Context, arg UpsertChann
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertChannelInstallationByAppID = `-- name: UpsertChannelInstallationByAppID :one
|
||||
INSERT INTO channel_installation (
|
||||
workspace_id, agent_id, channel_type, config, installer_user_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
ON CONFLICT (channel_type, (config ->> 'app_id')) DO UPDATE SET
|
||||
agent_id = EXCLUDED.agent_id,
|
||||
config = EXCLUDED.config,
|
||||
installer_user_id = EXCLUDED.installer_user_id,
|
||||
status = 'active',
|
||||
installed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE channel_installation.workspace_id = EXCLUDED.workspace_id
|
||||
RETURNING id, workspace_id, agent_id, channel_type, config, status, ws_lease_token, ws_lease_expires_at, installer_user_id, installed_at, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertChannelInstallationByAppIDParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
Config []byte `json:"config"`
|
||||
InstallerUserID pgtype.UUID `json:"installer_user_id"`
|
||||
}
|
||||
|
||||
// Team-keyed install / re-install for channels whose natural identity is the
|
||||
// platform workspace, not the (agent) pairing. Slack: one Slack workspace
|
||||
// (team_id, stored as config->>'app_id') maps to exactly one installation, so
|
||||
// re-connecting it — even to represent a DIFFERENT agent in the SAME Multica
|
||||
// workspace — UPDATES the existing row (moving agent_id) instead of colliding
|
||||
// with the (channel_type, app_id) unique index. Contrast UpsertChannelInstallation,
|
||||
// whose conflict key is (workspace_id, agent_id, channel_type): right for Feishu
|
||||
// (one app per agent), wrong for Slack.
|
||||
//
|
||||
// The `WHERE channel_installation.workspace_id = EXCLUDED.workspace_id` fences
|
||||
// the conflict update to the SAME Multica workspace: a team already owned by a
|
||||
// DIFFERENT workspace updates no row and RETURNING is empty (pgx.ErrNoRows),
|
||||
// which the caller maps to ErrTeamOwnedByAnotherWorkspace. This is the ATOMIC
|
||||
// cross-workspace guard — a plain SELECT before the upsert cannot stop two
|
||||
// workspaces racing to OAuth the same team (both read no rows, then one inserts
|
||||
// and the other's conflict-update would silently steal it). A re-connect that
|
||||
// would move the team to an agent already holding a different Slack install in
|
||||
// the same workspace still trips the (workspace_id, agent_id, channel_type)
|
||||
// unique constraint — a genuine conflict the OAuth callback turns into a redirect.
|
||||
func (q *Queries) UpsertChannelInstallationByAppID(ctx context.Context, arg UpsertChannelInstallationByAppIDParams) (ChannelInstallation, error) {
|
||||
row := q.db.QueryRow(ctx, upsertChannelInstallationByAppID,
|
||||
arg.WorkspaceID,
|
||||
arg.AgentID,
|
||||
arg.ChannelType,
|
||||
arg.Config,
|
||||
arg.InstallerUserID,
|
||||
)
|
||||
var i ChannelInstallation
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.AgentID,
|
||||
&i.ChannelType,
|
||||
&i.Config,
|
||||
&i.Status,
|
||||
&i.WsLeaseToken,
|
||||
&i.WsLeaseExpiresAt,
|
||||
&i.InstallerUserID,
|
||||
&i.InstalledAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -38,6 +38,41 @@ ON CONFLICT (workspace_id, agent_id, channel_type) DO UPDATE SET
|
||||
updated_at = now()
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpsertChannelInstallationByAppID :one
|
||||
-- Team-keyed install / re-install for channels whose natural identity is the
|
||||
-- platform workspace, not the (agent) pairing. Slack: one Slack workspace
|
||||
-- (team_id, stored as config->>'app_id') maps to exactly one installation, so
|
||||
-- re-connecting it — even to represent a DIFFERENT agent in the SAME Multica
|
||||
-- workspace — UPDATES the existing row (moving agent_id) instead of colliding
|
||||
-- with the (channel_type, app_id) unique index. Contrast UpsertChannelInstallation,
|
||||
-- whose conflict key is (workspace_id, agent_id, channel_type): right for Feishu
|
||||
-- (one app per agent), wrong for Slack.
|
||||
--
|
||||
-- The `WHERE channel_installation.workspace_id = EXCLUDED.workspace_id` fences
|
||||
-- the conflict update to the SAME Multica workspace: a team already owned by a
|
||||
-- DIFFERENT workspace updates no row and RETURNING is empty (pgx.ErrNoRows),
|
||||
-- which the caller maps to ErrTeamOwnedByAnotherWorkspace. This is the ATOMIC
|
||||
-- cross-workspace guard — a plain SELECT before the upsert cannot stop two
|
||||
-- workspaces racing to OAuth the same team (both read no rows, then one inserts
|
||||
-- and the other's conflict-update would silently steal it). A re-connect that
|
||||
-- would move the team to an agent already holding a different Slack install in
|
||||
-- the same workspace still trips the (workspace_id, agent_id, channel_type)
|
||||
-- unique constraint — a genuine conflict the OAuth callback turns into a redirect.
|
||||
INSERT INTO channel_installation (
|
||||
workspace_id, agent_id, channel_type, config, installer_user_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
ON CONFLICT (channel_type, (config ->> 'app_id')) DO UPDATE SET
|
||||
agent_id = EXCLUDED.agent_id,
|
||||
config = EXCLUDED.config,
|
||||
installer_user_id = EXCLUDED.installer_user_id,
|
||||
status = 'active',
|
||||
installed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE channel_installation.workspace_id = EXCLUDED.workspace_id
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetChannelInstallation :one
|
||||
-- Scoped by channel_type: a per-channel caller (e.g. the Feishu store)
|
||||
-- must never resolve another channel's installation by guessing its UUID.
|
||||
@@ -246,6 +281,17 @@ WHERE chat_session_id = $1;
|
||||
DELETE FROM channel_chat_session_binding
|
||||
WHERE chat_session_id = $1;
|
||||
|
||||
-- name: DeleteChannelChatSessionBindingsByInstallation :exec
|
||||
-- Retire every chat-session binding for an installation. Used when an
|
||||
-- installation is re-pointed to a different agent (Slack re-connect): each
|
||||
-- existing chat_session is permanently tied to the agent it was created under,
|
||||
-- so reusing it would keep routing the conversation to the OLD agent. Dropping
|
||||
-- the bindings forces the next inbound message to create a fresh session under
|
||||
-- the new agent. The chat_session rows are preserved for history; only the
|
||||
-- channel binding is removed.
|
||||
DELETE FROM channel_chat_session_binding
|
||||
WHERE installation_id = $1 AND channel_type = $2;
|
||||
|
||||
-- =====================
|
||||
-- channel_inbound_message_dedup
|
||||
-- =====================
|
||||
|
||||
@@ -134,4 +134,12 @@ const (
|
||||
// deleting the row; the audit trail is preserved.
|
||||
EventLarkInstallationCreated = "lark_installation:created"
|
||||
EventLarkInstallationRevoked = "lark_installation:revoked"
|
||||
|
||||
// Slack installation lifecycle (MUL-3666). Same semantics as the Lark
|
||||
// events: `created` covers both first install and OAuth re-install (the
|
||||
// UNIQUE on (workspace_id, agent_id, channel_type) means at most one row
|
||||
// per agent), `revoked` flips status without deleting the row. Front-ends
|
||||
// invalidate the Slack installations query on either.
|
||||
EventSlackInstallationCreated = "slack_installation:created"
|
||||
EventSlackInstallationRevoked = "slack_installation:revoked"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user