feat: add endpoint to fetch Slack channels with bot membership status and update UI to display channels

This commit is contained in:
Anish Sarkar 2026-01-31 18:30:50 +05:30
parent eaf0a454b1
commit 59dd9554b3
4 changed files with 296 additions and 3 deletions

View file

@ -6,6 +6,7 @@ Handles OAuth 2.0 authentication flow for Slack connector.
import logging import logging
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID from uuid import UUID
import httpx import httpx
@ -14,6 +15,7 @@ from fastapi.responses import RedirectResponse
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.config import config from app.config import config
from app.db import ( from app.db import (
@ -517,3 +519,93 @@ async def refresh_slack_token(
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to refresh Slack token: {e!s}" status_code=500, detail=f"Failed to refresh Slack token: {e!s}"
) from e ) 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

View file

@ -1,16 +1,79 @@
"use client"; "use client";
import { Info } from "lucide-react"; import { AlertCircle, CheckCircle2, Hash, Info, Lock, RefreshCw } from "lucide-react";
import type { FC } from "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"; import type { ConnectorConfigProps } from "../index";
export interface SlackConfigProps extends ConnectorConfigProps { export interface SlackConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void; onNameChange?: (name: string) => void;
} }
export const SlackConfig: FC<SlackConfigProps> = () => { export const SlackConfig: FC<SlackConfigProps> = ({ connector }) => {
const [channels, setChannels] = useState<SlackChannel[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastFetched, setLastFetched] = useState<Date | null>(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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Info box */}
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3"> <div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" /> <Info className="size-4" />
@ -25,6 +88,104 @@ export const SlackConfig: FC<SlackConfigProps> = () => {
</p> </p>
</div> </div>
</div> </div>
{/* Channels Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold">Channel Access</h3>
</div>
<div className="flex items-center gap-2">
{lastFetched && (
<span className="text-[10px] text-muted-foreground">{formatLastFetched()}</span>
)}
<Button
variant="secondary"
size="sm"
onClick={fetchChannels}
disabled={isLoading}
className="h-7 px-2.5 text-[11px] bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 border-slate-400/20 dark:border-white/20"
>
<RefreshCw className={cn("mr-1.5 size-3", isLoading && "animate-spin")} />
Refresh
</Button>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive">
{error}
</div>
)}
{isLoading && channels.length === 0 ? (
<div className="flex items-center justify-center py-8">
<Spinner size="sm" />
<span className="ml-2 text-sm text-muted-foreground">Loading channels</span>
</div>
) : channels.length === 0 && !error ? (
<div className="text-center py-8 text-sm text-muted-foreground">
No channels found. Make sure the bot has been added to your Slack workspace.
</div>
) : (
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 overflow-hidden">
{/* Channels with bot access */}
{channelsWithBot.length > 0 && (
<div className={cn("p-3", channelsWithoutBot.length > 0 && "border-b border-border")}>
<div className="flex items-center gap-2 mb-2">
<CheckCircle2 className="size-3.5 text-emerald-500" />
<span className="text-[11px] font-medium">Ready to index</span>
<span className="text-[10px] text-muted-foreground">
{channelsWithBot.length}{" "}
{channelsWithBot.length === 1 ? "channel" : "channels"}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{channelsWithBot.map((channel) => (
<ChannelPill key={channel.id} channel={channel} />
))}
</div>
</div>
)}
{/* Channels without bot access */}
{channelsWithoutBot.length > 0 && (
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="size-3.5 text-amber-500" />
<span className="text-[11px] font-medium">Add bot to index</span>
<span className="text-[10px] text-muted-foreground">
{channelsWithoutBot.length}{" "}
{channelsWithoutBot.length === 1 ? "channel" : "channels"}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{channelsWithoutBot.map((channel) => (
<ChannelPill key={channel.id} channel={channel} />
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
};
interface ChannelPillProps {
channel: SlackChannel;
}
const ChannelPill: FC<ChannelPillProps> = ({ channel }) => {
return (
<div className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 transition-colors">
{channel.is_private ? (
<Lock className="size-2.5 text-muted-foreground" />
) : (
<Hash className="size-2.5 text-muted-foreground" />
)}
<span>{channel.name}</span>
</div> </div>
); );
}; };

View file

@ -203,6 +203,25 @@ export const listGoogleDriveFoldersResponse = z.object({
items: z.array(googleDriveItem), 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 // Inferred types
export type SearchSourceConnectorType = z.infer<typeof searchSourceConnectorTypeEnum>; export type SearchSourceConnectorType = z.infer<typeof searchSourceConnectorTypeEnum>;
export type SearchSourceConnector = z.infer<typeof searchSourceConnector>; export type SearchSourceConnector = z.infer<typeof searchSourceConnector>;
@ -223,3 +242,6 @@ export type ListGitHubRepositoriesResponse = z.infer<typeof listGitHubRepositori
export type ListGoogleDriveFoldersRequest = z.infer<typeof listGoogleDriveFoldersRequest>; export type ListGoogleDriveFoldersRequest = z.infer<typeof listGoogleDriveFoldersRequest>;
export type ListGoogleDriveFoldersResponse = z.infer<typeof listGoogleDriveFoldersResponse>; export type ListGoogleDriveFoldersResponse = z.infer<typeof listGoogleDriveFoldersResponse>;
export type GoogleDriveItem = z.infer<typeof googleDriveItem>; export type GoogleDriveItem = z.infer<typeof googleDriveItem>;
export type SlackChannel = z.infer<typeof slackChannel>;
export type ListSlackChannelsRequest = z.infer<typeof listSlackChannelsRequest>;
export type ListSlackChannelsResponse = z.infer<typeof listSlackChannelsResponse>;

View file

@ -20,6 +20,8 @@ import {
listGitHubRepositoriesResponse, listGitHubRepositoriesResponse,
listGoogleDriveFoldersRequest, listGoogleDriveFoldersRequest,
listGoogleDriveFoldersResponse, listGoogleDriveFoldersResponse,
listSlackChannelsResponse,
type SlackChannel,
type UpdateConnectorRequest, type UpdateConnectorRequest,
updateConnectorRequest, updateConnectorRequest,
updateConnectorResponse, 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(); export const connectorsApiService = new ConnectorsApiService();