feat: implement Discord channel fetching with permission handling in connector UI

This commit is contained in:
Anish Sarkar 2026-02-01 22:34:41 +05:30
parent 47eaa705bf
commit ff4a574248
4 changed files with 493 additions and 8 deletions

View file

@ -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

View file

@ -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<DiscordConfigProps> = () => {
export const DiscordConfig: FC<DiscordConfigProps> = ({ connector }) => {
const [channels, setChannels] = useState<DiscordChannel[]>([]);
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.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 (
<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="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Add Bot to Servers</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
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 &quot;Read Message History&quot; permission to index channels.
Ask a server admin to grant this permission for channels shown below.
</p>
</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 Discord server with proper permissions.
</div>
) : (
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 overflow-hidden">
{/* Ready to index */}
{readyToIndex.length > 0 && (
<div className={cn("p-3", needsPermissions.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">
{readyToIndex.length} {readyToIndex.length === 1 ? "channel" : "channels"}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{readyToIndex.map((channel) => (
<ChannelPill key={channel.id} channel={channel} />
))}
</div>
</div>
)}
{/* Needs permissions */}
{needsPermissions.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">Grant permissions to index</span>
<span className="text-[10px] text-muted-foreground">
{needsPermissions.length}{" "}
{needsPermissions.length === 1 ? "channel" : "channels"}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{needsPermissions.map((channel) => (
<ChannelPill key={channel.id} channel={channel} />
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
};
interface ChannelPillProps {
channel: DiscordChannel;
}
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.type === "announcement" ? (
<Megaphone className="size-2.5 text-muted-foreground" />
) : (
<Hash className="size-2.5 text-muted-foreground" />
)}
<span>{channel.name}</span>
</div>
);
};

View file

@ -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<typeof searchSourceConnectorTypeEnum>;
export type SearchSourceConnector = z.infer<typeof searchSourceConnector>;
@ -245,3 +266,6 @@ 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>;
export type DiscordChannel = z.infer<typeof discordChannel>;
export type ListDiscordChannelsRequest = z.infer<typeof listDiscordChannelsRequest>;
export type ListDiscordChannelsResponse = z.infer<typeof listDiscordChannelsResponse>;

View file

@ -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();