Compare commits

...

12 Commits

Author SHA1 Message Date
Multica Eve
de7f3cb9e3 docs(changelog): add v0.3.32 entry for the 2026-06-29 release (MUL-3840) (#4706)
* docs(changelog): add v0.3.32 entry for the 2026-06-29 release (MUL-3840)

Lands the daily release notes for v0.3.32 in all four landing locales (en / zh-Hans / ko / ja). Groups today's PRs by Feature / Improvement / Bug Fix with product-oriented wording, keeping technical commit jargon out of the user-facing changelog.

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

* docs(changelog): drop two fixes from v0.3.32 per release-confirmation feedback

Removes the 'deleted agents hidden from usage leaderboard' (MUL-3771, #4637) and 'Antigravity daemon-mode guidance' fix lines from all four locales, leaving five customer-facing fixes for the 2026-06-29 release. The Issue body on MUL-3840 is kept in sync separately.

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

* docs(changelog): drop reverted self-host onboarding beacon from v0.3.32

The anonymous self-host onboarding source beacon (MUL-3708, #4691) was reverted in #4712 because of issues with the collection path. Remove the corresponding feature bullet from all four locales so the v0.3.32 changelog only advertises what actually ships.

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

* docs(changelog): hold Slack BYO app feature back from v0.3.32 user changelog

Per release confirmation, the Slack bring-your-own-app feature is shipping behind a disabled frontend entry for v0.3.32 — code lands, but it is not publicly available yet. Drop the Slack feature bullet from all four locales and rewrite the entry title around what is actually exposed to users (Remove parent Issue + daemon reconnect + attachment preview improvements).

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 19:18:13 +08:00
Naiyuan Qing
b336f07617 Revert "feat(analytics): anonymous self-host onboarding source beacon (MUL-37…" (#4712)
This reverts commit 63eb6f73ad.
2026-06-29 19:01:14 +08:00
Jiayuan Zhang
10b33b14f5 fix(dashboard): reconcile deleted-agent spend in usage leaderboard (MUL-3776) (#4661)
PR #4637 (MUL-3771) dropped hard-deleted agents from the per-agent
leaderboard so they'd stop rendering as a bare UUID, but the top-line
Cost/Tokens KPIs still count their spend (those totals aggregate
task_usage_hourly without joining `agent`). The breakdown therefore no
longer reconciled with the totals (#4640).

Instead of dropping unknown-agent rows, fold them into a single
aggregated "Deleted agents" row: sum(visible rows) == KPI total again,
with no UUID exposed. Archived agents still appear as themselves (the
agent list is fetched with include_archived). The bucket carries
tokens + cost only; Time/Tasks render as "—" since the run-time rollups
inner-join `agent` and never attribute time to deleted agents.

- bucketUnknownAgentRows replaces filterKnownAgentRows in dashboard/utils
- Leaderboard renders the sentinel bucket row with a neutral placeholder
  and a "{{count}} agents · {{deleted}} deleted" caption
- i18n: deleted_agents + caption_with_deleted (en/zh-Hans/ja/ko)
- tests cover bucket reconciliation, archived-stays, null-loading passthrough

Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 17:21:35 +08:00
Bohan Jiang
9f1766cdb3 docs(slack): binding link uses the web app URL, not MULTICA_PUBLIC_URL (MUL-3666) (#4705)
Match the code fix (#4703): the "link your account" link is built from the web
app URL (MULTICA_APP_URL ?? FRONTEND_ORIGIN), which a normal deployment already
sets — not MULTICA_PUBLIC_URL (the backend/API URL). Updates en + zh + ja + ko.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 17:18:18 +08:00
Bohan Jiang
2b940046d7 fix(slack): build the binding link from the web app URL, matching Lark (MUL-3666) (#4703)
The Slack "link your account" prompt built its redeem link from
MULTICA_PUBLIC_URL, but /slack/bind is a web-app page — the link must use the
web app URL, not the backend/API URL. MULTICA_PUBLIC_URL is intentionally the
backend/API public URL (webhooks, daemon server_url, attachments); the Lark
replier already uses appURLFromEnv() (MULTICA_APP_URL ?? FRONTEND_ORIGIN).
Slack was never migrated, so on deployments that set FRONTEND_ORIGIN but not
MULTICA_PUBLIC_URL (e.g. dev) the binding prompt silently failed
("public url not configured") and @-mentions got no response.

Rename slack.OutboundReplierConfig.PublicURL -> AppURL and feed it
appURLFromEnv() in router.go, mirroring Lark. Backend/API-URL uses of
MULTICA_PUBLIC_URL (webhooks, attachments, daemon server_url) are unchanged.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 17:13:41 +08:00
Multica Eve
f59cb2f494 MUL-3834: harden daemon websocket reconnect (#4699)
* MUL-3834 harden daemon websocket reconnect

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

* MUL-3834 stabilize daemon websocket liveness tests

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 16:46:57 +08:00
Bohan Jiang
d2bc85e01a refactor(slack): declutter the Slack connect UI (#4700)
* refactor(slack): declutter the Slack connect UI

Trim the Slack bring-your-own-app UI to match the leaner Lark card and
stop burying the setup behind prose nobody reads:

- Drop the "Required bot scopes: …" block from the connect dialog.
- Shorten the Slack integration card description to mirror the Lark
  card; the token/admin details stay in the setup docs.
- Remove the dialog intro paragraph and the per-field token hints;
  replace the small "Read the setup guide" link with a larger,
  more prominent step-by-step guide link.

Removes the now-unused i18n keys (byo_dialog_intro, byo_bot_token_hint,
byo_app_token_hint, byo_scopes_hint) across en/zh-Hans/ja/ko.

* docs(slack): drop the users:read warning callout

The bot manifest already lists users:read as a required scope (with the
bots.info rationale in the scopes table), so the standalone warning
callout was redundant. Removed across en/zh/ja/ko.
2026-06-29 16:31:42 +08:00
Naiyuan Qing
63eb6f73ad feat(analytics): anonymous self-host onboarding source beacon (MUL-3708) (#4691)
* feat(analytics): anonymous self-host onboarding source beacon (MUL-3708)

Production self-host servers now report the anonymous onboarding "how did
you hear about us" channel to Multica's public write-only ingest, so the
self-host source distribution becomes visible alongside official cloud.
Official cloud keeps its existing PostHog capture unchanged; this is a
submit-time beacon, not a background telemetry pipeline.

- server/internal/sourcebeacon: ShouldSend gate (production + non-local +
  non-*.multica.ai app host, fail-closed — judged by the app/frontend host,
  not the backend URL, which official often leaves unset), per-instance
  salted hashing, deterministic event uuid, fire-and-forget sender.
- POST /api/telemetry/self-host-source: public, write-only, per-IP
  rate-limited, 4 KiB body cap, channel allowlist, strict unknown-field
  rejection. Lands in PostHog as self_host_source_channel with a
  deterministic uuid (best-effort dedup), $process_person_profile=false,
  and deployment=self_host — a distinct event name so it never pollutes the
  official onboarding funnel.
- Hook in PatchOnboarding fires once when the source is first set; never
  blocks onboarding. Only channel enum(s) + two per-instance hashes leave
  the box — never user_id/email/name/workspace/org/domain/role/use_case/the
  source_other free-text/IP.
- migration 128: system_settings singleton holding instance_salt.
- frontend: self-host-only anonymous-collection notice on the source step,
  gated by a new /api/config self_host_source_notice flag (en/zh-Hans/ko/ja).
- analytics.Event gains an optional top-level uuid; docs/analytics.md,
  SELF_HOSTING.md and .env.example document exactly what is/isn't sent and
  how to disable it (ANALYTICS_DISABLED). Also fixes the long-standing
  team_size→source drift in docs/analytics.md.

Verified locally: go build/vet, go test (sourcebeacon, analytics, handler),
pnpm typecheck (all packages), locale parity (157), step-source (6) + core
config/schema (69) vitest, lint (0 errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(analytics): wire self-host source beacon through metrics, guard nil pool (MUL-3708)

Addresses Howard CI blockers on #4691 (no product-direction change):

- loadInstanceSalt returns "" on nil pool; salt is only loaded when
  ShouldSendFromEnv() is true, via a bounded (5s) context — restores the
  "router constructible without a DB" invariant (nil-pool routing tests).
- Add multica_self_host_source_channel_total counter (by source) + an
  IncForEvent case, so every analytics event is paired with a Prometheus
  counter. NormalizeSourceChannel reuses sourcebeacon allowlist (no 3rd copy).
- Beacon handler now builds the event via the analytics.SelfHostSourceChannel
  helper and ships it through obsmetrics.RecordEvent (no naked Capture); not
  IsMetricsOnly, so it still reaches PostHog.
- Prime the new family in the registry-families test.

Verified: go build/vet, go test ./internal/metrics ./internal/sourcebeacon
./internal/handler ./cmd/server (incl. the 3 named blockers + registry +
record-event-helper lints) all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 15:56:16 +08:00
Ryan Yu
c2e8892194 fix(chat): refresh message caches on reconnect (MUL-3831) (#4677)
Invalidate per-session chat message caches (messages, messages-page,
pending-task, task-messages) on websocket reconnect / WS instance change so
a chat that missed chat/task events while disconnected recovers without a
full reload, matching the existing per-issue recovery pattern.

Co-authored-by: Ryan <1141524679@qq.com>
2026-06-29 15:34:36 +08:00
Bohan Jiang
5206d7c613 feat(slack): link the Slack integration guide from the Connect dialog (MUL-3666) (#4697)
The bring-your-own-app Connect Slack dialog only had a (hidden) video CTA, so
users had no in-product pointer to the setup instructions. Add an always-visible
"Read the setup guide" link that opens the Slack integration docs page,
localized to the viewer's language (https://multica.ai/docs[/<lang>]/slack-bot-integration),
following the existing doc-link convention in the app. Adds the byo_docs_link
string to en / zh-Hans / ja / ko.

The doc page it points to ships in the docs PR (#4693).

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 15:33:40 +08:00
Bohan Jiang
e444698a09 docs: channel integrations + Slack bot + Slack app setup (MUL-3666) (#4693)
* docs: channel integrations overview + Slack bot page + Slack app setup guide (MUL-3666)

- channels.mdx: channel-engine overview with an architecture diagram (Mermaid),
  the inbound pipeline, the session/context model, and the authorization gates
  (account binding + workspace membership) — all shared by Lark and Slack.
- slack-bot-integration.mdx: the Slack channel page (mirrors lark-bot-integration)
  — BYO connect flow, usage (@ in channel / DM / /issue), one-bot-per-agent,
  permissions, and self-host (MULTICA_SLACK_SECRET_KEY).
- create-slack-app.mdx: standalone step-by-step — create a Slack app from a
  copy-paste manifest, install it, and grab the bot + app-level tokens.
- meta.json: list the three pages under Integrations.

English (canonical) only this pass; zh/ja/ko localization to follow.

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

* docs(slack): inline full manifest + step-by-step setup into the Slack page (MUL-3666)

The Slack page only linked out for setup, which read as too thin. Fold the
complete, code-verified app manifest and the full walkthrough (create from
manifest → install + bot token → app-level token with connections:write →
connect in Multica) directly into slack-bot-integration.mdx, plus a table
explaining what each scope/event is for.

Remove the now-redundant standalone create-slack-app.mdx (its content lives on
the Slack page) and update meta.json + the channels.mdx links accordingly, so
there's one comprehensive Slack page and no duplicated manifest to drift.

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

* docs(i18n): translate channel + Slack pages to zh/ja/ko and add to nav (MUL-3666)

Adds Simplified Chinese, Japanese, and Korean versions of channels.mdx and
slack-bot-integration.mdx, and lists both pages under Integrations in
meta.{zh,ja,ko}.json. The copy-paste manifest YAML and dotenv blocks are kept
byte-identical to the English source across all languages; in-page anchors in
the channel page point at the slug of each translated heading.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 15:33:23 +08:00
Bohan Jiang
658e63d9be fix: prefer local upload attachment URLs (#4686)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 14:49:04 +08:00
40 changed files with 1942 additions and 127 deletions

View 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)

View 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)

View 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)

View 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)

View File

@@ -30,8 +30,10 @@
"---インボックス---",
"inbox",
"---連携---",
"channels",
"github-integration",
"lark-bot-integration",
"slack-bot-integration",
"---セルフホスト & 運用---",
"environment-variables",
"auth-setup",

View File

@@ -30,8 +30,10 @@
"---Inbox---",
"inbox",
"---Integrations---",
"channels",
"github-integration",
"lark-bot-integration",
"slack-bot-integration",
"---Self-hosting & ops---",
"environment-variables",
"auth-setup",

View File

@@ -30,8 +30,10 @@
"---인박스---",
"inbox",
"---연동---",
"channels",
"github-integration",
"lark-bot-integration",
"slack-bot-integration",
"---자체 호스팅 & 운영---",
"environment-variables",
"auth-setup",

View File

@@ -30,8 +30,10 @@
"---收件箱---",
"inbox",
"---集成---",
"channels",
"github-integration",
"lark-bot-integration",
"slack-bot-integration",
"---自部署运维---",
"environment-variables",
"auth-setup",

View File

@@ -0,0 +1,175 @@
---
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="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) — セルフホスト構成の完全なリファレンス

View File

@@ -0,0 +1,175 @@
---
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="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) — 전체 자체 호스팅 구성 참조

View File

@@ -0,0 +1,175 @@
---
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="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

View File

@@ -0,0 +1,175 @@
---
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-appBYO**模式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="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) —— 完整的自部署配置参考

View File

@@ -293,6 +293,26 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.3.32",
date: "2026-06-29",
title: "Detach sub-Issues, sturdier daemon reconnects, and friendlier attachment previews",
changes: [],
features: [
"Issues now have a Remove parent action, so you can detach a sub-Issue without first having to pick a different parent.",
],
improvements: [
"The local daemon reconnects to Multica through a more resilient WebSocket flow with bounded backoff, so brief network drops recover smoothly instead of stalling.",
"The daemon now bounds each runtime probe with its own timeout, so a single wedged CLI can no longer block every other runtime from coming online.",
],
fixes: [
"Scheduled autopilots advance their next-run time the moment a run is dispatched, so a slow runner can no longer cause back-to-back duplicate dispatches.",
"Attachment previews open correctly whether the URL redirects inside a frame, comes back from the same origin, or was uploaded locally — and local upload URLs are now preferred when available.",
"When the failed-task handler unsticks an Issue, the Issue view refreshes immediately instead of waiting for a manual reload.",
"Sticky Issue comment headers share the same background fade as the highlight, so settling on a comment no longer looks out of sync.",
"Chat conversations refresh their message cache when reconnecting, so you no longer see stale messages right after coming back online.",
],
},
{
version: "0.3.31",
date: "2026-06-26",

View File

@@ -269,6 +269,26 @@ export function createJaDict(allowSignup: boolean): LandingDict {
fixes: "バグ修正",
},
entries: [
{
version: "0.3.32",
date: "2026-06-29",
title: "サブ Issue の切り離し、より堅牢なデーモン再接続、どこからでも開ける添付プレビュー",
changes: [],
features: [
"Issue のアクションに「親 Issue を解除」が追加され、別の親を選び直さなくても子 Issue を直接切り離せます。",
],
improvements: [
"ローカル デーモンの WebSocket 再接続が、上限付きのバックオフを備えたより堅牢な流れに見直され、瞬断にもスムーズに復帰します。",
"デーモンはランタイムのバージョン確認に個別のタイムアウトを設けるようになり、応答しない 1 つの CLI が他のランタイム起動を巻き込んで止めることがなくなりました。",
],
fixes: [
"予約オートパイロットはディスパッチ直後に次回実行時刻を進めるようになり、遅いランナーが同じ実行を続けて送り出すことがなくなりました。",
"添付プレビューは、フレーム内リダイレクト、同一オリジン、ローカル アップロードのいずれの場合も正しく開き、ローカル アップロード URL があるときはそちらを優先します。",
"失敗タスク ハンドラーが詰まった Issue を解除すると、Issue 表示が即座に更新され、手動リロードが不要になりました。",
"Issue コメントの sticky ヘッダーがハイライトのフェードと同じ背景遷移を共有し、固定切り替えの違和感がなくなりました。",
"Chat の会話は再接続時にメッセージ キャッシュを更新するため、オンラインに戻った直後に古いメッセージが残らなくなりました。",
],
},
{
version: "0.3.31",
date: "2026-06-26",

View File

@@ -268,6 +268,26 @@ export function createKoDict(allowSignup: boolean): LandingDict {
fixes: "버그 수정",
},
entries: [
{
version: "0.3.32",
date: "2026-06-29",
title: "하위 Issue 분리, 더 견고한 데몬 재연결, 어디서나 열리는 첨부 미리보기",
changes: [],
features: [
"Issue 액션에 '상위 Issue 해제'가 추가되어, 다른 상위를 먼저 고르지 않고도 하위 Issue를 즉시 분리할 수 있습니다.",
],
improvements: [
"로컬 데몬이 더 견고한 WebSocket 흐름과 상한이 있는 백오프로 재연결해, 짧은 네트워크 단절에도 매끄럽게 복구됩니다.",
"데몬이 각 런타임의 버전 점검에 별도 타임아웃을 두어, 멈춰 버린 단 하나의 CLI가 다른 런타임의 기동을 막지 못합니다.",
],
fixes: [
"예약 오토파일럿은 디스패치되자마자 다음 실행 시각을 앞당겨, 느린 러너가 같은 실행을 중복으로 내보내지 않습니다.",
"첨부 미리보기는 프레임 내 리다이렉트, 동일 출처, 로컬 업로드 어떤 경우에도 정상적으로 열리며, 로컬 업로드 URL이 있으면 그쪽을 우선 사용합니다.",
"실패 작업 핸들러가 멈춘 Issue를 풀어 줄 때 화면이 즉시 갱신되어, 수동 새로고침이 필요 없습니다.",
"Issue 댓글의 sticky 헤더가 하이라이트 페이드와 같은 배경 전환을 공유해, 고정 표시 전환이 더 이상 어색하지 않습니다.",
"Chat 대화가 재연결 시 메시지 캐시를 새로 받아, 오프라인에서 돌아왔을 때 오래된 메시지가 남지 않습니다.",
],
},
{
version: "0.3.31",
date: "2026-06-26",

View File

@@ -293,6 +293,26 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.3.32",
date: "2026-06-29",
title: "支持解除父子 Issue、守护进程重连更稳附件预览处处可开",
changes: [],
features: [
"Issue 操作菜单新增「移除父级 Issue」可以直接断开父子关系不用先去挑一个新的父级。",
],
improvements: [
"本地守护进程的 WebSocket 重连改为带上限的退避策略,短暂断网时恢复更顺滑,不再原地空转。",
"守护进程在探测各个智能体运行时版本时加上了独立超时,单个卡死的 CLI 不会再连累其他运行时。",
],
fixes: [
"定时 Autopilot 调度后会立即推进下一次运行时间,避免慢节点造成重复触发。",
"附件预览在框架内重定向、同源资源、本地上传等场景下都能正常打开;有本地上传 URL 时会优先使用本地链接。",
"失败任务处理器解开卡住的 Issue 时,前端视图会立即刷新,无需手动重新加载。",
"Issue 评论吸顶头与高亮渐隐使用了同一套背景过渡,吸顶切换不再有错位感。",
"Chat 在重新连上后会刷新消息缓存,掉线再回来时不再看到陈旧消息。",
],
},
{
version: "0.3.31",
date: "2026-06-26",

View File

@@ -14,13 +14,17 @@ export const chatKeys = {
/** Full sessions list (active + archived); the dropdown splits locally. */
sessions: (wsId: string) => [...chatKeys.all(wsId), "sessions"] as const,
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
messagesPage: (sessionId: string) => ["chat", "messages-page", sessionId] as const,
pendingTask: (sessionId: string) => ["chat", "pending-task", sessionId] as const,
messagesAll: () => ["chat", "messages"] as const,
messages: (sessionId: string) => [...chatKeys.messagesAll(), sessionId] as const,
messagesPageAll: () => ["chat", "messages-page"] as const,
messagesPage: (sessionId: string) => [...chatKeys.messagesPageAll(), sessionId] as const,
pendingTaskAll: () => ["chat", "pending-task"] as const,
pendingTask: (sessionId: string) => [...chatKeys.pendingTaskAll(), sessionId] as const,
/** Aggregate of in-flight chat tasks for the current user — FAB reads this. */
pendingTasks: (wsId: string) => [...chatKeys.all(wsId), "pending-tasks"] as const,
/** Per-task execution messages — shared with issue agent cards. */
taskMessages: (taskId: string) => ["task-messages", taskId] as const,
taskMessagesAll: () => ["task-messages"] as const,
taskMessages: (taskId: string) => [...chatKeys.taskMessagesAll(), taskId] as const,
};
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

View File

@@ -102,9 +102,9 @@ describe("useRealtimeSync — ws instance change", () => {
rerender({ ws: ws2 });
// Should have called invalidateQueries for all workspace-scoped keys
// (15 workspace-scoped + 6 per-issue prefixes + 1 workspaceKeys.list()
// + 1 cross-workspace inbox unread summary = 23 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(23);
// (15 workspace-scoped + 6 per-issue prefixes + 4 per-chat prefixes
// + 1 workspaceKeys.list() + 1 cross-workspace inbox unread summary = 27 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(27);
});
it("does not re-invalidate when rerendered with the same ws instance", () => {
@@ -164,4 +164,26 @@ describe("useRealtimeSync — ws instance change", () => {
expect(calls).toContainEqual(["issues", "attachments"]);
expect(calls).toContainEqual(["issues", "tasks"]);
});
it("invalidates per-chat-session caches (no wsId in key) on ws instance change", () => {
// These keys are not under the ["chat", wsId] prefix, so they need their
// own recovery invalidation when reconnecting after missed chat/task events.
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
rerender({ ws: null });
const ws2 = createMockWs();
rerender({ ws: ws2 });
const calls = invalidateSpy.mock.calls.map((call: [{ queryKey?: unknown }, ...unknown[]]) => call[0].queryKey);
expect(calls).toContainEqual(["chat", "messages"]);
expect(calls).toContainEqual(["chat", "messages-page"]);
expect(calls).toContainEqual(["chat", "pending-task"]);
expect(calls).toContainEqual(["task-messages"]);
});
});

View File

@@ -340,6 +340,14 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
qc.invalidateQueries({ queryKey: issueKeys.usageAll() });
qc.invalidateQueries({ queryKey: issueKeys.attachmentsAll() });
qc.invalidateQueries({ queryKey: issueKeys.tasksAll() });
// Per-chat-session caches are also keyed without wsId, so the
// chatKeys.all(wsId) prefix above only reaches session lists / aggregates.
// Message streams rely on WS invalidation with staleTime: Infinity; recover
// sessions that missed chat/task events while the socket was disconnected.
qc.invalidateQueries({ queryKey: chatKeys.messagesAll() });
qc.invalidateQueries({ queryKey: chatKeys.messagesPageAll() });
qc.invalidateQueries({ queryKey: chatKeys.pendingTaskAll() });
qc.invalidateQueries({ queryKey: chatKeys.taskMessagesAll() });
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { BarChart3, FolderKanban } from "lucide-react";
import { BarChart3, FolderKanban, Trash2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import {
@@ -52,8 +52,9 @@ import {
aggregateDailyTokens,
aggregateWeeklyTasks,
aggregateWeeklyTime,
bucketUnknownAgentRows,
computeDailyTotals,
filterKnownAgentRows,
DELETED_AGENTS_ROW_ID,
formatDuration,
mergeAgentDashboardRows,
type AgentDashboardRow,
@@ -314,17 +315,29 @@ export function DashboardPage() {
[agentTokenRows, runTimeRows],
);
// Hide rollup rows for agents that were hard-deleted from the workspace —
// they'd otherwise show up as a bare UUID on the leaderboard (MUL-3771).
// Archived agents stay (the agent list is fetched with archived included);
// only truly-removed agents drop out. Skip filtering until the agent list
// has loaded so a slow agents fetch doesn't transiently blank the list.
// Fold rollup rows for hard-deleted agents into one aggregated "Deleted
// agents" row instead of showing them as a bare UUID (MUL-3771) or dropping
// them outright — dropping made the per-agent breakdown stop reconciling
// with the top-line Cost/Tokens KPIs, which still count that spend (MUL-3776,
// #4640). Archived agents stay as themselves (the agent list is fetched with
// archived included); only truly-removed agents collapse into the bucket.
// Skip bucketing until the agent list has loaded so a slow agents fetch
// doesn't transiently merge every row.
const knownAgentIds = useMemo(
() => (agentsQuery.isSuccess ? new Set(agents.map((a) => a.id)) : null),
[agentsQuery.isSuccess, agents],
);
const visibleAgentRows = useMemo(
() => filterKnownAgentRows(agentRows, knownAgentIds),
() => bucketUnknownAgentRows(agentRows, knownAgentIds),
[agentRows, knownAgentIds],
);
// Distinct hard-deleted agents folded into the bucket — drives the caption's
// "· N deleted" suffix (the bucket itself is a single row).
const deletedAgentCount = useMemo(
() =>
knownAgentIds
? agentRows.filter((r) => !knownAgentIds.has(r.agentId)).length
: 0,
[agentRows, knownAgentIds],
);
@@ -431,6 +444,7 @@ export function DashboardPage() {
<Leaderboard
rows={visibleAgentRows}
agents={agents}
deletedAgentCount={deletedAgentCount}
lessThanMinuteLabel={t(($) => $.duration.less_than_minute)}
/>
</>
@@ -640,10 +654,12 @@ const SORT_METRIC: Record<LeaderboardSort, (r: AgentDashboardRow) => number> = {
function Leaderboard({
rows,
agents,
deletedAgentCount,
lessThanMinuteLabel,
}: {
rows: AgentDashboardRow[];
agents: { id: string; name: string }[];
deletedAgentCount: number;
lessThanMinuteLabel: string;
}) {
const { t } = useT("usage");
@@ -684,7 +700,12 @@ function Leaderboard({
<div className="flex items-center gap-3">
<Segmented value={sortBy} onChange={setSortBy} options={sortOptions} />
<span className="text-xs text-muted-foreground">
{t(($) => $.leaderboard.caption, { count: rows.length })}
{deletedAgentCount > 0
? t(($) => $.leaderboard.caption_with_deleted, {
count: rows.length - 1,
deleted: deletedAgentCount,
})
: t(($) => $.leaderboard.caption, { count: rows.length })}
</span>
</div>
</div>
@@ -704,6 +725,11 @@ function Leaderboard({
</div>
<div className="divide-y">
{sortedRows.map((row) => {
// The deleted-agents bucket is a synthetic row, not a real agent:
// render a neutral placeholder (no avatar fetch / hover card / UUID)
// and dash out Time/Tasks, which it never carries (see
// bucketUnknownAgentRows).
const isDeletedBucket = row.agentId === DELETED_AGENTS_ROW_ID;
const agent = agents.find((a) => a.id === row.agentId);
const value = SORT_METRIC[sortBy](row);
const pct = maxValue > 0 ? (value / maxValue) * 100 : 0;
@@ -713,15 +739,28 @@ function Leaderboard({
className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_5rem_5rem_5rem_4rem] items-center gap-3 px-4 py-2"
>
<div className="flex min-w-0 items-center gap-2">
<ActorAvatar
actorType="agent"
actorId={row.agentId}
size={22}
enableHoverCard
/>
<span className="cursor-pointer truncate text-sm font-medium">
{agent?.name ?? row.agentId}
</span>
{isDeletedBucket ? (
<>
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Trash2 className="h-3 w-3" />
</span>
<span className="truncate text-sm font-medium italic text-muted-foreground">
{t(($) => $.leaderboard.deleted_agents)}
</span>
</>
) : (
<>
<ActorAvatar
actorType="agent"
actorId={row.agentId}
size={22}
enableHoverCard
/>
<span className="cursor-pointer truncate text-sm font-medium">
{agent?.name ?? row.agentId}
</span>
</>
)}
</div>
<div className="relative h-2 overflow-hidden rounded-full bg-muted">
<div
@@ -742,12 +781,14 @@ function Leaderboard({
<div
className={`text-right text-xs tabular-nums ${sortBy === "time" ? "font-medium text-foreground" : "text-muted-foreground"}`}
>
{formatDuration(row.seconds, lessThanMinuteLabel)}
{isDeletedBucket
? "—"
: formatDuration(row.seconds, lessThanMinuteLabel)}
</div>
<div
className={`text-right text-xs tabular-nums ${sortBy === "tasks" ? "font-medium text-foreground" : "text-muted-foreground"}`}
>
{row.taskCount}
{isDeletedBucket ? "—" : row.taskCount}
</div>
</div>
);

View File

@@ -4,8 +4,9 @@ import {
aggregateDailyCost,
aggregateWeeklyTasks,
aggregateWeeklyTime,
bucketUnknownAgentRows,
computeDailyTotals,
filterKnownAgentRows,
DELETED_AGENTS_ROW_ID,
formatDuration,
mergeAgentDashboardRows,
} from "./utils";
@@ -202,26 +203,81 @@ describe("mergeAgentDashboardRows", () => {
});
});
describe("filterKnownAgentRows", () => {
const rows = [
{ agentId: "live", tokens: 100, cost: 1, seconds: 10, taskCount: 1 },
{ agentId: "deleted", tokens: 50, cost: 0.5, seconds: 5, taskCount: 1 },
];
describe("bucketUnknownAgentRows", () => {
const live = { agentId: "live", tokens: 100, cost: 1, seconds: 10, taskCount: 1 };
const archived = {
agentId: "archived",
tokens: 80,
cost: 0.8,
seconds: 8,
taskCount: 2,
};
const deletedA = {
agentId: "deleted-a",
tokens: 50,
cost: 0.5,
seconds: 5,
taskCount: 1,
};
const deletedB = {
agentId: "deleted-b",
tokens: 30,
cost: 0.25,
seconds: 3,
taskCount: 4,
};
it("drops rows whose agent is no longer in the workspace", () => {
// "deleted" is absent from the known set — it's a hard-deleted agent whose
// legacy rollup row would otherwise render as a bare UUID.
const out = filterKnownAgentRows(rows, new Set(["live"]));
expect(out.map((r) => r.agentId)).toEqual(["live"]);
it("folds every hard-deleted agent into one aggregated bucket row", () => {
// "deleted-a" / "deleted-b" are absent from the known set — they'd otherwise
// render as bare UUIDs. They collapse into a single sentinel row.
const out = bucketUnknownAgentRows(
[live, deletedA, deletedB],
new Set(["live"]),
);
expect(out.map((r) => r.agentId)).toEqual(["live", DELETED_AGENTS_ROW_ID]);
const bucket = out.find((r) => r.agentId === DELETED_AGENTS_ROW_ID)!;
expect(bucket.tokens).toBe(80);
expect(bucket.cost).toBeCloseTo(0.75);
// Time/Tasks never attach to the bucket — the run-time rollup inner-joins
// `agent`, so deleted agents contribute nothing to those columns.
expect(bucket.seconds).toBe(0);
expect(bucket.taskCount).toBe(0);
});
it("keeps every row while the agent list is still loading (null set)", () => {
const out = filterKnownAgentRows(rows, null);
expect(out.map((r) => r.agentId)).toEqual(["live", "deleted"]);
it("keeps the bucket total reconciled with the top-line spend", () => {
// The KPI total counts deleted-agent spend; sum(visible rows) must match it
// so the breakdown reconciles (MUL-3776).
const out = bucketUnknownAgentRows(
[live, deletedA, deletedB],
new Set(["live"]),
);
const visibleCost = out.reduce((s, r) => s + r.cost, 0);
const kpiCost = [live, deletedA, deletedB].reduce((s, r) => s + r.cost, 0);
expect(visibleCost).toBeCloseTo(kpiCost);
});
it("drops every row when the known set is empty", () => {
expect(filterKnownAgentRows(rows, new Set())).toEqual([]);
it("keeps archived agents as themselves, never in the bucket", () => {
// The agent list is fetched with archived included, so archived agents are
// in the known set and stay on the board under their own id.
const out = bucketUnknownAgentRows(
[live, archived, deletedA],
new Set(["live", "archived"]),
);
expect(out.map((r) => r.agentId)).toEqual([
"live",
"archived",
DELETED_AGENTS_ROW_ID,
]);
});
it("adds no bucket row when every agent is known", () => {
const out = bucketUnknownAgentRows([live, archived], new Set(["live", "archived"]));
expect(out.map((r) => r.agentId)).toEqual(["live", "archived"]);
});
it("keeps every row untouched while the agent list is still loading (null set)", () => {
const out = bucketUnknownAgentRows([live, deletedA], null);
expect(out.map((r) => r.agentId)).toEqual(["live", "deleted-a"]);
});
});

View File

@@ -227,21 +227,54 @@ export function mergeAgentDashboardRows(
});
}
// Drop usage rows whose agent no longer exists in the workspace. The agent
// list is fetched with `include_archived: true`, so archived agents keep
// their names and stay on the leaderboard; only hard-deleted agents fall out
// of `knownAgentIds`. Those are legacy rollup rows that would otherwise
// render as a bare UUID (MUL-3771).
// Synthetic agentId for the row that aggregates all hard-deleted agents.
// Sentinel (not a real UUID) so the component can detect it and render a
// placeholder instead of looking the id up in the agent list.
export const DELETED_AGENTS_ROW_ID = "__deleted_agents__";
// Fold usage rows whose agent no longer exists in the workspace into a single
// aggregated "Deleted agents" row instead of dropping them. The agent list is
// fetched with `include_archived: true`, so archived agents keep their names
// and stay on the leaderboard as themselves; only hard-deleted agents fall out
// of `knownAgentIds` and collapse into the bucket.
//
// `knownAgentIds` is empty while the agent list is still loading; callers
// MUL-3771 (PR #4637) originally *dropped* these rows so they'd stop rendering
// as a bare UUID — but the top-line Cost/Tokens KPIs still count their spend
// (those totals aggregate `task_usage_hourly` without joining `agent`), so the
// per-agent breakdown no longer reconciled with the totals (MUL-3776, #4640).
// Aggregating instead of dropping keeps `sum(visible rows) == KPI total` while
// still never exposing a UUID. The bucket carries tokens + cost only; seconds
// and taskCount stay 0 because the run-time rollups inner-join `agent`, so
// deleted agents already contribute nothing to the Time/Tasks KPIs — the
// component renders those two columns as "—" for this row.
//
// `knownAgentIds` is `null` while the agent list is still loading; callers
// pass `null` in that case so the rows pass through untouched instead of the
// whole leaderboard blanking on a slow fetch.
export function filterKnownAgentRows(
// whole leaderboard collapsing into one bucket on a slow fetch.
export function bucketUnknownAgentRows(
rows: AgentDashboardRow[],
knownAgentIds: ReadonlySet<string> | null,
): AgentDashboardRow[] {
if (!knownAgentIds) return rows;
return rows.filter((r) => knownAgentIds.has(r.agentId));
const known: AgentDashboardRow[] = [];
const bucket: AgentDashboardRow = {
agentId: DELETED_AGENTS_ROW_ID,
tokens: 0,
cost: 0,
seconds: 0,
taskCount: 0,
};
let hasDeleted = false;
for (const r of rows) {
if (knownAgentIds.has(r.agentId)) {
known.push(r);
continue;
}
hasDeleted = true;
bucket.tokens += r.tokens;
bucket.cost += r.cost;
}
return hasDeleted ? [...known, bucket] : known;
}
// ---------------------------------------------------------------------------

View File

@@ -262,6 +262,40 @@ describe("Attachment — image dispatch", () => {
);
});
it("prefers a local disk /uploads URL over API markdown in split-origin self-host", () => {
getBaseUrlMock.mockReturnValue("https://api.example.test");
const id = "11111111-2222-3333-4444-555555555555";
const markdownUrl = `https://api.example.test/api/attachments/${id}/download`;
const mediaUrl = "https://api.example.test/uploads/workspaces/ws-1/shot.png";
const att = makeRecord({
id,
url: "/uploads/workspaces/ws-1/shot.png",
markdown_url: markdownUrl,
download_url: `/api/attachments/${id}/download`,
});
resolverState.attachments = [att];
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: markdownUrl,
filename: "shot.png",
forceKind: "image",
}}
/>,
);
expect(document.querySelector("img")?.getAttribute("src")).toBe(mediaUrl);
fireEvent.click(screen.getByTitle("View"));
const imageSrcs = [...document.querySelectorAll("img")].map((img) =>
img.getAttribute("src"),
);
expect(imageSrcs).toEqual([mediaUrl, mediaUrl]);
});
it("opens preview with the same resolved media URL when a reopened draft record has no download_url", () => {
configStore.setState({ cdnDomain: "cdn.example.test" });
const id = "11111111-2222-3333-4444-555555555555";

View File

@@ -237,12 +237,17 @@ function absolutizeMediaURL(rawUrl: string): string {
// reports `cdn_signed` — in CloudFront signed-URL mode the same
// domain serves PRIVATE content and a raw (unsigned) storage URL is
// a guaranteed 403 (MUL-3254).
// 3. `record.markdown_url` — the durable, server-policy-aligned URL.
// 3. Local disk `record.url` — self-host LocalStorage without
// LOCAL_UPLOAD_BASE_URL stores a site-relative `/uploads/...` path.
// It is the direct static object URL and is loadable once
// `absolutizeMediaURL` prefixes apiBaseUrl in split-origin clients.
// 4. `record.markdown_url` — the durable, server-policy-aligned URL.
// Beats raw `record.url` because it never points at a private
// bucket (must-fix 2 from MUL-3192 review).
// 4. `record.url` — legacy fallback for responses that omit
// bucket (must-fix 2 from MUL-3192 review), except for the explicit
// site-relative local upload path above.
// 5. `record.url` — legacy fallback for responses that omit
// `markdown_url` (a backend old enough to predate MUL-3192).
// 5. The input URL — when there's no record at all.
// 6. The input URL — when there's no record at all.
function pickInlineMediaURL(
record: AttachmentRecord,
fallback: string,
@@ -257,11 +262,18 @@ function pickInlineMediaURL(
return dl;
}
if (!cdnSigned && storageURLMatchesCdnDomain(record.url, cdnDomain)) return record.url;
if (isSiteRelativeLocalUploadURL(record.url)) return record.url;
if (record.markdown_url) return record.markdown_url;
if (record.url) return record.url;
return fallback;
}
function isSiteRelativeLocalUploadURL(rawURL: string): boolean {
if (!rawURL || !rawURL.startsWith("/")) return false;
const path = rawURL.split(/[?#]/, 1)[0] ?? "";
return path === "/uploads" || path.startsWith("/uploads/");
}
function storageURLMatchesCdnDomain(rawURL: string, cdnDomain: string): boolean {
const expected = normalizeHost(cdnDomain);
if (!rawURL || !expected) return false;

View File

@@ -302,7 +302,7 @@
},
"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.",
"page_description": "Connect each Multica Agent to its own Slack bot. Members can DM the bot, @mention it in a channel, and type /issue 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.",
@@ -333,13 +333,10 @@
"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_docs_link": "Step-by-step: connect your Multica agent to Slack",
"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",

View File

@@ -41,6 +41,8 @@
"leaderboard": {
"title": "Leaderboard",
"caption": "{{count}} agents",
"caption_with_deleted": "{{count}} agents · {{deleted}} deleted",
"deleted_agents": "Deleted agents",
"header_agent": "Agent",
"header_tokens": "Tokens",
"header_cost": "Cost",

View File

@@ -302,7 +302,7 @@
},
"slack": {
"section_title": "Slack",
"page_description": "各 Multica エージェントを専用の Slack ボットに接続します。ワークスペース管理者が Slack アプリを作成し、その bot トークンと app レベルトークンを貼り付けます。メンバーはボットに DM したりチャンネルで @メンションしたりでき、/issue で始まるメッセージ(例:「@bot /issue ログインの不具合を修正」)で新しい Multica issue を作成できます。",
"page_description": "各 Multica エージェントを専用の Slack ボットに接続します。メンバーはボットに DM したりチャンネルで @メンションしたり、/issue と入力して新しい Multica イシューを起こすことができます。",
"not_enabled_title": "Slack 連携が有効になっていません",
"not_enabled_description_prefix": "サーバーで",
"not_enabled_description_suffix": "を設定すると Slack ボットのインストールが有効になります。",
@@ -333,13 +333,10 @@
"agent_bot_manage_link": "Slack で開く",
"agent_bot_manage_tooltip": "このボットの Slack ワークスペースを開きます。",
"byo_dialog_title": "Slack ボットを接続",
"byo_dialog_intro": "自分の Slack アプリを作成してワークスペースにインストールし、その 2 つのトークンを下に貼り付けてください。同じワークスペース内でエージェントごとに別のアプリを接続できます。",
"byo_video_cta": "セットアップ手順の動画を見る",
"byo_docs_link": "Step-by-stepMultica エージェントを Slack に接続する",
"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": "キャンセル",

View File

@@ -41,6 +41,8 @@
"leaderboard": {
"title": "リーダーボード",
"caption": "{{count}} 件のエージェント",
"caption_with_deleted": "{{count}} 件のエージェント · 削除済み {{deleted}} 件",
"deleted_agents": "削除済みエージェント",
"header_agent": "エージェント",
"header_tokens": "トークン",
"header_cost": "コスト",

View File

@@ -379,7 +379,7 @@
},
"slack": {
"section_title": "Slack",
"page_description": "각 Multica 에이전트를 전용 Slack 봇에 연결합니다. 워크스페이스 관리자가 Slack 앱을 만들고 봇 토큰과 app 레벨 토큰을 붙여넣으면, 멤버는 봇에게 DM하거나 채널에서 @멘션할 수 있습니다. /issue로 시작하는 메시지(예: \"@bot /issue 로그인 버그 수정\")로 새 Multica 이슈를 만들 수 있어요.",
"page_description": "각 Multica 에이전트를 전용 Slack 봇에 연결하세요. 멤버는 봇과 1:1로 대화하거나, 채널에서 @ 멘션하거나, /issue 를 입력해 새 Multica 이슈를 만들 수 있습니다.",
"not_enabled_title": "Slack 연동이 활성화되지 않았어요",
"not_enabled_description_prefix": "서버에서",
"not_enabled_description_suffix": "를 설정하면 Slack 봇 설치가 활성화됩니다.",
@@ -410,13 +410,10 @@
"agent_bot_manage_link": "Slack에서 열기",
"agent_bot_manage_tooltip": "이 봇의 Slack 워크스페이스를 엽니다.",
"byo_dialog_title": "Slack 봇 연결",
"byo_dialog_intro": "직접 만든 Slack 앱을 워크스페이스에 설치한 뒤, 두 개의 토큰을 아래에 붙여넣으세요. 같은 워크스페이스에서 에이전트마다 다른 앱을 연결할 수 있습니다.",
"byo_video_cta": "설정 안내 영상 보기",
"byo_docs_link": "Step-by-step: Multica 에이전트를 Slack에 연결하기",
"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": "취소",

View File

@@ -41,6 +41,8 @@
"leaderboard": {
"title": "리더보드",
"caption": "에이전트 {{count}}개",
"caption_with_deleted": "에이전트 {{count}}개 · 삭제됨 {{deleted}}개",
"deleted_agents": "삭제된 에이전트",
"header_agent": "에이전트",
"header_tokens": "토큰",
"header_cost": "비용",

View File

@@ -302,7 +302,7 @@
},
"slack": {
"section_title": "Slack",
"page_description": "每个 Multica Agent 连接到它自己的 Slack 机器人。工作区管理员创建一个 Slack app 并粘贴它的 bot 和 app-level token成员之后即可私聊机器人,或在频道中 @ 它,并以 /issue 开头发消息(例如「@机器人 /issue 修复登录问题」)来创建新的 Multica issue。",
"page_description": "每个 Multica 智能体连接到专属的 Slack 机器人。成员可私聊机器人在频道中 @ 它,或输入 /issue 直接创建 Multica issue。",
"not_enabled_title": "Slack 集成未启用",
"not_enabled_description_prefix": "在服务器上设置",
"not_enabled_description_suffix": "以启用 Slack 机器人安装。",
@@ -333,13 +333,10 @@
"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_docs_link": "Step-by-step把你的 Multica 智能体连接到 Slack",
"byo_bot_token_label": "Bot tokenxoxb-",
"byo_bot_token_hint": "Slack app → OAuth & Permissions → Bot User OAuth Token。",
"byo_app_token_label": "App-level tokenxapp-",
"byo_app_token_hint": "Slack app → Basic Information → App-Level Tokensscope 选 connections:write。",
"byo_scopes_hint": "需要的 Bot scopesapp_mentions:read、channels:history、chat:write、groups:history、im:history、mpim:history、users:read。",
"byo_submit": "连接",
"byo_submitting": "连接中…",
"byo_cancel": "取消",

View File

@@ -41,6 +41,8 @@
"leaderboard": {
"title": "排行榜",
"caption": "{{count}} 个智能体",
"caption_with_deleted": "{{count}} 个智能体 · {{deleted}} 个已删除",
"deleted_agents": "已删除的智能体",
"header_agent": "智能体",
"header_tokens": "Token",
"header_cost": "费用",

View File

@@ -10,7 +10,6 @@ import { Card, CardContent } from "@multica/ui/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -242,6 +241,21 @@ function InstallationRow({
// shows how to create the Slack app + copy its two tokens is recorded.
const SLACK_BYO_VIDEO_URL = "";
// slackDocsUrl points at the Slack integration guide on the docs site,
// localized to the viewer's language. The docs site uses /<lang>/ path
// prefixes (English has none), matching the convention used elsewhere in the
// app for doc links (e.g. the autopilots webhook docs link).
function slackDocsUrl(lang: string | undefined): string {
const prefix = lang?.startsWith("zh")
? "/zh"
: lang?.startsWith("ja")
? "/ja"
: lang?.startsWith("ko")
? "/ko"
: "";
return `https://multica.ai/docs${prefix}/slack-bot-integration`;
}
// 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
@@ -267,7 +281,7 @@ export function SlackAgentBindButton({
*/
onShowConnectedDetails?: () => void;
}) {
const { t } = useT("settings");
const { t, i18n } = useT("settings");
const wsId = useWorkspaceId();
const qc = useQueryClient();
const user = useAuthStore((s) => s.user);
@@ -370,25 +384,28 @@ export function SlackAgentBindButton({
<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"
className="inline-flex w-fit items-center gap-2 text-sm font-medium text-primary underline-offset-2 hover:underline"
>
<ExternalLink className="h-3.5 w-3.5" />
<ExternalLink className="h-4 w-4" />
{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>
<button
type="button"
onClick={() => openExternal(slackDocsUrl(i18n.language))}
className="inline-flex w-fit items-center gap-2 text-sm font-medium text-primary underline-offset-2 hover:underline"
data-testid="slack-byo-docs-link"
>
<ExternalLink className="h-4 w-4" />
{t(($) => $.slack.byo_docs_link)}
</button>
<div className="space-y-4">
<div className="space-y-1.5">
@@ -405,9 +422,6 @@ export function SlackAgentBindButton({
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">
@@ -424,9 +438,6 @@ export function SlackAgentBindButton({
spellCheck={false}
disabled={submitting}
/>
<p className="text-[11px] text-muted-foreground">
{t(($) => $.slack.byo_app_token_hint)}
</p>
</div>
</div>

View File

@@ -444,10 +444,13 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
slackBindingSvc := slack.NewBindingTokenService(queries, pool)
h.SlackBindingTokens = slackBindingSvc
slackReplier := slack.NewOutboundReplier(slack.OutboundReplierConfig{
Binding: slackBindingSvc,
Decrypt: box.Open,
PublicURL: signupConfig.PublicURL,
Logger: slog.Default(),
Binding: slackBindingSvc,
Decrypt: box.Open,
// The bind link (/slack/bind) is a web-app page, so it must use the
// app URL (MULTICA_APP_URL ?? FRONTEND_ORIGIN), NOT MULTICA_PUBLIC_URL
// (the backend/API URL). Mirrors the Lark replier (appURLFromEnv).
AppURL: appURLFromEnv(),
Logger: slog.Default(),
})
channelRouter.Register(slack.TypeSlack, slack.NewSlackResolverSet(queries, pool, slackReplier))
slack.NewOutbound(queries, box.Open, slog.Default()).Register(bus)

View File

@@ -110,13 +110,30 @@ type Client struct {
func NewClient(baseURL string) *Client {
return &Client{
baseURL: baseURL,
client: &http.Client{Timeout: 30 * time.Second},
client: &http.Client{Timeout: 30 * time.Second, Transport: cloneDefaultTransport()},
bundleClient: &http.Client{},
platform: "daemon",
os: normalizeGOOS(runtime.GOOS),
}
}
func cloneDefaultTransport() http.RoundTripper {
if transport, ok := http.DefaultTransport.(*http.Transport); ok {
return transport.Clone()
}
return http.DefaultTransport
}
// CloseIdleConnections drops pooled control-plane HTTP connections. The
// daemon calls this after repeated heartbeat transport failures so a stale
// keep-alive socket from a server restart cannot delay recovery indefinitely.
func (c *Client) CloseIdleConnections() {
if c == nil || c.client == nil {
return
}
c.client.CloseIdleConnections()
}
// normalizeGOOS maps Go's runtime.GOOS values to the protocol vocabulary
// used by X-Client-OS / client_os ("macos" / "windows" / "linux").
func normalizeGOOS(goos string) string {

View File

@@ -1905,7 +1905,19 @@ func (d *Daemon) runRuntimeHeartbeat(ctx context.Context, rid string) {
}
}
d.runHeartbeatTick(ctx, rid)
consecutiveTransientFailures := 0
tick := func() {
if d.runHeartbeatTick(ctx, rid) {
consecutiveTransientFailures++
if consecutiveTransientFailures == 2 {
d.client.CloseIdleConnections()
}
return
}
consecutiveTransientFailures = 0
}
tick()
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -1914,12 +1926,14 @@ func (d *Daemon) runRuntimeHeartbeat(ctx context.Context, rid string) {
case <-ctx.Done():
return
case <-ticker.C:
d.runHeartbeatTick(ctx, rid)
tick()
}
}
}
func (d *Daemon) runHeartbeatTick(ctx context.Context, rid string) {
// runHeartbeatTick returns true when the HTTP heartbeat hit a transient
// failure that should count toward stale idle-connection cleanup.
func (d *Daemon) runHeartbeatTick(ctx context.Context, rid string) bool {
// Skip HTTP heartbeat for runtimes that successfully acked a recent
// WebSocket heartbeat. The WS path keeps last_seen_at fresh and delivers
// actions, so the HTTP write would be a duplicate DB update. If the WS
@@ -1928,7 +1942,7 @@ func (d *Daemon) runHeartbeatTick(ctx context.Context, rid string) {
// relies on.
if d.wsHeartbeatRecentlyAcked(rid) {
d.logger.Debug("heartbeat: skipping HTTP tick, WS recently acked", "runtime_id", rid)
return
return false
}
d.logger.Debug("heartbeat: HTTP tick", "runtime_id", rid)
resp, err := d.client.SendHeartbeat(ctx, rid)
@@ -1941,20 +1955,21 @@ func (d *Daemon) runHeartbeatTick(ctx context.Context, rid string) {
// the daemon root context so notifyRuntimeSetChanged
// tearing down this heartbeat goroutine cannot abort it.
go d.handleRuntimeGone(rid)
return
return false
}
d.logger.Warn("heartbeat failed", "runtime_id", rid, "error", err)
}
return
return ctx.Err() == nil && isTransientError(err)
}
if resp != nil && resp.RuntimeGone {
// The WS path returns a successful ack with RuntimeGone=true for the
// same scenario; treat it the same way here in case HTTP starts
// surfacing this signal too.
go d.handleRuntimeGone(rid)
return
return false
}
d.handleHeartbeatActions(ctx, rid, resp)
return false
}
// handleHeartbeatActions dispatches the pending-action set returned by either

View File

@@ -18,6 +18,14 @@ import (
var errRuntimeSetChanged = errors.New("runtime set changed")
const taskWakeupMaxBackoff = 30 * time.Second
var (
taskWakeupPongWait = 60 * time.Second
taskWakeupWriteWait = 10 * time.Second
taskWakeupBackoffResetAfter = 10 * time.Second
)
type taskWakeup struct {
runtimeID string
}
@@ -36,7 +44,7 @@ func (d *Daemon) taskWakeupLoop(ctx context.Context, taskWakeups chan<- taskWake
continue
}
err := d.runTaskWakeupConnection(ctx, runtimeIDs, taskWakeups, runtimeSetCh)
connectedFor, err := d.runTaskWakeupConnection(ctx, runtimeIDs, taskWakeups, runtimeSetCh)
if ctx.Err() != nil {
return
}
@@ -44,6 +52,9 @@ func (d *Daemon) taskWakeupLoop(ctx context.Context, taskWakeups chan<- taskWake
backoff = time.Second
continue
}
if shouldResetTaskWakeupBackoff(connectedFor) {
backoff = time.Second
}
if err != nil {
d.logger.Debug("task wakeup websocket unavailable; polling fallback remains active", "error", err, "retry_in", backoff)
}
@@ -51,15 +62,22 @@ func (d *Daemon) taskWakeupLoop(ctx context.Context, taskWakeups chan<- taskWake
if err := sleepWithContextOrRuntimeChange(ctx, jitterDuration(backoff), runtimeSetCh); err != nil {
return
}
if backoff < 30*time.Second {
if backoff < taskWakeupMaxBackoff {
backoff *= 2
if backoff > 30*time.Second {
backoff = 30 * time.Second
if backoff > taskWakeupMaxBackoff {
backoff = taskWakeupMaxBackoff
}
}
}
}
func shouldResetTaskWakeupBackoff(connectedFor time.Duration) bool {
if connectedFor <= 0 {
return false
}
return taskWakeupBackoffResetAfter <= 0 || connectedFor >= taskWakeupBackoffResetAfter
}
func jitterDuration(d time.Duration) time.Duration {
if d <= 0 {
return d
@@ -72,10 +90,10 @@ func jitterDuration(d time.Duration) time.Duration {
return d + delta
}
func (d *Daemon) runTaskWakeupConnection(ctx context.Context, runtimeIDs []string, taskWakeups chan<- taskWakeup, runtimeSetCh <-chan struct{}) error {
func (d *Daemon) runTaskWakeupConnection(ctx context.Context, runtimeIDs []string, taskWakeups chan<- taskWakeup, runtimeSetCh <-chan struct{}) (time.Duration, error) {
wsURL, err := taskWakeupURL(d.cfg.ServerBaseURL, runtimeIDs)
if err != nil {
return err
return 0, err
}
headers := http.Header{}
@@ -95,8 +113,10 @@ func (d *Daemon) runTaskWakeupConnection(ctx context.Context, runtimeIDs []strin
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
conn, _, err := dialer.DialContext(ctx, wsURL, headers)
if err != nil {
return err
return 0, err
}
connectedAt := time.Now()
uptime := func() time.Duration { return time.Since(connectedAt) }
defer conn.Close()
// HTTP heartbeats resume the moment WS detaches so the freshness window
// from a previous connection cannot keep them silenced past disconnect.
@@ -151,11 +171,11 @@ func (d *Daemon) runTaskWakeupConnection(ctx context.Context, runtimeIDs []strin
select {
case <-ctx.Done():
return ctx.Err()
return uptime(), ctx.Err()
case <-runtimeSetCh:
return errRuntimeSetChanged
return uptime(), errRuntimeSetChanged
case err := <-errCh:
return err
return uptime(), err
}
}
@@ -260,12 +280,15 @@ func (d *Daemon) handleWSHeartbeatAck(ctx context.Context, ack *HeartbeatRespons
}
func (d *Daemon) readTaskWakeupMessages(conn *websocket.Conn, taskWakeups chan<- taskWakeup) error {
conn.SetReadLimit(64 * 1024)
d.configureTaskWakeupReadLiveness(conn)
for {
_, raw, err := conn.ReadMessage()
if err != nil {
return err
}
if err := d.extendTaskWakeupReadDeadline(conn); err != nil {
return err
}
var msg protocol.Message
if err := json.Unmarshal(raw, &msg); err != nil {
d.logger.Debug("task wakeup websocket invalid message", "error", err)
@@ -306,6 +329,26 @@ func (d *Daemon) readTaskWakeupMessages(conn *websocket.Conn, taskWakeups chan<-
}
}
func (d *Daemon) configureTaskWakeupReadLiveness(conn *websocket.Conn) {
conn.SetReadLimit(64 * 1024)
if err := d.extendTaskWakeupReadDeadline(conn); err != nil {
d.logger.Debug("task wakeup websocket read deadline failed", "error", err)
}
conn.SetPongHandler(func(string) error {
return d.extendTaskWakeupReadDeadline(conn)
})
conn.SetPingHandler(func(appData string) error {
if err := d.extendTaskWakeupReadDeadline(conn); err != nil {
return err
}
return conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(taskWakeupWriteWait))
})
}
func (d *Daemon) extendTaskWakeupReadDeadline(conn *websocket.Conn) error {
return conn.SetReadDeadline(time.Now().Add(taskWakeupPongWait))
}
func (d *Daemon) handleRuntimeProfilesChanged(payload protocol.RuntimeProfilesChangedPayload) {
if payload.WorkspaceID == "" {
return

View File

@@ -1,9 +1,20 @@
package daemon
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/multica-ai/multica/server/pkg/protocol"
)
func TestTaskWakeupURL(t *testing.T) {
@@ -75,3 +86,345 @@ func TestWSHeartbeatFreshnessSuppressesHTTP(t *testing.T) {
t.Fatalf("expected clearWSHeartbeatAcks to drop all entries")
}
}
func TestReadTaskWakeupMessagesTimesOutWithoutPeerTraffic(t *testing.T) {
overrideTaskWakeupTimings(t, 60*time.Millisecond, 20*time.Millisecond, taskWakeupBackoffResetAfter)
upgrader := websocket.Upgrader{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
time.Sleep(300 * time.Millisecond)
}))
defer srv.Close()
conn, _, err := websocket.DefaultDialer.Dial(taskWakeupTestWSURL(srv.URL), nil)
if err != nil {
t.Fatalf("dial websocket: %v", err)
}
defer conn.Close()
d := New(Config{}, slog.Default())
errCh := make(chan error, 1)
go func() {
errCh <- d.readTaskWakeupMessages(conn, make(chan taskWakeup, 1))
}()
select {
case err := <-errCh:
var netErr net.Error
if !errors.As(err, &netErr) || !netErr.Timeout() {
t.Fatalf("readTaskWakeupMessages error = %v, want timeout", err)
}
case <-time.After(time.Second):
t.Fatal("readTaskWakeupMessages did not time out")
}
}
func TestReadTaskWakeupMessagesExtendsDeadlineOnServerPing(t *testing.T) {
overrideTaskWakeupTimings(t, 120*time.Millisecond, 50*time.Millisecond, taskWakeupBackoffResetAfter)
clientReceived := make(chan struct{})
taskFrame := mustProtocolFrame(t, protocol.Message{
Type: protocol.EventDaemonTaskAvailable,
Payload: marshalRaw(protocol.TaskAvailablePayload{
RuntimeID: "runtime-1",
TaskID: "task-1",
}),
})
upgrader := websocket.Upgrader{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for i := 0; i < 3; i++ {
time.Sleep(50 * time.Millisecond)
conn.SetWriteDeadline(time.Now().Add(50 * time.Millisecond))
if err := conn.WriteMessage(websocket.PingMessage, []byte("keepalive")); err != nil {
return
}
}
if !writeWSMessage(t, conn, websocket.TextMessage, taskFrame) {
return
}
waitForClientWakeup(t, clientReceived)
}))
defer srv.Close()
conn, _, err := websocket.DefaultDialer.Dial(taskWakeupTestWSURL(srv.URL), nil)
if err != nil {
t.Fatalf("dial websocket: %v", err)
}
defer conn.Close()
d := New(Config{}, slog.Default())
taskWakeups := make(chan taskWakeup, 1)
errCh := make(chan error, 1)
go func() {
errCh <- d.readTaskWakeupMessages(conn, taskWakeups)
}()
select {
case wakeup := <-taskWakeups:
if wakeup.runtimeID != "runtime-1" {
t.Fatalf("wakeup runtimeID = %q, want runtime-1", wakeup.runtimeID)
}
close(clientReceived)
case err := <-errCh:
t.Fatalf("readTaskWakeupMessages returned before task frame: %v", err)
case <-time.After(time.Second):
t.Fatal("timed out waiting for task wakeup")
}
}
func TestReadTaskWakeupMessagesExtendsDeadlineOnApplicationMessage(t *testing.T) {
overrideTaskWakeupTimings(t, 120*time.Millisecond, 50*time.Millisecond, taskWakeupBackoffResetAfter)
clientReceived := make(chan struct{})
ackFrame := mustProtocolFrame(t, protocol.Message{
Type: protocol.EventDaemonHeartbeatAck,
Payload: marshalRaw(HeartbeatResponse{
RuntimeID: "runtime-1",
}),
})
taskFrame := mustProtocolFrame(t, protocol.Message{
Type: protocol.EventDaemonTaskAvailable,
Payload: marshalRaw(protocol.TaskAvailablePayload{
RuntimeID: "runtime-1",
TaskID: "task-1",
}),
})
upgrader := websocket.Upgrader{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for i := 0; i < 3; i++ {
time.Sleep(50 * time.Millisecond)
if !writeWSMessage(t, conn, websocket.TextMessage, ackFrame) {
return
}
}
time.Sleep(50 * time.Millisecond)
if !writeWSMessage(t, conn, websocket.TextMessage, taskFrame) {
return
}
waitForClientWakeup(t, clientReceived)
}))
defer srv.Close()
conn, _, err := websocket.DefaultDialer.Dial(taskWakeupTestWSURL(srv.URL), nil)
if err != nil {
t.Fatalf("dial websocket: %v", err)
}
defer conn.Close()
d := New(Config{}, slog.Default())
taskWakeups := make(chan taskWakeup, 1)
errCh := make(chan error, 1)
go func() {
errCh <- d.readTaskWakeupMessages(conn, taskWakeups)
}()
select {
case wakeup := <-taskWakeups:
if wakeup.runtimeID != "runtime-1" {
t.Fatalf("wakeup runtimeID = %q, want runtime-1", wakeup.runtimeID)
}
close(clientReceived)
case err := <-errCh:
t.Fatalf("readTaskWakeupMessages returned before task frame: %v", err)
case <-time.After(time.Second):
t.Fatal("timed out waiting for task wakeup")
}
}
func TestReadTaskWakeupMessagesExtendsDeadlineOnPong(t *testing.T) {
overrideTaskWakeupTimings(t, 120*time.Millisecond, 50*time.Millisecond, taskWakeupBackoffResetAfter)
clientReceived := make(chan struct{})
taskFrame := mustProtocolFrame(t, protocol.Message{
Type: protocol.EventDaemonTaskAvailable,
Payload: marshalRaw(protocol.TaskAvailablePayload{
RuntimeID: "runtime-1",
TaskID: "task-1",
}),
})
upgrader := websocket.Upgrader{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for i := 0; i < 3; i++ {
time.Sleep(50 * time.Millisecond)
if !writeWSMessage(t, conn, websocket.PongMessage, []byte("keepalive")) {
return
}
}
time.Sleep(50 * time.Millisecond)
if !writeWSMessage(t, conn, websocket.TextMessage, taskFrame) {
return
}
waitForClientWakeup(t, clientReceived)
}))
defer srv.Close()
conn, _, err := websocket.DefaultDialer.Dial(taskWakeupTestWSURL(srv.URL), nil)
if err != nil {
t.Fatalf("dial websocket: %v", err)
}
defer conn.Close()
d := New(Config{}, slog.Default())
taskWakeups := make(chan taskWakeup, 1)
errCh := make(chan error, 1)
go func() {
errCh <- d.readTaskWakeupMessages(conn, taskWakeups)
}()
select {
case wakeup := <-taskWakeups:
if wakeup.runtimeID != "runtime-1" {
t.Fatalf("wakeup runtimeID = %q, want runtime-1", wakeup.runtimeID)
}
close(clientReceived)
case err := <-errCh:
t.Fatalf("readTaskWakeupMessages returned before task frame: %v", err)
case <-time.After(time.Second):
t.Fatal("timed out waiting for task wakeup")
}
}
func TestShouldResetTaskWakeupBackoffRequiresStableConnection(t *testing.T) {
old := taskWakeupBackoffResetAfter
taskWakeupBackoffResetAfter = 10 * time.Second
t.Cleanup(func() {
taskWakeupBackoffResetAfter = old
})
if shouldResetTaskWakeupBackoff(0) {
t.Fatal("zero connection uptime reset backoff")
}
if shouldResetTaskWakeupBackoff(9 * time.Second) {
t.Fatal("short connection uptime reset backoff")
}
if !shouldResetTaskWakeupBackoff(10 * time.Second) {
t.Fatal("stable connection uptime did not reset backoff")
}
}
func TestRuntimeHeartbeatClosesIdleConnectionsAfterRepeatedTransientFailures(t *testing.T) {
transport := &closeCountingTransport{}
client := NewClient("http://daemon.test")
client.client = &http.Client{
Timeout: time.Second,
Transport: transport,
}
d := New(Config{HeartbeatInterval: 10 * time.Millisecond}, slog.Default())
d.client = client
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
d.runRuntimeHeartbeat(ctx, "runtime-1")
}()
deadline := time.After(time.Second)
ticker := time.NewTicker(5 * time.Millisecond)
defer ticker.Stop()
for transport.closeCount.Load() == 0 {
select {
case <-ticker.C:
case <-deadline:
cancel()
t.Fatal("CloseIdleConnections was not called")
}
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("runRuntimeHeartbeat did not stop after context cancellation")
}
if got := transport.roundTrips.Load(); got < 2 {
t.Fatalf("RoundTrip count = %d, want at least 2", got)
}
}
type closeCountingTransport struct {
roundTrips atomic.Int32
closeCount atomic.Int32
}
func (t *closeCountingTransport) RoundTrip(*http.Request) (*http.Response, error) {
t.roundTrips.Add(1)
return nil, errors.New("dial failed")
}
func (t *closeCountingTransport) CloseIdleConnections() {
t.closeCount.Add(1)
}
func overrideTaskWakeupTimings(t *testing.T, pongWait, writeWait, backoffResetAfter time.Duration) {
t.Helper()
oldPongWait := taskWakeupPongWait
oldWriteWait := taskWakeupWriteWait
oldBackoffResetAfter := taskWakeupBackoffResetAfter
taskWakeupPongWait = pongWait
taskWakeupWriteWait = writeWait
taskWakeupBackoffResetAfter = backoffResetAfter
t.Cleanup(func() {
taskWakeupPongWait = oldPongWait
taskWakeupWriteWait = oldWriteWait
taskWakeupBackoffResetAfter = oldBackoffResetAfter
})
}
func taskWakeupTestWSURL(httpURL string) string {
return strings.Replace(httpURL, "http", "ws", 1)
}
func mustProtocolFrame(t *testing.T, msg protocol.Message) []byte {
t.Helper()
frame, err := json.Marshal(msg)
if err != nil {
t.Fatalf("marshal websocket frame: %v", err)
}
return frame
}
func writeWSMessage(t *testing.T, conn *websocket.Conn, messageType int, frame []byte) bool {
t.Helper()
conn.SetWriteDeadline(time.Now().Add(50 * time.Millisecond))
if err := conn.WriteMessage(messageType, frame); err != nil {
t.Errorf("write websocket frame: %v", err)
return false
}
return true
}
func waitForClientWakeup(t *testing.T, clientReceived <-chan struct{}) {
t.Helper()
select {
case <-clientReceived:
case <-time.After(time.Second):
t.Errorf("server timed out waiting for client wakeup")
}
}

View File

@@ -46,18 +46,25 @@ type OutboundReplier struct {
binding bindingMinter
decrypt Decrypter
newSender func(creds credentials) replySender
publicURL string
appURL string
bindingPath string
logger *slog.Logger
}
// OutboundReplierConfig configures the replier. Binding + PublicURL are required
// OutboundReplierConfig configures the replier. Binding + AppURL 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
Binding bindingMinter
Decrypt Decrypter
// AppURL is the Multica web app host the user clicks into to redeem the
// binding token (e.g. https://multica.example). It comes from MULTICA_APP_URL
// (falling back to FRONTEND_ORIGIN) and is intentionally separate from
// MULTICA_PUBLIC_URL, which is the backend/API public URL used for webhook and
// daemon-facing endpoints — the bind page (/slack/bind) is served by the web
// app, so the link must point at the app host, not the API host. Mirrors the
// Lark replier's AppURL.
AppURL string
BindingPath string // default "/slack/bind"
Logger *slog.Logger
}
@@ -81,7 +88,7 @@ func NewOutboundReplier(cfg OutboundReplierConfig) *OutboundReplier {
r := &OutboundReplier{
binding: cfg.Binding,
decrypt: cfg.Decrypt,
publicURL: strings.TrimRight(cfg.PublicURL, "/"),
appURL: strings.TrimRight(cfg.AppURL, "/"),
bindingPath: bindingPath,
logger: logger,
}
@@ -133,14 +140,14 @@ func (r *OutboundReplier) sendBindingPrompt(ctx context.Context, inst engine.Res
if r.binding == nil {
return errors.New("binding service not configured")
}
if r.publicURL == "" {
return errors.New("public url not configured")
if r.appURL == "" {
return errors.New("app 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)
bindURL := r.appURL + 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.

View File

@@ -41,9 +41,9 @@ func (f *fakeBindingMinter) Mint(_ context.Context, ws, inst pgtype.UUID, user s
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",
Binding: binding,
Decrypt: nil, // identity: stored bot token is base64 plaintext
AppURL: "https://multica.example",
})
r.newSender = func(credentials) replySender { return sender }
return r