Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,23 @@ GOOGLE_CLIENT_SECRET=
# Set explicitly only when your OAuth callback URL differs from local frontend.
GOOGLE_REDIRECT_URI=${FRONTEND_ORIGIN}/auth/callback

# WeChat Work smart-bot ("智能机器人" / aibot) integration.
# MULTICA_WECOM_* drives the bidirectional chat integration (users message
# the smart bot in their WeCom client, an agent replies over the same
# WebSocket long connection).
#
# MULTICA_WECOM_SECRET_KEY 32-byte base64 master key that encrypts each
# installation's smart-bot secret at rest.
# Empty = the wecom integration is disabled
# and the wecom Web-UI endpoints return 503.
# Generate with: openssl rand -base64 32
#
# Per-installation credentials (bot_id, secret) are NOT env vars — they
# live in the channel_installation table. Create an installation from the
# WeCom settings tab in the app (Settings → Integrations → WeCom), which
# seals the smart-bot secret with MULTICA_WECOM_SECRET_KEY before storing it.
MULTICA_WECOM_SECRET_KEY=

# S3 / CloudFront
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
# ".s3.<region>.amazonaws.com" suffix; the server builds the public URL
Expand Down
23 changes: 23 additions & 0 deletions apps/web/app/wecom/bind/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";

import { Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { WecomBindPage } from "@multica/views/wecom";

// /wecom/bind?token=<raw> is the smart-bot's "link your Multica 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 WecomBindPageContent() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
return <WecomBindPage token={token} />;
}

export default function Page() {
return (
<Suspense fallback={null}>
<WecomBindPageContent />
</Suspense>
);
}
6 changes: 6 additions & 0 deletions docker-compose.selfhost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ services:
MULTICA_VCS_INTEGRATION_ENABLED: ${MULTICA_VCS_INTEGRATION_ENABLED:-true}
# VCS integration at-rest encryption key for token-based providers
MULTICA_VCS_SECRET_KEY: ${MULTICA_VCS_SECRET_KEY:-}
# WeChat Work smart-bot integration. MULTICA_WECOM_SECRET_KEY is the
# opt-in: unset = integration disabled. It encrypts each installation's
# smart-bot secret at rest. The server needs it to unseal a bot's
# subscribe credentials at connection time, and to seal the secret when
# an installation is created from the WeCom settings tab.
MULTICA_WECOM_SECRET_KEY: ${MULTICA_WECOM_SECRET_KEY:-}
restart: unless-stopped

frontend:
Expand Down
49 changes: 49 additions & 0 deletions packages/core/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ import type {
ListSlackInstallationsResponse,
RegisterSlackBYORequest,
RedeemSlackBindingTokenResponse,
WecomInstallation,
ListWecomInstallationsResponse,
RegisterWecomBYORequest,
RedeemWecomBindingTokenResponse,
Squad,
SquadMember,
SquadMemberStatusListResponse,
Expand Down Expand Up @@ -2941,4 +2945,49 @@ export class ApiClient {
body: JSON.stringify({ token }),
});
}

// WeChat Work smart-bot ("智能机器人" / aibot) integration. The bot dials a
// WebSocket long connection to wss://openws.work.weixin.qq.com and stays
// authenticated with (bot_id, secret); no public callback URL is required.
// These three methods drive the Settings-page BYO Connect dialog + list +
// disconnect only — the inbound WebSocket loop runs entirely server-side.
async listWecomInstallations(workspaceId: string): Promise<ListWecomInstallationsResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/wecom/installations`);
}

// registerWecomBYO performs a bring-your-own-app install: the admin pastes
// the bot id and long-connection secret from the WeChat Work admin console,
// and the backend seals the secret with MULTICA_WECOM_SECRET_KEY before
// persisting, returning the new installation.
async registerWecomBYO(
workspaceId: string,
agentId: string,
body: RegisterWecomBYORequest,
): Promise<WecomInstallation> {
const search = new URLSearchParams({ agent_id: agentId });
return this.fetch(`/api/workspaces/${workspaceId}/wecom/install/byo?${search.toString()}`, {
method: "POST",
body: JSON.stringify(body),
});
}

async deleteWecomInstallation(workspaceId: string, installationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/wecom/installations/${installationId}`, {
method: "DELETE",
});
}

