Compare commits

...

14 Commits

Author SHA1 Message Date
J
87af0d8590 fix(slack): publish slack_installation:created on BYO connect; refresh stale comments (MUL-3666)
Niko final review: RegisterSlackBYO wrote the response but never published
EventSlackInstallationCreated, so only the installer's own tab refreshed —
other open clients (Settings, Agent Integrations, other tabs) did not see the
new bot in realtime, inconsistent with the revoke event and Lark. Now publishes
it on success via a small publishSlackInstallationCreated helper, with a unit
test (Bus.Publish is synchronous).

Also refreshed comments that still described the removed hosted-OAuth /
single deployment-level AppConnector model (handler SlackInstall field,
channel.go / inbound.go / outbound.go / byo_install.go). PR title updated
separately to the BYO per-installation Socket Mode model.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-27 00:09:06 +08:00
J
9b536be40f fix(slack): Elon review — team_id routing guard, per-agent reconnect, users:read hint (MUL-3666)
1. Inbound routing keys on api_app_id (the APP, not the Slack workspace), so
   additionally require the event's team_id to match the installation's stored
   team. A distributed BYO app installed into another Slack workspace emits the
   same app id and would otherwise mis-route to this Multica installation.
   Extracted installationServesTeam() + unit test.

2. BYO install is now agent-keyed (UpsertChannelInstallation, conflict on
   workspace_id+agent_id+channel_type): one bot per agent. Disconnect →
   reconnect a NEW app for the SAME agent now UPDATES that agent's row in place
   instead of violating the (workspace, agent, channel) unique. A unique
   violation on the (channel_type, app_id) routing index → ErrTeamOwnedByAnother-
   Workspace (the app is already connected to another agent/workspace). No
   chat-session retire is needed: a row's agent_id never changes.

3. UX: bots.info (the same-app check) needs the users:read scope — the connect
   dialog now lists the required bot scopes including it, and the error text
   says so.

Backend build/vet/gofmt/test + views vitest + typecheck + locale parity green.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 23:36:37 +08:00
J
388be18514 feat(slack): in-product BYO install dialog (paste bot + app tokens) (MUL-3666)
The OAuth begin endpoint was removed server-side, so the "Connect Slack"
button now opens a dialog where the admin pastes the bot token (xoxb-) and
app-level token (xapp-) of the Slack app they created, and submits to the
BYO install endpoint. Includes an optional setup-video link (URL constant,
left empty until the walkthrough is recorded).

- core: drop beginSlackInstall / BeginSlackInstallResponse; add
  registerSlackBYO + RegisterSlackBYORequest.
- views: SlackAgentBindButton opens the BYO dialog; refreshed comments and
  install_supported docs (now means "configured", no OAuth).
- i18n: new slack.byo_* keys + refreshed page_description in en/zh-Hans/ja/ko.
- tests: dialog submit path; views vitest (1479), typecheck, lint, locale
  parity all green.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 19:58:52 +08:00
J
889795125d fix(slack): verify BYO bot + app tokens are from the same app, and the app token is live (MUL-3666)
Niko review: RegisterBYO only parsed the app id from the xapp string and
auth.test'd the bot token, so pasting app A's bot token with app B's app
token would 'connect' but be broken (inbound on B's socket, outbound with
A's identity). Now: resolve the bot's owning app id via bots.info (on the
bot_id from auth.test) and require it to equal the xapp's app id; and live-
validate the app token via apps.connections.open. Reject (no persist) on
mismatch or a dead app token.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 19:47:34 +08:00
J
88c3ab7462 refactor(slack): drop OAuth, unify on BYO per-installation model (MUL-3666)
Per product decision, Slack drops the hosted-app OAuth path entirely and
unifies on bring-your-own-app (BYO): every installation carries its OWN
app-level token and gets its OWN Socket Mode connection, so multiple agents
can each have a distinct bot identity in one Slack workspace.

- Remove OAuth install (Begin/Complete/code-exchange/sealed state/OAuthConfig/
  default scopes), the OAuth callback + begin handlers + routes, and the
  MULTICA_SLACK_CLIENT_ID/SECRET/REDIRECT/APP_TOKEN env wiring.
- Replace the single deployment-level AppConnector with a per-installation
  slackChannel (authenticated with its own xapp- token) registered as a channel
  Factory, so the engine Supervisor drives one Socket Mode connection per
  installation (exactly like Feishu). inbound/outbound/resolvers reused as-is.
- Route inbound by the event's api_app_id (== the installation's real app id),
  not team_id.
- InstallService slims to at-rest encryption + the shared persistInstall +
  list/get/revoke; install is the BYO paste path only (byo_install.go).
- Tests: drop the OAuth tests; slack + handler + engine all green.

Follow-up (frontend): replace the OAuth "Connect Slack" button with the BYO
paste dialog (the begin endpoint it calls is now gone).

Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 19:31:51 +08:00
J
82a5848959 feat(slack): BYO-app install backend — paste xoxb+xapp, per-app install keyed by real app id (MUL-3666)
Adds the bring-your-own-app install path so multiple agents can each have
their own bot identity in the SAME Slack workspace (hosted B2 caps at one
agent/workspace). User pastes their app's bot token (xoxb-) + app-level
token (xapp-); we validate the bot token via auth.test, parse the real
Slack app id from the xapp- token, encrypt both tokens, and persist a
per-app installation keyed by that app id (real 'A…' ids never collide
with hosted 'T…' team ids in the existing unique index — no schema change).

- config.go: add app_token_encrypted (BYO discriminator + per-app socket token)
- install.go: extract shared persistInstall (atomic cross-ws guard + agent-move retire)
- byo_install.go: RegisterBYO + auth.test + app-id parse
- handler + route: POST /api/workspaces/{id}/slack/install/byo (admin-only)
- tests: keying, encryption, invalid tokens, auth.test failure, cross-ws, agent move

Follow-ups (separate commits): per-app Socket Mode connector that consumes
the stored app token; in-product BYO install dialog (video + paste form).

Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 19:15:50 +08:00
J
35efd476f5 fix(slack): surface agent-page Slack entry points when Lark is off (MUL-3666)
The agent-detail Integrations tab and the inspector's Integrations section
only considered Lark, so a Slack-only deployment (Lark disabled) showed neither
the Integrations tab nor a Connect-Slack button — the per-agent entry points
were unreachable.

- agent-overview-pane: gate the Integrations tab on Lark OR Slack configured
  (new slackInstallationsOptions query), not Lark alone.
- agent-detail-inspector: render SlackAgentBindButton alongside LarkAgentBindButton
  in the Integrations section.
- regression test: the Integrations tab appears when only Slack is configured.

Verified: pnpm typecheck (6/6), pnpm --filter @multica/views test (1478+ pass),
pnpm lint (0 errors).

Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 14:05:37 +08:00
J
e15717131a fix(slack): atomic cross-workspace install guard + green up frontend CI (MUL-3666)
Two things: address Elon's review and fix the failing frontend CI job.

Review (atomic cross-workspace guard): the previous guard was a SELECT before
the upsert, which loses the concurrent-OAuth race — two workspaces can both read
no rows, one inserts, the other's ON CONFLICT update then silently re-points the
team. Move the guard into the upsert itself: ON CONFLICT ... DO UPDATE ... WHERE
channel_installation.workspace_id = EXCLUDED.workspace_id, and map the empty
RETURNING (pgx.ErrNoRows) to ErrTeamOwnedByAnotherWorkspace. The pre-SELECT now
only feeds the agent-change cleanup. Also corrected the error copy: a team stays
bound to its first Multica workspace (revoke is soft, keeping the row + unique
index), so migration is an operator action, not "disconnect first".

CI (frontend vitest, @multica/views#test):
- The agent IntegrationsTab now renders the real SlackAgentBindButton, whose
  connected badge calls useQueryClient — absent from integrations-tab.test.tsx's
  react-query mock. Hoisted the owner/admin gate above the per-platform sections
  (one role notice instead of one per platform), made the agents members_note
  generic (en/zh/ja/ko), and updated the test (mock @multica/core/slack, stub
  SlackAgentBindButton, assert both platforms).
- Added slack-tab.test.tsx covering the real SlackAgentBindButton / SlackTab.
- locale parity: added the slack (settings) + slack_bind (common) blocks to ja
  and ko so every EN key has a translated counterpart.

Verified: make sqlc, go build ./..., go vet, gofmt, go test ./internal/integrations/...;
pnpm --filter @multica/views test (1478 pass), pnpm typecheck (6/6), pnpm lint (0 errors).

Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 19:34:40 +08:00
J
3949f17aba fix(slack): make OAuth install transactional — agent-move binding consistency + cross-workspace guard (MUL-3666)
Address Elon's review: the team-keyed upsert kept the same installation row and
only flipped agent_id, but engine session reuse matches purely on
(installation_id, channel_chat_id) and each chat_session is permanently tied to
the agent it was created under — so after moving a Slack team from Agent A to
Agent B, existing DMs/threads kept routing to Agent A; only brand-new
channels/threads reached B. Cross-workspace re-install was worse: the SQL also
moved workspace_id while the application-layer user/chat-session bindings stayed
behind, inheriting the previous workspace's relations.

InstallService.Complete now runs one transaction (lookup → upsert → retire →
installer-bind), all application-layer per the no-FK rule:

- Look up the existing installation by team_id (config->>'app_id').
- Reject a silent cross-workspace ownership change (ErrTeamOwnedByAnotherWorkspace
  → callback redirects with slack_error=team_in_other_workspace). The owning
  workspace must disconnect first.
- On an agent change within the same workspace, retire the installation's
  chat-session bindings (new DeleteChannelChatSessionBindingsByInstallation) so
  the next message creates a fresh session under the new agent. The chat_session
  rows are preserved for history; user bindings stay valid (same users/workspace).
- Installer auto-bind moves into the tx; an already-bound-elsewhere id is a
  benign skip, a real DB error aborts the whole install.

InstallService now takes a TxStarter; the queries seam gains WithTx (dbInstallQueries
adapter) so Complete stays unit-testable with a fake tx.

Verified: make sqlc, go build ./..., go vet, gofmt, go test ./internal/integrations/...
(new tests: agent-move retire, same-agent no-retire, cross-workspace reject,
fresh-install no-retire).

Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 19:04:38 +08:00
J
f15fbeced6 fix(slack): address review must-fixes — connector leak, team-keyed install, /issue copy (MUL-3666)
Three fixes from Niko's review:

1. AppConnector.connectOnce leaked the Socket Mode goroutine/connection on a
   handler error: it ran sm.RunContext on the long-lived ctx and returned the
   error without cancelling it, so a transient DB/router error left the old
   connection alive (consuming events into an unread channel) while Run opened a
   second one. Each connection now runs under its own cancellable context and a
   deferred cancel + join tears it down on every exit path before reconnect.

2. Slack re-install collided with the (channel_type, app_id) unique index:
   connecting the same Slack team to a different agent failed because the upsert
   conflict key was (workspace_id, agent_id, channel_type). Add a team-keyed
   UpsertChannelInstallationByAppID (ON CONFLICT on the (channel_type, app_id)
   index, updating agent_id) and use it for the Slack OAuth install, so
   re-connecting a workspace moves the bot to the chosen agent instead of
   erroring. Feishu's per-agent upsert is unchanged.

3. /issue clarified: it is not a registered Slack slash command (no `commands`
   scope), so Slack never routes one to us. Issue creation runs through the
   message path — `@bot /issue <title>` in a channel or `/issue <title>` in a
   DM — which the engine parser handles. Documented in the connector and the
   user-facing copy (en + zh).

Verified: go build ./..., go vet, gofmt, go test ./internal/integrations/...,
make sqlc, plus pnpm typecheck (6/6) and pnpm lint (0 errors).

Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 17:38:47 +08:00
J
02203fb834 feat(slack): outbound Replier + user-binding redeem flow (MUL-3666)
Fill the stage-3 Replier=nil tail so non-installer Slack users can onboard and
get status feedback — completing B2 end to end.

- slack.OutboundReplier (engine.OutboundReplier): on NeedsBinding it mints a
  single-use binding token and DMs/replies a "link your account" prompt with the
  redeem URL (wrapped as <url|label> so formatMrkdwn doesn't mangle the
  base64url token); on AgentOffline/AgentArchived it posts a status notice; on an
  /issue-created Ingest it confirms the new issue. Plain chat stays silent (the
  agent's own reply lands via EventChatDone). Reuses the bot-token Send path and
  reads the installation row from ResolvedInstallation.Platform — no new transport.
- slack.BindingTokenService: Mint + transactional RedeemAndBind over the generic
  channel_binding_token / channel_user_binding queries (channel_type='slack'),
  mirroring lark.BindingTokenService. 15-min TTL, SHA256-hashed tokens, the
  three typed failure modes (invalid/expired, already-assigned, not-member).
- HTTP: POST /api/slack/binding/redeem (public, session-authed) maps the failures
  to 410/409/403. NewSlackResolverSet now takes the replier (nil disables it).
- Frontend: /slack/bind redeem page (packages/views/slack + apps/web route) +
  api.redeemSlackBindingToken + en/zh slack_bind copy.

Verified: go build ./..., go vet, gofmt, go test ./internal/integrations/...
(new replier_test.go covers all outcome branches + the prompt URL), plus full
pnpm typecheck (6/6) and pnpm lint (0 errors).

Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 16:29:37 +08:00
J
8c2650f8ef feat(slack): in-product OAuth install UI for web + desktop (MUL-3666)
Add the "Connect Slack" self-serve install UI mirroring the Feishu/Lark
integration, completing the in-product install half of B2. Slack's OAuth flow
is a redirect (not a device-code QR poll), so the UI is simpler than Lark's.

- core: SlackInstallation / List / Begin types; api.listSlackInstallations /
  beginSlackInstall / deleteSlackInstallation; slackKeys + slackInstallationsOptions
  query; realtime invalidation on slack_installation:* events.
- views: slack-tab.tsx (SlackTab settings panel + per-agent SlackAgentBindButton
  + connected badge + disconnect confirm). Connect calls beginSlackInstall and
  hands the authorize URL to openExternal (system browser on desktop, new tab on
  web); Slack bounces to the backend callback which lands the install, and the
  realtime event refreshes the list. Wired into the Settings → Integrations tab
  and the agent-detail Integrations tab alongside Lark.
- i18n: en + zh-Hans settings.slack.* strings.

Verified: pnpm typecheck (full monorepo, 6/6) and pnpm lint (@multica/core,
@multica/views — 0 errors) pass.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 16:11:37 +08:00
J
8a0c5f6ff2 feat(slack): OAuth self-serve install backend (MUL-3666)
Add the in-product OAuth install flow that creates Slack installations, the
keystone the B2 connector consumes.

- slack.InstallService: Begin (build authorize URL, seal workspace/agent/
  initiator into the OAuth state), Complete (verify state, exchange code via
  oauth.v2.access, upsert channel_type='slack' install with the bot token
  encrypted at rest, auto-bind the installer's Slack id so their first message
  is not dropped), plus List/Get/Revoke. State is stateless: sealed with the
  deployment secretbox + an embedded expiry, no session store.
- HTTP handlers (handler/slack.go): member-visible list, admin-only begin +
  revoke, and the public OAuth callback (recovers context from the sealed state,
  redirects the browser back to Settings → Integrations with a result flag).
- Routes + wiring: workspace-scoped list/begin/revoke mirror the Lark
  admin/member split; the callback is a public route like GitHub's. Built from
  MULTICA_SLACK_CLIENT_ID/SECRET (+ redirect derived from MULTICA_PUBLIC_URL,
  override MULTICA_SLACK_REDIRECT_URL; scopes via MULTICA_SLACK_SCOPES).
- Realtime: slack_installation:created / :revoked events.

Verified: go build ./..., go vet, gofmt, and go test ./internal/integrations/slack/...
all pass (new install_test.go covers state sign/verify/expiry/tamper, authorize
URL, code exchange + encrypted upsert + installer bind, and oauth error paths).

Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 15:59:14 +08:00
J
a3f770250a feat(slack): single app-level Socket Mode connection routed by team_id (MUL-3666)
Reshape the Slack adapter from the stage-3 per-installation Socket Mode model
into the multi-tenant B2 connection model: ONE deployment-level Socket Mode
connection (app-level xapp- token, env MULTICA_SLACK_APP_TOKEN) receives the
Events API stream for every installed workspace and routes each inbound event
to its channel_installation by team_id — the existing
GetChannelInstallationByAppID routing, unchanged.

- AppConnector: the single shared connection (slack/app_connector.go). No leader
  election — per the design "one (or a few)" connections are fine: each replica
  opens one, Slack delivers each event to one of them, and the existing
  (installation, message_id) two-phase dedup guarantees exactly-once processing.
  Resolves the per-team bot user id (via the same app_id query) to detect/strip
  @-mentions, since one connection serves many workspaces.
- Inbound translation (Events API -> channel.InboundMessage) extracted to
  slack/inbound.go as free functions parameterized by the per-team bot identity.
- channel.go trimmed to the outbound Send-only sender; per-installation config
  (config.go) no longer carries an app-level token — installs hold only the
  per-workspace bot token (xoxb-) for outbound, since xapp- can't be OAuth'd.
- engine.Supervisor now skips channel types with no registered Factory, so Slack
  installs (driven by the app-level connector, not per-installation channels) no
  longer churn the lease/Build loop.
- Wiring: router.go builds the connector when MULTICA_SLACK_APP_TOKEN is set;
  main.go runs it alongside the Supervisor. Feishu untouched; channel_* schema
  unchanged.

Verified: go build ./..., go vet ./..., gofmt, and
go test ./internal/integrations/... all pass.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 15:25:16 +08:00
57 changed files with 4144 additions and 736 deletions

View File

@@ -0,0 +1,23 @@
"use client";
import { Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { SlackBindPage } from "@multica/views/slack";
// /slack/bind?token=<raw> is the bot's "link your account" destination. Suspense
// wraps useSearchParams per Next.js 15's CSR-bailout rule; the loading text
// never paints in practice because the redemption page itself renders the
// "redeeming…" state immediately.
function SlackBindPageContent() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
return <SlackBindPage token={token} />;
}
export default function Page() {
return (
<Suspense fallback={null}>
<SlackBindPageContent />
</Suspense>
);
}

View File

@@ -111,6 +111,10 @@ import type {
BeginLarkInstallResponse,
LarkInstallStatusResponse,
RedeemLarkBindingTokenResponse,
SlackInstallation,
ListSlackInstallationsResponse,
RegisterSlackBYORequest,
RedeemSlackBindingTokenResponse,
Squad,
SquadMember,
SquadMemberStatusListResponse,
@@ -2240,4 +2244,37 @@ export class ApiClient {
body: JSON.stringify({ token }),
});
}
// Slack integration (MUL-3666)
async listSlackInstallations(workspaceId: string): Promise<ListSlackInstallationsResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/slack/installations`);
}
// registerSlackBYO performs a bring-your-own-app install: the admin pastes the
// bot token (xoxb-) + app-level token (xapp-) of the Slack app they created,
// and the backend validates + persists it, returning the new installation.
async registerSlackBYO(
workspaceId: string,
agentId: string,
body: RegisterSlackBYORequest,
): Promise<SlackInstallation> {
const search = new URLSearchParams({ agent_id: agentId });
return this.fetch(`/api/workspaces/${workspaceId}/slack/install/byo?${search.toString()}`, {
method: "POST",
body: JSON.stringify(body),
});
}
async deleteSlackInstallation(workspaceId: string, installationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/slack/installations/${installationId}`, {
method: "DELETE",
});
}
async redeemSlackBindingToken(token: string): Promise<RedeemSlackBindingTokenResponse> {
return this.fetch(`/api/slack/binding/redeem`, {
method: "POST",
body: JSON.stringify({ token }),
});
}
}

View File

@@ -85,6 +85,8 @@
"./github/queries": "./github/queries.ts",
"./lark": "./lark/index.ts",
"./lark/queries": "./lark/queries.ts",
"./slack": "./slack/index.ts",
"./slack/queries": "./slack/queries.ts",
"./feedback": "./feedback/index.ts",
"./feedback/mutations": "./feedback/mutations.ts",
"./realtime": "./realtime/index.ts",

View File

