From 0c4207fffa8a7ed37d3f2652477ee8f6c48396bb Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 13:05:48 -0700
Subject: [PATCH 1/3] Route Slack plain messages to the active pod lead
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
.../routes/slackOAuth.installables.test.js | 43 ++++++-
.../unit/services/slackBridgeService.test.js | 107 +++++++++++++++++-
backend/routes/installables.ts | 21 ++++
backend/services/slackBridgeService.ts | 41 ++++++-
docs-site/integrations/slack.mdx | 10 +-
docs/slack/README.md | 15 ++-
frontend/src/i18n/locales/en.json | 7 +-
frontend/src/i18n/locales/zh-CN.json | 7 +-
.../v2/__tests__/V2ConnectorsPage.test.tsx | 27 +++++
.../src/v2/components/V2ConnectorsPage.tsx | 37 +++++-
10 files changed, 290 insertions(+), 25 deletions(-)
diff --git a/backend/__tests__/unit/routes/slackOAuth.installables.test.js b/backend/__tests__/unit/routes/slackOAuth.installables.test.js
index 1a382b2a9c..a5ce641f0e 100644
--- a/backend/__tests__/unit/routes/slackOAuth.installables.test.js
+++ b/backend/__tests__/unit/routes/slackOAuth.installables.test.js
@@ -13,7 +13,7 @@ jest.mock('../../../middleware/integrationRateLimit', () => ({
listIntegrationsRateLimit: (_req, _res, next) => next(),
}));
jest.mock('../../../models/Pod', () => ({ findById: jest.fn() }));
-jest.mock('../../../models/User', () => ({ findById: jest.fn() }));
+jest.mock('../../../models/User', () => ({ findById: jest.fn(), findOne: jest.fn() }));
jest.mock('../../../models/Integration', () => ({
findOne: jest.fn(), findOneAndUpdate: jest.fn(), findById: jest.fn(), updateOne: jest.fn(),
collection: { indexExists: jest.fn() },
@@ -294,6 +294,47 @@ describe('Slack installable OAuth routes', () => {
expect(SlackApi.mock.results[0].value.postMessage).toHaveBeenCalledWith('D1', '[<Evil|pod>] connected');
});
+ test('stores the selected lead on the active pod gate at confirmation', async () => {
+ const pending = {
+ teamId: 'T1', slackUserId: 'U1', chatId: 'D1', botTokenRef: 'secret-ref',
+ expiresAt: new Date(Date.now() + 60_000),
+ };
+ Integration.findOne.mockResolvedValue({ ...integration, config: { pendingBind: pending } });
+ Pod.findById
+ .mockResolvedValueOnce({ members: [ownerId, 'bot-1'], type: 'team' })
+ .mockReturnValueOnce({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ name: 'Pod' }) }) });
+ User.findOne.mockResolvedValue({ _id: 'bot-1', username: 'kai', isBot: true });
+ Integration.findOneAndUpdate.mockResolvedValue({ ...integration, status: 'connected', config: { ...pending, chatType: 'im' } });
+ SlackApi.mockImplementationOnce(() => ({ postMessage: jest.fn().mockResolvedValue({ ok: true, ts: '1.1' }) }));
+
+ const response = await request(app).post('/api/installables/slack/confirm')
+ .send({ leadAgentUsername: 'kai' });
+
+ expect(response.status).toBe(200);
+ expect(User.findOne).toHaveBeenCalledWith({
+ _id: { $in: [ownerId, 'bot-1'] }, username: 'kai', isBot: true,
+ });
+ expect(Integration.findOneAndUpdate.mock.calls[0][1].$set[
+ `config.gates.${integration.podId}`
+ ]).toEqual(expect.objectContaining({ enabled: true, lead: 'kai', since: expect.any(Date) }));
+ });
+
+ test('refuses a selected lead who is not a bot member without binding the DM', async () => {
+ Integration.findOne.mockResolvedValue({
+ ...integration,
+ config: { pendingBind: { teamId: 'T1', chatId: 'D1', botTokenRef: 'secret-ref', expiresAt: new Date(Date.now() + 60_000) } },
+ });
+ Pod.findById.mockResolvedValue({ members: [ownerId], type: 'team' });
+ User.findOne.mockResolvedValue(null);
+
+ const response = await request(app).post('/api/installables/slack/confirm')
+ .send({ leadAgentUsername: 'outsider' });
+
+ expect(response.status).toBe(400);
+ expect(response.body.code).toBe('slack_lead_invalid');
+ expect(Integration.findOneAndUpdate).not.toHaveBeenCalled();
+ });
+
test('refuses a Slack DM already bound to another Commonly account by name', async () => {
const pending = {
teamId: 'T1', slackUserId: 'U1', chatId: 'D1', botTokenRef: 'secret-ref',
diff --git a/backend/__tests__/unit/services/slackBridgeService.test.js b/backend/__tests__/unit/services/slackBridgeService.test.js
index ea065358cf..0df4df6882 100644
--- a/backend/__tests__/unit/services/slackBridgeService.test.js
+++ b/backend/__tests__/unit/services/slackBridgeService.test.js
@@ -1,6 +1,6 @@
jest.mock('../../../models/Integration', () => ({ findOne: jest.fn(), findByIdAndUpdate: jest.fn() }));
jest.mock('../../../models/Pod', () => ({ findById: jest.fn() }));
-jest.mock('../../../models/User', () => ({ findById: jest.fn() }));
+jest.mock('../../../models/User', () => ({ findById: jest.fn(), findOne: jest.fn() }));
jest.mock('../../../models/pg/Message', () => ({ create: jest.fn(), findById: jest.fn() }));
jest.mock('../../../services/messageAgentDeliveryService', () => ({ deliverMessageToAgents: jest.fn() }));
jest.mock('../../../config/socket', () => ({ getIO: jest.fn(() => null) }));
@@ -142,9 +142,9 @@ describe('Slack installable bridge', () => {
// Two fixture shapes, because the routed pod and the active pod are different
// documents: the first names the pod that may not be reachable any more.
- const podWithMembers = (name) => ({
+ const podWithMembers = (name, members = ['user-1']) => ({
select: jest.fn().mockReturnValue({
- lean: jest.fn().mockResolvedValue({ name, type: 'team', members: ['user-1'] }),
+ lean: jest.fn().mockResolvedValue({ name, type: 'team', members }),
}),
});
const podsById = (gatedPodName) => (id) => ({
@@ -211,6 +211,107 @@ describe('Slack installable bridge', () => {
expect(PGMessage.create.mock.calls[0][0]).toBe('pod-1');
});
+ test('addresses an unquoted plain message to the active pod lead', async () => {
+ Pod.findById.mockReturnValue(podWithMembers('Alpha', ['user-1', 'bot-1']));
+ User.findOne.mockResolvedValue({ _id: 'bot-1', username: 'kai', isBot: true });
+ PGMessage.create.mockImplementation((_podId, _userId, content) => Promise.resolve({ id: 'pg-1', content }));
+ PGMessage.findById.mockResolvedValue(null);
+ User.findById.mockReturnValue({
+ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ username: 'sam' }) }),
+ });
+ deliverMessageToAgents.mockResolvedValue(undefined);
+
+ const result = await relaySlackMessageToPod({
+ integration: {
+ ...integration,
+ scope: 'user',
+ config: { ...integration.config, linkedUserId: 'user-1', slackUserId: 'U1', gates: { 'pod-1': { enabled: true, lead: 'kai' } } },
+ },
+ event: { text: 'hello', user: 'U1' },
+ });
+
+ expect(result).toEqual({ relayed: true, routedAgent: 'kai' });
+ expect(PGMessage.create.mock.calls[0][2]).toBe('💬 (via Slack): @kai hello');
+ expect(User.findOne).toHaveBeenCalledWith({
+ _id: { $in: ['user-1', 'bot-1'] }, username: 'kai', isBot: true,
+ });
+ expect(deliverMessageToAgents.mock.calls[0][0].message).toEqual(expect.objectContaining({
+ id: 'pg-1', content: '💬 (via Slack): @kai hello',
+ }));
+ expect(SlackApi).not.toHaveBeenCalled();
+ });
+
+ test('keeps an explicit mention instead of substituting the selected lead', async () => {
+ Pod.findById.mockReturnValue(podWithMembers('Alpha'));
+ PGMessage.create.mockResolvedValue({ id: 'pg-1' });
+ PGMessage.findById.mockResolvedValue({ id: 'pg-1', content: 'relayed' });
+ User.findById.mockReturnValue({
+ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ username: 'sam' }) }),
+ });
+ deliverMessageToAgents.mockResolvedValue(undefined);
+
+ await relaySlackMessageToPod({
+ integration: {
+ ...integration,
+ scope: 'user',
+ config: { ...integration.config, linkedUserId: 'user-1', slackUserId: 'U1', gates: { 'pod-1': { enabled: true, lead: 'kai' } } },
+ },
+ event: { text: '@vera please review', user: 'U1' },
+ });
+
+ expect(PGMessage.create.mock.calls[0][2]).toBe('💬 (via Slack): @vera please review');
+ expect(SlackApi).not.toHaveBeenCalled();
+ });
+
+ test('acknowledges an unaddressed message when the active pod has no lead', async () => {
+ Pod.findById.mockReturnValue(podWithMembers('Alpha'));
+ PGMessage.create.mockResolvedValue({ id: 'pg-1' });
+ PGMessage.findById.mockResolvedValue({ id: 'pg-1', content: 'relayed' });
+ User.findById.mockReturnValue({
+ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ username: 'sam' }) }),
+ });
+ deliverMessageToAgents.mockResolvedValue(undefined);
+
+ await relaySlackMessageToPod({
+ integration: {
+ ...integration,
+ scope: 'user',
+ config: { ...integration.config, linkedUserId: 'user-1', slackUserId: 'U1', gates: {} },
+ },
+ event: { text: 'hello', user: 'U1' },
+ });
+
+ expect(PGMessage.create.mock.calls[0][2]).toBe('💬 (via Slack): hello');
+ expect(SlackApi.mock.results[0].value.postMessage).toHaveBeenCalledWith(
+ 'D1', expect.stringContaining('no lead agent is selected'),
+ );
+ });
+
+ test('does not wake a selected lead after that bot leaves the pod', async () => {
+ Pod.findById.mockReturnValue(podWithMembers('Alpha'));
+ User.findOne.mockResolvedValue(null);
+ PGMessage.create.mockResolvedValue({ id: 'pg-1' });
+ PGMessage.findById.mockResolvedValue({ id: 'pg-1', content: 'relayed' });
+ User.findById.mockReturnValue({
+ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ username: 'sam' }) }),
+ });
+ deliverMessageToAgents.mockResolvedValue(undefined);
+
+ await relaySlackMessageToPod({
+ integration: {
+ ...integration,
+ scope: 'user',
+ config: { ...integration.config, linkedUserId: 'user-1', slackUserId: 'U1', gates: { 'pod-1': { enabled: true, lead: 'kai' } } },
+ },
+ event: { text: 'hello', user: 'U1' },
+ });
+
+ expect(PGMessage.create.mock.calls[0][2]).toBe('💬 (via Slack): hello');
+ expect(SlackApi.mock.results[0].value.postMessage).toHaveBeenCalledWith(
+ 'D1', expect.stringContaining('no lead agent is selected'),
+ );
+ });
+
test('refuses a thread reply into a pod whose gate is off, naming it, posting nothing', async () => {
Pod.findById.mockImplementation(podsById('Launch'));
diff --git a/backend/routes/installables.ts b/backend/routes/installables.ts
index 4d08c5919a..6455ba60a5 100644
--- a/backend/routes/installables.ts
+++ b/backend/routes/installables.ts
@@ -443,6 +443,16 @@ router.post('/slack/confirm', writeIntegrationsRateLimit, auth, async (req: Auth
if (!pod || !isConnectorTargetPod(pod, userId)) {
return slackError(res, 403, 'slack_pod_access_denied', 'You no longer have access to this pod.');
}
+ const selectedLead = req.body?.leadAgentUsername;
+ if (selectedLead !== undefined) {
+ if (typeof selectedLead !== 'string' || !/^[a-z0-9_-]{2,}$/i.test(selectedLead)) {
+ return slackError(res, 400, 'slack_lead_invalid', 'Choose an agent in the active pod.');
+ }
+ const member = await User.findOne({
+ _id: { $in: pod.members }, username: selectedLead, isBot: true,
+ });
+ if (!member) return slackError(res, 400, 'slack_lead_invalid', 'Choose an agent in the active pod.');
+ }
// A preflight lookup alone cannot prevent two replicas confirming the same
// DM at once. Refuse new binds until the unique index has been installed.
const unavailable = 'Slack connections are temporarily unavailable. Try again later.';
@@ -456,6 +466,9 @@ router.post('/slack/confirm', writeIntegrationsRateLimit, auth, async (req: Auth
const bindingSelector = activeSlackDm(String(pending.teamId), String(pending.chatId), owned.integration._id);
const existing = await Integration.findOne(bindingSelector);
if (existing && String(existing._id) !== String(owned.integration._id)) return slackBindConflict(res, existing);
+ const activePodId = String(owned.integration.podId);
+ const gates = owned.integration.config?.gates;
+ const oldGate = gates?.get?.(activePodId) || gates?.[activePodId];
let confirmed: any;
try {
confirmed = await Integration.findOneAndUpdate(
@@ -483,6 +496,14 @@ router.post('/slack/confirm', writeIntegrationsRateLimit, auth, async (req: Auth
'config.chatId': pending.chatId,
'config.chatType': 'im',
'config.botTokenRef': pending.botTokenRef,
+ ...(selectedLead ? {
+ [`config.gates.${activePodId}`]: {
+ enabled: oldGate?.enabled ?? true,
+ ...(oldGate?.mode ? { mode: oldGate.mode } : {}),
+ lead: selectedLead,
+ since: oldGate?.since || new Date(),
+ },
+ } : {}),
},
$unset: { 'config.pendingBind': 1 },
},
diff --git a/backend/services/slackBridgeService.ts b/backend/services/slackBridgeService.ts
index e1b2a082fa..383c77b502 100644
--- a/backend/services/slackBridgeService.ts
+++ b/backend/services/slackBridgeService.ts
@@ -44,7 +44,7 @@ interface SlackIntegrationDoc {
slackUserId?: string;
liveRelay?: boolean;
relayAllAgentMessages?: boolean;
- gates?: Record;
+ gates?: Record;
leadAgentUsername?: string;
relayMutedUntil?: Date | string;
adminPause?: { reason?: string; at?: Date | string; adminId?: string };
@@ -130,6 +130,8 @@ const isInboundRelayableIntegration = (integration: SlackIntegrationDoc, podId:
);
const NO_ACTIVE_POD_REPLY = 'This connector has no active pod. Choose one in Commonly first.';
+const NO_LEAD_REPLY = 'Your message reached the pod, but no lead agent is selected there. '
+ + 'Choose a lead in Commonly to get replies here.';
// ADR-025 D11: a quote-reply whose pod is no longer reachable is refused in the
// chat and posted nowhere. Naming the pod is the whole point — "your reply did
@@ -172,6 +174,19 @@ const replyRoutedPodRefused = async (
}
};
+const replyNoLead = async (integration: SlackIntegrationDoc): Promise => {
+ const chatId = integration.config?.chatId;
+ const botTokenRef = integration.config?.botTokenRef;
+ if (!chatId || !botTokenRef) return;
+ try {
+ const token = await connectorSecrets.get(String(botTokenRef));
+ const sent = await new SlackApi(token).postMessage(String(chatId), NO_LEAD_REPLY);
+ await deliveryFailures.noteBoundChatDeliveryFailure(integration, chatId, sent);
+ } catch (error) {
+ console.warn('[slack-bridge] could not send no-lead reply:', (error as Error).message);
+ }
+};
+
const findLiveIntegration = async (podId: string): Promise => (
Integration.findOne({
type: 'slack',
@@ -443,13 +458,14 @@ export const relaySlackMessageToPod = async (opts: {
}
podId = String(routed.podId);
}
- const { content: routedText, routedAgent } = routed;
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
+ const { extractMentions } = require('./agentMentionService');
+ const lead = config.gates?.[podId]?.lead;
+ const plainMessage = !routed.routedAgent && !event.thread_ts && !cardReply.lateReply
+ && extractMentions(rawText).length === 0;
const replyToMessageId = cardReply.lateReply?.messageId || null;
const linkedUserId = String(config.linkedUserId);
const senderName = event.user_profile?.display_name || event.user_profile?.real_name;
- const content = senderName
- ? `💬 ${senderName} (via Slack): ${routedText}`
- : `💬 (via Slack): ${routedText}`;
// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
const User = require('../models/User');
@@ -473,6 +489,20 @@ export const relaySlackMessageToPod = async (opts: {
console.warn('[slack-bridge] inbound dropped — linked user missing');
return { relayed: false };
}
+ let addressedLead: string | null = null;
+ if (plainMessage && typeof lead === 'string' && /^[a-z0-9_-]{2,}$/i.test(lead)) {
+ try {
+ const member = await User.findOne({ _id: { $in: pod.members }, username: lead, isBot: true });
+ if (member) addressedLead = lead;
+ } catch (error) {
+ console.warn('[slack-bridge] lead membership lookup failed:', (error as Error).message);
+ }
+ }
+ const routedText = addressedLead ? `@${addressedLead} ${routed.content}` : routed.content;
+ const routedAgent = routed.routedAgent || addressedLead;
+ const content = senderName
+ ? `💬 ${senderName} (via Slack): ${routedText}`
+ : `💬 (via Slack): ${routedText}`;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
const PGPod = require('../models/pg/Pod');
@@ -491,6 +521,7 @@ export const relaySlackMessageToPod = async (opts: {
threadRootId = await resolveThreadRoot({ podId, replyToMessageId, threadRootId: cardReply.lateReply.threadRootId });
}
const created = await PGMessage.create(podId, linkedUserId, content, 'text', replyToMessageId, null, threadRootId);
+ if (plainMessage && !addressedLead) await replyNoLead(integration);
let message: Record = created;
try {
const populated = created?.id ? await PGMessage.findById(created.id) : null;
diff --git a/docs-site/integrations/slack.mdx b/docs-site/integrations/slack.mdx
index 7f6e596465..2ff6121a74 100644
--- a/docs-site/integrations/slack.mdx
+++ b/docs-site/integrations/slack.mdx
@@ -9,7 +9,8 @@ description: Pod messages in a Slack direct message
2. Choose a pod under **Pod to bridge**, then select **Connect**.
3. Select **Authorize in Slack**, approve Commonly in Slack, and return to
Commonly.
-4. Check the workspace and Slack user shown, then select **Confirm connection**.
+4. Check the workspace and Slack user shown, choose an agent for plain messages,
+ then select **Confirm connection**.
The live mobile view shows **Add** on Slack before authorization. The connected
state appears after confirmation.
@@ -18,9 +19,10 @@ state appears after confirmation.
-With an agent in the pod, its messages can reach a Slack direct message. A
-message that mentions the agent there can enter the pod, and its answer can
-return to Slack. The relay identifies the pod and agent.
+Plain Slack messages reach the active pod and address its selected agent.
+An explicit `@agent` or a reply to a relayed agent line keeps that target.
+Agent replies can return to Slack. If the pod has no agent yet, messages still
+reach it and Slack tells you to choose a lead later.
If you operate the Slack app for a self-hosted instance, enable the App Home
messages tab and verify the Events Request URL in Slack's app settings so
diff --git a/docs/slack/README.md b/docs/slack/README.md
index c84797f331..f18d5ef31b 100644
--- a/docs/slack/README.md
+++ b/docs/slack/README.md
@@ -2,8 +2,9 @@
Commonly connects a Slack direct message to a pod. The person connecting it
authorizes the Slack app, checks the workspace and Slack user in Commonly, and
-confirms the connection. Messages that mention an agent can enter the pod; the
-agent's answer can return to the DM. See the [user walkthrough](../../docs-site/integrations/slack.mdx).
+confirms the connection. A plain DM addresses the active pod's selected lead;
+an explicit `@agent` or a reply to a relayed agent line keeps its own target.
+The agent's answer can return to the DM. See the [user walkthrough](../../docs-site/integrations/slack.mdx).
## Configure the Slack app
@@ -41,9 +42,13 @@ connection. Do not paste a signing secret or bot token into a connection row.
## Verify the connection
In Commonly, choose **Connectors → Slack → Add**, select a pod, authorize the
-app in Slack, and confirm the returned workspace and user. Send a DM that
-mentions a pod agent and check that it reaches the pod and that the answer
-returns to Slack. The HTTP events route accepts signed `message.im` events;
+app in Slack, choose an agent from the selected pod at confirmation, and
+confirm the returned workspace and user. Send a plain DM and check that it
+addresses that agent in the pod and that the answer returns to Slack. If the
+pod has no agent yet, confirmation remains available; a plain DM reaches the
+pod and gets an explicit notice in Slack that no lead is selected. The lead
+belongs to each pod's gate, so changing the active pod changes the plain DM
+target. The HTTP events route accepts signed `message.im` events;
the relay requires an active DM binding. `/commonly status` reports the relay
mode, and `/commonly mode mirror|attention` changes it for that binding.
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index a31ff9e11c..85e4adf6d5 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -2071,7 +2071,7 @@
"connectChannelHint": "Choose a channel and the pod it should join.",
"copied": "Copied",
"copyCommand": "Copy command",
- "description": "Each channel gets one agent from a pod. It answers there in its own name; the rest of the team stays behind it.",
+ "description": "Connect a channel to a pod. Messages arrive there as you; choose what reaches the channel.",
"errorLine": "The connection dropped.",
"errorReconnect": "reconnect to resume",
"gateLead": "Lead agent for {{pod}}",
@@ -2148,6 +2148,11 @@
"slackConfirmConnection": "Confirm connection",
"slackConfirmDetail": "waiting for you to confirm",
"slackConfirmHint": "{{workspace}} wants to connect as {{user}}.",
+ "slackBindLead": "Agent for plain messages",
+ "slackBindLeadNone": "Choose an agent",
+ "slackLeadInvalid": "Choose an agent in the active pod.",
+ "slackLeadNone": "No lead selected",
+ "slackNoAgent": "No agent is in this pod yet. Messages will reach the pod; choose a lead later.",
"slackConfirmRow": "{{workspace}} says {{user}} connected — is that you?",
"slackReject": "This is not me",
"title": "Connectors",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 3345572649..6dd96d3226 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -2062,7 +2062,7 @@
"connectChannelHint": "选择频道,以及它要加入的 Pod。",
"copied": "已复制",
"copyCommand": "复制命令",
- "description": "每个频道从一个 Pod 接入一个智能体。它会在那里以自己的名义回复,其余团队成员都在幕后。",
+ "description": "将频道连接到 Pod。消息会以你的身份进入 Pod;你可以选择哪些内容回传到频道。",
"errorLine": "连接已断开。",
"errorReconnect": "重新连接以恢复",
"gateLead": "{{pod}} 的主导智能体",
@@ -2139,6 +2139,11 @@
"slackConfirmConnection": "确认连接",
"slackConfirmDetail": "等待你确认",
"slackConfirmHint": "{{workspace}} 想以 {{user}} 的身份连接。",
+ "slackBindLead": "回复普通消息的智能体",
+ "slackBindLeadNone": "选择智能体",
+ "slackLeadInvalid": "请选择当前 Pod 中的智能体。",
+ "slackLeadNone": "尚未选择负责人",
+ "slackNoAgent": "这个 Pod 里还没有智能体。消息会送达 Pod;你可以稍后选择负责人。",
"slackConfirmRow": "{{workspace}} 显示 {{user}} 已连接,是你吗?",
"slackReject": "这不是我",
"title": "连接器",
diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
index 5f8a853f06..886c73f23c 100644
--- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
+++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
@@ -609,6 +609,7 @@ describe('V2ConnectorsPage', () => {
renderPage();
expect(await screen.findByText('Commonly HQ wants to connect as @sam.')).toBeInTheDocument();
+ expect(screen.getByText('No agent is in this pod yet. Messages will reach the pod; choose a lead later.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Confirm connection' }));
await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/installables/slack/confirm', {}, expect.anything()));
@@ -635,6 +636,32 @@ describe('V2ConnectorsPage', () => {
));
});
+ it('requires a pod agent at Slack bind and sends the selected lead', async () => {
+ mockGets([{
+ _id: 'i-slack-pending', installationId: 'install-slack-u1', type: 'slack', status: 'pending',
+ config: { pendingBind: { teamName: 'Commonly HQ', slackUserName: 'sam' } },
+ podId: { _id: 'p1', name: 'Rewire Live Demo' },
+ }]);
+ const priorGet = axios.get.getMockImplementation();
+ axios.get.mockImplementation((url) => url === '/api/pods'
+ ? Promise.resolve({ data: [{
+ _id: 'p1', name: 'Rewire Live Demo', type: 'chat',
+ members: [{ _id: 'agent-1', username: 'kai', isBot: true }],
+ }] })
+ : priorGet(url));
+ axios.post.mockResolvedValue({ data: { status: 'connected' } });
+ renderPage();
+
+ const confirm = await screen.findByRole('button', { name: 'Confirm connection' });
+ expect(confirm).toBeDisabled();
+ fireEvent.change(screen.getByLabelText('Agent for plain messages'), { target: { value: 'kai' } });
+ expect(confirm).toBeEnabled();
+ fireEvent.click(confirm);
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/installables/slack/confirm', { leadAgentUsername: 'kai' }, expect.anything(),
+ ));
+ });
+
it('keeps rejection reachable from the selected Slack aside', async () => {
mockGets([{
_id: 'i-slack-pending', installationId: 'install-slack-u1', type: 'slack', status: 'pending',
diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx
index 092b27764e..859fdbc471 100644
--- a/frontend/src/v2/components/V2ConnectorsPage.tsx
+++ b/frontend/src/v2/components/V2ConnectorsPage.tsx
@@ -229,6 +229,7 @@ const SLACK_REFUSALS: Record = {
slack_installation_not_found: { key: 'connectors.slackInstallationNotFound', defaultValue: 'Slack installation not found.' },
slack_bind_missing: { key: 'connectors.slackBindMissing', defaultValue: 'There is no Slack authorization to confirm.' },
slack_bind_expired: { key: 'connectors.slackBindExpired', defaultValue: 'Slack authorization expired. Start again.' },
+ slack_lead_invalid: { key: 'connectors.slackLeadInvalid', defaultValue: 'Choose an agent in the active pod.' },
slack_pod_access_denied: { key: 'connectors.slackPodAccessDenied', defaultValue: 'You no longer have access to this pod.' },
};
@@ -322,6 +323,7 @@ const V2ConnectorsPage: React.FC = () => {
// only in the page-level slot at the foot of the page.
const [rowRefusal, setRowRefusal] = useState<{ key: string; message: string } | null>(null);
const [slackCallbackError, setSlackCallbackError] = useState(null);
+ const [slackLeadChoices, setSlackLeadChoices] = useState>({});
const [hostedMcpCallbackError, setHostedMcpCallbackError] = useState(null);
const pollRef = useRef | null>(null);
const podPickerRef = useRef(null);
@@ -699,13 +701,14 @@ const V2ConnectorsPage: React.FC = () => {
}
};
- const resolveSlackBind = async (connector: Connector, action: 'confirm' | 'reject', rowKey: string) => {
+ const resolveSlackBind = async (connector: Connector, action: 'confirm' | 'reject', rowKey: string, lead?: string) => {
if (busyId) return;
setBusyId(connector._id);
setError(null);
setRowRefusal(null);
try {
- await api.post(`/api/installables/slack/${action}`, {});
+ await api.post(`/api/installables/slack/${action}`, action === 'confirm' && lead
+ ? { leadAgentUsername: lead } : {});
await load();
} catch (requestError) {
setRowRefusal({
@@ -1375,7 +1378,9 @@ const V2ConnectorsPage: React.FC = () => {
disabled={disabled}
onChange={(event) => { void writeGate(connector, pod._id, { ...gate, enabled, lead: event.target.value || undefined }); }}
>
-
+
{bots.map((bot) => )}
{!active && (
@@ -1477,6 +1482,12 @@ const V2ConnectorsPage: React.FC = () => {
const supportsRelayControls = isTelegram || isSlack;
const mirror = connector.config?.relayAllAgentMessages === true;
const pendingBind = connector.config?.pendingBind;
+ const activePod = podList.find((pod) => pod._id === connectorPodId(connector));
+ const availableLeads = botMembers(activePod);
+ const candidateSlackLead = slackLeadChoices[connector._id]
+ ?? connector.config?.gates?.[String(connectorPodId(connector))]?.lead ?? '';
+ const selectedSlackLead = availableLeads.some((bot) => bot.username === candidateSlackLead)
+ ? candidateSlackLead : '';
const userScoped = connector.scope === 'user' || Boolean(connector.config?.gates);
const busy = busyFor(item);
@@ -1517,8 +1528,24 @@ const V2ConnectorsPage: React.FC = () => {
workspace: pendingBind.teamName || 'This Slack workspace',
user: pendingBind.slackUserName ? `@${pendingBind.slackUserName}` : 'your Slack user',
})}
+
+
+ {availableLeads.length === 0 && (
+ {t('connectors.slackNoAgent', { defaultValue: 'No agent is in this pod yet. Messages will reach the pod; choose a lead later.' })}
+ )}
-