mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 18:09:14 +02:00
Compare commits
14 Commits
agent/lamb
...
agent/j/99
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87af0d8590 | ||
|
|
9b536be40f | ||
|
|
388be18514 | ||
|
|
889795125d | ||
|
|
88c3ab7462 | ||
|
|
82a5848959 | ||
|
|
35efd476f5 | ||
|
|
e15717131a | ||
|
|
3949f17aba | ||
|
|
f15fbeced6 | ||
|
|
02203fb834 | ||
|
|
8c2650f8ef | ||
|
|
8a0c5f6ff2 | ||
|
|
a3f770250a |
23
apps/web/app/slack/bind/page.tsx
Normal file
23
apps/web/app/slack/bind/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { SlackBindPage } from "@multica/views/slack";
|
||||
|
||||
// /slack/bind?token=<raw> is the bot's "link your account" destination. Suspense
|
||||
// wraps useSearchParams per Next.js 15's CSR-bailout rule; the loading text
|
||||
// never paints in practice because the redemption page itself renders the
|
||||
// "redeeming…" state immediately.
|
||||
function SlackBindPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
return <SlackBindPage token={token} />;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SlackBindPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@
|
||||
"./github/queries": "./github/queries.ts",
|
||||
"./lark": "./lark/index.ts",
|
||||
"./lark/queries": "./lark/queries.ts",
|
||||
"./slack": "./slack/index.ts",
|
||||
"./slack/queries": "./slack/queries.ts",
|
||||
"./feedback": "./feedback/index.ts",
|
||||
"./feedback/mutations": "./feedback/mutations.ts",
|
||||
"./realtime": "./realtime/index.ts",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "../agents/queries";
|
||||
import { githubKeys } from "../github/queries";
|
||||
import { larkKeys } from "../lark/queries";
|
||||
import { slackKeys } from "../slack/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
@@ -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.
|
||||
|
||||
1
packages/core/slack/index.ts
Normal file
1
packages/core/slack/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { slackKeys, slackInstallationsOptions } from "./queries";
|
||||
18
packages/core/slack/queries.ts
Normal file
18
packages/core/slack/queries.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
/** Query key namespace for everything Slack-installation-related. Realtime
|
||||
* sync invalidates `installations(wsId)` on `slack_installation:*` events so
|
||||
* the Settings panel updates without a manual refetch (e.g. after the OAuth
|
||||
* callback lands the install in another tab / the system browser). */
|
||||
export const slackKeys = {
|
||||
all: (wsId: string) => ["slack", wsId] as const,
|
||||
installations: (wsId: string) => [...slackKeys.all(wsId), "installations"] as const,
|
||||
};
|
||||
|
||||
export const slackInstallationsOptions = (wsId: string) =>
|
||||
queryOptions({
|
||||
queryKey: slackKeys.installations(wsId),
|
||||
queryFn: () => api.listSlackInstallations(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
@@ -119,6 +119,12 @@ export type {
|
||||
LarkInstallStatusResponse,
|
||||
RedeemLarkBindingTokenResponse,
|
||||
} from "./lark";
|
||||
export type {
|
||||
SlackInstallation,
|
||||
ListSlackInstallationsResponse,
|
||||
RegisterSlackBYORequest,
|
||||
RedeemSlackBindingTokenResponse,
|
||||
} from "./slack";
|
||||
export type {
|
||||
Autopilot,
|
||||
AutopilotStatus,
|
||||
|
||||
50
packages/core/types/slack.ts
Normal file
50
packages/core/types/slack.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/** A Slack bot installation bound to a single Multica agent (MUL-3666).
|
||||
*
|
||||
* Wire shape mirrors `SlackInstallationResponse` in
|
||||
* `server/internal/handler/slack.go`. New fields the backend adds in the
|
||||
* future MUST default to optional so older desktop builds keep parsing the
|
||||
* response — see CLAUDE.md → API Compatibility. */
|
||||
export interface SlackInstallation {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
agent_id: string;
|
||||
/** The Slack workspace (team) id this bot is installed in. */
|
||||
team_id: string;
|
||||
/** The installed bot's Slack user id. */
|
||||
bot_user_id: string;
|
||||
installer_user_id: string;
|
||||
status: "active" | "revoked" | string;
|
||||
installed_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ListSlackInstallationsResponse {
|
||||
installations: SlackInstallation[];
|
||||
/** Whether the deployment has the at-rest secret key configured. When false
|
||||
* the connect entry points are hidden and the panel renders an "ask the
|
||||
* operator to enable Slack" state. */
|
||||
configured: boolean;
|
||||
/** Whether the install path is available (true whenever Slack is configured,
|
||||
* i.e. the at-rest key is set — a bring-your-own-app install needs no hosted
|
||||
* OAuth credentials). Kept as a separate flag for forward/backward compat;
|
||||
* optional so an older desktop build that predates it treats it as off. */
|
||||
install_supported?: boolean;
|
||||
}
|
||||
|
||||
/** Request body for a bring-your-own-app (BYO) install: the two tokens the
|
||||
* admin pastes from the Slack app they created. The backend validates that both
|
||||
* belong to the same Slack app (and that the app token is live) before
|
||||
* persisting, then returns the created SlackInstallation. */
|
||||
export interface RegisterSlackBYORequest {
|
||||
bot_token: string;
|
||||
app_token: string;
|
||||
}
|
||||
|
||||
/** Post-redemption echo: the Slack user id the token carried is now bound to
|
||||
* the logged-in Multica user in this workspace/installation. */
|
||||
export interface RedeemSlackBindingTokenResponse {
|
||||
workspace_id: string;
|
||||
installation_id: string;
|
||||
slack_user_id: string;
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { SkillAttach } from "./inspector/skill-attach";
|
||||
import { ThinkingPropRow } from "./inspector/thinking-prop-row";
|
||||
import { VisibilityPicker } from "./inspector/visibility-picker";
|
||||
import { LarkAgentBindButton } from "../../settings/components/lark-tab";
|
||||
import { SlackAgentBindButton } from "../../settings/components/slack-tab";
|
||||
|
||||
interface InspectorProps {
|
||||
agent: Agent;
|
||||
@@ -215,13 +216,12 @@ export function AgentDetailInspector({
|
||||
</div>
|
||||
|
||||
{/* Integrations — surfaces external-channel bind entry points
|
||||
(Lark Bot today; Slack / Discord in the future). The bind
|
||||
button self-hides when the server-side device-flow install
|
||||
capability gate is closed, so this section may render empty
|
||||
on deployments without a configured Lark app — that's
|
||||
intentional and matches the "don't surface a flow that will
|
||||
fail" guarantee. We only mount it for editors: viewers
|
||||
shouldn't see a CTA they can't action. */}
|
||||
(Lark + Slack today; Discord in the future). Each bind button
|
||||
self-hides when its server-side install capability gate is
|
||||
closed, so this section may render empty on deployments without
|
||||
a configured channel — that's intentional and matches the
|
||||
"don't surface a flow that will fail" guarantee. We only mount
|
||||
it for editors: viewers shouldn't see a CTA they can't action. */}
|
||||
{canEdit && (
|
||||
<div className="flex flex-col px-5 py-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
@@ -235,6 +235,11 @@ export function AgentDetailInspector({
|
||||
agentName={agent.name}
|
||||
onShowConnectedDetails={onShowIntegrations}
|
||||
/>
|
||||
<SlackAgentBindButton
|
||||
agentId={agent.id}
|
||||
agentName={agent.name}
|
||||
onShowConnectedDetails={onShowIntegrations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -45,6 +45,9 @@ vi.mock("../../common/actor-issues-panel", () => ({
|
||||
const larkListingRef = vi.hoisted(() => ({
|
||||
current: { installations: [] as unknown[], configured: false },
|
||||
}));
|
||||
const slackListingRef = vi.hoisted(() => ({
|
||||
current: { installations: [] as unknown[], configured: false },
|
||||
}));
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
@@ -54,6 +57,12 @@ vi.mock("@multica/core/lark", () => ({
|
||||
queryFn: () => Promise.resolve(larkListingRef.current),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@multica/core/slack", () => ({
|
||||
slackInstallationsOptions: () => ({
|
||||
queryKey: ["slack", "installations"],
|
||||
queryFn: () => Promise.resolve(slackListingRef.current),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { AgentOverviewPane } from "./agent-overview-pane";
|
||||
|
||||
@@ -119,6 +128,7 @@ function renderPane(runtimes: AgentRuntime[]) {
|
||||
|
||||
beforeEach(() => {
|
||||
larkListingRef.current = { installations: [], configured: false };
|
||||
slackListingRef.current = { installations: [], configured: false };
|
||||
});
|
||||
|
||||
describe("AgentOverviewPane MCP tab visibility", () => {
|
||||
@@ -163,9 +173,19 @@ describe("AgentOverviewPane Integrations tab visibility", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Integrations tab when Lark is not configured", () => {
|
||||
// Default ref is configured:false; the tab must not appear on
|
||||
// deployments without the integration, which are the common case.
|
||||
it("shows the Integrations tab when only Slack is configured (Lark off)", async () => {
|
||||
// Regression: the tab gate must consider Slack too, not just Lark —
|
||||
// a Slack-only deployment was hiding the tab (and its bind entry).
|
||||
slackListingRef.current = { installations: [], configured: true };
|
||||
renderPane([makeRuntime("claude")]);
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /^Integrations$/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Integrations tab when neither Lark nor Slack is configured", () => {
|
||||
// Default refs are configured:false; the tab must not appear on
|
||||
// deployments without either integration, the common case.
|
||||
renderPane([makeRuntime("claude")]);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /^Integrations$/i }),
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { Agent, AgentRuntime } from "@multica/core/types";
|
||||
import { providerSupportsMcpConfig } from "@multica/core/agents";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { larkInstallationsOptions } from "@multica/core/lark";
|
||||
import { slackInstallationsOptions } from "@multica/core/slack";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -141,16 +142,24 @@ export function AgentOverviewPane({
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const larkConfigured = larkListing?.configured === true;
|
||||
const { data: slackListing } = useQuery({
|
||||
...slackInstallationsOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const slackConfigured = slackListing?.configured === true;
|
||||
// The Integrations tab appears once EITHER channel is wired on the
|
||||
// deployment, so a Slack-only deployment (no Lark) still surfaces it.
|
||||
const integrationsConfigured = larkConfigured || slackConfigured;
|
||||
|
||||
// The MCP tab is only shown when the agent's runtime backend actually
|
||||
// consumes mcp_config — see providerSupportsMcpConfig. We default to
|
||||
// showing it when the runtime row hasn't loaded yet so a slow fetch
|
||||
// can't transiently flicker the tab off and then on.
|
||||
//
|
||||
// The Integrations tab only appears once the deployment has Lark wired
|
||||
// The Integrations tab appears once the deployment has Lark OR Slack wired
|
||||
// (configured). Unlike MCP we default to HIDING while the listing loads:
|
||||
// deployments without Lark are the common case, so flashing the tab on
|
||||
// then off would be the worse flicker.
|
||||
// deployments without either channel are the common case, so flashing the
|
||||
// tab on then off would be the worse flicker.
|
||||
//
|
||||
// The Runtime Config tab is openclaw-only today (gateway mode lives there,
|
||||
// issue #3260). Other providers' runtime_config is freeform JSONB that no
|
||||
@@ -161,11 +170,11 @@ export function AgentOverviewPane({
|
||||
const showRuntimeConfig = runtime ? runtime.provider === "openclaw" : false;
|
||||
return detailTabs.filter((tab) => {
|
||||
if (tab.id === "mcp_config") return showMcp;
|
||||
if (tab.id === "integrations") return larkConfigured;
|
||||
if (tab.id === "integrations") return integrationsConfigured;
|
||||
if (tab.id === "runtime_config") return showRuntimeConfig;
|
||||
return true;
|
||||
});
|
||||
}, [runtime, larkConfigured]);
|
||||
}, [runtime, integrationsConfigured]);
|
||||
|
||||
// If the active tab disappears (e.g. user just switched the agent's
|
||||
// runtime to one that doesn't read mcp_config), fall back to Activity
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
if (key.includes("installations")) return { data: installationsRef.current };
|
||||
return { data: undefined };
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
queryOptions: <T,>(opts: T) => opts,
|
||||
}));
|
||||
|
||||
@@ -53,6 +54,13 @@ vi.mock("@multica/core/lark", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/slack", () => ({
|
||||
slackInstallationsOptions: () => ({
|
||||
queryKey: ["slack", "installations"],
|
||||
queryFn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const useAuthStore = Object.assign(
|
||||
(sel?: (s: { user: { id: string } }) => unknown) =>
|
||||
@@ -68,6 +76,14 @@ vi.mock("../../../settings/components/lark-tab", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// SlackAgentBindButton is the shared bind entry covered in slack-tab.test.tsx;
|
||||
// here it is a marker so the tests assert branch selection, not the OAuth flow.
|
||||
vi.mock("../../../settings/components/slack-tab", () => ({
|
||||
SlackAgentBindButton: ({ agentId }: { agentId: string }) => (
|
||||
<div data-testid="slack-bind-button" data-agent-id={agentId} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { IntegrationsTab } from "./integrations-tab";
|
||||
|
||||
const TEST_RESOURCES = {
|
||||
@@ -118,11 +134,12 @@ function resetFixtures() {
|
||||
describe("IntegrationsTab", () => {
|
||||
beforeEach(resetFixtures);
|
||||
|
||||
it("renders the shared bind entry for an owner when Lark is configured and supported", () => {
|
||||
it("renders the shared bind entry for both platforms for an owner when configured and supported", () => {
|
||||
renderTab(<IntegrationsTab agent={agent} />);
|
||||
expect(screen.getByText("Lark")).toBeTruthy();
|
||||
const button = screen.getByTestId("lark-bind-button");
|
||||
expect(button.getAttribute("data-agent-id")).toBe("agent-1");
|
||||
expect(screen.getByText("Slack")).toBeTruthy();
|
||||
expect(screen.getByTestId("lark-bind-button").getAttribute("data-agent-id")).toBe("agent-1");
|
||||
expect(screen.getByTestId("slack-bind-button").getAttribute("data-agent-id")).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("shows the coming-soon notice when the install transport is not wired", () => {
|
||||
@@ -147,13 +164,16 @@ describe("IntegrationsTab", () => {
|
||||
expect(screen.queryByTestId("lark-bind-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("points members at Settings instead of a dead button when they can't manage", () => {
|
||||
it("points members at Settings with one role notice (not per-platform) when they can't manage", () => {
|
||||
membersRef.current = [{ user_id: "user-1", role: "member" }];
|
||||
renderTab(<IntegrationsTab agent={agent} />);
|
||||
// The role gate is hoisted above the per-platform sections, so the notice
|
||||
// appears exactly once and neither bind entry renders.
|
||||
expect(
|
||||
screen.getByText(/Only workspace owners and admins can bind a Lark Bot/i),
|
||||
screen.getByText(/Only workspace owners and admins can connect an agent/i),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByTestId("lark-bind-button")).toBeNull();
|
||||
expect(screen.queryByTestId("slack-bind-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the bind entry (not coming-soon) when installs are unavailable but the agent is already bound", () => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Webhook } from "lucide-react";
|
||||
import { MessagesSquare, Webhook } from "lucide-react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { larkInstallationsOptions } from "@multica/core/lark";
|
||||
import { slackInstallationsOptions } from "@multica/core/slack";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { LarkAgentBindButton } from "../../../settings/components/lark-tab";
|
||||
import { SlackAgentBindButton } from "../../../settings/components/slack-tab";
|
||||
import { useT } from "../../../i18n";
|
||||
|
||||
/**
|
||||
@@ -37,6 +39,10 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
|
||||
...larkInstallationsOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: slackListing } = useQuery({
|
||||
...slackInstallationsOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: members = [] } = useQuery({
|
||||
...memberListOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
@@ -52,6 +58,30 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
|
||||
(inst) => inst.agent_id === agent.id && inst.status === "active",
|
||||
) ?? false;
|
||||
|
||||
const slackConfigured = slackListing?.configured === true;
|
||||
const slackInstallSupported = slackListing?.install_supported === true;
|
||||
const slackHasActiveInstall =
|
||||
slackListing?.installations.some(
|
||||
(inst) => inst.agent_id === agent.id && inst.status === "active",
|
||||
) ?? false;
|
||||
|
||||
// Install / manage is gated on workspace owner/admin for every platform, so
|
||||
// the role notice is hoisted above the per-platform sections — one note
|
||||
// instead of repeating it under each integration. Members can still view
|
||||
// connected bots in the (member-visible) Settings → Integrations listing.
|
||||
if (!canManage) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.integrations.intro)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.integrations.members_note)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -78,14 +108,6 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ts(($) => $.lark.not_enabled_title)}
|
||||
</p>
|
||||
) : !canManage ? (
|
||||
// The backend gates install / manage on workspace owner/admin.
|
||||
// Members can still view connected bots in the (member-visible)
|
||||
// Settings listing, so point them there rather than show a dead
|
||||
// button.
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.integrations.members_note)}
|
||||
</p>
|
||||
) : !installSupported && !hasActiveInstall ? (
|
||||
// Key is set but the device-flow transport isn't wired in this
|
||||
// build — a fresh scan would fail at the post-poll bot-info step,
|
||||
@@ -107,6 +129,39 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border">
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border bg-muted/40 text-muted-foreground">
|
||||
<MessagesSquare className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h3 className="text-sm font-medium">{ts(($) => $.slack.section_title)}</h3>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{ts(($) => $.slack.page_description)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t px-4 py-3">
|
||||
{!slackConfigured ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ts(($) => $.slack.not_enabled_title)}
|
||||
</p>
|
||||
) : !slackInstallSupported && !slackHasActiveInstall ? (
|
||||
// Secret key is set but the OAuth client credentials aren't, so a
|
||||
// fresh "Connect Slack" would 503. Surface the "coming soon" notice
|
||||
// instead of a broken CTA; an already-bound agent still renders.
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">{ts(($) => $.slack.preview_title)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ts(($) => $.slack.preview_description)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<SlackAgentBindButton agentId={agent.id} agentName={agent.name} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"intro": "Connect this agent to external chat platforms so people can work with it where they already are.",
|
||||
"members_note": "Only workspace owners and admins can bind a Lark Bot to an agent. You can view connected bots in Settings → Integrations."
|
||||
"members_note": "Only workspace owners and admins can connect an agent to an external chat platform. You can view connected bots in Settings → Integrations."
|
||||
},
|
||||
"activity": {
|
||||
"section_now": "Now",
|
||||
|
||||
@@ -24,5 +24,20 @@
|
||||
"error_already_bound": "This Lark account is already bound to a different Multica user. Account transfers must go through an explicit unbind first.",
|
||||
"error_not_member": "You're signed in to a Multica account that isn't a member of this workspace.",
|
||||
"error_unknown": "Something went wrong. Try again, and if the problem persists, contact the workspace admin."
|
||||
},
|
||||
"slack_bind": {
|
||||
"page_title": "Link your Slack account",
|
||||
"redeeming": "Linking your account…",
|
||||
"needs_auth_description": "Sign in to Multica to complete the link. The token in the link binds your Slack account to this Multica user, so you must be logged in first.",
|
||||
"sign_in": "Sign in",
|
||||
"done_title": "You're linked.",
|
||||
"done_description": "Your next message to the bot in Slack will go straight to the agent. You can close this tab.",
|
||||
"error_title": "Couldn't complete the link",
|
||||
"error_admin_hint": "If this keeps happening, message the bot again in Slack to get a fresh link.",
|
||||
"error_missing_token": "The link is missing its token. Message the bot again in Slack to get a new one.",
|
||||
"error_expired": "This link is invalid or expired (links are valid for 15 minutes). Message the bot again to get a new one.",
|
||||
"error_already_bound": "This Slack account is already linked to a different Multica user. Account transfers must go through an explicit unbind first.",
|
||||
"error_not_member": "You're signed in to a Multica account that isn't a member of this workspace.",
|
||||
"error_unknown": "Something went wrong. Try again, and if the problem persists, contact the workspace admin."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +300,52 @@
|
||||
"install_error_forbidden": "You no longer have permission to install Lark Bots in this workspace. Ask a workspace admin to continue.",
|
||||
"install_error_generic": "Install failed. Try again."
|
||||
},
|
||||
"slack": {
|
||||
"section_title": "Slack",
|
||||
"page_description": "Connect each Multica Agent to its own Slack bot. A workspace admin creates a Slack app and pastes its bot + app-level tokens; members can then DM the bot or @mention it in a channel, and start a message with /issue (e.g. \"@bot /issue Fix the login bug\") to spin up a new Multica issue.",
|
||||
"not_enabled_title": "Slack integration not enabled",
|
||||
"not_enabled_description_prefix": "Set",
|
||||
"not_enabled_description_suffix": "on the server to enable Slack bot installations.",
|
||||
"not_enabled_self_host_hint": "Self-hosters: see the project README for details.",
|
||||
"preview_title": "Slack install coming soon",
|
||||
"preview_description": "The at-rest key is set, but the hosted Slack app's OAuth credentials are not configured in this deployment. The Connect button will appear here once they are set.",
|
||||
"connected_bots": "Connected bots",
|
||||
"loading": "Loading…",
|
||||
"empty_title": "No bots connected yet",
|
||||
"empty_description_prefix": "Open an Agent in this workspace and click",
|
||||
"empty_description_cta": "Connect Slack",
|
||||
"empty_description_suffix": "to install a bot for it.",
|
||||
"revoked_badge": "revoked",
|
||||
"installed_at_label": "Installed {{when}}",
|
||||
"disconnect": "Disconnect",
|
||||
"disconnecting": "Disconnecting…",
|
||||
"disconnect_confirm_title": "Disconnect this Slack bot?",
|
||||
"disconnect_confirm_description": "The bot will stop receiving Slack messages for this workspace. The installation row is kept for audit; you can re-install later from the same Agent.",
|
||||
"disconnect_confirm_cancel": "Cancel",
|
||||
"toast_disconnected": "Disconnected Slack bot",
|
||||
"toast_disconnect_failed": "Disconnect failed",
|
||||
"bind_button": "Connect Slack",
|
||||
"bind_button_title": "Connect {{agent}} to a Slack bot",
|
||||
"connecting": "Opening Slack…",
|
||||
"connect_failed_toast": "Could not start the Slack install",
|
||||
"agent_bot_connected_label": "Connected to Slack",
|
||||
"agent_bot_disconnect_tooltip": "Unbind this Slack bot from the Agent. The bot will stop receiving Slack messages.",
|
||||
"agent_bot_manage_link": "Open in Slack",
|
||||
"agent_bot_manage_tooltip": "Open this bot's Slack workspace.",
|
||||
"byo_dialog_title": "Connect a Slack bot",
|
||||
"byo_dialog_intro": "Create your own Slack app, install it to your workspace, then paste its two tokens below. You can connect a different app for each agent in the same workspace.",
|
||||
"byo_video_cta": "Watch the setup walkthrough",
|
||||
"byo_bot_token_label": "Bot token (xoxb-)",
|
||||
"byo_bot_token_hint": "Slack app → OAuth & Permissions → Bot User OAuth Token.",
|
||||
"byo_app_token_label": "App-level token (xapp-)",
|
||||
"byo_app_token_hint": "Slack app → Basic Information → App-Level Tokens (scope connections:write).",
|
||||
"byo_scopes_hint": "Required bot scopes: app_mentions:read, channels:history, chat:write, groups:history, im:history, mpim:history, users:read.",
|
||||
"byo_submit": "Connect",
|
||||
"byo_submitting": "Connecting…",
|
||||
"byo_cancel": "Cancel",
|
||||
"byo_success_toast": "Slack bot connected",
|
||||
"byo_failed_toast": "Could not connect the Slack bot"
|
||||
},
|
||||
"repositories": {
|
||||
"section_title": "Repositories",
|
||||
"description": "Git repositories associated with this workspace. Agents use these to clone and work on code.",
|
||||
|
||||
@@ -354,7 +354,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"intro": "このエージェントを外部のチャットプラットフォームに接続し、普段使っているツールから直接やり取りできるようにします。",
|
||||
"members_note": "エージェントに Lark Bot を紐付けできるのはワークスペースのオーナーと管理者のみです。接続済みの Bot は「設定 → 連携」で確認できます。"
|
||||
"members_note": "エージェントを外部チャットプラットフォームに接続できるのはワークスペースのオーナーと管理者のみです。接続済みの Bot は「設定 → 連携」で確認できます。"
|
||||
},
|
||||
"activity": {
|
||||
"section_now": "現在",
|
||||
|
||||
@@ -24,5 +24,20 @@
|
||||
"error_already_bound": "この Lark アカウントはすでに別の Multica ユーザーに連携されています。アカウントを移すには、まず明示的に連携を解除する必要があります。",
|
||||
"error_not_member": "現在ログイン中の Multica アカウントは、このワークスペースのメンバーではありません。",
|
||||
"error_unknown": "問題が発生しました。もう一度試し、それでも解決しない場合はワークスペース管理者にお問い合わせください。"
|
||||
},
|
||||
"slack_bind": {
|
||||
"page_title": "Slack アカウントを連携",
|
||||
"redeeming": "アカウントを連携しています…",
|
||||
"needs_auth_description": "連携を完了するには Multica にサインインしてください。リンク内のトークンが、あなたの Slack アカウントをこの Multica ユーザーに紐付けるため、先にログインが必要です。",
|
||||
"sign_in": "サインイン",
|
||||
"done_title": "連携が完了しました。",
|
||||
"done_description": "次に Slack でボットへ送るメッセージは、そのままエージェントに届きます。このタブは閉じて構いません。",
|
||||
"error_title": "連携を完了できませんでした",
|
||||
"error_admin_hint": "繰り返し発生する場合は、Slack でボットにもう一度メッセージを送って新しいリンクを取得してください。",
|
||||
"error_missing_token": "リンクにトークンがありません。Slack でボットにもう一度メッセージを送って新しいリンクを取得してください。",
|
||||
"error_expired": "このリンクは無効か期限切れです(有効期限は 15 分)。ボットにもう一度メッセージを送って新しいリンクを取得してください。",
|
||||
"error_already_bound": "この Slack アカウントは別の Multica ユーザーに連携済みです。移行するにはまず明示的に解除する必要があります。",
|
||||
"error_not_member": "サインインしている Multica アカウントはこのワークスペースのメンバーではありません。",
|
||||
"error_unknown": "問題が発生しました。もう一度試し、それでも解決しない場合はワークスペース管理者にお問い合わせください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +300,52 @@
|
||||
"install_error_forbidden": "このワークスペースに Lark ボットを設置する権限がなくなりました。ワークスペース管理者にお問い合わせください。",
|
||||
"install_error_generic": "設置に失敗しました。もう一度お試しください。"
|
||||
},
|
||||
"slack": {
|
||||
"section_title": "Slack",
|
||||
"page_description": "各 Multica エージェントを専用の Slack ボットに接続します。ワークスペース管理者が Slack アプリを作成し、その bot トークンと app レベルトークンを貼り付けます。メンバーはボットに DM したりチャンネルで @メンションしたりでき、/issue で始まるメッセージ(例:「@bot /issue ログインの不具合を修正」)で新しい Multica issue を作成できます。",
|
||||
"not_enabled_title": "Slack 連携が有効になっていません",
|
||||
"not_enabled_description_prefix": "サーバーで",
|
||||
"not_enabled_description_suffix": "を設定すると Slack ボットのインストールが有効になります。",
|
||||
"not_enabled_self_host_hint": "セルフホストの場合: 詳細はプロジェクトの README を参照してください。",
|
||||
"preview_title": "Slack インストールは近日対応",
|
||||
"preview_description": "保存用キーは設定済みですが、このデプロイではホスト型 Slack アプリの OAuth 認証情報が未設定です。設定すると接続ボタンがここに表示されます。",
|
||||
"connected_bots": "接続済みのボット",
|
||||
"loading": "読み込み中…",
|
||||
"empty_title": "まだボットが接続されていません",
|
||||
"empty_description_prefix": "このワークスペースのエージェントを開き、",
|
||||
"empty_description_cta": "Slack を接続",
|
||||
"empty_description_suffix": "をクリックしてボットをインストールします。",
|
||||
"revoked_badge": "取り消し済み",
|
||||
"installed_at_label": "{{when}} にインストール",
|
||||
"disconnect": "切断",
|
||||
"disconnecting": "切断中…",
|
||||
"disconnect_confirm_title": "この Slack ボットを切断しますか?",
|
||||
"disconnect_confirm_description": "このボットはこのワークスペースの Slack メッセージを受信しなくなります。インストール記録は監査のため保持され、同じエージェントから再インストールできます。",
|
||||
"disconnect_confirm_cancel": "キャンセル",
|
||||
"toast_disconnected": "Slack ボットを切断しました",
|
||||
"toast_disconnect_failed": "切断に失敗しました",
|
||||
"bind_button": "Slack を接続",
|
||||
"bind_button_title": "{{agent}} を Slack ボットに接続",
|
||||
"connecting": "Slack を開いています…",
|
||||
"connect_failed_toast": "Slack のインストールを開始できませんでした",
|
||||
"agent_bot_connected_label": "Slack に接続済み",
|
||||
"agent_bot_disconnect_tooltip": "この Slack ボットをエージェントから解除します。ボットは Slack メッセージを受信しなくなります。",
|
||||
"agent_bot_manage_link": "Slack で開く",
|
||||
"agent_bot_manage_tooltip": "このボットの Slack ワークスペースを開きます。",
|
||||
"byo_dialog_title": "Slack ボットを接続",
|
||||
"byo_dialog_intro": "自分の Slack アプリを作成してワークスペースにインストールし、その 2 つのトークンを下に貼り付けてください。同じワークスペース内でエージェントごとに別のアプリを接続できます。",
|
||||
"byo_video_cta": "セットアップ手順の動画を見る",
|
||||
"byo_bot_token_label": "Bot トークン(xoxb-)",
|
||||
"byo_bot_token_hint": "Slack アプリ → OAuth & Permissions → Bot User OAuth Token。",
|
||||
"byo_app_token_label": "App レベルトークン(xapp-)",
|
||||
"byo_app_token_hint": "Slack アプリ → Basic Information → App-Level Tokens(スコープ connections:write)。",
|
||||
"byo_scopes_hint": "必要な Bot スコープ:app_mentions:read、channels:history、chat:write、groups:history、im:history、mpim:history、users:read。",
|
||||
"byo_submit": "接続",
|
||||
"byo_submitting": "接続中…",
|
||||
"byo_cancel": "キャンセル",
|
||||
"byo_success_toast": "Slack ボットを接続しました",
|
||||
"byo_failed_toast": "Slack ボットを接続できませんでした"
|
||||
},
|
||||
"repositories": {
|
||||
"section_title": "リポジトリ",
|
||||
"description": "このワークスペースに関連付けられた Git リポジトリです。エージェントはこれらをクローンしてコードを作業します。",
|
||||
|
||||
@@ -370,7 +370,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"intro": "이 에이전트를 외부 채팅 플랫폼에 연결해 팀원이 평소 사용하는 도구에서 바로 함께 작업할 수 있도록 합니다.",
|
||||
"members_note": "에이전트에 Lark 봇을 연결할 수 있는 사람은 워크스페이스 소유자와 관리자뿐입니다. 연결된 봇은 설정 → 연동에서 확인할 수 있습니다."
|
||||
"members_note": "에이전트를 외부 채팅 플랫폼에 연결할 수 있는 사람은 워크스페이스 소유자와 관리자뿐입니다. 연결된 봇은 설정 → 연동에서 확인할 수 있습니다."
|
||||
},
|
||||
"activity": {
|
||||
"section_now": "현재",
|
||||
|
||||
@@ -24,5 +24,20 @@
|
||||
"error_already_bound": "이 Lark 계정은 이미 다른 Multica 사용자에 연결되어 있습니다. 계정 이전은 먼저 명시적으로 연결을 해제해야 합니다.",
|
||||
"error_not_member": "현재 로그인한 Multica 계정이 이 워크스페이스의 멤버가 아닙니다.",
|
||||
"error_unknown": "문제가 발생했어요. 다시 시도해 보고, 계속되면 워크스페이스 관리자에게 문의하세요."
|
||||
},
|
||||
"slack_bind": {
|
||||
"page_title": "Slack 계정 연결",
|
||||
"redeeming": "계정을 연결하는 중…",
|
||||
"needs_auth_description": "연결을 완료하려면 Multica에 로그인하세요. 링크의 토큰이 Slack 계정을 이 Multica 사용자와 연결하므로 먼저 로그인해야 해요.",
|
||||
"sign_in": "로그인",
|
||||
"done_title": "연결되었어요.",
|
||||
"done_description": "이제 Slack에서 봇에게 보내는 다음 메시지는 바로 에이전트로 전달돼요. 이 탭은 닫아도 됩니다.",
|
||||
"error_title": "연결을 완료하지 못했어요",
|
||||
"error_admin_hint": "계속 발생하면 Slack에서 봇에게 다시 메시지를 보내 새 링크를 받으세요.",
|
||||
"error_missing_token": "링크에 토큰이 없어요. Slack에서 봇에게 다시 메시지를 보내 새 링크를 받으세요.",
|
||||
"error_expired": "이 링크는 유효하지 않거나 만료됐어요(유효 기간 15분). 봇에게 다시 메시지를 보내 새 링크를 받으세요.",
|
||||
"error_already_bound": "이 Slack 계정은 이미 다른 Multica 사용자에 연결되어 있어요. 이전하려면 먼저 명시적으로 연결을 해제해야 합니다.",
|
||||
"error_not_member": "로그인한 Multica 계정이 이 워크스페이스의 멤버가 아니에요.",
|
||||
"error_unknown": "문제가 발생했어요. 다시 시도해 보고, 계속되면 워크스페이스 관리자에게 문의하세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,5 +376,51 @@
|
||||
"install_error_session_lost": "설치 세션이 만료되었거나 유실되었어요. 다시 스캔해 처음부터 진행하세요.",
|
||||
"install_error_forbidden": "이 워크스페이스에 Lark 봇을 설치할 권한이 더 이상 없어요. 워크스페이스 관리자에게 문의하세요.",
|
||||
"install_error_generic": "설치에 실패했어요. 다시 시도하세요."
|
||||
},
|
||||
"slack": {
|
||||
"section_title": "Slack",
|
||||
"page_description": "각 Multica 에이전트를 전용 Slack 봇에 연결합니다. 워크스페이스 관리자가 Slack 앱을 만들고 봇 토큰과 app 레벨 토큰을 붙여넣으면, 멤버는 봇에게 DM하거나 채널에서 @멘션할 수 있습니다. /issue로 시작하는 메시지(예: \"@bot /issue 로그인 버그 수정\")로 새 Multica 이슈를 만들 수 있어요.",
|
||||
"not_enabled_title": "Slack 연동이 활성화되지 않았어요",
|
||||
"not_enabled_description_prefix": "서버에서",
|
||||
"not_enabled_description_suffix": "를 설정하면 Slack 봇 설치가 활성화됩니다.",
|
||||
"not_enabled_self_host_hint": "셀프 호스팅: 자세한 내용은 프로젝트 README를 참고하세요.",
|
||||
"preview_title": "Slack 설치 곧 지원 예정",
|
||||
"preview_description": "저장용 키는 설정되어 있지만, 이 배포에는 호스팅 Slack 앱의 OAuth 자격 증명이 설정되지 않았어요. 설정하면 연결 버튼이 여기에 표시됩니다.",
|
||||
"connected_bots": "연결된 봇",
|
||||
"loading": "불러오는 중…",
|
||||
"empty_title": "아직 연결된 봇이 없어요",
|
||||
"empty_description_prefix": "이 워크스페이스의 에이전트를 열고",
|
||||
"empty_description_cta": "Slack 연결",
|
||||
"empty_description_suffix": "을(를) 클릭해 봇을 설치하세요.",
|
||||
"revoked_badge": "해제됨",
|
||||
"installed_at_label": "{{when}}에 설치됨",
|
||||
"disconnect": "연결 해제",
|
||||
"disconnecting": "연결 해제 중…",
|
||||
"disconnect_confirm_title": "이 Slack 봇을 연결 해제할까요?",
|
||||
"disconnect_confirm_description": "봇이 이 워크스페이스의 Slack 메시지를 더 이상 받지 않습니다. 설치 기록은 감사를 위해 보관되며, 같은 에이전트에서 다시 설치할 수 있어요.",
|
||||
"disconnect_confirm_cancel": "취소",
|
||||
"toast_disconnected": "Slack 봇을 연결 해제했어요",
|
||||
"toast_disconnect_failed": "연결 해제에 실패했어요",
|
||||
"bind_button": "Slack 연결",
|
||||
"bind_button_title": "{{agent}}을(를) Slack 봇에 연결",
|
||||
"connecting": "Slack 여는 중…",
|
||||
"connect_failed_toast": "Slack 설치를 시작할 수 없었어요",
|
||||
"agent_bot_connected_label": "Slack에 연결됨",
|
||||
"agent_bot_disconnect_tooltip": "이 Slack 봇을 에이전트에서 연결 해제합니다. 봇이 Slack 메시지를 받지 않게 됩니다.",
|
||||
"agent_bot_manage_link": "Slack에서 열기",
|
||||
"agent_bot_manage_tooltip": "이 봇의 Slack 워크스페이스를 엽니다.",
|
||||
"byo_dialog_title": "Slack 봇 연결",
|
||||
"byo_dialog_intro": "직접 만든 Slack 앱을 워크스페이스에 설치한 뒤, 두 개의 토큰을 아래에 붙여넣으세요. 같은 워크스페이스에서 에이전트마다 다른 앱을 연결할 수 있습니다.",
|
||||
"byo_video_cta": "설정 안내 영상 보기",
|
||||
"byo_bot_token_label": "Bot 토큰(xoxb-)",
|
||||
"byo_bot_token_hint": "Slack 앱 → OAuth & Permissions → Bot User OAuth Token.",
|
||||
"byo_app_token_label": "App 레벨 토큰(xapp-)",
|
||||
"byo_app_token_hint": "Slack 앱 → Basic Information → App-Level Tokens(스코프 connections:write).",
|
||||
"byo_scopes_hint": "필요한 Bot 스코프: app_mentions:read, channels:history, chat:write, groups:history, im:history, mpim:history, users:read.",
|
||||
"byo_submit": "연결",
|
||||
"byo_submitting": "연결 중…",
|
||||
"byo_cancel": "취소",
|
||||
"byo_success_toast": "Slack 봇을 연결했어요",
|
||||
"byo_failed_toast": "Slack 봇을 연결하지 못했어요"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"intro": "把这个智能体连接到外部聊天平台,让大家在自己熟悉的工具里直接与它协作。",
|
||||
"members_note": "只有工作区的所有者和管理员才能为智能体绑定飞书 Bot。你可以在「设置 → 集成」中查看已连接的 Bot。"
|
||||
"members_note": "只有工作区的所有者和管理员才能把智能体连接到外部聊天平台。你可以在「设置 → 集成」中查看已连接的 Bot。"
|
||||
},
|
||||
"activity": {
|
||||
"section_now": "当前",
|
||||
|
||||
@@ -24,5 +24,20 @@
|
||||
"error_already_bound": "该飞书账号已绑定到其他 Multica 用户。账户转移需要先显式解绑。",
|
||||
"error_not_member": "你登录的 Multica 账号不是当前工作区成员。",
|
||||
"error_unknown": "出现未知错误。请稍后再试,如反复失败请联系工作区管理员。"
|
||||
},
|
||||
"slack_bind": {
|
||||
"page_title": "关联 Slack 账号",
|
||||
"redeeming": "正在关联账号…",
|
||||
"needs_auth_description": "需要登录 Multica 才能完成关联。链接中的 token 会将你的 Slack 账号绑定到当前登录的 Multica 用户。",
|
||||
"sign_in": "登录",
|
||||
"done_title": "已关联。",
|
||||
"done_description": "下次在 Slack 向机器人发送消息时,会直接送达绑定的智能体。可以关闭此页面。",
|
||||
"error_title": "关联未完成",
|
||||
"error_admin_hint": "如果反复失败,请在 Slack 重新向机器人发消息以获取新的链接。",
|
||||
"error_missing_token": "链接缺少 token。请在 Slack 重新向机器人发消息以获取新的链接。",
|
||||
"error_expired": "链接无效或已过期(有效期 15 分钟)。请重新向机器人发消息获取新的链接。",
|
||||
"error_already_bound": "该 Slack 账号已关联到其他 Multica 用户。账户转移需要先显式解绑。",
|
||||
"error_not_member": "你登录的 Multica 账号不是当前工作区成员。",
|
||||
"error_unknown": "出现未知错误。请稍后再试,如反复失败请联系工作区管理员。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +300,52 @@
|
||||
"install_error_forbidden": "你已没有在此工作区安装飞书 Bot 的权限,请联系工作区管理员。",
|
||||
"install_error_generic": "安装失败,请重试。"
|
||||
},
|
||||
"slack": {
|
||||
"section_title": "Slack",
|
||||
"page_description": "把每个 Multica Agent 连接到它自己的 Slack 机器人。工作区管理员创建一个 Slack app 并粘贴它的 bot 和 app-level token;成员之后即可私聊机器人,或在频道中 @ 它,并以 /issue 开头发消息(例如「@机器人 /issue 修复登录问题」)来创建新的 Multica issue。",
|
||||
"not_enabled_title": "Slack 集成未启用",
|
||||
"not_enabled_description_prefix": "在服务器上设置",
|
||||
"not_enabled_description_suffix": "以启用 Slack 机器人安装。",
|
||||
"not_enabled_self_host_hint": "自部署用户:详见项目 README。",
|
||||
"preview_title": "Slack 安装即将上线",
|
||||
"preview_description": "静态加密密钥已设置,但本部署尚未配置托管 Slack 应用的 OAuth 凭据。配置后,连接按钮会出现在这里。",
|
||||
"connected_bots": "已连接的机器人",
|
||||
"loading": "加载中…",
|
||||
"empty_title": "尚未连接机器人",
|
||||
"empty_description_prefix": "在本工作区打开一个 Agent,点击",
|
||||
"empty_description_cta": "连接 Slack",
|
||||
"empty_description_suffix": "为其安装机器人。",
|
||||
"revoked_badge": "已撤销",
|
||||
"installed_at_label": "安装于 {{when}}",
|
||||
"disconnect": "断开连接",
|
||||
"disconnecting": "正在断开…",
|
||||
"disconnect_confirm_title": "断开此 Slack 机器人?",
|
||||
"disconnect_confirm_description": "该机器人将停止接收此工作区的 Slack 消息。安装记录会保留以备审计;你之后可以从同一个 Agent 重新安装。",
|
||||
"disconnect_confirm_cancel": "取消",
|
||||
"toast_disconnected": "已断开 Slack 机器人",
|
||||
"toast_disconnect_failed": "断开失败",
|
||||
"bind_button": "连接 Slack",
|
||||
"bind_button_title": "把 {{agent}} 连接到 Slack 机器人",
|
||||
"connecting": "正在打开 Slack…",
|
||||
"connect_failed_toast": "无法开始 Slack 安装",
|
||||
"agent_bot_connected_label": "已连接到 Slack",
|
||||
"agent_bot_disconnect_tooltip": "将此 Slack 机器人从 Agent 解绑。机器人将停止接收 Slack 消息。",
|
||||
"agent_bot_manage_link": "在 Slack 中打开",
|
||||
"agent_bot_manage_tooltip": "打开此机器人所在的 Slack 工作区。",
|
||||
"byo_dialog_title": "连接 Slack 机器人",
|
||||
"byo_dialog_intro": "创建你自己的 Slack app,安装到你的工作区,然后把它的两个 token 粘贴到下面。同一个工作区里,每个 agent 可以连接不同的 app。",
|
||||
"byo_video_cta": "观看配置教程视频",
|
||||
"byo_bot_token_label": "Bot token(xoxb-)",
|
||||
"byo_bot_token_hint": "Slack app → OAuth & Permissions → Bot User OAuth Token。",
|
||||
"byo_app_token_label": "App-level token(xapp-)",
|
||||
"byo_app_token_hint": "Slack app → Basic Information → App-Level Tokens(scope 选 connections:write)。",
|
||||
"byo_scopes_hint": "需要的 Bot scopes:app_mentions:read、channels:history、chat:write、groups:history、im:history、mpim:history、users:read。",
|
||||
"byo_submit": "连接",
|
||||
"byo_submitting": "连接中…",
|
||||
"byo_cancel": "取消",
|
||||
"byo_success_toast": "Slack 机器人已连接",
|
||||
"byo_failed_toast": "无法连接 Slack 机器人"
|
||||
},
|
||||
"repositories": {
|
||||
"section_title": "代码仓库",
|
||||
"description": "与该工作区关联的 Git 仓库。智能体会从这里 clone 代码并完成工作。",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"./settings": "./settings/index.ts",
|
||||
"./settings/lark-tab": "./settings/components/lark-tab.tsx",
|
||||
"./lark": "./lark/index.ts",
|
||||
"./slack": "./slack/index.ts",
|
||||
"./invite": "./invite/index.ts",
|
||||
"./invitations": "./invitations/index.ts",
|
||||
"./onboarding": "./onboarding/index.ts",
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { LarkTab } from "./lark-tab";
|
||||
import { SlackTab } from "./slack-tab";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// Integrations is the umbrella tab for third-party platform connections.
|
||||
// GitHub has its own top-level tab (see github-tab.tsx); everything else
|
||||
// — currently just Lark, with Slack/Linear etc. to follow — lives in
|
||||
// here under its own section heading so additional integrations slot in
|
||||
// without changing the IA. IntegrationsTab is just the host; each
|
||||
// integration owns its own description and install flow.
|
||||
// — currently Lark and Slack, with Linear etc. to follow — lives in here
|
||||
// under its own section heading so additional integrations slot in without
|
||||
// changing the IA. IntegrationsTab is just the host; each integration owns
|
||||
// its own description and install flow.
|
||||
export function IntegrationsTab() {
|
||||
const { t } = useT("settings");
|
||||
return (
|
||||
@@ -17,6 +18,10 @@ export function IntegrationsTab() {
|
||||
<h2 className="text-sm font-semibold">{t(($) => $.lark.section_title)}</h2>
|
||||
<LarkTab />
|
||||
</section>
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold">{t(($) => $.slack.section_title)}</h2>
|
||||
<SlackTab />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
181
packages/views/settings/components/slack-tab.test.tsx
Normal file
181
packages/views/settings/components/slack-tab.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enSettings from "../../locales/en/settings.json";
|
||||
|
||||
type MemberRole = "owner" | "admin" | "member" | "guest";
|
||||
|
||||
const membersRef = vi.hoisted(() => ({
|
||||
current: [{ user_id: "user-1", role: "owner" as MemberRole }],
|
||||
}));
|
||||
const installationsRef = vi.hoisted(() => ({
|
||||
current: {
|
||||
installations: [] as unknown[],
|
||||
configured: true,
|
||||
install_supported: true,
|
||||
},
|
||||
}));
|
||||
const mockRegisterBYO = vi.hoisted(() => vi.fn());
|
||||
const mockDeleteInstallation = vi.hoisted(() => vi.fn());
|
||||
const mockOpenExternal = vi.hoisted(() => vi.fn());
|
||||
const mockInvalidate = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (opts: { queryKey: unknown[]; enabled?: boolean }) => {
|
||||
if (opts.enabled === false) return { data: undefined, isLoading: false };
|
||||
const key = JSON.stringify(opts.queryKey);
|
||||
if (key.includes("members")) return { data: membersRef.current, isLoading: false };
|
||||
if (key.includes("installations")) return { data: installationsRef.current, isLoading: false };
|
||||
return { data: undefined, isLoading: false };
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: mockInvalidate }),
|
||||
queryOptions: <T,>(opts: T) => opts,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "workspace-1" }));
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
memberListOptions: () => ({ queryKey: ["members"], queryFn: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/hooks", () => ({
|
||||
useActorName: () => ({
|
||||
getAgentName: (agentId: string) => `Agent ${agentId}`,
|
||||
getMemberName: () => "Unknown",
|
||||
getSquadName: () => "Unknown Squad",
|
||||
getActorName: () => "Unknown",
|
||||
getActorInitials: () => "??",
|
||||
getActorAvatarUrl: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../common/actor-avatar", () => ({
|
||||
ActorAvatar: ({ actorId }: { actorId: string }) => (
|
||||
<span data-testid="actor-avatar" data-actor-id={actorId} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/slack", () => ({
|
||||
slackInstallationsOptions: () => ({
|
||||
queryKey: ["slack", "installations"],
|
||||
queryFn: vi.fn(),
|
||||
}),
|
||||
slackKeys: { installations: (wsId: string) => ["slack", "installations", wsId] },
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
registerSlackBYO: mockRegisterBYO,
|
||||
deleteSlackInstallation: mockDeleteInstallation,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const useAuthStore = Object.assign(
|
||||
(sel?: (s: { user: { id: string } }) => unknown) =>
|
||||
sel ? sel({ user: { id: "user-1" } }) : { user: { id: "user-1" } },
|
||||
{ getState: () => ({ user: { id: "user-1" } }) },
|
||||
);
|
||||
return { useAuthStore };
|
||||
});
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), message: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../platform", () => ({ openExternal: mockOpenExternal }));
|
||||
|
||||
import { SlackAgentBindButton, SlackTab } from "./slack-tab";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, settings: enSettings } };
|
||||
|
||||
function renderUI(children: ReactNode) {
|
||||
return render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
{children}
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function resetFixtures() {
|
||||
vi.clearAllMocks();
|
||||
membersRef.current = [{ user_id: "user-1", role: "owner" }];
|
||||
installationsRef.current = { installations: [], configured: true, install_supported: true };
|
||||
}
|
||||
|
||||
describe("SlackAgentBindButton", () => {
|
||||
beforeEach(resetFixtures);
|
||||
|
||||
it("opens the BYO dialog and submits the pasted bot + app tokens", async () => {
|
||||
mockRegisterBYO.mockResolvedValue({ id: "i1", agent_id: "agent-1", status: "active" });
|
||||
renderUI(<SlackAgentBindButton agentId="agent-1" agentName="Bot" />);
|
||||
await userEvent.click(screen.getByTestId("slack-agent-connect"));
|
||||
const botInput = await screen.findByTestId("slack-byo-bot-token");
|
||||
await userEvent.type(botInput, "xoxb-bot");
|
||||
await userEvent.type(screen.getByTestId("slack-byo-app-token"), "xapp-1-A0X-1-secret");
|
||||
await userEvent.click(screen.getByTestId("slack-byo-submit"));
|
||||
await waitFor(() =>
|
||||
expect(mockRegisterBYO).toHaveBeenCalledWith("workspace-1", "agent-1", {
|
||||
bot_token: "xoxb-bot",
|
||||
app_token: "xapp-1-A0X-1-secret",
|
||||
}),
|
||||
);
|
||||
// No OAuth redirect anymore — install is a direct API call.
|
||||
expect(mockOpenExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the connected badge (not the CTA) when the agent already has an active install", () => {
|
||||
installationsRef.current = {
|
||||
installations: [{ id: "i1", agent_id: "agent-1", status: "active", team_id: "T1" }],
|
||||
configured: true,
|
||||
install_supported: true,
|
||||
};
|
||||
renderUI(<SlackAgentBindButton agentId="agent-1" />);
|
||||
expect(screen.getByTestId("slack-agent-bot-connected")).toBeTruthy();
|
||||
expect(screen.getByTestId("slack-agent-bot-disconnect")).toBeTruthy();
|
||||
expect(screen.queryByTestId("slack-agent-connect")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing for a non-manager", () => {
|
||||
membersRef.current = [{ user_id: "user-1", role: "member" }];
|
||||
const { container } = renderUI(<SlackAgentBindButton agentId="agent-1" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing when install is unavailable and the agent is unbound", () => {
|
||||
installationsRef.current = { installations: [], configured: true, install_supported: false };
|
||||
const { container } = renderUI(<SlackAgentBindButton agentId="agent-1" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlackTab", () => {
|
||||
beforeEach(resetFixtures);
|
||||
|
||||
it("surfaces the not-enabled notice when the deployment has no Slack key", () => {
|
||||
installationsRef.current = { installations: [], configured: false, install_supported: false };
|
||||
renderUI(<SlackTab />);
|
||||
expect(screen.getByText(/Slack integration not enabled/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the empty state when configured but nothing is connected", () => {
|
||||
renderUI(<SlackTab />);
|
||||
expect(screen.getByText(/No bots connected yet/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lists a connected installation with its agent name and a disconnect control", () => {
|
||||
installationsRef.current = {
|
||||
installations: [{ id: "i1", agent_id: "agent-7", status: "active", team_id: "T1" }],
|
||||
configured: true,
|
||||
install_supported: true,
|
||||
};
|
||||
renderUI(<SlackTab />);
|
||||
expect(screen.getByText("Agent agent-7")).toBeTruthy();
|
||||
expect(screen.getByText(/Disconnect/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
591
packages/views/settings/components/slack-tab.tsx
Normal file
591
packages/views/settings/components/slack-tab.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronRight, ExternalLink, MessagesSquare, Trash2 } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { slackInstallationsOptions, slackKeys } from "@multica/core/slack";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { SlackInstallation } from "@multica/core/types";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { openExternal } from "../../platform";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// SlackTab is the workspace settings panel for Slack bot installations.
|
||||
// Listing is member-visible; the disconnect action is admin-only (the backend
|
||||
// enforces it; the UI hides the button for non-admins to match).
|
||||
//
|
||||
// Adding a new installation flows through the Agent detail page: the install
|
||||
// path is per-agent (each Multica agent gets exactly one bot — the
|
||||
// (workspace_id, agent_id, channel_type) UNIQUE in channel_installation), so
|
||||
// asking the user to pick an agent here would re-create that page's picker.
|
||||
export function SlackTab() {
|
||||
const { t } = useT("settings");
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
|
||||
const canManage =
|
||||
currentMember?.role === "owner" || currentMember?.role === "admin";
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
...slackInstallationsOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const installations = data?.installations ?? [];
|
||||
const configured = data?.configured === true;
|
||||
// install_supported tracks whether the OAuth client credentials are wired on
|
||||
// the server. When false, "Connect Slack" would 503, so we hide the connect
|
||||
// entry points and surface a "coming soon" notice. Already-installed bots
|
||||
// still appear below and remain manageable.
|
||||
const installSupported = data?.install_supported === true;
|
||||
|
||||
const [disconnectTarget, setDisconnectTarget] = useState<string | null>(null);
|
||||
const [disconnecting, setDisconnecting] = useState(false);
|
||||
|
||||
async function handleDisconnect() {
|
||||
if (!disconnectTarget || disconnecting) return;
|
||||
setDisconnecting(true);
|
||||
try {
|
||||
await api.deleteSlackInstallation(wsId, disconnectTarget);
|
||||
await qc.invalidateQueries({ queryKey: slackKeys.installations(wsId) });
|
||||
toast.success(t(($) => $.slack.toast_disconnected));
|
||||
setDisconnectTarget(null);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t(($) => $.slack.toast_disconnect_failed),
|
||||
);
|
||||
} finally {
|
||||
setDisconnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(($) => $.slack.page_description)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{!configured ? (
|
||||
<Card>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm font-medium">{t(($) => $.slack.not_enabled_title)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.slack.not_enabled_description_prefix)}{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[10px]">
|
||||
MULTICA_SLACK_SECRET_KEY
|
||||
</code>{" "}
|
||||
{t(($) => $.slack.not_enabled_description_suffix)}{" "}
|
||||
{t(($) => $.slack.not_enabled_self_host_hint)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : !installSupported && installations.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm font-medium">{t(($) => $.slack.preview_title)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.slack.preview_description)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">{t(($) => $.slack.connected_bots)}</h2>
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{t(($) => $.slack.loading)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : installations.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm font-medium">{t(($) => $.slack.empty_title)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.slack.empty_description_prefix)}{" "}
|
||||
<strong>{t(($) => $.slack.empty_description_cta)}</strong>{" "}
|
||||
{t(($) => $.slack.empty_description_suffix)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="divide-y">
|
||||
{installations.map((inst) => (
|
||||
<InstallationRow
|
||||
key={inst.id}
|
||||
installation={inst}
|
||||
canManage={canManage}
|
||||
onDisconnect={() => setDisconnectTarget(inst.id)}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={!!disconnectTarget}
|
||||
onOpenChange={(v) => {
|
||||
if (!v && !disconnecting) setDisconnectTarget(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t(($) => $.slack.disconnect_confirm_title)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(($) => $.slack.disconnect_confirm_description)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={disconnecting}>
|
||||
{t(($) => $.slack.disconnect_confirm_cancel)}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDisconnect} disabled={disconnecting}>
|
||||
{disconnecting
|
||||
? t(($) => $.slack.disconnecting)
|
||||
: t(($) => $.slack.disconnect)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallationRow({
|
||||
installation,
|
||||
canManage,
|
||||
onDisconnect,
|
||||
}: {
|
||||
installation: SlackInstallation;
|
||||
canManage: boolean;
|
||||
onDisconnect: () => void;
|
||||
}) {
|
||||
const { t } = useT("settings");
|
||||
const { getAgentName } = useActorName();
|
||||
const isActive = installation.status === "active";
|
||||
const agentName = getAgentName(installation.agent_id);
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={installation.agent_id}
|
||||
size={32}
|
||||
enableHoverCard
|
||||
profileLink
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{agentName}
|
||||
{!isActive && (
|
||||
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{t(($) => $.slack.revoked_badge)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t(($) => $.slack.installed_at_label, {
|
||||
when: new Date(installation.installed_at).toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && isActive && (
|
||||
<Button variant="outline" size="sm" onClick={onDisconnect}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t(($) => $.slack.disconnect)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SLACK_BYO_VIDEO_URL is the optional setup-tutorial video linked from the
|
||||
// connect dialog. Leave "" to hide the link; set it once the walkthrough that
|
||||
// shows how to create the Slack app + copy its two tokens is recorded.
|
||||
const SLACK_BYO_VIDEO_URL = "";
|
||||
|
||||
// SlackAgentBindButton is the per-agent CTA exposed from the agent detail page.
|
||||
// Slack uses the bring-your-own-app model: the button opens a dialog where the
|
||||
// admin pastes the bot token (xoxb-) + app-level token (xapp-) of the Slack app
|
||||
// they created (the backend validates both belong to the same app). Visibility:
|
||||
// 1. Non-owner/admin viewers see nothing (the backend gates install/revoke).
|
||||
// 2. If this agent already has an active installation, show the connected
|
||||
// badge (already-installed bots stay manageable).
|
||||
// 3. Otherwise the Connect CTA shows whenever install is available.
|
||||
export function SlackAgentBindButton({
|
||||
agentId,
|
||||
agentName,
|
||||
className,
|
||||
onShowConnectedDetails,
|
||||
}: {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
className?: string;
|
||||
/**
|
||||
* When set, the connected state renders as a compact read-only status row
|
||||
* that invokes this callback on click instead of the full badge with inline
|
||||
* actions — the agent inspector passes a "jump to the Integrations tab"
|
||||
* handler so management actions live in one place.
|
||||
*/
|
||||
onShowConnectedDetails?: () => void;
|
||||
}) {
|
||||
const { t } = useT("settings");
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [botToken, setBotToken] = useState("");
|
||||
const [appToken, setAppToken] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const { data: listing } = useQuery({
|
||||
...slackInstallationsOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const installSupported = listing?.install_supported === true;
|
||||
|
||||
const { data: members = [] } = useQuery({
|
||||
...memberListOptions(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
|
||||
const canManage =
|
||||
currentMember?.role === "owner" || currentMember?.role === "admin";
|
||||
|
||||
if (!canManage) return null;
|
||||
|
||||
const existing = listing?.installations.find(
|
||||
(inst) => inst.agent_id === agentId && inst.status === "active",
|
||||
);
|
||||
if (existing) {
|
||||
return onShowConnectedDetails ? (
|
||||
<SlackAgentBotStatusRow
|
||||
onClick={onShowConnectedDetails}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<SlackAgentBotConnectedBadge installation={existing} className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!installSupported) return null;
|
||||
|
||||
function closeDialog() {
|
||||
if (submitting) return;
|
||||
setDialogOpen(false);
|
||||
setBotToken("");
|
||||
setAppToken("");
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const bot_token = botToken.trim();
|
||||
const app_token = appToken.trim();
|
||||
if (submitting || !agentId || !bot_token || !app_token) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.registerSlackBYO(wsId, agentId, { bot_token, app_token });
|
||||
// The slack_installation realtime event also refreshes this list, but
|
||||
// invalidate explicitly so the connected badge appears immediately.
|
||||
await qc.invalidateQueries({ queryKey: slackKeys.installations(wsId) });
|
||||
toast.success(t(($) => $.slack.byo_success_toast));
|
||||
setDialogOpen(false);
|
||||
setBotToken("");
|
||||
setAppToken("");
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t(($) => $.slack.byo_failed_toast),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
botToken.trim() !== "" && appToken.trim() !== "" && !submitting;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-wrap items-center gap-2", className)}
|
||||
data-testid="slack-agent-bind-buttons"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
disabled={!agentId}
|
||||
title={
|
||||
agentName
|
||||
? t(($) => $.slack.bind_button_title, { agent: agentName })
|
||||
: undefined
|
||||
}
|
||||
data-testid="slack-agent-connect"
|
||||
>
|
||||
<MessagesSquare className="h-3 w-3" />
|
||||
{t(($) => $.slack.bind_button)}
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(v) => (v ? setDialogOpen(true) : closeDialog())}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg" data-testid="slack-byo-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(($) => $.slack.byo_dialog_title)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(($) => $.slack.byo_dialog_intro)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{SLACK_BYO_VIDEO_URL ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openExternal(SLACK_BYO_VIDEO_URL)}
|
||||
className="inline-flex w-fit items-center gap-1.5 text-xs font-medium text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t(($) => $.slack.byo_video_cta)}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<p className="rounded-md bg-muted px-3 py-2 text-[11px] text-muted-foreground">
|
||||
{t(($) => $.slack.byo_scopes_hint)}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="slack-byo-bot-token">
|
||||
{t(($) => $.slack.byo_bot_token_label)}
|
||||
</Label>
|
||||
<Input
|
||||
id="slack-byo-bot-token"
|
||||
data-testid="slack-byo-bot-token"
|
||||
value={botToken}
|
||||
onChange={(e) => setBotToken(e.target.value)}
|
||||
placeholder="xoxb-…"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t(($) => $.slack.byo_bot_token_hint)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="slack-byo-app-token">
|
||||
{t(($) => $.slack.byo_app_token_label)}
|
||||
</Label>
|
||||
<Input
|
||||
id="slack-byo-app-token"
|
||||
data-testid="slack-byo-app-token"
|
||||
value={appToken}
|
||||
onChange={(e) => setAppToken(e.target.value)}
|
||||
placeholder="xapp-…"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t(($) => $.slack.byo_app_token_hint)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={closeDialog}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t(($) => $.slack.byo_cancel)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
data-testid="slack-byo-submit"
|
||||
>
|
||||
{submitting
|
||||
? t(($) => $.slack.byo_submitting)
|
||||
: t(($) => $.slack.byo_submit)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SlackAgentBotStatusRow is the compact, read-only connected affordance the
|
||||
// agent inspector renders instead of the full badge; it deep-links into the
|
||||
// Integrations tab where Manage / Disconnect live.
|
||||
function SlackAgentBotStatusRow({
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useT("settings");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
className,
|
||||
)}
|
||||
data-testid="slack-agent-bot-status"
|
||||
>
|
||||
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
|
||||
<span className="truncate">{t(($) => $.slack.agent_bot_connected_label)}</span>
|
||||
<ChevronRight className="ml-auto h-3.5 w-3.5 shrink-0" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// SlackAgentBotConnectedBadge is the full "already connected" affordance the
|
||||
// Integrations tab renders in place of the Connect button. Two rows: status +
|
||||
// soft-destructive Disconnect, then a secondary "Open in Slack" link to the
|
||||
// installed workspace. Only owners/admins ever reach this component.
|
||||
function SlackAgentBotConnectedBadge({
|
||||
installation,
|
||||
className,
|
||||
}: {
|
||||
installation: SlackInstallation;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useT("settings");
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [disconnecting, setDisconnecting] = useState(false);
|
||||
|
||||
async function handleDisconnect() {
|
||||
if (disconnecting) return;
|
||||
setDisconnecting(true);
|
||||
try {
|
||||
await api.deleteSlackInstallation(wsId, installation.id);
|
||||
await qc.invalidateQueries({ queryKey: slackKeys.installations(wsId) });
|
||||
toast.success(t(($) => $.slack.toast_disconnected));
|
||||
setConfirmOpen(false);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t(($) => $.slack.toast_disconnect_failed),
|
||||
);
|
||||
} finally {
|
||||
setDisconnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("space-y-2", className)}
|
||||
data-testid="slack-agent-bot-connected"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="inline-flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
|
||||
<span className="truncate">{t(($) => $.slack.agent_bot_connected_label)}</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={disconnecting}
|
||||
title={t(($) => $.slack.agent_bot_disconnect_tooltip)}
|
||||
aria-label={t(($) => $.slack.disconnect)}
|
||||
data-testid="slack-agent-bot-disconnect"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{disconnecting
|
||||
? t(($) => $.slack.disconnecting)
|
||||
: t(($) => $.slack.disconnect)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{installation.team_id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openExternal(`https://app.slack.com/client/${installation.team_id}`)
|
||||
}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
|
||||
title={t(($) => $.slack.agent_bot_manage_tooltip)}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t(($) => $.slack.agent_bot_manage_link)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(v) => {
|
||||
if (!v && !disconnecting) setConfirmOpen(false);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t(($) => $.slack.disconnect_confirm_title)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(($) => $.slack.disconnect_confirm_description)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={disconnecting}>
|
||||
{t(($) => $.slack.disconnect_confirm_cancel)}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDisconnect} disabled={disconnecting}>
|
||||
{disconnecting
|
||||
? t(($) => $.slack.disconnecting)
|
||||
: t(($) => $.slack.disconnect)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
packages/views/slack/bind-page.tsx
Normal file
139
packages/views/slack/bind-page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
type RedeemState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "redeeming" }
|
||||
| { kind: "done"; workspaceId: string; installationId: string }
|
||||
| { kind: "needs-auth" }
|
||||
| { kind: "error"; reason: string };
|
||||
|
||||
// SlackBindPage is the destination the bot's "link your account" prompt points
|
||||
// at (MUL-3666). The user lands here logged out OR logged in; we require auth
|
||||
// before redeeming because the redeemer's Multica identity is taken from the
|
||||
// session (the token alone never proves who is binding — see
|
||||
// slack.BindingTokenService.RedeemAndBind).
|
||||
//
|
||||
// The token comes in via `?token=<raw>`. We POST it to /api/slack/binding/redeem;
|
||||
// the backend returns 410 (invalid/expired), 409 (already bound to another
|
||||
// user), 403 (not a workspace member) or 200 with the bound installation. Each
|
||||
// maps to distinct copy via slack_bind in common.json.
|
||||
export function SlackBindPage({ token }: { token: string | null }) {
|
||||
const { t } = useT("common");
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAuthLoading = useAuthStore((s) => s.isLoading);
|
||||
const navigation = useNavigation();
|
||||
const [state, setState] = useState<RedeemState>({ kind: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setState({ kind: "error", reason: "missing_token" });
|
||||
return;
|
||||
}
|
||||
if (isAuthLoading) return;
|
||||
if (!user) {
|
||||
setState({ kind: "needs-auth" });
|
||||
return;
|
||||
}
|
||||
if (state.kind !== "idle" && state.kind !== "needs-auth") return;
|
||||
setState({ kind: "redeeming" });
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await api.redeemSlackBindingToken(token);
|
||||
setState({
|
||||
kind: "done",
|
||||
workspaceId: resp.workspace_id,
|
||||
installationId: resp.installation_id,
|
||||
});
|
||||
} catch (e) {
|
||||
setState({
|
||||
kind: "error",
|
||||
reason: redemptionFailureReason(e),
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [token, user, isAuthLoading, state.kind]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center p-6">
|
||||
<Card className="w-full">
|
||||
<CardContent className="space-y-4">
|
||||
<h1 className="text-lg font-semibold">{t(($) => $.slack_bind.page_title)}</h1>
|
||||
{state.kind === "idle" || state.kind === "redeeming" ? (
|
||||
<p className="text-sm text-muted-foreground">{t(($) => $.slack_bind.redeeming)}</p>
|
||||
) : state.kind === "needs-auth" ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(($) => $.slack_bind.needs_auth_description)}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigation.push(
|
||||
`/login?next=${encodeURIComponent(
|
||||
`/slack/bind?token=${encodeURIComponent(token ?? "")}`,
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(($) => $.slack_bind.sign_in)}
|
||||
</Button>
|
||||
</>
|
||||
) : state.kind === "done" ? (
|
||||
<>
|
||||
<p className="text-sm font-medium">{t(($) => $.slack_bind.done_title)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.slack_bind.done_description)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium">{t(($) => $.slack_bind.error_title)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(() => {
|
||||
switch (state.reason) {
|
||||
case "missing_token":
|
||||
return t(($) => $.slack_bind.error_missing_token);
|
||||
case "expired":
|
||||
return t(($) => $.slack_bind.error_expired);
|
||||
case "already_bound":
|
||||
return t(($) => $.slack_bind.error_already_bound);
|
||||
case "not_member":
|
||||
return t(($) => $.slack_bind.error_not_member);
|
||||
default:
|
||||
return t(($) => $.slack_bind.error_unknown);
|
||||
}
|
||||
})()}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t(($) => $.slack_bind.error_admin_hint)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function redemptionFailureReason(err: unknown): string {
|
||||
const msg = err instanceof Error ? err.message : "";
|
||||
const lower = msg.toLowerCase();
|
||||
if (lower.includes("invalid") || lower.includes("expired") || lower.includes("410")) {
|
||||
return "expired";
|
||||
}
|
||||
if (lower.includes("already bound") || lower.includes("409")) {
|
||||
return "already_bound";
|
||||
}
|
||||
if (lower.includes("workspace member") || lower.includes("403")) {
|
||||
return "not_member";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
1
packages/views/slack/index.ts
Normal file
1
packages/views/slack/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SlackBindPage } from "./bind-page";
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
278
server/internal/handler/slack.go
Normal file
278
server/internal/handler/slack.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/slack"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// SlackInstallationResponse is the wire shape for a Slack installation row. The
|
||||
// encrypted bot token in config is INTENTIONALLY absent — it is server-internal
|
||||
// (only the outbound sender decrypts it). WS lease columns are runtime state,
|
||||
// not API surface, so they are omitted too.
|
||||
type SlackInstallationResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
BotUserID string `json:"bot_user_id"`
|
||||
InstallerUserID string `json:"installer_user_id"`
|
||||
Status string `json:"status"`
|
||||
InstalledAt string `json:"installed_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func slackInstallationToResponse(row db.ChannelInstallation) SlackInstallationResponse {
|
||||
info := slack.DecodePublicConfig(row.Config)
|
||||
return SlackInstallationResponse{
|
||||
ID: uuidToString(row.ID),
|
||||
WorkspaceID: uuidToString(row.WorkspaceID),
|
||||
AgentID: uuidToString(row.AgentID),
|
||||
TeamID: info.TeamID,
|
||||
BotUserID: info.BotUserID,
|
||||
InstallerUserID: uuidToString(row.InstallerUserID),
|
||||
Status: row.Status,
|
||||
InstalledAt: row.InstalledAt.Time.UTC().Format(time.RFC3339),
|
||||
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: row.UpdatedAt.Time.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// ListSlackInstallations (GET /api/workspaces/{id}/slack/installations) is
|
||||
// member-visible so the Integrations tab renders for non-admins. Response
|
||||
// flags mirror Lark:
|
||||
// - configured: at-rest encryption key is set (SlackInstall != nil).
|
||||
// - install_supported: kept for the management UI; true whenever configured,
|
||||
// since a BYO install needs only the at-rest key (no hosted OAuth creds).
|
||||
func (h *Handler) ListSlackInstallations(w http.ResponseWriter, r *http.Request) {
|
||||
if h.SlackInstall == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"installations": []SlackInstallationResponse{},
|
||||
"configured": false,
|
||||
"install_supported": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := h.SlackInstall.ListByWorkspace(r.Context(), wsUUID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list slack installations")
|
||||
return
|
||||
}
|
||||
out := make([]SlackInstallationResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, slackInstallationToResponse(row))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"installations": out,
|
||||
"configured": true,
|
||||
"install_supported": true,
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterSlackBYORequest is the body for a bring-your-own-app install: the two
|
||||
// tokens the user pasted from their own Slack app.
|
||||
type RegisterSlackBYORequest struct {
|
||||
BotToken string `json:"bot_token"`
|
||||
AppToken string `json:"app_token"`
|
||||
}
|
||||
|
||||
// RegisterSlackBYO (POST /api/workspaces/{id}/slack/install/byo?agent_id=…)
|
||||
// installs a user-supplied ("bring your own") Slack app for an agent, so several
|
||||
// agents can each have their own bot identity in the SAME Slack workspace.
|
||||
// Admin-only at the router. Unlike the hosted OAuth path this needs only the
|
||||
// at-rest key configured (SlackInstall != nil), NOT the hosted OAuth client
|
||||
// credentials — BYO is exactly the path for deployments without a hosted app.
|
||||
func (h *Handler) RegisterSlackBYO(w http.ResponseWriter, r *http.Request) {
|
||||
if h.SlackInstall == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "slack integration not enabled")
|
||||
return
|
||||
}
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
agentIDStr := strings.TrimSpace(r.URL.Query().Get("agent_id"))
|
||||
if agentIDStr == "" {
|
||||
writeError(w, http.StatusBadRequest, "agent_id is required")
|
||||
return
|
||||
}
|
||||
agentUUID, ok := parseUUIDOrBadRequest(w, agentIDStr, "agent_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Ownership pre-check at the boundary so a wrong agent_id is a clear 404.
|
||||
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
||||
ID: agentUUID,
|
||||
WorkspaceID: wsUUID,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusNotFound, "agent not found in this workspace")
|
||||
return
|
||||
}
|
||||
initiatorUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body RegisterSlackBYORequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
row, err := h.SlackInstall.RegisterBYO(r.Context(), slack.RegisterBYOParams{
|
||||
WorkspaceID: wsUUID,
|
||||
AgentID: agentUUID,
|
||||
InitiatorID: initiatorUUID,
|
||||
BotToken: body.BotToken,
|
||||
AppToken: body.AppToken,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, slack.ErrInvalidBotToken), errors.Is(err, slack.ErrInvalidAppToken), errors.Is(err, slack.ErrTokenAppMismatch):
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, slack.ErrTeamOwnedByAnotherWorkspace):
|
||||
writeError(w, http.StatusConflict, "this Slack app is already connected to a different Multica workspace")
|
||||
default:
|
||||
// The dominant non-sentinel failure here is auth.test rejecting the
|
||||
// pasted bot token (a user error), so guide the user to recheck the
|
||||
// tokens rather than surfacing an opaque 500.
|
||||
writeError(w, http.StatusBadRequest, "could not verify the Slack tokens — check the bot token and app-level token, that the app is installed to your workspace, and that it has the users:read scope")
|
||||
}
|
||||
return
|
||||
}
|
||||
// Broadcast so every open client (Settings, Agent Integrations, other tabs)
|
||||
// invalidates its installations query and shows the new bot — matching the
|
||||
// revoke event and Lark's install semantics. The installer's own tab also
|
||||
// invalidates locally, but other clients rely on this event.
|
||||
h.publishSlackInstallationCreated(row, userID)
|
||||
writeJSON(w, http.StatusOK, slackInstallationToResponse(row))
|
||||
}
|
||||
|
||||
// publishSlackInstallationCreated emits slack_installation:created for a newly
|
||||
// connected bot. The realtime layer fans it out to the workspace; the web app
|
||||
// listens on slack_installation:* to invalidate the installations query.
|
||||
func (h *Handler) publishSlackInstallationCreated(row db.ChannelInstallation, actorID string) {
|
||||
h.publish(protocol.EventSlackInstallationCreated, uuidToString(row.WorkspaceID), "user", actorID, map[string]any{
|
||||
"id": uuidToString(row.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeSlackInstallation (DELETE /api/workspaces/{id}/slack/installations/{installationId})
|
||||
// flips status to 'revoked'. Admin-only at the router. The row is preserved for
|
||||
// audit; a re-install (re-pasting the app's tokens) flips status back to 'active'.
|
||||
func (h *Handler) RevokeSlackInstallation(w http.ResponseWriter, r *http.Request) {
|
||||
if h.SlackInstall == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "slack integration not configured")
|
||||
return
|
||||
}
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
instUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "installationId"), "installation id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Workspace-scoped lookup so one workspace cannot revoke another's
|
||||
// installation by guessing the UUID.
|
||||
if _, err := h.SlackInstall.GetInWorkspace(r.Context(), instUUID, wsUUID); err != nil {
|
||||
if errors.Is(err, slack.ErrInstallationNotFound) {
|
||||
writeError(w, http.StatusNotFound, "slack installation not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to load installation")
|
||||
return
|
||||
}
|
||||
if err := h.SlackInstall.Revoke(r.Context(), instUUID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to revoke installation")
|
||||
return
|
||||
}
|
||||
h.publish(protocol.EventSlackInstallationRevoked, uuidToString(wsUUID), "user", userID, map[string]any{
|
||||
"id": uuidToString(instUUID),
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RedeemSlackBindingTokenRequest carries the raw token the user clicked through
|
||||
// from the bot's "link your account" prompt.
|
||||
type RedeemSlackBindingTokenRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// RedeemSlackBindingTokenResponse echoes the bound workspace/installation/user
|
||||
// so the frontend can confirm without a second fetch.
|
||||
type RedeemSlackBindingTokenResponse struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
InstallationID string `json:"installation_id"`
|
||||
SlackUserID string `json:"slack_user_id"`
|
||||
}
|
||||
|
||||
// RedeemSlackBindingToken (POST /api/slack/binding/redeem) binds the Slack user
|
||||
// id carried by the token to the logged-in Multica user. The redeemer's identity
|
||||
// comes from the session, not the token, so a stolen token cannot bind a Slack
|
||||
// id to an attacker's account. Failure modes map to distinct status codes:
|
||||
// - 410 Gone: token unknown / consumed / expired
|
||||
// - 409 Conflict: this Slack id is already bound to a different user
|
||||
// - 403 Forbidden: redeemer is not a workspace member
|
||||
func (h *Handler) RedeemSlackBindingToken(w http.ResponseWriter, r *http.Request) {
|
||||
if h.SlackBindingTokens == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "slack integration not configured")
|
||||
return
|
||||
}
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req RedeemSlackBindingTokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Token == "" {
|
||||
writeError(w, http.StatusBadRequest, "token is required")
|
||||
return
|
||||
}
|
||||
userUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
redeemed, err := h.SlackBindingTokens.RedeemAndBind(r.Context(), req.Token, userUUID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, slack.ErrBindingTokenInvalid):
|
||||
writeError(w, http.StatusGone, "binding token invalid or expired")
|
||||
case errors.Is(err, slack.ErrBindingAlreadyAssigned):
|
||||
writeError(w, http.StatusConflict, "this Slack account is already bound to a different Multica user")
|
||||
case errors.Is(err, slack.ErrBindingNotWorkspaceMember):
|
||||
writeError(w, http.StatusForbidden, "binding refused (are you a workspace member?)")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "failed to redeem token")
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, RedeemSlackBindingTokenResponse{
|
||||
WorkspaceID: uuidToString(redeemed.WorkspaceID),
|
||||
InstallationID: uuidToString(redeemed.InstallationID),
|
||||
SlackUserID: redeemed.SlackUserID,
|
||||
})
|
||||
}
|
||||
46
server/internal/handler/slack_test.go
Normal file
46
server/internal/handler/slack_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// A successful BYO install must broadcast slack_installation:created so all open
|
||||
// clients (not just the installer's tab) invalidate the installations query —
|
||||
// the regression Niko's review caught (RegisterSlackBYO previously only wrote
|
||||
// the response). Bus.Publish is synchronous, so the subscriber fires inline.
|
||||
func TestPublishSlackInstallationCreated(t *testing.T) {
|
||||
bus := events.New()
|
||||
h := &Handler{Bus: bus}
|
||||
|
||||
const (
|
||||
wsID = "11111111-1111-1111-1111-111111111111"
|
||||
instID = "22222222-2222-2222-2222-222222222222"
|
||||
)
|
||||
|
||||
var got events.Event
|
||||
fired := 0
|
||||
bus.Subscribe(protocol.EventSlackInstallationCreated, func(e events.Event) {
|
||||
got = e
|
||||
fired++
|
||||
})
|
||||
|
||||
h.publishSlackInstallationCreated(db.ChannelInstallation{
|
||||
ID: parseUUID(instID),
|
||||
WorkspaceID: parseUUID(wsID),
|
||||
}, "user-1")
|
||||
|
||||
if fired != 1 {
|
||||
t.Fatalf("expected slack_installation:created published once, got %d", fired)
|
||||
}
|
||||
if got.WorkspaceID != wsID || got.ActorType != "user" || got.ActorID != "user-1" {
|
||||
t.Errorf("event envelope = %+v", got)
|
||||
}
|
||||
payload, ok := got.Payload.(map[string]any)
|
||||
if !ok || payload["id"] != instID {
|
||||
t.Errorf("payload = %v, want installation id %s", got.Payload, instID)
|
||||
}
|
||||
}
|
||||
@@ -345,6 +345,16 @@ func (s *Supervisor) sweep(ctx context.Context) {
|
||||
}
|
||||
active := make(map[string]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
// Skip channel types with no registered per-installation Factory. Such
|
||||
// rows are driven outside the Supervisor (e.g. Slack's app-level Socket
|
||||
// Mode connector owns ONE deployment connection for all its
|
||||
// installations, so each Slack row carries only outbound creds + routing,
|
||||
// not its own connection). Without this guard the supervise loop would
|
||||
// acquire the lease, hit ErrUnknownType from Registry.Build, release, and
|
||||
// back off forever — churning the lease and the log on every such row.
|
||||
if _, ok := s.registry.Lookup(row.ChannelType); !ok {
|
||||
continue
|
||||
}
|
||||
id := uuidString(row.ID)
|
||||
active[id] = struct{}{}
|
||||
s.maybeRestartOnRotation(id, row)
|
||||
|
||||
@@ -255,6 +255,42 @@ func TestSupervisorAcquiresLeaseAndConnects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupervisorSkipsUnregisteredChannelType covers the B2 (MUL-3666) guard:
|
||||
// an active installation whose channel_type has no registered Factory must be
|
||||
// left alone — never leased, never Built — because it is driven outside the
|
||||
// Supervisor (Slack's app-level connector owns one shared connection for all
|
||||
// its installations). A registered type alongside it still connects normally.
|
||||
func TestSupervisorSkipsUnregisteredChannelType(t *testing.T) {
|
||||
q := newFakeStore()
|
||||
feishuID := uuidFromString(t, "2a111111-1111-1111-1111-111111111111")
|
||||
slackID := uuidFromString(t, "2b222222-2222-2222-2222-222222222222")
|
||||
q.installations = []Installation{
|
||||
activeInst(feishuID, "fp1"),
|
||||
{ID: slackID, ChannelType: channel.Type("slack"), Fingerprint: "fp2", Config: []byte(`{}`)},
|
||||
}
|
||||
|
||||
fc := &fakeChannel{typ: channel.TypeFeishu}
|
||||
var builds int32
|
||||
reg := fakeRegistry(fc, &builds, nil) // registers ONLY TypeFeishu
|
||||
|
||||
sup := NewSupervisor(q, reg, nil, fastConfig())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go sup.Run(ctx)
|
||||
|
||||
if !waitFor(300*time.Millisecond, func() bool { return fc.Connects() >= 1 }) {
|
||||
t.Fatalf("registered feishu installation should connect; connects=%d", fc.Connects())
|
||||
}
|
||||
// Give the supervisor a few sweep cycles to (not) act on the slack row.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if owner, ok := q.leaseHolder(slackID); ok {
|
||||
t.Fatalf("unregistered channel type must never be leased, got owner %q", owner)
|
||||
}
|
||||
if got := atomic.LoadInt32(&builds); got != 1 {
|
||||
t.Fatalf("only the registered feishu channel should be built, builds=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupervisorInjectsHandler(t *testing.T) {
|
||||
q := newFakeStore()
|
||||
instID := uuidFromString(t, "1a111111-1111-1111-1111-111111111111")
|
||||
|
||||
165
server/internal/integrations/slack/binding.go
Normal file
165
server/internal/integrations/slack/binding.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// This file is the Slack user-binding token flow: an unbound Slack user who
|
||||
// messages the bot gets a "link your account" prompt (minted here, delivered by
|
||||
// the OutboundReplier), clicks through to the in-product redeem page, and their
|
||||
// Slack user id is bound to their Multica account. It mirrors
|
||||
// lark.BindingTokenService but runs on the generic channel_* queries with
|
||||
// channel_type='slack' (lark's ChannelStore hardcodes 'feishu').
|
||||
|
||||
// BindingTokenTTL bounds a token's life. The channel_binding_token CHECK
|
||||
// enforces the same 15-minute cap so a misconfigured caller cannot mint longer.
|
||||
const BindingTokenTTL = 15 * time.Minute
|
||||
|
||||
var (
|
||||
// ErrBindingTokenInvalid: token unknown / already consumed / expired. One
|
||||
// opaque error for all three avoids a replay timing oracle.
|
||||
ErrBindingTokenInvalid = errors.New("slack: binding token invalid or expired")
|
||||
// ErrBindingAlreadyAssigned: this Slack user id is already bound to a
|
||||
// different Multica user (account transfer must go through explicit unbind).
|
||||
ErrBindingAlreadyAssigned = errors.New("slack: user id is already bound to a different user")
|
||||
// ErrBindingNotWorkspaceMember: the redeemer is not a member of the token's
|
||||
// workspace. Translated to 403 at the HTTP boundary.
|
||||
ErrBindingNotWorkspaceMember = errors.New("slack: redeemer is not a workspace member")
|
||||
)
|
||||
|
||||
// BindingToken is a freshly minted token. The raw value is returned exactly
|
||||
// once (embedded in the binding URL); only its hash is persisted.
|
||||
type BindingToken struct {
|
||||
Raw string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// RedeemedBindingToken is returned after a successful redemption.
|
||||
type RedeemedBindingToken struct {
|
||||
WorkspaceID pgtype.UUID
|
||||
InstallationID pgtype.UUID
|
||||
SlackUserID string
|
||||
}
|
||||
|
||||
// BindingTokenService mints and redeems Slack binding tokens. Redemption is
|
||||
// transactional: consuming the token and inserting the channel_user_binding row
|
||||
// commit together, so a failed bind never burns a token.
|
||||
type BindingTokenService struct {
|
||||
q *db.Queries
|
||||
tx engine.TxStarter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewBindingTokenService constructs the service. tx (a *pgxpool.Pool) is needed
|
||||
// for the transactional redeem path.
|
||||
func NewBindingTokenService(q *db.Queries, tx engine.TxStarter) *BindingTokenService {
|
||||
return &BindingTokenService{q: q, tx: tx, now: time.Now}
|
||||
}
|
||||
|
||||
// Mint creates a single-use binding token for (installation, slackUserID) and
|
||||
// returns the raw secret + expiry. The raw value must be delivered over Slack
|
||||
// (encrypted in transit by the platform) and never logged.
|
||||
func (s *BindingTokenService) Mint(ctx context.Context, workspaceID, installationID pgtype.UUID, slackUserID string) (BindingToken, error) {
|
||||
raw, err := randomBindingToken(32)
|
||||
if err != nil {
|
||||
return BindingToken{}, fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
expiresAt := s.now().Add(BindingTokenTTL)
|
||||
if _, err := s.q.CreateChannelBindingToken(ctx, db.CreateChannelBindingTokenParams{
|
||||
TokenHash: hashBindingToken(raw),
|
||||
WorkspaceID: workspaceID,
|
||||
InstallationID: installationID,
|
||||
ChannelType: string(TypeSlack),
|
||||
ChannelUserID: slackUserID,
|
||||
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
||||
}); err != nil {
|
||||
return BindingToken{}, fmt.Errorf("persist token: %w", err)
|
||||
}
|
||||
return BindingToken{Raw: raw, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
// RedeemAndBind atomically consumes a raw token and binds the Slack user id to
|
||||
// multicaUserID (taken from the session, never from the token). Returns
|
||||
// ErrBindingTokenInvalid / ErrBindingAlreadyAssigned / ErrBindingNotWorkspaceMember.
|
||||
func (s *BindingTokenService) RedeemAndBind(ctx context.Context, raw string, multicaUserID pgtype.UUID) (RedeemedBindingToken, error) {
|
||||
if s.tx == nil {
|
||||
return RedeemedBindingToken{}, errors.New("slack: BindingTokenService missing TxStarter")
|
||||
}
|
||||
tx, err := s.tx.Begin(ctx)
|
||||
if err != nil {
|
||||
return RedeemedBindingToken{}, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
qtx := s.q.WithTx(tx)
|
||||
|
||||
row, err := qtx.ConsumeChannelBindingToken(ctx, hashBindingToken(raw))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return RedeemedBindingToken{}, ErrBindingTokenInvalid
|
||||
}
|
||||
return RedeemedBindingToken{}, fmt.Errorf("consume token: %w", err)
|
||||
}
|
||||
|
||||
// Explicit membership gate (no member FK): returning before Commit rolls the
|
||||
// consume back, so a non-member's attempt does not burn the token.
|
||||
if _, err := qtx.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: multicaUserID,
|
||||
WorkspaceID: row.WorkspaceID,
|
||||
}); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return RedeemedBindingToken{}, ErrBindingNotWorkspaceMember
|
||||
}
|
||||
return RedeemedBindingToken{}, fmt.Errorf("check membership: %w", err)
|
||||
}
|
||||
|
||||
if _, err := qtx.CreateChannelUserBinding(ctx, db.CreateChannelUserBindingParams{
|
||||
WorkspaceID: row.WorkspaceID,
|
||||
MulticaUserID: multicaUserID,
|
||||
InstallationID: row.InstallationID,
|
||||
ChannelType: string(TypeSlack),
|
||||
ChannelUserID: row.ChannelUserID,
|
||||
Config: []byte(`{}`),
|
||||
}); err != nil {
|
||||
// pgx.ErrNoRows means the existing binding points at a different user —
|
||||
// the ON CONFLICT DO UPDATE WHERE multica_user_id=… gating rejected it.
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return RedeemedBindingToken{}, ErrBindingAlreadyAssigned
|
||||
}
|
||||
return RedeemedBindingToken{}, fmt.Errorf("create binding: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return RedeemedBindingToken{}, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return RedeemedBindingToken{
|
||||
WorkspaceID: row.WorkspaceID,
|
||||
InstallationID: row.InstallationID,
|
||||
SlackUserID: row.ChannelUserID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func randomBindingToken(n int) (string, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func hashBindingToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
189
server/internal/integrations/slack/byo_install.go
Normal file
189
server/internal/integrations/slack/byo_install.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/slack-go/slack"
|
||||
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// ErrInvalidBotToken / ErrInvalidAppToken are returned by RegisterBYO when a
|
||||
// pasted token is malformed (wrong prefix, or an app token whose app id cannot
|
||||
// be parsed). The handler maps them to 400 so the dialog can show a precise hint
|
||||
// instead of a generic failure.
|
||||
var (
|
||||
ErrInvalidBotToken = errors.New("slack: bot token must start with xoxb-")
|
||||
ErrInvalidAppToken = errors.New("slack: app-level token must start with xapp- and embed an app id")
|
||||
// ErrTokenAppMismatch is returned when the pasted bot token and app-level
|
||||
// token belong to DIFFERENT Slack apps. Persisting that pair would "connect"
|
||||
// but be broken: inbound arrives on the app token's socket (routed by its
|
||||
// app id) while mention detection + outbound use the bot token's identity.
|
||||
ErrTokenAppMismatch = errors.New("slack: the bot token and app-level token are from different Slack apps")
|
||||
)
|
||||
|
||||
// RegisterBYOParams are the inputs for a bring-your-own-app install: the agent
|
||||
// this bot represents, who is installing, and the two tokens the user pasted
|
||||
// from their own Slack app.
|
||||
type RegisterBYOParams struct {
|
||||
WorkspaceID pgtype.UUID
|
||||
AgentID pgtype.UUID
|
||||
InitiatorID pgtype.UUID
|
||||
BotToken string // xoxb-… — outbound Web API (chat.postMessage)
|
||||
AppToken string // xapp-… — this app's OWN Socket Mode connection (inbound)
|
||||
}
|
||||
|
||||
// RegisterBYO installs a user-supplied ("bring your own") Slack app for an agent.
|
||||
// The user creates their own Slack app, installs it to their workspace, and
|
||||
// pastes its bot token (xoxb-) + app-level token (xapp-). There is NO OAuth code
|
||||
// exchange: we validate the bot token live via auth.test (which also yields the
|
||||
// team id + bot user id), prove the bot + app tokens belong to the SAME app,
|
||||
// parse the real Slack app id out of the app-level token, encrypt BOTH tokens at
|
||||
// rest, and persist the installation.
|
||||
//
|
||||
// Because each BYO app is a distinct Slack app — a distinct bot identity — the
|
||||
// SAME Slack workspace can host several of them, one per agent. The stored
|
||||
// config carries the real app id for inbound routing; persistInstall keys the
|
||||
// row by (workspace, agent) and refuses the pair if that app id is already
|
||||
// connected to another agent/workspace. The dedicated Socket Mode connection
|
||||
// that consumes the stored app token lives in slack_channel.go; this method
|
||||
// only persists the installation.
|
||||
func (s *InstallService) RegisterBYO(ctx context.Context, p RegisterBYOParams) (db.ChannelInstallation, error) {
|
||||
botToken := strings.TrimSpace(p.BotToken)
|
||||
appToken := strings.TrimSpace(p.AppToken)
|
||||
if !strings.HasPrefix(botToken, "xoxb-") {
|
||||
return db.ChannelInstallation{}, ErrInvalidBotToken
|
||||
}
|
||||
appID, err := parseSlackAppID(appToken)
|
||||
if err != nil {
|
||||
return db.ChannelInstallation{}, err
|
||||
}
|
||||
|
||||
// Validate the bot token live and learn the team + bot user id. auth.test
|
||||
// authenticates with the bot token and returns the bot's OWN user id, which
|
||||
// is the @-mention identity inbound translation strips.
|
||||
auth, err := s.authTest(ctx, botToken)
|
||||
if err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("slack auth.test: %w", err)
|
||||
}
|
||||
if auth.TeamID == "" || auth.UserID == "" || auth.BotID == "" {
|
||||
return db.ChannelInstallation{}, errors.New("slack auth.test: response missing team_id / user_id / bot_id")
|
||||
}
|
||||
|
||||
// Prove the two tokens belong to the SAME Slack app: resolve the bot's
|
||||
// OWNING app id (bots.info on the bot id auth.test returned) and require it to
|
||||
// equal the app id embedded in the app-level token. Without this, pasting app
|
||||
// A's bot token with app B's app token would "connect" but be broken —
|
||||
// inbound arrives on app B's socket (routed by api_app_id=B) while mention
|
||||
// detection + outbound use app A's bot identity / token (Niko review).
|
||||
botAppID, err := s.botAppID(ctx, botToken, auth.BotID)
|
||||
if err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("slack bots.info: %w", err)
|
||||
}
|
||||
if botAppID != appID {
|
||||
return db.ChannelInstallation{}, ErrTokenAppMismatch
|
||||
}
|
||||
|
||||
// Validate the app-level token is live (Socket Mode can actually open) so we
|
||||
// never persist a token that will silently never receive events.
|
||||
if err := s.validateAppToken(ctx, appToken); err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("slack apps.connections.open: %w", err)
|
||||
}
|
||||
|
||||
sealedBot, err := s.box.Seal([]byte(botToken))
|
||||
if err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("encrypt slack bot token: %w", err)
|
||||
}
|
||||
sealedApp, err := s.box.Seal([]byte(appToken))
|
||||
if err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("encrypt slack app token: %w", err)
|
||||
}
|
||||
cfgJSON, err := json.Marshal(installConfig{
|
||||
AppID: appID,
|
||||
TeamID: auth.TeamID,
|
||||
BotUserID: auth.UserID,
|
||||
BotTokenEncrypted: base64.StdEncoding.EncodeToString(sealedBot),
|
||||
AppTokenEncrypted: base64.StdEncoding.EncodeToString(sealedApp),
|
||||
})
|
||||
if err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("encode slack installation config: %w", err)
|
||||
}
|
||||
|
||||
// Persist one bot per agent (the row is keyed by workspace + agent). The
|
||||
// stored config carries the real app id for inbound routing; persistInstall
|
||||
// refuses the pair if that app is already connected to another agent/workspace.
|
||||
return s.persistInstall(ctx, installPersist{
|
||||
wsID: p.WorkspaceID,
|
||||
agentID: p.AgentID,
|
||||
installerID: p.InitiatorID,
|
||||
configJSON: cfgJSON,
|
||||
})
|
||||
}
|
||||
|
||||
// slackOpts builds the slack.Client options shared by the install-time Web API
|
||||
// calls, honoring the apiURL override so tests can point them at an httptest
|
||||
// server. The Slack SDK appends the method name to the endpoint, so the base
|
||||
// must end in a slash. A fresh slice is returned each call (safe to append to).
|
||||
func (s *InstallService) slackOpts() []slack.Option {
|
||||
httpClient := s.httpClient
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
opts := []slack.Option{slack.OptionHTTPClient(httpClient)}
|
||||
if s.apiURL != "" {
|
||||
base := s.apiURL
|
||||
if !strings.HasSuffix(base, "/") {
|
||||
base += "/"
|
||||
}
|
||||
opts = append(opts, slack.OptionAPIURL(base))
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// authTest calls Slack auth.test with the bot token: validates it and returns
|
||||
// the team id, the bot's own user id, and the bot id (for the bots.info lookup).
|
||||
func (s *InstallService) authTest(ctx context.Context, botToken string) (*slack.AuthTestResponse, error) {
|
||||
return slack.New(botToken, s.slackOpts()...).AuthTestContext(ctx)
|
||||
}
|
||||
|
||||
// botAppID resolves the Slack app that OWNS the bot, via bots.info on the bot id
|
||||
// from auth.test. It is the only token→app_id path for a bot token, so it is how
|
||||
// we prove the pasted bot + app tokens belong to the same app.
|
||||
func (s *InstallService) botAppID(ctx context.Context, botToken, botID string) (string, error) {
|
||||
bot, err := slack.New(botToken, s.slackOpts()...).GetBotInfoContext(ctx, slack.GetBotInfoParameters{Bot: botID})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return bot.AppID, nil
|
||||
}
|
||||
|
||||
// validateAppToken confirms the app-level token can open a Socket Mode
|
||||
// connection (apps.connections.open) — a live check that the xapp is valid for
|
||||
// THIS app, so we never store a token that will silently receive nothing.
|
||||
func (s *InstallService) validateAppToken(ctx context.Context, appToken string) error {
|
||||
api := slack.New("", append(s.slackOpts(), slack.OptionAppLevelToken(appToken))...)
|
||||
_, _, err := api.StartSocketModeContext(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// parseSlackAppID extracts the real Slack app id from an app-level token. The
|
||||
// token format is `xapp-1-<APP_ID>-<gen>-<secret>` (e.g. xapp-1-A0BCXGVCS7R-…),
|
||||
// so the app id is the third dash-segment. It is the per-app storage / routing
|
||||
// key that lets multiple BYO apps coexist in one Slack workspace.
|
||||
func parseSlackAppID(appToken string) (string, error) {
|
||||
if !strings.HasPrefix(appToken, "xapp-") {
|
||||
return "", ErrInvalidAppToken
|
||||
}
|
||||
parts := strings.SplitN(appToken, "-", 5)
|
||||
if len(parts) < 4 || parts[2] == "" || !strings.HasPrefix(parts[2], "A") {
|
||||
return "", ErrInvalidAppToken
|
||||
}
|
||||
return parts[2], nil
|
||||
}
|
||||
290
server/internal/integrations/slack/byo_install_test.go
Normal file
290
server/internal/integrations/slack/byo_install_test.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// slackMock parameterizes the install-time Slack API stub. botAppID defaults to
|
||||
// the app id embedded in byoParams' xapp token (so the same-app check passes).
|
||||
type slackMock struct {
|
||||
authOK bool // auth.test result
|
||||
botAppID string // bots.info -> bot.app_id
|
||||
appTokenOK bool // apps.connections.open result
|
||||
}
|
||||
|
||||
// slackMockServer stubs the three Web API calls RegisterBYO makes: auth.test
|
||||
// (bot token), bots.info (bot id -> owning app id), apps.connections.open (app
|
||||
// token live check).
|
||||
func slackMockServer(t *testing.T, m slackMock) *httptest.Server {
|
||||
t.Helper()
|
||||
if m.botAppID == "" {
|
||||
m.botAppID = "A0BCXGVCS7R"
|
||||
}
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/auth.test":
|
||||
if !m.authOK {
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"team_id":"T999","user_id":"UBOTBYO","bot_id":"B0BOT","team":"Acme Inc","url":"https://acme.slack.com/"}`))
|
||||
case "/bots.info":
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"bot":{"id":"B0BOT","app_id":%q,"user_id":"UBOTBYO"}}`, m.botAppID)))
|
||||
case "/apps.connections.open":
|
||||
if !m.appTokenOK {
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"url":"wss://example.test/link"}`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error":"unknown_method"}`))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// authTestServer is the happy-path stub (valid bot token, matching app id, live
|
||||
// app token) unless ok=false, which makes auth.test reject the bot token.
|
||||
func authTestServer(t *testing.T, ok bool) *httptest.Server {
|
||||
return slackMockServer(t, slackMock{authOK: ok, appTokenOK: true})
|
||||
}
|
||||
|
||||
func byoParams(ws, agent string) RegisterBYOParams {
|
||||
return RegisterBYOParams{
|
||||
WorkspaceID: pgtypeUUID(ws),
|
||||
AgentID: pgtypeUUID(agent),
|
||||
InitiatorID: pgtypeUUID("33333333-3333-3333-3333-333333333333"),
|
||||
BotToken: "xoxb-real-bot-token",
|
||||
AppToken: "xapp-1-A0BCXGVCS7R-111-appsecret",
|
||||
}
|
||||
}
|
||||
|
||||
// pgtypeUUID is a test-local UUID parse that panics on bad input (test data is
|
||||
// always valid), so byoParams stays a plain literal.
|
||||
func pgtypeUUID(s string) pgtype.UUID {
|
||||
var u pgtype.UUID
|
||||
if err := u.Scan(s); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestParseSlackAppID(t *testing.T) {
|
||||
cases := []struct {
|
||||
token string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"xapp-1-A0BCXGVCS7R-111-secret", "A0BCXGVCS7R", false},
|
||||
{"xapp-1-A12345-9-abc", "A12345", false},
|
||||
{"xoxb-not-an-app-token", "", true},
|
||||
{"xapp-1-", "", true},
|
||||
{"xapp-1-B123-9-abc", "", true}, // app ids start with A
|
||||
{"", "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := parseSlackAppID(c.token)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("parseSlackAppID(%q) = %q, want error", c.token, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || got != c.want {
|
||||
t.Errorf("parseSlackAppID(%q) = %q, %v; want %q", c.token, got, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBYO_PersistsEncryptedTokensKeyedByAppID(t *testing.T) {
|
||||
srv := authTestServer(t, true)
|
||||
defer srv.Close()
|
||||
|
||||
q := &fakeInstallQueries{rowID: mustUUID(t, "44444444-4444-4444-4444-444444444444")}
|
||||
svc := newTestInstallService(t, q) // BYO needs NO OAuth creds
|
||||
svc.apiURL = srv.URL + "/"
|
||||
|
||||
row, err := svc.RegisterBYO(context.Background(), byoParams(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterBYO: %v", err)
|
||||
}
|
||||
if row.ID != q.rowID {
|
||||
t.Errorf("row id = %v, want %v", row.ID, q.rowID)
|
||||
}
|
||||
if !q.upsertCalled || q.upsertParams.ChannelType != string(TypeSlack) {
|
||||
t.Fatalf("upsert not called for slack: %+v", q.upsertParams)
|
||||
}
|
||||
|
||||
var cfg installConfig
|
||||
if err := json.Unmarshal(q.upsertParams.Config, &cfg); err != nil {
|
||||
t.Fatalf("decode upserted config: %v", err)
|
||||
}
|
||||
// Keyed by the REAL app id (parsed from the xapp token), NOT the team id —
|
||||
// this is what lets several BYO apps share one Slack workspace.
|
||||
if cfg.AppID != "A0BCXGVCS7R" {
|
||||
t.Errorf("config app_id = %q, want the real app id A0BCXGVCS7R", cfg.AppID)
|
||||
}
|
||||
if cfg.TeamID != "T999" || cfg.BotUserID != "UBOTBYO" {
|
||||
t.Errorf("config team/bot = %q/%q, want T999/UBOTBYO", cfg.TeamID, cfg.BotUserID)
|
||||
}
|
||||
// Both tokens stored encrypted (never plaintext) and both decrypt back.
|
||||
if cfg.BotTokenEncrypted == "" || cfg.AppTokenEncrypted == "" {
|
||||
t.Fatalf("both tokens must be stored: %+v", cfg)
|
||||
}
|
||||
if strings.Contains(cfg.BotTokenEncrypted, "xoxb-") || strings.Contains(cfg.AppTokenEncrypted, "xapp-") {
|
||||
t.Error("tokens must be stored encrypted, not plaintext")
|
||||
}
|
||||
botTok, err := decryptToken(cfg.BotTokenEncrypted, svc.box.Open)
|
||||
if err != nil || botTok != "xoxb-real-bot-token" {
|
||||
t.Errorf("decrypted bot token = %q, %v", botTok, err)
|
||||
}
|
||||
appTok, err := decryptToken(cfg.AppTokenEncrypted, svc.box.Open)
|
||||
if err != nil || appTok != "xapp-1-A0BCXGVCS7R-111-appsecret" {
|
||||
t.Errorf("decrypted app token = %q, %v", appTok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBYO_InvalidTokens(t *testing.T) {
|
||||
q := &fakeInstallQueries{}
|
||||
svc := newTestInstallService(t, q)
|
||||
|
||||
// Bad bot token prefix — rejected before any network call or upsert.
|
||||
p := byoParams("11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222")
|
||||
p.BotToken = "nope-not-a-bot-token"
|
||||
if _, err := svc.RegisterBYO(context.Background(), p); err != ErrInvalidBotToken {
|
||||
t.Errorf("bad bot token = %v, want ErrInvalidBotToken", err)
|
||||
}
|
||||
// Bad app token.
|
||||
p = byoParams("11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222")
|
||||
p.AppToken = "xapp-broken"
|
||||
if _, err := svc.RegisterBYO(context.Background(), p); err != ErrInvalidAppToken {
|
||||
t.Errorf("bad app token = %v, want ErrInvalidAppToken", err)
|
||||
}
|
||||
if q.upsertCalled {
|
||||
t.Error("malformed tokens must be rejected before the upsert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBYO_AuthTestFailure(t *testing.T) {
|
||||
srv := authTestServer(t, false) // Slack rejects the bot token
|
||||
defer srv.Close()
|
||||
q := &fakeInstallQueries{}
|
||||
svc := newTestInstallService(t, q)
|
||||
svc.apiURL = srv.URL + "/"
|
||||
|
||||
if _, err := svc.RegisterBYO(context.Background(), byoParams(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
)); err == nil {
|
||||
t.Fatal("expected an error when auth.test rejects the bot token")
|
||||
}
|
||||
if q.upsertCalled {
|
||||
t.Error("a failed auth.test must not persist an installation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBYO_AppAlreadyConnected_Rejected(t *testing.T) {
|
||||
srv := authTestServer(t, true)
|
||||
defer srv.Close()
|
||||
// The pasted app is already connected to another agent / workspace, so the
|
||||
// (channel_type, app_id) routing index rejects the upsert (unique violation).
|
||||
// We must refuse, not steal it.
|
||||
q := &fakeInstallQueries{
|
||||
rowID: mustUUID(t, "44444444-4444-4444-4444-444444444444"),
|
||||
appIDTaken: true,
|
||||
}
|
||||
svc := newTestInstallService(t, q)
|
||||
svc.apiURL = srv.URL + "/"
|
||||
|
||||
if _, err := svc.RegisterBYO(context.Background(), byoParams(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
)); err != ErrTeamOwnedByAnotherWorkspace {
|
||||
t.Fatalf("app already connected = %v, want ErrTeamOwnedByAnotherWorkspace", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBYO_ReconnectSameAgent_UpdatesRowInPlace(t *testing.T) {
|
||||
srv := authTestServer(t, true)
|
||||
defer srv.Close()
|
||||
// The agent already has a Slack row (e.g. a previously-disconnected app).
|
||||
// Re-connecting it — even with a NEW app — must UPDATE that same row in place
|
||||
// (keyed by workspace+agent), not error on the (workspace, agent, channel)
|
||||
// unique. The fake returns the existing row id on the upsert.
|
||||
existingID := mustUUID(t, "55555555-5555-5555-5555-555555555555")
|
||||
q := &fakeInstallQueries{
|
||||
rowID: mustUUID(t, "44444444-4444-4444-4444-444444444444"),
|
||||
existing: &db.ChannelInstallation{
|
||||
ID: existingID,
|
||||
WorkspaceID: mustUUID(t, "11111111-1111-1111-1111-111111111111"),
|
||||
AgentID: mustUUID(t, "22222222-2222-2222-2222-222222222222"),
|
||||
},
|
||||
}
|
||||
svc := newTestInstallService(t, q)
|
||||
svc.apiURL = srv.URL + "/"
|
||||
|
||||
row, err := svc.RegisterBYO(context.Background(), byoParams(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterBYO: %v", err)
|
||||
}
|
||||
if row.ID != existingID {
|
||||
t.Errorf("reconnect should reuse the agent's existing row %v, got %v", existingID, row.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBYO_TokenAppMismatch(t *testing.T) {
|
||||
// The bot token belongs to a DIFFERENT app (bots.info -> A0OTHER) than the
|
||||
// app id embedded in the xapp token (A0BCXGVCS7R) — must be rejected so we
|
||||
// never persist a broken installation (Niko review).
|
||||
srv := slackMockServer(t, slackMock{authOK: true, botAppID: "A0OTHERAPP", appTokenOK: true})
|
||||
defer srv.Close()
|
||||
q := &fakeInstallQueries{}
|
||||
svc := newTestInstallService(t, q)
|
||||
svc.apiURL = srv.URL + "/"
|
||||
|
||||
if _, err := svc.RegisterBYO(context.Background(), byoParams(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
)); err != ErrTokenAppMismatch {
|
||||
t.Fatalf("mismatched tokens = %v, want ErrTokenAppMismatch", err)
|
||||
}
|
||||
if q.upsertCalled {
|
||||
t.Error("mismatched bot/app tokens must be rejected before the upsert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBYO_AppTokenNotLive(t *testing.T) {
|
||||
// auth.test + same-app check pass, but apps.connections.open rejects the app
|
||||
// token — we must not persist a token that will never receive events.
|
||||
srv := slackMockServer(t, slackMock{authOK: true, appTokenOK: false})
|
||||
defer srv.Close()
|
||||
q := &fakeInstallQueries{}
|
||||
svc := newTestInstallService(t, q)
|
||||
svc.apiURL = srv.URL + "/"
|
||||
|
||||
if _, err := svc.RegisterBYO(context.Background(), byoParams(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
)); err == nil {
|
||||
t.Fatal("expected an error when the app-level token is not live")
|
||||
}
|
||||
if q.upsertCalled {
|
||||
t.Error("an invalid app token must not persist an installation")
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,11 @@ package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/slack-go/slack"
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
"github.com/slack-go/slack/socketmode"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
||||
)
|
||||
@@ -25,89 +20,22 @@ const TypeSlack channel.Type = "slack"
|
||||
// a message around 40k characters; we chunk below that with headroom.
|
||||
const maxMessageRunes = 38000
|
||||
|
||||
// slackChannel is the Slack implementation of channel.Channel. One instance is
|
||||
// built per channel_installation by the registered Factory. It holds only what
|
||||
// Connect/Send need (the decoded credentials + an API client); the installation
|
||||
// identity is resolved per message by the Router, so it is absent here — the
|
||||
// same split the Feishu adapter uses.
|
||||
type slackChannel struct {
|
||||
creds credentials
|
||||
api *slack.Client
|
||||
handler channel.InboundHandler
|
||||
logger *slog.Logger
|
||||
mentionRe *regexp.Regexp
|
||||
// slackSender posts agent replies back to Slack via chat.postMessage. It is the
|
||||
// OUTBOUND half: it holds the per-installation bot token (xoxb-) the reply must
|
||||
// be sent with (inbound runs on the per-installation Socket Mode connection in
|
||||
// slack_channel.go). The installation identity (workspace / agent / installer)
|
||||
// is resolved per message by the Router, so it is absent here.
|
||||
type slackSender struct {
|
||||
creds credentials
|
||||
api *slack.Client
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
var _ channel.Channel = (*slackChannel)(nil)
|
||||
|
||||
func (c *slackChannel) Type() channel.Type { return TypeSlack }
|
||||
|
||||
// Connect opens the Slack Socket Mode WebSocket and runs the receive loop,
|
||||
// blocking until ctx is cancelled or the connection drops — the contract
|
||||
// engine.Supervisor relies on to tie lease renewal to connection liveness
|
||||
// (matching feishuChannel.Connect). Each decoded Events API message is
|
||||
// normalized to a channel.InboundMessage and handed to the engine handler. The
|
||||
// envelope is ACKed immediately on receipt (Slack expires un-ACKed envelopes in
|
||||
// ~3s) so the handler's slower DB work never races the ACK.
|
||||
func (c *slackChannel) Connect(ctx context.Context) error {
|
||||
if c.handler == nil {
|
||||
return errors.New("slack: inbound handler not configured")
|
||||
}
|
||||
if c.api == nil {
|
||||
return errors.New("slack: api client not configured")
|
||||
}
|
||||
sm := socketmode.New(c.api)
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() { runErr <- sm.RunContext(ctx) }()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Graceful teardown: the Supervisor cancelled the run context.
|
||||
return nil
|
||||
case err := <-runErr:
|
||||
// The managed connection loop ended. On ctx cancellation this is a
|
||||
// graceful stop; otherwise it is a real failure the Supervisor
|
||||
// retries under backoff.
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("slack: socket mode connection closed")
|
||||
case evt, ok := <-sm.Events:
|
||||
if !ok {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New("slack: socket mode event stream closed")
|
||||
}
|
||||
if err := c.handleSocketEvent(ctx, sm, evt); err != nil {
|
||||
// A handler error is an infrastructure failure (InboundHandler
|
||||
// contract): surface it so the Supervisor tears the connection
|
||||
// down and reconnects under backoff, instead of silently
|
||||
// dropping every subsequent event. ctx cancellation is a
|
||||
// graceful stop, not a failure.
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect is a no-op: the Socket Mode loop is torn down by ctx cancellation
|
||||
// (the Supervisor cancels the run context), mirroring feishuChannel.Disconnect.
|
||||
func (c *slackChannel) Disconnect(ctx context.Context) error { return nil }
|
||||
|
||||
// Send delivers a minimal text reply via chat.postMessage, threading into
|
||||
// out.ThreadID when set so a decoupled reply lands back in the originating
|
||||
// thread. Long bodies are chunked under Slack's per-message cap; the returned
|
||||
// SendResult carries the timestamp of the LAST posted chunk.
|
||||
func (c *slackChannel) Send(ctx context.Context, out channel.OutboundMessage) (channel.SendResult, error) {
|
||||
func (c *slackSender) Send(ctx context.Context, out channel.OutboundMessage) (channel.SendResult, error) {
|
||||
if c.api == nil {
|
||||
return channel.SendResult{}, errors.New("slack: api client not configured")
|
||||
}
|
||||
@@ -132,237 +60,14 @@ func (c *slackChannel) Send(ctx context.Context, out channel.OutboundMessage) (c
|
||||
return channel.SendResult{MessageID: lastTS}, nil
|
||||
}
|
||||
|
||||
// Capabilities declares what the Slack adapter supports TODAY. Declaration
|
||||
// only — the engine performs no degradation, and callers pick a rendering from
|
||||
// these bits, so declaring a capability the Send path cannot fulfil would
|
||||
// mislead them. The minimal Send delivers text into a chat or thread, so only
|
||||
// CapText | CapThreadReply are declared. Block Kit (CapRichCard), file
|
||||
// attachments (CapAttachment) and chat.update edits (CapMessageEdit) are
|
||||
// deferred until those paths are actually wired.
|
||||
func (c *slackChannel) Capabilities() channel.Capability {
|
||||
return channel.CapText | channel.CapThreadReply
|
||||
}
|
||||
|
||||
// ---- inbound ----
|
||||
|
||||
func (c *slackChannel) handleSocketEvent(ctx context.Context, sm *socketmode.Client, evt socketmode.Event) error {
|
||||
switch evt.Type {
|
||||
case socketmode.EventTypeEventsAPI:
|
||||
eventsAPI, ok := evt.Data.(slackevents.EventsAPIEvent)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// ACK first: Slack expires un-ACKed envelopes in ~3s, far below the
|
||||
// handler's DB work. The ACK is independent of the handler outcome —
|
||||
// a handler error is surfaced to the Supervisor (reconnect/backoff),
|
||||
// not retried through the un-ACK path.
|
||||
if evt.Request != nil {
|
||||
if err := sm.Ack(*evt.Request); err != nil {
|
||||
c.logger.WarnContext(ctx, "slack: ack failed", "error", err)
|
||||
}
|
||||
}
|
||||
return c.dispatchEventsAPI(ctx, eventsAPI)
|
||||
case socketmode.EventTypeConnecting, socketmode.EventTypeConnected, socketmode.EventTypeHello:
|
||||
c.logger.DebugContext(ctx, "slack: socket mode", "event", evt.Type)
|
||||
case socketmode.EventTypeIncomingError, socketmode.EventTypeErrorBadMessage:
|
||||
c.logger.WarnContext(ctx, "slack: socket mode error", "event", evt.Type)
|
||||
default:
|
||||
// Interactive / slash-command / other events are out of scope for the
|
||||
// minimal adapter; ACK so Slack does not retry, then ignore.
|
||||
if evt.Request != nil {
|
||||
_ = sm.Ack(*evt.Request)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *slackChannel) dispatchEventsAPI(ctx context.Context, e slackevents.EventsAPIEvent) error {
|
||||
var (
|
||||
msg channel.InboundMessage
|
||||
ok bool
|
||||
)
|
||||
switch inner := e.InnerEvent.Data.(type) {
|
||||
case *slackevents.AppMentionEvent:
|
||||
msg, ok = c.inboundFromAppMention(e, inner)
|
||||
case *slackevents.MessageEvent:
|
||||
msg, ok = c.inboundFromMessage(e, inner)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// A non-nil handler error is an infrastructure failure; propagate it so the
|
||||
// Supervisor reconnects (InboundHandler contract). A legitimate product
|
||||
// drop (dedup hit / unbound sender / group filter) returns nil — not an
|
||||
// error — so it does not tear the connection down.
|
||||
return c.handler(ctx, msg)
|
||||
}
|
||||
|
||||
// inboundFromMessage normalizes a Slack message event. It returns ok=false for
|
||||
// events that must not reach the core: the bot's own messages and other bots'
|
||||
// messages (loop guard), and edits/deletes/joins and similar subtyped system
|
||||
// messages (only brand-new user messages are ingested).
|
||||
//
|
||||
// Group addressing policy (v1, deliberate): a group message is addressed to the
|
||||
// bot only when it carries an explicit <@bot> mention. Mention-free follow-ups
|
||||
// inside a thread the bot is already engaged in are NOT auto-addressed here:
|
||||
// "reply to a bot message" is session state, so it belongs in the session-aware
|
||||
// shared service / resolver layer (which can detect an existing bound session
|
||||
// for the thread and survive reconnects) rather than in per-connection adapter
|
||||
// memory. Until that lands, channel/thread continuation requires re-mentioning
|
||||
// the bot. P2P (DM) ingests every message, unchanged.
|
||||
func (c *slackChannel) inboundFromMessage(e slackevents.EventsAPIEvent, m *slackevents.MessageEvent) (channel.InboundMessage, bool) {
|
||||
if m.BotID != "" || m.SubType == "bot_message" {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
if m.User == "" || (c.creds.BotUserID != "" && m.User == c.creds.BotUserID) {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
if !isIngestableSubtype(m.SubType) {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
|
||||
chatType := slackChatType(m.Channel, m.ChannelType)
|
||||
addressed := chatType == channel.ChatTypeP2P || c.mentionsBot(m.Text)
|
||||
return c.buildInbound(e, buildInboundParams{
|
||||
eventType: "message",
|
||||
subType: m.SubType,
|
||||
channelID: m.Channel,
|
||||
userID: m.User,
|
||||
text: m.Text,
|
||||
ts: m.TimeStamp,
|
||||
threadTS: m.ThreadTimeStamp,
|
||||
chatType: chatType,
|
||||
addressed: addressed,
|
||||
}), true
|
||||
}
|
||||
|
||||
// inboundFromAppMention normalizes an app_mention event. An app_mention is, by
|
||||
// definition, addressed to the bot and occurs in a channel (group). The same
|
||||
// channel @mention also arrives as a message event with the identical ts, so
|
||||
// the engine's (installation, message_id=ts) dedup collapses the pair — no
|
||||
// special-casing needed here.
|
||||
func (c *slackChannel) inboundFromAppMention(e slackevents.EventsAPIEvent, m *slackevents.AppMentionEvent) (channel.InboundMessage, bool) {
|
||||
if m.BotID != "" || m.User == "" || (c.creds.BotUserID != "" && m.User == c.creds.BotUserID) {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
return c.buildInbound(e, buildInboundParams{
|
||||
eventType: "app_mention",
|
||||
channelID: m.Channel,
|
||||
userID: m.User,
|
||||
text: m.Text,
|
||||
ts: m.TimeStamp,
|
||||
threadTS: m.ThreadTimeStamp,
|
||||
chatType: channel.ChatTypeGroup,
|
||||
addressed: true,
|
||||
}), true
|
||||
}
|
||||
|
||||
type buildInboundParams struct {
|
||||
eventType string
|
||||
subType string
|
||||
channelID string
|
||||
userID string
|
||||
text string
|
||||
ts string
|
||||
threadTS string
|
||||
chatType channel.ChatType
|
||||
addressed bool
|
||||
}
|
||||
|
||||
func (c *slackChannel) buildInbound(e slackevents.EventsAPIEvent, p buildInboundParams) channel.InboundMessage {
|
||||
teamID := e.TeamID
|
||||
if teamID == "" {
|
||||
teamID = c.creds.TeamID
|
||||
}
|
||||
raw, _ := json.Marshal(slackRawEvent{
|
||||
TeamID: teamID,
|
||||
APIAppID: e.APIAppID,
|
||||
EventType: p.eventType,
|
||||
SubType: p.subType,
|
||||
ChannelType: string(p.chatType),
|
||||
})
|
||||
var reply *channel.ReplyCtx
|
||||
if p.threadTS != "" && p.threadTS != p.ts {
|
||||
reply = &channel.ReplyCtx{MessageID: p.threadTS, RootID: p.threadTS}
|
||||
}
|
||||
return channel.InboundMessage{
|
||||
EventID: p.ts,
|
||||
MessageID: p.ts,
|
||||
Type: channel.MsgTypeText,
|
||||
Text: c.cleanText(p.text),
|
||||
ReplyTo: reply,
|
||||
AddressedToBot: p.addressed,
|
||||
Source: channel.Source{
|
||||
ChannelType: TypeSlack,
|
||||
ChatID: p.channelID,
|
||||
ChatType: p.chatType,
|
||||
SenderID: p.userID,
|
||||
ThreadID: p.threadTS,
|
||||
},
|
||||
Raw: raw,
|
||||
}
|
||||
}
|
||||
|
||||
// slackRawEvent carries the Slack-specific fields the cross-platform envelope
|
||||
// does not — read back only inside the Slack resolvers (team_id routes the
|
||||
// installation; the core never reads Raw).
|
||||
type slackRawEvent struct {
|
||||
TeamID string `json:"team_id"`
|
||||
APIAppID string `json:"api_app_id,omitempty"`
|
||||
EventType string `json:"event_type"`
|
||||
SubType string `json:"subtype,omitempty"`
|
||||
ChannelType string `json:"channel_type,omitempty"`
|
||||
}
|
||||
|
||||
// cleanText strips a leading/embedded bot mention token and trims surrounding
|
||||
// whitespace so the core sees the user's actual prompt, not "<@U123> hi".
|
||||
func (c *slackChannel) cleanText(text string) string {
|
||||
if c.mentionRe != nil {
|
||||
text = c.mentionRe.ReplaceAllString(text, "")
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// mentionsBot reports whether text contains an @-mention of this bot. Slack
|
||||
// renders a mention as <@U123> or <@U123|name>.
|
||||
func (c *slackChannel) mentionsBot(text string) bool {
|
||||
return c.mentionRe != nil && c.mentionRe.MatchString(text)
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// slackChatType maps a Slack channel id / channel_type to the normalized
|
||||
// ChatType. Only a 1:1 direct message ("im", or a "D…" channel id) is p2p;
|
||||
// everything else — public/private channels AND multi-party DMs ("mpim", which
|
||||
// are multi-person conversations) — is a group. A group routes through the
|
||||
// engine's "must address the bot" filter, so plain chatter in a multi-party DM
|
||||
// is not mistaken for a prompt to the bot.
|
||||
func slackChatType(channelID, channelType string) channel.ChatType {
|
||||
switch channelType {
|
||||
case "im":
|
||||
return channel.ChatTypeP2P
|
||||
case "mpim", "channel", "group", "private_channel":
|
||||
return channel.ChatTypeGroup
|
||||
}
|
||||
if strings.HasPrefix(channelID, "D") {
|
||||
return channel.ChatTypeP2P
|
||||
}
|
||||
return channel.ChatTypeGroup
|
||||
}
|
||||
|
||||
// isIngestableSubtype reports whether a message subtype is a brand-new user
|
||||
// message the core should ingest. Empty subtype is the normal case;
|
||||
// thread_broadcast and file_share are real user messages; everything else
|
||||
// (message_changed, message_deleted, channel_join, …) is a system/edit event.
|
||||
func isIngestableSubtype(subType string) bool {
|
||||
switch subType {
|
||||
case "", "thread_broadcast", "file_share":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
// newSlackSender builds a Send-only client from decoded credentials and a
|
||||
// configured API client. Kept separate from the outbound subscriber so tests
|
||||
// can inject a client pointed at an httptest server.
|
||||
func newSlackSender(creds credentials, api *slack.Client, logger *slog.Logger) *slackSender {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &slackSender{creds: creds, api: api, logger: logger}
|
||||
}
|
||||
|
||||
// outboundThreadTS picks the Slack thread_ts for an outbound reply: an explicit
|
||||
@@ -394,59 +99,3 @@ func chunkMessage(text string, maxRunes int) []string {
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
// ---- registration ----
|
||||
|
||||
// SlackChannelDeps bundles the shared dependencies the Slack Factory closes
|
||||
// over. The inbound handler is supplied per-build by the engine via
|
||||
// channel.Config.Handler, mirroring FeishuChannelDeps.
|
||||
type SlackChannelDeps struct {
|
||||
// Decrypt turns the stored bot/app token ciphertext into plaintext. A nil
|
||||
// Decrypter treats stored tokens as plaintext (tests / un-encrypted dev).
|
||||
Decrypt Decrypter
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// RegisterSlack registers the Slack Factory on reg under TypeSlack so the
|
||||
// engine.Supervisor can build a slackChannel per installation. "Adding a
|
||||
// channel" is this call plus the adapter — no engine edit.
|
||||
func RegisterSlack(reg *channel.Registry, deps SlackChannelDeps) {
|
||||
reg.Register(TypeSlack, newSlackFactory(deps))
|
||||
}
|
||||
|
||||
func newSlackFactory(deps SlackChannelDeps) channel.Factory {
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return func(cfg channel.Config) (channel.Channel, error) {
|
||||
creds, err := decodeCredentials(cfg.Raw, deps.Decrypt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if creds.BotToken == "" || creds.AppToken == "" {
|
||||
return nil, errors.New("slack: installation config missing bot or app token")
|
||||
}
|
||||
return newSlackChannel(creds, slack.New(creds.BotToken, slack.OptionAppLevelToken(creds.AppToken)), cfg.Handler, logger), nil
|
||||
}
|
||||
}
|
||||
|
||||
// newSlackChannel builds a slackChannel from decoded credentials and a
|
||||
// configured API client. Kept separate from the Factory so tests can inject a
|
||||
// client pointed at an httptest server.
|
||||
func newSlackChannel(creds credentials, api *slack.Client, handler channel.InboundHandler, logger *slog.Logger) *slackChannel {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
var mentionRe *regexp.Regexp
|
||||
if creds.BotUserID != "" {
|
||||
mentionRe = regexp.MustCompile(`<@` + regexp.QuoteMeta(creds.BotUserID) + `(\|[^>]*)?>`)
|
||||
}
|
||||
return &slackChannel{
|
||||
creds: creds,
|
||||
api: api,
|
||||
handler: handler,
|
||||
logger: logger,
|
||||
mentionRe: mentionRe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,285 +2,16 @@ package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/slack-go/slack"
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
||||
)
|
||||
|
||||
func testChannel(botUserID string) *slackChannel {
|
||||
return newSlackChannel(credentials{TeamID: "T1", BotUserID: botUserID}, nil, nil, nil)
|
||||
}
|
||||
|
||||
func eventsAPI(inner any) slackevents.EventsAPIEvent {
|
||||
return slackevents.EventsAPIEvent{
|
||||
TeamID: "T1",
|
||||
APIAppID: "A1",
|
||||
InnerEvent: slackevents.EventsAPIInnerEvent{
|
||||
Data: inner,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_DM(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
e := eventsAPI(nil)
|
||||
msg, ok := c.inboundFromMessage(e, &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "hello bot",
|
||||
Channel: "D123",
|
||||
ChannelType: "im",
|
||||
TimeStamp: "1700000000.000100",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected DM message to be ingestable")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeP2P {
|
||||
t.Errorf("ChatType = %q, want p2p", msg.Source.ChatType)
|
||||
}
|
||||
if !msg.AddressedToBot {
|
||||
t.Error("DM should always be addressed to bot")
|
||||
}
|
||||
if msg.Source.ChannelType != TypeSlack {
|
||||
t.Errorf("ChannelType = %q, want slack", msg.Source.ChannelType)
|
||||
}
|
||||
if msg.MessageID != "1700000000.000100" || msg.EventID != msg.MessageID {
|
||||
t.Errorf("MessageID/EventID = %q/%q, want the ts", msg.MessageID, msg.EventID)
|
||||
}
|
||||
if msg.Source.SenderID != "UALICE" || msg.Source.ChatID != "D123" {
|
||||
t.Errorf("sender/chat = %q/%q", msg.Source.SenderID, msg.Source.ChatID)
|
||||
}
|
||||
if msg.Text != "hello bot" {
|
||||
t.Errorf("Text = %q", msg.Text)
|
||||
}
|
||||
// team_id must be in Raw so the installation resolver can route.
|
||||
var raw slackRawEvent
|
||||
if err := json.Unmarshal(msg.Raw, &raw); err != nil {
|
||||
t.Fatalf("decode raw: %v", err)
|
||||
}
|
||||
if raw.TeamID != "T1" || raw.EventType != "message" {
|
||||
t.Errorf("raw = %+v", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_ChannelMention(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "<@UBOT> create an issue",
|
||||
Channel: "C123",
|
||||
ChannelType: "channel",
|
||||
TimeStamp: "1700000000.000200",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected channel message to be ingestable")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeGroup {
|
||||
t.Errorf("ChatType = %q, want group", msg.Source.ChatType)
|
||||
}
|
||||
if !msg.AddressedToBot {
|
||||
t.Error("channel message mentioning the bot should be addressed to bot")
|
||||
}
|
||||
if msg.Text != "create an issue" {
|
||||
t.Errorf("Text = %q, want mention stripped", msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_ChannelNoMention(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "just chatting with the team",
|
||||
Channel: "C123",
|
||||
ChannelType: "channel",
|
||||
TimeStamp: "1700000000.000300",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("a non-mention channel message is still ingested; the engine group filter drops it")
|
||||
}
|
||||
if msg.AddressedToBot {
|
||||
t.Error("channel message without a mention must not be addressed to bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_ThreadReply(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "<@UBOT> follow up",
|
||||
Channel: "C123",
|
||||
ChannelType: "channel",
|
||||
TimeStamp: "1700000000.000500",
|
||||
ThreadTimeStamp: "1700000000.000400",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("thread reply should be ingestable")
|
||||
}
|
||||
if msg.Source.ThreadID != "1700000000.000400" {
|
||||
t.Errorf("ThreadID = %q", msg.Source.ThreadID)
|
||||
}
|
||||
if msg.ReplyTo == nil || msg.ReplyTo.MessageID != "1700000000.000400" {
|
||||
t.Errorf("ReplyTo = %+v, want the thread root", msg.ReplyTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_SkipsBotAndOwnAndEdits(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
cases := []struct {
|
||||
name string
|
||||
m *slackevents.MessageEvent
|
||||
}{
|
||||
{"own message", &slackevents.MessageEvent{User: "UBOT", Text: "hi", Channel: "D1", ChannelType: "im", TimeStamp: "1.1"}},
|
||||
{"other bot", &slackevents.MessageEvent{User: "UX", BotID: "B1", Text: "hi", Channel: "C1", TimeStamp: "1.2"}},
|
||||
{"bot_message subtype", &slackevents.MessageEvent{SubType: "bot_message", Text: "hi", Channel: "C1", TimeStamp: "1.3"}},
|
||||
{"edit", &slackevents.MessageEvent{User: "UALICE", SubType: "message_changed", Text: "hi", Channel: "C1", TimeStamp: "1.4"}},
|
||||
{"delete", &slackevents.MessageEvent{User: "UALICE", SubType: "message_deleted", Channel: "C1", TimeStamp: "1.5"}},
|
||||
{"empty user", &slackevents.MessageEvent{Text: "hi", Channel: "C1", TimeStamp: "1.6"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, ok := c.inboundFromMessage(eventsAPI(nil), tc.m); ok {
|
||||
t.Errorf("%s should not be ingested", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromAppMention(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
msg, ok := c.inboundFromAppMention(eventsAPI(nil), &slackevents.AppMentionEvent{
|
||||
User: "UALICE",
|
||||
Text: "<@UBOT> hi",
|
||||
Channel: "C123",
|
||||
TimeStamp: "1700000000.000700",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("app_mention should be ingestable")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeGroup || !msg.AddressedToBot {
|
||||
t.Errorf("app_mention must be a group message addressed to bot: %+v", msg.Source)
|
||||
}
|
||||
if msg.Text != "hi" {
|
||||
t.Errorf("Text = %q, want mention stripped", msg.Text)
|
||||
}
|
||||
// The bot's own app_mention echo (BotID set) must be skipped.
|
||||
if _, ok := c.inboundFromAppMention(eventsAPI(nil), &slackevents.AppMentionEvent{User: "UBOT", Channel: "C1", TimeStamp: "1.9"}); ok {
|
||||
t.Error("bot's own mention should be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitiesAndType(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
if c.Type() != TypeSlack {
|
||||
t.Errorf("Type = %q", c.Type())
|
||||
}
|
||||
caps := c.Capabilities()
|
||||
if !caps.Has(channel.CapText) || !caps.Has(channel.CapThreadReply) {
|
||||
t.Errorf("capabilities = %s, want text + thread_reply", caps)
|
||||
}
|
||||
// Capabilities the Send path cannot fulfil yet must NOT be declared.
|
||||
for _, cap := range []channel.Capability{channel.CapRichCard, channel.CapAttachment, channel.CapMessageEdit} {
|
||||
if caps.Has(cap) {
|
||||
t.Errorf("capability %s must not be declared until implemented", cap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlackChatType(t *testing.T) {
|
||||
cases := []struct {
|
||||
channelID, channelType string
|
||||
want channel.ChatType
|
||||
}{
|
||||
{"D123", "im", channel.ChatTypeP2P},
|
||||
{"G123", "mpim", channel.ChatTypeGroup}, // multi-party DM is a group
|
||||
{"C123", "channel", channel.ChatTypeGroup},
|
||||
{"C123", "private_channel", channel.ChatTypeGroup},
|
||||
{"D999", "", channel.ChatTypeP2P}, // fallback by id prefix
|
||||
{"C999", "", channel.ChatTypeGroup},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := slackChatType(tc.channelID, tc.channelType); got != tc.want {
|
||||
t.Errorf("slackChatType(%q,%q) = %q, want %q", tc.channelID, tc.channelType, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMpimRequiresMention(t *testing.T) {
|
||||
c := testChannel("UBOT")
|
||||
// A multi-party DM is a group: plain chatter must NOT be addressed to bot.
|
||||
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE", Text: "team lunch?", Channel: "G123", ChannelType: "mpim", TimeStamp: "1.1",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("mpim message should still be ingested (engine group filter decides)")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeGroup {
|
||||
t.Errorf("mpim ChatType = %q, want group", msg.Source.ChatType)
|
||||
}
|
||||
if msg.AddressedToBot {
|
||||
t.Error("plain mpim chatter must not be addressed to bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchEventsAPI_PropagatesHandlerError(t *testing.T) {
|
||||
wantErr := errors.New("db down")
|
||||
calls := 0
|
||||
c := newSlackChannel(credentials{TeamID: "T1", BotUserID: "UBOT"}, nil, func(_ context.Context, _ channel.InboundMessage) error {
|
||||
calls++
|
||||
return wantErr
|
||||
}, nil)
|
||||
|
||||
e := eventsAPI(&slackevents.MessageEvent{User: "UALICE", Text: "hi", Channel: "D1", ChannelType: "im", TimeStamp: "1.1"})
|
||||
if err := c.dispatchEventsAPI(context.Background(), e); !errors.Is(err, wantErr) {
|
||||
t.Errorf("dispatchEventsAPI error = %v, want %v (infra error must propagate to Connect→Supervisor)", err, wantErr)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("handler called %d times, want 1", calls)
|
||||
}
|
||||
|
||||
// A non-ingestable event (the bot's own message) must not reach the handler
|
||||
// and must not error.
|
||||
calls = 0
|
||||
skip := eventsAPI(&slackevents.MessageEvent{User: "UBOT", Text: "echo", Channel: "D1", ChannelType: "im", TimeStamp: "1.2"})
|
||||
if err := c.dispatchEventsAPI(context.Background(), skip); err != nil {
|
||||
t.Errorf("skipped event should not error: %v", err)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Errorf("handler called %d times for skipped event, want 0", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCredentials(t *testing.T) {
|
||||
// app_id holds the team_id routing key; tokens stored as base64 plaintext
|
||||
// here (nil Decrypter = identity).
|
||||
raw := json.RawMessage(`{
|
||||
"app_id": "T1",
|
||||
"bot_user_id": "UBOT",
|
||||
"bot_token_encrypted": "eG94Yi1ib3Q=",
|
||||
"app_token_encrypted": "eGFwcC1hcHA="
|
||||
}`)
|
||||
creds, err := decodeCredentials(raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeCredentials: %v", err)
|
||||
}
|
||||
if creds.TeamID != "T1" || creds.BotUserID != "UBOT" {
|
||||
t.Errorf("creds = %+v", creds)
|
||||
}
|
||||
if creds.BotToken != "xoxb-bot" || creds.AppToken != "xapp-app" {
|
||||
t.Errorf("tokens = %q / %q", creds.BotToken, creds.AppToken)
|
||||
}
|
||||
if _, err := decodeCredentials(nil, nil); err == nil {
|
||||
t.Error("empty config should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkMessage(t *testing.T) {
|
||||
if got := chunkMessage("short", 100); len(got) != 1 || got[0] != "short" {
|
||||
t.Errorf("short message should be one chunk: %v", got)
|
||||
@@ -309,7 +40,7 @@ func TestSend(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
api := slack.New("xoxb-test", slack.OptionAPIURL(srv.URL+"/"))
|
||||
c := newSlackChannel(credentials{TeamID: "T1"}, api, nil, nil)
|
||||
c := newSlackSender(credentials{TeamID: "T1"}, api, nil)
|
||||
|
||||
res, err := c.Send(context.Background(), channel.OutboundMessage{
|
||||
ChatID: "C123",
|
||||
@@ -344,7 +75,7 @@ func TestSend_AppliesMrkdwn(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
api := slack.New("xoxb-test", slack.OptionAPIURL(srv.URL+"/"))
|
||||
c := newSlackChannel(credentials{TeamID: "T1"}, api, nil, nil)
|
||||
c := newSlackSender(credentials{TeamID: "T1"}, api, nil)
|
||||
|
||||
if _, err := c.Send(context.Background(), channel.OutboundMessage{
|
||||
ChatID: "C1",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// Package slack is the Slack implementation of channel.Channel — the second
|
||||
// adapter driven by the channel-agnostic engine (MUL-3516), proving the
|
||||
// MUL-3506 thesis that adding an IM is "implement Channel + register" with no
|
||||
// engine, core, or channel_* schema change. It mirrors the Feishu reference
|
||||
// adapter (server/internal/integrations/lark/feishu_channel.go): Connect runs
|
||||
// the platform receive loop (here Slack Socket Mode, an outbound WebSocket
|
||||
// long-conn that needs no public inbound URL) and hands every decoded event to
|
||||
// the engine's shared inbound handler as a normalized channel.InboundMessage;
|
||||
// Send posts a text reply via chat.postMessage. The design references the
|
||||
// proven Slack adapter in Nous Research's Hermes Agent.
|
||||
// Package slack is the Slack integration for the channel-agnostic engine. It
|
||||
// uses the bring-your-own-app (BYO) model (MUL-3666): each agent's Slack app is
|
||||
// created and installed by the workspace admin, who pastes its bot token (xoxb-)
|
||||
// and app-level token (xapp-) into Multica. Each channel_installation therefore
|
||||
// carries its OWN app-level token and gets its OWN Socket Mode connection,
|
||||
// supervised per-installation by the engine like Feishu (slack_channel.go) — so
|
||||
// several agents can each have a distinct bot identity in one Slack workspace.
|
||||
// Installations are keyed and routed by the real Slack app id
|
||||
// (config->>'app_id' == the inbound event's api_app_id). The inbound translation
|
||||
// (Events API payload -> channel.InboundMessage) lives in inbound.go; the
|
||||
// outbound reply path (chat.postMessage with Markdown->mrkdwn + threading) lives
|
||||
// in channel.go. The design references the proven Slack adapter in Nous
|
||||
// Research's Hermes Agent.
|
||||
package slack
|
||||
|
||||
import (
|
||||
@@ -22,25 +25,26 @@ import (
|
||||
// Slack installation. The cross-platform columns stay flat; everything
|
||||
// Slack-specific lives in this opaque blob (the documented config boundary).
|
||||
//
|
||||
// app_id holds the Slack team_id — the per-installation routing key — so the
|
||||
// generic GetChannelInstallationByAppID query (which reads config->>'app_id')
|
||||
// and the (channel_type, config->>'app_id') unique index route Slack inbound
|
||||
// events with NO new query and NO schema change. team_id is also kept as its
|
||||
// own field for readability; the two carry the same value.
|
||||
// app_id holds the REAL Slack app id (parsed from the xapp- token). It is the
|
||||
// per-installation routing key: the generic GetChannelInstallationByAppID query
|
||||
// (config->>'app_id') and the (channel_type, app_id) unique index map an inbound
|
||||
// event's api_app_id to its installation, so several apps — several agents — in
|
||||
// one Slack workspace stay distinct. team_id is kept for display only.
|
||||
//
|
||||
// Tokens are stored as base64-encoded secretbox ciphertext (never plaintext),
|
||||
// mirroring Feishu's app_secret_encrypted. The bot token (xoxb-…) authorizes
|
||||
// Web API calls (chat.postMessage); the app-level token (xapp-…) authorizes the
|
||||
// Socket Mode connection.
|
||||
// bot_token_encrypted (xoxb-, outbound Web API: chat.postMessage) and
|
||||
// app_token_encrypted (xapp-, this installation's own Socket Mode connection)
|
||||
// are both stored as base64-encoded secretbox ciphertext, never plaintext
|
||||
// (mirroring Feishu's app_secret_encrypted). Both are pasted by the admin at
|
||||
// BYO install time.
|
||||
type installConfig struct {
|
||||
AppID string `json:"app_id"`
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
BotUserID string `json:"bot_user_id,omitempty"`
|
||||
BotTokenEncrypted string `json:"bot_token_encrypted"`
|
||||
AppTokenEncrypted string `json:"app_token_encrypted"`
|
||||
AppTokenEncrypted string `json:"app_token_encrypted,omitempty"`
|
||||
}
|
||||
|
||||
// credentials is the decoded, decrypted form the adapter runs on. The
|
||||
// credentials is the decoded, decrypted form the outbound sender runs on. The
|
||||
// installation IDENTITY (workspace / agent / installer) is deliberately absent:
|
||||
// it is resolved per message by the Router's InstallationResolver, exactly as
|
||||
// the Feishu adapter does.
|
||||
@@ -48,7 +52,6 @@ type credentials struct {
|
||||
TeamID string
|
||||
BotUserID string
|
||||
BotToken string
|
||||
AppToken string
|
||||
}
|
||||
|
||||
// Decrypter turns stored ciphertext into plaintext. The wiring injects a
|
||||
@@ -70,10 +73,6 @@ func decodeCredentials(raw json.RawMessage, decrypt Decrypter) (credentials, err
|
||||
if err != nil {
|
||||
return credentials{}, fmt.Errorf("decrypt bot token: %w", err)
|
||||
}
|
||||
appToken, err := decryptToken(cfg.AppTokenEncrypted, decrypt)
|
||||
if err != nil {
|
||||
return credentials{}, fmt.Errorf("decrypt app token: %w", err)
|
||||
}
|
||||
teamID := cfg.TeamID
|
||||
if teamID == "" {
|
||||
teamID = cfg.AppID
|
||||
@@ -82,10 +81,30 @@ func decodeCredentials(raw json.RawMessage, decrypt Decrypter) (credentials, err
|
||||
TeamID: teamID,
|
||||
BotUserID: cfg.BotUserID,
|
||||
BotToken: botToken,
|
||||
AppToken: appToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PublicConfig is the non-secret subset of an installation config, safe to
|
||||
// surface on the management API (the encrypted bot token is never included).
|
||||
type PublicConfig struct {
|
||||
AppID string
|
||||
TeamID string
|
||||
BotUserID string
|
||||
}
|
||||
|
||||
// DecodePublicConfig extracts the display-safe fields from a stored config blob.
|
||||
// A decode miss yields a zero-value PublicConfig rather than an error: the
|
||||
// management list should still render the row's identity columns.
|
||||
func DecodePublicConfig(raw json.RawMessage) PublicConfig {
|
||||
var cfg installConfig
|
||||
_ = json.Unmarshal(raw, &cfg)
|
||||
teamID := cfg.TeamID
|
||||
if teamID == "" {
|
||||
teamID = cfg.AppID
|
||||
}
|
||||
return PublicConfig{AppID: cfg.AppID, TeamID: teamID, BotUserID: cfg.BotUserID}
|
||||
}
|
||||
|
||||
// decryptToken base64-decodes the stored ciphertext (tolerating the MIME
|
||||
// newline wrapping PostgreSQL's encode(...,'base64') emits) and runs it through
|
||||
// the injected Decrypter. An empty stored value decodes to an empty token; a
|
||||
|
||||
187
server/internal/integrations/slack/inbound.go
Normal file
187
server/internal/integrations/slack/inbound.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
||||
)
|
||||
|
||||
// This file holds the platform-neutral translation from a Slack Events API
|
||||
// payload to the engine's normalized channel.InboundMessage. These are free
|
||||
// functions parameterized by the bot identity rather than methods on the
|
||||
// channel, so the per-installation Socket Mode connection (slack_channel.go)
|
||||
// threads in its own installed bot's user id when translating each event.
|
||||
|
||||
// slackRawEvent carries the Slack-specific fields the cross-platform envelope
|
||||
// does not — read back only inside the Slack resolvers (team_id routes the
|
||||
// installation; the core never reads Raw).
|
||||
type slackRawEvent struct {
|
||||
TeamID string `json:"team_id"`
|
||||
APIAppID string `json:"api_app_id,omitempty"`
|
||||
EventType string `json:"event_type"`
|
||||
SubType string `json:"subtype,omitempty"`
|
||||
ChannelType string `json:"channel_type,omitempty"`
|
||||
}
|
||||
|
||||
// compileMentionRe builds the regexp that matches an @-mention of botUserID.
|
||||
// Slack renders a mention as <@U123> or <@U123|name>. An empty botUserID
|
||||
// (installation not found / not yet known) yields nil — mention detection is
|
||||
// then a no-op, which is safe: DMs and app_mention events do not rely on it,
|
||||
// and an un-routable team is dropped at installation resolution anyway.
|
||||
func compileMentionRe(botUserID string) *regexp.Regexp {
|
||||
if botUserID == "" {
|
||||
return nil
|
||||
}
|
||||
return regexp.MustCompile(`<@` + regexp.QuoteMeta(botUserID) + `(\|[^>]*)?>`)
|
||||
}
|
||||
|
||||
// inboundFromMessage normalizes a Slack message event. It returns ok=false for
|
||||
// events that must not reach the core: the bot's own messages and other bots'
|
||||
// messages (loop guard), and edits/deletes/joins and similar subtyped system
|
||||
// messages (only brand-new user messages are ingested).
|
||||
//
|
||||
// Group addressing policy (v1, deliberate): a group message is addressed to the
|
||||
// bot only when it carries an explicit <@bot> mention. Mention-free follow-ups
|
||||
// inside a thread the bot is already engaged in are NOT auto-addressed here:
|
||||
// "reply to a bot message" is session state, so it belongs in the session-aware
|
||||
// shared service / resolver layer rather than in per-connection adapter memory.
|
||||
// Until that lands, channel/thread continuation requires re-mentioning the bot.
|
||||
// P2P (DM) ingests every message, unchanged.
|
||||
func inboundFromMessage(e slackevents.EventsAPIEvent, m *slackevents.MessageEvent, botUserID string, mentionRe *regexp.Regexp) (channel.InboundMessage, bool) {
|
||||
if m.BotID != "" || m.SubType == "bot_message" {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
if m.User == "" || (botUserID != "" && m.User == botUserID) {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
if !isIngestableSubtype(m.SubType) {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
|
||||
chatType := slackChatType(m.Channel, m.ChannelType)
|
||||
addressed := chatType == channel.ChatTypeP2P || mentionsBot(m.Text, mentionRe)
|
||||
return buildInbound(e, buildInboundParams{
|
||||
eventType: "message",
|
||||
subType: m.SubType,
|
||||
channelID: m.Channel,
|
||||
userID: m.User,
|
||||
text: m.Text,
|
||||
ts: m.TimeStamp,
|
||||
threadTS: m.ThreadTimeStamp,
|
||||
chatType: chatType,
|
||||
addressed: addressed,
|
||||
}, mentionRe), true
|
||||
}
|
||||
|
||||
// inboundFromAppMention normalizes an app_mention event. An app_mention is, by
|
||||
// definition, addressed to the bot and occurs in a channel (group). The same
|
||||
// channel @mention also arrives as a message event with the identical ts, so
|
||||
// the engine's (installation, message_id=ts) dedup collapses the pair — no
|
||||
// special-casing needed here.
|
||||
func inboundFromAppMention(e slackevents.EventsAPIEvent, m *slackevents.AppMentionEvent, botUserID string, mentionRe *regexp.Regexp) (channel.InboundMessage, bool) {
|
||||
if m.BotID != "" || m.User == "" || (botUserID != "" && m.User == botUserID) {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
return buildInbound(e, buildInboundParams{
|
||||
eventType: "app_mention",
|
||||
channelID: m.Channel,
|
||||
userID: m.User,
|
||||
text: m.Text,
|
||||
ts: m.TimeStamp,
|
||||
threadTS: m.ThreadTimeStamp,
|
||||
chatType: channel.ChatTypeGroup,
|
||||
addressed: true,
|
||||
}, mentionRe), true
|
||||
}
|
||||
|
||||
type buildInboundParams struct {
|
||||
eventType string
|
||||
subType string
|
||||
channelID string
|
||||
userID string
|
||||
text string
|
||||
ts string
|
||||
threadTS string
|
||||
chatType channel.ChatType
|
||||
addressed bool
|
||||
}
|
||||
|
||||
func buildInbound(e slackevents.EventsAPIEvent, p buildInboundParams, mentionRe *regexp.Regexp) channel.InboundMessage {
|
||||
raw, _ := json.Marshal(slackRawEvent{
|
||||
TeamID: e.TeamID,
|
||||
APIAppID: e.APIAppID,
|
||||
EventType: p.eventType,
|
||||
SubType: p.subType,
|
||||
ChannelType: string(p.chatType),
|
||||
})
|
||||
var reply *channel.ReplyCtx
|
||||
if p.threadTS != "" && p.threadTS != p.ts {
|
||||
reply = &channel.ReplyCtx{MessageID: p.threadTS, RootID: p.threadTS}
|
||||
}
|
||||
return channel.InboundMessage{
|
||||
EventID: p.ts,
|
||||
MessageID: p.ts,
|
||||
Type: channel.MsgTypeText,
|
||||
Text: cleanText(p.text, mentionRe),
|
||||
ReplyTo: reply,
|
||||
AddressedToBot: p.addressed,
|
||||
Source: channel.Source{
|
||||
ChannelType: TypeSlack,
|
||||
ChatID: p.channelID,
|
||||
ChatType: p.chatType,
|
||||
SenderID: p.userID,
|
||||
ThreadID: p.threadTS,
|
||||
},
|
||||
Raw: raw,
|
||||
}
|
||||
}
|
||||
|
||||
// cleanText strips a leading/embedded bot mention token and trims surrounding
|
||||
// whitespace so the core sees the user's actual prompt, not "<@U123> hi".
|
||||
func cleanText(text string, mentionRe *regexp.Regexp) string {
|
||||
if mentionRe != nil {
|
||||
text = mentionRe.ReplaceAllString(text, "")
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// mentionsBot reports whether text contains an @-mention of this bot.
|
||||
func mentionsBot(text string, mentionRe *regexp.Regexp) bool {
|
||||
return mentionRe != nil && mentionRe.MatchString(text)
|
||||
}
|
||||
|
||||
// slackChatType maps a Slack channel id / channel_type to the normalized
|
||||
// ChatType. Only a 1:1 direct message ("im", or a "D…" channel id) is p2p;
|
||||
// everything else — public/private channels AND multi-party DMs ("mpim", which
|
||||
// are multi-person conversations) — is a group. A group routes through the
|
||||
// engine's "must address the bot" filter, so plain chatter in a multi-party DM
|
||||
// is not mistaken for a prompt to the bot.
|
||||
func slackChatType(channelID, channelType string) channel.ChatType {
|
||||
switch channelType {
|
||||
case "im":
|
||||
return channel.ChatTypeP2P
|
||||
case "mpim", "channel", "group", "private_channel":
|
||||
return channel.ChatTypeGroup
|
||||
}
|
||||
if strings.HasPrefix(channelID, "D") {
|
||||
return channel.ChatTypeP2P
|
||||
}
|
||||
return channel.ChatTypeGroup
|
||||
}
|
||||
|
||||
// isIngestableSubtype reports whether a message subtype is a brand-new user
|
||||
// message the core should ingest. Empty subtype is the normal case;
|
||||
// thread_broadcast and file_share are real user messages; everything else
|
||||
// (message_changed, message_deleted, channel_join, …) is a system/edit event.
|
||||
func isIngestableSubtype(subType string) bool {
|
||||
switch subType {
|
||||
case "", "thread_broadcast", "file_share":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
228
server/internal/integrations/slack/inbound_test.go
Normal file
228
server/internal/integrations/slack/inbound_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
||||
)
|
||||
|
||||
func eventsAPI(inner any) slackevents.EventsAPIEvent {
|
||||
return slackevents.EventsAPIEvent{
|
||||
TeamID: "T1",
|
||||
APIAppID: "A1",
|
||||
InnerEvent: slackevents.EventsAPIInnerEvent{
|
||||
Data: inner,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// translateMessage runs the message-event translation as the AppConnector does:
|
||||
// resolve the team's bot user id, then normalize.
|
||||
func translateMessage(botUserID string, e slackevents.EventsAPIEvent, m *slackevents.MessageEvent) (channel.InboundMessage, bool) {
|
||||
return inboundFromMessage(e, m, botUserID, compileMentionRe(botUserID))
|
||||
}
|
||||
|
||||
func translateAppMention(botUserID string, e slackevents.EventsAPIEvent, m *slackevents.AppMentionEvent) (channel.InboundMessage, bool) {
|
||||
return inboundFromAppMention(e, m, botUserID, compileMentionRe(botUserID))
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_DM(t *testing.T) {
|
||||
msg, ok := translateMessage("UBOT", eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "hello bot",
|
||||
Channel: "D123",
|
||||
ChannelType: "im",
|
||||
TimeStamp: "1700000000.000100",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected DM message to be ingestable")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeP2P {
|
||||
t.Errorf("ChatType = %q, want p2p", msg.Source.ChatType)
|
||||
}
|
||||
if !msg.AddressedToBot {
|
||||
t.Error("DM should always be addressed to bot")
|
||||
}
|
||||
if msg.Source.ChannelType != TypeSlack {
|
||||
t.Errorf("ChannelType = %q, want slack", msg.Source.ChannelType)
|
||||
}
|
||||
if msg.MessageID != "1700000000.000100" || msg.EventID != msg.MessageID {
|
||||
t.Errorf("MessageID/EventID = %q/%q, want the ts", msg.MessageID, msg.EventID)
|
||||
}
|
||||
if msg.Source.SenderID != "UALICE" || msg.Source.ChatID != "D123" {
|
||||
t.Errorf("sender/chat = %q/%q", msg.Source.SenderID, msg.Source.ChatID)
|
||||
}
|
||||
if msg.Text != "hello bot" {
|
||||
t.Errorf("Text = %q", msg.Text)
|
||||
}
|
||||
// team_id must be in Raw so the installation resolver can route.
|
||||
var raw slackRawEvent
|
||||
if err := json.Unmarshal(msg.Raw, &raw); err != nil {
|
||||
t.Fatalf("decode raw: %v", err)
|
||||
}
|
||||
if raw.TeamID != "T1" || raw.EventType != "message" {
|
||||
t.Errorf("raw = %+v", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_ChannelMention(t *testing.T) {
|
||||
msg, ok := translateMessage("UBOT", eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "<@UBOT> create an issue",
|
||||
Channel: "C123",
|
||||
ChannelType: "channel",
|
||||
TimeStamp: "1700000000.000200",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected channel message to be ingestable")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeGroup {
|
||||
t.Errorf("ChatType = %q, want group", msg.Source.ChatType)
|
||||
}
|
||||
if !msg.AddressedToBot {
|
||||
t.Error("channel message mentioning the bot should be addressed to bot")
|
||||
}
|
||||
if msg.Text != "create an issue" {
|
||||
t.Errorf("Text = %q, want mention stripped", msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_ChannelNoMention(t *testing.T) {
|
||||
msg, ok := translateMessage("UBOT", eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "just chatting with the team",
|
||||
Channel: "C123",
|
||||
ChannelType: "channel",
|
||||
TimeStamp: "1700000000.000300",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("a non-mention channel message is still ingested; the engine group filter drops it")
|
||||
}
|
||||
if msg.AddressedToBot {
|
||||
t.Error("channel message without a mention must not be addressed to bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_ThreadReply(t *testing.T) {
|
||||
msg, ok := translateMessage("UBOT", eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE",
|
||||
Text: "<@UBOT> follow up",
|
||||
Channel: "C123",
|
||||
ChannelType: "channel",
|
||||
TimeStamp: "1700000000.000500",
|
||||
ThreadTimeStamp: "1700000000.000400",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("thread reply should be ingestable")
|
||||
}
|
||||
if msg.Source.ThreadID != "1700000000.000400" {
|
||||
t.Errorf("ThreadID = %q", msg.Source.ThreadID)
|
||||
}
|
||||
if msg.ReplyTo == nil || msg.ReplyTo.MessageID != "1700000000.000400" {
|
||||
t.Errorf("ReplyTo = %+v, want the thread root", msg.ReplyTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromMessage_SkipsBotAndOwnAndEdits(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
m *slackevents.MessageEvent
|
||||
}{
|
||||
{"own message", &slackevents.MessageEvent{User: "UBOT", Text: "hi", Channel: "D1", ChannelType: "im", TimeStamp: "1.1"}},
|
||||
{"other bot", &slackevents.MessageEvent{User: "UX", BotID: "B1", Text: "hi", Channel: "C1", TimeStamp: "1.2"}},
|
||||
{"bot_message subtype", &slackevents.MessageEvent{SubType: "bot_message", Text: "hi", Channel: "C1", TimeStamp: "1.3"}},
|
||||
{"edit", &slackevents.MessageEvent{User: "UALICE", SubType: "message_changed", Text: "hi", Channel: "C1", TimeStamp: "1.4"}},
|
||||
{"delete", &slackevents.MessageEvent{User: "UALICE", SubType: "message_deleted", Channel: "C1", TimeStamp: "1.5"}},
|
||||
{"empty user", &slackevents.MessageEvent{Text: "hi", Channel: "C1", TimeStamp: "1.6"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, ok := translateMessage("UBOT", eventsAPI(nil), tc.m); ok {
|
||||
t.Errorf("%s should not be ingested", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundFromAppMention(t *testing.T) {
|
||||
msg, ok := translateAppMention("UBOT", eventsAPI(nil), &slackevents.AppMentionEvent{
|
||||
User: "UALICE",
|
||||
Text: "<@UBOT> hi",
|
||||
Channel: "C123",
|
||||
TimeStamp: "1700000000.000700",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("app_mention should be ingestable")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeGroup || !msg.AddressedToBot {
|
||||
t.Errorf("app_mention must be a group message addressed to bot: %+v", msg.Source)
|
||||
}
|
||||
if msg.Text != "hi" {
|
||||
t.Errorf("Text = %q, want mention stripped", msg.Text)
|
||||
}
|
||||
// The bot's own app_mention echo (BotID set) must be skipped.
|
||||
if _, ok := translateAppMention("UBOT", eventsAPI(nil), &slackevents.AppMentionEvent{User: "UBOT", Channel: "C1", TimeStamp: "1.9"}); ok {
|
||||
t.Error("bot's own mention should be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlackChatType(t *testing.T) {
|
||||
cases := []struct {
|
||||
channelID, channelType string
|
||||
want channel.ChatType
|
||||
}{
|
||||
{"D123", "im", channel.ChatTypeP2P},
|
||||
{"G123", "mpim", channel.ChatTypeGroup}, // multi-party DM is a group
|
||||
{"C123", "channel", channel.ChatTypeGroup},
|
||||
{"C123", "private_channel", channel.ChatTypeGroup},
|
||||
{"D999", "", channel.ChatTypeP2P}, // fallback by id prefix
|
||||
{"C999", "", channel.ChatTypeGroup},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := slackChatType(tc.channelID, tc.channelType); got != tc.want {
|
||||
t.Errorf("slackChatType(%q,%q) = %q, want %q", tc.channelID, tc.channelType, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMpimRequiresMention(t *testing.T) {
|
||||
// A multi-party DM is a group: plain chatter must NOT be addressed to bot.
|
||||
msg, ok := translateMessage("UBOT", eventsAPI(nil), &slackevents.MessageEvent{
|
||||
User: "UALICE", Text: "team lunch?", Channel: "G123", ChannelType: "mpim", TimeStamp: "1.1",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("mpim message should still be ingested (engine group filter decides)")
|
||||
}
|
||||
if msg.Source.ChatType != channel.ChatTypeGroup {
|
||||
t.Errorf("mpim ChatType = %q, want group", msg.Source.ChatType)
|
||||
}
|
||||
if msg.AddressedToBot {
|
||||
t.Error("plain mpim chatter must not be addressed to bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCredentials(t *testing.T) {
|
||||
// app_id holds the team_id routing key; the bot token is stored as base64
|
||||
// plaintext here (nil Decrypter = identity).
|
||||
raw := json.RawMessage(`{
|
||||
"app_id": "T1",
|
||||
"bot_user_id": "UBOT",
|
||||
"bot_token_encrypted": "eG94Yi1ib3Q="
|
||||
}`)
|
||||
creds, err := decodeCredentials(raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeCredentials: %v", err)
|
||||
}
|
||||
if creds.TeamID != "T1" || creds.BotUserID != "UBOT" {
|
||||
t.Errorf("creds = %+v", creds)
|
||||
}
|
||||
if creds.BotToken != "xoxb-bot" {
|
||||
t.Errorf("bot token = %q", creds.BotToken)
|
||||
}
|
||||
if _, err := decodeCredentials(nil, nil); err == nil {
|
||||
t.Error("empty config should error")
|
||||
}
|
||||
}
|
||||
200
server/internal/integrations/slack/install.go
Normal file
200
server/internal/integrations/slack/install.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
|
||||
"github.com/multica-ai/multica/server/internal/util/secretbox"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// This file is the Slack install backend (MUL-3666). Slack uses the
|
||||
// bring-your-own-app (BYO) model: the workspace admin creates their own Slack
|
||||
// app, installs it to their Slack workspace, and pastes its bot token (xoxb-) +
|
||||
// app-level token (xapp-) into Multica (the paste path lives in byo_install.go).
|
||||
// The InstallService owns the at-rest encryption of those tokens — so no caller
|
||||
// can write a channel_installation with a plaintext token — plus the shared
|
||||
// persistInstall transaction and the list / get / revoke management surface.
|
||||
|
||||
var (
|
||||
// ErrInstallationNotFound surfaces "no row matches in this workspace".
|
||||
ErrInstallationNotFound = errors.New("slack installation not found")
|
||||
// ErrTeamOwnedByAnotherWorkspace is returned when the pasted Slack app is
|
||||
// already connected to a DIFFERENT agent or Multica workspace — it would
|
||||
// collide with the (channel_type, app_id) routing index. A Slack app is one
|
||||
// bot identity and maps to one agent; reusing it elsewhere requires
|
||||
// disconnecting it there first.
|
||||
ErrTeamOwnedByAnotherWorkspace = errors.New("slack: this Slack app is already connected to another agent or Multica workspace")
|
||||
)
|
||||
|
||||
// installQueries is the slice of generated queries InstallService needs. WithTx
|
||||
// returns the same interface bound to a transaction so persistInstall runs its
|
||||
// upsert atomically (and so tests can inject a fake without a real DB).
|
||||
type installQueries interface {
|
||||
WithTx(tx pgx.Tx) installQueries
|
||||
UpsertChannelInstallation(ctx context.Context, arg db.UpsertChannelInstallationParams) (db.ChannelInstallation, error)
|
||||
ListChannelInstallationsByWorkspace(ctx context.Context, arg db.ListChannelInstallationsByWorkspaceParams) ([]db.ChannelInstallation, error)
|
||||
GetChannelInstallationInWorkspace(ctx context.Context, arg db.GetChannelInstallationInWorkspaceParams) (db.ChannelInstallation, error)
|
||||
SetChannelInstallationStatus(ctx context.Context, arg db.SetChannelInstallationStatusParams) error
|
||||
}
|
||||
|
||||
// dbInstallQueries adapts *db.Queries to installQueries — the generated WithTx
|
||||
// returns *db.Queries, so we wrap it to return the interface (the same adapter
|
||||
// pattern engine.ChatSession uses).
|
||||
type dbInstallQueries struct{ *db.Queries }
|
||||
|
||||
func (q dbInstallQueries) WithTx(tx pgx.Tx) installQueries {
|
||||
return dbInstallQueries{q.Queries.WithTx(tx)}
|
||||
}
|
||||
|
||||
// InstallService owns the at-rest encryption of the bot + app tokens (so no
|
||||
// caller can write a channel_installation with a plaintext token) and the shared
|
||||
// install transaction. The box MUST be non-nil (we refuse plaintext storage even
|
||||
// in dev).
|
||||
type InstallService struct {
|
||||
box *secretbox.Box
|
||||
q installQueries
|
||||
tx engine.TxStarter
|
||||
httpClient *http.Client
|
||||
logger *slog.Logger
|
||||
|
||||
// apiURL overrides the Slack API base for the BYO auth.test call (tests point
|
||||
// it at an httptest server). Empty uses the real Slack API.
|
||||
apiURL string
|
||||
}
|
||||
|
||||
// NewInstallService binds the service to queries, a tx starter (*pgxpool.Pool),
|
||||
// and an encryption box. Listing / revoking and BYO register all require only
|
||||
// the box (the at-rest key); there is no hosted OAuth credential.
|
||||
func NewInstallService(q *db.Queries, tx engine.TxStarter, box *secretbox.Box, logger *slog.Logger) (*InstallService, error) {
|
||||
if q == nil {
|
||||
return nil, errors.New("slack: InstallService requires queries")
|
||||
}
|
||||
return newInstallService(dbInstallQueries{q}, tx, box, logger)
|
||||
}
|
||||
|
||||
// newInstallService is the testable core: it takes the installQueries interface
|
||||
// so tests can inject a fake (with a fake TxStarter) without a real DB.
|
||||
func newInstallService(q installQueries, tx engine.TxStarter, box *secretbox.Box, logger *slog.Logger) (*InstallService, error) {
|
||||
if box == nil {
|
||||
return nil, errors.New("slack: InstallService requires a non-nil secretbox.Box")
|
||||
}
|
||||
if q == nil {
|
||||
return nil, errors.New("slack: InstallService requires queries")
|
||||
}
|
||||
if tx == nil {
|
||||
return nil, errors.New("slack: InstallService requires a tx starter")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &InstallService{
|
||||
box: box,
|
||||
q: q,
|
||||
tx: tx,
|
||||
httpClient: http.DefaultClient,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// installPersist carries the resolved fields persistInstall writes. appIDKey is
|
||||
// the value stored at config->>'app_id' — the real Slack app id — and MUST equal
|
||||
// the app_id inside configJSON; it is the lookup / ON CONFLICT key. installerSlackID
|
||||
// is the installer's Slack user id to auto-bind, or "" to skip (a BYO paste
|
||||
// carries no authed_user, so the installer binds via the normal token flow on
|
||||
// first message).
|
||||
type installPersist struct {
|
||||
wsID pgtype.UUID
|
||||
agentID pgtype.UUID
|
||||
installerID pgtype.UUID
|
||||
// configJSON holds the Slack app id (config->>'app_id') used for inbound
|
||||
// routing; the ROW itself is keyed by (workspace, agent) — one bot per agent.
|
||||
configJSON []byte
|
||||
}
|
||||
|
||||
// pgUniqueViolation is the Postgres SQLSTATE for a unique-constraint violation.
|
||||
const pgUniqueViolation = "23505"
|
||||
|
||||
// persistInstall upserts the installation keyed by (workspace_id, agent_id,
|
||||
// channel_type): ONE Slack bot per agent. Re-connecting an agent — including
|
||||
// swapping it to a NEW Slack app after a disconnect — UPDATES that agent's row
|
||||
// in place instead of colliding with the (workspace, agent, channel) unique.
|
||||
//
|
||||
// The (channel_type, app_id) routing index is the only OTHER unique constraint,
|
||||
// and it is NOT this upsert's conflict target, so a unique violation here means
|
||||
// the pasted Slack app is already connected to a DIFFERENT agent or Multica
|
||||
// workspace — refuse it (ErrTeamOwnedByAnotherWorkspace) rather than steal it.
|
||||
// No chat-session retire is needed: a row's agent_id never changes (it is part
|
||||
// of the key), so existing sessions stay valid for the same agent.
|
||||
func (s *InstallService) persistInstall(ctx context.Context, p installPersist) (db.ChannelInstallation, error) {
|
||||
tx, err := s.tx.Begin(ctx)
|
||||
if err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("begin install tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
qtx := s.q.WithTx(tx)
|
||||
|
||||
inst, err := qtx.UpsertChannelInstallation(ctx, db.UpsertChannelInstallationParams{
|
||||
WorkspaceID: p.wsID,
|
||||
AgentID: p.agentID,
|
||||
ChannelType: string(TypeSlack),
|
||||
Config: p.configJSON,
|
||||
InstallerUserID: p.installerID,
|
||||
})
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == pgUniqueViolation {
|
||||
return db.ChannelInstallation{}, ErrTeamOwnedByAnotherWorkspace
|
||||
}
|
||||
return db.ChannelInstallation{}, fmt.Errorf("upsert slack installation: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return db.ChannelInstallation{}, fmt.Errorf("commit slack install: %w", err)
|
||||
}
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
// ListByWorkspace returns every Slack installation in the workspace (active and
|
||||
// revoked), for the management surface.
|
||||
func (s *InstallService) ListByWorkspace(ctx context.Context, wsID pgtype.UUID) ([]db.ChannelInstallation, error) {
|
||||
return s.q.ListChannelInstallationsByWorkspace(ctx, db.ListChannelInstallationsByWorkspaceParams{
|
||||
WorkspaceID: wsID,
|
||||
ChannelType: string(TypeSlack),
|
||||
})
|
||||
}
|
||||
|
||||
// GetInWorkspace is the workspace-scoped lookup so a forged installation id from
|
||||
// another workspace returns NotFound instead of leaking existence.
|
||||
func (s *InstallService) GetInWorkspace(ctx context.Context, id, wsID pgtype.UUID) (db.ChannelInstallation, error) {
|
||||
inst, err := s.q.GetChannelInstallationInWorkspace(ctx, db.GetChannelInstallationInWorkspaceParams{
|
||||
ID: id,
|
||||
WorkspaceID: wsID,
|
||||
ChannelType: string(TypeSlack),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return db.ChannelInstallation{}, ErrInstallationNotFound
|
||||
}
|
||||
return db.ChannelInstallation{}, err
|
||||
}
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
// Revoke flips status to 'revoked'. The row is preserved for audit; a re-install
|
||||
// flips it back to 'active'. The Supervisor stops supervising the installation
|
||||
// (ListActiveInstallations filters to active), so its Socket Mode connection
|
||||
// winds down, and outbound drops too.
|
||||
func (s *InstallService) Revoke(ctx context.Context, id pgtype.UUID) error {
|
||||
return s.q.SetChannelInstallationStatus(ctx, db.SetChannelInstallationStatusParams{
|
||||
ID: id,
|
||||
Status: "revoked",
|
||||
})
|
||||
}
|
||||
109
server/internal/integrations/slack/install_test.go
Normal file
109
server/internal/integrations/slack/install_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
"github.com/multica-ai/multica/server/internal/util/secretbox"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
func testBox(t *testing.T) *secretbox.Box {
|
||||
t.Helper()
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 1)
|
||||
}
|
||||
box, err := secretbox.New(key)
|
||||
if err != nil {
|
||||
t.Fatalf("secretbox.New: %v", err)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
func mustUUID(t *testing.T, s string) pgtype.UUID {
|
||||
t.Helper()
|
||||
u, err := util.ParseUUID(s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse uuid %q: %v", s, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
type fakeInstallQueries struct {
|
||||
// existing, when set, is the agent's current row; UpsertChannelInstallation
|
||||
// returns it (an UPDATE) so a reconnect reuses the same row id.
|
||||
existing *db.ChannelInstallation
|
||||
// appIDTaken makes UpsertChannelInstallation report a unique-constraint
|
||||
// violation on the (channel_type, app_id) routing index — i.e. the pasted app
|
||||
// is already connected to another agent / workspace.
|
||||
appIDTaken bool
|
||||
upsertParams db.UpsertChannelInstallationParams
|
||||
upsertCalled bool
|
||||
rowID pgtype.UUID
|
||||
}
|
||||
|
||||
// WithTx returns the same fake — the fake tx is a no-op token.
|
||||
func (f *fakeInstallQueries) WithTx(_ pgx.Tx) installQueries { return f }
|
||||
|
||||
func (f *fakeInstallQueries) UpsertChannelInstallation(_ context.Context, arg db.UpsertChannelInstallationParams) (db.ChannelInstallation, error) {
|
||||
f.upsertCalled = true
|
||||
f.upsertParams = arg
|
||||
if f.appIDTaken {
|
||||
return db.ChannelInstallation{}, &pgconn.PgError{Code: "23505"}
|
||||
}
|
||||
id := f.rowID
|
||||
if f.existing != nil {
|
||||
id = f.existing.ID // reconnect updates the agent's existing row in place
|
||||
}
|
||||
return db.ChannelInstallation{
|
||||
ID: id,
|
||||
WorkspaceID: arg.WorkspaceID,
|
||||
AgentID: arg.AgentID,
|
||||
ChannelType: arg.ChannelType,
|
||||
Config: arg.Config,
|
||||
InstallerUserID: arg.InstallerUserID,
|
||||
Status: "active",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeInstallQueries) ListChannelInstallationsByWorkspace(_ context.Context, _ db.ListChannelInstallationsByWorkspaceParams) ([]db.ChannelInstallation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeInstallQueries) GetChannelInstallationInWorkspace(_ context.Context, _ db.GetChannelInstallationInWorkspaceParams) (db.ChannelInstallation, error) {
|
||||
return db.ChannelInstallation{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeInstallQueries) SetChannelInstallationStatus(_ context.Context, _ db.SetChannelInstallationStatusParams) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeTx is a no-op pgx.Tx: embedding the interface satisfies it, and the
|
||||
// install paths only ever call Commit / Rollback. committed records whether the
|
||||
// install committed (the happy path) vs rolled back (a rejected install).
|
||||
type fakeTx struct {
|
||||
pgx.Tx
|
||||
committed bool
|
||||
}
|
||||
|
||||
func (t *fakeTx) Commit(context.Context) error { t.committed = true; return nil }
|
||||
func (t *fakeTx) Rollback(context.Context) error { return nil }
|
||||
|
||||
type fakeTxStarter struct{ tx *fakeTx }
|
||||
|
||||
func (f *fakeTxStarter) Begin(context.Context) (pgx.Tx, error) { return f.tx, nil }
|
||||
|
||||
func newTestInstallService(t *testing.T, q installQueries) *InstallService {
|
||||
t.Helper()
|
||||
svc, err := newInstallService(q, &fakeTxStarter{tx: &fakeTx{}}, testBox(t), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newInstallService: %v", err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
@@ -25,7 +25,7 @@ type outboundQueries interface {
|
||||
GetChannelInstallation(ctx context.Context, arg db.GetChannelInstallationParams) (db.ChannelInstallation, error)
|
||||
}
|
||||
|
||||
// replySender posts one reply. Satisfied by *slackChannel, so the outbound path
|
||||
// replySender posts one reply. Satisfied by *slackSender, so the outbound path
|
||||
// reuses Send's Markdown->mrkdwn conversion, chunking, and threading.
|
||||
type replySender interface {
|
||||
Send(ctx context.Context, out channel.OutboundMessage) (channel.SendResult, error)
|
||||
@@ -52,9 +52,9 @@ func NewOutbound(q outboundQueries, decrypt Decrypter, logger *slog.Logger) *Out
|
||||
}
|
||||
o := &Outbound{q: q, decrypt: decrypt, logger: logger}
|
||||
o.newSender = func(c credentials) replySender {
|
||||
// Only the bot token is needed to post; the app token is a Socket Mode
|
||||
// (inbound) credential.
|
||||
return newSlackChannel(c, slack.New(c.BotToken), nil, logger)
|
||||
// Only the bot token is needed to post; inbound Socket Mode uses the
|
||||
// installation's separate app-level token (see slack_channel.go).
|
||||
return newSlackSender(c, slack.New(c.BotToken), logger)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ func slackInstallConfigJSON() []byte {
|
||||
"app_id": "T1",
|
||||
"bot_user_id": "UBOT",
|
||||
"bot_token_encrypted": base64.StdEncoding.EncodeToString([]byte("xoxb-test")),
|
||||
"app_token_encrypted": base64.StdEncoding.EncodeToString([]byte("xapp-test")),
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
183
server/internal/integrations/slack/replier.go
Normal file
183
server/internal/integrations/slack/replier.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/slack-go/slack"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// This file is the Slack OutboundReplier — the engine seam that delivers a
|
||||
// verdict-driven reply back to the user (MUL-3666, completing the stage-3
|
||||
// Replier=nil tail). It posts through the same bot-token Send path as the
|
||||
// EventChatDone outbound subscriber, so it needs no new transport.
|
||||
//
|
||||
// Outcomes handled:
|
||||
// - NeedsBinding: the sender is unbound. Mint a single-use binding token and
|
||||
// reply with a "link your account" prompt pointing at the in-product redeem
|
||||
// page. After they bind, their next message reaches the agent.
|
||||
// - AgentOffline / AgentArchived: a status notice so the user is not left
|
||||
// wondering why nothing happened.
|
||||
// - Ingested with an /issue created: a confirmation of the new issue.
|
||||
|
||||
const (
|
||||
agentOfflineText = "⚠️ The agent is offline right now. Your message was received and will be handled once it's back online."
|
||||
agentArchivedText = "⚠️ This agent has been archived and can't respond. Please contact your workspace admin."
|
||||
)
|
||||
|
||||
// bindingMinter is the binding-token surface the replier needs.
|
||||
// *BindingTokenService satisfies it.
|
||||
type bindingMinter interface {
|
||||
Mint(ctx context.Context, workspaceID, installationID pgtype.UUID, slackUserID string) (BindingToken, error)
|
||||
}
|
||||
|
||||
// OutboundReplier implements engine.OutboundReplier for Slack.
|
||||
type OutboundReplier struct {
|
||||
binding bindingMinter
|
||||
decrypt Decrypter
|
||||
newSender func(creds credentials) replySender
|
||||
publicURL string
|
||||
bindingPath string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// OutboundReplierConfig configures the replier. Binding + PublicURL are required
|
||||
// for the NeedsBinding prompt to work; without them the prompt is skipped (the
|
||||
// offline/archived/issue notices still fire).
|
||||
type OutboundReplierConfig struct {
|
||||
Binding bindingMinter
|
||||
Decrypt Decrypter
|
||||
PublicURL string
|
||||
BindingPath string // default "/slack/bind"
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
var _ engine.OutboundReplier = (*OutboundReplier)(nil)
|
||||
|
||||
// NewOutboundReplier builds the replier. The sender factory mirrors the outbound
|
||||
// subscriber: only the bot token is needed to post.
|
||||
func NewOutboundReplier(cfg OutboundReplierConfig) *OutboundReplier {
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
bindingPath := cfg.BindingPath
|
||||
if bindingPath == "" {
|
||||
bindingPath = "/slack/bind"
|
||||
}
|
||||
if !strings.HasPrefix(bindingPath, "/") {
|
||||
bindingPath = "/" + bindingPath
|
||||
}
|
||||
r := &OutboundReplier{
|
||||
binding: cfg.Binding,
|
||||
decrypt: cfg.Decrypt,
|
||||
publicURL: strings.TrimRight(cfg.PublicURL, "/"),
|
||||
bindingPath: bindingPath,
|
||||
logger: logger,
|
||||
}
|
||||
r.newSender = func(c credentials) replySender {
|
||||
return newSlackSender(c, slack.New(c.BotToken), logger)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Reply routes each outcome to its user-visible message. Errors are logged, not
|
||||
// propagated: the replier runs detached from the inbound ACK path.
|
||||
func (r *OutboundReplier) Reply(ctx context.Context, inst engine.ResolvedInstallation, msg channel.InboundMessage, res engine.Result) {
|
||||
switch res.Outcome {
|
||||
case engine.OutcomeNeedsBinding:
|
||||
if err := r.sendBindingPrompt(ctx, inst, msg, res); err != nil {
|
||||
r.logger.WarnContext(ctx, "slack replier: binding prompt failed",
|
||||
"installation_id", util.UUIDToString(inst.ID), "error", err)
|
||||
}
|
||||
case engine.OutcomeAgentOffline:
|
||||
if err := r.post(ctx, inst, msg, agentOfflineText); err != nil {
|
||||
r.logger.WarnContext(ctx, "slack replier: offline notice failed",
|
||||
"installation_id", util.UUIDToString(inst.ID), "error", err)
|
||||
}
|
||||
case engine.OutcomeAgentArchived:
|
||||
if err := r.post(ctx, inst, msg, agentArchivedText); err != nil {
|
||||
r.logger.WarnContext(ctx, "slack replier: archived notice failed",
|
||||
"installation_id", util.UUIDToString(inst.ID), "error", err)
|
||||
}
|
||||
case engine.OutcomeIngested:
|
||||
// Only a /issue-created message warrants a confirmation; a plain chat
|
||||
// message stays silent (the agent's own reply lands via EventChatDone).
|
||||
if res.IssueID.Valid {
|
||||
if err := r.post(ctx, inst, msg, issueCreatedText(res)); err != nil {
|
||||
r.logger.WarnContext(ctx, "slack replier: issue-created confirmation failed",
|
||||
"installation_id", util.UUIDToString(inst.ID), "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *OutboundReplier) sendBindingPrompt(ctx context.Context, inst engine.ResolvedInstallation, msg channel.InboundMessage, res engine.Result) error {
|
||||
sender := res.Sender
|
||||
if sender == "" {
|
||||
sender = msg.Source.SenderID
|
||||
}
|
||||
if sender == "" {
|
||||
return errors.New("missing sender id")
|
||||
}
|
||||
if r.binding == nil {
|
||||
return errors.New("binding service not configured")
|
||||
}
|
||||
if r.publicURL == "" {
|
||||
return errors.New("public url not configured")
|
||||
}
|
||||
token, err := r.binding.Mint(ctx, inst.WorkspaceID, inst.ID, sender)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mint binding token: %w", err)
|
||||
}
|
||||
bindURL := r.publicURL + r.bindingPath + "?token=" + url.QueryEscape(token.Raw)
|
||||
// Wrap the URL as an explicit Slack link <url|label>: formatMrkdwn protects
|
||||
// these from its markdown passes, so the base64url token's `_`/`-` chars are
|
||||
// not mangled into italics.
|
||||
text := "👋 To start chatting with me, link your Slack account to Multica: <" +
|
||||
bindURL + "|link your account>\n(This link expires in 15 minutes.)"
|
||||
return r.post(ctx, inst, msg, text)
|
||||
}
|
||||
|
||||
// post resolves the installation's bot token from the carried platform row and
|
||||
// sends text back into the originating channel / thread.
|
||||
func (r *OutboundReplier) post(ctx context.Context, inst engine.ResolvedInstallation, msg channel.InboundMessage, text string) error {
|
||||
row, ok := inst.Platform.(db.ChannelInstallation)
|
||||
if !ok {
|
||||
return errors.New("installation platform row unavailable")
|
||||
}
|
||||
creds, err := decodeCredentials(row.Config, r.decrypt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode credentials: %w", err)
|
||||
}
|
||||
if _, err := r.newSender(creds).Send(ctx, channel.OutboundMessage{
|
||||
ChatID: msg.Source.ChatID,
|
||||
Text: text,
|
||||
ThreadID: msg.Source.ThreadID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("post slack reply: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func issueCreatedText(res engine.Result) string {
|
||||
id := res.IssueIdentifier
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("#%d", res.IssueNumber)
|
||||
}
|
||||
title := strings.TrimSpace(res.IssueTitle)
|
||||
if title == "" {
|
||||
return "✅ Created " + id
|
||||
}
|
||||
return "✅ Created " + id + " — " + title
|
||||
}
|
||||
181
server/internal/integrations/slack/replier_test.go
Normal file
181
server/internal/integrations/slack/replier_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
type fakeReplySender struct {
|
||||
sent *channel.OutboundMessage
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeReplySender) Send(_ context.Context, out channel.OutboundMessage) (channel.SendResult, error) {
|
||||
f.calls++
|
||||
cp := out
|
||||
f.sent = &cp
|
||||
return channel.SendResult{MessageID: "1.1"}, nil
|
||||
}
|
||||
|
||||
type fakeBindingMinter struct {
|
||||
raw string
|
||||
gotWS pgtype.UUID
|
||||
gotInst pgtype.UUID
|
||||
gotUser string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeBindingMinter) Mint(_ context.Context, ws, inst pgtype.UUID, user string) (BindingToken, error) {
|
||||
f.calls++
|
||||
f.gotWS, f.gotInst, f.gotUser = ws, inst, user
|
||||
return BindingToken{Raw: f.raw, ExpiresAt: time.Unix(0, 0)}, nil
|
||||
}
|
||||
|
||||
func newTestReplier(binding bindingMinter, sender replySender) *OutboundReplier {
|
||||
r := NewOutboundReplier(OutboundReplierConfig{
|
||||
Binding: binding,
|
||||
Decrypt: nil, // identity: stored bot token is base64 plaintext
|
||||
PublicURL: "https://multica.example",
|
||||
})
|
||||
r.newSender = func(credentials) replySender { return sender }
|
||||
return r
|
||||
}
|
||||
|
||||
// installConfigJSON with a base64 (identity-decryptable) bot token so
|
||||
// decodeCredentials succeeds inside post().
|
||||
const replierConfigJSON = `{"app_id":"T1","bot_user_id":"UBOT","bot_token_encrypted":"eG94Yi10ZXN0"}`
|
||||
|
||||
func testResolvedInstallation(t *testing.T) engine.ResolvedInstallation {
|
||||
return engine.ResolvedInstallation{
|
||||
ID: mustUUID(t, "44444444-4444-4444-4444-444444444444"),
|
||||
WorkspaceID: mustUUID(t, "11111111-1111-1111-1111-111111111111"),
|
||||
AgentID: mustUUID(t, "22222222-2222-2222-2222-222222222222"),
|
||||
Active: true,
|
||||
Platform: db.ChannelInstallation{Config: []byte(replierConfigJSON)},
|
||||
}
|
||||
}
|
||||
|
||||
func testInboundForReply() channel.InboundMessage {
|
||||
return channel.InboundMessage{
|
||||
MessageID: "1700000000.000300",
|
||||
Source: channel.Source{
|
||||
ChannelType: TypeSlack,
|
||||
ChatID: "C1",
|
||||
ChatType: channel.ChatTypeGroup,
|
||||
SenderID: "UALICE",
|
||||
ThreadID: "1700000000.000200",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestReply_NeedsBinding_MintsAndPostsPrompt(t *testing.T) {
|
||||
sender := &fakeReplySender{}
|
||||
minter := &fakeBindingMinter{raw: "tok_RAW-123"}
|
||||
r := newTestReplier(minter, sender)
|
||||
inst := testResolvedInstallation(t)
|
||||
msg := testInboundForReply()
|
||||
|
||||
r.Reply(context.Background(), inst, msg, engine.Result{
|
||||
Outcome: engine.OutcomeNeedsBinding,
|
||||
Sender: "UALICE",
|
||||
})
|
||||
|
||||
if minter.calls != 1 || minter.gotUser != "UALICE" {
|
||||
t.Fatalf("Mint called %d times for user %q", minter.calls, minter.gotUser)
|
||||
}
|
||||
if minter.gotWS != inst.WorkspaceID || minter.gotInst != inst.ID {
|
||||
t.Error("Mint must receive the resolved workspace + installation ids")
|
||||
}
|
||||
if sender.calls != 1 || sender.sent == nil {
|
||||
t.Fatalf("expected one reply, got %d", sender.calls)
|
||||
}
|
||||
if sender.sent.ChatID != "C1" || sender.sent.ThreadID != "1700000000.000200" {
|
||||
t.Errorf("reply target = %+v", sender.sent)
|
||||
}
|
||||
// The prompt must carry the redeem URL with the minted token, wrapped as a
|
||||
// Slack link so formatMrkdwn does not mangle the base64url token.
|
||||
wantLink := "<https://multica.example/slack/bind?token=tok_RAW-123|link your account>"
|
||||
if !strings.Contains(sender.sent.Text, wantLink) {
|
||||
t.Errorf("prompt text = %q, want it to contain %q", sender.sent.Text, wantLink)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReply_AgentOfflineAndArchived_PostNotices(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
outcome engine.Outcome
|
||||
want string
|
||||
}{
|
||||
{engine.OutcomeAgentOffline, agentOfflineText},
|
||||
{engine.OutcomeAgentArchived, agentArchivedText},
|
||||
} {
|
||||
sender := &fakeReplySender{}
|
||||
r := newTestReplier(&fakeBindingMinter{}, sender)
|
||||
r.Reply(context.Background(), testResolvedInstallation(t), testInboundForReply(), engine.Result{Outcome: tc.outcome})
|
||||
if sender.calls != 1 || sender.sent == nil || sender.sent.Text != tc.want {
|
||||
t.Errorf("outcome %s: got %d sends, text %q, want %q", tc.outcome, sender.calls, textOrEmpty(sender.sent), tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReply_IngestedWithIssue_Confirms(t *testing.T) {
|
||||
sender := &fakeReplySender{}
|
||||
r := newTestReplier(&fakeBindingMinter{}, sender)
|
||||
r.Reply(context.Background(), testResolvedInstallation(t), testInboundForReply(), engine.Result{
|
||||
Outcome: engine.OutcomeIngested,
|
||||
IssueID: mustUUID(t, "55555555-5555-5555-5555-555555555555"),
|
||||
IssueIdentifier: "MUL-42",
|
||||
IssueTitle: "Fix the thing",
|
||||
})
|
||||
if sender.calls != 1 || sender.sent == nil {
|
||||
t.Fatalf("expected one confirmation, got %d", sender.calls)
|
||||
}
|
||||
if !strings.Contains(sender.sent.Text, "MUL-42") || !strings.Contains(sender.sent.Text, "Fix the thing") {
|
||||
t.Errorf("confirmation text = %q", sender.sent.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReply_IngestedWithoutIssue_Silent(t *testing.T) {
|
||||
sender := &fakeReplySender{}
|
||||
r := newTestReplier(&fakeBindingMinter{}, sender)
|
||||
// A plain chat message (no /issue) must NOT post — the agent's own reply
|
||||
// lands via the EventChatDone outbound subscriber.
|
||||
r.Reply(context.Background(), testResolvedInstallation(t), testInboundForReply(), engine.Result{
|
||||
Outcome: engine.OutcomeIngested,
|
||||
})
|
||||
if sender.calls != 0 {
|
||||
t.Errorf("plain ingested message must stay silent, got %d sends", sender.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReply_Dropped_Silent(t *testing.T) {
|
||||
sender := &fakeReplySender{}
|
||||
r := newTestReplier(&fakeBindingMinter{}, sender)
|
||||
r.Reply(context.Background(), testResolvedInstallation(t), testInboundForReply(), engine.Result{Outcome: engine.OutcomeDropped})
|
||||
if sender.calls != 0 {
|
||||
t.Errorf("dropped outcome must stay silent, got %d sends", sender.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCreatedText(t *testing.T) {
|
||||
if got := issueCreatedText(engine.Result{IssueIdentifier: "MUL-7", IssueTitle: "Title"}); got != "✅ Created MUL-7 — Title" {
|
||||
t.Errorf("with title = %q", got)
|
||||
}
|
||||
if got := issueCreatedText(engine.Result{IssueNumber: 9}); got != "✅ Created #9" {
|
||||
t.Errorf("fallback to number = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func textOrEmpty(m *channel.OutboundMessage) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m.Text
|
||||
}
|
||||
@@ -25,11 +25,12 @@ import (
|
||||
const originSlackChat = "slack_chat"
|
||||
|
||||
// NewSlackResolverSet assembles the Slack ResolverSet over the generated
|
||||
// queries + a tx starter (for the shared session service). Replier/Typing are
|
||||
// left nil for now: the outbound binding-prompt / notice path is a later step
|
||||
// (the inbound pipeline — route, identity, dedup, session, /issue, run trigger
|
||||
// — is fully functional without them).
|
||||
func NewSlackResolverSet(q *db.Queries, tx engine.TxStarter) engine.ResolverSet {
|
||||
// queries + a tx starter (for the shared session service). The replier delivers
|
||||
// the outbound binding-prompt / status / issue-created notices; pass a nil
|
||||
// engine.OutboundReplier to disable them (the inbound pipeline — route,
|
||||
// identity, dedup, session, /issue, run trigger — is fully functional without
|
||||
// it). Typing is left nil. (MUL-3666 wired the replier; stage 3 had it nil.)
|
||||
func NewSlackResolverSet(q *db.Queries, tx engine.TxStarter, replier engine.OutboundReplier) engine.ResolverSet {
|
||||
return engine.ResolverSet{
|
||||
Installation: &installationResolver{q: q},
|
||||
Identity: &identityResolver{q: q},
|
||||
@@ -40,6 +41,7 @@ func NewSlackResolverSet(q *db.Queries, tx engine.TxStarter) engine.ResolverSet
|
||||
Fallback: "Slack chat",
|
||||
})},
|
||||
Audit: &auditor{q: q},
|
||||
Replier: replier,
|
||||
OriginType: originSlackChat,
|
||||
}
|
||||
}
|
||||
@@ -105,6 +107,21 @@ func nullText(s string) pgtype.Text {
|
||||
return pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
|
||||
// installationServesTeam reports whether an installation (its stored config) may
|
||||
// serve events from eventTeamID. Inbound routing keys on api_app_id, which
|
||||
// identifies the Slack APP, not the Slack workspace: a BYO app distributed /
|
||||
// installed into another Slack workspace emits events carrying the SAME app id.
|
||||
// So we additionally require the event's team to match the team the installed
|
||||
// bot belongs to. An installation with no recorded team (legacy) is permissive.
|
||||
func installationServesTeam(installConfigJSON json.RawMessage, eventTeamID string) bool {
|
||||
// Read team_id directly (NOT via DecodePublicConfig, which falls back to
|
||||
// app_id when team_id is absent — a hosted-era convenience that would defeat
|
||||
// this check for BYO where app_id != team_id).
|
||||
var cfg installConfig
|
||||
_ = json.Unmarshal(installConfigJSON, &cfg)
|
||||
return cfg.TeamID == "" || cfg.TeamID == eventTeamID
|
||||
}
|
||||
|
||||
// ---- installation routing ----
|
||||
|
||||
type installationResolver struct{ q *db.Queries }
|
||||
@@ -116,7 +133,11 @@ func (r *installationResolver) ResolveInstallation(ctx context.Context, msg chan
|
||||
}
|
||||
inst, err := r.q.GetChannelInstallationByAppID(ctx, db.GetChannelInstallationByAppIDParams{
|
||||
ChannelType: string(TypeSlack),
|
||||
AppID: raw.TeamID, // Slack team_id is stored in the routing-key slot
|
||||
// Route by the event's api_app_id: each BYO installation stores its real
|
||||
// Slack app id in the routing-key slot (config->>'app_id'), and the
|
||||
// per-installation Socket Mode connection only ever delivers events for
|
||||
// its own app, so api_app_id uniquely identifies the installation.
|
||||
AppID: raw.APIAppID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -124,6 +145,9 @@ func (r *installationResolver) ResolveInstallation(ctx context.Context, msg chan
|
||||
}
|
||||
return engine.ResolvedInstallation{}, err
|
||||
}
|
||||
if !installationServesTeam(inst.Config, raw.TeamID) {
|
||||
return engine.ResolvedInstallation{}, engine.ErrInstallationNotFound
|
||||
}
|
||||
return engine.ResolvedInstallation{
|
||||
ID: inst.ID,
|
||||
WorkspaceID: inst.WorkspaceID,
|
||||
|
||||
@@ -93,11 +93,46 @@ func TestSlackThreadIsolation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewSlackResolverSet(t *testing.T) {
|
||||
set := NewSlackResolverSet(nil, nil)
|
||||
set := NewSlackResolverSet(nil, nil, nil)
|
||||
if set.Installation == nil || set.Identity == nil || set.Dedup == nil || set.Session == nil || set.Audit == nil {
|
||||
t.Error("resolver set must populate all required resolvers")
|
||||
}
|
||||
if set.OriginType != "slack_chat" {
|
||||
t.Errorf("OriginType = %q, want slack_chat", set.OriginType)
|
||||
}
|
||||
if set.Replier != nil {
|
||||
t.Error("a nil replier arg must leave Replier nil (not a typed-nil interface)")
|
||||
}
|
||||
|
||||
// A real replier threads through.
|
||||
set = NewSlackResolverSet(nil, nil, NewOutboundReplier(OutboundReplierConfig{}))
|
||||
if set.Replier == nil {
|
||||
t.Error("a non-nil replier must populate ResolverSet.Replier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallationServesTeam(t *testing.T) {
|
||||
cfg := func(team string) json.RawMessage {
|
||||
b, _ := json.Marshal(installConfig{AppID: "A0BCXGVCS7R", TeamID: team})
|
||||
return b
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfgTeam string
|
||||
eventTeam string
|
||||
want bool
|
||||
}{
|
||||
{"matching team", "T999", "T999", true},
|
||||
// api_app_id alone is not enough: the same app installed into another Slack
|
||||
// workspace emits the same app id but a different team — must not route here.
|
||||
{"different team", "T999", "TOTHER", false},
|
||||
{"empty event team", "T999", "", false},
|
||||
{"legacy row without a team is permissive", "", "TANY", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := installationServesTeam(cfg(c.cfgTeam), c.eventTeam); got != c.want {
|
||||
t.Errorf("%s: installationServesTeam(cfg=%q, event=%q) = %v, want %v",
|
||||
c.name, c.cfgTeam, c.eventTeam, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
218
server/internal/integrations/slack/slack_channel.go
Normal file
218
server/internal/integrations/slack/slack_channel.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
|
||||
"github.com/slack-go/slack"
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
"github.com/slack-go/slack/socketmode"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
||||
)
|
||||
|
||||
// slackChannel is ONE installation's Socket Mode connection. Under the
|
||||
// bring-your-own-app (BYO) model every Slack installation carries its own Slack
|
||||
// app — its own app-level token (xapp-, stored encrypted in the installation
|
||||
// config) — so it gets its own connection, exactly like the stage-3
|
||||
// per-installation model and like Feishu today. The engine.Supervisor builds
|
||||
// one slackChannel per active Slack installation (via the registered Factory)
|
||||
// and owns the lease / reconnect lifecycle; Connect blocks on the receive loop.
|
||||
//
|
||||
// Inbound events are translated by the shared inbound.go helpers, parameterized
|
||||
// by THIS installation's bot user id, and handed to the engine router, which
|
||||
// resolves the installation by the event's api_app_id — equal to this app's id,
|
||||
// the per-app routing key. Outbound replies primarily flow through the
|
||||
// EventChatDone subscriber (NewOutbound); Send satisfies the Channel contract
|
||||
// and posts with this installation's bot token.
|
||||
type slackChannel struct {
|
||||
appID string
|
||||
botUserID string
|
||||
appToken string // decrypted xapp- — authorizes the Socket Mode connection
|
||||
botAPI *slack.Client // bot-token client for outbound Send
|
||||
handler channel.InboundHandler
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (c *slackChannel) Type() channel.Type { return TypeSlack }
|
||||
|
||||
func (c *slackChannel) Capabilities() channel.Capability {
|
||||
return channel.CapText | channel.CapThreadReply
|
||||
}
|
||||
|
||||
// Disconnect is a no-op: the Socket Mode connection's whole lifetime is scoped
|
||||
// to Connect (it returns when the run context is cancelled), so there is no
|
||||
// long-lived resource to release here. Mirrors feishuChannel.Disconnect.
|
||||
func (c *slackChannel) Disconnect(ctx context.Context) error { return nil }
|
||||
|
||||
// Send posts an outbound reply with this installation's bot token, reusing the
|
||||
// shared slackSender (Markdown→mrkdwn, chunking, threading).
|
||||
func (c *slackChannel) Send(ctx context.Context, out channel.OutboundMessage) (channel.SendResult, error) {
|
||||
return newSlackSender(credentials{BotUserID: c.botUserID}, c.botAPI, c.logger).Send(ctx, out)
|
||||
}
|
||||
|
||||
// Connect opens this installation's Socket Mode connection (authenticated with
|
||||
// its OWN app-level token) and runs the receive loop until ctx is cancelled or
|
||||
// the link drops. It mirrors the removed AppConnector.connectOnce but is
|
||||
// per-installation: the bot identity is fixed (this install's bot user id)
|
||||
// rather than resolved per event by team_id.
|
||||
func (c *slackChannel) Connect(ctx context.Context) error {
|
||||
if c.handler == nil {
|
||||
return errors.New("slack: inbound handler not configured")
|
||||
}
|
||||
if c.appToken == "" {
|
||||
return errors.New("slack: app-level token not configured")
|
||||
}
|
||||
// The Socket Mode connection authenticates with the app-level token alone;
|
||||
// the bot token is only for outbound Web API calls.
|
||||
api := slack.New("", slack.OptionAppLevelToken(c.appToken))
|
||||
sm := socketmode.New(api)
|
||||
|
||||
// Each connection runs under its OWN cancellable context. Every exit path
|
||||
// (handler error, event-stream close, ctx cancellation) cancels runCtx and
|
||||
// waits for the run goroutine to observe it and exit, so a transient failure
|
||||
// tears the live connection down before the supervisor reconnects — no
|
||||
// leaked socket goroutine consuming events into an unread channel.
|
||||
runCtx, runCancel := context.WithCancel(ctx)
|
||||
runErr := make(chan error, 1)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runErr <- sm.RunContext(runCtx)
|
||||
close(done)
|
||||
}()
|
||||
defer func() {
|
||||
runCancel()
|
||||
<-done
|
||||
}()
|
||||
|
||||
mentionRe := compileMentionRe(c.botUserID)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case err := <-runErr:
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("slack: socket mode connection closed")
|
||||
case evt, ok := <-sm.Events:
|
||||
if !ok {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New("slack: socket mode event stream closed")
|
||||
}
|
||||
if err := c.handleSocketEvent(ctx, sm, evt, mentionRe); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *slackChannel) handleSocketEvent(ctx context.Context, sm *socketmode.Client, evt socketmode.Event, mentionRe *regexp.Regexp) error {
|
||||
switch evt.Type {
|
||||
case socketmode.EventTypeEventsAPI:
|
||||
eventsAPI, ok := evt.Data.(slackevents.EventsAPIEvent)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// ACK first: Slack expires un-ACKed envelopes in ~3s, far below the
|
||||
// handler's DB work. The ACK is independent of the handler outcome.
|
||||
if evt.Request != nil {
|
||||
if err := sm.Ack(*evt.Request); err != nil {
|
||||
c.logger.WarnContext(ctx, "slack: ack failed", "error", err)
|
||||
}
|
||||
}
|
||||
return c.dispatchEventsAPI(ctx, eventsAPI, mentionRe)
|
||||
case socketmode.EventTypeConnecting, socketmode.EventTypeConnected, socketmode.EventTypeHello:
|
||||
c.logger.DebugContext(ctx, "slack: socket mode", "event", evt.Type, "app_id", c.appID)
|
||||
case socketmode.EventTypeIncomingError, socketmode.EventTypeErrorBadMessage:
|
||||
c.logger.WarnContext(ctx, "slack: socket mode error", "event", evt.Type, "app_id", c.appID)
|
||||
default:
|
||||
if evt.Request != nil {
|
||||
_ = sm.Ack(*evt.Request)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatchEventsAPI translates one Events API envelope to a normalized inbound
|
||||
// message and hands it to the engine. A non-nil handler error is an
|
||||
// infrastructure failure; it propagates so the supervisor reconnects. A
|
||||
// legitimate product drop returns nil.
|
||||
func (c *slackChannel) dispatchEventsAPI(ctx context.Context, e slackevents.EventsAPIEvent, mentionRe *regexp.Regexp) error {
|
||||
var (
|
||||
msg channel.InboundMessage
|
||||
ok bool
|
||||
)
|
||||
switch inner := e.InnerEvent.Data.(type) {
|
||||
case *slackevents.AppMentionEvent:
|
||||
msg, ok = inboundFromAppMention(e, inner, c.botUserID, mentionRe)
|
||||
case *slackevents.MessageEvent:
|
||||
msg, ok = inboundFromMessage(e, inner, c.botUserID, mentionRe)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return c.handler(ctx, msg)
|
||||
}
|
||||
|
||||
// ChannelDeps are the shared dependencies the Slack Factory closes over. The
|
||||
// engine inbound handler is supplied per-build via channel.Config.Handler; the
|
||||
// Decrypter turns the installation's stored ciphertext tokens into plaintext.
|
||||
type ChannelDeps struct {
|
||||
Decrypt Decrypter
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// RegisterSlack registers the per-installation Slack Factory so the
|
||||
// engine.Supervisor builds + supervises one slackChannel per active Slack
|
||||
// installation. "Adding Slack inbound" is this call plus the adapter — no engine
|
||||
// edit (the same contract as lark.RegisterFeishu).
|
||||
func RegisterSlack(reg *channel.Registry, deps ChannelDeps) {
|
||||
reg.Register(TypeSlack, newSlackFactory(deps))
|
||||
}
|
||||
|
||||
func newSlackFactory(deps ChannelDeps) channel.Factory {
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return func(cfg channel.Config) (channel.Channel, error) {
|
||||
var ic installConfig
|
||||
if err := json.Unmarshal(cfg.Raw, &ic); err != nil {
|
||||
return nil, fmt.Errorf("slack: decode installation config: %w", err)
|
||||
}
|
||||
appToken, err := decryptToken(ic.AppTokenEncrypted, deps.Decrypt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("slack: decrypt app token: %w", err)
|
||||
}
|
||||
if appToken == "" {
|
||||
return nil, errors.New("slack: installation has no app-level token")
|
||||
}
|
||||
botToken, err := decryptToken(ic.BotTokenEncrypted, deps.Decrypt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("slack: decrypt bot token: %w", err)
|
||||
}
|
||||
return &slackChannel{
|
||||
appID: ic.AppID,
|
||||
botUserID: ic.BotUserID,
|
||||
appToken: appToken,
|
||||
botAPI: slack.New(botToken),
|
||||
handler: cfg.Handler,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -362,6 +362,28 @@ func (q *Queries) DeleteChannelChatSessionBindingBySession(ctx context.Context,
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteChannelChatSessionBindingsByInstallation = `-- name: DeleteChannelChatSessionBindingsByInstallation :exec
|
||||
DELETE FROM channel_chat_session_binding
|
||||
WHERE installation_id = $1 AND channel_type = $2
|
||||
`
|
||||
|
||||
type DeleteChannelChatSessionBindingsByInstallationParams struct {
|
||||
InstallationID pgtype.UUID `json:"installation_id"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
}
|
||||
|
||||
// Retire every chat-session binding for an installation. Used when an
|
||||
// installation is re-pointed to a different agent (Slack re-connect): each
|
||||
// existing chat_session is permanently tied to the agent it was created under,
|
||||
// so reusing it would keep routing the conversation to the OLD agent. Dropping
|
||||
// the bindings forces the next inbound message to create a fresh session under
|
||||
// the new agent. The chat_session rows are preserved for history; only the
|
||||
// channel binding is removed.
|
||||
func (q *Queries) DeleteChannelChatSessionBindingsByInstallation(ctx context.Context, arg DeleteChannelChatSessionBindingsByInstallationParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteChannelChatSessionBindingsByInstallation, arg.InstallationID, arg.ChannelType)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteChannelUserBindingsByWorkspaceMember = `-- name: DeleteChannelUserBindingsByWorkspaceMember :exec
|
||||
DELETE FROM channel_user_binding
|
||||
WHERE workspace_id = $1 AND multica_user_id = $2
|
||||
@@ -1085,3 +1107,73 @@ func (q *Queries) UpsertChannelInstallation(ctx context.Context, arg UpsertChann
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertChannelInstallationByAppID = `-- name: UpsertChannelInstallationByAppID :one
|
||||
INSERT INTO channel_installation (
|
||||
workspace_id, agent_id, channel_type, config, installer_user_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
ON CONFLICT (channel_type, (config ->> 'app_id')) DO UPDATE SET
|
||||
agent_id = EXCLUDED.agent_id,
|
||||
config = EXCLUDED.config,
|
||||
installer_user_id = EXCLUDED.installer_user_id,
|
||||
status = 'active',
|
||||
installed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE channel_installation.workspace_id = EXCLUDED.workspace_id
|
||||
RETURNING id, workspace_id, agent_id, channel_type, config, status, ws_lease_token, ws_lease_expires_at, installer_user_id, installed_at, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertChannelInstallationByAppIDParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
Config []byte `json:"config"`
|
||||
InstallerUserID pgtype.UUID `json:"installer_user_id"`
|
||||
}
|
||||
|
||||
// Team-keyed install / re-install for channels whose natural identity is the
|
||||
// platform workspace, not the (agent) pairing. Slack: one Slack workspace
|
||||
// (team_id, stored as config->>'app_id') maps to exactly one installation, so
|
||||
// re-connecting it — even to represent a DIFFERENT agent in the SAME Multica
|
||||
// workspace — UPDATES the existing row (moving agent_id) instead of colliding
|
||||
// with the (channel_type, app_id) unique index. Contrast UpsertChannelInstallation,
|
||||
// whose conflict key is (workspace_id, agent_id, channel_type): right for Feishu
|
||||
// (one app per agent), wrong for Slack.
|
||||
//
|
||||
// The `WHERE channel_installation.workspace_id = EXCLUDED.workspace_id` fences
|
||||
// the conflict update to the SAME Multica workspace: a team already owned by a
|
||||
// DIFFERENT workspace updates no row and RETURNING is empty (pgx.ErrNoRows),
|
||||
// which the caller maps to ErrTeamOwnedByAnotherWorkspace. This is the ATOMIC
|
||||
// cross-workspace guard — a plain SELECT before the upsert cannot stop two
|
||||
// workspaces racing to OAuth the same team (both read no rows, then one inserts
|
||||
// and the other's conflict-update would silently steal it). A re-connect that
|
||||
// would move the team to an agent already holding a different Slack install in
|
||||
// the same workspace still trips the (workspace_id, agent_id, channel_type)
|
||||
// unique constraint — a genuine conflict the OAuth callback turns into a redirect.
|
||||
func (q *Queries) UpsertChannelInstallationByAppID(ctx context.Context, arg UpsertChannelInstallationByAppIDParams) (ChannelInstallation, error) {
|
||||
row := q.db.QueryRow(ctx, upsertChannelInstallationByAppID,
|
||||
arg.WorkspaceID,
|
||||
arg.AgentID,
|
||||
arg.ChannelType,
|
||||
arg.Config,
|
||||
arg.InstallerUserID,
|
||||
)
|
||||
var i ChannelInstallation
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.AgentID,
|
||||
&i.ChannelType,
|
||||
&i.Config,
|
||||
&i.Status,
|
||||
&i.WsLeaseToken,
|
||||
&i.WsLeaseExpiresAt,
|
||||
&i.InstallerUserID,
|
||||
&i.InstalledAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -38,6 +38,41 @@ ON CONFLICT (workspace_id, agent_id, channel_type) DO UPDATE SET
|
||||
updated_at = now()
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpsertChannelInstallationByAppID :one
|
||||
-- Team-keyed install / re-install for channels whose natural identity is the
|
||||
-- platform workspace, not the (agent) pairing. Slack: one Slack workspace
|
||||
-- (team_id, stored as config->>'app_id') maps to exactly one installation, so
|
||||
-- re-connecting it — even to represent a DIFFERENT agent in the SAME Multica
|
||||
-- workspace — UPDATES the existing row (moving agent_id) instead of colliding
|
||||
-- with the (channel_type, app_id) unique index. Contrast UpsertChannelInstallation,
|
||||
-- whose conflict key is (workspace_id, agent_id, channel_type): right for Feishu
|
||||
-- (one app per agent), wrong for Slack.
|
||||
--
|
||||
-- The `WHERE channel_installation.workspace_id = EXCLUDED.workspace_id` fences
|
||||
-- the conflict update to the SAME Multica workspace: a team already owned by a
|
||||
-- DIFFERENT workspace updates no row and RETURNING is empty (pgx.ErrNoRows),
|
||||
-- which the caller maps to ErrTeamOwnedByAnotherWorkspace. This is the ATOMIC
|
||||
-- cross-workspace guard — a plain SELECT before the upsert cannot stop two
|
||||
-- workspaces racing to OAuth the same team (both read no rows, then one inserts
|
||||
-- and the other's conflict-update would silently steal it). A re-connect that
|
||||
-- would move the team to an agent already holding a different Slack install in
|
||||
-- the same workspace still trips the (workspace_id, agent_id, channel_type)
|
||||
-- unique constraint — a genuine conflict the OAuth callback turns into a redirect.
|
||||
INSERT INTO channel_installation (
|
||||
workspace_id, agent_id, channel_type, config, installer_user_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
ON CONFLICT (channel_type, (config ->> 'app_id')) DO UPDATE SET
|
||||
agent_id = EXCLUDED.agent_id,
|
||||
config = EXCLUDED.config,
|
||||
installer_user_id = EXCLUDED.installer_user_id,
|
||||
status = 'active',
|
||||
installed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE channel_installation.workspace_id = EXCLUDED.workspace_id
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetChannelInstallation :one
|
||||
-- Scoped by channel_type: a per-channel caller (e.g. the Feishu store)
|
||||
-- must never resolve another channel's installation by guessing its UUID.
|
||||
@@ -246,6 +281,17 @@ WHERE chat_session_id = $1;
|
||||
DELETE FROM channel_chat_session_binding
|
||||
WHERE chat_session_id = $1;
|
||||
|
||||
-- name: DeleteChannelChatSessionBindingsByInstallation :exec
|
||||
-- Retire every chat-session binding for an installation. Used when an
|
||||
-- installation is re-pointed to a different agent (Slack re-connect): each
|
||||
-- existing chat_session is permanently tied to the agent it was created under,
|
||||
-- so reusing it would keep routing the conversation to the OLD agent. Dropping
|
||||
-- the bindings forces the next inbound message to create a fresh session under
|
||||
-- the new agent. The chat_session rows are preserved for history; only the
|
||||
-- channel binding is removed.
|
||||
DELETE FROM channel_chat_session_binding
|
||||
WHERE installation_id = $1 AND channel_type = $2;
|
||||
|
||||
-- =====================
|
||||
-- channel_inbound_message_dedup
|
||||
-- =====================
|
||||
|
||||
@@ -134,4 +134,12 @@ const (
|
||||
// deleting the row; the audit trail is preserved.
|
||||
EventLarkInstallationCreated = "lark_installation:created"
|
||||
EventLarkInstallationRevoked = "lark_installation:revoked"
|
||||
|
||||
// Slack installation lifecycle (MUL-3666). Same semantics as the Lark
|
||||
// events: `created` covers both first install and OAuth re-install (the
|
||||
// UNIQUE on (workspace_id, agent_id, channel_type) means at most one row
|
||||
// per agent), `revoked` flips status without deleting the row. Front-ends
|
||||
// invalidate the Slack installations query on either.
|
||||
EventSlackInstallationCreated = "slack_installation:created"
|
||||
EventSlackInstallationRevoked = "slack_installation:revoked"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user