@@ -23,6 +23,7 @@ import {
} from "../agents/queries";
import { githubKeys } from "../github/queries";
import { larkKeys } from "../lark/queries";
import { slackKeys } from "../slack/queries";
import {
onIssueCreated,
onIssueUpdated,
@@ -472,6 +473,10 @@ export function useRealtimeSync(
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: larkKeys.installations(wsId) });
},
slack_installation: () => {
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: slackKeys.installations(wsId) });
},
pull_request: () => {
// PR list is keyed by issue id, not workspace, so we invalidate all
// PR queries — the open issue detail page will refetch its own list.

View File

@@ -0,0 +1 @@
export { slackKeys, slackInstallationsOptions } from "./queries";

View File

@@ -0,0 +1,18 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
/** Query key namespace for everything Slack-installation-related. Realtime
* sync invalidates `installations(wsId)` on `slack_installation:*` events so
* the Settings panel updates without a manual refetch (e.g. after the OAuth
* callback lands the install in another tab / the system browser). */
export const slackKeys = {
all: (wsId: string) => ["slack", wsId] as const,
installations: (wsId: string) => [...slackKeys.all(wsId), "installations"] as const,
};
export const slackInstallationsOptions = (wsId: string) =>
queryOptions({
queryKey: slackKeys.installations(wsId),
queryFn: () => api.listSlackInstallations(wsId),
enabled: !!wsId,
});

View File

@@ -119,6 +119,12 @@ export type {
LarkInstallStatusResponse,
RedeemLarkBindingTokenResponse,
} from "./lark";
export type {
SlackInstallation,
ListSlackInstallationsResponse,
RegisterSlackBYORequest,
RedeemSlackBindingTokenResponse,
} from "./slack";
export type {
Autopilot,
AutopilotStatus,

View File

@@ -0,0 +1,50 @@
/** A Slack bot installation bound to a single Multica agent (MUL-3666).
*
* Wire shape mirrors `SlackInstallationResponse` in
* `server/internal/handler/slack.go`. New fields the backend adds in the
* future MUST default to optional so older desktop builds keep parsing the
* response — see CLAUDE.md → API Compatibility. */
export interface SlackInstallation {
id: string;
workspace_id: string;
agent_id: string;
/** The Slack workspace (team) id this bot is installed in. */
team_id: string;
/** The installed bot's Slack user id. */
bot_user_id: string;
installer_user_id: string;
status: "active" | "revoked" | string;
installed_at: string;
created_at: string;
updated_at: string;
}
export interface ListSlackInstallationsResponse {
installations: SlackInstallation[];
/** Whether the deployment has the at-rest secret key configured. When false
* the connect entry points are hidden and the panel renders an "ask the
* operator to enable Slack" state. */
configured: boolean;
/** Whether the install path is available (true whenever Slack is configured,
* i.e. the at-rest key is set — a bring-your-own-app install needs no hosted
* OAuth credentials). Kept as a separate flag for forward/backward compat;
* optional so an older desktop build that predates it treats it as off. */
install_supported?: boolean;
}
/** Request body for a bring-your-own-app (BYO) install: the two tokens the
* admin pastes from the Slack app they created. The backend validates that both
* belong to the same Slack app (and that the app token is live) before
* persisting, then returns the created SlackInstallation. */
export interface RegisterSlackBYORequest {
bot_token: string;
app_token: string;
}
/** Post-redemption echo: the Slack user id the token carried is now bound to
* the logged-in Multica user in this workspace/installation. */
export interface RedeemSlackBindingTokenResponse {
workspace_id: string;
installation_id: string;
slack_user_id: string;
}

View File

@@ -47,6 +47,7 @@ import { SkillAttach } from "./inspector/skill-attach";
import { ThinkingPropRow } from "./inspector/thinking-prop-row";
import { VisibilityPicker } from "./inspector/visibility-picker";
import { LarkAgentBindButton } from "../../settings/components/lark-tab";
import { SlackAgentBindButton } from "../../settings/components/slack-tab";
interface InspectorProps {
agent: Agent;
@@ -215,13 +216,12 @@ export function AgentDetailInspector({
</div>
{/* Integrations — surfaces external-channel bind entry points
(Lark Bot today; Slack / Discord in the future). The bind
button self-hides when the server-side device-flow install
capability gate is closed, so this section may render empty
on deployments without a configured Lark app — that's
intentional and matches the "don't surface a flow that will
fail" guarantee. We only mount it for editors: viewers
shouldn't see a CTA they can't action. */}
(Lark + Slack today; Discord in the future). Each bind button
self-hides when its server-side install capability gate is
closed, so this section may render empty on deployments without
a configured channel — that's intentional and matches the
"don't surface a flow that will fail" guarantee. We only mount
it for editors: viewers shouldn't see a CTA they can't action. */}
{canEdit && (
<div className="flex flex-col px-5 py-4">
<div className="mb-2 flex items-center gap-2">
@@ -235,6 +235,11 @@ export function AgentDetailInspector({
agentName={agent.name}
onShowConnectedDetails={onShowIntegrations}
/>
<SlackAgentBindButton
agentId={agent.id}
agentName={agent.name}
onShowConnectedDetails={onShowIntegrations}
/>
</div>
</div>
)}

View File

@@ -45,6 +45,9 @@ vi.mock("../../common/actor-issues-panel", () => ({
const larkListingRef = vi.hoisted(() => ({
current: { installations: [] as unknown[], configured: false },
}));
const slackListingRef = vi.hoisted(() => ({
current: { installations: [] as unknown[], configured: false },
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
@@ -54,6 +57,12 @@ vi.mock("@multica/core/lark", () => ({
queryFn: () => Promise.resolve(larkListingRef.current),
}),
}));
vi.mock("@multica/core/slack", () => ({
slackInstallationsOptions: () => ({
queryKey: ["slack", "installations"],
queryFn: () => Promise.resolve(slackListingRef.current),
}),
}));
import { AgentOverviewPane } from "./agent-overview-pane";
@@ -119,6 +128,7 @@ function renderPane(runtimes: AgentRuntime[]) {
beforeEach(() => {
larkListingRef.current = { installations: [], configured: false };
slackListingRef.current = { installations: [], configured: false };
});
describe("AgentOverviewPane MCP tab visibility", () => {
@@ -163,9 +173,19 @@ describe("AgentOverviewPane Integrations tab visibility", () => {
).toBeInTheDocument();
});
it("hides the Integrations tab when Lark is not configured", () => {
// Default ref is configured:false; the tab must not appear on
// deployments without the integration, which are the common case.
it("shows the Integrations tab when only Slack is configured (Lark off)", async () => {
// Regression: the tab gate must consider Slack too, not just Lark —
// a Slack-only deployment was hiding the tab (and its bind entry).
slackListingRef.current = { installations: [], configured: true };
renderPane([makeRuntime("claude")]);
expect(
await screen.findByRole("button", { name: /^Integrations$/i }),
).toBeInTheDocument();
});
it("hides the Integrations tab when neither Lark nor Slack is configured", () => {
// Default refs are configured:false; the tab must not appear on
// deployments without either integration, the common case.
renderPane([makeRuntime("claude")]);
expect(
screen.queryByRole("button", { name: /^Integrations$/i }),

View File

@@ -17,6 +17,7 @@ import type { Agent, AgentRuntime } from "@multica/core/types";
import { providerSupportsMcpConfig } from "@multica/core/agents";
import { useWorkspaceId } from "@multica/core/hooks";
import { larkInstallationsOptions } from "@multica/core/lark";
import { slackInstallationsOptions } from "@multica/core/slack";
import {
AlertDialog,
AlertDialogAction,
@@ -141,16 +142,24 @@ export function AgentOverviewPane({
enabled: !!wsId,
});
const larkConfigured = larkListing?.configured === true;
const { data: slackListing } = useQuery({
...slackInstallationsOptions(wsId),
enabled: !!wsId,
});
const slackConfigured = slackListing?.configured === true;
// The Integrations tab appears once EITHER channel is wired on the
// deployment, so a Slack-only deployment (no Lark) still surfaces it.
const integrationsConfigured = larkConfigured || slackConfigured;
// The MCP tab is only shown when the agent's runtime backend actually
// consumes mcp_config — see providerSupportsMcpConfig. We default to
// showing it when the runtime row hasn't loaded yet so a slow fetch
// can't transiently flicker the tab off and then on.
//
// The Integrations tab only appears once the deployment has Lark wired
// The Integrations tab appears once the deployment has Lark OR Slack wired
// (configured). Unlike MCP we default to HIDING while the listing loads:
// deployments without Lark are the common case, so flashing the tab on
// then off would be the worse flicker.
// deployments without either channel are the common case, so flashing the
// tab on then off would be the worse flicker.
//
// The Runtime Config tab is openclaw-only today (gateway mode lives there,
// issue #3260). Other providers' runtime_config is freeform JSONB that no
@@ -161,11 +170,11 @@ export function AgentOverviewPane({
const showRuntimeConfig = runtime ? runtime.provider === "openclaw" : false;
return detailTabs.filter((tab) => {
if (tab.id === "mcp_config") return showMcp;
if (tab.id === "integrations") return larkConfigured;
if (tab.id === "integrations") return integrationsConfigured;
if (tab.id === "runtime_config") return showRuntimeConfig;
return true;
});
}, [runtime, larkConfigured]);
}, [runtime, integrationsConfigured]);
// If the active tab disappears (e.g. user just switched the agent's
// runtime to one that doesn't read mcp_config), fall back to Activity

View File

@@ -35,6 +35,7 @@ vi.mock("@tanstack/react-query", () => ({
if (key.includes("installations")) return { data: installationsRef.current };
return { data: undefined };
},
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
queryOptions: <T,>(opts: T) => opts,
}));
@@ -53,6 +54,13 @@ vi.mock("@multica/core/lark", () => ({
}),
}));
vi.mock("@multica/core/slack", () => ({
slackInstallationsOptions: () => ({
queryKey: ["slack", "installations"],
queryFn: vi.fn(),
}),
}));
vi.mock("@multica/core/auth", () => {
const useAuthStore = Object.assign(
(sel?: (s: { user: { id: string } }) => unknown) =>
@@ -68,6 +76,14 @@ vi.mock("../../../settings/components/lark-tab", () => ({
),
}));
// SlackAgentBindButton is the shared bind entry covered in slack-tab.test.tsx;
// here it is a marker so the tests assert branch selection, not the OAuth flow.
vi.mock("../../../settings/components/slack-tab", () => ({
SlackAgentBindButton: ({ agentId }: { agentId: string }) => (
<div data-testid="slack-bind-button" data-agent-id={agentId} />
),
}));
import { IntegrationsTab } from "./integrations-tab";
const TEST_RESOURCES = {
@@ -118,11 +134,12 @@ function resetFixtures() {
describe("IntegrationsTab", () => {
beforeEach(resetFixtures);
it("renders the shared bind entry for an owner when Lark is configured and supported", () => {
it("renders the shared bind entry for both platforms for an owner when configured and supported", () => {
renderTab(<IntegrationsTab agent={agent} />);
expect(screen.getByText("Lark")).toBeTruthy();
const button = screen.getByTestId("lark-bind-button");
expect(button.getAttribute("data-agent-id")).toBe("agent-1");
expect(screen.getByText("Slack")).toBeTruthy();
expect(screen.getByTestId("lark-bind-button").getAttribute("data-agent-id")).toBe("agent-1");
expect(screen.getByTestId("slack-bind-button").getAttribute("data-agent-id")).toBe("agent-1");
});
it("shows the coming-soon notice when the install transport is not wired", () => {
@@ -147,13 +164,16 @@ describe("IntegrationsTab", () => {
expect(screen.queryByTestId("lark-bind-button")).toBeNull();
});
it("points members at Settings instead of a dead button when they can't manage", () => {
it("points members at Settings with one role notice (not per-platform) when they can't manage", () => {
membersRef.current = [{ user_id: "user-1", role: "member" }];
renderTab(<IntegrationsTab agent={agent} />);
// The role gate is hoisted above the per-platform sections, so the notice
// appears exactly once and neither bind entry renders.
expect(
screen.getByText(/Only workspace owners and admins can bind a Lark Bot/i),
screen.getByText(/Only workspace owners and admins can connect an agent/i),
).toBeTruthy();
expect(screen.queryByTestId("lark-bind-button")).toBeNull();
expect(screen.queryByTestId("slack-bind-button")).toBeNull();
});
it("renders the bind entry (not coming-soon) when installs are unavailable but the agent is already bound", () => {

View File

@@ -1,13 +1,15 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Webhook } from "lucide-react";
import { MessagesSquare, Webhook } from "lucide-react";
import type { Agent } from "@multica/core/types";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { larkInstallationsOptions } from "@multica/core/lark";
import { slackInstallationsOptions } from "@multica/core/slack";
import { memberListOptions } from "@multica/core/workspace/queries";
import { LarkAgentBindButton } from "../../../settings/components/lark-tab";
import { SlackAgentBindButton } from "../../../settings/components/slack-tab";
import { useT } from "../../../i18n";
/**
@@ -37,6 +39,10 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
...larkInstallationsOptions(wsId),
enabled: !!wsId,
});
const { data: slackListing } = useQuery({
...slackInstallationsOptions(wsId),
enabled: !!wsId,
});
const { data: members = [] } = useQuery({
...memberListOptions(wsId),
enabled: !!wsId,
@@ -52,6 +58,30 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
(inst) => inst.agent_id === agent.id && inst.status === "active",
) ?? false;
const slackConfigured = slackListing?.configured === true;
const slackInstallSupported = slackListing?.install_supported === true;
const slackHasActiveInstall =
slackListing?.installations.some(
(inst) => inst.agent_id === agent.id && inst.status === "active",
) ?? false;
// Install / manage is gated on workspace owner/admin for every platform, so
// the role notice is hoisted above the per-platform sections — one note
// instead of repeating it under each integration. Members can still view
// connected bots in the (member-visible) Settings → Integrations listing.
if (!canManage) {
return (
<div className="space-y-6">
<p className="text-xs text-muted-foreground">
{t(($) => $.tab_body.integrations.intro)}
</p>
<p className="text-xs text-muted-foreground">
{t(($) => $.tab_body.integrations.members_note)}
</p>
</div>
);
}
return (
<div className="space-y-6">
<p className="text-xs text-muted-foreground">
@@ -78,14 +108,6 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
<p className="text-xs text-muted-foreground">
{ts(($) => $.lark.not_enabled_title)}
</p>
) : !canManage ? (
// The backend gates install / manage on workspace owner/admin.
// Members can still view connected bots in the (member-visible)
// Settings listing, so point them there rather than show a dead
// button.
<p className="text-xs text-muted-foreground">
{t(($) => $.tab_body.integrations.members_note)}
</p>
) : !installSupported && !hasActiveInstall ? (
// Key is set but the device-flow transport isn't wired in this
// build — a fresh scan would fail at the post-poll bot-info step,
@@ -107,6 +129,39 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
)}
</div>
</section>
<section className="rounded-lg border">
<div className="flex items-start gap-3 p-4">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border bg-muted/40 text-muted-foreground">
<MessagesSquare className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1 space-y-1">
<h3 className="text-sm font-medium">{ts(($) => $.slack.section_title)}</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{ts(($) => $.slack.page_description)}
</p>
</div>
</div>
<div className="border-t px-4 py-3">
{!slackConfigured ? (
<p className="text-xs text-muted-foreground">
{ts(($) => $.slack.not_enabled_title)}
</p>
) : !slackInstallSupported && !slackHasActiveInstall ? (
// Secret key is set but the OAuth client credentials aren't, so a
// fresh "Connect Slack" would 503. Surface the "coming soon" notice
// instead of a broken CTA; an already-bound agent still renders.
<div className="space-y-1">
<p className="text-xs font-medium">{ts(($) => $.slack.preview_title)}</p>
<p className="text-xs text-muted-foreground">
{ts(($) => $.slack.preview_description)}
</p>
</div>
) : (
<SlackAgentBindButton agentId={agent.id} agentName={agent.name} />
)}
</div>
</section>
</div>
);
}

View File

@@ -370,7 +370,7 @@
},
"integrations": {
"intro": "Connect this agent to external chat platforms so people can work with it where they already are.",
"members_note": "Only workspace owners and admins can bind a Lark Bot to an agent. You can view connected bots in Settings → Integrations."
"members_note": "Only workspace owners and admins can connect an agent to an external chat platform. You can view connected bots in Settings → Integrations."
},
"activity": {
"section_now": "Now",

View File

@@ -24,5 +24,20 @@
"error_already_bound": "This Lark account is already bound to a different Multica user. Account transfers must go through an explicit unbind first.",
"error_not_member": "You're signed in to a Multica account that isn't a member of this workspace.",
"error_unknown": "Something went wrong. Try again, and if the problem persists, contact the workspace admin."
},
"slack_bind": {
"page_title": "Link your Slack account",
"redeeming": "Linking your account…",
"needs_auth_description": "Sign in to Multica to complete the link. The token in the link binds your Slack account to this Multica user, so you must be logged in first.",
"sign_in": "Sign in",
"done_title": "You're linked.",
"done_description": "Your next message to the bot in Slack will go straight to the agent. You can close this tab.",
"error_title": "Couldn't complete the link",
"error_admin_hint": "If this keeps happening, message the bot again in Slack to get a fresh link.",
"error_missing_token": "The link is missing its token. Message the bot again in Slack to get a new one.",
"error_expired": "This link is invalid or expired (links are valid for 15 minutes). Message the bot again to get a new one.",
"error_already_bound": "This Slack account is already linked to a different Multica user. Account transfers must go through an explicit unbind first.",
"error_not_member": "You're signed in to a Multica account that isn't a member of this workspace.",
"error_unknown": "Something went wrong. Try again, and if the problem persists, contact the workspace admin."
}
}

View File

@@ -300,6 +300,52 @@
"install_error_forbidden": "You no longer have permission to install Lark Bots in this workspace. Ask a workspace admin to continue.",
"install_error_generic": "Install failed. Try again."
},
"slack": {
"section_title": "Slack",
"page_description": "Connect each Multica Agent to its own Slack bot. A workspace admin creates a Slack app and pastes its bot + app-level tokens; members can then DM the bot or @mention it in a channel, and start a message with /issue (e.g. \"@bot /issue Fix the login bug\") to spin up a new Multica issue.",
"not_enabled_title": "Slack integration not enabled",
"not_enabled_description_prefix": "Set",
"not_enabled_description_suffix": "on the server to enable Slack bot installations.",
"not_enabled_self_host_hint": "Self-hosters: see the project README for details.",
"preview_title": "Slack install coming soon",
"preview_description": "The at-rest key is set, but the hosted Slack app's OAuth credentials are not configured in this deployment. The Connect button will appear here once they are set.",
"connected_bots": "Connected bots",
"loading": "Loading…",
"empty_title": "No bots connected yet",
"empty_description_prefix": "Open an Agent in this workspace and click",
"empty_description_cta": "Connect Slack",
"empty_description_suffix": "to install a bot for it.",
"revoked_badge": "revoked",
"installed_at_label": "Installed {{when}}",
"disconnect": "Disconnect",
"disconnecting": "Disconnecting…",
"disconnect_confirm_title": "Disconnect this Slack bot?",
"disconnect_confirm_description": "The bot will stop receiving Slack messages for this workspace. The installation row is kept for audit; you can re-install later from the same Agent.",
"disconnect_confirm_cancel": "Cancel",
"toast_disconnected": "Disconnected Slack bot",
"toast_disconnect_failed": "Disconnect failed",
"bind_button": "Connect Slack",
"bind_button_title": "Connect {{agent}} to a Slack bot",
"connecting": "Opening Slack…",
"connect_failed_toast": "Could not start the Slack install",
"agent_bot_connected_label": "Connected to Slack",
"agent_bot_disconnect_tooltip": "Unbind this Slack bot from the Agent. The bot will stop receiving Slack messages.",
"agent_bot_manage_link": "Open in Slack",
"agent_bot_manage_tooltip": "Open this bot's Slack workspace.",
"byo_dialog_title": "Connect a Slack bot",
"byo_dialog_intro": "Create your own Slack app, install it to your workspace, then paste its two tokens below. You can connect a different app for each agent in the same workspace.",
"byo_video_cta": "Watch the setup walkthrough",
"byo_bot_token_label": "Bot token (xoxb-)",
"byo_bot_token_hint": "Slack app → OAuth & Permissions → Bot User OAuth Token.",
"byo_app_token_label": "App-level token (xapp-)",
"byo_app_token_hint": "Slack app → Basic Information → App-Level Tokens (scope connections:write).",
"byo_scopes_hint": "Required bot scopes: app_mentions:read, channels:history, chat:write, groups:history, im:history, mpim:history, users:read.",
"byo_submit": "Connect",
"byo_submitting": "Connecting…",
"byo_cancel": "Cancel",
"byo_success_toast": "Slack bot connected",
"byo_failed_toast": "Could not connect the Slack bot"
},
"repositories": {
"section_title": "Repositories",
"description": "Git repositories associated with this workspace. Agents use these to clone and work on code.",

View File

@@ -354,7 +354,7 @@
},
"integrations": {
"intro": "このエージェントを外部のチャットプラットフォームに接続し、普段使っているツールから直接やり取りできるようにします。",
"members_note": "エージェントに Lark Bot を紐付けできるのはワークスペースのオーナーと管理者のみです。接続済みの Bot は「設定 → 連携」で確認できます。"
"members_note": "エージェントを外部チャットプラットフォームに接続できるのはワークスペースのオーナーと管理者のみです。接続済みの Bot は「設定 → 連携」で確認できます。"
},
"activity": {
"section_now": "現在",

View File

@@ -24,5 +24,20 @@
"error_already_bound": "この Lark アカウントはすでに別の Multica ユーザーに連携されています。アカウントを移すには、まず明示的に連携を解除する必要があります。",
"error_not_member": "現在ログイン中の Multica アカウントは、このワークスペースのメンバーではありません。",
"error_unknown": "問題が発生しました。もう一度試し、それでも解決しない場合はワークスペース管理者にお問い合わせください。"
},
"slack_bind": {
"page_title": "Slack アカウントを連携",
"redeeming": "アカウントを連携しています…",
"needs_auth_description": "連携を完了するには Multica にサインインしてください。リンク内のトークンが、あなたの Slack アカウントをこの Multica ユーザーに紐付けるため、先にログインが必要です。",
"sign_in": "サインイン",
"done_title": "連携が完了しました。",
"done_description": "次に Slack でボットへ送るメッセージは、そのままエージェントに届きます。このタブは閉じて構いません。",
"error_title": "連携を完了できませんでした",
"error_admin_hint": "繰り返し発生する場合は、Slack でボットにもう一度メッセージを送って新しいリンクを取得してください。",
"error_missing_token": "リンクにトークンがありません。Slack でボットにもう一度メッセージを送って新しいリンクを取得してください。",
"error_expired": "このリンクは無効か期限切れです(有効期限は 15 分)。ボットにもう一度メッセージを送って新しいリンクを取得してください。",
"error_already_bound": "この Slack アカウントは別の Multica ユーザーに連携済みです。移行するにはまず明示的に解除する必要があります。",
"error_not_member": "サインインしている Multica アカウントはこのワークスペースのメンバーではありません。",
"error_unknown": "問題が発生しました。もう一度試し、それでも解決しない場合はワークスペース管理者にお問い合わせください。"
}
}

View File

@@ -300,6 +300,52 @@
"install_error_forbidden": "このワークスペースに Lark ボットを設置する権限がなくなりました。ワークスペース管理者にお問い合わせください。",
"install_error_generic": "設置に失敗しました。もう一度お試しください。"
},
"slack": {
"section_title": "Slack",
"page_description": "各 Multica エージェントを専用の Slack ボットに接続します。ワークスペース管理者が Slack アプリを作成し、その bot トークンと app レベルトークンを貼り付けます。メンバーはボットに DM したりチャンネルで @メンションしたりでき、/issue で始まるメッセージ(例:「@bot /issue ログインの不具合を修正」)で新しい Multica issue を作成できます。",
"not_enabled_title": "Slack 連携が有効になっていません",
"not_enabled_description_prefix": "サーバーで",
"not_enabled_description_suffix": "を設定すると Slack ボットのインストールが有効になります。",
"not_enabled_self_host_hint": "セルフホストの場合: 詳細はプロジェクトの README を参照してください。",
"preview_title": "Slack インストールは近日対応",
"preview_description": "保存用キーは設定済みですが、このデプロイではホスト型 Slack アプリの OAuth 認証情報が未設定です。設定すると接続ボタンがここに表示されます。",
"connected_bots": "接続済みのボット",
"loading": "読み込み中…",
"empty_title": "まだボットが接続されていません",
"empty_description_prefix": "このワークスペースのエージェントを開き、",
"empty_description_cta": "Slack を接続",
"empty_description_suffix": "をクリックしてボットをインストールします。",
"revoked_badge": "取り消し済み",
"installed_at_label": "{{when}} にインストール",
"disconnect": "切断",
"disconnecting": "切断中…",
"disconnect_confirm_title": "この Slack ボットを切断しますか?",
"disconnect_confirm_description": "このボットはこのワークスペースの Slack メッセージを受信しなくなります。インストール記録は監査のため保持され、同じエージェントから再インストールできます。",
"disconnect_confirm_cancel": "キャンセル",
"toast_disconnected": "Slack ボットを切断しました",
"toast_disconnect_failed": "切断に失敗しました",
"bind_button": "Slack を接続",
"bind_button_title": "{{agent}} を Slack ボットに接続",
"connecting": "Slack を開いています…",
"connect_failed_toast": "Slack のインストールを開始できませんでした",
"agent_bot_connected_label": "Slack に接続済み",
"agent_bot_disconnect_tooltip": "この Slack ボットをエージェントから解除します。ボットは Slack メッセージを受信しなくなります。",
"agent_bot_manage_link": "Slack で開く",
"agent_bot_manage_tooltip": "このボットの Slack ワークスペースを開きます。",
"byo_dialog_title": "Slack ボットを接続",
"byo_dialog_intro": "自分の Slack アプリを作成してワークスペースにインストールし、その 2 つのトークンを下に貼り付けてください。同じワークスペース内でエージェントごとに別のアプリを接続できます。",
"byo_video_cta": "セットアップ手順の動画を見る",
"byo_bot_token_label": "Bot トークンxoxb-",
"byo_bot_token_hint": "Slack アプリ → OAuth & Permissions → Bot User OAuth Token。",
"byo_app_token_label": "App レベルトークンxapp-",
"byo_app_token_hint": "Slack アプリ → Basic Information → App-Level Tokensスコープ connections:write。",
"byo_scopes_hint": "必要な Bot スコープapp_mentions:read、channels:history、chat:write、groups:history、im:history、mpim:history、users:read。",
"byo_submit": "接続",
"byo_submitting": "接続中…",
"byo_cancel": "キャンセル",
"byo_success_toast": "Slack ボットを接続しました",
"byo_failed_toast": "Slack ボットを接続できませんでした"
},
"repositories": {
"section_title": "リポジトリ",
"description": "このワークスペースに関連付けられた Git リポジトリです。エージェントはこれらをクローンしてコードを作業します。",

View File

@@ -370,7 +370,7 @@
},
"integrations": {
"intro": "이 에이전트를 외부 채팅 플랫폼에 연결해 팀원이 평소 사용하는 도구에서 바로 함께 작업할 수 있도록 합니다.",
"members_note": "에이전트에 Lark 봇을 연결할 수 있는 사람은 워크스페이스 소유자와 관리자뿐입니다. 연결된 봇은 설정 → 연동에서 확인할 수 있습니다."
"members_note": "에이전트를 외부 채팅 플랫폼에 연결할 수 있는 사람은 워크스페이스 소유자와 관리자뿐입니다. 연결된 봇은 설정 → 연동에서 확인할 수 있습니다."
},
"activity": {
"section_now": "현재",

View File

@@ -24,5 +24,20 @@
"error_already_bound": "이 Lark 계정은 이미 다른 Multica 사용자에 연결되어 있습니다. 계정 이전은 먼저 명시적으로 연결을 해제해야 합니다.",
"error_not_member": "현재 로그인한 Multica 계정이 이 워크스페이스의 멤버가 아닙니다.",
"error_unknown": "문제가 발생했어요. 다시 시도해 보고, 계속되면 워크스페이스 관리자에게 문의하세요."
},
"slack_bind": {
"page_title": "Slack 계정 연결",
"redeeming": "계정을 연결하는 중…",
"needs_auth_description": "연결을 완료하려면 Multica에 로그인하세요. 링크의 토큰이 Slack 계정을 이 Multica 사용자와 연결하므로 먼저 로그인해야 해요.",
"sign_in": "로그인",
"done_title": "연결되었어요.",
"done_description": "이제 Slack에서 봇에게 보내는 다음 메시지는 바로 에이전트로 전달돼요. 이 탭은 닫아도 됩니다.",
"error_title": "연결을 완료하지 못했어요",
"error_admin_hint": "계속 발생하면 Slack에서 봇에게 다시 메시지를 보내 새 링크를 받으세요.",
"error_missing_token": "링크에 토큰이 없어요. Slack에서 봇에게 다시 메시지를 보내 새 링크를 받으세요.",
"error_expired": "이 링크는 유효하지 않거나 만료됐어요(유효 기간 15분). 봇에게 다시 메시지를 보내 새 링크를 받으세요.",
"error_already_bound": "이 Slack 계정은 이미 다른 Multica 사용자에 연결되어 있어요. 이전하려면 먼저 명시적으로 연결을 해제해야 합니다.",
"error_not_member": "로그인한 Multica 계정이 이 워크스페이스의 멤버가 아니에요.",
"error_unknown": "문제가 발생했어요. 다시 시도해 보고, 계속되면 워크스페이스 관리자에게 문의하세요."
}
}

View File

@@ -376,5 +376,51 @@
"install_error_session_lost": "설치 세션이 만료되었거나 유실되었어요. 다시 스캔해 처음부터 진행하세요.",
"install_error_forbidden": "이 워크스페이스에 Lark 봇을 설치할 권한이 더 이상 없어요. 워크스페이스 관리자에게 문의하세요.",
"install_error_generic": "설치에 실패했어요. 다시 시도하세요."
},
"slack": {
"section_title": "Slack",
"page_description": "각 Multica 에이전트를 전용 Slack 봇에 연결합니다. 워크스페이스 관리자가 Slack 앱을 만들고 봇 토큰과 app 레벨 토큰을 붙여넣으면, 멤버는 봇에게 DM하거나 채널에서 @멘션할 수 있습니다. /issue로 시작하는 메시지(예: \"@bot /issue 로그인 버그 수정\")로 새 Multica 이슈를 만들 수 있어요.",
"not_enabled_title": "Slack 연동이 활성화되지 않았어요",
"not_enabled_description_prefix": "서버에서",
"not_enabled_description_suffix": "를 설정하면 Slack 봇 설치가 활성화됩니다.",
"not_enabled_self_host_hint": "셀프 호스팅: 자세한 내용은 프로젝트 README를 참고하세요.",
"preview_title": "Slack 설치 곧 지원 예정",
"preview_description": "저장용 키는 설정되어 있지만, 이 배포에는 호스팅 Slack 앱의 OAuth 자격 증명이 설정되지 않았어요. 설정하면 연결 버튼이 여기에 표시됩니다.",
"connected_bots": "연결된 봇",
"loading": "불러오는 중…",
"empty_title": "아직 연결된 봇이 없어요",
"empty_description_prefix": "이 워크스페이스의 에이전트를 열고",
"empty_description_cta": "Slack 연결",
"empty_description_suffix": "을(를) 클릭해 봇을 설치하세요.",
"revoked_badge": "해제됨",
"installed_at_label": "{{when}}에 설치됨",
"disconnect": "연결 해제",
"disconnecting": "연결 해제 중…",
"disconnect_confirm_title": "이 Slack 봇을 연결 해제할까요?",
"disconnect_confirm_description": "봇이 이 워크스페이스의 Slack 메시지를 더 이상 받지 않습니다. 설치 기록은 감사를 위해 보관되며, 같은 에이전트에서 다시 설치할 수 있어요.",
"disconnect_confirm_cancel": "취소",
"toast_disconnected": "Slack 봇을 연결 해제했어요",
"toast_disconnect_failed": "연결 해제에 실패했어요",
"bind_button": "Slack 연결",
"bind_button_title": "{{agent}}을(를) Slack 봇에 연결",
"connecting": "Slack 여는 중…",
"connect_failed_toast": "Slack 설치를 시작할 수 없었어요",
"agent_bot_connected_label": "Slack에 연결됨",
"agent_bot_disconnect_tooltip": "이 Slack 봇을 에이전트에서 연결 해제합니다. 봇이 Slack 메시지를 받지 않게 됩니다.",
"agent_bot_manage_link": "Slack에서 열기",
"agent_bot_manage_tooltip": "이 봇의 Slack 워크스페이스를 엽니다.",
"byo_dialog_title": "Slack 봇 연결",
"byo_dialog_intro": "직접 만든 Slack 앱을 워크스페이스에 설치한 뒤, 두 개의 토큰을 아래에 붙여넣으세요. 같은 워크스페이스에서 에이전트마다 다른 앱을 연결할 수 있습니다.",
"byo_video_cta": "설정 안내 영상 보기",
"byo_bot_token_label": "Bot 토큰(xoxb-)",
"byo_bot_token_hint": "Slack 앱 → OAuth & Permissions → Bot User OAuth Token.",
"byo_app_token_label": "App 레벨 토큰(xapp-)",
"byo_app_token_hint": "Slack 앱 → Basic Information → App-Level Tokens(스코프 connections:write).",
"byo_scopes_hint": "필요한 Bot 스코프: app_mentions:read, channels:history, chat:write, groups:history, im:history, mpim:history, users:read.",
"byo_submit": "연결",
"byo_submitting": "연결 중…",
"byo_cancel": "취소",
"byo_success_toast": "Slack 봇을 연결했어요",
"byo_failed_toast": "Slack 봇을 연결하지 못했어요"
}
}

View File

@@ -362,7 +362,7 @@
},
"integrations": {
"intro": "把这个智能体连接到外部聊天平台,让大家在自己熟悉的工具里直接与它协作。",
"members_note": "只有工作区的所有者和管理员才能智能体绑定飞书 Bot。你可以在「设置 → 集成」中查看已连接的 Bot。"
"members_note": "只有工作区的所有者和管理员才能智能体连接到外部聊天平台。你可以在「设置 → 集成」中查看已连接的 Bot。"
},
"activity": {
"section_now": "当前",

View File

@@ -24,5 +24,20 @@
"error_already_bound": "该飞书账号已绑定到其他 Multica 用户。账户转移需要先显式解绑。",
"error_not_member": "你登录的 Multica 账号不是当前工作区成员。",
"error_unknown": "出现未知错误。请稍后再试,如反复失败请联系工作区管理员。"
},
"slack_bind": {
"page_title": "关联 Slack 账号",
"redeeming": "正在关联账号…",
"needs_auth_description": "需要登录 Multica 才能完成关联。链接中的 token 会将你的 Slack 账号绑定到当前登录的 Multica 用户。",
"sign_in": "登录",
"done_title": "已关联。",
"done_description": "下次在 Slack 向机器人发送消息时,会直接送达绑定的智能体。可以关闭此页面。",
"error_title": "关联未完成",
"error_admin_hint": "如果反复失败,请在 Slack 重新向机器人发消息以获取新的链接。",
"error_missing_token": "链接缺少 token。请在 Slack 重新向机器人发消息以获取新的链接。",
"error_expired": "链接无效或已过期(有效期 15 分钟)。请重新向机器人发消息获取新的链接。",
"error_already_bound": "该 Slack 账号已关联到其他 Multica 用户。账户转移需要先显式解绑。",
"error_not_member": "你登录的 Multica 账号不是当前工作区成员。",
"error_unknown": "出现未知错误。请稍后再试,如反复失败请联系工作区管理员。"
}
}

View File

@@ -300,6 +300,52 @@
"install_error_forbidden": "你已没有在此工作区安装飞书 Bot 的权限,请联系工作区管理员。",
"install_error_generic": "安装失败,请重试。"
},
"slack": {
"section_title": "Slack",
"page_description": "把每个 Multica Agent 连接到它自己的 Slack 机器人。工作区管理员创建一个 Slack app 并粘贴它的 bot 和 app-level token成员之后即可私聊机器人或在频道中 @ 它,并以 /issue 开头发消息(例如「@机器人 /issue 修复登录问题」)来创建新的 Multica issue。",
"not_enabled_title": "Slack 集成未启用",
"not_enabled_description_prefix": "在服务器上设置",
"not_enabled_description_suffix": "以启用 Slack 机器人安装。",
"not_enabled_self_host_hint": "自部署用户:详见项目 README。",
"preview_title": "Slack 安装即将上线",
"preview_description": "静态加密密钥已设置,但本部署尚未配置托管 Slack 应用的 OAuth 凭据。配置后,连接按钮会出现在这里。",
"connected_bots": "已连接的机器人",
"loading": "加载中…",
"empty_title": "尚未连接机器人",
"empty_description_prefix": "在本工作区打开一个 Agent点击",
"empty_description_cta": "连接 Slack",
"empty_description_suffix": "为其安装机器人。",
"revoked_badge": "已撤销",
"installed_at_label": "安装于 {{when}}",
"disconnect": "断开连接",
"disconnecting": "正在断开…",
"disconnect_confirm_title": "断开此 Slack 机器人?",
"disconnect_confirm_description": "该机器人将停止接收此工作区的 Slack 消息。安装记录会保留以备审计;你之后可以从同一个 Agent 重新安装。",
"disconnect_confirm_cancel": "取消",
"toast_disconnected": "已断开 Slack 机器人",
"toast_disconnect_failed": "断开失败",
"bind_button": "连接 Slack",
"bind_button_title": "把 {{agent}} 连接到 Slack 机器人",
"connecting": "正在打开 Slack…",
"connect_failed_toast": "无法开始 Slack 安装",
"agent_bot_connected_label": "已连接到 Slack",
"agent_bot_disconnect_tooltip": "将此 Slack 机器人从 Agent 解绑。机器人将停止接收 Slack 消息。",
"agent_bot_manage_link": "在 Slack 中打开",
"agent_bot_manage_tooltip": "打开此机器人所在的 Slack 工作区。",
"byo_dialog_title": "连接 Slack 机器人",
"byo_dialog_intro": "创建你自己的 Slack app安装到你的工作区然后把它的两个 token 粘贴到下面。同一个工作区里,每个 agent 可以连接不同的 app。",
"byo_video_cta": "观看配置教程视频",
"byo_bot_token_label": "Bot 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": "取消",
"byo_success_toast": "Slack 机器人已连接",
"byo_failed_toast": "无法连接 Slack 机器人"
},
"repositories": {
"section_title": "代码仓库",
"description": "与该工作区关联的 Git 仓库。智能体会从这里 clone 代码并完成工作。",

View File

@@ -45,6 +45,7 @@
"./settings": "./settings/index.ts",
"./settings/lark-tab": "./settings/components/lark-tab.tsx",
"./lark": "./lark/index.ts",
"./slack": "./slack/index.ts",
"./invite": "./invite/index.ts",
"./invitations": "./invitations/index.ts",
"./onboarding": "./onboarding/index.ts",

View File

@@ -1,14 +1,15 @@
"use client";
import { LarkTab } from "./lark-tab";
import { SlackTab } from "./slack-tab";
import { useT } from "../../i18n";
// Integrations is the umbrella tab for third-party platform connections.
// GitHub has its own top-level tab (see github-tab.tsx); everything else
// — currently just Lark, with Slack/Linear etc. to follow — lives in
// here under its own section heading so additional integrations slot in
// without changing the IA. IntegrationsTab is just the host; each
// integration owns its own description and install flow.
// — currently Lark and Slack, with Linear etc. to follow — lives in here
// under its own section heading so additional integrations slot in without
// changing the IA. IntegrationsTab is just the host; each integration owns
// its own description and install flow.
export function IntegrationsTab() {
const { t } = useT("settings");
return (
@@ -17,6 +18,10 @@ export function IntegrationsTab() {
<h2 className="text-sm font-semibold">{t(($) => $.lark.section_title)}</h2>
<LarkTab />
</section>
<section className="space-y-4">
<h2 className="text-sm font-semibold">{t(($) => $.slack.section_title)}</h2>
<SlackTab />
</section>
</div>
);
}

View File

@@ -0,0 +1,181 @@
// @vitest-environment jsdom
import { type ReactNode } from "react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
import enSettings from "../../locales/en/settings.json";
type MemberRole = "owner" | "admin" | "member" | "guest";
const membersRef = vi.hoisted(() => ({
current: [{ user_id: "user-1", role: "owner" as MemberRole }],
}));
const installationsRef = vi.hoisted(() => ({
current: {
installations: [] as unknown[],
configured: true,
install_supported: true,
},
}));
const mockRegisterBYO = vi.hoisted(() => vi.fn());
const mockDeleteInstallation = vi.hoisted(() => vi.fn());
const mockOpenExternal = vi.hoisted(() => vi.fn());
const mockInvalidate = vi.hoisted(() => vi.fn());
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: { queryKey: unknown[]; enabled?: boolean }) => {
if (opts.enabled === false) return { data: undefined, isLoading: false };
const key = JSON.stringify(opts.queryKey);
if (key.includes("members")) return { data: membersRef.current, isLoading: false };
if (key.includes("installations")) return { data: installationsRef.current, isLoading: false };
return { data: undefined, isLoading: false };
},
useQueryClient: () => ({ invalidateQueries: mockInvalidate }),
queryOptions: <T,>(opts: T) => opts,
}));
vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "workspace-1" }));
vi.mock("@multica/core/workspace/queries", () => ({
memberListOptions: () => ({ queryKey: ["members"], queryFn: vi.fn() }),
}));
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => ({
getAgentName: (agentId: string) => `Agent ${agentId}`,
getMemberName: () => "Unknown",
getSquadName: () => "Unknown Squad",
getActorName: () => "Unknown",
getActorInitials: () => "??",
getActorAvatarUrl: () => null,
}),
}));
vi.mock("../../common/actor-avatar", () => ({
ActorAvatar: ({ actorId }: { actorId: string }) => (
<span data-testid="actor-avatar" data-actor-id={actorId} />
),
}));
vi.mock("@multica/core/slack", () => ({
slackInstallationsOptions: () => ({
queryKey: ["slack", "installations"],
queryFn: vi.fn(),
}),
slackKeys: { installations: (wsId: string) => ["slack", "installations", wsId] },
}));
vi.mock("@multica/core/api", () => ({
api: {
registerSlackBYO: mockRegisterBYO,
deleteSlackInstallation: mockDeleteInstallation,
},
}));
vi.mock("@multica/core/auth", () => {
const useAuthStore = Object.assign(
(sel?: (s: { user: { id: string } }) => unknown) =>
sel ? sel({ user: { id: "user-1" } }) : { user: { id: "user-1" } },
{ getState: () => ({ user: { id: "user-1" } }) },
);
return { useAuthStore };
});
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn(), message: vi.fn() },
}));
vi.mock("../../platform", () => ({ openExternal: mockOpenExternal }));
import { SlackAgentBindButton, SlackTab } from "./slack-tab";
const TEST_RESOURCES = { en: { common: enCommon, settings: enSettings } };
function renderUI(children: ReactNode) {
return render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
{children}
</I18nProvider>,
);
}
function resetFixtures() {
vi.clearAllMocks();
membersRef.current = [{ user_id: "user-1", role: "owner" }];
installationsRef.current = { installations: [], configured: true, install_supported: true };
}
describe("SlackAgentBindButton", () => {
beforeEach(resetFixtures);
it("opens the BYO dialog and submits the pasted bot + app tokens", async () => {
mockRegisterBYO.mockResolvedValue({ id: "i1", agent_id: "agent-1", status: "active" });
renderUI(<SlackAgentBindButton agentId="agent-1" agentName="Bot" />);
await userEvent.click(screen.getByTestId("slack-agent-connect"));
const botInput = await screen.findByTestId("slack-byo-bot-token");
await userEvent.type(botInput, "xoxb-bot");
await userEvent.type(screen.getByTestId("slack-byo-app-token"), "xapp-1-A0X-1-secret");
await userEvent.click(screen.getByTestId("slack-byo-submit"));
await waitFor(() =>
expect(mockRegisterBYO).toHaveBeenCalledWith("workspace-1", "agent-1", {
bot_token: "xoxb-bot",
app_token: "xapp-1-A0X-1-secret",
}),
);
// No OAuth redirect anymore — install is a direct API call.
expect(mockOpenExternal).not.toHaveBeenCalled();
});
it("shows the connected badge (not the CTA) when the agent already has an active install", () => {
installationsRef.current = {
installations: [{ id: "i1", agent_id: "agent-1", status: "active", team_id: "T1" }],
configured: true,
install_supported: true,
};
renderUI(<SlackAgentBindButton agentId="agent-1" />);
expect(screen.getByTestId("slack-agent-bot-connected")).toBeTruthy();
expect(screen.getByTestId("slack-agent-bot-disconnect")).toBeTruthy();
expect(screen.queryByTestId("slack-agent-connect")).toBeNull();
});
it("renders nothing for a non-manager", () => {
membersRef.current = [{ user_id: "user-1", role: "member" }];
const { container } = renderUI(<SlackAgentBindButton agentId="agent-1" />);
expect(container).toBeEmptyDOMElement();
});
it("renders nothing when install is unavailable and the agent is unbound", () => {
installationsRef.current = { installations: [], configured: true, install_supported: false };
const { container } = renderUI(<SlackAgentBindButton agentId="agent-1" />);
expect(container).toBeEmptyDOMElement();
});
});
describe("SlackTab", () => {
beforeEach(resetFixtures);
it("surfaces the not-enabled notice when the deployment has no Slack key", () => {
installationsRef.current = { installations: [], configured: false, install_supported: false };
renderUI(<SlackTab />);
expect(screen.getByText(/Slack integration not enabled/i)).toBeTruthy();
});
it("shows the empty state when configured but nothing is connected", () => {
renderUI(<SlackTab />);
expect(screen.getByText(/No bots connected yet/i)).toBeTruthy();
});
it("lists a connected installation with its agent name and a disconnect control", () => {
installationsRef.current = {
installations: [{ id: "i1", agent_id: "agent-7", status: "active", team_id: "T1" }],
configured: true,
install_supported: true,
};
renderUI(<SlackTab />);
expect(screen.getByText("Agent agent-7")).toBeTruthy();
expect(screen.getByText(/Disconnect/i)).toBeTruthy();
});
});

View File

@@ -0,0 +1,591 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ChevronRight, ExternalLink, MessagesSquare, Trash2 } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions } from "@multica/core/workspace/queries";
import { useActorName } from "@multica/core/workspace/hooks";
import { slackInstallationsOptions, slackKeys } from "@multica/core/slack";
import { api } from "@multica/core/api";
import type { SlackInstallation } from "@multica/core/types";
import { ActorAvatar } from "../../common/actor-avatar";
import { openExternal } from "../../platform";
import { useT } from "../../i18n";
// SlackTab is the workspace settings panel for Slack bot installations.
// Listing is member-visible; the disconnect action is admin-only (the backend
// enforces it; the UI hides the button for non-admins to match).
//
// Adding a new installation flows through the Agent detail page: the install
// path is per-agent (each Multica agent gets exactly one bot — the
// (workspace_id, agent_id, channel_type) UNIQUE in channel_installation), so
// asking the user to pick an agent here would re-create that page's picker.
export function SlackTab() {
const { t } = useT("settings");
const wsId = useWorkspaceId();
const qc = useQueryClient();
const user = useAuthStore((s) => s.user);
const { data: members = [] } = useQuery(memberListOptions(wsId));
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
const canManage =
currentMember?.role === "owner" || currentMember?.role === "admin";
const { data, isLoading } = useQuery({
...slackInstallationsOptions(wsId),
enabled: !!wsId,
});
const installations = data?.installations ?? [];
const configured = data?.configured === true;
// install_supported tracks whether the OAuth client credentials are wired on
// the server. When false, "Connect Slack" would 503, so we hide the connect
// entry points and surface a "coming soon" notice. Already-installed bots
// still appear below and remain manageable.
const installSupported = data?.install_supported === true;
const [disconnectTarget, setDisconnectTarget] = useState<string | null>(null);
const [disconnecting, setDisconnecting] = useState(false);
async function handleDisconnect() {
if (!disconnectTarget || disconnecting) return;
setDisconnecting(true);
try {
await api.deleteSlackInstallation(wsId, disconnectTarget);
await qc.invalidateQueries({ queryKey: slackKeys.installations(wsId) });
toast.success(t(($) => $.slack.toast_disconnected));
setDisconnectTarget(null);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t(($) => $.slack.toast_disconnect_failed),
);
} finally {
setDisconnecting(false);
}
}
return (
<div className="space-y-8">
<section className="space-y-1">
<p className="text-sm text-muted-foreground">
{t(($) => $.slack.page_description)}
</p>
</section>
{!configured ? (
<Card>
<CardContent className="space-y-2">
<p className="text-sm font-medium">{t(($) => $.slack.not_enabled_title)}</p>
<p className="text-xs text-muted-foreground">
{t(($) => $.slack.not_enabled_description_prefix)}{" "}
<code className="rounded bg-muted px-1 py-0.5 text-[10px]">
MULTICA_SLACK_SECRET_KEY
</code>{" "}
{t(($) => $.slack.not_enabled_description_suffix)}{" "}
{t(($) => $.slack.not_enabled_self_host_hint)}
</p>
</CardContent>
</Card>
) : !installSupported && installations.length === 0 ? (
<Card>
<CardContent className="space-y-2">
<p className="text-sm font-medium">{t(($) => $.slack.preview_title)}</p>
<p className="text-xs text-muted-foreground">
{t(($) => $.slack.preview_description)}
</p>
</CardContent>
</Card>
) : (
<section className="space-y-3">
<h2 className="text-sm font-semibold">{t(($) => $.slack.connected_bots)}</h2>
{isLoading ? (
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">{t(($) => $.slack.loading)}</p>
</CardContent>
</Card>
) : installations.length === 0 ? (
<Card>
<CardContent className="space-y-2">
<p className="text-sm font-medium">{t(($) => $.slack.empty_title)}</p>
<p className="text-xs text-muted-foreground">
{t(($) => $.slack.empty_description_prefix)}{" "}
<strong>{t(($) => $.slack.empty_description_cta)}</strong>{" "}
{t(($) => $.slack.empty_description_suffix)}
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="divide-y">
{installations.map((inst) => (
<InstallationRow
key={inst.id}
installation={inst}
canManage={canManage}
onDisconnect={() => setDisconnectTarget(inst.id)}
/>
))}
</CardContent>
</Card>
)}
</section>
)}
<AlertDialog
open={!!disconnectTarget}
onOpenChange={(v) => {
if (!v && !disconnecting) setDisconnectTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t(($) => $.slack.disconnect_confirm_title)}
</AlertDialogTitle>
<AlertDialogDescription>
{t(($) => $.slack.disconnect_confirm_description)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={disconnecting}>
{t(($) => $.slack.disconnect_confirm_cancel)}
</AlertDialogCancel>
<AlertDialogAction onClick={handleDisconnect} disabled={disconnecting}>
{disconnecting
? t(($) => $.slack.disconnecting)
: t(($) => $.slack.disconnect)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function InstallationRow({
installation,
canManage,
onDisconnect,
}: {
installation: SlackInstallation;
canManage: boolean;
onDisconnect: () => void;
}) {
const { t } = useT("settings");
const { getAgentName } = useActorName();
const isActive = installation.status === "active";
const agentName = getAgentName(installation.agent_id);
return (
<div className="flex items-start justify-between gap-4 py-3 first:pt-0 last:pb-0">
<div className="flex items-start gap-3">
<ActorAvatar
actorType="agent"
actorId={installation.agent_id}
size={32}
enableHoverCard
profileLink
/>
<div className="space-y-1">
<p className="text-sm font-medium">
{agentName}
{!isActive && (
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{t(($) => $.slack.revoked_badge)}
</span>
)}
</p>
<p className="text-[10px] text-muted-foreground">
{t(($) => $.slack.installed_at_label, {
when: new Date(installation.installed_at).toLocaleString(),
})}
</p>
</div>
</div>
{canManage && isActive && (
<Button variant="outline" size="sm" onClick={onDisconnect}>
<Trash2 className="h-3 w-3" />
{t(($) => $.slack.disconnect)}
</Button>
)}
</div>
);
}
// SLACK_BYO_VIDEO_URL is the optional setup-tutorial video linked from the
// connect dialog. Leave "" to hide the link; set it once the walkthrough that
// shows how to create the Slack app + copy its two tokens is recorded.
const SLACK_BYO_VIDEO_URL = "";
// SlackAgentBindButton is the per-agent CTA exposed from the agent detail page.
// Slack uses the bring-your-own-app model: the button opens a dialog where the
// admin pastes the bot token (xoxb-) + app-level token (xapp-) of the Slack app
// they created (the backend validates both belong to the same app). Visibility:
// 1. Non-owner/admin viewers see nothing (the backend gates install/revoke).
// 2. If this agent already has an active installation, show the connected
// badge (already-installed bots stay manageable).
// 3. Otherwise the Connect CTA shows whenever install is available.
export function SlackAgentBindButton({
agentId,
agentName,
className,
onShowConnectedDetails,
}: {
agentId: string;
agentName?: string;
className?: string;
/**
* When set, the connected state renders as a compact read-only status row
* that invokes this callback on click instead of the full badge with inline
* actions — the agent inspector passes a "jump to the Integrations tab"
* handler so management actions live in one place.
*/
onShowConnectedDetails?: () => void;
}) {
const { t } = useT("settings");
const wsId = useWorkspaceId();
const qc = useQueryClient();
const user = useAuthStore((s) => s.user);
const [dialogOpen, setDialogOpen] = useState(false);
const [botToken, setBotToken] = useState("");
const [appToken, setAppToken] = useState("");
const [submitting, setSubmitting] = useState(false);
const { data: listing } = useQuery({
...slackInstallationsOptions(wsId),
enabled: !!wsId,
});
const installSupported = listing?.install_supported === true;
const { data: members = [] } = useQuery({
...memberListOptions(wsId),
enabled: !!wsId,
});
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
const canManage =
currentMember?.role === "owner" || currentMember?.role === "admin";
if (!canManage) return null;
const existing = listing?.installations.find(
(inst) => inst.agent_id === agentId && inst.status === "active",
);
if (existing) {
return onShowConnectedDetails ? (
<SlackAgentBotStatusRow
onClick={onShowConnectedDetails}
className={className}
/>
) : (
<SlackAgentBotConnectedBadge installation={existing} className={className} />
);
}
if (!installSupported) return null;
function closeDialog() {
if (submitting) return;
setDialogOpen(false);
setBotToken("");
setAppToken("");
}
async function handleSubmit() {
const bot_token = botToken.trim();
const app_token = appToken.trim();
if (submitting || !agentId || !bot_token || !app_token) return;
setSubmitting(true);
try {
await api.registerSlackBYO(wsId, agentId, { bot_token, app_token });
// The slack_installation realtime event also refreshes this list, but
// invalidate explicitly so the connected badge appears immediately.
await qc.invalidateQueries({ queryKey: slackKeys.installations(wsId) });
toast.success(t(($) => $.slack.byo_success_toast));
setDialogOpen(false);
setBotToken("");
setAppToken("");
} catch (e) {
toast.error(
e instanceof Error ? e.message : t(($) => $.slack.byo_failed_toast),
);
} finally {
setSubmitting(false);
}
}
const canSubmit =
botToken.trim() !== "" && appToken.trim() !== "" && !submitting;
return (
<div
className={cn("flex flex-wrap items-center gap-2", className)}
data-testid="slack-agent-bind-buttons"
>
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(true)}
disabled={!agentId}
title={
agentName
? t(($) => $.slack.bind_button_title, { agent: agentName })
: undefined
}
data-testid="slack-agent-connect"
>
<MessagesSquare className="h-3 w-3" />
{t(($) => $.slack.bind_button)}
</Button>
<Dialog
open={dialogOpen}
onOpenChange={(v) => (v ? setDialogOpen(true) : closeDialog())}
>
<DialogContent className="sm:max-w-lg" data-testid="slack-byo-dialog">
<DialogHeader>
<DialogTitle>{t(($) => $.slack.byo_dialog_title)}</DialogTitle>
<DialogDescription>
{t(($) => $.slack.byo_dialog_intro)}
</DialogDescription>
</DialogHeader>
{SLACK_BYO_VIDEO_URL ? (
<button
type="button"
onClick={() => openExternal(SLACK_BYO_VIDEO_URL)}
className="inline-flex w-fit items-center gap-1.5 text-xs font-medium text-primary underline-offset-2 hover:underline"
>
<ExternalLink className="h-3.5 w-3.5" />
{t(($) => $.slack.byo_video_cta)}
</button>
) : null}
<p className="rounded-md bg-muted px-3 py-2 text-[11px] text-muted-foreground">
{t(($) => $.slack.byo_scopes_hint)}
</p>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="slack-byo-bot-token">
{t(($) => $.slack.byo_bot_token_label)}
</Label>
<Input
id="slack-byo-bot-token"
data-testid="slack-byo-bot-token"
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
placeholder="xoxb-…"
autoComplete="off"
spellCheck={false}
disabled={submitting}
/>
<p className="text-[11px] text-muted-foreground">
{t(($) => $.slack.byo_bot_token_hint)}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="slack-byo-app-token">
{t(($) => $.slack.byo_app_token_label)}
</Label>
<Input
id="slack-byo-app-token"
data-testid="slack-byo-app-token"
value={appToken}
onChange={(e) => setAppToken(e.target.value)}
placeholder="xapp-…"
autoComplete="off"
spellCheck={false}
disabled={submitting}
/>
<p className="text-[11px] text-muted-foreground">
{t(($) => $.slack.byo_app_token_hint)}
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={closeDialog}
disabled={submitting}
>
{t(($) => $.slack.byo_cancel)}
</Button>
<Button
size="sm"
onClick={handleSubmit}
disabled={!canSubmit}
data-testid="slack-byo-submit"
>
{submitting
? t(($) => $.slack.byo_submitting)
: t(($) => $.slack.byo_submit)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// SlackAgentBotStatusRow is the compact, read-only connected affordance the
// agent inspector renders instead of the full badge; it deep-links into the
// Integrations tab where Manage / Disconnect live.
function SlackAgentBotStatusRow({
onClick,
className,
}: {
onClick: () => void;
className?: string;
}) {
const { t } = useT("settings");
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
className,
)}
data-testid="slack-agent-bot-status"
>
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
<span className="truncate">{t(($) => $.slack.agent_bot_connected_label)}</span>
<ChevronRight className="ml-auto h-3.5 w-3.5 shrink-0" />
</button>
);
}
// SlackAgentBotConnectedBadge is the full "already connected" affordance the
// Integrations tab renders in place of the Connect button. Two rows: status +
// soft-destructive Disconnect, then a secondary "Open in Slack" link to the
// installed workspace. Only owners/admins ever reach this component.
function SlackAgentBotConnectedBadge({
installation,
className,
}: {
installation: SlackInstallation;
className?: string;
}) {
const { t } = useT("settings");
const wsId = useWorkspaceId();
const qc = useQueryClient();
const [confirmOpen, setConfirmOpen] = useState(false);
const [disconnecting, setDisconnecting] = useState(false);
async function handleDisconnect() {
if (disconnecting) return;
setDisconnecting(true);
try {
await api.deleteSlackInstallation(wsId, installation.id);
await qc.invalidateQueries({ queryKey: slackKeys.installations(wsId) });
toast.success(t(($) => $.slack.toast_disconnected));
setConfirmOpen(false);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t(($) => $.slack.toast_disconnect_failed),
);
} finally {
setDisconnecting(false);
}
}
return (
<div
className={cn("space-y-2", className)}
data-testid="slack-agent-bot-connected"
>
<div className="flex items-center justify-between gap-3">
<span className="inline-flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
<span className="truncate">{t(($) => $.slack.agent_bot_connected_label)}</span>
</span>
<Button
variant="destructive"
size="sm"
onClick={() => setConfirmOpen(true)}
disabled={disconnecting}
title={t(($) => $.slack.agent_bot_disconnect_tooltip)}
aria-label={t(($) => $.slack.disconnect)}
data-testid="slack-agent-bot-disconnect"
>
<Trash2 className="h-3 w-3" />
{disconnecting
? t(($) => $.slack.disconnecting)
: t(($) => $.slack.disconnect)}
</Button>
</div>
{installation.team_id && (
<button
type="button"
onClick={() =>
openExternal(`https://app.slack.com/client/${installation.team_id}`)
}
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
title={t(($) => $.slack.agent_bot_manage_tooltip)}
>
<ExternalLink className="h-3 w-3" />
{t(($) => $.slack.agent_bot_manage_link)}
</button>
)}
<AlertDialog
open={confirmOpen}
onOpenChange={(v) => {
if (!v && !disconnecting) setConfirmOpen(false);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t(($) => $.slack.disconnect_confirm_title)}
</AlertDialogTitle>
<AlertDialogDescription>
{t(($) => $.slack.disconnect_confirm_description)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={disconnecting}>
{t(($) => $.slack.disconnect_confirm_cancel)}
</AlertDialogCancel>
<AlertDialogAction onClick={handleDisconnect} disabled={disconnecting}>
{disconnecting
? t(($) => $.slack.disconnecting)
: t(($) => $.slack.disconnect)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Button } from "@multica/ui/components/ui/button";
import { api } from "@multica/core/api";
import { useAuthStore } from "@multica/core/auth";
import { useNavigation } from "../navigation";
import { useT } from "../i18n";
type RedeemState =
| { kind: "idle" }
| { kind: "redeeming" }
| { kind: "done"; workspaceId: string; installationId: string }
| { kind: "needs-auth" }
| { kind: "error"; reason: string };
// SlackBindPage is the destination the bot's "link your account" prompt points
// at (MUL-3666). The user lands here logged out OR logged in; we require auth
// before redeeming because the redeemer's Multica identity is taken from the
// session (the token alone never proves who is binding — see
// slack.BindingTokenService.RedeemAndBind).
//
// The token comes in via `?token=<raw>`. We POST it to /api/slack/binding/redeem;
// the backend returns 410 (invalid/expired), 409 (already bound to another
// user), 403 (not a workspace member) or 200 with the bound installation. Each
// maps to distinct copy via slack_bind in common.json.
export function SlackBindPage({ token }: { token: string | null }) {
const { t } = useT("common");
const user = useAuthStore((s) => s.user);
const isAuthLoading = useAuthStore((s) => s.isLoading);
const navigation = useNavigation();
const [state, setState] = useState<RedeemState>({ kind: "idle" });
useEffect(() => {
if (!token) {
setState({ kind: "error", reason: "missing_token" });
return;
}
if (isAuthLoading) return;
if (!user) {
setState({ kind: "needs-auth" });
return;
}
if (state.kind !== "idle" && state.kind !== "needs-auth") return;
setState({ kind: "redeeming" });
(async () => {
try {
const resp = await api.redeemSlackBindingToken(token);
setState({
kind: "done",
workspaceId: resp.workspace_id,
installationId: resp.installation_id,
});
} catch (e) {
setState({
kind: "error",
reason: redemptionFailureReason(e),
});
}
})();
}, [token, user, isAuthLoading, state.kind]);
return (
<div className="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center p-6">
<Card className="w-full">
<CardContent className="space-y-4">
<h1 className="text-lg font-semibold">{t(($) => $.slack_bind.page_title)}</h1>
{state.kind === "idle" || state.kind === "redeeming" ? (
<p className="text-sm text-muted-foreground">{t(($) => $.slack_bind.redeeming)}</p>
) : state.kind === "needs-auth" ? (
<>
<p className="text-sm text-muted-foreground">
{t(($) => $.slack_bind.needs_auth_description)}
</p>
<Button
size="sm"
onClick={() =>
navigation.push(
`/login?next=${encodeURIComponent(
`/slack/bind?token=${encodeURIComponent(token ?? "")}`,
)}`,
)
}
>
{t(($) => $.slack_bind.sign_in)}
</Button>
</>
) : state.kind === "done" ? (
<>
<p className="text-sm font-medium">{t(($) => $.slack_bind.done_title)}</p>
<p className="text-xs text-muted-foreground">
{t(($) => $.slack_bind.done_description)}
</p>
</>
) : (
<>
<p className="text-sm font-medium">{t(($) => $.slack_bind.error_title)}</p>
<p className="text-xs text-muted-foreground">
{(() => {
switch (state.reason) {
case "missing_token":
return t(($) => $.slack_bind.error_missing_token);
case "expired":
return t(($) => $.slack_bind.error_expired);
case "already_bound":
return t(($) => $.slack_bind.error_already_bound);
case "not_member":
return t(($) => $.slack_bind.error_not_member);
default:
return t(($) => $.slack_bind.error_unknown);
}
})()}
</p>
<p className="text-[10px] text-muted-foreground">
{t(($) => $.slack_bind.error_admin_hint)}
</p>
</>
)}
</CardContent>
</Card>
</div>
);
}
function redemptionFailureReason(err: unknown): string {
const msg = err instanceof Error ? err.message : "";
const lower = msg.toLowerCase();
if (lower.includes("invalid") || lower.includes("expired") || lower.includes("410")) {
return "expired";
}
if (lower.includes("already bound") || lower.includes("409")) {
return "already_bound";
}
if (lower.includes("workspace member") || lower.includes("403")) {
return "not_member";
}
return "unknown";
}

View File

@@ -0,0 +1 @@
export { SlackBindPage } from "./bind-page";

View File

@@ -197,12 +197,11 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
// Slack-only deployment has no Lark key). Platform adapters register a
// Factory + ResolverSet into it below; the Supervisor enumerates active
// installations across ALL channel types and routes each to its
// registered platform's Factory. With no platform registered the store
// still lists any active installation rows, but Registry.Build returns
// ErrUnknownType for them, so the supervisor logs and backs off without
// opening a connection (the normal state is simply that no rows exist
// for an unregistered platform). The Router is the single shared inbound
// handler injected into every Channel.
// registered platform's Factory. Installations whose channel_type has no
// registered Factory are skipped by the Supervisor — either no platform is
// configured, or (Slack/B2) the platform drives ONE deployment-level
// connection of its own outside the per-installation supervisor. The Router
// is the single shared inbound handler injected into every Channel.
channelRegistry := channel.NewRegistry()
channelRouter := engine.NewRouter(h.IssueService, h.TaskService, queries, engine.RouterConfig{Logger: slog.Default()})
// Debounce the per-session run trigger so a burst of messages collapses
@@ -397,26 +396,65 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
slog.Info("lark integration disabled (MULTICA_LARK_SECRET_KEY not set)")
}
// Slack integration (MUL-3516). Gated by MULTICA_SLACK_SECRET_KEY — the key
// that decrypts the bot/app tokens stored on the channel_installation row.
// When unset the whole block is skipped, so existing deployments are
// unaffected; an operator opts in by setting the key and creating a
// channel_type='slack' installation (config: app_id=team_id, bot_user_id,
// bot_token_encrypted, app_token_encrypted). Registering the Factory
// (Socket Mode connect/send) + ResolverSet (inbound pipeline) + the outbound
// subscriber (agent reply -> Slack) is all it takes — no engine or core edit,
// and Feishu is untouched. The Slack ResolverSet/Outbound share the same
// engine.ChatSession, channel_* tables, IssueService and TaskService as
// Feishu, so /issue, dedup, and run-triggering behave identically.
// Slack integration. Multi-tenant B2 model (MUL-3666): Multica hosts ONE
// Slack app, workspaces self-install via OAuth, and inbound runs on a single
// deployment-level Socket Mode connection routed by team_id — replacing the
// stage-3 per-installation connection model (MUL-3516).
//
// Two deployment-level env vars gate the two halves:
// - MULTICA_SLACK_SECRET_KEY decrypts the per-installation bot token
// (xoxb-) stored on the channel_installation row. It gates the inbound
// ResolverSet + the outbound reply subscriber, so without it there is no
// Slack at all.
// - MULTICA_SLACK_APP_TOKEN is the app-level token (xapp-) authorizing the
// single Socket Mode connection. It cannot be obtained via OAuth, so it
// is a one-time operator config. Without it, inbound is disabled (the
// ResolverSet + outbound are still wired so an existing install's replies
// keep flowing, but no new events are received).
//
// The ResolverSet/Outbound share the same engine.ChatSession, channel_*
// tables, IssueService and TaskService as Feishu, so /issue, dedup, and
// run-triggering behave identically. Feishu is untouched. Each Slack
// installation is a bring-your-own-app (BYO) install carrying its OWN
// app-level token, so a per-installation Slack Factory is registered and the
// Supervisor drives one Socket Mode connection per installation (like Feishu).
if slackKey, err := secretbox.LoadKey("MULTICA_SLACK_SECRET_KEY"); err == nil {
box, err := secretbox.New(slackKey)
if err != nil {
slog.Error("slack: secretbox.New failed; slack integration disabled", "error", err)
} else {
slack.RegisterSlack(channelRegistry, slack.SlackChannelDeps{Decrypt: box.Open, Logger: slog.Default()})
channelRouter.Register(slack.TypeSlack, slack.NewSlackResolverSet(queries, pool))
// Outbound replier (MUL-3666): delivers NeedsBinding prompt /
// AgentOffline / AgentArchived / issue-created notices. The binding
// token service mints the single-use token embedded in the prompt's
// redeem link; the redeem endpoint (registered below, public) binds
// the Slack user to their Multica account.
slackBindingSvc := slack.NewBindingTokenService(queries, pool)
h.SlackBindingTokens = slackBindingSvc
slackReplier := slack.NewOutboundReplier(slack.OutboundReplierConfig{
Binding: slackBindingSvc,
Decrypt: box.Open,
PublicURL: signupConfig.PublicURL,
Logger: slog.Default(),
})
channelRouter.Register(slack.TypeSlack, slack.NewSlackResolverSet(queries, pool, slackReplier))
slack.NewOutbound(queries, box.Open, slog.Default()).Register(bus)
slog.Info("slack integration enabled")
// Per-installation inbound: the Supervisor builds + supervises one
// Socket Mode connection per active Slack installation, authenticated
// with that installation's OWN app-level token (xapp-, pasted at BYO
// install) — no deployment-level app token, no single connection.
slack.RegisterSlack(channelRegistry, slack.ChannelDeps{Decrypt: box.Open, Logger: slog.Default()})
// BYO self-serve install (paste bot token + app-level token). The
// InstallService needs only the at-rest encryption key — there is no
// hosted OAuth client credential.
installSvc, ierr := slack.NewInstallService(queries, pool, box, slog.Default())
if ierr != nil {
slog.Error("slack: InstallService init failed; install disabled", "error", ierr)
} else {
h.SlackInstall = installSvc
}
slog.Info("slack integration enabled (BYO per-installation socket mode)")
}
} else {
slog.Info("slack integration disabled (MULTICA_SLACK_SECRET_KEY not set)")
@@ -550,6 +588,10 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
// HMAC-SHA256 signature in the handler) and post-install setup callback.
r.Post("/api/webhooks/github", h.HandleGitHubWebhook)
r.Get("/api/github/setup", h.GitHubSetupCallback)
// Slack OAuth callback (no Multica auth in the path — it is hit by Slack's
// browser redirect; the workspace/agent/initiator are recovered from the
// sealed state). It exchanges the code, upserts the install, then bounces
// the browser back to Settings → Integrations.
// Stripe webhook (no Multica auth — Stripe signs the raw body
// with a shared secret, the multica-cloud upstream verifies. We
// only forward the bytes + the Stripe-Signature header; see
@@ -702,6 +744,21 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Post("/lark/install/begin", h.BeginLarkInstall)
r.Get("/lark/install/{sessionId}/status", h.GetLarkInstallStatus)
})
// Slack integration (MUL-3666). Same admin/member split as
// Lark: listing is member-visible; OAuth begin + revoke are
// admin-only. The OAuth callback itself is a public route (it is
// hit by Slack's browser redirect with no workspace in the path)
// and is registered outside this workspace group.
r.Group(func(r chi.Router) {
r.Use(middleware.RequireWorkspaceMemberFromURL(queries, "id"))
r.Get("/slack/installations", h.ListSlackInstallations)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner", "admin"))
r.Delete("/slack/installations/{installationId}", h.RevokeSlackInstallation)
r.Post("/slack/install/byo", h.RegisterSlackBYO)
})
})
})
@@ -712,6 +769,12 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
// the token only proves "this open_id requested binding," and
// is combined with the logged-in user to create the mapping.
r.Post("/api/lark/binding/redeem", h.RedeemLarkBindingToken)
// Slack binding-token redemption. Same rationale as Lark: NOT
// workspace-scoped because the redeemer hits this before they have any
// workspace context — the redemption itself mints their binding row. The
// logged-in user (from the session) is bound to the Slack id the token
// carries.
r.Post("/api/slack/binding/redeem", h.RedeemSlackBindingToken)
// User-scoped invitation routes (no workspace context required)
r.Get("/api/invitations", h.ListMyInvitations)

View File

@@ -23,6 +23,7 @@ import (
"github.com/multica-ai/multica/server/internal/featureflagdispatch"
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
"github.com/multica-ai/multica/server/internal/integrations/lark"
"github.com/multica-ai/multica/server/internal/integrations/slack"
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
"github.com/multica-ai/multica/server/internal/middleware"
"github.com/multica-ai/multica/server/internal/realtime"
@@ -171,7 +172,15 @@ type Handler struct {
// delivering events, to flush debounced run triggers and join in-flight
// reply goroutines. Built unconditionally (even without Lark).
ChannelRouter *engine.Router
cfg Config
// SlackInstall owns the bring-your-own-app Slack install lifecycle (register
// pasted tokens / list / revoke) and the at-rest encryption of each app's bot
// + app tokens (MUL-3666). Nil unless MULTICA_SLACK_SECRET_KEY is set.
SlackInstall *slack.InstallService
// SlackBindingTokens mints/redeems the user-binding tokens behind the
// "link your Slack account" prompt (MUL-3666). Nil unless Slack is
// configured (MULTICA_SLACK_SECRET_KEY set).
SlackBindingTokens *slack.BindingTokenService
cfg Config
}
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner, analyticsClient analytics.Client, cfg Config, daemonHubs ...*daemonws.Hub) *Handler {

View File

@@ -0,0 +1,278 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/multica-ai/multica/server/internal/integrations/slack"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// SlackInstallationResponse is the wire shape for a Slack installation row. The
// encrypted bot token in config is INTENTIONALLY absent — it is server-internal
// (only the outbound sender decrypts it). WS lease columns are runtime state,
// not API surface, so they are omitted too.
type SlackInstallationResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
AgentID string `json:"agent_id"`
TeamID string `json:"team_id"`
BotUserID string `json:"bot_user_id"`
InstallerUserID string `json:"installer_user_id"`
Status string `json:"status"`
InstalledAt string `json:"installed_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func slackInstallationToResponse(row db.ChannelInstallation) SlackInstallationResponse {
info := slack.DecodePublicConfig(row.Config)
return SlackInstallationResponse{
ID: uuidToString(row.ID),
WorkspaceID: uuidToString(row.WorkspaceID),
AgentID: uuidToString(row.AgentID),
TeamID: info.TeamID,
BotUserID: info.BotUserID,
InstallerUserID: uuidToString(row.InstallerUserID),
Status: row.Status,
InstalledAt: row.InstalledAt.Time.UTC().Format(time.RFC3339),
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
UpdatedAt: row.UpdatedAt.Time.UTC().Format(time.RFC3339),
}
}
// ListSlackInstallations (GET /api/workspaces/{id}/slack/installations) is
// member-visible so the Integrations tab renders for non-admins. Response
// flags mirror Lark:
// - configured: at-rest encryption key is set (SlackInstall != nil).
// - install_supported: kept for the management UI; true whenever configured,
// since a BYO install needs only the at-rest key (no hosted OAuth creds).
func (h *Handler) ListSlackInstallations(w http.ResponseWriter, r *http.Request) {
if h.SlackInstall == nil {
writeJSON(w, http.StatusOK, map[string]any{
"installations": []SlackInstallationResponse{},
"configured": false,
"install_supported": false,
})
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
if !ok {
return
}
rows, err := h.SlackInstall.ListByWorkspace(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list slack installations")
return
}
out := make([]SlackInstallationResponse, 0, len(rows))
for _, row := range rows {
out = append(out, slackInstallationToResponse(row))
}
writeJSON(w, http.StatusOK, map[string]any{
"installations": out,
"configured": true,
"install_supported": true,
})
}
// RegisterSlackBYORequest is the body for a bring-your-own-app install: the two
// tokens the user pasted from their own Slack app.
type RegisterSlackBYORequest struct {
BotToken string `json:"bot_token"`
AppToken string `json:"app_token"`
}
// RegisterSlackBYO (POST /api/workspaces/{id}/slack/install/byo?agent_id=…)
// installs a user-supplied ("bring your own") Slack app for an agent, so several
// agents can each have their own bot identity in the SAME Slack workspace.
// Admin-only at the router. Unlike the hosted OAuth path this needs only the
// at-rest key configured (SlackInstall != nil), NOT the hosted OAuth client
// credentials — BYO is exactly the path for deployments without a hosted app.
func (h *Handler) RegisterSlackBYO(w http.ResponseWriter, r *http.Request) {
if h.SlackInstall == nil {
writeError(w, http.StatusServiceUnavailable, "slack integration not enabled")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
if !ok {
return
}
agentIDStr := strings.TrimSpace(r.URL.Query().Get("agent_id"))
if agentIDStr == "" {
writeError(w, http.StatusBadRequest, "agent_id is required")
return
}
agentUUID, ok := parseUUIDOrBadRequest(w, agentIDStr, "agent_id")
if !ok {
return
}
// Ownership pre-check at the boundary so a wrong agent_id is a clear 404.
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
ID: agentUUID,
WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusNotFound, "agent not found in this workspace")
return
}
initiatorUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
if !ok {
return
}
var body RegisterSlackBYORequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
row, err := h.SlackInstall.RegisterBYO(r.Context(), slack.RegisterBYOParams{
WorkspaceID: wsUUID,
AgentID: agentUUID,
InitiatorID: initiatorUUID,
BotToken: body.BotToken,
AppToken: body.AppToken,
})
if err != nil {
switch {
case errors.Is(err, slack.ErrInvalidBotToken), errors.Is(err, slack.ErrInvalidAppToken), errors.Is(err, slack.ErrTokenAppMismatch):
writeError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, slack.ErrTeamOwnedByAnotherWorkspace):
writeError(w, http.StatusConflict, "this Slack app is already connected to a different Multica workspace")
default:
// The dominant non-sentinel failure here is auth.test rejecting the
// pasted bot token (a user error), so guide the user to recheck the
// tokens rather than surfacing an opaque 500.
writeError(w, http.StatusBadRequest, "could not verify the Slack tokens — check the bot token and app-level token, that the app is installed to your workspace, and that it has the users:read scope")
}
return
}
// Broadcast so every open client (Settings, Agent Integrations, other tabs)
// invalidates its installations query and shows the new bot — matching the
// revoke event and Lark's install semantics. The installer's own tab also
// invalidates locally, but other clients rely on this event.
h.publishSlackInstallationCreated(row, userID)
writeJSON(w, http.StatusOK, slackInstallationToResponse(row))
}
// publishSlackInstallationCreated emits slack_installation:created for a newly
// connected bot. The realtime layer fans it out to the workspace; the web app
// listens on slack_installation:* to invalidate the installations query.
func (h *Handler) publishSlackInstallationCreated(row db.ChannelInstallation, actorID string) {
h.publish(protocol.EventSlackInstallationCreated, uuidToString(row.WorkspaceID), "user", actorID, map[string]any{
"id": uuidToString(row.ID),
})
}
// RevokeSlackInstallation (DELETE /api/workspaces/{id}/slack/installations/{installationId})
// flips status to 'revoked'. Admin-only at the router. The row is preserved for
// audit; a re-install (re-pasting the app's tokens) flips status back to 'active'.
func (h *Handler) RevokeSlackInstallation(w http.ResponseWriter, r *http.Request) {
if h.SlackInstall == nil {
writeError(w, http.StatusServiceUnavailable, "slack integration not configured")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
if !ok {
return
}
instUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "installationId"), "installation id")
if !ok {
return
}
// Workspace-scoped lookup so one workspace cannot revoke another's
// installation by guessing the UUID.
if _, err := h.SlackInstall.GetInWorkspace(r.Context(), instUUID, wsUUID); err != nil {
if errors.Is(err, slack.ErrInstallationNotFound) {
writeError(w, http.StatusNotFound, "slack installation not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load installation")
return
}
if err := h.SlackInstall.Revoke(r.Context(), instUUID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to revoke installation")
return
}
h.publish(protocol.EventSlackInstallationRevoked, uuidToString(wsUUID), "user", userID, map[string]any{
"id": uuidToString(instUUID),
})
w.WriteHeader(http.StatusNoContent)
}
// RedeemSlackBindingTokenRequest carries the raw token the user clicked through
// from the bot's "link your account" prompt.
type RedeemSlackBindingTokenRequest struct {
Token string `json:"token"`
}
// RedeemSlackBindingTokenResponse echoes the bound workspace/installation/user
// so the frontend can confirm without a second fetch.
type RedeemSlackBindingTokenResponse struct {
WorkspaceID string `json:"workspace_id"`
InstallationID string `json:"installation_id"`
SlackUserID string `json:"slack_user_id"`
}
// RedeemSlackBindingToken (POST /api/slack/binding/redeem) binds the Slack user
// id carried by the token to the logged-in Multica user. The redeemer's identity
// comes from the session, not the token, so a stolen token cannot bind a Slack
// id to an attacker's account. Failure modes map to distinct status codes:
// - 410 Gone: token unknown / consumed / expired
// - 409 Conflict: this Slack id is already bound to a different user
// - 403 Forbidden: redeemer is not a workspace member
func (h *Handler) RedeemSlackBindingToken(w http.ResponseWriter, r *http.Request) {
if h.SlackBindingTokens == nil {
writeError(w, http.StatusServiceUnavailable, "slack integration not configured")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req RedeemSlackBindingTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, "token is required")
return
}
userUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
if !ok {
return
}
redeemed, err := h.SlackBindingTokens.RedeemAndBind(r.Context(), req.Token, userUUID)
if err != nil {
switch {
case errors.Is(err, slack.ErrBindingTokenInvalid):
writeError(w, http.StatusGone, "binding token invalid or expired")
case errors.Is(err, slack.ErrBindingAlreadyAssigned):
writeError(w, http.StatusConflict, "this Slack account is already bound to a different Multica user")
case errors.Is(err, slack.ErrBindingNotWorkspaceMember):
writeError(w, http.StatusForbidden, "binding refused (are you a workspace member?)")
default:
writeError(w, http.StatusInternalServerError, "failed to redeem token")
}
return
}
writeJSON(w, http.StatusOK, RedeemSlackBindingTokenResponse{
WorkspaceID: uuidToString(redeemed.WorkspaceID),
InstallationID: uuidToString(redeemed.InstallationID),
SlackUserID: redeemed.SlackUserID,
})
}

View File

@@ -0,0 +1,46 @@
package handler
import (
"testing"
"github.com/multica-ai/multica/server/internal/events"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// A successful BYO install must broadcast slack_installation:created so all open
// clients (not just the installer's tab) invalidate the installations query —
// the regression Niko's review caught (RegisterSlackBYO previously only wrote
// the response). Bus.Publish is synchronous, so the subscriber fires inline.
func TestPublishSlackInstallationCreated(t *testing.T) {
bus := events.New()
h := &Handler{Bus: bus}
const (
wsID = "11111111-1111-1111-1111-111111111111"
instID = "22222222-2222-2222-2222-222222222222"
)
var got events.Event
fired := 0
bus.Subscribe(protocol.EventSlackInstallationCreated, func(e events.Event) {
got = e
fired++
})
h.publishSlackInstallationCreated(db.ChannelInstallation{
ID: parseUUID(instID),
WorkspaceID: parseUUID(wsID),
}, "user-1")
if fired != 1 {
t.Fatalf("expected slack_installation:created published once, got %d", fired)
}
if got.WorkspaceID != wsID || got.ActorType != "user" || got.ActorID != "user-1" {
t.Errorf("event envelope = %+v", got)
}
payload, ok := got.Payload.(map[string]any)
if !ok || payload["id"] != instID {
t.Errorf("payload = %v, want installation id %s", got.Payload, instID)
}
}

View File

@@ -345,6 +345,16 @@ func (s *Supervisor) sweep(ctx context.Context) {
}
active := make(map[string]struct{}, len(rows))
for _, row := range rows {
// Skip channel types with no registered per-installation Factory. Such
// rows are driven outside the Supervisor (e.g. Slack's app-level Socket
// Mode connector owns ONE deployment connection for all its
// installations, so each Slack row carries only outbound creds + routing,
// not its own connection). Without this guard the supervise loop would
// acquire the lease, hit ErrUnknownType from Registry.Build, release, and
// back off forever — churning the lease and the log on every such row.
if _, ok := s.registry.Lookup(row.ChannelType); !ok {
continue
}
id := uuidString(row.ID)
active[id] = struct{}{}
s.maybeRestartOnRotation(id, row)

View File

@@ -255,6 +255,42 @@ func TestSupervisorAcquiresLeaseAndConnects(t *testing.T) {
}
}
// TestSupervisorSkipsUnregisteredChannelType covers the B2 (MUL-3666) guard:
// an active installation whose channel_type has no registered Factory must be
// left alone — never leased, never Built — because it is driven outside the
// Supervisor (Slack's app-level connector owns one shared connection for all
// its installations). A registered type alongside it still connects normally.
func TestSupervisorSkipsUnregisteredChannelType(t *testing.T) {
q := newFakeStore()
feishuID := uuidFromString(t, "2a111111-1111-1111-1111-111111111111")
slackID := uuidFromString(t, "2b222222-2222-2222-2222-222222222222")
q.installations = []Installation{
activeInst(feishuID, "fp1"),
{ID: slackID, ChannelType: channel.Type("slack"), Fingerprint: "fp2", Config: []byte(`{}`)},
}
fc := &fakeChannel{typ: channel.TypeFeishu}
var builds int32
reg := fakeRegistry(fc, &builds, nil) // registers ONLY TypeFeishu
sup := NewSupervisor(q, reg, nil, fastConfig())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go sup.Run(ctx)
if !waitFor(300*time.Millisecond, func() bool { return fc.Connects() >= 1 }) {
t.Fatalf("registered feishu installation should connect; connects=%d", fc.Connects())
}
// Give the supervisor a few sweep cycles to (not) act on the slack row.
time.Sleep(50 * time.Millisecond)
if owner, ok := q.leaseHolder(slackID); ok {
t.Fatalf("unregistered channel type must never be leased, got owner %q", owner)
}
if got := atomic.LoadInt32(&builds); got != 1 {
t.Fatalf("only the registered feishu channel should be built, builds=%d", got)
}
}
func TestSupervisorInjectsHandler(t *testing.T) {
q := newFakeStore()
instID := uuidFromString(t, "1a111111-1111-1111-1111-111111111111")

View File

@@ -0,0 +1,165 @@
package slack
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// This file is the Slack user-binding token flow: an unbound Slack user who
// messages the bot gets a "link your account" prompt (minted here, delivered by
// the OutboundReplier), clicks through to the in-product redeem page, and their
// Slack user id is bound to their Multica account. It mirrors
// lark.BindingTokenService but runs on the generic channel_* queries with
// channel_type='slack' (lark's ChannelStore hardcodes 'feishu').
// BindingTokenTTL bounds a token's life. The channel_binding_token CHECK
// enforces the same 15-minute cap so a misconfigured caller cannot mint longer.
const BindingTokenTTL = 15 * time.Minute
var (
// ErrBindingTokenInvalid: token unknown / already consumed / expired. One
// opaque error for all three avoids a replay timing oracle.
ErrBindingTokenInvalid = errors.New("slack: binding token invalid or expired")
// ErrBindingAlreadyAssigned: this Slack user id is already bound to a
// different Multica user (account transfer must go through explicit unbind).
ErrBindingAlreadyAssigned = errors.New("slack: user id is already bound to a different user")
// ErrBindingNotWorkspaceMember: the redeemer is not a member of the token's
// workspace. Translated to 403 at the HTTP boundary.
ErrBindingNotWorkspaceMember = errors.New("slack: redeemer is not a workspace member")
)
// BindingToken is a freshly minted token. The raw value is returned exactly
// once (embedded in the binding URL); only its hash is persisted.
type BindingToken struct {
Raw string
ExpiresAt time.Time
}
// RedeemedBindingToken is returned after a successful redemption.
type RedeemedBindingToken struct {
WorkspaceID pgtype.UUID
InstallationID pgtype.UUID
SlackUserID string
}
// BindingTokenService mints and redeems Slack binding tokens. Redemption is
// transactional: consuming the token and inserting the channel_user_binding row
// commit together, so a failed bind never burns a token.
type BindingTokenService struct {
q *db.Queries
tx engine.TxStarter
now func() time.Time
}
// NewBindingTokenService constructs the service. tx (a *pgxpool.Pool) is needed
// for the transactional redeem path.
func NewBindingTokenService(q *db.Queries, tx engine.TxStarter) *BindingTokenService {
return &BindingTokenService{q: q, tx: tx, now: time.Now}
}
// Mint creates a single-use binding token for (installation, slackUserID) and
// returns the raw secret + expiry. The raw value must be delivered over Slack
// (encrypted in transit by the platform) and never logged.
func (s *BindingTokenService) Mint(ctx context.Context, workspaceID, installationID pgtype.UUID, slackUserID string) (BindingToken, error) {
raw, err := randomBindingToken(32)
if err != nil {
return BindingToken{}, fmt.Errorf("generate token: %w", err)
}
expiresAt := s.now().Add(BindingTokenTTL)
if _, err := s.q.CreateChannelBindingToken(ctx, db.CreateChannelBindingTokenParams{
TokenHash: hashBindingToken(raw),
WorkspaceID: workspaceID,
InstallationID: installationID,
ChannelType: string(TypeSlack),
ChannelUserID: slackUserID,
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
}); err != nil {
return BindingToken{}, fmt.Errorf("persist token: %w", err)
}
return BindingToken{Raw: raw, ExpiresAt: expiresAt}, nil
}
// RedeemAndBind atomically consumes a raw token and binds the Slack user id to
// multicaUserID (taken from the session, never from the token). Returns
// ErrBindingTokenInvalid / ErrBindingAlreadyAssigned / ErrBindingNotWorkspaceMember.
func (s *BindingTokenService) RedeemAndBind(ctx context.Context, raw string, multicaUserID pgtype.UUID) (RedeemedBindingToken, error) {
if s.tx == nil {
return RedeemedBindingToken{}, errors.New("slack: BindingTokenService missing TxStarter")
}
tx, err := s.tx.Begin(ctx)
if err != nil {
return RedeemedBindingToken{}, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
qtx := s.q.WithTx(tx)
row, err := qtx.ConsumeChannelBindingToken(ctx, hashBindingToken(raw))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return RedeemedBindingToken{}, ErrBindingTokenInvalid
}
return RedeemedBindingToken{}, fmt.Errorf("consume token: %w", err)
}
// Explicit membership gate (no member FK): returning before Commit rolls the
// consume back, so a non-member's attempt does not burn the token.
if _, err := qtx.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
UserID: multicaUserID,
WorkspaceID: row.WorkspaceID,
}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return RedeemedBindingToken{}, ErrBindingNotWorkspaceMember
}
return RedeemedBindingToken{}, fmt.Errorf("check membership: %w", err)
}
if _, err := qtx.CreateChannelUserBinding(ctx, db.CreateChannelUserBindingParams{
WorkspaceID: row.WorkspaceID,
MulticaUserID: multicaUserID,
InstallationID: row.InstallationID,
ChannelType: string(TypeSlack),
ChannelUserID: row.ChannelUserID,
Config: []byte(`{}`),
}); err != nil {
// pgx.ErrNoRows means the existing binding points at a different user —
// the ON CONFLICT DO UPDATE WHERE multica_user_id=… gating rejected it.
if errors.Is(err, pgx.ErrNoRows) {
return RedeemedBindingToken{}, ErrBindingAlreadyAssigned
}
return RedeemedBindingToken{}, fmt.Errorf("create binding: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return RedeemedBindingToken{}, fmt.Errorf("commit: %w", err)
}
return RedeemedBindingToken{
WorkspaceID: row.WorkspaceID,
InstallationID: row.InstallationID,
SlackUserID: row.ChannelUserID,
}, nil
}
func randomBindingToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func hashBindingToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,189 @@
package slack
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"github.com/slack-go/slack"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// ErrInvalidBotToken / ErrInvalidAppToken are returned by RegisterBYO when a
// pasted token is malformed (wrong prefix, or an app token whose app id cannot
// be parsed). The handler maps them to 400 so the dialog can show a precise hint
// instead of a generic failure.
var (
ErrInvalidBotToken = errors.New("slack: bot token must start with xoxb-")
ErrInvalidAppToken = errors.New("slack: app-level token must start with xapp- and embed an app id")
// ErrTokenAppMismatch is returned when the pasted bot token and app-level
// token belong to DIFFERENT Slack apps. Persisting that pair would "connect"
// but be broken: inbound arrives on the app token's socket (routed by its
// app id) while mention detection + outbound use the bot token's identity.
ErrTokenAppMismatch = errors.New("slack: the bot token and app-level token are from different Slack apps")
)
// RegisterBYOParams are the inputs for a bring-your-own-app install: the agent
// this bot represents, who is installing, and the two tokens the user pasted
// from their own Slack app.
type RegisterBYOParams struct {
WorkspaceID pgtype.UUID
AgentID pgtype.UUID
InitiatorID pgtype.UUID
BotToken string // xoxb-… — outbound Web API (chat.postMessage)
AppToken string // xapp-… — this app's OWN Socket Mode connection (inbound)
}
// RegisterBYO installs a user-supplied ("bring your own") Slack app for an agent.
// The user creates their own Slack app, installs it to their workspace, and
// pastes its bot token (xoxb-) + app-level token (xapp-). There is NO OAuth code
// exchange: we validate the bot token live via auth.test (which also yields the
// team id + bot user id), prove the bot + app tokens belong to the SAME app,
// parse the real Slack app id out of the app-level token, encrypt BOTH tokens at
// rest, and persist the installation.
//
// Because each BYO app is a distinct Slack app — a distinct bot identity — the
// SAME Slack workspace can host several of them, one per agent. The stored
// config carries the real app id for inbound routing; persistInstall keys the
// row by (workspace, agent) and refuses the pair if that app id is already
// connected to another agent/workspace. The dedicated Socket Mode connection
// that consumes the stored app token lives in slack_channel.go; this method
// only persists the installation.
func (s *InstallService) RegisterBYO(ctx context.Context, p RegisterBYOParams) (db.ChannelInstallation, error) {
botToken := strings.TrimSpace(p.BotToken)
appToken := strings.TrimSpace(p.AppToken)
if !strings.HasPrefix(botToken, "xoxb-") {
return db.ChannelInstallation{}, ErrInvalidBotToken
}
appID, err := parseSlackAppID(appToken)
if err != nil {
return db.ChannelInstallation{}, err
}
// Validate the bot token live and learn the team + bot user id. auth.test
// authenticates with the bot token and returns the bot's OWN user id, which
// is the @-mention identity inbound translation strips.
auth, err := s.authTest(ctx, botToken)
if err != nil {
return db.ChannelInstallation{}, fmt.Errorf("slack auth.test: %w", err)
}
if auth.TeamID == "" || auth.UserID == "" || auth.BotID == "" {
return db.ChannelInstallation{}, errors.New("slack auth.test: response missing team_id / user_id / bot_id")
}
// Prove the two tokens belong to the SAME Slack app: resolve the bot's
// OWNING app id (bots.info on the bot id auth.test returned) and require it to
// equal the app id embedded in the app-level token. Without this, pasting app
// A's bot token with app B's app token would "connect" but be broken —
// inbound arrives on app B's socket (routed by api_app_id=B) while mention
// detection + outbound use app A's bot identity / token (Niko review).
botAppID, err := s.botAppID(ctx, botToken, auth.BotID)
if err != nil {
return db.ChannelInstallation{}, fmt.Errorf("slack bots.info: %w", err)
}
if botAppID != appID {
return db.ChannelInstallation{}, ErrTokenAppMismatch
}
// Validate the app-level token is live (Socket Mode can actually open) so we
// never persist a token that will silently never receive events.
if err := s.validateAppToken(ctx, appToken); err != nil {
return db.ChannelInstallation{}, fmt.Errorf("slack apps.connections.open: %w", err)
}
sealedBot, err := s.box.Seal([]byte(botToken))
if err != nil {
return db.ChannelInstallation{}, fmt.Errorf("encrypt slack bot token: %w", err)
}
sealedApp, err := s.box.Seal([]byte(appToken))
if err != nil {
return db.ChannelInstallation{}, fmt.Errorf("encrypt slack app token: %w", err)
}
cfgJSON, err := json.Marshal(installConfig{
AppID: appID,
TeamID: auth.TeamID,
BotUserID: auth.UserID,
BotTokenEncrypted: base64.StdEncoding.EncodeToString(sealedBot),
AppTokenEncrypted: base64.StdEncoding.EncodeToString(sealedApp),
})
if err != nil {
return db.ChannelInstallation{}, fmt.Errorf("encode slack installation config: %w", err)
}
// Persist one bot per agent (the row is keyed by workspace + agent). The
// stored config carries the real app id for inbound routing; persistInstall
// refuses the pair if that app is already connected to another agent/workspace.
return s.persistInstall(ctx, installPersist{
wsID: p.WorkspaceID,
agentID: p.AgentID,
installerID: p.InitiatorID,
configJSON: cfgJSON,
})
}
// slackOpts builds the slack.Client options shared by the install-time Web API
// calls, honoring the apiURL override so tests can point them at an httptest
// server. The Slack SDK appends the method name to the endpoint, so the base
// must end in a slash. A fresh slice is returned each call (safe to append to).
func (s *InstallService) slackOpts() []slack.Option {
httpClient := s.httpClient
if httpClient == nil {
httpClient = http.DefaultClient
}
opts := []slack.Option{slack.OptionHTTPClient(httpClient)}
if s.apiURL != "" {
base := s.apiURL
if !strings.HasSuffix(base, "/") {
base += "/"
}
opts = append(opts, slack.OptionAPIURL(base))
}
return opts
}
// authTest calls Slack auth.test with the bot token: validates it and returns
// the team id, the bot's own user id, and the bot id (for the bots.info lookup).
func (s *InstallService) authTest(ctx context.Context, botToken string) (*slack.AuthTestResponse, error) {
return slack.New(botToken, s.slackOpts()...).AuthTestContext(ctx)
}
// botAppID resolves the Slack app that OWNS the bot, via bots.info on the bot id
// from auth.test. It is the only token→app_id path for a bot token, so it is how
// we prove the pasted bot + app tokens belong to the same app.
func (s *InstallService) botAppID(ctx context.Context, botToken, botID string) (string, error) {
bot, err := slack.New(botToken, s.slackOpts()...).GetBotInfoContext(ctx, slack.GetBotInfoParameters{Bot: botID})
if err != nil {
return "", err
}
return bot.AppID, nil
}
// validateAppToken confirms the app-level token can open a Socket Mode
// connection (apps.connections.open) — a live check that the xapp is valid for
// THIS app, so we never store a token that will silently receive nothing.
func (s *InstallService) validateAppToken(ctx context.Context, appToken string) error {
api := slack.New("", append(s.slackOpts(), slack.OptionAppLevelToken(appToken))...)
_, _, err := api.StartSocketModeContext(ctx)
return err
}
// parseSlackAppID extracts the real Slack app id from an app-level token. The
// token format is `xapp-1-<APP_ID>-<gen>-<secret>` (e.g. xapp-1-A0BCXGVCS7R-…),
// so the app id is the third dash-segment. It is the per-app storage / routing
// key that lets multiple BYO apps coexist in one Slack workspace.
func parseSlackAppID(appToken string) (string, error) {
if !strings.HasPrefix(appToken, "xapp-") {
return "", ErrInvalidAppToken
}
parts := strings.SplitN(appToken, "-", 5)
if len(parts) < 4 || parts[2] == "" || !strings.HasPrefix(parts[2], "A") {
return "", ErrInvalidAppToken
}
return parts[2], nil
}

View File

@@ -0,0 +1,290 @@
package slack
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// slackMock parameterizes the install-time Slack API stub. botAppID defaults to
// the app id embedded in byoParams' xapp token (so the same-app check passes).
type slackMock struct {
authOK bool // auth.test result
botAppID string // bots.info -> bot.app_id
appTokenOK bool // apps.connections.open result
}
// slackMockServer stubs the three Web API calls RegisterBYO makes: auth.test
// (bot token), bots.info (bot id -> owning app id), apps.connections.open (app
// token live check).
func slackMockServer(t *testing.T, m slackMock) *httptest.Server {
t.Helper()
if m.botAppID == "" {
m.botAppID = "A0BCXGVCS7R"
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/auth.test":
if !m.authOK {
_, _ = w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`))
return
}
_, _ = w.Write([]byte(`{"ok":true,"team_id":"T999","user_id":"UBOTBYO","bot_id":"B0BOT","team":"Acme Inc","url":"https://acme.slack.com/"}`))
case "/bots.info":
_, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"bot":{"id":"B0BOT","app_id":%q,"user_id":"UBOTBYO"}}`, m.botAppID)))
case "/apps.connections.open":
if !m.appTokenOK {
_, _ = w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`))
return
}
_, _ = w.Write([]byte(`{"ok":true,"url":"wss://example.test/link"}`))
default:
_, _ = w.Write([]byte(`{"ok":false,"error":"unknown_method"}`))
}
}))
}
// authTestServer is the happy-path stub (valid bot token, matching app id, live
// app token) unless ok=false, which makes auth.test reject the bot token.
func authTestServer(t *testing.T, ok bool) *httptest.Server {
return slackMockServer(t, slackMock{authOK: ok, appTokenOK: true})
}
func byoParams(ws, agent string) RegisterBYOParams {
return RegisterBYOParams{
WorkspaceID: pgtypeUUID(ws),
AgentID: pgtypeUUID(agent),
InitiatorID: pgtypeUUID("33333333-3333-3333-3333-333333333333"),
BotToken: "xoxb-real-bot-token",
AppToken: "xapp-1-A0BCXGVCS7R-111-appsecret",
}
}
// pgtypeUUID is a test-local UUID parse that panics on bad input (test data is
// always valid), so byoParams stays a plain literal.
func pgtypeUUID(s string) pgtype.UUID {
var u pgtype.UUID
if err := u.Scan(s); err != nil {
panic(err)
}
return u
}
func TestParseSlackAppID(t *testing.T) {
cases := []struct {
token string
want string
wantErr bool
}{
{"xapp-1-A0BCXGVCS7R-111-secret", "A0BCXGVCS7R", false},
{"xapp-1-A12345-9-abc", "A12345", false},
{"xoxb-not-an-app-token", "", true},
{"xapp-1-", "", true},
{"xapp-1-B123-9-abc", "", true}, // app ids start with A
{"", "", true},
}
for _, c := range cases {
got, err := parseSlackAppID(c.token)
if c.wantErr {
if err == nil {
t.Errorf("parseSlackAppID(%q) = %q, want error", c.token, got)
}
continue
}
if err != nil || got != c.want {
t.Errorf("parseSlackAppID(%q) = %q, %v; want %q", c.token, got, err, c.want)
}
}
}
func TestRegisterBYO_PersistsEncryptedTokensKeyedByAppID(t *testing.T) {
srv := authTestServer(t, true)
defer srv.Close()
q := &fakeInstallQueries{rowID: mustUUID(t, "44444444-4444-4444-4444-444444444444")}
svc := newTestInstallService(t, q) // BYO needs NO OAuth creds
svc.apiURL = srv.URL + "/"
row, err := svc.RegisterBYO(context.Background(), byoParams(
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
))
if err != nil {
t.Fatalf("RegisterBYO: %v", err)
}
if row.ID != q.rowID {
t.Errorf("row id = %v, want %v", row.ID, q.rowID)
}
if !q.upsertCalled || q.upsertParams.ChannelType != string(TypeSlack) {
t.Fatalf("upsert not called for slack: %+v", q.upsertParams)
}
var cfg installConfig
if err := json.Unmarshal(q.upsertParams.Config, &cfg); err != nil {
t.Fatalf("decode upserted config: %v", err)
}
// Keyed by the REAL app id (parsed from the xapp token), NOT the team id —
// this is what lets several BYO apps share one Slack workspace.
if cfg.AppID != "A0BCXGVCS7R" {
t.Errorf("config app_id = %q, want the real app id A0BCXGVCS7R", cfg.AppID)
}
if cfg.TeamID != "T999" || cfg.BotUserID != "UBOTBYO" {
t.Errorf("config team/bot = %q/%q, want T999/UBOTBYO", cfg.TeamID, cfg.BotUserID)
}
// Both tokens stored encrypted (never plaintext) and both decrypt back.
if cfg.BotTokenEncrypted == "" || cfg.AppTokenEncrypted == "" {
t.Fatalf("both tokens must be stored: %+v", cfg)
}
if strings.Contains(cfg.BotTokenEncrypted, "xoxb-") || strings.Contains(cfg.AppTokenEncrypted, "xapp-") {
t.Error("tokens must be stored encrypted, not plaintext")
}
botTok, err := decryptToken(cfg.BotTokenEncrypted, svc.box.Open)
if err != nil || botTok != "xoxb-real-bot-token" {
t.Errorf("decrypted bot token = %q, %v", botTok, err)
}
appTok, err := decryptToken(cfg.AppTokenEncrypted, svc.box.Open)
if err != nil || appTok != "xapp-1-A0BCXGVCS7R-111-appsecret" {
t.Errorf("decrypted app token = %q, %v", appTok, err)
}
}
func TestRegisterBYO_InvalidTokens(t *testing.T) {
q := &fakeInstallQueries{}
svc := newTestInstallService(t, q)
// Bad bot token prefix — rejected before any network call or upsert.
p := byoParams("11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222")
p.BotToken = "nope-not-a-bot-token"
if _, err := svc.RegisterBYO(context.Background(), p); err != ErrInvalidBotToken {
t.Errorf("bad bot token = %v, want ErrInvalidBotToken", err)
}
// Bad app token.
p = byoParams("11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222")
p.AppToken = "xapp-broken"
if _, err := svc.RegisterBYO(context.Background(), p); err != ErrInvalidAppToken {
t.Errorf("bad app token = %v, want ErrInvalidAppToken", err)
}
if q.upsertCalled {
t.Error("malformed tokens must be rejected before the upsert")
}
}
func TestRegisterBYO_AuthTestFailure(t *testing.T) {
srv := authTestServer(t, false) // Slack rejects the bot token
defer srv.Close()
q := &fakeInstallQueries{}
svc := newTestInstallService(t, q)
svc.apiURL = srv.URL + "/"
if _, err := svc.RegisterBYO(context.Background(), byoParams(
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
)); err == nil {
t.Fatal("expected an error when auth.test rejects the bot token")
}
if q.upsertCalled {
t.Error("a failed auth.test must not persist an installation")
}
}
func TestRegisterBYO_AppAlreadyConnected_Rejected(t *testing.T) {
srv := authTestServer(t, true)
defer srv.Close()
// The pasted app is already connected to another agent / workspace, so the
// (channel_type, app_id) routing index rejects the upsert (unique violation).
// We must refuse, not steal it.
q := &fakeInstallQueries{
rowID: mustUUID(t, "44444444-4444-4444-4444-444444444444"),
appIDTaken: true,
}
svc := newTestInstallService(t, q)
svc.apiURL = srv.URL + "/"
if _, err := svc.RegisterBYO(context.Background(), byoParams(
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
)); err != ErrTeamOwnedByAnotherWorkspace {
t.Fatalf("app already connected = %v, want ErrTeamOwnedByAnotherWorkspace", err)
}
}
func TestRegisterBYO_ReconnectSameAgent_UpdatesRowInPlace(t *testing.T) {
srv := authTestServer(t, true)
defer srv.Close()
// The agent already has a Slack row (e.g. a previously-disconnected app).
// Re-connecting it — even with a NEW app — must UPDATE that same row in place
// (keyed by workspace+agent), not error on the (workspace, agent, channel)
// unique. The fake returns the existing row id on the upsert.
existingID := mustUUID(t, "55555555-5555-5555-5555-555555555555")
q := &fakeInstallQueries{
rowID: mustUUID(t, "44444444-4444-4444-4444-444444444444"),
existing: &db.ChannelInstallation{
ID: existingID,
WorkspaceID: mustUUID(t, "11111111-1111-1111-1111-111111111111"),
AgentID: mustUUID(t, "22222222-2222-2222-2222-222222222222"),
},
}
svc := newTestInstallService(t, q)
svc.apiURL = srv.URL + "/"
row, err := svc.RegisterBYO(context.Background(), byoParams(
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
))
if err != nil {
t.Fatalf("RegisterBYO: %v", err)
}
if row.ID != existingID {
t.Errorf("reconnect should reuse the agent's existing row %v, got %v", existingID, row.ID)
}
}
func TestRegisterBYO_TokenAppMismatch(t *testing.T) {
// The bot token belongs to a DIFFERENT app (bots.info -> A0OTHER) than the
// app id embedded in the xapp token (A0BCXGVCS7R) — must be rejected so we
// never persist a broken installation (Niko review).
srv := slackMockServer(t, slackMock{authOK: true, botAppID: "A0OTHERAPP", appTokenOK: true})
defer srv.Close()
q := &fakeInstallQueries{}
svc := newTestInstallService(t, q)
svc.apiURL = srv.URL + "/"
if _, err := svc.RegisterBYO(context.Background(), byoParams(
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
)); err != ErrTokenAppMismatch {
t.Fatalf("mismatched tokens = %v, want ErrTokenAppMismatch", err)
}
if q.upsertCalled {
t.Error("mismatched bot/app tokens must be rejected before the upsert")
}
}
func TestRegisterBYO_AppTokenNotLive(t *testing.T) {
// auth.test + same-app check pass, but apps.connections.open rejects the app
// token — we must not persist a token that will never receive events.
srv := slackMockServer(t, slackMock{authOK: true, appTokenOK: false})
defer srv.Close()
q := &fakeInstallQueries{}
svc := newTestInstallService(t, q)
svc.apiURL = srv.URL + "/"
if _, err := svc.RegisterBYO(context.Background(), byoParams(
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
)); err == nil {
t.Fatal("expected an error when the app-level token is not live")
}
if q.upsertCalled {
t.Error("an invalid app token must not persist an installation")
}
}

View File

@@ -2,16 +2,11 @@ package slack
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"regexp"
"strings"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/slack-go/slack/socketmode"
"github.com/multica-ai/multica/server/internal/integrations/channel"
)
@@ -25,89 +20,22 @@ const TypeSlack channel.Type = "slack"
// a message around 40k characters; we chunk below that with headroom.
const maxMessageRunes = 38000
// slackChannel is the Slack implementation of channel.Channel. One instance is
// built per channel_installation by the registered Factory. It holds only what
// Connect/Send need (the decoded credentials + an API client); the installation
// identity is resolved per message by the Router, so it is absent here — the
// same split the Feishu adapter uses.
type slackChannel struct {
creds credentials
api *slack.Client
handler channel.InboundHandler
logger *slog.Logger
mentionRe *regexp.Regexp
// slackSender posts agent replies back to Slack via chat.postMessage. It is the
// OUTBOUND half: it holds the per-installation bot token (xoxb-) the reply must
// be sent with (inbound runs on the per-installation Socket Mode connection in
// slack_channel.go). The installation identity (workspace / agent / installer)
// is resolved per message by the Router, so it is absent here.
type slackSender struct {
creds credentials
api *slack.Client
logger *slog.Logger
}
var _ channel.Channel = (*slackChannel)(nil)
func (c *slackChannel) Type() channel.Type { return TypeSlack }
// Connect opens the Slack Socket Mode WebSocket and runs the receive loop,
// blocking until ctx is cancelled or the connection drops — the contract
// engine.Supervisor relies on to tie lease renewal to connection liveness
// (matching feishuChannel.Connect). Each decoded Events API message is
// normalized to a channel.InboundMessage and handed to the engine handler. The
// envelope is ACKed immediately on receipt (Slack expires un-ACKed envelopes in
// ~3s) so the handler's slower DB work never races the ACK.
func (c *slackChannel) Connect(ctx context.Context) error {
if c.handler == nil {
return errors.New("slack: inbound handler not configured")
}
if c.api == nil {
return errors.New("slack: api client not configured")
}
sm := socketmode.New(c.api)
runErr := make(chan error, 1)
go func() { runErr <- sm.RunContext(ctx) }()
for {
select {
case <-ctx.Done():
// Graceful teardown: the Supervisor cancelled the run context.
return nil
case err := <-runErr:
// The managed connection loop ended. On ctx cancellation this is a
// graceful stop; otherwise it is a real failure the Supervisor
// retries under backoff.
if ctx.Err() != nil {
return nil
}
if err != nil {
return err
}
return errors.New("slack: socket mode connection closed")
case evt, ok := <-sm.Events:
if !ok {
if ctx.Err() != nil {
return nil
}
return errors.New("slack: socket mode event stream closed")
}
if err := c.handleSocketEvent(ctx, sm, evt); err != nil {
// A handler error is an infrastructure failure (InboundHandler
// contract): surface it so the Supervisor tears the connection
// down and reconnects under backoff, instead of silently
// dropping every subsequent event. ctx cancellation is a
// graceful stop, not a failure.
if ctx.Err() != nil {
return nil
}
return err
}
}
}
}
// Disconnect is a no-op: the Socket Mode loop is torn down by ctx cancellation
// (the Supervisor cancels the run context), mirroring feishuChannel.Disconnect.
func (c *slackChannel) Disconnect(ctx context.Context) error { return nil }
// Send delivers a minimal text reply via chat.postMessage, threading into
// out.ThreadID when set so a decoupled reply lands back in the originating
// thread. Long bodies are chunked under Slack's per-message cap; the returned
// SendResult carries the timestamp of the LAST posted chunk.
func (c *slackChannel) Send(ctx context.Context, out channel.OutboundMessage) (channel.SendResult, error) {
func (c *slackSender) Send(ctx context.Context, out channel.OutboundMessage) (channel.SendResult, error) {
if c.api == nil {
return channel.SendResult{}, errors.New("slack: api client not configured")
}
@@ -132,237 +60,14 @@ func (c *slackChannel) Send(ctx context.Context, out channel.OutboundMessage) (c
return channel.SendResult{MessageID: lastTS}, nil
}
// Capabilities declares what the Slack adapter supports TODAY. Declaration
// only — the engine performs no degradation, and callers pick a rendering from
// these bits, so declaring a capability the Send path cannot fulfil would
// mislead them. The minimal Send delivers text into a chat or thread, so only
// CapText | CapThreadReply are declared. Block Kit (CapRichCard), file
// attachments (CapAttachment) and chat.update edits (CapMessageEdit) are
// deferred until those paths are actually wired.
func (c *slackChannel) Capabilities() channel.Capability {
return channel.CapText | channel.CapThreadReply
}
// ---- inbound ----
func (c *slackChannel) handleSocketEvent(ctx context.Context, sm *socketmode.Client, evt socketmode.Event) error {
switch evt.Type {
case socketmode.EventTypeEventsAPI:
eventsAPI, ok := evt.Data.(slackevents.EventsAPIEvent)
if !ok {
return nil
}
// ACK first: Slack expires un-ACKed envelopes in ~3s, far below the
// handler's DB work. The ACK is independent of the handler outcome —
// a handler error is surfaced to the Supervisor (reconnect/backoff),
// not retried through the un-ACK path.
if evt.Request != nil {
if err := sm.Ack(*evt.Request); err != nil {
c.logger.WarnContext(ctx, "slack: ack failed", "error", err)
}
}
return c.dispatchEventsAPI(ctx, eventsAPI)
case socketmode.EventTypeConnecting, socketmode.EventTypeConnected, socketmode.EventTypeHello:
c.logger.DebugContext(ctx, "slack: socket mode", "event", evt.Type)
case socketmode.EventTypeIncomingError, socketmode.EventTypeErrorBadMessage:
c.logger.WarnContext(ctx, "slack: socket mode error", "event", evt.Type)
default:
// Interactive / slash-command / other events are out of scope for the
// minimal adapter; ACK so Slack does not retry, then ignore.
if evt.Request != nil {
_ = sm.Ack(*evt.Request)
}
}
return nil
}
func (c *slackChannel) dispatchEventsAPI(ctx context.Context, e slackevents.EventsAPIEvent) error {
var (
msg channel.InboundMessage
ok bool
)
switch inner := e.InnerEvent.Data.(type) {
case *slackevents.AppMentionEvent:
msg, ok = c.inboundFromAppMention(e, inner)
case *slackevents.MessageEvent:
msg, ok = c.inboundFromMessage(e, inner)
default:
return nil
}
if !ok {
return nil
}
// A non-nil handler error is an infrastructure failure; propagate it so the
// Supervisor reconnects (InboundHandler contract). A legitimate product
// drop (dedup hit / unbound sender / group filter) returns nil — not an
// error — so it does not tear the connection down.
return c.handler(ctx, msg)
}
// inboundFromMessage normalizes a Slack message event. It returns ok=false for
// events that must not reach the core: the bot's own messages and other bots'
// messages (loop guard), and edits/deletes/joins and similar subtyped system
// messages (only brand-new user messages are ingested).
//
// Group addressing policy (v1, deliberate): a group message is addressed to the
// bot only when it carries an explicit <@bot> mention. Mention-free follow-ups
// inside a thread the bot is already engaged in are NOT auto-addressed here:
// "reply to a bot message" is session state, so it belongs in the session-aware
// shared service / resolver layer (which can detect an existing bound session
// for the thread and survive reconnects) rather than in per-connection adapter
// memory. Until that lands, channel/thread continuation requires re-mentioning
// the bot. P2P (DM) ingests every message, unchanged.
func (c *slackChannel) inboundFromMessage(e slackevents.EventsAPIEvent, m *slackevents.MessageEvent) (channel.InboundMessage, bool) {
if m.BotID != "" || m.SubType == "bot_message" {
return channel.InboundMessage{}, false
}
if m.User == "" || (c.creds.BotUserID != "" && m.User == c.creds.BotUserID) {
return channel.InboundMessage{}, false
}
if !isIngestableSubtype(m.SubType) {
return channel.InboundMessage{}, false
}
chatType := slackChatType(m.Channel, m.ChannelType)
addressed := chatType == channel.ChatTypeP2P || c.mentionsBot(m.Text)
return c.buildInbound(e, buildInboundParams{
eventType: "message",
subType: m.SubType,
channelID: m.Channel,
userID: m.User,
text: m.Text,
ts: m.TimeStamp,
threadTS: m.ThreadTimeStamp,
chatType: chatType,
addressed: addressed,
}), true
}
// inboundFromAppMention normalizes an app_mention event. An app_mention is, by
// definition, addressed to the bot and occurs in a channel (group). The same
// channel @mention also arrives as a message event with the identical ts, so
// the engine's (installation, message_id=ts) dedup collapses the pair — no
// special-casing needed here.
func (c *slackChannel) inboundFromAppMention(e slackevents.EventsAPIEvent, m *slackevents.AppMentionEvent) (channel.InboundMessage, bool) {
if m.BotID != "" || m.User == "" || (c.creds.BotUserID != "" && m.User == c.creds.BotUserID) {
return channel.InboundMessage{}, false
}
return c.buildInbound(e, buildInboundParams{
eventType: "app_mention",
channelID: m.Channel,
userID: m.User,
text: m.Text,
ts: m.TimeStamp,
threadTS: m.ThreadTimeStamp,
chatType: channel.ChatTypeGroup,
addressed: true,
}), true
}
type buildInboundParams struct {
eventType string
subType string
channelID string
userID string
text string
ts string
threadTS string
chatType channel.ChatType
addressed bool
}
func (c *slackChannel) buildInbound(e slackevents.EventsAPIEvent, p buildInboundParams) channel.InboundMessage {
teamID := e.TeamID
if teamID == "" {
teamID = c.creds.TeamID
}
raw, _ := json.Marshal(slackRawEvent{
TeamID: teamID,
APIAppID: e.APIAppID,
EventType: p.eventType,
SubType: p.subType,
ChannelType: string(p.chatType),
})
var reply *channel.ReplyCtx
if p.threadTS != "" && p.threadTS != p.ts {
reply = &channel.ReplyCtx{MessageID: p.threadTS, RootID: p.threadTS}
}
return channel.InboundMessage{
EventID: p.ts,
MessageID: p.ts,
Type: channel.MsgTypeText,
Text: c.cleanText(p.text),
ReplyTo: reply,
AddressedToBot: p.addressed,
Source: channel.Source{
ChannelType: TypeSlack,
ChatID: p.channelID,
ChatType: p.chatType,
SenderID: p.userID,
ThreadID: p.threadTS,
},
Raw: raw,
}
}
// slackRawEvent carries the Slack-specific fields the cross-platform envelope
// does not — read back only inside the Slack resolvers (team_id routes the
// installation; the core never reads Raw).
type slackRawEvent struct {
TeamID string `json:"team_id"`
APIAppID string `json:"api_app_id,omitempty"`
EventType string `json:"event_type"`
SubType string `json:"subtype,omitempty"`
ChannelType string `json:"channel_type,omitempty"`
}
// cleanText strips a leading/embedded bot mention token and trims surrounding
// whitespace so the core sees the user's actual prompt, not "<@U123> hi".
func (c *slackChannel) cleanText(text string) string {
if c.mentionRe != nil {
text = c.mentionRe.ReplaceAllString(text, "")
}
return strings.TrimSpace(text)
}
// mentionsBot reports whether text contains an @-mention of this bot. Slack
// renders a mention as <@U123> or <@U123|name>.
func (c *slackChannel) mentionsBot(text string) bool {
return c.mentionRe != nil && c.mentionRe.MatchString(text)
}
// ---- helpers ----
// slackChatType maps a Slack channel id / channel_type to the normalized
// ChatType. Only a 1:1 direct message ("im", or a "D…" channel id) is p2p;
// everything else — public/private channels AND multi-party DMs ("mpim", which
// are multi-person conversations) — is a group. A group routes through the
// engine's "must address the bot" filter, so plain chatter in a multi-party DM
// is not mistaken for a prompt to the bot.
func slackChatType(channelID, channelType string) channel.ChatType {
switch channelType {
case "im":
return channel.ChatTypeP2P
case "mpim", "channel", "group", "private_channel":
return channel.ChatTypeGroup
}
if strings.HasPrefix(channelID, "D") {
return channel.ChatTypeP2P
}
return channel.ChatTypeGroup
}
// isIngestableSubtype reports whether a message subtype is a brand-new user
// message the core should ingest. Empty subtype is the normal case;
// thread_broadcast and file_share are real user messages; everything else
// (message_changed, message_deleted, channel_join, …) is a system/edit event.
func isIngestableSubtype(subType string) bool {
switch subType {
case "", "thread_broadcast", "file_share":
return true
default:
return false
// newSlackSender builds a Send-only client from decoded credentials and a
// configured API client. Kept separate from the outbound subscriber so tests
// can inject a client pointed at an httptest server.
func newSlackSender(creds credentials, api *slack.Client, logger *slog.Logger) *slackSender {
if logger == nil {
logger = slog.Default()
}
return &slackSender{creds: creds, api: api, logger: logger}
}
// outboundThreadTS picks the Slack thread_ts for an outbound reply: an explicit
@@ -394,59 +99,3 @@ func chunkMessage(text string, maxRunes int) []string {
}
return chunks
}
// ---- registration ----
// SlackChannelDeps bundles the shared dependencies the Slack Factory closes
// over. The inbound handler is supplied per-build by the engine via
// channel.Config.Handler, mirroring FeishuChannelDeps.
type SlackChannelDeps struct {
// Decrypt turns the stored bot/app token ciphertext into plaintext. A nil
// Decrypter treats stored tokens as plaintext (tests / un-encrypted dev).
Decrypt Decrypter
Logger *slog.Logger
}
// RegisterSlack registers the Slack Factory on reg under TypeSlack so the
// engine.Supervisor can build a slackChannel per installation. "Adding a
// channel" is this call plus the adapter — no engine edit.
func RegisterSlack(reg *channel.Registry, deps SlackChannelDeps) {
reg.Register(TypeSlack, newSlackFactory(deps))
}
func newSlackFactory(deps SlackChannelDeps) channel.Factory {
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return func(cfg channel.Config) (channel.Channel, error) {
creds, err := decodeCredentials(cfg.Raw, deps.Decrypt)
if err != nil {
return nil, err
}
if creds.BotToken == "" || creds.AppToken == "" {
return nil, errors.New("slack: installation config missing bot or app token")
}
return newSlackChannel(creds, slack.New(creds.BotToken, slack.OptionAppLevelToken(creds.AppToken)), cfg.Handler, logger), nil
}
}
// newSlackChannel builds a slackChannel from decoded credentials and a
// configured API client. Kept separate from the Factory so tests can inject a
// client pointed at an httptest server.
func newSlackChannel(creds credentials, api *slack.Client, handler channel.InboundHandler, logger *slog.Logger) *slackChannel {
if logger == nil {
logger = slog.Default()
}
var mentionRe *regexp.Regexp
if creds.BotUserID != "" {
mentionRe = regexp.MustCompile(`<@` + regexp.QuoteMeta(creds.BotUserID) + `(\|[^>]*)?>`)
}
return &slackChannel{
creds: creds,
api: api,
handler: handler,
logger: logger,
mentionRe: mentionRe,
}
}

View File

@@ -2,285 +2,16 @@ package slack
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/multica-ai/multica/server/internal/integrations/channel"
)
func testChannel(botUserID string) *slackChannel {
return newSlackChannel(credentials{TeamID: "T1", BotUserID: botUserID}, nil, nil, nil)
}
func eventsAPI(inner any) slackevents.EventsAPIEvent {
return slackevents.EventsAPIEvent{
TeamID: "T1",
APIAppID: "A1",
InnerEvent: slackevents.EventsAPIInnerEvent{
Data: inner,
},
}
}
func TestInboundFromMessage_DM(t *testing.T) {
c := testChannel("UBOT")
e := eventsAPI(nil)
msg, ok := c.inboundFromMessage(e, &slackevents.MessageEvent{
User: "UALICE",
Text: "hello bot",
Channel: "D123",
ChannelType: "im",
TimeStamp: "1700000000.000100",
})
if !ok {
t.Fatal("expected DM message to be ingestable")
}
if msg.Source.ChatType != channel.ChatTypeP2P {
t.Errorf("ChatType = %q, want p2p", msg.Source.ChatType)
}
if !msg.AddressedToBot {
t.Error("DM should always be addressed to bot")
}
if msg.Source.ChannelType != TypeSlack {
t.Errorf("ChannelType = %q, want slack", msg.Source.ChannelType)
}
if msg.MessageID != "1700000000.000100" || msg.EventID != msg.MessageID {
t.Errorf("MessageID/EventID = %q/%q, want the ts", msg.MessageID, msg.EventID)
}
if msg.Source.SenderID != "UALICE" || msg.Source.ChatID != "D123" {
t.Errorf("sender/chat = %q/%q", msg.Source.SenderID, msg.Source.ChatID)
}
if msg.Text != "hello bot" {
t.Errorf("Text = %q", msg.Text)
}
// team_id must be in Raw so the installation resolver can route.
var raw slackRawEvent
if err := json.Unmarshal(msg.Raw, &raw); err != nil {
t.Fatalf("decode raw: %v", err)
}
if raw.TeamID != "T1" || raw.EventType != "message" {
t.Errorf("raw = %+v", raw)
}
}
func TestInboundFromMessage_ChannelMention(t *testing.T) {
c := testChannel("UBOT")
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
User: "UALICE",
Text: "<@UBOT> create an issue",
Channel: "C123",
ChannelType: "channel",
TimeStamp: "1700000000.000200",
})
if !ok {
t.Fatal("expected channel message to be ingestable")
}
if msg.Source.ChatType != channel.ChatTypeGroup {
t.Errorf("ChatType = %q, want group", msg.Source.ChatType)
}
if !msg.AddressedToBot {
t.Error("channel message mentioning the bot should be addressed to bot")
}
if msg.Text != "create an issue" {
t.Errorf("Text = %q, want mention stripped", msg.Text)
}
}
func TestInboundFromMessage_ChannelNoMention(t *testing.T) {
c := testChannel("UBOT")
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
User: "UALICE",
Text: "just chatting with the team",
Channel: "C123",
ChannelType: "channel",
TimeStamp: "1700000000.000300",
})
if !ok {
t.Fatal("a non-mention channel message is still ingested; the engine group filter drops it")
}
if msg.AddressedToBot {
t.Error("channel message without a mention must not be addressed to bot")
}
}
func TestInboundFromMessage_ThreadReply(t *testing.T) {
c := testChannel("UBOT")
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
User: "UALICE",
Text: "<@UBOT> follow up",
Channel: "C123",
ChannelType: "channel",
TimeStamp: "1700000000.000500",
ThreadTimeStamp: "1700000000.000400",
})
if !ok {
t.Fatal("thread reply should be ingestable")
}
if msg.Source.ThreadID != "1700000000.000400" {
t.Errorf("ThreadID = %q", msg.Source.ThreadID)
}
if msg.ReplyTo == nil || msg.ReplyTo.MessageID != "1700000000.000400" {
t.Errorf("ReplyTo = %+v, want the thread root", msg.ReplyTo)
}
}
func TestInboundFromMessage_SkipsBotAndOwnAndEdits(t *testing.T) {
c := testChannel("UBOT")
cases := []struct {
name string
m *slackevents.MessageEvent
}{
{"own message", &slackevents.MessageEvent{User: "UBOT", Text: "hi", Channel: "D1", ChannelType: "im", TimeStamp: "1.1"}},
{"other bot", &slackevents.MessageEvent{User: "UX", BotID: "B1", Text: "hi", Channel: "C1", TimeStamp: "1.2"}},
{"bot_message subtype", &slackevents.MessageEvent{SubType: "bot_message", Text: "hi", Channel: "C1", TimeStamp: "1.3"}},
{"edit", &slackevents.MessageEvent{User: "UALICE", SubType: "message_changed", Text: "hi", Channel: "C1", TimeStamp: "1.4"}},
{"delete", &slackevents.MessageEvent{User: "UALICE", SubType: "message_deleted", Channel: "C1", TimeStamp: "1.5"}},
{"empty user", &slackevents.MessageEvent{Text: "hi", Channel: "C1", TimeStamp: "1.6"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, ok := c.inboundFromMessage(eventsAPI(nil), tc.m); ok {
t.Errorf("%s should not be ingested", tc.name)
}
})
}
}
func TestInboundFromAppMention(t *testing.T) {
c := testChannel("UBOT")
msg, ok := c.inboundFromAppMention(eventsAPI(nil), &slackevents.AppMentionEvent{
User: "UALICE",
Text: "<@UBOT> hi",
Channel: "C123",
TimeStamp: "1700000000.000700",
})
if !ok {
t.Fatal("app_mention should be ingestable")
}
if msg.Source.ChatType != channel.ChatTypeGroup || !msg.AddressedToBot {
t.Errorf("app_mention must be a group message addressed to bot: %+v", msg.Source)
}
if msg.Text != "hi" {
t.Errorf("Text = %q, want mention stripped", msg.Text)
}
// The bot's own app_mention echo (BotID set) must be skipped.
if _, ok := c.inboundFromAppMention(eventsAPI(nil), &slackevents.AppMentionEvent{User: "UBOT", Channel: "C1", TimeStamp: "1.9"}); ok {
t.Error("bot's own mention should be skipped")
}
}
func TestCapabilitiesAndType(t *testing.T) {
c := testChannel("UBOT")
if c.Type() != TypeSlack {
t.Errorf("Type = %q", c.Type())
}
caps := c.Capabilities()
if !caps.Has(channel.CapText) || !caps.Has(channel.CapThreadReply) {
t.Errorf("capabilities = %s, want text + thread_reply", caps)
}
// Capabilities the Send path cannot fulfil yet must NOT be declared.
for _, cap := range []channel.Capability{channel.CapRichCard, channel.CapAttachment, channel.CapMessageEdit} {
if caps.Has(cap) {
t.Errorf("capability %s must not be declared until implemented", cap)
}
}
}
func TestSlackChatType(t *testing.T) {
cases := []struct {
channelID, channelType string
want channel.ChatType
}{
{"D123", "im", channel.ChatTypeP2P},
{"G123", "mpim", channel.ChatTypeGroup}, // multi-party DM is a group
{"C123", "channel", channel.ChatTypeGroup},
{"C123", "private_channel", channel.ChatTypeGroup},
{"D999", "", channel.ChatTypeP2P}, // fallback by id prefix
{"C999", "", channel.ChatTypeGroup},
}
for _, tc := range cases {
if got := slackChatType(tc.channelID, tc.channelType); got != tc.want {
t.Errorf("slackChatType(%q,%q) = %q, want %q", tc.channelID, tc.channelType, got, tc.want)
}
}
}
func TestMpimRequiresMention(t *testing.T) {
c := testChannel("UBOT")
// A multi-party DM is a group: plain chatter must NOT be addressed to bot.
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
User: "UALICE", Text: "team lunch?", Channel: "G123", ChannelType: "mpim", TimeStamp: "1.1",
})
if !ok {
t.Fatal("mpim message should still be ingested (engine group filter decides)")
}
if msg.Source.ChatType != channel.ChatTypeGroup {
t.Errorf("mpim ChatType = %q, want group", msg.Source.ChatType)
}
if msg.AddressedToBot {
t.Error("plain mpim chatter must not be addressed to bot")
}
}
func TestDispatchEventsAPI_PropagatesHandlerError(t *testing.T) {
wantErr := errors.New("db down")
calls := 0
c := newSlackChannel(credentials{TeamID: "T1", BotUserID: "UBOT"}, nil, func(_ context.Context, _ channel.InboundMessage) error {
calls++
return wantErr
}, nil)
e := eventsAPI(&slackevents.MessageEvent{User: "UALICE", Text: "hi", Channel: "D1", ChannelType: "im", TimeStamp: "1.1"})
if err := c.dispatchEventsAPI(context.Background(), e); !errors.Is(err, wantErr) {
t.Errorf("dispatchEventsAPI error = %v, want %v (infra error must propagate to Connect→Supervisor)", err, wantErr)
}
if calls != 1 {
t.Errorf("handler called %d times, want 1", calls)
}
// A non-ingestable event (the bot's own message) must not reach the handler
// and must not error.
calls = 0
skip := eventsAPI(&slackevents.MessageEvent{User: "UBOT", Text: "echo", Channel: "D1", ChannelType: "im", TimeStamp: "1.2"})
if err := c.dispatchEventsAPI(context.Background(), skip); err != nil {
t.Errorf("skipped event should not error: %v", err)
}
if calls != 0 {
t.Errorf("handler called %d times for skipped event, want 0", calls)
}
}
func TestDecodeCredentials(t *testing.T) {
// app_id holds the team_id routing key; tokens stored as base64 plaintext
// here (nil Decrypter = identity).
raw := json.RawMessage(`{
"app_id": "T1",
"bot_user_id": "UBOT",
"bot_token_encrypted": "eG94Yi1ib3Q=",
"app_token_encrypted": "eGFwcC1hcHA="
}`)
creds, err := decodeCredentials(raw, nil)
if err != nil {
t.Fatalf("decodeCredentials: %v", err)
}
if creds.TeamID != "T1" || creds.BotUserID != "UBOT" {
t.Errorf("creds = %+v", creds)
}
if creds.BotToken != "xoxb-bot" || creds.AppToken != "xapp-app" {
t.Errorf("tokens = %q / %q", creds.BotToken, creds.AppToken)
}
if _, err := decodeCredentials(nil, nil); err == nil {
t.Error("empty config should error")
}
}
func TestChunkMessage(t *testing.T) {
if got := chunkMessage("short", 100); len(got) != 1 || got[0] != "short" {
t.Errorf("short message should be one chunk: %v", got)
@@ -309,7 +40,7 @@ func TestSend(t *testing.T) {
defer srv.Close()
api := slack.New("xoxb-test", slack.OptionAPIURL(srv.URL+"/"))
c := newSlackChannel(credentials{TeamID: "T1"}, api, nil, nil)
c := newSlackSender(credentials{TeamID: "T1"}, api, nil)
res, err := c.Send(context.Background(), channel.OutboundMessage{
ChatID: "C123",
@@ -344,7 +75,7 @@ func TestSend_AppliesMrkdwn(t *testing.T) {
defer srv.Close()
api := slack.New("xoxb-test", slack.OptionAPIURL(srv.URL+"/"))
c := newSlackChannel(credentials{TeamID: "T1"}, api, nil, nil)
c := newSlackSender(credentials{TeamID: "T1"}, api, nil)
if _, err := c.Send(context.Background(), channel.OutboundMessage{
ChatID: "C1",

View File

@@ -1,13 +1,16 @@
// Package slack is the Slack implementation of channel.Channel — the second
// adapter driven by the channel-agnostic engine (MUL-3516), proving the
// MUL-3506 thesis that adding an IM is "implement Channel + register" with no
// engine, core, or channel_* schema change. It mirrors the Feishu reference
// adapter (server/internal/integrations/lark/feishu_channel.go): Connect runs
// the platform receive loop (here Slack Socket Mode, an outbound WebSocket
// long-conn that needs no public inbound URL) and hands every decoded event to
// the engine's shared inbound handler as a normalized channel.InboundMessage;
// Send posts a text reply via chat.postMessage. The design references the
// proven Slack adapter in Nous Research's Hermes Agent.
// Package slack is the Slack integration for the channel-agnostic engine. It
// uses the bring-your-own-app (BYO) model (MUL-3666): each agent's Slack app is
// created and installed by the workspace admin, who pastes its bot token (xoxb-)
// and app-level token (xapp-) into Multica. Each channel_installation therefore
// carries its OWN app-level token and gets its OWN Socket Mode connection,
// supervised per-installation by the engine like Feishu (slack_channel.go) — so
// several agents can each have a distinct bot identity in one Slack workspace.
// Installations are keyed and routed by the real Slack app id
// (config->>'app_id' == the inbound event's api_app_id). The inbound translation
// (Events API payload -> channel.InboundMessage) lives in inbound.go; the
// outbound reply path (chat.postMessage with Markdown->mrkdwn + threading) lives
// in channel.go. The design references the proven Slack adapter in Nous
// Research's Hermes Agent.
package slack
import (
@@ -22,25 +25,26 @@ import (
// Slack installation. The cross-platform columns stay flat; everything
// Slack-specific lives in this opaque blob (the documented config boundary).
//
// app_id holds the Slack team_id — the per-installation routing key — so the
// generic GetChannelInstallationByAppID query (which reads config->>'app_id')
// and the (channel_type, config->>'app_id') unique index route Slack inbound
// events with NO new query and NO schema change. team_id is also kept as its
// own field for readability; the two carry the same value.
// app_id holds the REAL Slack app id (parsed from the xapp- token). It is the
// per-installation routing key: the generic GetChannelInstallationByAppID query
// (config->>'app_id') and the (channel_type, app_id) unique index map an inbound
// event's api_app_id to its installation, so several apps — several agents — in
// one Slack workspace stay distinct. team_id is kept for display only.
//
// Tokens are stored as base64-encoded secretbox ciphertext (never plaintext),
// mirroring Feishu's app_secret_encrypted. The bot token (xoxb-…) authorizes
// Web API calls (chat.postMessage); the app-level token (xapp-…) authorizes the
// Socket Mode connection.
// bot_token_encrypted (xoxb-, outbound Web API: chat.postMessage) and
// app_token_encrypted (xapp-, this installation's own Socket Mode connection)
// are both stored as base64-encoded secretbox ciphertext, never plaintext
// (mirroring Feishu's app_secret_encrypted). Both are pasted by the admin at
// BYO install time.
type installConfig struct {
AppID string `json:"app_id"`
TeamID string `json:"team_id,omitempty"`
BotUserID string `json:"bot_user_id,omitempty"`
BotTokenEncrypted string `json:"bot_token_encrypted"`
AppTokenEncrypted string `json:"app_token_encrypted"`
AppTokenEncrypted string `json:"app_token_encrypted,omitempty"`
}
// credentials is the decoded, decrypted form the adapter runs on. The
// credentials is the decoded, decrypted form the outbound sender runs on. The
// installation IDENTITY (workspace / agent / installer) is deliberately absent:
// it is resolved per message by the Router's InstallationResolver, exactly as
// the Feishu adapter does.
@@ -48,7 +52,6 @@ type credentials struct {
TeamID string
BotUserID string
BotToken string
AppToken string
}
// Decrypter turns stored ciphertext into plaintext. The wiring injects a
@@ -70,10 +73,6 @@ func decodeCredentials(raw json.RawMessage, decrypt Decrypter) (credentials, err
if err != nil {
return credentials{}, fmt.Errorf("decrypt bot token: %w", err)
}
appToken, err := decryptToken(cfg.AppTokenEncrypted, decrypt)
if err != nil {
return credentials{}, fmt.Errorf("decrypt app token: %w", err)
}
teamID := cfg.TeamID
if teamID == "" {
teamID = cfg.AppID
@@ -82,10 +81,30 @@ func decodeCredentials(raw json.RawMessage, decrypt Decrypter) (credentials, err
TeamID: teamID,
BotUserID: cfg.BotUserID,
BotToken: botToken,
AppToken: appToken,
}, nil
}
// PublicConfig is the non-secret subset of an installation config, safe to
// surface on the management API (the encrypted bot token is never included).
type PublicConfig struct {
AppID string
TeamID string
BotUserID string
}
// DecodePublicConfig extracts the display-safe fields from a stored config blob.
// A decode miss yields a zero-value PublicConfig rather than an error: the
// management list should still render the row's identity columns.
func DecodePublicConfig(raw json.RawMessage) PublicConfig {
var cfg installConfig
_ = json.Unmarshal(raw, &cfg)
teamID := cfg.TeamID
if teamID == "" {
teamID = cfg.AppID
}
return PublicConfig{AppID: cfg.AppID, TeamID: teamID, BotUserID: cfg.BotUserID}
}
// decryptToken base64-decodes the stored ciphertext (tolerating the MIME
// newline wrapping PostgreSQL's encode(...,'base64') emits) and runs it through
// the injected Decrypter. An empty stored value decodes to an empty token; a

View File

@@ -0,0 +1,187 @@
package slack
import (
"encoding/json"
"regexp"
"strings"
"github.com/slack-go/slack/slackevents"
"github.com/multica-ai/multica/server/internal/integrations/channel"
)
// This file holds the platform-neutral translation from a Slack Events API
// payload to the engine's normalized channel.InboundMessage. These are free
// functions parameterized by the bot identity rather than methods on the
// channel, so the per-installation Socket Mode connection (slack_channel.go)
// threads in its own installed bot's user id when translating each event.
// slackRawEvent carries the Slack-specific fields the cross-platform envelope
// does not — read back only inside the Slack resolvers (team_id routes the
// installation; the core never reads Raw).
type slackRawEvent struct {
TeamID string `json:"team_id"`
APIAppID string `json:"api_app_id,omitempty"`
EventType string `json:"event_type"`
SubType string `json:"subtype,omitempty"`
ChannelType string `json:"channel_type,omitempty"`
}
// compileMentionRe builds the regexp that matches an @-mention of botUserID.
// Slack renders a mention as <@U123> or <@U123|name>. An empty botUserID
// (installation not found / not yet known) yields nil — mention detection is
// then a no-op, which is safe: DMs and app_mention events do not rely on it,
// and an un-routable team is dropped at installation resolution anyway.
func compileMentionRe(botUserID string) *regexp.Regexp {
if botUserID == "" {
return nil
}
return regexp.MustCompile(`<@` + regexp.QuoteMeta(botUserID) + `(\|[^>]*)?>`)
}
// inboundFromMessage normalizes a Slack message event. It returns ok=false for
// events that must not reach the core: the bot's own messages and other bots'
// messages (loop guard), and edits/deletes/joins and similar subtyped system
// messages (only brand-new user messages are ingested).
//
// Group addressing policy (v1, deliberate): a group message is addressed to the
// bot only when it carries an explicit <@bot> mention. Mention-free follow-ups
// inside a thread the bot is already engaged in are NOT auto-addressed here:
// "reply to a bot message" is session state, so it belongs in the session-aware
// shared service / resolver layer rather than in per-connection adapter memory.
// Until that lands, channel/thread continuation requires re-mentioning the bot.
// P2P (DM) ingests every message, unchanged.
func inboundFromMessage(e slackevents.EventsAPIEvent, m *slackevents.MessageEvent, botUserID string, mentionRe *regexp.Regexp) (channel.InboundMessage, bool) {
if m.BotID != "" || m.SubType == "bot_message" {
return channel.InboundMessage{}, false
}
if m.User == "" || (botUserID != "" && m.User == botUserID) {
return channel.InboundMessage{}, false
}
if !isIngestableSubtype(m.SubType) {
return channel.InboundMessage{}, false
}
chatType := slackChatType(m.Channel, m.ChannelType)
addressed := chatType == channel.ChatTypeP2P || mentionsBot(m.Text, mentionRe)
return buildInbound(e, buildInboundParams{
eventType: "message",
subType: m.SubType,
channelID: m.Channel,
userID: m.User,
text: m.Text,
ts: m.TimeStamp,
threadTS: m.ThreadTimeStamp,
chatType: chatType,
addressed: addressed,
}, mentionRe), true
}
// inboundFromAppMention normalizes an app_mention event. An app_mention is, by
// definition, addressed to the bot and occurs in a channel (group). The same
// channel @mention also arrives as a message event with the identical ts, so
// the engine's (installation, message_id=ts) dedup collapses the pair — no
// special-casing needed here.
func inboundFromAppMention(e slackevents.EventsAPIEvent, m *slackevents.AppMentionEvent, botUserID string, mentionRe *regexp.Regexp) (channel.InboundMessage, bool) {
if m.BotID != "" || m.User == "" || (botUserID != "" && m.User == botUserID) {
return channel.InboundMessage{}, false
}
return buildInbound(e, buildInboundParams{
eventType: "app_mention",
channelID: m.Channel,
userID: m.User,
text: m.Text,
ts: m.TimeStamp,
threadTS: m.ThreadTimeStamp,
chatType: channel.ChatTypeGroup,
addressed: true,
}, mentionRe), true
}
type buildInboundParams struct {
eventType string
subType string
channelID string
userID string
text string
ts string
threadTS string
chatType channel.ChatType
addressed bool
}
func buildInbound(e slackevents.EventsAPIEvent, p buildInboundParams, mentionRe *regexp.Regexp) channel.InboundMessage {
raw, _ := json.Marshal(slackRawEvent{
TeamID: e.TeamID,
APIAppID: e.APIAppID,
EventType: p.eventType,
SubType: p.subType,
ChannelType: string(p.chatType),
})
var reply *channel.ReplyCtx
if p.threadTS != "" && p.threadTS != p.ts {
reply = &channel.ReplyCtx{MessageID: p.threadTS, RootID: p.threadTS}
}
return channel.InboundMessage{
EventID: p.ts,
MessageID: p.ts,
Type: channel.MsgTypeText,
Text: cleanText(p.text, mentionRe),
ReplyTo: reply,
AddressedToBot: p.addressed,
Source: channel.Source{
ChannelType: TypeSlack,
ChatID: p.channelID,
ChatType: p.chatType,
SenderID: p.userID,
ThreadID: p.threadTS,
},
Raw: raw,
}
}
// cleanText strips a leading/embedded bot mention token and trims surrounding
// whitespace so the core sees the user's actual prompt, not "<@U123> hi".
func cleanText(text string, mentionRe *regexp.Regexp) string {
if mentionRe != nil {
text = mentionRe.ReplaceAllString(text, "")
}
return strings.TrimSpace(text)
}
// mentionsBot reports whether text contains an @-mention of this bot.
func mentionsBot(text string, mentionRe *regexp.Regexp) bool {
return mentionRe != nil && mentionRe.MatchString(text)
}
// slackChatType maps a Slack channel id / channel_type to the normalized
// ChatType. Only a 1:1 direct message ("im", or a "D…" channel id) is p2p;
// everything else — public/private channels AND multi-party DMs ("mpim", which
// are multi-person conversations) — is a group. A group routes through the
// engine's "must address the bot" filter, so plain chatter in a multi-party DM
// is not mistaken for a prompt to the bot.
func slackChatType(channelID, channelType string) channel.ChatType {
switch channelType {
case "im":
return channel.ChatTypeP2P
case "mpim", "channel", "group", "private_channel":
return channel.ChatTypeGroup
}
if strings.HasPrefix(channelID, "D") {
return channel.ChatTypeP2P
}
return channel.ChatTypeGroup
}
// isIngestableSubtype reports whether a message subtype is a brand-new user
// message the core should ingest. Empty subtype is the normal case;
// thread_broadcast and file_share are real user messages; everything else
// (message_changed, message_deleted, channel_join, …) is a system/edit event.
func isIngestableSubtype(subType string) bool {
switch subType {
case "", "thread_broadcast", "file_share":
return true
default:
return false
}
}

View File

@@ -0,0 +1,228 @@
package slack
import (
"encoding/json"
"testing"
"github.com/slack-go/slack/slackevents"
"github.com/multica-ai/multica/server/internal/integrations/channel"
)
func eventsAPI(inner any) slackevents.EventsAPIEvent {
return slackevents.EventsAPIEvent{
TeamID: "T1",
APIAppID: "A1",
InnerEvent: slackevents.EventsAPIInnerEvent{
Data: inner,
},
}
}
// translateMessage runs the message-event translation as the AppConnector does:
// resolve the team's bot user id, then normalize.
func translateMessage(botUserID string, e slackevents.EventsAPIEvent, m *slackevents.MessageEvent) (channel.InboundMessage, bool) {
return inboundFromMessage(e, m, botUserID, compileMentionRe(botUserID))
}
func translateAppMention(botUserID string, e slackevents.EventsAPIEvent, m *slackevents.AppMentionEvent) (channel.InboundMessage, bool) {
return inboundFromAppMention(e, m, botUserID, compileMentionRe(botUserID))
}
func TestInboundFromMessage_DM(t *testing.T) {
msg, ok := translateMessage("UBOT", eventsAPI(nil), &slackevents.MessageEvent{
User: "UALICE",
Text: "hello bot",
Channel: "D123",
ChannelType: "im",
TimeStamp: "1700000000.000100",
})
if !ok {
t.Fatal("expected DM message to be ingestable")
}
if msg.Source.ChatType != channel.ChatTypeP2P {
t.Errorf("ChatType = %q, want p2p", msg.Source.ChatType)
}
if !msg.AddressedToBot {
t.Error("DM should always be addressed to bot")
}
if msg.Source.ChannelType != TypeSlack {
t.Errorf("ChannelType = %q, want slack", msg.Source.ChannelType)
}
if msg.MessageID != "1700000000.000100" || msg.EventID != msg.MessageID {
t.Errorf("MessageID/EventID = %q/%q, want the ts", msg.MessageID, msg.EventID)
}
if msg.Source.SenderID != "UALICE" || msg.Source.ChatID != "D123" {
t.Errorf("sender/chat = %q/%q", msg.Source.SenderID, msg.Source.ChatID)
}
if msg.Text != "hello bot" {
t.Errorf("Text = %q", msg.Text)
}
// team_id must be in Raw so the installation resolver can route.
var raw slackRawEvent
if err := json.Unmarshal(msg.Raw, &raw); err != nil {
t.Fatalf("decode raw: %v", err)
}
if raw.TeamID != "T1" || raw.EventType != "message" {
t.Errorf("raw = %+v", raw)
}
}
func TestInboundFromMessage_ChannelMention(t *testing.T) {
msg, ok := translateMessage("UBOT", eventsAPI(nil), &slackevents.MessageEvent{
User: "UALICE",
Text: "<@UBOT> create an issue",
Channel: "C123",
ChannelType: "channel",
TimeStamp: "1700000000.000200",
})
if !ok {
t.Fatal("expected channel message to be ingestable")
}
if msg.Source.ChatType != channel.ChatTypeGroup {
t.Errorf("ChatType = %q, want group", msg.Source.ChatType)
}
if !msg.AddressedToBot {
t.Error("channel message mentioning the bot should be addressed to bot")
}
if msg.Text != "create an issue" {
t.Errorf("Text = %q, want mention stripped", msg.Text)
}
}
func TestInboundFromMessage_ChannelNoMention(t *testing.T) {
msg, ok := translateMessage("UBOT", eventsAPI(nil), &slackevents.MessageEvent{
User: "UALICE",
Text: "just chatting with the team",
Channel: "C123",
ChannelType: "channel",
TimeStamp: "1700000000.000300",
})
if !ok {
t.Fatal("a non-mention channel message is still ingested; the engine group filter drops it")
}
if msg.AddressedToBot {
t.Error("channel message without a mention must not be addressed to bot")
}
}
func TestInboundFromMessage_ThreadReply(t *testing.T) {
msg, ok := translateMessage("UBOT", eventsAPI(nil), &slackevents.MessageEvent{
User: "UALICE",
Text: "<@UBOT> follow up",
Channel: "C123",
ChannelType: "channel",
TimeStamp: "1700000000.000500",
ThreadTimeStamp: "1700000000.000400",
})
if !ok {
t.Fatal("thread reply should be ingestable")
}
if msg.Source.ThreadID != "1700000000.000400" {
t.Errorf("ThreadID = %q", msg.Source.ThreadID)
}
if msg.ReplyTo == nil || msg.ReplyTo.MessageID != "1700000000.000400" {
t.Errorf("ReplyTo = %+v, want the thread root", msg.ReplyTo)
}
}
func TestInboundFromMessage_SkipsBotAndOwnAndEdits(t *testing.T) {
cases := []struct {
name string
m *slackevents.MessageEvent
}{
{"own message", &slackevents.MessageEvent{User: "UBOT", Text: "hi", Channel: "D1", ChannelType: "im", TimeStamp: "1.1"}},
{"other bot", &slackevents.MessageEvent{User: "UX", BotID: "B1", Text: "hi", Channel: "C1", TimeStamp: "1.2"}},
{"bot_message subtype", &slackevents.MessageEvent{SubType: "bot_message", Text: "hi", Channel: "C1", TimeStamp: "1.3"}},
{"edit", &slackevents.MessageEvent{User: "UALICE", SubType: "message_changed", Text: "hi", Channel: "C1", TimeStamp: "1.4"}},
{"delete", &slackevents.MessageEvent{User: "UALICE", SubType: "message_deleted", Channel: "C1", TimeStamp: "1.5"}},
{"empty user", &slackevents.MessageEvent{Text: "hi", Channel: "C1", TimeStamp: "1.6"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, ok := translateMessage("UBOT", eventsAPI(nil), tc.m); ok {
t.Errorf("%s should not be ingested", tc.name)
}
})
}
}
func TestInboundFromAppMention(t *testing.T) {
msg, ok := translateAppMention("UBOT", eventsAPI(nil), &slackevents.AppMentionEvent{
User: "UALICE",
Text: "<@UBOT> hi",
Channel: "C123",
TimeStamp: "1700000000.000700",
})
if !ok {
t.Fatal("app_mention should be ingestable")
}
if msg.Source.ChatType != channel.ChatTypeGroup || !msg.AddressedToBot {
t.Errorf("app_mention must be a group message addressed to bot: %+v", msg.Source)
}
if msg.Text != "hi" {
t.Errorf("Text = %q, want mention stripped", msg.Text)
}
// The bot's own app_mention echo (BotID set) must be skipped.
if _, ok := translateAppMention("UBOT", eventsAPI(nil), &slackevents.AppMentionEvent{User: "UBOT", Channel: "C1", TimeStamp: "1.9"}); ok {
t.Error("bot's own mention should be skipped")
}
}
func TestSlackChatType(t *testing.T) {
cases := []struct {
channelID, channelType string
want channel.ChatType
}{
{"D123", "im", channel.ChatTypeP2P},
{"G123", "mpim", channel.ChatTypeGroup}, // multi-party DM is a group
{"C123", "channel", channel.ChatTypeGroup},
{"C123", "private_channel", channel.ChatTypeGroup},
{"D999", "", channel.ChatTypeP2P}, // fallback by id prefix
{"C999", "", channel.ChatTypeGroup},
}
for _, tc := range cases {
if got := slackChatType(tc.channelID, tc.channelType); got != tc.want {
t.Errorf("slackChatType(%q,%q) = %q, want %q", tc.channelID, tc.channelType, got, tc.want)
}
}
}
func TestMpimRequiresMention(t *testing.T) {
// A multi-party DM is a group: plain chatter must NOT be addressed to bot.
msg, ok := translateMessage("UBOT", eventsAPI(nil), &slackevents.MessageEvent{
User: "UALICE", Text: "team lunch?", Channel: "G123", ChannelType: "mpim", TimeStamp: "1.1",
})
if !ok {
t.Fatal("mpim message should still be ingested (engine group filter decides)")
}
if msg.Source.ChatType != channel.ChatTypeGroup {
t.Errorf("mpim ChatType = %q, want group", msg.Source.ChatType)
}
if msg.AddressedToBot {
t.Error("plain mpim chatter must not be addressed to bot")
}
}
func TestDecodeCredentials(t *testing.T) {
// app_id holds the team_id routing key; the bot token is stored as base64
// plaintext here (nil Decrypter = identity).
raw := json.RawMessage(`{
"app_id": "T1",
"bot_user_id": "UBOT",
"bot_token_encrypted": "eG94Yi1ib3Q="
}`)
creds, err := decodeCredentials(raw, nil)
if err != nil {
t.Fatalf("decodeCredentials: %v", err)
}
if creds.TeamID != "T1" || creds.BotUserID != "UBOT" {
t.Errorf("creds = %+v", creds)
}
if creds.BotToken != "xoxb-bot" {
t.Errorf("bot token = %q", creds.BotToken)
}
if _, err := decodeCredentials(nil, nil); err == nil {
t.Error("empty config should error")
}
}

View File

@@ -0,0 +1,200 @@
package slack
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
"github.com/multica-ai/multica/server/internal/util/secretbox"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// This file is the Slack install backend (MUL-3666). Slack uses the
// bring-your-own-app (BYO) model: the workspace admin creates their own Slack
// app, installs it to their Slack workspace, and pastes its bot token (xoxb-) +
// app-level token (xapp-) into Multica (the paste path lives in byo_install.go).
// The InstallService owns the at-rest encryption of those tokens — so no caller
// can write a channel_installation with a plaintext token — plus the shared
// persistInstall transaction and the list / get / revoke management surface.
var (
// ErrInstallationNotFound surfaces "no row matches in this workspace".
ErrInstallationNotFound = errors.New("slack installation not found")
// ErrTeamOwnedByAnotherWorkspace is returned when the pasted Slack app is
// already connected to a DIFFERENT agent or Multica workspace — it would
// collide with the (channel_type, app_id) routing index. A Slack app is one
// bot identity and maps to one agent; reusing it elsewhere requires
// disconnecting it there first.
ErrTeamOwnedByAnotherWorkspace = errors.New("slack: this Slack app is already connected to another agent or Multica workspace")
)
// installQueries is the slice of generated queries InstallService needs. WithTx
// returns the same interface bound to a transaction so persistInstall runs its
// upsert atomically (and so tests can inject a fake without a real DB).
type installQueries interface {
WithTx(tx pgx.Tx) installQueries
UpsertChannelInstallation(ctx context.Context, arg db.UpsertChannelInstallationParams) (db.ChannelInstallation, error)
ListChannelInstallationsByWorkspace(ctx context.Context, arg db.ListChannelInstallationsByWorkspaceParams) ([]db.ChannelInstallation, error)
GetChannelInstallationInWorkspace(ctx context.Context, arg db.GetChannelInstallationInWorkspaceParams) (db.ChannelInstallation, error)
SetChannelInstallationStatus(ctx context.Context, arg db.SetChannelInstallationStatusParams) error
}
// dbInstallQueries adapts *db.Queries to installQueries — the generated WithTx
// returns *db.Queries, so we wrap it to return the interface (the same adapter
// pattern engine.ChatSession uses).
type dbInstallQueries struct{ *db.Queries }
func (q dbInstallQueries) WithTx(tx pgx.Tx) installQueries {
return dbInstallQueries{q.Queries.WithTx(tx)}
}
// InstallService owns the at-rest encryption of the bot + app tokens (so no
// caller can write a channel_installation with a plaintext token) and the shared
// install transaction. The box MUST be non-nil (we refuse plaintext storage even
// in dev).
type InstallService struct {
box *secretbox.Box
q installQueries
tx engine.TxStarter
httpClient *http.Client
logger *slog.Logger
// apiURL overrides the Slack API base for the BYO auth.test call (tests point
// it at an httptest server). Empty uses the real Slack API.
apiURL string
}
// NewInstallService binds the service to queries, a tx starter (*pgxpool.Pool),
// and an encryption box. Listing / revoking and BYO register all require only
// the box (the at-rest key); there is no hosted OAuth credential.
func NewInstallService(q *db.Queries, tx engine.TxStarter, box *secretbox.Box, logger *slog.Logger) (*InstallService, error) {
if q == nil {
return nil, errors.New("slack: InstallService requires queries")
}
return newInstallService(dbInstallQueries{q}, tx, box, logger)
}
// newInstallService is the testable core: it takes the installQueries interface
// so tests can inject a fake (with a fake TxStarter) without a real DB.
func newInstallService(q installQueries, tx engine.TxStarter, box *secretbox.Box, logger *slog.Logger) (*InstallService, error) {
if box == nil {
return nil, errors.New("slack: InstallService requires a non-nil secretbox.Box")
}
if q == nil {
return nil, errors.New("slack: InstallService requires queries")
}
if tx == nil {
return nil, errors.New("slack: InstallService requires a tx starter")
}
if logger == nil {
logger = slog.Default()
}
return &InstallService{
box: box,
q: q,
tx: tx,
httpClient: http.DefaultClient,
logger: logger,
}, nil
}
// installPersist carries the resolved fields persistInstall writes. appIDKey is
// the value stored at config->>'app_id' — the real Slack app id — and MUST equal
// the app_id inside configJSON; it is the lookup / ON CONFLICT key. installerSlackID
// is the installer's Slack user id to auto-bind, or "" to skip (a BYO paste
// carries no authed_user, so the installer binds via the normal token flow on
// first message).
type installPersist struct {
wsID pgtype.UUID
agentID pgtype.UUID
installerID pgtype.UUID
// configJSON holds the Slack app id (config->>'app_id') used for inbound
// routing; the ROW itself is keyed by (workspace, agent) — one bot per agent.
configJSON []byte
}
// pgUniqueViolation is the Postgres SQLSTATE for a unique-constraint violation.
const pgUniqueViolation = "23505"
// persistInstall upserts the installation keyed by (workspace_id, agent_id,
// channel_type): ONE Slack bot per agent. Re-connecting an agent — including
// swapping it to a NEW Slack app after a disconnect — UPDATES that agent's row
// in place instead of colliding with the (workspace, agent, channel) unique.
//
// The (channel_type, app_id) routing index is the only OTHER unique constraint,
// and it is NOT this upsert's conflict target, so a unique violation here means
// the pasted Slack app is already connected to a DIFFERENT agent or Multica
// workspace — refuse it (ErrTeamOwnedByAnotherWorkspace) rather than steal it.
// No chat-session retire is needed: a row's agent_id never changes (it is part
// of the key), so existing sessions stay valid for the same agent.
func (s *InstallService) persistInstall(ctx context.Context, p installPersist) (db.ChannelInstallation, error) {
tx, err := s.tx.Begin(ctx)
if err != nil {
return db.ChannelInstallation{}, fmt.Errorf("begin install tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
qtx := s.q.WithTx(tx)
inst, err := qtx.UpsertChannelInstallation(ctx, db.UpsertChannelInstallationParams{
WorkspaceID: p.wsID,
AgentID: p.agentID,
ChannelType: string(TypeSlack),
Config: p.configJSON,
InstallerUserID: p.installerID,
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgUniqueViolation {
return db.ChannelInstallation{}, ErrTeamOwnedByAnotherWorkspace
}
return db.ChannelInstallation{}, fmt.Errorf("upsert slack installation: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return db.ChannelInstallation{}, fmt.Errorf("commit slack install: %w", err)
}
return inst, nil
}
// ListByWorkspace returns every Slack installation in the workspace (active and
// revoked), for the management surface.
func (s *InstallService) ListByWorkspace(ctx context.Context, wsID pgtype.UUID) ([]db.ChannelInstallation, error) {
return s.q.ListChannelInstallationsByWorkspace(ctx, db.ListChannelInstallationsByWorkspaceParams{
WorkspaceID: wsID,
ChannelType: string(TypeSlack),
})
}
// GetInWorkspace is the workspace-scoped lookup so a forged installation id from
// another workspace returns NotFound instead of leaking existence.
func (s *InstallService) GetInWorkspace(ctx context.Context, id, wsID pgtype.UUID) (db.ChannelInstallation, error) {
inst, err := s.q.GetChannelInstallationInWorkspace(ctx, db.GetChannelInstallationInWorkspaceParams{
ID: id,
WorkspaceID: wsID,
ChannelType: string(TypeSlack),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return db.ChannelInstallation{}, ErrInstallationNotFound
}
return db.ChannelInstallation{}, err
}
return inst, nil
}
// Revoke flips status to 'revoked'. The row is preserved for audit; a re-install
// flips it back to 'active'. The Supervisor stops supervising the installation
// (ListActiveInstallations filters to active), so its Socket Mode connection
// winds down, and outbound drops too.
func (s *InstallService) Revoke(ctx context.Context, id pgtype.UUID) error {
return s.q.SetChannelInstallationStatus(ctx, db.SetChannelInstallationStatusParams{
ID: id,
Status: "revoked",
})
}

View File

@@ -0,0 +1,109 @@
package slack
import (
"context"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
"github.com/multica-ai/multica/server/internal/util/secretbox"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func testBox(t *testing.T) *secretbox.Box {
t.Helper()
key := make([]byte, secretbox.KeySize)
for i := range key {
key[i] = byte(i + 1)
}
box, err := secretbox.New(key)
if err != nil {
t.Fatalf("secretbox.New: %v", err)
}
return box
}
func mustUUID(t *testing.T, s string) pgtype.UUID {
t.Helper()
u, err := util.ParseUUID(s)
if err != nil {
t.Fatalf("parse uuid %q: %v", s, err)
}
return u
}
type fakeInstallQueries struct {
// existing, when set, is the agent's current row; UpsertChannelInstallation
// returns it (an UPDATE) so a reconnect reuses the same row id.
existing *db.ChannelInstallation
// appIDTaken makes UpsertChannelInstallation report a unique-constraint
// violation on the (channel_type, app_id) routing index — i.e. the pasted app
// is already connected to another agent / workspace.
appIDTaken bool
upsertParams db.UpsertChannelInstallationParams
upsertCalled bool
rowID pgtype.UUID
}
// WithTx returns the same fake — the fake tx is a no-op token.
func (f *fakeInstallQueries) WithTx(_ pgx.Tx) installQueries { return f }
func (f *fakeInstallQueries) UpsertChannelInstallation(_ context.Context, arg db.UpsertChannelInstallationParams) (db.ChannelInstallation, error) {
f.upsertCalled = true
f.upsertParams = arg
if f.appIDTaken {
return db.ChannelInstallation{}, &pgconn.PgError{Code: "23505"}
}
id := f.rowID
if f.existing != nil {
id = f.existing.ID // reconnect updates the agent's existing row in place
}
return db.ChannelInstallation{
ID: id,
WorkspaceID: arg.WorkspaceID,
AgentID: arg.AgentID,
ChannelType: arg.ChannelType,
Config: arg.Config,
InstallerUserID: arg.InstallerUserID,
Status: "active",
}, nil
}
func (f *fakeInstallQueries) ListChannelInstallationsByWorkspace(_ context.Context, _ db.ListChannelInstallationsByWorkspaceParams) ([]db.ChannelInstallation, error) {
return nil, nil
}
func (f *fakeInstallQueries) GetChannelInstallationInWorkspace(_ context.Context, _ db.GetChannelInstallationInWorkspaceParams) (db.ChannelInstallation, error) {
return db.ChannelInstallation{}, nil
}
func (f *fakeInstallQueries) SetChannelInstallationStatus(_ context.Context, _ db.SetChannelInstallationStatusParams) error {
return nil
}
// fakeTx is a no-op pgx.Tx: embedding the interface satisfies it, and the
// install paths only ever call Commit / Rollback. committed records whether the
// install committed (the happy path) vs rolled back (a rejected install).
type fakeTx struct {
pgx.Tx
committed bool
}
func (t *fakeTx) Commit(context.Context) error { t.committed = true; return nil }
func (t *fakeTx) Rollback(context.Context) error { return nil }
type fakeTxStarter struct{ tx *fakeTx }
func (f *fakeTxStarter) Begin(context.Context) (pgx.Tx, error) { return f.tx, nil }
func newTestInstallService(t *testing.T, q installQueries) *InstallService {
t.Helper()
svc, err := newInstallService(q, &fakeTxStarter{tx: &fakeTx{}}, testBox(t), nil)
if err != nil {
t.Fatalf("newInstallService: %v", err)
}
return svc
}

View File

@@ -25,7 +25,7 @@ type outboundQueries interface {
GetChannelInstallation(ctx context.Context, arg db.GetChannelInstallationParams) (db.ChannelInstallation, error)
}
// replySender posts one reply. Satisfied by *slackChannel, so the outbound path
// replySender posts one reply. Satisfied by *slackSender, so the outbound path
// reuses Send's Markdown->mrkdwn conversion, chunking, and threading.
type replySender interface {
Send(ctx context.Context, out channel.OutboundMessage) (channel.SendResult, error)
@@ -52,9 +52,9 @@ func NewOutbound(q outboundQueries, decrypt Decrypter, logger *slog.Logger) *Out
}
o := &Outbound{q: q, decrypt: decrypt, logger: logger}
o.newSender = func(c credentials) replySender {
// Only the bot token is needed to post; the app token is a Socket Mode
// (inbound) credential.
return newSlackChannel(c, slack.New(c.BotToken), nil, logger)
// Only the bot token is needed to post; inbound Socket Mode uses the
// installation's separate app-level token (see slack_channel.go).
return newSlackSender(c, slack.New(c.BotToken), logger)
}
return o
}

View File

@@ -55,7 +55,6 @@ func slackInstallConfigJSON() []byte {
"app_id": "T1",
"bot_user_id": "UBOT",
"bot_token_encrypted": base64.StdEncoding.EncodeToString([]byte("xoxb-test")),
"app_token_encrypted": base64.StdEncoding.EncodeToString([]byte("xapp-test")),
})
return b
}

View File

@@ -0,0 +1,183 @@
package slack
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"github.com/slack-go/slack"
"github.com/multica-ai/multica/server/internal/integrations/channel"
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// This file is the Slack OutboundReplier — the engine seam that delivers a
// verdict-driven reply back to the user (MUL-3666, completing the stage-3
// Replier=nil tail). It posts through the same bot-token Send path as the
// EventChatDone outbound subscriber, so it needs no new transport.
//
// Outcomes handled:
// - NeedsBinding: the sender is unbound. Mint a single-use binding token and
// reply with a "link your account" prompt pointing at the in-product redeem
// page. After they bind, their next message reaches the agent.
// - AgentOffline / AgentArchived: a status notice so the user is not left
// wondering why nothing happened.
// - Ingested with an /issue created: a confirmation of the new issue.
const (
agentOfflineText = "⚠️ The agent is offline right now. Your message was received and will be handled once it's back online."
agentArchivedText = "⚠️ This agent has been archived and can't respond. Please contact your workspace admin."
)
// bindingMinter is the binding-token surface the replier needs.
// *BindingTokenService satisfies it.
type bindingMinter interface {
Mint(ctx context.Context, workspaceID, installationID pgtype.UUID, slackUserID string) (BindingToken, error)
}
// OutboundReplier implements engine.OutboundReplier for Slack.
type OutboundReplier struct {
binding bindingMinter
decrypt Decrypter
newSender func(creds credentials) replySender
publicURL string
bindingPath string
logger *slog.Logger
}
// OutboundReplierConfig configures the replier. Binding + PublicURL are required
// for the NeedsBinding prompt to work; without them the prompt is skipped (the
// offline/archived/issue notices still fire).
type OutboundReplierConfig struct {
Binding bindingMinter
Decrypt Decrypter
PublicURL string
BindingPath string // default "/slack/bind"
Logger *slog.Logger
}
var _ engine.OutboundReplier = (*OutboundReplier)(nil)
// NewOutboundReplier builds the replier. The sender factory mirrors the outbound
// subscriber: only the bot token is needed to post.
func NewOutboundReplier(cfg OutboundReplierConfig) *OutboundReplier {
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
bindingPath := cfg.BindingPath
if bindingPath == "" {
bindingPath = "/slack/bind"
}
if !strings.HasPrefix(bindingPath, "/") {
bindingPath = "/" + bindingPath
}
r := &OutboundReplier{
binding: cfg.Binding,
decrypt: cfg.Decrypt,
publicURL: strings.TrimRight(cfg.PublicURL, "/"),
bindingPath: bindingPath,
logger: logger,
}
r.newSender = func(c credentials) replySender {
return newSlackSender(c, slack.New(c.BotToken), logger)
}
return r
}
// Reply routes each outcome to its user-visible message. Errors are logged, not
// propagated: the replier runs detached from the inbound ACK path.
func (r *OutboundReplier) Reply(ctx context.Context, inst engine.ResolvedInstallation, msg channel.InboundMessage, res engine.Result) {
switch res.Outcome {
case engine.OutcomeNeedsBinding:
if err := r.sendBindingPrompt(ctx, inst, msg, res); err != nil {
r.logger.WarnContext(ctx, "slack replier: binding prompt failed",
"installation_id", util.UUIDToString(inst.ID), "error", err)
}
case engine.OutcomeAgentOffline:
if err := r.post(ctx, inst, msg, agentOfflineText); err != nil {
r.logger.WarnContext(ctx, "slack replier: offline notice failed",
"installation_id", util.UUIDToString(inst.ID), "error", err)
}
case engine.OutcomeAgentArchived:
if err := r.post(ctx, inst, msg, agentArchivedText); err != nil {
r.logger.WarnContext(ctx, "slack replier: archived notice failed",
"installation_id", util.UUIDToString(inst.ID), "error", err)
}
case engine.OutcomeIngested:
// Only a /issue-created message warrants a confirmation; a plain chat
// message stays silent (the agent's own reply lands via EventChatDone).
if res.IssueID.Valid {
if err := r.post(ctx, inst, msg, issueCreatedText(res)); err != nil {
r.logger.WarnContext(ctx, "slack replier: issue-created confirmation failed",
"installation_id", util.UUIDToString(inst.ID), "error", err)
}
}
}
}
func (r *OutboundReplier) sendBindingPrompt(ctx context.Context, inst engine.ResolvedInstallation, msg channel.InboundMessage, res engine.Result) error {
sender := res.Sender
if sender == "" {
sender = msg.Source.SenderID
}
if sender == "" {
return errors.New("missing sender id")
}
if r.binding == nil {
return errors.New("binding service not configured")
}
if r.publicURL == "" {
return errors.New("public url not configured")
}
token, err := r.binding.Mint(ctx, inst.WorkspaceID, inst.ID, sender)
if err != nil {
return fmt.Errorf("mint binding token: %w", err)
}
bindURL := r.publicURL + r.bindingPath + "?token=" + url.QueryEscape(token.Raw)
// Wrap the URL as an explicit Slack link <url|label>: formatMrkdwn protects
// these from its markdown passes, so the base64url token's `_`/`-` chars are
// not mangled into italics.
text := "👋 To start chatting with me, link your Slack account to Multica: <" +
bindURL + "|link your account>\n(This link expires in 15 minutes.)"
return r.post(ctx, inst, msg, text)
}
// post resolves the installation's bot token from the carried platform row and
// sends text back into the originating channel / thread.
func (r *OutboundReplier) post(ctx context.Context, inst engine.ResolvedInstallation, msg channel.InboundMessage, text string) error {
row, ok := inst.Platform.(db.ChannelInstallation)
if !ok {
return errors.New("installation platform row unavailable")
}
creds, err := decodeCredentials(row.Config, r.decrypt)
if err != nil {
return fmt.Errorf("decode credentials: %w", err)
}
if _, err := r.newSender(creds).Send(ctx, channel.OutboundMessage{
ChatID: msg.Source.ChatID,
Text: text,
ThreadID: msg.Source.ThreadID,
}); err != nil {
return fmt.Errorf("post slack reply: %w", err)
}
return nil
}
func issueCreatedText(res engine.Result) string {
id := res.IssueIdentifier
if id == "" {
id = fmt.Sprintf("#%d", res.IssueNumber)
}
title := strings.TrimSpace(res.IssueTitle)
if title == "" {
return "✅ Created " + id
}
return "✅ Created " + id + " — " + title
}

View File

@@ -0,0 +1,181 @@
package slack
import (
"context"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/integrations/channel"
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
type fakeReplySender struct {
sent *channel.OutboundMessage
calls int
}
func (f *fakeReplySender) Send(_ context.Context, out channel.OutboundMessage) (channel.SendResult, error) {
f.calls++
cp := out
f.sent = &cp
return channel.SendResult{MessageID: "1.1"}, nil
}
type fakeBindingMinter struct {
raw string
gotWS pgtype.UUID
gotInst pgtype.UUID
gotUser string
calls int
}
func (f *fakeBindingMinter) Mint(_ context.Context, ws, inst pgtype.UUID, user string) (BindingToken, error) {
f.calls++
f.gotWS, f.gotInst, f.gotUser = ws, inst, user
return BindingToken{Raw: f.raw, ExpiresAt: time.Unix(0, 0)}, nil
}
func newTestReplier(binding bindingMinter, sender replySender) *OutboundReplier {
r := NewOutboundReplier(OutboundReplierConfig{
Binding: binding,
Decrypt: nil, // identity: stored bot token is base64 plaintext
PublicURL: "https://multica.example",
})
r.newSender = func(credentials) replySender { return sender }
return r
}
// installConfigJSON with a base64 (identity-decryptable) bot token so
// decodeCredentials succeeds inside post().
const replierConfigJSON = `{"app_id":"T1","bot_user_id":"UBOT","bot_token_encrypted":"eG94Yi10ZXN0"}`
func testResolvedInstallation(t *testing.T) engine.ResolvedInstallation {
return engine.ResolvedInstallation{
ID: mustUUID(t, "44444444-4444-4444-4444-444444444444"),
WorkspaceID: mustUUID(t, "11111111-1111-1111-1111-111111111111"),
AgentID: mustUUID(t, "22222222-2222-2222-2222-222222222222"),
Active: true,
Platform: db.ChannelInstallation{Config: []byte(replierConfigJSON)},
}
}
func testInboundForReply() channel.InboundMessage {
return channel.InboundMessage{
MessageID: "1700000000.000300",
Source: channel.Source{
ChannelType: TypeSlack,
ChatID: "C1",
ChatType: channel.ChatTypeGroup,
SenderID: "UALICE",
ThreadID: "1700000000.000200",
},
}
}
func TestReply_NeedsBinding_MintsAndPostsPrompt(t *testing.T) {
sender := &fakeReplySender{}
minter := &fakeBindingMinter{raw: "tok_RAW-123"}
r := newTestReplier(minter, sender)
inst := testResolvedInstallation(t)
msg := testInboundForReply()
r.Reply(context.Background(), inst, msg, engine.Result{
Outcome: engine.OutcomeNeedsBinding,
Sender: "UALICE",
})
if minter.calls != 1 || minter.gotUser != "UALICE" {
t.Fatalf("Mint called %d times for user %q", minter.calls, minter.gotUser)
}
if minter.gotWS != inst.WorkspaceID || minter.gotInst != inst.ID {
t.Error("Mint must receive the resolved workspace + installation ids")
}
if sender.calls != 1 || sender.sent == nil {
t.Fatalf("expected one reply, got %d", sender.calls)
}
if sender.sent.ChatID != "C1" || sender.sent.ThreadID != "1700000000.000200" {
t.Errorf("reply target = %+v", sender.sent)
}
// The prompt must carry the redeem URL with the minted token, wrapped as a
// Slack link so formatMrkdwn does not mangle the base64url token.
wantLink := "<https://multica.example/slack/bind?token=tok_RAW-123|link your account>"
if !strings.Contains(sender.sent.Text, wantLink) {
t.Errorf("prompt text = %q, want it to contain %q", sender.sent.Text, wantLink)
}
}
func TestReply_AgentOfflineAndArchived_PostNotices(t *testing.T) {
for _, tc := range []struct {
outcome engine.Outcome
want string
}{
{engine.OutcomeAgentOffline, agentOfflineText},
{engine.OutcomeAgentArchived, agentArchivedText},
} {
sender := &fakeReplySender{}
r := newTestReplier(&fakeBindingMinter{}, sender)
r.Reply(context.Background(), testResolvedInstallation(t), testInboundForReply(), engine.Result{Outcome: tc.outcome})
if sender.calls != 1 || sender.sent == nil || sender.sent.Text != tc.want {
t.Errorf("outcome %s: got %d sends, text %q, want %q", tc.outcome, sender.calls, textOrEmpty(sender.sent), tc.want)
}
}
}
func TestReply_IngestedWithIssue_Confirms(t *testing.T) {
sender := &fakeReplySender{}
r := newTestReplier(&fakeBindingMinter{}, sender)
r.Reply(context.Background(), testResolvedInstallation(t), testInboundForReply(), engine.Result{
Outcome: engine.OutcomeIngested,
IssueID: mustUUID(t, "55555555-5555-5555-5555-555555555555"),
IssueIdentifier: "MUL-42",
IssueTitle: "Fix the thing",
})
if sender.calls != 1 || sender.sent == nil {
t.Fatalf("expected one confirmation, got %d", sender.calls)
}
if !strings.Contains(sender.sent.Text, "MUL-42") || !strings.Contains(sender.sent.Text, "Fix the thing") {
t.Errorf("confirmation text = %q", sender.sent.Text)
}
}
func TestReply_IngestedWithoutIssue_Silent(t *testing.T) {
sender := &fakeReplySender{}
r := newTestReplier(&fakeBindingMinter{}, sender)
// A plain chat message (no /issue) must NOT post — the agent's own reply
// lands via the EventChatDone outbound subscriber.
r.Reply(context.Background(), testResolvedInstallation(t), testInboundForReply(), engine.Result{
Outcome: engine.OutcomeIngested,
})
if sender.calls != 0 {
t.Errorf("plain ingested message must stay silent, got %d sends", sender.calls)
}
}
func TestReply_Dropped_Silent(t *testing.T) {
sender := &fakeReplySender{}
r := newTestReplier(&fakeBindingMinter{}, sender)
r.Reply(context.Background(), testResolvedInstallation(t), testInboundForReply(), engine.Result{Outcome: engine.OutcomeDropped})
if sender.calls != 0 {
t.Errorf("dropped outcome must stay silent, got %d sends", sender.calls)
}
}
func TestIssueCreatedText(t *testing.T) {
if got := issueCreatedText(engine.Result{IssueIdentifier: "MUL-7", IssueTitle: "Title"}); got != "✅ Created MUL-7 — Title" {
t.Errorf("with title = %q", got)
}
if got := issueCreatedText(engine.Result{IssueNumber: 9}); got != "✅ Created #9" {
t.Errorf("fallback to number = %q", got)
}
}
func textOrEmpty(m *channel.OutboundMessage) string {
if m == nil {
return ""
}
return m.Text
}

View File

@@ -25,11 +25,12 @@ import (
const originSlackChat = "slack_chat"
// NewSlackResolverSet assembles the Slack ResolverSet over the generated
// queries + a tx starter (for the shared session service). Replier/Typing are
// left nil for now: the outbound binding-prompt / notice path is a later step
// (the inbound pipeline — route, identity, dedup, session, /issue, run trigger
// — is fully functional without them).
func NewSlackResolverSet(q *db.Queries, tx engine.TxStarter) engine.ResolverSet {
// queries + a tx starter (for the shared session service). The replier delivers
// the outbound binding-prompt / status / issue-created notices; pass a nil
// engine.OutboundReplier to disable them (the inbound pipeline — route,
// identity, dedup, session, /issue, run trigger — is fully functional without
// it). Typing is left nil. (MUL-3666 wired the replier; stage 3 had it nil.)
func NewSlackResolverSet(q *db.Queries, tx engine.TxStarter, replier engine.OutboundReplier) engine.ResolverSet {
return engine.ResolverSet{
Installation: &installationResolver{q: q},
Identity: &identityResolver{q: q},
@@ -40,6 +41,7 @@ func NewSlackResolverSet(q *db.Queries, tx engine.TxStarter) engine.ResolverSet
Fallback: "Slack chat",
})},
Audit: &auditor{q: q},
Replier: replier,
OriginType: originSlackChat,
}
}
@@ -105,6 +107,21 @@ func nullText(s string) pgtype.Text {
return pgtype.Text{String: s, Valid: true}
}
// installationServesTeam reports whether an installation (its stored config) may
// serve events from eventTeamID. Inbound routing keys on api_app_id, which
// identifies the Slack APP, not the Slack workspace: a BYO app distributed /
// installed into another Slack workspace emits events carrying the SAME app id.
// So we additionally require the event's team to match the team the installed
// bot belongs to. An installation with no recorded team (legacy) is permissive.
func installationServesTeam(installConfigJSON json.RawMessage, eventTeamID string) bool {
// Read team_id directly (NOT via DecodePublicConfig, which falls back to
// app_id when team_id is absent — a hosted-era convenience that would defeat
// this check for BYO where app_id != team_id).
var cfg installConfig
_ = json.Unmarshal(installConfigJSON, &cfg)
return cfg.TeamID == "" || cfg.TeamID == eventTeamID
}
// ---- installation routing ----
type installationResolver struct{ q *db.Queries }
@@ -116,7 +133,11 @@ func (r *installationResolver) ResolveInstallation(ctx context.Context, msg chan
}
inst, err := r.q.GetChannelInstallationByAppID(ctx, db.GetChannelInstallationByAppIDParams{
ChannelType: string(TypeSlack),
AppID: raw.TeamID, // Slack team_id is stored in the routing-key slot
// Route by the event's api_app_id: each BYO installation stores its real
// Slack app id in the routing-key slot (config->>'app_id'), and the
// per-installation Socket Mode connection only ever delivers events for
// its own app, so api_app_id uniquely identifies the installation.
AppID: raw.APIAppID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -124,6 +145,9 @@ func (r *installationResolver) ResolveInstallation(ctx context.Context, msg chan
}
return engine.ResolvedInstallation{}, err
}
if !installationServesTeam(inst.Config, raw.TeamID) {
return engine.ResolvedInstallation{}, engine.ErrInstallationNotFound
}
return engine.ResolvedInstallation{
ID: inst.ID,
WorkspaceID: inst.WorkspaceID,

View File

@@ -93,11 +93,46 @@ func TestSlackThreadIsolation(t *testing.T) {
}
func TestNewSlackResolverSet(t *testing.T) {
set := NewSlackResolverSet(nil, nil)
set := NewSlackResolverSet(nil, nil, nil)
if set.Installation == nil || set.Identity == nil || set.Dedup == nil || set.Session == nil || set.Audit == nil {
t.Error("resolver set must populate all required resolvers")
}
if set.OriginType != "slack_chat" {
t.Errorf("OriginType = %q, want slack_chat", set.OriginType)
}
if set.Replier != nil {
t.Error("a nil replier arg must leave Replier nil (not a typed-nil interface)")
}
// A real replier threads through.
set = NewSlackResolverSet(nil, nil, NewOutboundReplier(OutboundReplierConfig{}))
if set.Replier == nil {
t.Error("a non-nil replier must populate ResolverSet.Replier")
}
}
func TestInstallationServesTeam(t *testing.T) {
cfg := func(team string) json.RawMessage {
b, _ := json.Marshal(installConfig{AppID: "A0BCXGVCS7R", TeamID: team})
return b
}
cases := []struct {
name string
cfgTeam string
eventTeam string
want bool
}{
{"matching team", "T999", "T999", true},
// api_app_id alone is not enough: the same app installed into another Slack
// workspace emits the same app id but a different team — must not route here.
{"different team", "T999", "TOTHER", false},
{"empty event team", "T999", "", false},
{"legacy row without a team is permissive", "", "TANY", true},
}
for _, c := range cases {
if got := installationServesTeam(cfg(c.cfgTeam), c.eventTeam); got != c.want {
t.Errorf("%s: installationServesTeam(cfg=%q, event=%q) = %v, want %v",
c.name, c.cfgTeam, c.eventTeam, got, c.want)
}
}
}

View File

@@ -0,0 +1,218 @@
package slack
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"regexp"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/slack-go/slack/socketmode"
"github.com/multica-ai/multica/server/internal/integrations/channel"
)
// slackChannel is ONE installation's Socket Mode connection. Under the
// bring-your-own-app (BYO) model every Slack installation carries its own Slack
// app — its own app-level token (xapp-, stored encrypted in the installation
// config) — so it gets its own connection, exactly like the stage-3
// per-installation model and like Feishu today. The engine.Supervisor builds
// one slackChannel per active Slack installation (via the registered Factory)
// and owns the lease / reconnect lifecycle; Connect blocks on the receive loop.
//
// Inbound events are translated by the shared inbound.go helpers, parameterized
// by THIS installation's bot user id, and handed to the engine router, which
// resolves the installation by the event's api_app_id — equal to this app's id,
// the per-app routing key. Outbound replies primarily flow through the
// EventChatDone subscriber (NewOutbound); Send satisfies the Channel contract
// and posts with this installation's bot token.
type slackChannel struct {
appID string
botUserID string
appToken string // decrypted xapp- — authorizes the Socket Mode connection
botAPI *slack.Client // bot-token client for outbound Send
handler channel.InboundHandler
logger *slog.Logger
}
func (c *slackChannel) Type() channel.Type { return TypeSlack }
func (c *slackChannel) Capabilities() channel.Capability {
return channel.CapText | channel.CapThreadReply
}
// Disconnect is a no-op: the Socket Mode connection's whole lifetime is scoped
// to Connect (it returns when the run context is cancelled), so there is no
// long-lived resource to release here. Mirrors feishuChannel.Disconnect.
func (c *slackChannel) Disconnect(ctx context.Context) error { return nil }
// Send posts an outbound reply with this installation's bot token, reusing the
// shared slackSender (Markdown→mrkdwn, chunking, threading).
func (c *slackChannel) Send(ctx context.Context, out channel.OutboundMessage) (channel.SendResult, error) {
return newSlackSender(credentials{BotUserID: c.botUserID}, c.botAPI, c.logger).Send(ctx, out)
}
// Connect opens this installation's Socket Mode connection (authenticated with
// its OWN app-level token) and runs the receive loop until ctx is cancelled or
// the link drops. It mirrors the removed AppConnector.connectOnce but is
// per-installation: the bot identity is fixed (this install's bot user id)
// rather than resolved per event by team_id.
func (c *slackChannel) Connect(ctx context.Context) error {
if c.handler == nil {
return errors.New("slack: inbound handler not configured")
}
if c.appToken == "" {
return errors.New("slack: app-level token not configured")
}
// The Socket Mode connection authenticates with the app-level token alone;
// the bot token is only for outbound Web API calls.
api := slack.New("", slack.OptionAppLevelToken(c.appToken))
sm := socketmode.New(api)
// Each connection runs under its OWN cancellable context. Every exit path
// (handler error, event-stream close, ctx cancellation) cancels runCtx and
// waits for the run goroutine to observe it and exit, so a transient failure
// tears the live connection down before the supervisor reconnects — no
// leaked socket goroutine consuming events into an unread channel.
runCtx, runCancel := context.WithCancel(ctx)
runErr := make(chan error, 1)
done := make(chan struct{})
go func() {
runErr <- sm.RunContext(runCtx)
close(done)
}()
defer func() {
runCancel()
<-done
}()
mentionRe := compileMentionRe(c.botUserID)
for {
select {
case <-ctx.Done():
return nil
case err := <-runErr:
if ctx.Err() != nil {
return nil
}
if err != nil {
return err
}
return errors.New("slack: socket mode connection closed")
case evt, ok := <-sm.Events:
if !ok {
if ctx.Err() != nil {
return nil
}
return errors.New("slack: socket mode event stream closed")
}
if err := c.handleSocketEvent(ctx, sm, evt, mentionRe); err != nil {
if ctx.Err() != nil {
return nil
}
return err
}
}
}
}
func (c *slackChannel) handleSocketEvent(ctx context.Context, sm *socketmode.Client, evt socketmode.Event, mentionRe *regexp.Regexp) error {
switch evt.Type {
case socketmode.EventTypeEventsAPI:
eventsAPI, ok := evt.Data.(slackevents.EventsAPIEvent)
if !ok {
return nil
}
// ACK first: Slack expires un-ACKed envelopes in ~3s, far below the
// handler's DB work. The ACK is independent of the handler outcome.
if evt.Request != nil {
if err := sm.Ack(*evt.Request); err != nil {
c.logger.WarnContext(ctx, "slack: ack failed", "error", err)
}
}
return c.dispatchEventsAPI(ctx, eventsAPI, mentionRe)
case socketmode.EventTypeConnecting, socketmode.EventTypeConnected, socketmode.EventTypeHello:
c.logger.DebugContext(ctx, "slack: socket mode", "event", evt.Type, "app_id", c.appID)
case socketmode.EventTypeIncomingError, socketmode.EventTypeErrorBadMessage:
c.logger.WarnContext(ctx, "slack: socket mode error", "event", evt.Type, "app_id", c.appID)
default:
if evt.Request != nil {
_ = sm.Ack(*evt.Request)
}
}
return nil
}
// dispatchEventsAPI translates one Events API envelope to a normalized inbound
// message and hands it to the engine. A non-nil handler error is an
// infrastructure failure; it propagates so the supervisor reconnects. A
// legitimate product drop returns nil.
func (c *slackChannel) dispatchEventsAPI(ctx context.Context, e slackevents.EventsAPIEvent, mentionRe *regexp.Regexp) error {
var (
msg channel.InboundMessage
ok bool
)
switch inner := e.InnerEvent.Data.(type) {
case *slackevents.AppMentionEvent:
msg, ok = inboundFromAppMention(e, inner, c.botUserID, mentionRe)
case *slackevents.MessageEvent:
msg, ok = inboundFromMessage(e, inner, c.botUserID, mentionRe)
default:
return nil
}
if !ok {
return nil
}
return c.handler(ctx, msg)
}
// ChannelDeps are the shared dependencies the Slack Factory closes over. The
// engine inbound handler is supplied per-build via channel.Config.Handler; the
// Decrypter turns the installation's stored ciphertext tokens into plaintext.
type ChannelDeps struct {
Decrypt Decrypter
Logger *slog.Logger
}
// RegisterSlack registers the per-installation Slack Factory so the
// engine.Supervisor builds + supervises one slackChannel per active Slack
// installation. "Adding Slack inbound" is this call plus the adapter — no engine
// edit (the same contract as lark.RegisterFeishu).
func RegisterSlack(reg *channel.Registry, deps ChannelDeps) {
reg.Register(TypeSlack, newSlackFactory(deps))
}
func newSlackFactory(deps ChannelDeps) channel.Factory {
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return func(cfg channel.Config) (channel.Channel, error) {
var ic installConfig
if err := json.Unmarshal(cfg.Raw, &ic); err != nil {
return nil, fmt.Errorf("slack: decode installation config: %w", err)
}
appToken, err := decryptToken(ic.AppTokenEncrypted, deps.Decrypt)
if err != nil {
return nil, fmt.Errorf("slack: decrypt app token: %w", err)
}
if appToken == "" {
return nil, errors.New("slack: installation has no app-level token")
}
botToken, err := decryptToken(ic.BotTokenEncrypted, deps.Decrypt)
if err != nil {
return nil, fmt.Errorf("slack: decrypt bot token: %w", err)
}
return &slackChannel{
appID: ic.AppID,
botUserID: ic.BotUserID,
appToken: appToken,
botAPI: slack.New(botToken),
handler: cfg.Handler,
logger: logger,
}, nil
}
}

View File

@@ -362,6 +362,28 @@ func (q *Queries) DeleteChannelChatSessionBindingBySession(ctx context.Context,
return err
}
const deleteChannelChatSessionBindingsByInstallation = `-- name: DeleteChannelChatSessionBindingsByInstallation :exec
DELETE FROM channel_chat_session_binding
WHERE installation_id = $1 AND channel_type = $2
`
type DeleteChannelChatSessionBindingsByInstallationParams struct {
InstallationID pgtype.UUID `json:"installation_id"`
ChannelType string `json:"channel_type"`
}
// Retire every chat-session binding for an installation. Used when an
// installation is re-pointed to a different agent (Slack re-connect): each
// existing chat_session is permanently tied to the agent it was created under,
// so reusing it would keep routing the conversation to the OLD agent. Dropping
// the bindings forces the next inbound message to create a fresh session under
// the new agent. The chat_session rows are preserved for history; only the
// channel binding is removed.
func (q *Queries) DeleteChannelChatSessionBindingsByInstallation(ctx context.Context, arg DeleteChannelChatSessionBindingsByInstallationParams) error {
_, err := q.db.Exec(ctx, deleteChannelChatSessionBindingsByInstallation, arg.InstallationID, arg.ChannelType)
return err
}
const deleteChannelUserBindingsByWorkspaceMember = `-- name: DeleteChannelUserBindingsByWorkspaceMember :exec
DELETE FROM channel_user_binding
WHERE workspace_id = $1 AND multica_user_id = $2
@@ -1085,3 +1107,73 @@ func (q *Queries) UpsertChannelInstallation(ctx context.Context, arg UpsertChann
)
return i, err
}
const upsertChannelInstallationByAppID = `-- name: UpsertChannelInstallationByAppID :one
INSERT INTO channel_installation (
workspace_id, agent_id, channel_type, config, installer_user_id
) VALUES (
$1, $2, $3, $4, $5
)
ON CONFLICT (channel_type, (config ->> 'app_id')) DO UPDATE SET
agent_id = EXCLUDED.agent_id,
config = EXCLUDED.config,
installer_user_id = EXCLUDED.installer_user_id,
status = 'active',
installed_at = now(),
updated_at = now()
WHERE channel_installation.workspace_id = EXCLUDED.workspace_id
RETURNING id, workspace_id, agent_id, channel_type, config, status, ws_lease_token, ws_lease_expires_at, installer_user_id, installed_at, created_at, updated_at
`
type UpsertChannelInstallationByAppIDParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
AgentID pgtype.UUID `json:"agent_id"`
ChannelType string `json:"channel_type"`
Config []byte `json:"config"`
InstallerUserID pgtype.UUID `json:"installer_user_id"`
}
// Team-keyed install / re-install for channels whose natural identity is the
// platform workspace, not the (agent) pairing. Slack: one Slack workspace
// (team_id, stored as config->>'app_id') maps to exactly one installation, so
// re-connecting it — even to represent a DIFFERENT agent in the SAME Multica
// workspace — UPDATES the existing row (moving agent_id) instead of colliding
// with the (channel_type, app_id) unique index. Contrast UpsertChannelInstallation,
// whose conflict key is (workspace_id, agent_id, channel_type): right for Feishu
// (one app per agent), wrong for Slack.
//
// The `WHERE channel_installation.workspace_id = EXCLUDED.workspace_id` fences
// the conflict update to the SAME Multica workspace: a team already owned by a
// DIFFERENT workspace updates no row and RETURNING is empty (pgx.ErrNoRows),
// which the caller maps to ErrTeamOwnedByAnotherWorkspace. This is the ATOMIC
// cross-workspace guard — a plain SELECT before the upsert cannot stop two
// workspaces racing to OAuth the same team (both read no rows, then one inserts
// and the other's conflict-update would silently steal it). A re-connect that
// would move the team to an agent already holding a different Slack install in
// the same workspace still trips the (workspace_id, agent_id, channel_type)
// unique constraint — a genuine conflict the OAuth callback turns into a redirect.
func (q *Queries) UpsertChannelInstallationByAppID(ctx context.Context, arg UpsertChannelInstallationByAppIDParams) (ChannelInstallation, error) {
row := q.db.QueryRow(ctx, upsertChannelInstallationByAppID,
arg.WorkspaceID,
arg.AgentID,
arg.ChannelType,
arg.Config,
arg.InstallerUserID,
)
var i ChannelInstallation
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.AgentID,
&i.ChannelType,
&i.Config,
&i.Status,
&i.WsLeaseToken,
&i.WsLeaseExpiresAt,
&i.InstallerUserID,
&i.InstalledAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@@ -38,6 +38,41 @@ ON CONFLICT (workspace_id, agent_id, channel_type) DO UPDATE SET
updated_at = now()
RETURNING *;
-- name: UpsertChannelInstallationByAppID :one
-- Team-keyed install / re-install for channels whose natural identity is the
-- platform workspace, not the (agent) pairing. Slack: one Slack workspace
-- (team_id, stored as config->>'app_id') maps to exactly one installation, so
-- re-connecting it — even to represent a DIFFERENT agent in the SAME Multica
-- workspace — UPDATES the existing row (moving agent_id) instead of colliding
-- with the (channel_type, app_id) unique index. Contrast UpsertChannelInstallation,
-- whose conflict key is (workspace_id, agent_id, channel_type): right for Feishu
-- (one app per agent), wrong for Slack.
--
-- The `WHERE channel_installation.workspace_id = EXCLUDED.workspace_id` fences
-- the conflict update to the SAME Multica workspace: a team already owned by a
-- DIFFERENT workspace updates no row and RETURNING is empty (pgx.ErrNoRows),
-- which the caller maps to ErrTeamOwnedByAnotherWorkspace. This is the ATOMIC
-- cross-workspace guard — a plain SELECT before the upsert cannot stop two
-- workspaces racing to OAuth the same team (both read no rows, then one inserts
-- and the other's conflict-update would silently steal it). A re-connect that
-- would move the team to an agent already holding a different Slack install in
-- the same workspace still trips the (workspace_id, agent_id, channel_type)
-- unique constraint — a genuine conflict the OAuth callback turns into a redirect.
INSERT INTO channel_installation (
workspace_id, agent_id, channel_type, config, installer_user_id
) VALUES (
$1, $2, $3, $4, $5
)
ON CONFLICT (channel_type, (config ->> 'app_id')) DO UPDATE SET
agent_id = EXCLUDED.agent_id,
config = EXCLUDED.config,
installer_user_id = EXCLUDED.installer_user_id,
status = 'active',
installed_at = now(),
updated_at = now()
WHERE channel_installation.workspace_id = EXCLUDED.workspace_id
RETURNING *;
-- name: GetChannelInstallation :one
-- Scoped by channel_type: a per-channel caller (e.g. the Feishu store)
-- must never resolve another channel's installation by guessing its UUID.
@@ -246,6 +281,17 @@ WHERE chat_session_id = $1;
DELETE FROM channel_chat_session_binding
WHERE chat_session_id = $1;
-- name: DeleteChannelChatSessionBindingsByInstallation :exec
-- Retire every chat-session binding for an installation. Used when an
-- installation is re-pointed to a different agent (Slack re-connect): each
-- existing chat_session is permanently tied to the agent it was created under,
-- so reusing it would keep routing the conversation to the OLD agent. Dropping
-- the bindings forces the next inbound message to create a fresh session under
-- the new agent. The chat_session rows are preserved for history; only the
-- channel binding is removed.
DELETE FROM channel_chat_session_binding
WHERE installation_id = $1 AND channel_type = $2;
-- =====================
-- channel_inbound_message_dedup
-- =====================

View File

@@ -134,4 +134,12 @@ const (
// deleting the row; the audit trail is preserved.
EventLarkInstallationCreated = "lark_installation:created"
EventLarkInstallationRevoked = "lark_installation:revoked"
// Slack installation lifecycle (MUL-3666). Same semantics as the Lark
// events: `created` covers both first install and OAuth re-install (the
// UNIQUE on (workspace_id, agent_id, channel_type) means at most one row
// per agent), `revoked` flips status without deleting the row. Front-ends
// invalidate the Slack installations query on either.
EventSlackInstallationCreated = "slack_installation:created"
EventSlackInstallationRevoked = "slack_installation:revoked"
)