// redeemWecomBindingToken binds the WeCom aibot userid carried by the
// token to the logged-in Multica user. Called by the /wecom/bind redeem
// page after the user clicks through the "link your Multica account"
// prompt the bot sent in WeChat Work. Status codes:
// 410 Gone → invalid / expired / already consumed
// 409 Conflict → the WeCom user is already bound to a different user
// 403 Forbidden → the logged-in user is not a workspace member
async redeemWecomBindingToken(token: string): Promise<RedeemWecomBindingTokenResponse> {
return this.fetch(`/api/wecom/binding/redeem`, {
method: "POST",
body: JSON.stringify({ token }),
});
}
}
2 changes: 2 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
"./composio/queries": "./composio/queries.ts",
"./slack": "./slack/index.ts",
"./slack/queries": "./slack/queries.ts",
"./wecom": "./wecom/index.ts",
"./wecom/queries": "./wecom/queries.ts",
"./feedback": "./feedback/index.ts",
"./feedback/mutations": "./feedback/mutations.ts",
"./realtime": "./realtime/index.ts",
Expand Down
5 changes: 5 additions & 0 deletions packages/core/realtime/use-realtime-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { githubKeys } from "../github/queries";
import { larkKeys } from "../lark/queries";
import { slackKeys } from "../slack/queries";
import { wecomKeys } from "../wecom/queries";
import {
onIssueCreated,
onIssueUpdated,
Expand Down Expand Up @@ -690,6 +691,10 @@ export function useRealtimeSync(
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: ["vcs", wsId] });
},
wecom_installation: () => {
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: wecomKeys.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.
Expand Down
6 changes: 6 additions & 0 deletions packages/core/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ export type {
RegisterSlackBYORequest,
RedeemSlackBindingTokenResponse,
} from "./slack";
export type {
WecomInstallation,
ListWecomInstallationsResponse,
RegisterWecomBYORequest,
RedeemWecomBindingTokenResponse,
} from "./wecom";
export type {
Autopilot,
AutopilotStatus,
Expand Down
46 changes: 46 additions & 0 deletions packages/core/types/wecom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* A WeChat Work smart-bot ("智能机器人" / aibot) installation bound to a single
* Multica agent. Wire shape mirrors `WecomInstallationResponse` in
* `server/internal/handler/wecom_web.go`. Any new field the backend adds MUST
* default to optional so older desktop builds keep parsing the response — see
* CLAUDE.md → API Compatibility.
*/
export interface WecomInstallation {
id: string;
workspace_id: string;
agent_id: string;
/** The smart-bot identifier assigned by the WeChat Work admin console. */
bot_id: string;
installer_user_id: string;
status: "active" | "revoked" | string;
}

export interface ListWecomInstallationsResponse {
installations: WecomInstallation[];
/** Whether MULTICA_WECOM_SECRET_KEY is set on this deployment. When false the
* BYO Connect button is hidden and the panel renders an "ask the operator"
* state. */
configured: boolean;
/** Whether the install path is available (true whenever configured). Kept as
* a separate flag for parity with Slack / Lark; optional so a desktop build
* that predates it treats it as off. */
install_supported?: boolean;
}

/** Request body for the Web UI's BYO Connect dialog. Two fields, both copied
* from the WeChat Work admin console's smart-bot page: the bot's stable
* identifier and its long-connection secret. The backend seals the secret
* with the deployment's MULTICA_WECOM_SECRET_KEY before writing it, so
* plaintext never lands in the DB. */
export interface RegisterWecomBYORequest {
bot_id: string;
secret: string;
}

/** Post-redemption echo: the WeCom aibot userid the token carried is now
* bound to the logged-in Multica user in this workspace/installation. */
export interface RedeemWecomBindingTokenResponse {
workspace_id: string;
installation_id: string;
wecom_user_id: string;
}
1 change: 1 addition & 0 deletions packages/core/wecom/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { wecomKeys, wecomInstallationsOptions } from "./queries";
20 changes: 20 additions & 0 deletions packages/core/wecom/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";

/**
* Query key namespace for everything wecom-installation-related. Realtime sync
* invalidates `installations(wsId)` on `wecom_installation:*` events so the
* Settings panel updates without a manual refetch when another tab / an admin
* on another machine connects or disconnects a bot.
*/
export const wecomKeys = {
all: (wsId: string) => ["wecom", wsId] as const,
installations: (wsId: string) => [...wecomKeys.all(wsId), "installations"] as const,
};

export const wecomInstallationsOptions = (wsId: string) =>
queryOptions({
queryKey: wecomKeys.installations(wsId),
queryFn: () => api.listWecomInstallations(wsId),
enabled: !!wsId,
});
9 changes: 8 additions & 1 deletion packages/views/agents/components/agent-overview-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { COMPOSIO_MCP_APPS_FLAG } from "@multica/core/feature-flags";
import { useWorkspaceId } from "@multica/core/hooks";
import { larkInstallationsOptions } from "@multica/core/lark";
import { slackInstallationsOptions } from "@multica/core/slack";
import { wecomInstallationsOptions } from "@multica/core/wecom";
import {
AlertDialog,
AlertDialogAction,
Expand Down Expand Up @@ -171,9 +172,15 @@ export function AgentOverviewPane({
...slackInstallationsOptions(wsId),
enabled: !!wsId,
});
const { data: wecomListing } = useQuery({
...wecomInstallationsOptions(wsId),
enabled: !!wsId,
});

const integrationsConfigured =
larkListing?.configured === true || slackListing?.configured === true;
larkListing?.configured === true ||
slackListing?.configured === true ||
wecomListing?.configured === true;

const visibleCapabilityTabs = useMemo(() => {
const showMcp = runtime
Expand Down
31 changes: 24 additions & 7 deletions packages/views/agents/components/tabs/integrations-tab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ vi.mock("@multica/core/slack", () => ({
}),
}));

vi.mock("@multica/core/wecom", () => ({
wecomInstallationsOptions: () => ({
queryKey: ["wecom", "installations"],
queryFn: vi.fn(),
}),
}));

vi.mock("@multica/core/auth", () => {
const useAuthStore = Object.assign(
(sel?: (s: { user: { id: string } }) => unknown) =>
Expand Down Expand Up @@ -94,6 +101,14 @@ vi.mock("../../../settings/components/slack-tab", () => ({
),
}));

// Same stubbing rationale for WeCom smart-bot: the shared bind entry has
// its own coverage in wecom-tab.test.tsx (when added); here it's a marker.
vi.mock("../../../settings/components/wecom-tab", () => ({
WecomAgentBindButton: ({ agentId }: { agentId: string }) => (
<div data-testid="wecom-bind-button" data-agent-id={agentId} />
),
}));

import { IntegrationsTab } from "./integrations-tab";

const TEST_RESOURCES = {
Expand Down Expand Up @@ -182,27 +197,29 @@ describe("IntegrationsTab", () => {
membersRef.current = [{ user_id: "user-1", role: "member" }];
renderTab(<IntegrationsTab agent={{ ...agent, owner_id: "user-2" }} />);
expect(
screen.getByText(/Only workspace owners and admins can connect an agent/i),
).toBeTruthy();
screen.getAllByText(/Only workspace owners and admins can connect an agent/i).length,
).toBeGreaterThanOrEqual(1);
expect(screen.queryByTestId("lark-bind-button")).toBeNull();
expect(screen.queryByTestId("slack-bind-button")).toBeNull();
expect(screen.queryByTestId("wecom-bind-button")).toBeNull();
});

it("lets a non-admin agent owner bind Lark but keeps Slack admin-only", () => {
// The agent's owner (user-1) is only a plain workspace member. Lark
// authorizes the agent owner (canManageAgent), so the Lark bind entry
// renders and receives owner_id; Slack's routes stay admin-only, so it
// shows the read-only note instead of a CTA (MUL-4213).
// renders and receives owner_id; Slack and WeCom's routes stay admin-only,
// so both show the read-only note instead of a CTA (MUL-4213).
membersRef.current = [{ user_id: "user-1", role: "member" }];
renderTab(<IntegrationsTab agent={agent} />);
const larkButton = screen.getByTestId("lark-bind-button");
expect(larkButton.getAttribute("data-agent-id")).toBe("agent-1");
expect(larkButton.getAttribute("data-agent-owner-id")).toBe("user-1");
expect(screen.queryByTestId("slack-bind-button")).toBeNull();
// The Slack section falls back to the shared members note.
expect(screen.queryByTestId("wecom-bind-button")).toBeNull();
// Both Slack and WeCom fall back to the shared members note.
expect(
screen.getByText(/Only workspace owners and admins can connect an agent/i),
).toBeTruthy();
screen.getAllByText(/Only workspace owners and admins can connect an agent/i).length,
).toBeGreaterThanOrEqual(1);
});

it("renders the bind entry (not coming-soon) when installs are unavailable but the agent is already bound", () => {
Expand Down
Loading
Loading