diff --git a/surfsense_backend/app/routes/slack_add_connector_route.py b/surfsense_backend/app/routes/slack_add_connector_route.py index e7f19e8b0..66ba1cd41 100644 --- a/surfsense_backend/app/routes/slack_add_connector_route.py +++ b/surfsense_backend/app/routes/slack_add_connector_route.py @@ -6,6 +6,7 @@ Handles OAuth 2.0 authentication flow for Slack connector. import logging from datetime import UTC, datetime, timedelta +from typing import Any from uuid import UUID import httpx @@ -14,6 +15,7 @@ from fastapi.responses import RedirectResponse from pydantic import ValidationError from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select from app.config import config from app.db import ( @@ -517,3 +519,93 @@ async def refresh_slack_token( raise HTTPException( status_code=500, detail=f"Failed to refresh Slack token: {e!s}" ) from e + + +@router.get("/slack/connector/{connector_id}/channels") +async def get_slack_channels( + connector_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +) -> list[dict[str, Any]]: + """ + Get list of Slack channels with bot membership status. + + This endpoint fetches all channels the bot can see and indicates + whether the bot is a member of each channel (required for accessing messages). + + Args: + connector_id: The Slack connector ID + session: Database session + user: Current authenticated user + + Returns: + List of channels with id, name, is_private, and is_member fields + """ + try: + # Get the connector and verify ownership + result = await session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == connector_id, + SearchSourceConnector.user_id == user.id, + SearchSourceConnector.connector_type == SearchSourceConnectorType.SLACK_CONNECTOR, + ) + ) + connector = result.scalar_one_or_none() + + if not connector: + raise HTTPException( + status_code=404, + detail="Slack connector not found or access denied", + ) + + # Get credentials and decrypt bot token + credentials = SlackAuthCredentialsBase.from_dict(connector.config) + token_encryption = get_token_encryption() + is_encrypted = connector.config.get("_token_encrypted", False) + + bot_token = credentials.bot_token + if is_encrypted and bot_token: + try: + bot_token = token_encryption.decrypt_token(bot_token) + except Exception as e: + logger.error(f"Failed to decrypt bot token: {e!s}") + raise HTTPException( + status_code=500, detail="Failed to decrypt stored bot token" + ) from e + + if not bot_token: + raise HTTPException( + status_code=400, + detail="No bot token available. Please re-authenticate.", + ) + + # Import SlackHistory here to avoid circular imports + from app.connectors.slack_history import SlackHistory + + # Create Slack client and fetch channels + slack_client = SlackHistory( + session=session, + connector_id=connector_id, + credentials=credentials, + ) + # Set the decrypted token directly + slack_client.set_token(bot_token) + + channels = await slack_client.get_all_channels(include_private=True) + + logger.info( + f"Fetched {len(channels)} channels for Slack connector {connector_id}" + ) + + return channels + + except HTTPException: + raise + except Exception as e: + logger.error( + f"Failed to get Slack channels for connector {connector_id}: {e!s}", + exc_info=True, + ) + raise HTTPException( + status_code=500, detail=f"Failed to get Slack channels: {e!s}" + ) from e diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/slack-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/slack-config.tsx index 58293c4de..3af3e564e 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/slack-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/slack-config.tsx @@ -1,16 +1,79 @@ "use client"; -import { Info } from "lucide-react"; -import type { FC } from "react"; +import { AlertCircle, CheckCircle2, Hash, Info, Lock, RefreshCw } from "lucide-react"; +import { type FC, useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { connectorsApiService, type SlackChannel } from "@/lib/apis/connectors-api.service"; +import { cn } from "@/lib/utils"; import type { ConnectorConfigProps } from "../index"; export interface SlackConfigProps extends ConnectorConfigProps { onNameChange?: (name: string) => void; } -export const SlackConfig: FC = () => { +export const SlackConfig: FC = ({ connector }) => { + const [channels, setChannels] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [lastFetched, setLastFetched] = useState(null); + + const fetchChannels = useCallback(async () => { + if (!connector?.id) return; + + setIsLoading(true); + setError(null); + + try { + const data = await connectorsApiService.getSlackChannels(connector.id); + setChannels(data); + setLastFetched(new Date()); + } catch (err) { + console.error("Failed to fetch Slack channels:", err); + setError(err instanceof Error ? err.message : "Failed to fetch channels"); + } finally { + setIsLoading(false); + } + }, [connector?.id]); + + // Fetch channels on mount + useEffect(() => { + fetchChannels(); + }, [fetchChannels]); + + // Auto-refresh when user returns to tab + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === "visible" && connector?.id) { + fetchChannels(); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => document.removeEventListener("visibilitychange", handleVisibilityChange); + }, [connector?.id, fetchChannels]); + + // Separate channels by bot membership + const channelsWithBot = channels.filter((ch) => ch.is_member); + const channelsWithoutBot = channels.filter((ch) => !ch.is_member); + + // Format last fetched time + const formatLastFetched = () => { + if (!lastFetched) return null; + const now = new Date(); + const diffMs = now.getTime() - lastFetched.getTime(); + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + + if (diffSecs < 60) return "just now"; + if (diffMins === 1) return "1 minute ago"; + if (diffMins < 60) return `${diffMins} minutes ago`; + return lastFetched.toLocaleTimeString(); + }; + return (
+ {/* Info box */}
@@ -25,6 +88,104 @@ export const SlackConfig: FC = () => {

+ + {/* Channels Section */} +
+
+
+

Channel Access

+
+
+ {lastFetched && ( + {formatLastFetched()} + )} + +
+
+ + {error && ( +
+ {error} +
+ )} + + {isLoading && channels.length === 0 ? ( +
+ + Loading channels +
+ ) : channels.length === 0 && !error ? ( +
+ No channels found. Make sure the bot has been added to your Slack workspace. +
+ ) : ( +
+ {/* Channels with bot access */} + {channelsWithBot.length > 0 && ( +
0 && "border-b border-border")}> +
+ + Ready to index + + {channelsWithBot.length}{" "} + {channelsWithBot.length === 1 ? "channel" : "channels"} + +
+
+ {channelsWithBot.map((channel) => ( + + ))} +
+
+ )} + + {/* Channels without bot access */} + {channelsWithoutBot.length > 0 && ( +
+
+ + Add bot to index + + {channelsWithoutBot.length}{" "} + {channelsWithoutBot.length === 1 ? "channel" : "channels"} + +
+
+ {channelsWithoutBot.map((channel) => ( + + ))} +
+
+ )} +
+ )} +
+
+ ); +}; + +interface ChannelPillProps { + channel: SlackChannel; +} + +const ChannelPill: FC = ({ channel }) => { + return ( +
+ {channel.is_private ? ( + + ) : ( + + )} + {channel.name}
); }; diff --git a/surfsense_web/contracts/types/connector.types.ts b/surfsense_web/contracts/types/connector.types.ts index 5082fe49c..05efa51d2 100644 --- a/surfsense_web/contracts/types/connector.types.ts +++ b/surfsense_web/contracts/types/connector.types.ts @@ -203,6 +203,25 @@ export const listGoogleDriveFoldersResponse = z.object({ items: z.array(googleDriveItem), }); +/** + * Slack channel with bot membership status + */ +export const slackChannel = z.object({ + id: z.string(), + name: z.string(), + is_private: z.boolean(), + is_member: z.boolean(), +}); + +/** + * List Slack channels + */ +export const listSlackChannelsRequest = z.object({ + connector_id: z.number(), +}); + +export const listSlackChannelsResponse = z.array(slackChannel); + // Inferred types export type SearchSourceConnectorType = z.infer; export type SearchSourceConnector = z.infer; @@ -223,3 +242,6 @@ export type ListGitHubRepositoriesResponse = z.infer; export type ListGoogleDriveFoldersResponse = z.infer; export type GoogleDriveItem = z.infer; +export type SlackChannel = z.infer; +export type ListSlackChannelsRequest = z.infer; +export type ListSlackChannelsResponse = z.infer; diff --git a/surfsense_web/lib/apis/connectors-api.service.ts b/surfsense_web/lib/apis/connectors-api.service.ts index 10e08dc71..75e5a938a 100644 --- a/surfsense_web/lib/apis/connectors-api.service.ts +++ b/surfsense_web/lib/apis/connectors-api.service.ts @@ -20,6 +20,8 @@ import { listGitHubRepositoriesResponse, listGoogleDriveFoldersRequest, listGoogleDriveFoldersResponse, + listSlackChannelsResponse, + type SlackChannel, type UpdateConnectorRequest, updateConnectorRequest, updateConnectorResponse, @@ -335,6 +337,22 @@ class ConnectorsApiService { } ); }; + + // ============================================================================= + // Slack Connector Methods + // ============================================================================= + + /** + * Get Slack channels with bot membership status + */ + getSlackChannels = async (connectorId: number) => { + return baseApiService.get( + `/api/v1/slack/connector/${connectorId}/channels`, + listSlackChannelsResponse + ); + }; } +export type { SlackChannel }; + export const connectorsApiService = new ConnectorsApiService();