From ff4a5742487da8e6785c1a505c77dfe1fe527e55 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 1 Feb 2026 22:34:41 +0530 Subject: [PATCH] feat: implement Discord channel fetching with permission handling in connector UI --- .../app/routes/discord_add_connector_route.py | 287 ++++++++++++++++++ .../components/discord-config.tsx | 172 ++++++++++- .../contracts/types/connector.types.ts | 24 ++ .../lib/apis/connectors-api.service.ts | 18 +- 4 files changed, 493 insertions(+), 8 deletions(-) diff --git a/surfsense_backend/app/routes/discord_add_connector_route.py b/surfsense_backend/app/routes/discord_add_connector_route.py index 1d8b40fcf..09881bcac 100644 --- a/surfsense_backend/app/routes/discord_add_connector_route.py +++ b/surfsense_backend/app/routes/discord_add_connector_route.py @@ -531,3 +531,290 @@ async def refresh_discord_token( raise HTTPException( status_code=500, detail=f"Failed to refresh Discord tokens: {e!s}" ) from e + + +def _compute_channel_permissions( + base_permissions: int, + bot_role_ids: set[str], + bot_user_id: str | None, + channel_overwrites: list[dict], + guild_id: str, +) -> int: + """ + Compute effective permissions for a channel based on role permissions and overwrites. + + Discord permission computation follows this order (per official docs): + 1. Start with base permissions from roles + 2. Apply @everyone role overwrites (deny, then allow) + 3. Apply role-specific overwrites (deny, then allow) + 4. Apply member-specific overwrites (deny, then allow) + + Args: + base_permissions: Combined permissions from all bot roles + bot_role_ids: Set of role IDs the bot has + bot_user_id: The bot's user ID for member-specific overwrites + channel_overwrites: List of permission overwrites for the channel + guild_id: Guild ID (same as @everyone role ID) + + Returns: + Computed permission integer + """ + permissions = base_permissions + + # Permission overwrites are applied in order: @everyone, roles, member + everyone_allow = 0 + everyone_deny = 0 + role_allow = 0 + role_deny = 0 + member_allow = 0 + member_deny = 0 + + for overwrite in channel_overwrites: + overwrite_id = overwrite.get("id") + overwrite_type = overwrite.get("type") # 0 = role, 1 = member + allow = int(overwrite.get("allow", 0)) + deny = int(overwrite.get("deny", 0)) + + if overwrite_type == 0: # Role overwrite + if overwrite_id == guild_id: # @everyone role + everyone_allow = allow + everyone_deny = deny + elif overwrite_id in bot_role_ids: + role_allow |= allow + role_deny |= deny + elif overwrite_type == 1: # Member overwrite + if bot_user_id and overwrite_id == bot_user_id: + member_allow = allow + member_deny = deny + + # Apply in order per Discord docs: + # 1. @everyone deny, then allow + permissions &= ~everyone_deny + permissions |= everyone_allow + # 2. Role deny, then allow + permissions &= ~role_deny + permissions |= role_allow + # 3. Member deny, then allow (applied LAST, highest priority) + permissions &= ~member_deny + permissions |= member_allow + + return permissions + + +@router.get("/discord/connector/{connector_id}/channels", response_model=None) +async def get_discord_channels( + connector_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Get list of Discord text channels for a connector with permission info. + + Uses Discord's HTTP REST API directly instead of WebSocket bot connection. + Computes effective permissions to determine if bot can read message history. + + Args: + connector_id: The Discord connector ID + session: Database session + user: Current authenticated user + + Returns: + List of channels with id, name, type, position, category_id, and can_index fields + """ + from sqlalchemy import select + + # Discord permission bits + VIEW_CHANNEL = 1 << 10 # 1024 + READ_MESSAGE_HISTORY = 1 << 16 # 65536 + ADMINISTRATOR = 1 << 3 # 8 + + try: + # Get connector and verify ownership + result = await session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == connector_id, + SearchSourceConnector.user_id == user.id, + SearchSourceConnector.connector_type == SearchSourceConnectorType.DISCORD_CONNECTOR, + ) + ) + connector = result.scalar_one_or_none() + + if not connector: + raise HTTPException( + status_code=404, + detail="Discord connector not found or access denied", + ) + + # Get credentials and decrypt bot token + credentials = DiscordAuthCredentialsBase.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.", + ) + + # Get guild_id from connector config + guild_id = connector.config.get("guild_id") + if not guild_id: + raise HTTPException( + status_code=400, + detail="No guild_id associated with this connector. Please reconnect the Discord server.", + ) + + headers = {"Authorization": f"Bot {bot_token}"} + + async with httpx.AsyncClient() as client: + # Fetch bot's user info to get bot user ID + bot_user_response = await client.get( + "https://discord.com/api/v10/users/@me", + headers=headers, + timeout=30.0, + ) + + if bot_user_response.status_code != 200: + logger.warning(f"Failed to fetch bot user info: {bot_user_response.text}") + bot_user_id = None + else: + bot_user_id = bot_user_response.json().get("id") + + # Fetch guild info to get roles + guild_response = await client.get( + f"https://discord.com/api/v10/guilds/{guild_id}", + headers=headers, + timeout=30.0, + ) + + if guild_response.status_code != 200: + raise HTTPException( + status_code=guild_response.status_code, + detail="Failed to fetch guild information", + ) + + guild_data = guild_response.json() + guild_roles = {role["id"]: role for role in guild_data.get("roles", [])} + + # Fetch bot's member info to get its roles + bot_member_response = await client.get( + f"https://discord.com/api/v10/guilds/{guild_id}/members/{bot_user_id}", + headers=headers, + timeout=30.0, + ) + + if bot_member_response.status_code != 200: + logger.warning(f"Failed to fetch bot member info: {bot_member_response.text}") + bot_role_ids = {guild_id} # At minimum, bot has @everyone role + base_permissions = int(guild_roles.get(guild_id, {}).get("permissions", 0)) + else: + bot_member_data = bot_member_response.json() + bot_role_ids = set(bot_member_data.get("roles", [])) + bot_role_ids.add(guild_id) # @everyone role is always included + + # Compute base permissions from all bot roles + base_permissions = 0 + for role_id in bot_role_ids: + if role_id in guild_roles: + role_perms = int(guild_roles[role_id].get("permissions", 0)) + base_permissions |= role_perms + + # Check if bot has administrator permission (bypasses all checks) + is_admin = (base_permissions & ADMINISTRATOR) == ADMINISTRATOR + + # Fetch channels + channels_response = await client.get( + f"https://discord.com/api/v10/guilds/{guild_id}/channels", + headers=headers, + timeout=30.0, + ) + + if channels_response.status_code == 403: + raise HTTPException( + status_code=403, + detail="Bot does not have permission to view channels in this server. Please ensure the bot has the 'View Channels' permission.", + ) + elif channels_response.status_code == 404: + raise HTTPException( + status_code=404, + detail="Discord server not found. The bot may have been removed from the server.", + ) + elif channels_response.status_code != 200: + error_detail = channels_response.text + try: + error_json = channels_response.json() + error_detail = error_json.get("message", error_detail) + except Exception: + pass + raise HTTPException( + status_code=channels_response.status_code, + detail=f"Failed to fetch Discord channels: {error_detail}", + ) + + channels_data = channels_response.json() + + # Discord channel types: + # 0 = GUILD_TEXT, 2 = GUILD_VOICE, 4 = GUILD_CATEGORY, 5 = GUILD_ANNOUNCEMENT + # We want text channels (type 0) and announcement channels (type 5) + text_channel_types = {0, 5} + + text_channels = [] + for ch in channels_data: + if ch.get("type") in text_channel_types: + # Compute effective permissions for this channel + if is_admin: + # Administrators bypass all permission checks + can_index = True + else: + channel_overwrites = ch.get("permission_overwrites", []) + effective_perms = _compute_channel_permissions( + base_permissions, + bot_role_ids, + bot_user_id, + channel_overwrites, + guild_id, + ) + + # Bot can index if it has both VIEW_CHANNEL and READ_MESSAGE_HISTORY + has_view = (effective_perms & VIEW_CHANNEL) == VIEW_CHANNEL + has_read_history = (effective_perms & READ_MESSAGE_HISTORY) == READ_MESSAGE_HISTORY + can_index = has_view and has_read_history + + text_channels.append({ + "id": ch["id"], + "name": ch["name"], + "type": "text" if ch["type"] == 0 else "announcement", + "position": ch.get("position", 0), + "category_id": ch.get("parent_id"), + "can_index": can_index, + }) + + # Sort by position + text_channels.sort(key=lambda x: x["position"]) + + logger.info( + f"Fetched {len(text_channels)} text channels for Discord connector {connector_id}" + ) + + return text_channels + + except HTTPException: + raise + except Exception as e: + logger.error( + f"Failed to get Discord channels for connector {connector_id}: {e!s}", + exc_info=True, + ) + raise HTTPException( + status_code=500, detail=f"Failed to get Discord channels: {e!s}" + ) from e diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/discord-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/discord-config.tsx index dd4c89c8e..a0fd6888f 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/discord-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/discord-config.tsx @@ -1,29 +1,187 @@ "use client"; -import { Info } from "lucide-react"; -import type { FC } from "react"; +import { AlertCircle, CheckCircle2, Hash, Info, Megaphone, 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 DiscordChannel } from "@/lib/apis/connectors-api.service"; +import { cn } from "@/lib/utils"; import type { ConnectorConfigProps } from "../index"; export interface DiscordConfigProps extends ConnectorConfigProps { onNameChange?: (name: string) => void; } -export const DiscordConfig: FC = () => { +export const DiscordConfig: 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.getDiscordChannels(connector.id); + setChannels(data); + setLastFetched(new Date()); + } catch (err) { + console.error("Failed to fetch Discord 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 indexing capability + const readyToIndex = channels.filter((ch) => ch.can_index); + const needsPermissions = channels.filter((ch) => !ch.can_index); + + // 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 */}
-

Add Bot to Servers

- Before indexing, make sure the Discord bot has been added to the servers (guilds) you - want to index. The bot can only access messages from servers it's been added to. Use the - OAuth authorization flow to add the bot to your servers. + The bot needs "Read Message History" permission to index channels. + Ask a server admin to grant this permission for channels shown below.

+ + {/* 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 Discord server with proper permissions. +
+ ) : ( +
+ {/* Ready to index */} + {readyToIndex.length > 0 && ( +
0 && "border-b border-border")}> +
+ + Ready to index + + {readyToIndex.length} {readyToIndex.length === 1 ? "channel" : "channels"} + +
+
+ {readyToIndex.map((channel) => ( + + ))} +
+
+ )} + + {/* Needs permissions */} + {needsPermissions.length > 0 && ( +
+
+ + Grant permissions to index + + {needsPermissions.length}{" "} + {needsPermissions.length === 1 ? "channel" : "channels"} + +
+
+ {needsPermissions.map((channel) => ( + + ))} +
+
+ )} +
+ )} +
+
+ ); +}; + +interface ChannelPillProps { + channel: DiscordChannel; +} + +const ChannelPill: FC = ({ channel }) => { + return ( +
+ {channel.type === "announcement" ? ( + + ) : ( + + )} + {channel.name}
); }; diff --git a/surfsense_web/contracts/types/connector.types.ts b/surfsense_web/contracts/types/connector.types.ts index 05efa51d2..a7760745d 100644 --- a/surfsense_web/contracts/types/connector.types.ts +++ b/surfsense_web/contracts/types/connector.types.ts @@ -222,6 +222,27 @@ export const listSlackChannelsRequest = z.object({ export const listSlackChannelsResponse = z.array(slackChannel); +/** + * Discord channel with indexing permission info + */ +export const discordChannel = z.object({ + id: z.string(), + name: z.string(), + type: z.enum(["text", "announcement"]), + position: z.number(), + category_id: z.string().nullable().optional(), + can_index: z.boolean(), +}); + +/** + * List Discord channels + */ +export const listDiscordChannelsRequest = z.object({ + connector_id: z.number(), +}); + +export const listDiscordChannelsResponse = z.array(discordChannel); + // Inferred types export type SearchSourceConnectorType = z.infer; export type SearchSourceConnector = z.infer; @@ -245,3 +266,6 @@ export type GoogleDriveItem = z.infer; export type SlackChannel = z.infer; export type ListSlackChannelsRequest = z.infer; export type ListSlackChannelsResponse = z.infer; +export type DiscordChannel = z.infer; +export type ListDiscordChannelsRequest = z.infer; +export type ListDiscordChannelsResponse = z.infer; diff --git a/surfsense_web/lib/apis/connectors-api.service.ts b/surfsense_web/lib/apis/connectors-api.service.ts index 75e5a938a..45898b762 100644 --- a/surfsense_web/lib/apis/connectors-api.service.ts +++ b/surfsense_web/lib/apis/connectors-api.service.ts @@ -5,6 +5,7 @@ import { type DeleteConnectorRequest, deleteConnectorRequest, deleteConnectorResponse, + type DiscordChannel, type GetConnectorRequest, type GetConnectorsRequest, getConnectorRequest, @@ -16,6 +17,7 @@ import { indexConnectorResponse, type ListGitHubRepositoriesRequest, type ListGoogleDriveFoldersRequest, + listDiscordChannelsResponse, listGitHubRepositoriesRequest, listGitHubRepositoriesResponse, listGoogleDriveFoldersRequest, @@ -351,8 +353,22 @@ class ConnectorsApiService { listSlackChannelsResponse ); }; + + // ============================================================================= + // Discord Connector Methods + // ============================================================================= + + /** + * Get Discord text channels for a connector + */ + getDiscordChannels = async (connectorId: number) => { + return baseApiService.get( + `/api/v1/discord/connector/${connectorId}/channels`, + listDiscordChannelsResponse + ); + }; } -export type { SlackChannel }; +export type { SlackChannel, DiscordChannel }; export const connectorsApiService = new ConnectorsApiService();