diff --git a/apps/web/package.json b/apps/web/package.json index 15a3dd7aad..7872bc2ce6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,12 +35,12 @@ "@anthropic-ai/sdk": "^0.90.0", "@aws-sdk/client-s3": "^3.1009.0", "@aws-sdk/s3-request-presigner": "^3.1009.0", - "@chat-adapter/slack": "^4.20.1", - "@chat-adapter/state-memory": "^4.20.1", - "@chat-adapter/state-redis": "^4.20.1", + "@chat-adapter/slack": "^4.26.0", + "@chat-adapter/state-memory": "^4.26.0", + "@chat-adapter/state-redis": "^4.26.0", + "@chat-adapter/teams": "^4.26.0", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", - "google-auth-library": "^10.4.1", "@kilocode/db": "workspace:*", "@kilocode/encryption": "workspace:*", "@kilocode/event-service": "workspace:*", @@ -99,7 +99,7 @@ "@xterm/xterm": "^6.0.0", "ai": "^6.0.116", "archiver": "^7.0.1", - "chat": "^4.20.1", + "chat": "^4.26.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -116,6 +116,7 @@ "eventsource-parser": "^3.0.6", "fflate": "^0.8.2", "form-data": "^4.0.5", + "google-auth-library": "^10.4.1", "jotai": "^2.18.1", "jotai-minidb": "^0.0.8", "js-cookie": "^3.0.5", diff --git a/apps/web/src/app/api/chat/install-integration/route.test.ts b/apps/web/src/app/api/chat/install-integration/route.test.ts new file mode 100644 index 0000000000..cbab890f72 --- /dev/null +++ b/apps/web/src/app/api/chat/install-integration/route.test.ts @@ -0,0 +1,168 @@ +process.env.NEXTAUTH_SECRET ||= 'test-nextauth-secret'; +process.env.TURNSTILE_SECRET_KEY ||= 'test-turnstile-secret'; + +const mockLimit = jest.fn(); +const mockGetUserFromAuth = jest.fn(); +const mockGetUserOrganizationsWithSeats = jest.fn(); +const mockIsOrganizationMember = jest.fn(); +const mockLinkKiloUser = jest.fn(); +const mockUpsertTeamsInstallation = jest.fn(); + +jest.mock('@/lib/drizzle', () => ({ + db: { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ + limit: mockLimit, + })), + })), + })), + }, +})); + +jest.mock('@/lib/user.server', () => ({ + getUserFromAuth: (...args: unknown[]) => mockGetUserFromAuth(...args), +})); + +jest.mock('@/lib/organizations/organizations', () => ({ + getUserOrganizationsWithSeats: (...args: unknown[]) => mockGetUserOrganizationsWithSeats(...args), + isOrganizationMember: (...args: unknown[]) => mockIsOrganizationMember(...args), +})); + +jest.mock('@/lib/bot-identity', () => ({ + ...jest.requireActual('@/lib/bot-identity'), + linkKiloUser: (...args: unknown[]) => mockLinkKiloUser(...args), +})); + +jest.mock('@/lib/integrations/teams-service', () => ({ + upsertTeamsInstallation: (...args: unknown[]) => mockUpsertTeamsInstallation(...args), +})); + +jest.mock('@/lib/integrations/slack-service', () => ({ + upsertSlackInstallation: jest.fn(), +})); + +jest.mock('@/lib/bot', () => ({ + bot: { + initialize: jest.fn(async () => {}), + getState: jest.fn(() => 'state'), + getAdapter: jest.fn(() => ({ getInstallation: jest.fn() })), + }, +})); + +import { createLinkToken } from '@/lib/bot-identity'; +import { GET, POST } from './route'; + +function buildUser() { + return { id: 'user-1' }; +} + +function buildToken() { + return createLinkToken({ + platform: 'teams', + teamId: 'tenant-1', + teamName: 'Acme', + userId: '29:user', + }); +} + +describe('chat install integration route', () => { + beforeEach(() => { + mockLimit.mockReset(); + mockGetUserFromAuth.mockReset(); + mockGetUserOrganizationsWithSeats.mockReset(); + mockIsOrganizationMember.mockReset(); + mockLinkKiloUser.mockReset(); + mockUpsertTeamsInstallation.mockReset(); + + mockGetUserFromAuth.mockResolvedValue({ user: buildUser(), authFailedResponse: null }); + mockGetUserOrganizationsWithSeats.mockResolvedValue([]); + mockLinkKiloUser.mockResolvedValue(undefined); + mockUpsertTeamsInstallation.mockResolvedValue({ id: 'pi_teams' }); + }); + + it('shows a connection form when no platform integration exists', async () => { + mockLimit.mockResolvedValue([]); + mockGetUserOrganizationsWithSeats.mockResolvedValue([ + { organizationId: 'org-1', organizationName: 'Example Org', role: 'owner' }, + ]); + + const response = await GET( + new Request(`http://localhost:3000/api/chat/install-integration?token=${buildToken()}`) + ); + + await expect(response.text()).resolves.toContain('Example Org'); + expect(response.status).toBe(200); + expect(mockLinkKiloUser).not.toHaveBeenCalled(); + }); + + it('creates a Teams integration for the selected organization and links the user', async () => { + const token = buildToken(); + mockLimit.mockResolvedValue([]); + mockGetUserOrganizationsWithSeats.mockResolvedValue([ + { organizationId: 'org-1', organizationName: 'Example Org', role: 'owner' }, + ]); + + const response = await POST( + new Request('http://localhost:3000/api/chat/install-integration', { + method: 'POST', + body: new URLSearchParams({ token, owner: 'org:org-1' }), + }) + ); + + await expect(response.text()).resolves.toContain('Workspace connected'); + expect(mockUpsertTeamsInstallation).toHaveBeenCalledWith({ + owner: { type: 'org', id: 'org-1' }, + tenantId: 'tenant-1', + tenantName: 'Acme', + }); + expect(mockLinkKiloUser).toHaveBeenCalledWith( + 'state', + { + platform: 'teams', + teamId: 'tenant-1', + teamName: 'Acme', + userId: '29:user', + }, + 'user-1' + ); + }); + + it('links an existing integration when the user can access it', async () => { + mockLimit.mockResolvedValue([ + { + id: 'pi_teams', + integration_status: 'active', + owned_by_organization_id: 'org-1', + owned_by_user_id: null, + }, + ]); + mockIsOrganizationMember.mockResolvedValue(true); + + const response = await GET( + new Request(`http://localhost:3000/api/chat/install-integration?token=${buildToken()}`) + ); + + await expect(response.text()).resolves.toContain('Workspace connected'); + expect(mockUpsertTeamsInstallation).not.toHaveBeenCalled(); + expect(mockLinkKiloUser).toHaveBeenCalled(); + }); + + it('rejects organization installs from non-owner roles', async () => { + mockLimit.mockResolvedValue([]); + mockGetUserOrganizationsWithSeats.mockResolvedValue([ + { organizationId: 'org-1', organizationName: 'Example Org', role: 'member' }, + ]); + + const response = await POST( + new Request('http://localhost:3000/api/chat/install-integration', { + method: 'POST', + body: new URLSearchParams({ token: buildToken(), owner: 'org:org-1' }), + }) + ); + + expect(response.status).toBe(403); + expect(mockUpsertTeamsInstallation).not.toHaveBeenCalled(); + expect(mockLinkKiloUser).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/chat/install-integration/route.ts b/apps/web/src/app/api/chat/install-integration/route.ts new file mode 100644 index 0000000000..85d9417a85 --- /dev/null +++ b/apps/web/src/app/api/chat/install-integration/route.ts @@ -0,0 +1,285 @@ +import { bot } from '@/lib/bot'; +import { APP_URL } from '@/lib/constants'; +import { linkKiloUser, type PlatformIdentity, verifyLinkToken } from '@/lib/bot-identity'; +import { db } from '@/lib/drizzle'; +import { + getUserOrganizationsWithSeats, + isOrganizationMember, +} from '@/lib/organizations/organizations'; +import { getUserFromAuth } from '@/lib/user.server'; +import { INTEGRATION_STATUS, PLATFORM } from '@/lib/integrations/core/constants'; +import type { Owner } from '@/lib/integrations/core/types'; +import { upsertSlackInstallation } from '@/lib/integrations/slack-service'; +import { upsertTeamsInstallation } from '@/lib/integrations/teams-service'; +import { platform_integrations } from '@kilocode/db'; +import type { User } from '@kilocode/db/schema'; +import { and, eq } from 'drizzle-orm'; + +const INSTALLABLE_PLATFORMS = new Set([PLATFORM.SLACK, PLATFORM.TEAMS]); +const ORG_OWNER_ROLES = new Set(['owner', 'billing_manager']); + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function page(title: string, body: string, status = 200): Response { + return new Response( + ` +${escapeHtml(title)} + +
+${body} +
+`, + { status, headers: { 'content-type': 'text/html; charset=utf-8' } } + ); +} + +function errorPage(title: string, message: string, status: number): Response { + return page( + title, + `

${escapeHtml(title)}

+

${escapeHtml(message)}

`, + status + ); +} + +function successPage(platform: string): Response { + return page( + 'Workspace Connected', + `

Workspace connected

+

Your ${escapeHtml(platformLabel(platform))} workspace and chat account are now linked to Kilo. You can close this tab and @mention Kilo again.

` + ); +} + +function platformLabel(platform: string): string { + switch (platform) { + case PLATFORM.SLACK: + return 'Slack'; + case PLATFORM.TEAMS: + return 'Microsoft Teams'; + default: + return platform; + } +} + +async function authenticateOrRedirect(requestUrl: URL) { + const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + if (!authFailedResponse) return { user, response: null }; + + const signInUrl = new URL('/users/sign_in', APP_URL); + signInUrl.searchParams.set('callbackPath', requestUrl.pathname + requestUrl.search); + return { user: null, response: Response.redirect(signInUrl.toString()) }; +} + +function verifyInstallToken(token: string | null): PlatformIdentity | null { + if (!token) return null; + const identity = verifyLinkToken(token); + if (!identity || !INSTALLABLE_PLATFORMS.has(identity.platform)) return null; + return identity; +} + +async function findPlatformIntegration(identity: PlatformIdentity) { + const [integration] = await db + .select() + .from(platform_integrations) + .where( + and( + eq(platform_integrations.platform, identity.platform), + eq(platform_integrations.platform_installation_id, identity.teamId) + ) + ) + .limit(1); + + return integration ?? null; +} + +async function verifyIntegrationAccess( + integration: NonNullable>>, + kiloUserId: string +): Promise<{ ok: true } | { ok: false; error: string }> { + if (integration.integration_status !== INTEGRATION_STATUS.ACTIVE) { + return { ok: false, error: 'This workspace integration is not active.' }; + } + + if (integration.owned_by_organization_id) { + const isMember = await isOrganizationMember(integration.owned_by_organization_id, kiloUserId); + if (!isMember) { + return { + ok: false, + error: 'You are not a member of the organization that owns this workspace integration.', + }; + } + return { ok: true }; + } + + if (integration.owned_by_user_id === kiloUserId) { + return { ok: true }; + } + + return { ok: false, error: 'You are not the owner of this workspace integration.' }; +} + +async function parseOwner( + ownerValue: FormDataEntryValue | null, + user: User +): Promise { + if (ownerValue === `user:${user.id}`) { + return { type: 'user', id: user.id }; + } + + if (typeof ownerValue !== 'string' || !ownerValue.startsWith('org:')) { + return null; + } + + const organizationId = ownerValue.slice('org:'.length); + const organizations = await getUserOrganizationsWithSeats(user.id); + const organization = organizations.find(org => org.organizationId === organizationId); + if (!organization || !ORG_OWNER_ROLES.has(organization.role)) { + return null; + } + + return { type: 'org', id: organizationId }; +} + +async function createIntegration(identity: PlatformIdentity, owner: Owner): Promise { + if (identity.platform === PLATFORM.TEAMS) { + await upsertTeamsInstallation({ + owner, + tenantId: identity.teamId, + tenantName: identity.teamName, + }); + return; + } + + if (identity.platform === PLATFORM.SLACK) { + await bot.initialize(); + const installation = await bot.getAdapter('slack').getInstallation(identity.teamId); + if (!installation) { + throw new Error('Slack authorization is missing. Please reinstall Slack from Kilo.'); + } + + await upsertSlackInstallation({ owner, teamId: identity.teamId, installation }); + return; + } + + throw new Error(`Unsupported platform: ${identity.platform}`); +} + +async function linkIdentity(identity: PlatformIdentity, kiloUserId: string): Promise { + await bot.initialize(); + await linkKiloUser(bot.getState(), identity, kiloUserId); +} + +function installForm({ + identity, + token, + organizations, + user, +}: { + identity: PlatformIdentity; + token: string; + organizations: Awaited>; + user: User; +}): Response { + const platform = platformLabel(identity.platform); + const orgOptions = organizations + .filter(org => ORG_OWNER_ROLES.has(org.role)) + .map( + org => + `` + ) + .join(''); + + return page( + `Connect ${platform}`, + `

Connect ${escapeHtml(platform)} to Kilo

+

Choose the Kilo account or organization that should own this ${escapeHtml(platform)} workspace integration.

+
+ + + ${orgOptions} + +
` + ); +} + +export async function GET(request: Request) { + const url = new URL(request.url); + const token = url.searchParams.get('token'); + const identity = verifyInstallToken(token); + if (!identity || !token) { + return errorPage( + 'Invalid Link', + 'Invalid or expired workspace connection link. Please go back to your chat and try again.', + 400 + ); + } + + const auth = await authenticateOrRedirect(url); + if (auth.response) return auth.response; + const user = auth.user; + + const existing = await findPlatformIntegration(identity); + if (existing) { + const access = await verifyIntegrationAccess(existing, user.id); + if (!access.ok) return errorPage('Access Denied', access.error, 403); + + await linkIdentity(identity, user.id); + return successPage(identity.platform); + } + + const organizations = await getUserOrganizationsWithSeats(user.id); + return installForm({ identity, token, organizations, user }); +} + +export async function POST(request: Request) { + const url = new URL(request.url); + const formData = await request.formData(); + const tokenEntry = formData.get('token'); + const token = typeof tokenEntry === 'string' ? tokenEntry : null; + const identity = verifyInstallToken(token); + if (!identity) { + return errorPage( + 'Invalid Link', + 'Invalid or expired workspace connection link. Please go back to your chat and try again.', + 400 + ); + } + + const auth = await authenticateOrRedirect(url); + if (auth.response) return auth.response; + const user = auth.user; + + const existing = await findPlatformIntegration(identity); + if (existing) { + const access = await verifyIntegrationAccess(existing, user.id); + if (!access.ok) return errorPage('Access Denied', access.error, 403); + + await linkIdentity(identity, user.id); + return successPage(identity.platform); + } + + const owner = await parseOwner(formData.get('owner'), user); + if (!owner) { + return errorPage( + 'Access Denied', + 'You cannot connect this workspace to the selected Kilo owner.', + 403 + ); + } + + try { + await createIntegration(identity, owner); + await linkIdentity(identity, user.id); + return successPage(identity.platform); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to connect this workspace.'; + return errorPage('Connection Failed', message, 500); + } +} diff --git a/apps/web/src/app/api/chat/link-account/route.ts b/apps/web/src/app/api/chat/link-account/route.ts index 7888f263c6..1ba6b99825 100644 --- a/apps/web/src/app/api/chat/link-account/route.ts +++ b/apps/web/src/app/api/chat/link-account/route.ts @@ -1,12 +1,9 @@ import { bot } from '@/lib/bot'; import { APP_URL } from '@/lib/constants'; -import { linkKiloUser, verifyLinkToken } from '@/lib/bot-identity'; -import { db } from '@/lib/drizzle'; +import { linkKiloUser, verifyLinkToken, type PlatformIdentity } from '@/lib/bot-identity'; import { isOrganizationMember } from '@/lib/organizations/organizations'; import { getUserFromAuth } from '@/lib/user.server'; -import { platform_integrations } from '@kilocode/db'; -import { PLATFORM } from '@/lib/integrations/core/constants'; -import { and, eq } from 'drizzle-orm'; +import { getPlatformIntegration } from '@/lib/bot/platform-helpers'; function errorPage(title: string, message: string, status: number): Response { return new Response( @@ -28,22 +25,13 @@ function errorPage(title: string, message: string, status: number): Response { * an org member; for user-owned integrations only the owner may link. */ async function verifyIntegrationAccess( - teamId: string, + identity: PlatformIdentity, kiloUserId: string ): Promise<{ ok: true } | { ok: false; error: string }> { - const [integration] = await db - .select() - .from(platform_integrations) - .where( - and( - eq(platform_integrations.platform, PLATFORM.SLACK), - eq(platform_integrations.platform_installation_id, teamId) - ) - ) - .limit(1); + const integration = await getPlatformIntegration(identity); if (!integration) { - return { ok: false, error: 'No matching integration found for this workspace.' }; + return { ok: false, error: 'No matching integration found for this platform.' }; } if (integration.owned_by_organization_id) { @@ -51,12 +39,12 @@ async function verifyIntegrationAccess( if (!isMember) { return { ok: false, - error: 'You are not a member of the organization that owns this workspace integration.', + error: 'You are not a member of the organization that owns this integration.', }; } } else if (integration.owned_by_user_id) { if (integration.owned_by_user_id !== kiloUserId) { - return { ok: false, error: 'You are not the owner of this workspace integration.' }; + return { ok: false, error: 'You are not the owner of this integration.' }; } } else { return { ok: false, error: 'This integration has invalid ownership data.' }; @@ -106,7 +94,7 @@ export async function GET(request: Request) { } // Verify the user is allowed to link to this integration - const access = await verifyIntegrationAccess(identity.teamId, user.id); + const access = await verifyIntegrationAccess(identity, user.id); if (!access.ok) { return errorPage('Access Denied', access.error, 403); } diff --git a/apps/web/src/app/api/internal/bot-session-callback/[botRequestId]/route.test.ts b/apps/web/src/app/api/internal/bot-session-callback/[botRequestId]/route.test.ts new file mode 100644 index 0000000000..5d5d7b697d --- /dev/null +++ b/apps/web/src/app/api/internal/bot-session-callback/[botRequestId]/route.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { createHmac } from 'crypto'; +import type { NextRequest } from 'next/server'; +import type { POST as POSTType } from './route'; + +type MockAnyAsync = (...args: unknown[]) => Promise; +type MockAnySync = (...args: unknown[]) => unknown; +type MockGetAdapter = (platform: string) => unknown; + +const mockDbLimit = jest.fn(); +const mockDbReturning = jest.fn(); +const mockTeamsStartTyping = jest.fn(); +const mockTeamsPostMessage = jest.fn(); +const mockTeamsChannelIdFromThreadId = jest.fn(); +const mockTeamsIsDM = jest.fn(); +const mockSlackGetInstallation = jest.fn(); +const mockBotInitialize = jest.fn(); +const mockBotGetAdapter = jest.fn(); +const mockBotRegisterSingleton = jest.fn(); +const mockGetBotRequestCloudAgentSession = jest.fn(); +const mockGetBotRequestCloudAgentSessionGroupReadiness = jest.fn(); +const mockClaimBotRequestCloudAgentSessionGroupContinuation = jest.fn(); +const mockMarkBotRequestCloudAgentSessionTerminalStrict = jest.fn(); +const mockRecordBotRequestCloudAgentSessionResultStrict = jest.fn(); +const mockRecordBotRequestCloudAgentSessionResultErrorStrict = jest.fn(); + +let afterPromises: Promise[] = []; + +jest.mock('next/server', () => ({ + ...(jest.requireActual('next/server') as Record), + after: (fn: () => Promise) => { + afterPromises.push(fn()); + }, +})); + +jest.mock('@/lib/config.server', () => ({ + INTERNAL_API_SECRET: 'test-internal-secret', +})); + +jest.mock('@/lib/bot/constants', () => ({ + MAX_ITERATIONS: 5, +})); + +jest.mock('@/lib/drizzle', () => ({ + db: { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ + limit: mockDbLimit, + })), + })), + })), + update: jest.fn(() => ({ + set: jest.fn(() => ({ + where: jest.fn(() => ({ + returning: mockDbReturning, + })), + })), + })), + }, +})); + +jest.mock('@/lib/bot', () => ({ + bot: { + initialize: mockBotInitialize, + getAdapter: mockBotGetAdapter, + registerSingleton: mockBotRegisterSingleton, + }, +})); + +jest.mock('@/lib/bot/cloud-agent-session-groups', () => ({ + claimBotRequestCloudAgentSessionGroupContinuation: + mockClaimBotRequestCloudAgentSessionGroupContinuation, + getBotRequestCloudAgentSession: mockGetBotRequestCloudAgentSession, + getBotRequestCloudAgentSessionGroupReadiness: mockGetBotRequestCloudAgentSessionGroupReadiness, +})); + +jest.mock('@/lib/bot/request-logging', () => ({ + markBotRequestCloudAgentSessionTerminalStrict: mockMarkBotRequestCloudAgentSessionTerminalStrict, + recordBotRequestCloudAgentSessionResultErrorStrict: + mockRecordBotRequestCloudAgentSessionResultErrorStrict, + recordBotRequestCloudAgentSessionResultStrict: mockRecordBotRequestCloudAgentSessionResultStrict, +})); + +jest.mock('@/lib/bot/agent-runner', () => ({ + createSyntheticThread: jest.fn(), + runBotAgent: jest.fn(), +})); + +jest.mock('@/lib/user', () => ({ + findUserById: jest.fn(), +})); + +jest.mock('@sentry/nextjs', () => ({ + captureException: jest.fn(), +})); + +const BOT_REQUEST_ID = 'd6423759-6630-427a-9631-7faf541d5a1e'; +const CLOUD_AGENT_SESSION_ID = 'agent_bc8c260d-9365-4a30-98a7-33371c018b04'; +const TEAMS_THREAD_ID = 'teams:conversation:service'; + +function makeRequest(body: Record, currentStep = 5): NextRequest { + const token = createHmac('sha256', 'test-internal-secret') + .update(`bot-callback:${BOT_REQUEST_ID}`) + .digest('hex'); + + return { + headers: { + get: (name: string) => (name === 'X-Bot-Callback-Token' ? token : null), + }, + json: () => Promise.resolve(body), + nextUrl: new URL( + `https://test.kilo.ai/api/internal/bot-session-callback/${BOT_REQUEST_ID}?currentStep=${currentStep}` + ), + } as unknown as NextRequest; +} + +function makeParams(): { params: Promise<{ botRequestId: string }> } { + return { params: Promise.resolve({ botRequestId: BOT_REQUEST_ID }) }; +} + +async function flushAfterCallbacks() { + await Promise.all(afterPromises); + afterPromises = []; +} + +describe('POST /api/internal/bot-session-callback/[botRequestId]', () => { + let POST: typeof POSTType; + + beforeEach(async () => { + jest.clearAllMocks(); + afterPromises = []; + + mockBotInitialize.mockResolvedValue(undefined); + mockBotGetAdapter.mockImplementation((platform: string) => { + if (platform === 'teams') { + return { + name: 'teams', + startTyping: mockTeamsStartTyping, + postMessage: mockTeamsPostMessage, + channelIdFromThreadId: mockTeamsChannelIdFromThreadId, + isDM: mockTeamsIsDM, + }; + } + + return { + name: 'slack', + getInstallation: mockSlackGetInstallation, + }; + }); + mockTeamsStartTyping.mockResolvedValue(undefined); + mockTeamsPostMessage.mockResolvedValue({ + id: 'teams-message-1', + threadId: TEAMS_THREAD_ID, + raw: {}, + }); + mockTeamsChannelIdFromThreadId.mockReturnValue('teams:conversation'); + mockTeamsIsDM.mockReturnValue(false); + mockSlackGetInstallation.mockRejectedValue(new Error('Slack installation should not be read')); + + mockDbLimit.mockResolvedValue([ + { + id: BOT_REQUEST_ID, + created_by: 'user-1', + organization_id: null, + platform_integration_id: 'teams-integration-1', + platform: 'teams', + platform_thread_id: TEAMS_THREAD_ID, + platform_message_id: 'teams-message-original', + user_message: 'Create T3 App', + status: 'pending', + error_message: null, + model_used: null, + steps: [], + cloud_agent_session_id: CLOUD_AGENT_SESSION_ID, + response_time_ms: null, + created_at: '2026-04-29T14:00:00.000Z', + updated_at: '2026-04-29T14:00:00.000Z', + }, + ]); + mockDbReturning.mockResolvedValue([{ id: BOT_REQUEST_ID }]); + + mockGetBotRequestCloudAgentSession.mockResolvedValue({ + bot_request_id: BOT_REQUEST_ID, + cloud_agent_session_id: CLOUD_AGENT_SESSION_ID, + status: 'running', + final_message: null, + final_message_error: null, + github_repo: null, + gitlab_project: null, + mode: 'code', + }); + mockMarkBotRequestCloudAgentSessionTerminalStrict.mockResolvedValue(true); + mockRecordBotRequestCloudAgentSessionResultStrict.mockResolvedValue(true); + mockRecordBotRequestCloudAgentSessionResultErrorStrict.mockResolvedValue(true); + mockGetBotRequestCloudAgentSessionGroupReadiness.mockResolvedValue({ + status: 'ready', + sessions: [ + { + bot_request_id: BOT_REQUEST_ID, + cloud_agent_session_id: CLOUD_AGENT_SESSION_ID, + status: 'completed', + final_message: '# Create T3 App', + final_message_error: null, + github_repo: null, + gitlab_project: null, + mode: 'code', + }, + ], + }); + mockClaimBotRequestCloudAgentSessionGroupContinuation.mockResolvedValue(true); + + ({ POST } = await import('./route')); + }); + + it('uses the Teams adapter for tracked Cloud Agent callbacks', async () => { + const response = await POST( + makeRequest({ + status: 'completed', + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + executionId: 'execution-1', + kiloSessionId: 'ses_22660159dffeXLZ427LdfmBTZy', + lastAssistantMessageText: '# Create T3 App', + }), + makeParams() + ); + + expect(response.status).toBe(200); + await flushAfterCallbacks(); + + expect(mockBotGetAdapter).toHaveBeenCalledWith('teams'); + expect(mockBotGetAdapter).not.toHaveBeenCalledWith('slack'); + expect(mockSlackGetInstallation).not.toHaveBeenCalled(); + expect(mockTeamsStartTyping).toHaveBeenCalledWith( + TEAMS_THREAD_ID, + 'Processing Cloud Agent result...' + ); + expect(mockTeamsPostMessage).toHaveBeenCalledWith(TEAMS_THREAD_ID, { + markdown: + 'Cloud Agent result for unknown repository (code, agent_bc8c260d-9365-4a30-98a7-33371c018b04, completed):\n\n# Create T3 App', + }); + expect(mockDbReturning).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/internal/bot-session-callback/[botRequestId]/route.ts b/apps/web/src/app/api/internal/bot-session-callback/[botRequestId]/route.ts index db5dfa31f7..4e3ddf0be1 100644 --- a/apps/web/src/app/api/internal/bot-session-callback/[botRequestId]/route.ts +++ b/apps/web/src/app/api/internal/bot-session-callback/[botRequestId]/route.ts @@ -29,6 +29,8 @@ import { type BotAgentMessageLike, } from '@/lib/bot/agent-runner'; import { findUserById } from '@/lib/user'; +import { PLATFORM } from '@/lib/integrations/core/constants'; +import type { Adapter, Message } from 'chat'; type ExecutionCallbackPayload = { sessionId: string; @@ -43,6 +45,8 @@ type ExecutionCallbackPayload = { type TerminalCallbackStatus = ExecutionCallbackPayload['status']; +type BotRequestRow = NonNullable>>; + async function getBotRequest(botRequestId: string) { const [request] = await db .select() @@ -160,7 +164,7 @@ async function failBotRequest(params: { async function failBotRequestForCallbackProcessingError(params: { botRequestId: string; - requestRow: NonNullable>>; + requestRow: BotRequestRow; startedAt: number; errorMessage: string; logMessage: string; @@ -181,59 +185,110 @@ async function failBotRequestForCallbackProcessingError(params: { return; } - await postSlackThreadMessage({ - threadId: params.requestRow.platform_thread_id, + await postBotThreadMessage({ + requestRow: params.requestRow, markdown: params.errorMessage, - platformIntegrationId: params.requestRow.platform_integration_id, }); } -async function startTyping({ - threadId, - platformIntegrationId, -}: { - threadId: string; - platformIntegrationId: string | null; -}): Promise { - const botToken = await getSlackBotToken(platformIntegrationId); - +async function getAdapterForBotRequest(requestRow: BotRequestRow): Promise { await bot.initialize(); + + switch (requestRow.platform) { + case PLATFORM.SLACK: + return bot.getAdapter('slack'); + case PLATFORM.TEAMS: + return bot.getAdapter('teams'); + default: + throw new Error(`PlatformNotSupported: ${requestRow.platform}`); + } +} + +async function withBotRequestAdapter( + requestRow: BotRequestRow, + fn: (adapter: Adapter) => Promise +): Promise { + const adapter = await getAdapterForBotRequest(requestRow); + + if (requestRow.platform !== PLATFORM.SLACK) { + return await fn(adapter); + } + + const botToken = await getSlackBotToken(requestRow.platform_integration_id); const slackAdapter = bot.getAdapter('slack'); - await slackAdapter.withBotToken(botToken, async () => { - await slackAdapter.startTyping(threadId, 'Processing Cloud Agent result...'); + return await slackAdapter.withBotToken(botToken, async () => await fn(slackAdapter)); +} + +async function startTyping({ requestRow }: { requestRow: BotRequestRow }): Promise { + await withBotRequestAdapter(requestRow, async adapter => { + await adapter.startTyping(requestRow.platform_thread_id, 'Processing Cloud Agent result...'); }); } -async function postSlackThreadMessage(params: { - threadId: string; +async function postBotThreadMessage(params: { + requestRow: BotRequestRow; markdown: string; - platformIntegrationId: string | null; }): Promise { - logCallback('Posting Slack thread message', { - threadId: params.threadId, + logCallback('Posting bot thread message', { + threadId: params.requestRow.platform_thread_id, markdownLength: params.markdown.length, - platformIntegrationId: params.platformIntegrationId, + platform: params.requestRow.platform, + platformIntegrationId: params.requestRow.platform_integration_id, }); - const botToken = await getSlackBotToken(params.platformIntegrationId); - - await bot.initialize(); - const slackAdapter = bot.getAdapter('slack'); - - const posted = await slackAdapter.withBotToken( - botToken, - async () => await slackAdapter.postMessage(params.threadId, { markdown: params.markdown }) + const posted = await withBotRequestAdapter( + params.requestRow, + async adapter => + await adapter.postMessage(params.requestRow.platform_thread_id, { markdown: params.markdown }) ); - logCallback('Slack thread message posted', { - threadId: params.threadId, + logCallback('Bot thread message posted', { + threadId: params.requestRow.platform_thread_id, messageId: posted.id, + platform: params.requestRow.platform, }); } +function buildFallbackCallbackMessage(params: { + requestRow: BotRequestRow; + botRequestId: string; + continuationPrompt: string; +}): BotAgentMessageLike { + return { + author: { + fullName: 'Cloud Agent Callback', + isBot: false, + isMe: false, + userId: params.requestRow.created_by, + userName: 'cloud-agent-callback', + }, + id: `${params.botRequestId}:callback`, + text: params.continuationPrompt, + }; +} + +async function fetchOriginalMessageForContinuation(params: { + adapter: Adapter; + requestRow: BotRequestRow; +}): Promise { + if (!params.requestRow.platform_message_id || !params.adapter.fetchMessage) { + return null; + } + + try { + return await params.adapter.fetchMessage( + params.requestRow.platform_thread_id, + params.requestRow.platform_message_id + ); + } catch (error) { + console.warn('[BotSessionCallback] Failed to fetch original bot message:', error); + return null; + } +} + async function continueBotAgentAfterCallback(params: { botRequestId: string; - requestRow: NonNullable>>; + requestRow: BotRequestRow; continuationPrompt: string; completedStepCount: number; }) { @@ -254,42 +309,25 @@ async function continueBotAgentAfterCallback(params: { await bot.initialize(); bot.registerSingleton(); - const slackAdapter = bot.getAdapter('slack'); - const botToken = await getSlackBotToken(params.requestRow.platform_integration_id); - return await slackAdapter.withBotToken(botToken, async () => { - const [threadInfo, originalMessage] = await Promise.all([ - slackAdapter.fetchThread(params.requestRow.platform_thread_id), - params.requestRow.platform_message_id - ? slackAdapter - .fetchMessage( - params.requestRow.platform_thread_id, - params.requestRow.platform_message_id - ) - .catch(error => { - console.warn('[BotSessionCallback] Failed to fetch original Slack message:', error); - return null; - }) - : null, + return await withBotRequestAdapter(params.requestRow, async adapter => { + const [originalMessage] = await Promise.all([ + fetchOriginalMessageForContinuation({ adapter, requestRow: params.requestRow }), ]); const thread = createSyntheticThread({ - threadId: threadInfo.id, - adapterName: 'slack', - channelId: threadInfo.channelId, - isDM: threadInfo.isDM ?? false, + threadId: params.requestRow.platform_thread_id, + adapterName: adapter.name, + channelId: adapter.channelIdFromThreadId(params.requestRow.platform_thread_id), + isDM: adapter.isDM?.(params.requestRow.platform_thread_id) ?? false, }); - const callbackMessage: BotAgentMessageLike = { - author: originalMessage?.author ?? { - fullName: 'Cloud Agent Callback', - isBot: false, - isMe: false, - userId: params.requestRow.created_by, - userName: 'cloud-agent-callback', - }, - id: `${params.botRequestId}:callback`, - text: params.continuationPrompt, - }; + const callbackMessage = originalMessage + ? { + author: originalMessage.author, + id: `${params.botRequestId}:callback`, + text: params.continuationPrompt, + } + : buildFallbackCallbackMessage(params); return await runBotAgent({ thread, @@ -467,7 +505,7 @@ async function handleCompletedCallback( botRequestId: string, payload: ExecutionCallbackPayload, startedAt: number, - requestRow: NonNullable>>, + requestRow: BotRequestRow, completedStepCount: number, trackedCallbackSession: BotRequestCloudAgentSession | undefined ) { @@ -547,8 +585,7 @@ async function handleCompletedCallback( } await startTyping({ - threadId: requestRow.platform_thread_id, - platformIntegrationId: requestRow.platform_integration_id, + requestRow, }); const failedSessions = readiness.sessions.filter(session => session.status !== 'completed'); @@ -567,10 +604,9 @@ async function handleCompletedCallback( }); if (updated) { - await postSlackThreadMessage({ - threadId: requestRow.platform_thread_id, + await postBotThreadMessage({ + requestRow, markdown: errorMessage, - platformIntegrationId: requestRow.platform_integration_id, }); } return; @@ -594,10 +630,9 @@ async function handleCompletedCallback( }); if (updated) { - await postSlackThreadMessage({ - threadId: requestRow.platform_thread_id, + await postBotThreadMessage({ + requestRow, markdown: errorMessage, - platformIntegrationId: requestRow.platform_integration_id, }); } return; @@ -639,10 +674,9 @@ async function handleCompletedCallback( }); if (updated) { - await postSlackThreadMessage({ - threadId: requestRow.platform_thread_id, + await postBotThreadMessage({ + requestRow, markdown: errorMessage, - platformIntegrationId: requestRow.platform_integration_id, }); } return; @@ -673,7 +707,7 @@ async function handleCompletedCallback( }); if (!updated) { - logCallback('Skipping Slack post because step-limit completed update returned no row', { + logCallback('Skipping bot post because step-limit completed update returned no row', { botRequestId, requestStatus: requestRow.status, storedCloudAgentSessionId: requestRow.cloud_agent_session_id, @@ -682,10 +716,9 @@ async function handleCompletedCallback( return; } - await postSlackThreadMessage({ - threadId: requestRow.platform_thread_id, + await postBotThreadMessage({ + requestRow, markdown: cloudAgentResultsForSlack, - platformIntegrationId: requestRow.platform_integration_id, }); return; @@ -733,7 +766,7 @@ ${cloudAgentResultsForPrompt}`; }); if (!updated) { - logCallback('Skipping Slack post because completed update returned no row', { + logCallback('Skipping bot post because completed update returned no row', { botRequestId, requestStatus: requestRow.status, storedCloudAgentSessionId: requestRow.cloud_agent_session_id, @@ -742,10 +775,9 @@ ${cloudAgentResultsForPrompt}`; return; } - await postSlackThreadMessage({ - threadId: requestRow.platform_thread_id, + await postBotThreadMessage({ + requestRow, markdown: continuation.finalText, - platformIntegrationId: requestRow.platform_integration_id, }); } @@ -753,7 +785,7 @@ async function handleFailedCallback( botRequestId: string, payload: ExecutionCallbackPayload, startedAt: number, - requestRow: NonNullable>>, + requestRow: BotRequestRow, trackedCallbackSession: BotRequestCloudAgentSession | undefined ) { let errorMessage = formatFailureMessage(payload); @@ -819,7 +851,7 @@ async function handleFailedCallback( }); if (!updated) { - logCallback('Skipping Slack post because failed update returned no row', { + logCallback('Skipping bot post because failed update returned no row', { botRequestId, requestStatus: requestRow.status, storedCloudAgentSessionId: requestRow.cloud_agent_session_id, @@ -828,10 +860,9 @@ async function handleFailedCallback( return; } - await postSlackThreadMessage({ - threadId: requestRow.platform_thread_id, + await postBotThreadMessage({ + requestRow, markdown: errorMessage, - platformIntegrationId: requestRow.platform_integration_id, }); } diff --git a/apps/web/src/lib/bot-identity.ts b/apps/web/src/lib/bot-identity.ts index 5051b4a880..8195afa548 100644 --- a/apps/web/src/lib/bot-identity.ts +++ b/apps/web/src/lib/bot-identity.ts @@ -32,6 +32,8 @@ export type PlatformIdentity = { teamId: string; /** Platform-specific user ID (e.g. Slack's "U123ABC") */ userId: string; + /** Human-readable workspace / tenant name, when the platform provides one. */ + teamName?: string; }; /** @@ -160,6 +162,7 @@ export function verifyLinkToken(token: string): PlatformIdentity | null { platform?: string; teamId?: string; userId?: string; + teamName?: string; iat?: number; nonce?: string; }; @@ -172,13 +175,22 @@ export function verifyLinkToken(token: string): PlatformIdentity | null { return null; } + if (data.teamName !== undefined && typeof data.teamName !== 'string') { + return null; + } + if (typeof data.iat !== 'number') return null; const age = Math.floor(Date.now() / 1000) - data.iat; if (age < 0 || age > TOKEN_TTL_SECONDS) return null; if (typeof data.nonce !== 'string' || data.nonce.length === 0) return null; - return { platform: data.platform, teamId: data.teamId, userId: data.userId }; + return { + platform: data.platform, + teamId: data.teamId, + userId: data.userId, + ...(data.teamName ? { teamName: data.teamName } : {}), + }; } catch { return null; } diff --git a/apps/web/src/lib/bot.ts b/apps/web/src/lib/bot.ts index 58c174336b..f6d61f016e 100644 --- a/apps/web/src/lib/bot.ts +++ b/apps/web/src/lib/bot.ts @@ -1,15 +1,24 @@ import { Chat, type ActionEvent, type Message, type Thread } from 'chat'; import { createSlackAdapter, SlackAdapter } from '@chat-adapter/slack'; +import { createTeamsAdapter } from '@chat-adapter/teams'; import { captureException } from '@sentry/nextjs'; import type { HomeView } from '@slack/types'; import { resolveKiloUserId, unlinkKiloUser } from '@/lib/bot-identity'; import { getPlatformIdentity, getPlatformIntegration } from '@/lib/bot/platform-helpers'; +import { promptInstallIntegration } from '@/lib/bot/install-integration'; import { LINK_ACCOUNT_ACTION_PREFIX, promptLinkAccount } from '@/lib/bot/link-account'; import { createBotRequest, updateBotRequest } from '@/lib/bot/request-logging'; import { findUserById } from '@/lib/user'; import { processMessage } from '@/lib/bot/run'; import { createChatState } from '@/lib/bot/state'; -import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_SIGNING_SECRET } from '@/lib/config.server'; +import { + SLACK_CLIENT_ID, + SLACK_CLIENT_SECRET, + SLACK_SIGNING_SECRET, + TEAMS_APP_ID, + TEAMS_APP_PASSWORD, + TEAMS_APP_TENANT_ID, +} from '@/lib/config.server'; const SLACK_ASSISTANT_SUGGESTED_PROMPTS = [ { @@ -117,11 +126,15 @@ export function buildSlackAppHomeView() { } satisfies HomeView; } -function createKiloBot(slackAdapter: ReturnType) { +function createKiloBot( + slackAdapter: ReturnType, + teamsAdapter: ReturnType +) { const chatBot = new Chat({ userName: process.env.NODE_ENV === 'production' ? 'Kilo' : 'Henk', adapters: { slack: slackAdapter, + teams: teamsAdapter, }, state: createChatState(), }); @@ -132,14 +145,12 @@ function createKiloBot(slackAdapter: ReturnType) { ): Promise { const identity = getPlatformIdentity(thread, message); const [platformIntegration, kiloUserId] = await Promise.all([ - getPlatformIntegration(thread, message), + getPlatformIntegration(identity), resolveKiloUserId(chatBot.getState(), identity), ]); if (!platformIntegration) { - captureException(new Error('No active platform integration found'), { - extra: { platform: identity.platform, teamId: identity.teamId }, - }); + await promptInstallIntegration(thread, message, identity); return; } @@ -245,4 +256,11 @@ const slackAdapter = createSlackAdapter({ signingSecret: SLACK_SIGNING_SECRET, }); -export const bot = createKiloBot(slackAdapter); +const teamsAdapter = createTeamsAdapter({ + appId: TEAMS_APP_ID, + appPassword: TEAMS_APP_PASSWORD, + appTenantId: TEAMS_APP_TENANT_ID, + appType: 'SingleTenant', +}); + +export const bot = createKiloBot(slackAdapter, teamsAdapter); diff --git a/apps/web/src/lib/bot/install-integration.ts b/apps/web/src/lib/bot/install-integration.ts new file mode 100644 index 0000000000..81d7cbb846 --- /dev/null +++ b/apps/web/src/lib/bot/install-integration.ts @@ -0,0 +1,55 @@ +import { Actions, Card, LinkButton, Section, CardText, type Message, type Thread } from 'chat'; +import { createLinkToken, type PlatformIdentity } from '@/lib/bot-identity'; +import { APP_URL } from '@/lib/constants'; +import { isChannelLevelMessage } from '@/lib/bot/helpers'; + +const INSTALL_INTEGRATION_PATH = '/api/chat/install-integration'; + +function platformLabel(platform: string): string { + switch (platform) { + case 'slack': + return 'Slack'; + case 'teams': + return 'Microsoft Teams'; + default: + return platform; + } +} + +function buildInstallIntegrationUrl(identity: PlatformIdentity): string { + const url = new URL(INSTALL_INTEGRATION_PATH, APP_URL); + url.searchParams.set('token', createLinkToken(identity)); + return url.toString(); +} + +function installIntegrationCard(linkUrl: string, identity: PlatformIdentity) { + const platform = platformLabel(identity.platform); + return Card({ + title: `Connect ${platform} to Kilo`, + children: [ + Section([ + CardText( + `This ${platform} workspace is not linked to a Kilo account or organization yet. ` + + 'Open the secure setup page to choose where this workspace should be connected.' + ), + ]), + Actions([LinkButton({ label: 'Connect Workspace', url: linkUrl, style: 'primary' })]), + ], + }); +} + +export async function promptInstallIntegration( + thread: Thread, + message: Message, + identity: PlatformIdentity +): Promise { + const target = isChannelLevelMessage(thread, message) ? thread.channel : thread; + + await target.postEphemeral( + message.author, + installIntegrationCard(buildInstallIntegrationUrl(identity), identity), + { + fallbackToDM: true, + } + ); +} diff --git a/apps/web/src/lib/bot/platform-helpers.test.ts b/apps/web/src/lib/bot/platform-helpers.test.ts index 21b54cdfff..e1eef63d12 100644 --- a/apps/web/src/lib/bot/platform-helpers.test.ts +++ b/apps/web/src/lib/bot/platform-helpers.test.ts @@ -13,7 +13,8 @@ jest.mock('@/lib/drizzle', () => ({ })); import { PLATFORM } from '@/lib/integrations/core/constants'; -import { getPlatformIntegration } from './platform-helpers'; +import type { PlatformIdentity } from '@/lib/bot-identity'; +import { getPlatformIdentity, getPlatformIntegration } from './platform-helpers'; describe('platform helpers', () => { beforeEach(() => { @@ -28,13 +29,13 @@ describe('platform helpers', () => { }; mockLimit.mockResolvedValue([integration]); - const result = await getPlatformIntegration( - { id: 'slack:C123:123.456' } as Parameters[0], - { - raw: { team_id: 'T123' }, - author: { userId: 'U123' }, - } as Parameters[1] - ); + const identity = { + platform: PLATFORM.SLACK, + teamId: 'T123', + userId: 'U123', + } satisfies PlatformIdentity; + + const result = await getPlatformIntegration(identity); expect(result).toBe(integration); }); @@ -42,14 +43,53 @@ describe('platform helpers', () => { it('returns null when no canonical Slack platform integration exists', async () => { mockLimit.mockResolvedValue([]); - const result = await getPlatformIntegration( - { id: 'slack:C123:123.456' } as Parameters[0], + const identity = { + platform: PLATFORM.SLACK, + teamId: 'T404', + userId: 'U123', + } satisfies PlatformIdentity; + + const result = await getPlatformIntegration(identity); + + expect(result).toBeNull(); + }); + + it('extracts Teams identity from tenant metadata', () => { + const result = getPlatformIdentity( + { id: 'teams:conversation:service' } as Parameters[0], { - raw: { team_id: 'T404' }, - author: { userId: 'U123' }, - } as Parameters[1] + raw: { + conversation: { tenantId: 'tenant-1' }, + channelData: { team: { name: 'Example Team' } }, + }, + author: { userId: '29:user' }, + } as Parameters[1] ); - expect(result).toBeNull(); + expect(result).toEqual({ + platform: 'teams', + teamId: 'tenant-1', + userId: '29:user', + teamName: 'Example Team', + }); + }); + + it('returns the canonical Teams platform integration for a Teams tenant', async () => { + const integration = { + id: 'pi_teams', + platform: PLATFORM.TEAMS, + platform_installation_id: 'tenant-1', + }; + mockLimit.mockResolvedValue([integration]); + + const identity = { + platform: PLATFORM.TEAMS, + teamId: 'tenant-1', + userId: '29:user', + } satisfies PlatformIdentity; + + const result = await getPlatformIntegration(identity); + + expect(result).toBe(integration); }); }); diff --git a/apps/web/src/lib/bot/platform-helpers.ts b/apps/web/src/lib/bot/platform-helpers.ts index 05e5005496..fb1cb46b50 100644 --- a/apps/web/src/lib/bot/platform-helpers.ts +++ b/apps/web/src/lib/bot/platform-helpers.ts @@ -1,17 +1,34 @@ import type { PlatformIdentity } from '@/lib/bot-identity'; import { db } from '@/lib/drizzle'; import { eq, and } from 'drizzle-orm'; -import { PLATFORM } from '@/lib/integrations/core/constants'; import { type SlackEvent } from '@chat-adapter/slack'; import { platform_integrations } from '@kilocode/db'; import type { Message, Thread } from 'chat'; +type TeamsActivity = { + channelData?: { + team?: { aadGroupId?: string; name?: string }; + tenant?: { id?: string; name?: string }; + }; + conversation?: { tenantId?: string }; +}; + export function getSlackTeamId(message: Message): string { const teamId = message.raw.team_id ?? message.raw.team; if (!teamId) throw new Error('Expected a teamId in message.raw'); return teamId; } +function getTeamsTenantId(message: Message): string { + const tenantId = message.raw.conversation?.tenantId ?? message.raw.channelData?.tenant?.id; + if (!tenantId) throw new Error('Expected a tenant ID in Teams message.raw'); + return tenantId; +} + +function getTeamsTenantName(message: Message): string | undefined { + return message.raw.channelData?.team?.name ?? message.raw.channelData?.tenant?.name; +} + /** * Extract platform identity coordinates from any adapter's message. * Extend the switch for Discord / Teams / Google Chat / etc. @@ -24,33 +41,36 @@ export function getPlatformIdentity(thread: Thread, message: Message): PlatformI const teamId = getSlackTeamId(message as Message); return { platform: 'slack', teamId, userId: message.author.userId }; } + case 'teams': { + const teamsMessage = message as Message; + const teamName = getTeamsTenantName(teamsMessage); + return { + platform: 'teams', + teamId: getTeamsTenantId(teamsMessage), + userId: message.author.userId, + ...(teamName ? { teamName } : {}), + }; + } default: throw new Error(`PlatformNotSupported: ${platform}`); } } -async function getSlackPlatformIntegration(teamId: string) { +/** + * Look up the platform integration row for a given identity. + * Platform-agnostic: queries by identity.platform + identity.teamId. + */ +export async function getPlatformIntegration(identity: PlatformIdentity) { const [integration] = await db .select() .from(platform_integrations) .where( and( - eq(platform_integrations.platform, PLATFORM.SLACK), - eq(platform_integrations.platform_installation_id, teamId) + eq(platform_integrations.platform, identity.platform), + eq(platform_integrations.platform_installation_id, identity.teamId) ) ) .limit(1); return integration ?? null; } - -export async function getPlatformIntegration(thread: Thread, message: Message) { - const platform = thread.id.split(':')[0]; - - switch (platform) { - case 'slack': - return await getSlackPlatformIntegration(getSlackTeamId(message as Message)); - default: - throw new Error(`PlatformNotSupported: ${platform}`); - } -} diff --git a/apps/web/src/lib/config.server.ts b/apps/web/src/lib/config.server.ts index f3bb4df35f..d9bfe35a76 100644 --- a/apps/web/src/lib/config.server.ts +++ b/apps/web/src/lib/config.server.ts @@ -122,6 +122,11 @@ export const SLACK_CLIENT_ID = getEnvVariable('SLACK_CLIENT_ID'); export const SLACK_CLIENT_SECRET = getEnvVariable('SLACK_CLIENT_SECRET'); export const SLACK_SIGNING_SECRET = getEnvVariable('SLACK_SIGNING_SECRET'); +// Microsoft Teams +export const TEAMS_APP_ID = getEnvVariable('TEAMS_APP_ID'); +export const TEAMS_APP_PASSWORD = getEnvVariable('TEAMS_APP_PASSWORD'); +export const TEAMS_APP_TENANT_ID = getEnvVariable('TEAMS_APP_TENANT_ID'); + // Discord (bot integration — existing) export const DISCORD_CLIENT_ID = getEnvVariable('DISCORD_CLIENT_ID'); export const DISCORD_CLIENT_SECRET = getEnvVariable('DISCORD_CLIENT_SECRET'); diff --git a/apps/web/src/lib/integrations/core/constants.ts b/apps/web/src/lib/integrations/core/constants.ts index 8bb08f196f..2d57c65087 100644 --- a/apps/web/src/lib/integrations/core/constants.ts +++ b/apps/web/src/lib/integrations/core/constants.ts @@ -147,6 +147,7 @@ export const PLATFORM = { GITLAB: 'gitlab', SLACK: 'slack', DISCORD: 'discord', + TEAMS: 'teams', } as const; /** diff --git a/apps/web/src/lib/integrations/teams-service.test.ts b/apps/web/src/lib/integrations/teams-service.test.ts new file mode 100644 index 0000000000..405bf05f4d --- /dev/null +++ b/apps/web/src/lib/integrations/teams-service.test.ts @@ -0,0 +1,101 @@ +process.env.NEXTAUTH_SECRET ||= 'test-nextauth-secret'; +process.env.TURNSTILE_SECRET_KEY ||= 'test-turnstile-secret'; + +const mockLimit = jest.fn(); +const mockInsertValues = jest.fn(); +const mockInsertReturning = jest.fn(); +const mockUpdateSet = jest.fn(); +const mockUpdateWhere = jest.fn(); +const mockUpdateReturning = jest.fn(); + +jest.mock('@/lib/drizzle', () => ({ + db: { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ + limit: mockLimit, + })), + })), + })), + insert: jest.fn(() => ({ + values: mockInsertValues, + })), + update: jest.fn(() => ({ + set: mockUpdateSet, + })), + }, +})); + +jest.mock('@/lib/ai-gateway/kilo-auto', () => ({ + KILO_AUTO_FREE_MODEL: { id: 'kilo-auto' }, +})); + +jest.mock('@/lib/slack-bot/model-allow-list', () => ({ + getDefaultAllowedModel: jest.fn(async () => 'org-model'), +})); + +import type { Owner } from '@/lib/integrations/core/types'; +import { upsertTeamsInstallation } from './teams-service'; + +describe('teams-service upsertTeamsInstallation', () => { + beforeEach(() => { + mockLimit.mockReset(); + mockInsertValues.mockReset(); + mockInsertReturning.mockReset(); + mockUpdateSet.mockReset(); + mockUpdateWhere.mockReset(); + mockUpdateReturning.mockReset(); + + mockInsertValues.mockReturnValue({ returning: mockInsertReturning }); + mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }); + mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning }); + }); + + it('creates a Teams platform integration for a user owner', async () => { + const owner = { type: 'user', id: 'user-1' } satisfies Owner; + const created = { id: 'teams-integration-1' }; + mockLimit.mockResolvedValue([]); + mockInsertReturning.mockResolvedValue([created]); + + await expect( + upsertTeamsInstallation({ owner, tenantId: 'tenant-1', tenantName: 'Acme' }) + ).resolves.toBe(created); + + expect(mockInsertValues).toHaveBeenCalledWith( + expect.objectContaining({ + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + platform: 'teams', + integration_type: 'app', + platform_installation_id: 'tenant-1', + platform_account_id: 'tenant-1', + platform_account_login: 'Acme', + integration_status: 'active', + metadata: { model_slug: 'kilo-auto' }, + }) + ); + }); + + it('updates an existing Teams integration and preserves its model', async () => { + const owner = { type: 'org', id: 'org-1' } satisfies Owner; + const updated = { id: 'teams-integration-1' }; + mockLimit.mockResolvedValue([ + { id: 'teams-integration-1', metadata: { model_slug: 'custom' } }, + ]); + mockUpdateReturning.mockResolvedValue([updated]); + + await expect( + upsertTeamsInstallation({ owner, tenantId: 'tenant-2', tenantName: 'Example Org' }) + ).resolves.toBe(updated); + + expect(mockUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ + platform_installation_id: 'tenant-2', + platform_account_id: 'tenant-2', + platform_account_login: 'Example Org', + integration_status: 'active', + metadata: { model_slug: 'custom' }, + }) + ); + }); +}); diff --git a/apps/web/src/lib/integrations/teams-service.ts b/apps/web/src/lib/integrations/teams-service.ts new file mode 100644 index 0000000000..b664af0fd3 --- /dev/null +++ b/apps/web/src/lib/integrations/teams-service.ts @@ -0,0 +1,113 @@ +import 'server-only'; +import { db } from '@/lib/drizzle'; +import type { PlatformIntegration } from '@kilocode/db/schema'; +import { platform_integrations } from '@kilocode/db/schema'; +import { eq, and, isNull } from 'drizzle-orm'; +import type { Owner } from '@/lib/integrations/core/types'; +import { INTEGRATION_STATUS, PLATFORM } from '@/lib/integrations/core/constants'; +import { getDefaultAllowedModel } from '@/lib/slack-bot/model-allow-list'; +import { KILO_AUTO_FREE_MODEL } from '@/lib/ai-gateway/kilo-auto'; + +const TEAMS_DEFAULT_MODEL = KILO_AUTO_FREE_MODEL.id; + +function getOwnershipConditions(owner: Owner) { + return owner.type === 'user' + ? [ + eq(platform_integrations.owned_by_user_id, owner.id), + isNull(platform_integrations.owned_by_organization_id), + ] + : [ + eq(platform_integrations.owned_by_organization_id, owner.id), + isNull(platform_integrations.owned_by_user_id), + ]; +} + +export async function getInstallation(owner: Owner): Promise { + const [integration] = await db + .select() + .from(platform_integrations) + .where( + and(...getOwnershipConditions(owner), eq(platform_integrations.platform, PLATFORM.TEAMS)) + ) + .limit(1); + + return integration || null; +} + +export async function getInstallationByTenantId( + tenantId: string +): Promise { + const [integration] = await db + .select() + .from(platform_integrations) + .where( + and( + eq(platform_integrations.platform, PLATFORM.TEAMS), + eq(platform_integrations.platform_installation_id, tenantId) + ) + ) + .limit(1); + + return integration || null; +} + +export async function upsertTeamsInstallation({ + owner, + tenantId, + tenantName, +}: { + owner: Owner; + tenantId: string; + tenantName?: string; +}): Promise { + const existing = await getInstallation(owner); + const accountName = tenantName || tenantId; + + if (existing) { + const existingMetadata = (existing.metadata || {}) as Record; + const metadata = existingMetadata.model_slug + ? existingMetadata + : { + ...existingMetadata, + model_slug: await getDefaultModel(owner), + }; + const [updated] = await db + .update(platform_integrations) + .set({ + platform_installation_id: tenantId, + platform_account_id: tenantId, + platform_account_login: accountName, + integration_status: INTEGRATION_STATUS.ACTIVE, + metadata, + updated_at: new Date().toISOString(), + }) + .where(eq(platform_integrations.id, existing.id)) + .returning(); + + return updated; + } + + const [created] = await db + .insert(platform_integrations) + .values({ + owned_by_user_id: owner.type === 'user' ? owner.id : null, + owned_by_organization_id: owner.type === 'org' ? owner.id : null, + platform: PLATFORM.TEAMS, + integration_type: 'app', + platform_installation_id: tenantId, + platform_account_id: tenantId, + platform_account_login: accountName, + integration_status: INTEGRATION_STATUS.ACTIVE, + metadata: { model_slug: await getDefaultModel(owner) }, + installed_at: new Date().toISOString(), + }) + .returning(); + + return created; +} + +async function getDefaultModel(owner: Owner): Promise { + return owner.type === 'org' + ? await getDefaultAllowedModel(owner.id, TEAMS_DEFAULT_MODEL) + : TEAMS_DEFAULT_MODEL; +} diff --git a/apps/web/src/microsoft-teams/color.png b/apps/web/src/microsoft-teams/color.png new file mode 100644 index 0000000000..384bf72f80 Binary files /dev/null and b/apps/web/src/microsoft-teams/color.png differ diff --git a/apps/web/src/microsoft-teams/manifest.json b/apps/web/src/microsoft-teams/manifest.json new file mode 100644 index 0000000000..35f2832930 --- /dev/null +++ b/apps/web/src/microsoft-teams/manifest.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json", + "manifestVersion": "1.16", + "version": "1.0.0", + "id": "c13c1718-edbf-47a0-9f34-4780e6bb1d03", + "packageName": "com.kilo.chatbot", + "developer": { + "name": "Kilo", + "websiteUrl": "https://kilo.ai", + "privacyUrl": "https://kilo.ai/privacy", + "termsOfUseUrl": "https://kilo.ai/terms" + }, + "name": { + "short": "KiloDev Bot", + "full": "Chat SDK Demo Bot" + }, + "description": { + "short": "A chat bot powered by Chat SDK", + "full": "A chat bot powered by Chat SDK that responds to messages and commands." + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "c13c1718-edbf-47a0-9f34-4780e6bb1d03", + "scopes": ["personal", "team", "groupchat"], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": ["identity", "messageTeamMembers"], + "validDomains": ["kilo.ai"] +} diff --git a/apps/web/src/microsoft-teams/outline.png b/apps/web/src/microsoft-teams/outline.png new file mode 100644 index 0000000000..0c093842d4 Binary files /dev/null and b/apps/web/src/microsoft-teams/outline.png differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c53c6e9e43..586c4ee477 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -461,14 +461,17 @@ importers: specifier: ^3.1009.0 version: 3.1009.0 '@chat-adapter/slack': - specifier: ^4.20.1 - version: 4.20.1 + specifier: ^4.26.0 + version: 4.26.0 '@chat-adapter/state-memory': - specifier: ^4.20.1 - version: 4.20.1 + specifier: ^4.26.0 + version: 4.26.0 '@chat-adapter/state-redis': - specifier: ^4.20.1 - version: 4.20.1 + specifier: ^4.26.0 + version: 4.26.0 + '@chat-adapter/teams': + specifier: ^4.26.0 + version: 4.26.0 '@emoji-mart/data': specifier: ^1.2.1 version: 1.2.1 @@ -650,8 +653,8 @@ importers: specifier: ^7.0.1 version: 7.0.1 chat: - specifier: ^4.20.1 - version: 4.20.1 + specifier: ^4.26.0 + version: 4.26.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2458,6 +2461,14 @@ packages: resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} + '@azure/msal-common@15.17.0': + resolution: {integrity: sha512-VQ5/gTLFADkwue+FohVuCqlzFPUq4xSrX8jeZe+iwZuY6moliNC8xt86qPVNYdtbQfELDf2Nu6LI+demFPHGgw==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@3.8.10': + resolution: {integrity: sha512-0Hz7Kx4hs70KZWep/Rd7aw/qOLUF92wUOhn7ZsOuB5xNR/06NL1E2RAI9+UKH1FtvN8nD6mFjH7UKSjv6vOWvQ==} + engines: {node: '>=16'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -3179,17 +3190,20 @@ packages: '@braintree/sanitize-url@6.0.4': resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} - '@chat-adapter/shared@4.20.1': - resolution: {integrity: sha512-UawGmT7O+3vxvaU9f+lc0PVQKU+TvE0PUxa0zL43qH1rqGkosngtT3cOOhW6JOx+rxt3jox2a99xr8hnJPkshA==} + '@chat-adapter/shared@4.26.0': + resolution: {integrity: sha512-YD0MGktFXrArUqTBsyPfL5vkdD1WBS58PAWO0oVrMQAMmPxpAXfWGjBtZCkf3y8R8Svb0uVuVXiMZSForaEnMQ==} + + '@chat-adapter/slack@4.26.0': + resolution: {integrity: sha512-NNN47rURI6qpJf4rRK8xyeumjPTAXr7YSU4/FnViU8cFV/vKYFR4xZTzlFMVWNrYi9SmSwasUjcBmQznigK54Q==} - '@chat-adapter/slack@4.20.1': - resolution: {integrity: sha512-DGrNKHiYCFPFd6WzWkoJmCmtK/TuHw5euUjev6i01ygYNLQ7h2JiL424iqkdZmXfkOIxnDcnVBbKDuF9uEKQlg==} + '@chat-adapter/state-memory@4.26.0': + resolution: {integrity: sha512-FsfyM/A9Bf1yFc1FWmOsK+a4YVwm5FogX25hZxFG6cEvyFb6Cd924SsbtvF06yItY/7J2UFetCsMmBPkdPKshQ==} - '@chat-adapter/state-memory@4.20.1': - resolution: {integrity: sha512-JtITUuz4xYwXMOSH8dCYjuxgIgtkecVH5RUVR46PT9Nh8QT4l6WRMNZvdrWkJOn9jOZToXiNS/ae0OFJHJtN+g==} + '@chat-adapter/state-redis@4.26.0': + resolution: {integrity: sha512-NSX2E6wkDlg0AMfKFx4LPoV6cBHolL08Ht3cT+S01jss24t9GzlDw/BUsrWOeoTElwEhxZcQw5bUkStUAA7JMg==} - '@chat-adapter/state-redis@4.20.1': - resolution: {integrity: sha512-aAN+GXNTCKrXsSurh5arLMDGl8XbbxTspD1qLZuYXPwQBBZsuF3mPBfeFmwRJqweHuwv+3b/VofXvxFmRosR6A==} + '@chat-adapter/teams@4.26.0': + resolution: {integrity: sha512-Hg7TzN7Y/G/PcJr7VWKnv26XBDjP8+KDoe1gp62BAN7Y2MXCPdhsZLSR3cWQ5AH7nw0oz3vPZ38UilWUIhCxFg==} '@chromatic-com/playwright@0.12.8': resolution: {integrity: sha512-2oi8ghd+2N+hDI0Y65Ac/aYfsvDTyDHvEZMNS4Ln7VzyZ6SdGLoQLOvP80IGJlekTI68K8gQpzXewRV2chUaZQ==} @@ -4406,6 +4420,30 @@ packages: '@types/react': '>=16' react: '>=16' + '@microsoft/teams.api@2.0.8': + resolution: {integrity: sha512-N13idaRZNnfL7aefzsn2rhPtujqke1QVM81bNWq1XeK+5yeXod3aLTmBY701DEKkZUXiSG0AogvzkNwVnBw4+g==} + engines: {node: '>=20'} + + '@microsoft/teams.apps@2.0.8': + resolution: {integrity: sha512-6YBOxnQSoEPu841zMa1SDBdwH3gsuzrz0LCONuaGOHJ3Kmv2S+7ih1DqEx4Ak3iAYHeCvxnWGqjcYSBhgeiuMQ==} + engines: {node: '>=20'} + + '@microsoft/teams.cards@2.0.8': + resolution: {integrity: sha512-WrGAgkDKqhvFhnsKPwFeKTkhAXu/fbySxq5+uIOqY1ffdgiEdEoj6c0cjyQJy0HJ9B5u/0SQ4hO2KUc6jlZSZw==} + engines: {node: '>=20'} + + '@microsoft/teams.common@2.0.8': + resolution: {integrity: sha512-nkolOYX9qCpfK2uitiuAYm3EvMleHer5P3OCx3IBOp2gxkkFvNNOcOIDXuPZ6BtiENt47FPn4fYMMgJrawCHcA==} + engines: {node: '>=20'} + + '@microsoft/teams.graph-endpoints@2.0.8': + resolution: {integrity: sha512-KjDhMWn8ZcwTjDOw1hL0By/HvHEXe5+Bzy8tmEOsqPGUc/1HfwtBZAYsr8FUVBSBe0CObLYs2Pkh0wiHqWNNPw==} + engines: {node: '>=20'} + + '@microsoft/teams.graph@2.0.8': + resolution: {integrity: sha512-M/skUNJFD+lIVNa+ng0iy8t3sFmki6NOiWpGwnWOBAKFgTYBTJVtPfWm6SNdGFTCUsV9rjep4s9S5zTKUg2HJg==} + engines: {node: '>=20'} + '@mistralai/mistralai@1.15.1': resolution: {integrity: sha512-fb995eiz3r0KsBGtRjFV+/iLbX+UpfalxpF+YitT3R6ukrPD4PN+FGwwmYcRFhNAzVzDUtTVxQYnjQWEnwV5nw==} @@ -8754,8 +8792,8 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} - chat@4.20.1: - resolution: {integrity: sha512-TwvP1bbzK2g3sYHhD94wot2watxnySCYLQkaMdYjM79T43OTybG8hSSFhOrrp/AXjLHei2sch4GCOQu+4zcfHw==} + chat@4.26.0: + resolution: {integrity: sha512-QToDnIEGpyb8yQA6YLMHOSRK30YVk4RtsyFyuWFYyB2c4jQlyIrSWtwVK7qyvmvqzQp9uDwCdJRAhS8GtCHAGQ==} check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} @@ -11422,9 +11460,17 @@ packages: jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jwks-rsa@3.2.2: + resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==} + engines: {node: '>=14'} + jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -11532,6 +11578,9 @@ packages: resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -11571,6 +11620,9 @@ packages: lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -11664,6 +11716,9 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lru-memoizer@2.3.0: + resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} + lucide-react-native@1.7.0: resolution: {integrity: sha512-wGJY5nosSawh028jg8r1ZKqnGPDIVfIL9xvKOs4wPYFQHeJMHsADYm/lmuFYXMXXatSkHhpsCjeqIRgeFGzf8g==} peerDependencies: @@ -13240,6 +13295,9 @@ packages: resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + regenerate-unicode-properties@10.2.2: resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} engines: {node: '>=4'} @@ -15612,6 +15670,14 @@ snapshots: '@aws/lambda-invoke-store@0.2.4': {} + '@azure/msal-common@15.17.0': {} + + '@azure/msal-node@3.8.10': + dependencies: + '@azure/msal-common': 15.17.0 + jsonwebtoken: 9.0.3 + uuid: 8.3.2 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -16521,35 +16587,47 @@ snapshots: '@braintree/sanitize-url@6.0.4': {} - '@chat-adapter/shared@4.20.1': + '@chat-adapter/shared@4.26.0': dependencies: - chat: 4.20.1 + chat: 4.26.0 transitivePeerDependencies: - supports-color - '@chat-adapter/slack@4.20.1': + '@chat-adapter/slack@4.26.0': dependencies: - '@chat-adapter/shared': 4.20.1 + '@chat-adapter/shared': 4.26.0 '@slack/web-api': 7.15.0 - chat: 4.20.1 + chat: 4.26.0 transitivePeerDependencies: - debug - supports-color - '@chat-adapter/state-memory@4.20.1': + '@chat-adapter/state-memory@4.26.0': dependencies: - chat: 4.20.1 + chat: 4.26.0 transitivePeerDependencies: - supports-color - '@chat-adapter/state-redis@4.20.1': + '@chat-adapter/state-redis@4.26.0': dependencies: - chat: 4.20.1 + chat: 4.26.0 redis: 5.11.0 transitivePeerDependencies: - '@node-rs/xxhash' - supports-color + '@chat-adapter/teams@4.26.0': + dependencies: + '@chat-adapter/shared': 4.26.0 + '@microsoft/teams.api': 2.0.8 + '@microsoft/teams.apps': 2.0.8 + '@microsoft/teams.cards': 2.0.8 + '@microsoft/teams.graph-endpoints': 2.0.8 + chat: 4.26.0 + transitivePeerDependencies: + - debug + - supports-color + '@chromatic-com/playwright@0.12.8(@playwright/test@1.58.2)(@swc/core@1.15.18)(@types/react@19.2.14)(bufferutil@4.1.0)(esbuild@0.27.4)(typescript@5.9.3)(utf-8-validate@6.0.6)': dependencies: '@chromaui/rrweb-snapshot': 2.0.0-alpha.18-noAbsolute @@ -18013,6 +18091,48 @@ snapshots: '@types/react': 19.2.14 react: 19.2.4 + '@microsoft/teams.api@2.0.8': + dependencies: + '@microsoft/teams.cards': 2.0.8 + '@microsoft/teams.common': 2.0.8 + jwt-decode: 4.0.0 + qs: 6.15.0 + transitivePeerDependencies: + - debug + + '@microsoft/teams.apps@2.0.8': + dependencies: + '@azure/msal-node': 3.8.10 + '@microsoft/teams.api': 2.0.8 + '@microsoft/teams.common': 2.0.8 + '@microsoft/teams.graph': 2.0.8 + axios: 1.15.0 + cors: 2.8.6 + express: 5.2.1 + jsonwebtoken: 9.0.3 + jwks-rsa: 3.2.2 + reflect-metadata: 0.2.2 + transitivePeerDependencies: + - debug + - supports-color + + '@microsoft/teams.cards@2.0.8': {} + + '@microsoft/teams.common@2.0.8': + dependencies: + axios: 1.15.0 + transitivePeerDependencies: + - debug + + '@microsoft/teams.graph-endpoints@2.0.8': {} + + '@microsoft/teams.graph@2.0.8': + dependencies: + '@microsoft/teams.common': 2.0.8 + qs: 6.15.0 + transitivePeerDependencies: + - debug + '@mistralai/mistralai@1.15.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: ws: 8.19.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -23025,7 +23145,7 @@ snapshots: character-reference-invalid@2.0.1: {} - chat@4.20.1: + chat@4.26.0: dependencies: '@workflow/serde': 4.1.0-beta.2 mdast-util-to-string: 4.0.0 @@ -26348,11 +26468,23 @@ snapshots: ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + jwks-rsa@3.2.2: + dependencies: + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3 + jose: 4.15.9 + limiter: 1.1.5 + lru-memoizer: 2.3.0 + transitivePeerDependencies: + - supports-color + jws@4.0.1: dependencies: jwa: 2.0.1 safe-buffer: 5.2.1 + jwt-decode@4.0.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -26457,6 +26589,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1 + limiter@1.1.5: {} + lines-and-columns@1.2.4: {} linkify-it@5.0.0: @@ -26491,6 +26625,8 @@ snapshots: lodash-es@4.17.23: {} + lodash.clonedeep@4.5.0: {} + lodash.debounce@4.0.8: {} lodash.deburr@4.1.0: @@ -26563,6 +26699,11 @@ snapshots: dependencies: yallist: 4.0.0 + lru-memoizer@2.3.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 6.0.0 + lucide-react-native@1.7.0(react-native-svg@15.15.3(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0): dependencies: react: 19.2.0 @@ -28826,6 +28967,8 @@ snapshots: dependencies: '@eslint-community/regexpp': 4.12.2 + reflect-metadata@0.2.2: {} + regenerate-unicode-properties@10.2.2: dependencies: regenerate: 1.4.2