Skip to content

Commit 0614954

Browse files
authored
feat(agents): add Microsoft Teams bridge integration (#2433)
* feat(agents): add Microsoft Teams bridge integration Add AIAgentTeamsBridge proto message and wire it into the console UI as a new Integrations tab on the agent detail page. The tab exposes enable toggle, bot app ID, tenant ID, and client secret fields. When saved and enabled, the computed messaging endpoint URL is displayed for Azure Bot registration. Proto: AIAgentTeamsBridge message added to AIAgent (field 18), AIAgentCreate (field 15), and AIAgentUpdate (field 15). * fix: strip both agent-id and ai-agents from URL when deriving Teams endpoint The agent URL pattern is <id>.ai-agents.<cluster>.clusters.ign.rdpa.co. The old code only stripped the first subdomain, leaving ai-agents in place. Now we find the ai-agents segment and take everything after it as the cluster domain. * feat: read teams_bridge_endpoint from status instead of deriving it Add optional OUTPUT_ONLY teams_bridge_endpoint field to AIAgent. The bridge controller populates this when it starts serving, so console just displays it -- no URL gymnastics needed. * fix: use bare secret key for botAppSecretRef, not ${secrets.} wrapper The bridge expects a bare key (e.g. TEAMS_BOT_SECRET) that it resolves directly from the secret store. The ${secrets.} template pattern is for interpolation in deployment env vars, not for ref fields read by external services. Removes extractSecretName regex and SECRET_TEMPLATE_REGEX. Proto validation updated to accept bare UPPER_SNAKE_CASE keys. * proto: regenerate after validation pattern change bot_app_secret_ref validation changed from ${secrets.X} pattern to bare UPPER_SNAKE_CASE key. Regenerate pb.go and pb.ts. * feat: gate Teams Integrations tab behind enableTeamsBridge feature flag Off by default. Cloud-ui pipes the LaunchDarkly value through useConsoleFeatureFlags; without it the tab and its content are not rendered. Also: disable Save when enabled with incomplete fields, consolidate isEditing into editedState, fix dark-mode classes on read-only fields, document proto fields including bare-key divergence on bot_app_secret_ref.
1 parent 5c9f92f commit 0614954

11 files changed

Lines changed: 1455 additions & 883 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ requests.txt
2020
build
2121

2222
.cursor
23+
.claude/worktrees/

backend/pkg/protogen/redpanda/api/dataplane/v1alpha3/ai_agent.pb.go

Lines changed: 1014 additions & 861 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/components/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const FEATURE_FLAGS = {
1616
enableNewPipelineLogs: false,
1717
enablePipelineDiagrams: false,
1818
enableConnectSlashMenu: false,
19+
enableTeamsBridge: false,
1920
};
2021

2122
// Cloud-managed tag keys for service account integration

frontend/src/components/pages/agents/details/ai-agent-details-page.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import { getRouteApi, useNavigate } from '@tanstack/react-router';
1414
const routeApi = getRouteApi('/agents/$id/');
1515

1616
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'components/redpanda-ui/components/tabs';
17-
import { AlertCircle, FileText, Loader2, Network, Search, Settings } from 'lucide-react';
17+
import { isFeatureFlagEnabled } from 'config';
18+
import { AlertCircle, FileText, Loader2, Network, Plug, Search, Settings } from 'lucide-react';
1819
import { useEffect } from 'react';
1920
import { useGetAIAgentQuery } from 'react-query/api/ai-agent';
2021
import { uiState } from 'state/ui-state';
@@ -23,6 +24,7 @@ import { AIAgentCardTab } from './ai-agent-card-tab';
2324
import { AIAgentConfigurationTab } from './ai-agent-configuration-tab';
2425
import { AIAgentDetailsHeader } from './ai-agent-details-header';
2526
import { AIAgentInspectorTab } from './ai-agent-inspector-tab';
27+
import { AIAgentIntegrationsTab } from './ai-agent-integrations-tab';
2628
import { AIAgentTranscriptsTab } from './ai-agent-transcripts-tab';
2729

2830
export const updatePageTitle = (agentName?: string) => {
@@ -79,6 +81,8 @@ export const AIAgentDetailsPage = () => {
7981
return null;
8082
}
8183

84+
const showTeamsBridge = isFeatureFlagEnabled('enableTeamsBridge');
85+
8286
return (
8387
<div className="flex flex-col gap-4 pb-1">
8488
<AIAgentDetailsHeader />
@@ -91,6 +95,14 @@ export const AIAgentDetailsPage = () => {
9195
Configuration
9296
</div>
9397
</TabsTrigger>
98+
{showTeamsBridge && (
99+
<TabsTrigger className="gap-2" value="integrations">
100+
<div className="flex items-center gap-2">
101+
<Plug className="h-4 w-4" />
102+
Integrations
103+
</div>
104+
</TabsTrigger>
105+
)}
94106
<TabsTrigger className="gap-2" value="agent-card">
95107
<div className="flex items-center gap-2">
96108
<Network className="h-4 w-4" />
@@ -112,6 +124,11 @@ export const AIAgentDetailsPage = () => {
112124
<TabsContent value="configuration">
113125
<AIAgentConfigurationTab />
114126
</TabsContent>
127+
{showTeamsBridge && (
128+
<TabsContent value="integrations">
129+
<AIAgentIntegrationsTab />
130+
</TabsContent>
131+
)}
115132
<TabsContent value="agent-card">
116133
<AIAgentCardTab />
117134
</TabsContent>
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/**
2+
* Copyright 2026 Redpanda Data, Inc.
3+
*
4+
* Use of this software is governed by the Business Source License
5+
* included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
6+
*
7+
* As of the Change Date specified in that file, in accordance with
8+
* the Business Source License, use of this software will be governed
9+
* by the Apache License, Version 2.0
10+
*/
11+
12+
import { create } from '@bufbuild/protobuf';
13+
import { FieldMaskSchema } from '@bufbuild/protobuf/wkt';
14+
import { getRouteApi } from '@tanstack/react-router';
15+
import { Button } from 'components/redpanda-ui/components/button';
16+
import { Card, CardContent, CardHeader, CardTitle } from 'components/redpanda-ui/components/card';
17+
import { DynamicCodeBlock } from 'components/redpanda-ui/components/code-block-dynamic';
18+
import { Input } from 'components/redpanda-ui/components/input';
19+
import { Label } from 'components/redpanda-ui/components/label';
20+
import { Switch } from 'components/redpanda-ui/components/switch';
21+
import { Text } from 'components/redpanda-ui/components/typography';
22+
import { SecretSelector } from 'components/ui/secret/secret-selector';
23+
import { Edit, Save } from 'lucide-react';
24+
import { Scope } from 'protogen/redpanda/api/dataplane/v1/secret_pb';
25+
import {
26+
AIAgentTeamsBridgeSchema,
27+
AIAgentUpdateSchema,
28+
UpdateAIAgentRequestSchema,
29+
} from 'protogen/redpanda/api/dataplane/v1alpha3/ai_agent_pb';
30+
import { useMemo, useState } from 'react';
31+
import { useGetAIAgentQuery, useUpdateAIAgentMutation } from 'react-query/api/ai-agent';
32+
import { useListSecretsQuery } from 'react-query/api/secret';
33+
import { toast } from 'sonner';
34+
import { formatToastErrorMessageGRPC } from 'utils/toast.utils';
35+
36+
const routeApi = getRouteApi('/agents/$id/');
37+
38+
const TEAMS_SECRET_TEXT = {
39+
dialogDescription: 'Create a new secret for your Microsoft Teams bot client secret.',
40+
secretNamePlaceholder: 'e.g., TEAMS_CLIENT_SECRET',
41+
secretValuePlaceholder: 'Enter your client secret...',
42+
secretValueDescription: 'Your Microsoft Teams bot application client secret',
43+
emptyStateDescription: 'Create a secret to securely store your Teams bot client secret',
44+
};
45+
46+
type TeamsBridgeState = {
47+
enabled: boolean;
48+
botAppId: string;
49+
botTenantId: string;
50+
botAppSecretName: string;
51+
};
52+
53+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Integrations tab with edit/view mode conditionals
54+
export const AIAgentIntegrationsTab = () => {
55+
const { id } = routeApi.useParams();
56+
const { data: aiAgentData } = useGetAIAgentQuery({ id: id || '' }, { enabled: !!id });
57+
const { mutateAsync: updateAIAgent, isPending: isUpdatePending } = useUpdateAIAgentMutation();
58+
const { data: secretsData } = useListSecretsQuery();
59+
60+
const [editedState, setEditedState] = useState<TeamsBridgeState | null>(null);
61+
const isEditing = editedState !== null;
62+
63+
const agent = aiAgentData?.aiAgent;
64+
65+
const availableSecrets = useMemo(() => {
66+
if (!secretsData?.secrets) {
67+
return [];
68+
}
69+
return secretsData.secrets
70+
.filter((secret): secret is NonNullable<typeof secret> & { id: string } => !!secret?.id)
71+
.map((secret) => ({ id: secret.id, name: secret.id }));
72+
}, [secretsData]);
73+
74+
if (!agent) {
75+
return null;
76+
}
77+
78+
const displayState: TeamsBridgeState = editedState || {
79+
enabled: agent.teamsBridge?.enabled ?? false,
80+
botAppId: agent.teamsBridge?.botAppId ?? '',
81+
botTenantId: agent.teamsBridge?.botTenantId ?? '',
82+
botAppSecretName: agent.teamsBridge?.botAppSecretRef ?? '',
83+
};
84+
85+
const updateField = (updates: Partial<TeamsBridgeState>) => {
86+
setEditedState({ ...displayState, ...updates });
87+
};
88+
89+
const isSaveDisabled =
90+
isUpdatePending ||
91+
(displayState.enabled && !(displayState.botAppId && displayState.botTenantId && displayState.botAppSecretName));
92+
93+
const handleSave = async () => {
94+
if (!id) {
95+
return;
96+
}
97+
98+
const secretRef = displayState.botAppSecretName || '';
99+
100+
try {
101+
await updateAIAgent(
102+
create(UpdateAIAgentRequestSchema, {
103+
id,
104+
aiAgent: create(AIAgentUpdateSchema, {
105+
teamsBridge: create(AIAgentTeamsBridgeSchema, {
106+
enabled: displayState.enabled,
107+
botAppId: displayState.botAppId,
108+
botTenantId: displayState.botTenantId,
109+
botAppSecretRef: secretRef || undefined,
110+
}),
111+
}),
112+
updateMask: create(FieldMaskSchema, {
113+
paths: [
114+
'teams_bridge.enabled',
115+
'teams_bridge.bot_app_id',
116+
'teams_bridge.bot_tenant_id',
117+
'teams_bridge.bot_app_secret_ref',
118+
],
119+
}),
120+
}),
121+
{
122+
onSuccess: () => {
123+
toast.success('Teams integration updated');
124+
setEditedState(null);
125+
},
126+
onError: (error) => {
127+
toast.error(formatToastErrorMessageGRPC({ error, action: 'update', entity: 'Teams integration' }));
128+
},
129+
}
130+
);
131+
} catch {
132+
// Error already handled
133+
}
134+
};
135+
136+
const handleCancel = () => {
137+
setEditedState(null);
138+
};
139+
140+
return (
141+
<div className="space-y-4">
142+
<Card className="px-0 py-0" size="full">
143+
<CardHeader className="flex flex-row items-center justify-between border-b p-4 dark:border-border [.border-b]:pb-4">
144+
<div className="space-y-1">
145+
<CardTitle>
146+
<Text className="font-semibold">Microsoft Teams</Text>
147+
</CardTitle>
148+
<Text className="text-muted-foreground text-sm">
149+
Connect this agent to Microsoft Teams to enable conversational interactions through a Teams bot.
150+
</Text>
151+
</div>
152+
<div className="flex gap-2">
153+
{isEditing ? (
154+
<>
155+
<Button disabled={isSaveDisabled} onClick={handleSave} variant="primary">
156+
<Save className="h-4 w-4" />
157+
{isUpdatePending ? 'Saving...' : 'Save Changes'}
158+
</Button>
159+
<Button onClick={handleCancel} variant="outline">
160+
Cancel
161+
</Button>
162+
</>
163+
) : (
164+
<Button onClick={() => setEditedState(displayState)} variant="primary">
165+
<Edit className="h-4 w-4" />
166+
Edit Configuration
167+
</Button>
168+
)}
169+
</div>
170+
</CardHeader>
171+
<CardContent className="space-y-6 px-4 pb-4">
172+
{/* Enable toggle */}
173+
<div className="flex items-center justify-between">
174+
<div className="space-y-0.5">
175+
<Label htmlFor="teams-enabled">Enable Teams Integration</Label>
176+
<Text className="text-muted-foreground text-sm">Activate the Microsoft Teams bridge for this agent</Text>
177+
</div>
178+
{isEditing ? (
179+
<Switch
180+
checked={displayState.enabled}
181+
id="teams-enabled"
182+
onCheckedChange={(checked) => updateField({ enabled: checked })}
183+
/>
184+
) : (
185+
<Text className="font-medium text-sm">{displayState.enabled ? 'Enabled' : 'Disabled'}</Text>
186+
)}
187+
</div>
188+
189+
{/* Bot configuration fields */}
190+
<div className="grid gap-4 md:grid-cols-2">
191+
<div className="space-y-2">
192+
<Label htmlFor="teams-bot-app-id">Application (client) ID</Label>
193+
{isEditing ? (
194+
<Input
195+
id="teams-bot-app-id"
196+
onChange={(e) => updateField({ botAppId: e.target.value })}
197+
placeholder="e.g., 12345678-abcd-efgh-ijkl-123456789012"
198+
value={displayState.botAppId}
199+
/>
200+
) : (
201+
<div className="flex h-10 items-center rounded-md border border-border bg-muted px-3 py-2">
202+
<Text className="truncate">{displayState.botAppId || '-'}</Text>
203+
</div>
204+
)}
205+
</div>
206+
207+
<div className="space-y-2">
208+
<Label htmlFor="teams-bot-tenant-id">Tenant ID</Label>
209+
{isEditing ? (
210+
<Input
211+
id="teams-bot-tenant-id"
212+
onChange={(e) => updateField({ botTenantId: e.target.value })}
213+
placeholder="e.g., 12345678-abcd-efgh-ijkl-123456789012"
214+
value={displayState.botTenantId}
215+
/>
216+
) : (
217+
<div className="flex h-10 items-center rounded-md border border-border bg-muted px-3 py-2">
218+
<Text className="truncate">{displayState.botTenantId || '-'}</Text>
219+
</div>
220+
)}
221+
</div>
222+
</div>
223+
224+
{/* Client Secret */}
225+
<div className="space-y-2">
226+
<Label>Client Secret</Label>
227+
{isEditing ? (
228+
<div className="[&>div]:flex-col [&>div]:items-stretch [&>div]:gap-2">
229+
<SecretSelector
230+
availableSecrets={availableSecrets}
231+
customText={TEAMS_SECRET_TEXT}
232+
onChange={(value) => updateField({ botAppSecretName: value })}
233+
placeholder="Select from secrets store or create new"
234+
scopes={[Scope.AI_AGENT]}
235+
value={displayState.botAppSecretName}
236+
/>
237+
</div>
238+
) : (
239+
<div className="flex h-10 items-center rounded-md border border-border bg-muted px-3 py-2">
240+
<Text className="truncate">{displayState.botAppSecretName || '-'}</Text>
241+
</div>
242+
)}
243+
</div>
244+
245+
{/* Messaging endpoint URL - populated by the bridge controller */}
246+
{Boolean(agent.teamsBridgeEndpoint) && (
247+
<div className="space-y-2">
248+
<Label>Messaging Endpoint</Label>
249+
<Text className="text-muted-foreground text-sm">
250+
Configure this URL as the messaging endpoint in your Azure Bot registration.
251+
</Text>
252+
<DynamicCodeBlock code={agent.teamsBridgeEndpoint ?? ''} lang="text" />
253+
</div>
254+
)}
255+
</CardContent>
256+
</Card>
257+
</div>
258+
);
259+
};

0 commit comments

Comments
 (0)