diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index c7e93bf4b..2786bfbc6 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.51.6" +version = "0.51.8" dependencies = [ "env_logger", "log", diff --git a/app/src/components/channels/DiscordConfig.tsx b/app/src/components/channels/DiscordConfig.tsx index 97fa239cd..7daf1df3a 100644 --- a/app/src/components/channels/DiscordConfig.tsx +++ b/app/src/components/channels/DiscordConfig.tsx @@ -19,6 +19,7 @@ import type { import { openUrl } from '../../utils/openUrl'; import ChannelFieldInput from './ChannelFieldInput'; import ChannelStatusBadge from './ChannelStatusBadge'; +import DiscordServerChannelPicker from './DiscordServerChannelPicker'; const log = debug('channels:discord'); @@ -206,6 +207,19 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => { Disconnect + + {/* Server + Channel picker — shown after successful bot_token connection */} + {spec.mode === 'bot_token' && status === 'connected' && ( + { + updateField(compositeKey, 'guild_id', guildId); + updateField(compositeKey, 'channel_id', ''); + }} + onChannelSelected={channelId => updateField(compositeKey, 'channel_id', channelId)} + /> + )} ); })} diff --git a/app/src/components/channels/DiscordServerChannelPicker.tsx b/app/src/components/channels/DiscordServerChannelPicker.tsx new file mode 100644 index 000000000..a6496ed90 --- /dev/null +++ b/app/src/components/channels/DiscordServerChannelPicker.tsx @@ -0,0 +1,276 @@ +import debug from 'debug'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { channelConnectionsApi } from '../../services/api/channelConnectionsApi'; +import type { BotPermissionCheck, DiscordGuild, DiscordTextChannel } from '../../types/channels'; + +const log = debug('channels:discord:picker'); + +interface DiscordServerChannelPickerProps { + selectedGuildId?: string; + selectedChannelId?: string; + onGuildSelected?: (guildId: string) => void; + onChannelSelected?: (channelId: string) => void; +} + +type PickerState = + | 'idle' + | 'loading_guilds' + | 'guilds_loaded' + | 'loading_channels' + | 'channels_loaded' + | 'checking_permissions' + | 'ready' + | 'error'; + +const DiscordServerChannelPicker = ({ + selectedGuildId: selectedGuildIdProp, + selectedChannelId: selectedChannelIdProp, + onGuildSelected, + onChannelSelected, +}: DiscordServerChannelPickerProps) => { + const [state, setState] = useState('idle'); + const [guilds, setGuilds] = useState([]); + const [channels, setChannels] = useState([]); + const [selectedGuildId, setSelectedGuildId] = useState(selectedGuildIdProp ?? ''); + const [selectedChannelId, setSelectedChannelId] = useState(selectedChannelIdProp ?? ''); + const [permissions, setPermissions] = useState(null); + const [error, setError] = useState(null); + const channelsRequestIdRef = useRef(0); + const permissionsRequestIdRef = useRef(0); + + useEffect(() => { + setSelectedGuildId(selectedGuildIdProp ?? ''); + }, [selectedGuildIdProp]); + + useEffect(() => { + setSelectedChannelId(selectedChannelIdProp ?? ''); + }, [selectedChannelIdProp]); + + // Load guilds on mount + useEffect(() => { + const loadGuilds = async () => { + setState('loading_guilds'); + setError(null); + try { + const result = await channelConnectionsApi.listDiscordGuilds(); + setGuilds(result); + setState('guilds_loaded'); + log('loaded %d guilds', result.length); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setError(msg); + setState('error'); + log('failed to load guilds: %s', msg); + } + }; + void loadGuilds(); + }, []); + + const handleGuildChange = useCallback( + (guildId: string) => { + channelsRequestIdRef.current += 1; + permissionsRequestIdRef.current += 1; + setSelectedGuildId(guildId); + setSelectedChannelId(''); + setChannels([]); + setPermissions(null); + onGuildSelected?.(guildId); + onChannelSelected?.(''); + + if (!guildId) { + setState('guilds_loaded'); + return; + } + + const loadChannels = async () => { + const requestId = channelsRequestIdRef.current; + setState('loading_channels'); + setError(null); + try { + const result = await channelConnectionsApi.listDiscordChannels(guildId); + if (requestId !== channelsRequestIdRef.current) { + return; + } + setChannels(result); + setState('channels_loaded'); + log('loaded %d channels for guild %s', result.length, guildId); + } catch (e) { + if (requestId !== channelsRequestIdRef.current) { + return; + } + const msg = e instanceof Error ? e.message : String(e); + setError(msg); + setState('error'); + } + }; + void loadChannels(); + }, + [onChannelSelected, onGuildSelected] + ); + + const handleChannelChange = useCallback( + (channelId: string) => { + permissionsRequestIdRef.current += 1; + setSelectedChannelId(channelId); + setPermissions(null); + onChannelSelected?.(channelId); + + if (!channelId || !selectedGuildId) { + setState('channels_loaded'); + return; + } + + const checkPerms = async () => { + const requestId = permissionsRequestIdRef.current; + setState('checking_permissions'); + setError(null); + try { + const result = await channelConnectionsApi.checkDiscordPermissions( + selectedGuildId, + channelId + ); + if (requestId !== permissionsRequestIdRef.current) { + return; + } + setPermissions(result); + setState('ready'); + log('permissions for channel %s: %o', channelId, result); + } catch (e) { + if (requestId !== permissionsRequestIdRef.current) { + return; + } + const msg = e instanceof Error ? e.message : String(e); + setError(msg); + setState('error'); + } + }; + void checkPerms(); + }, + [selectedGuildId, onChannelSelected] + ); + + // Group channels by category + const groupedChannels = channels.reduce>((acc, ch) => { + const key = ch.parent_id ?? '__uncategorized'; + if (!acc[key]) acc[key] = []; + acc[key].push(ch); + return acc; + }, {}); + + const isLoading = + state === 'loading_guilds' || state === 'loading_channels' || state === 'checking_permissions'; + + return ( +
+

Server & Channel Selection

+ + {/* Error banner */} + {error && ( +
+ {error} +
+ )} + + {/* Guild selector */} +
+ + + {guilds.length === 0 && state === 'guilds_loaded' && ( +

+ The bot is not in any servers. Invite it using the Discord Developer Portal. +

+ )} +
+ + {/* Channel selector */} + {selectedGuildId && ( +
+ + +
+ )} + + {/* Permission check result */} + {state === 'checking_permissions' && ( +
+ + Checking permissions... +
+ )} + + {permissions && state === 'ready' && ( +
+ {permissions.missing_permissions.length === 0 ? ( + Bot has all required permissions in this channel. + ) : ( +
+ Missing permissions: + {permissions.missing_permissions.join(', ')} +
+ )} +
+ )} +
+ ); +}; + +export default DiscordServerChannelPicker; diff --git a/app/src/components/channels/__tests__/DiscordServerChannelPicker.test.tsx b/app/src/components/channels/__tests__/DiscordServerChannelPicker.test.tsx new file mode 100644 index 000000000..54554b772 --- /dev/null +++ b/app/src/components/channels/__tests__/DiscordServerChannelPicker.test.tsx @@ -0,0 +1,54 @@ +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import DiscordServerChannelPicker from '../DiscordServerChannelPicker'; + +// Mock the RPC client to avoid actual network calls +vi.mock('../../../services/coreRpcClient', () => ({ + callCoreRpc: vi.fn().mockImplementation(({ method }: { method: string }) => { + if (method === 'openhuman.channels_discord_list_guilds') { + return Promise.resolve([ + { id: '111', name: 'Test Server', icon: null }, + { id: '222', name: 'Another Server', icon: 'abc' }, + ]); + } + if (method === 'openhuman.channels_discord_list_channels') { + return Promise.resolve([ + { id: '901', name: 'general', type: 0, position: 0, parent_id: null }, + { id: '902', name: 'dev', type: 0, position: 1, parent_id: '800' }, + ]); + } + if (method === 'openhuman.channels_discord_check_permissions') { + return Promise.resolve({ + can_view_channel: true, + can_send_messages: true, + can_read_message_history: true, + missing_permissions: [], + }); + } + return Promise.reject(new Error(`unexpected RPC method: ${method}`)); + }), +})); + +describe('DiscordServerChannelPicker', () => { + it('renders server selection heading', () => { + renderWithProviders(); + expect(screen.getByText('Server & Channel Selection')).toBeInTheDocument(); + }); + + it('loads and displays guilds', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Test Server')).toBeInTheDocument(); + expect(screen.getByText('Another Server')).toBeInTheDocument(); + }); + }); + + it('shows "Select a server" placeholder after guilds load', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByRole('combobox', { name: /server/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/services/api/channelConnectionsApi.ts b/app/src/services/api/channelConnectionsApi.ts index a19ca0610..18bc06c88 100644 --- a/app/src/services/api/channelConnectionsApi.ts +++ b/app/src/services/api/channelConnectionsApi.ts @@ -1,9 +1,12 @@ import type { + BotPermissionCheck, ChannelAuthMode, ChannelConnectionResult, ChannelDefinition, ChannelStatusEntry, ChannelType, + DiscordGuild, + DiscordTextChannel, } from '../../types/channels'; import { callCoreRpc } from '../coreRpcClient'; @@ -92,6 +95,33 @@ export const channelConnectionsApi = { return result; }, + /** List Discord servers (guilds) the connected bot is a member of. */ + listDiscordGuilds: async (): Promise => { + return callCoreRpc({ + method: 'openhuman.channels_discord_list_guilds', + params: {}, + }); + }, + + /** List text channels in a Discord server. */ + listDiscordChannels: async (guildId: string): Promise => { + return callCoreRpc({ + method: 'openhuman.channels_discord_list_channels', + params: { guildId }, + }); + }, + + /** Check bot permissions in a Discord channel. */ + checkDiscordPermissions: async ( + guildId: string, + channelId: string + ): Promise => { + return callCoreRpc({ + method: 'openhuman.channels_discord_check_permissions', + params: { guildId, channelId }, + }); + }, + /** Placeholder for default channel preference sync. */ updatePreferences: async (defaultMessagingChannel: ChannelType): Promise => { void defaultMessagingChannel; diff --git a/app/src/types/channels.ts b/app/src/types/channels.ts index 243e17296..715156c27 100644 --- a/app/src/types/channels.ts +++ b/app/src/types/channels.ts @@ -82,3 +82,26 @@ export interface ChannelConnectionResult { auth_action?: string; message?: string; } + +// --- Discord guild/channel discovery types --- + +export interface DiscordGuild { + id: string; + name: string; + icon: string | null; +} + +export interface DiscordTextChannel { + id: string; + name: string; + type: number; + position: number; + parent_id: string | null; +} + +export interface BotPermissionCheck { + can_view_channel: boolean; + can_send_messages: boolean; + can_read_message_history: boolean; + missing_permissions: string[]; +} diff --git a/src/openhuman/channels/commands.rs b/src/openhuman/channels/commands.rs index 08e43b063..a5e3e16a3 100644 --- a/src/openhuman/channels/commands.rs +++ b/src/openhuman/channels/commands.rs @@ -64,6 +64,7 @@ pub async fn doctor_channels(config: Config) -> Result<()> { Arc::new(DiscordChannel::new( dc.bot_token.clone(), dc.guild_id.clone(), + dc.channel_id.clone(), dc.allowed_users.clone(), dc.listen_to_bots, dc.mention_only, diff --git a/src/openhuman/channels/controllers/definitions.rs b/src/openhuman/channels/controllers/definitions.rs index 844eef038..6c7797123 100644 --- a/src/openhuman/channels/controllers/definitions.rs +++ b/src/openhuman/channels/controllers/definitions.rs @@ -233,6 +233,13 @@ fn discord_definition() -> ChannelDefinition { required: false, placeholder: "Optional: restrict to a specific server", }, + FieldRequirement { + key: "channel_id", + label: "Channel ID", + field_type: "string", + required: false, + placeholder: "Optional: default channel for outbound messages", + }, ], auth_action: None, }, diff --git a/src/openhuman/channels/controllers/ops.rs b/src/openhuman/channels/controllers/ops.rs index 6b0547c1b..a49057384 100644 --- a/src/openhuman/channels/controllers/ops.rs +++ b/src/openhuman/channels/controllers/ops.rs @@ -560,6 +560,89 @@ pub async fn channel_list_threads( Ok(RpcOutcome::new(result, vec![])) } +// --------------------------------------------------------------------------- +// Discord guild/channel discovery +// --------------------------------------------------------------------------- + +/// Retrieve the stored Discord bot token from credentials. +async fn discord_bot_token(config: &Config) -> Result { + let provider_key = credential_provider("discord", ChannelAuthMode::BotToken); + let auth = credentials::AuthService::from_config(config); + let profile = auth + .get_profile(&provider_key, None) + .map_err(|e| format!("failed to load Discord credentials: {e}"))? + .ok_or("Discord bot token not configured. Connect Discord first.")?; + + let token = profile.token.unwrap_or_default(); + if token.is_empty() { + return Err("Discord bot token is empty.".to_string()); + } + Ok(token) +} + +/// List Discord guilds (servers) the connected bot is a member of. +pub async fn discord_list_guilds( + config: &Config, +) -> Result< + RpcOutcome>, + String, +> { + use crate::openhuman::channels::providers::discord::api; + + let token = discord_bot_token(config).await?; + let guilds = api::list_bot_guilds(&token) + .await + .map_err(|e| format!("Discord API error: {e}"))?; + Ok(RpcOutcome::single_log(guilds, "discord guilds listed")) +} + +/// List text channels in a Discord guild. +pub async fn discord_list_channels( + config: &Config, + guild_id: &str, +) -> Result< + RpcOutcome>, + String, +> { + use crate::openhuman::channels::providers::discord::api; + + if guild_id.is_empty() { + return Err("guild_id is required".to_string()); + } + let token = discord_bot_token(config).await?; + let channels = api::list_guild_channels(&token, guild_id) + .await + .map_err(|e| format!("Discord API error: {e}"))?; + Ok(RpcOutcome::single_log( + channels, + format!("discord channels listed for guild {guild_id}"), + )) +} + +/// Check bot permissions in a Discord channel. +pub async fn discord_check_permissions( + config: &Config, + guild_id: &str, + channel_id: &str, +) -> Result< + RpcOutcome, + String, +> { + use crate::openhuman::channels::providers::discord::api; + + if guild_id.is_empty() || channel_id.is_empty() { + return Err("guild_id and channel_id are required".to_string()); + } + let token = discord_bot_token(config).await?; + let check = api::check_channel_permissions(&token, guild_id, channel_id) + .await + .map_err(|e| format!("Discord API error: {e}"))?; + Ok(RpcOutcome::single_log( + check, + format!("discord permissions checked for channel {channel_id}"), + )) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/openhuman/channels/controllers/schemas.rs b/src/openhuman/channels/controllers/schemas.rs index bd52cca3b..71f65c440 100644 --- a/src/openhuman/channels/controllers/schemas.rs +++ b/src/openhuman/channels/controllers/schemas.rs @@ -59,6 +59,19 @@ struct TelegramLoginCheckParams { link_token: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DiscordListChannelsParams { + guild_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DiscordCheckPermissionsParams { + guild_id: String, + channel_id: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SendMessageParams { @@ -110,6 +123,9 @@ pub fn all_controller_schemas() -> Vec { schemas("test"), schemas("telegram_login_start"), schemas("telegram_login_check"), + schemas("discord_list_guilds"), + schemas("discord_list_channels"), + schemas("discord_check_permissions"), schemas("send_message"), schemas("send_reaction"), schemas("create_thread"), @@ -152,6 +168,18 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("telegram_login_check"), handler: handle_telegram_login_check, }, + RegisteredController { + schema: schemas("discord_list_guilds"), + handler: handle_discord_list_guilds, + }, + RegisteredController { + schema: schemas("discord_list_channels"), + handler: handle_discord_list_channels, + }, + RegisteredController { + schema: schemas("discord_check_permissions"), + handler: handle_discord_check_permissions, + }, RegisteredController { schema: schemas("send_message"), handler: handle_send_message, @@ -276,6 +304,30 @@ pub fn schemas(function: &str) -> ControllerSchema { "Object with linked (bool) and optional details.", )], }, + "discord_list_guilds" => ControllerSchema { + namespace: "channels", + function: "discord_list_guilds", + description: "List Discord servers (guilds) the connected bot is a member of.", + inputs: vec![], + outputs: vec![json_output("guilds", "Array of guild objects with id, name, and icon.")], + }, + "discord_list_channels" => ControllerSchema { + namespace: "channels", + function: "discord_list_channels", + description: "List text channels in a Discord guild.", + inputs: vec![required_string("guildId", "The Discord guild (server) ID.")], + outputs: vec![json_output("channels", "Array of text channel objects with id, name, position, and parentId.")], + }, + "discord_check_permissions" => ControllerSchema { + namespace: "channels", + function: "discord_check_permissions", + description: "Check bot permissions in a Discord channel.", + inputs: vec![ + required_string("guildId", "The Discord guild (server) ID."), + required_string("channelId", "The Discord channel ID to check."), + ], + outputs: vec![json_output("permissions", "Permission check result with flags and missing permissions.")], + }, "send_message" => ControllerSchema { namespace: "channels", function: "send_message", @@ -437,6 +489,31 @@ fn handle_telegram_login_check(params: Map) -> ControllerFuture { }) } +fn handle_discord_list_guilds(_params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + to_json(ops::discord_list_guilds(&config).await?) + }) +} + +fn handle_discord_list_channels(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let p = deserialize_params::(params)?; + to_json(ops::discord_list_channels(&config, p.guild_id.trim()).await?) + }) +} + +fn handle_discord_check_permissions(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let p = deserialize_params::(params)?; + to_json( + ops::discord_check_permissions(&config, p.guild_id.trim(), p.channel_id.trim()).await?, + ) + }) +} + fn handle_send_message(params: Map) -> ControllerFuture { Box::pin(async move { let config = config_rpc::load_config_with_timeout().await?; diff --git a/src/openhuman/channels/providers/discord/api.rs b/src/openhuman/channels/providers/discord/api.rs new file mode 100644 index 000000000..4aa849ad6 --- /dev/null +++ b/src/openhuman/channels/providers/discord/api.rs @@ -0,0 +1,343 @@ +//! Discord REST API helpers for guild/channel discovery and permission checks. + +use serde::{Deserialize, Serialize}; + +const DISCORD_API_BASE: &str = "https://discord.com/api/v10"; + +/// Minimal guild (server) info returned by `GET /users/@me/guilds`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscordGuild { + pub id: String, + pub name: String, + pub icon: Option, +} + +/// Minimal channel info returned by `GET /guilds/{guild_id}/channels`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscordTextChannel { + pub id: String, + pub name: String, + /// Discord channel type — 0 = text, 2 = voice, 4 = category, etc. + #[serde(rename = "type")] + pub channel_type: u64, + #[serde(default)] + pub position: u64, + /// Parent category ID (if nested under a category). + pub parent_id: Option, +} + +/// Result of a bot permission check for a given channel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BotPermissionCheck { + pub can_view_channel: bool, + pub can_send_messages: bool, + pub can_read_message_history: bool, + pub missing_permissions: Vec, +} + +// Discord permission flag bits +const VIEW_CHANNEL: u64 = 1 << 10; // 0x400 +const SEND_MESSAGES: u64 = 1 << 11; // 0x800 +const READ_MESSAGE_HISTORY: u64 = 1 << 16; // 0x10000 + +fn build_client() -> reqwest::Client { + crate::openhuman::config::build_runtime_proxy_client("channel.discord") +} + +fn auth_header(token: &str) -> String { + format!("Bot {token}") +} + +/// List all guilds (servers) the bot is a member of. +pub async fn list_bot_guilds(token: &str) -> anyhow::Result> { + let url = format!("{DISCORD_API_BASE}/users/@me/guilds"); + tracing::debug!("[discord-api] listing guilds for bot"); + + let resp = build_client() + .get(&url) + .header("Authorization", auth_header(token)) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Discord list guilds failed ({status}): {body}"); + } + + let guilds: Vec = resp.json().await?; + tracing::debug!("[discord-api] found {} guilds", guilds.len()); + Ok(guilds) +} + +/// List text channels in a guild. Filters to type=0 (text channels) only. +pub async fn list_guild_channels( + token: &str, + guild_id: &str, +) -> anyhow::Result> { + let url = format!("{DISCORD_API_BASE}/guilds/{guild_id}/channels"); + tracing::debug!("[discord-api] listing channels for guild {guild_id}"); + + let resp = build_client() + .get(&url) + .header("Authorization", auth_header(token)) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Discord list channels failed ({status}): {body}"); + } + + let all_channels: Vec = resp.json().await?; + + // Filter to text channels (type 0) and sort by position + let mut text_channels: Vec = all_channels + .into_iter() + .filter(|c| c.channel_type == 0) + .collect(); + text_channels.sort_by_key(|c| c.position); + + tracing::debug!( + "[discord-api] found {} text channels in guild {guild_id}", + text_channels.len() + ); + Ok(text_channels) +} + +/// Check bot permissions in a specific channel. +/// +/// Uses `GET /channels/{channel_id}` combined with the bot's guild member +/// permissions to determine if the bot can view, send, and read history. +pub async fn check_channel_permissions( + token: &str, + guild_id: &str, + channel_id: &str, +) -> anyhow::Result { + // Fetch the bot's guild member info which includes computed permissions + let url = format!("{DISCORD_API_BASE}/guilds/{guild_id}/members/@me"); + tracing::debug!( + "[discord-api] checking permissions in channel {channel_id} (guild {guild_id})" + ); + + let resp = build_client() + .get(&url) + .header("Authorization", auth_header(token)) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Discord get member info failed ({status}): {body}"); + } + + let member: serde_json::Value = resp.json().await?; + + // Fetch guild roles to compute permissions + let roles_url = format!("{DISCORD_API_BASE}/guilds/{guild_id}/roles"); + let roles_resp = build_client() + .get(&roles_url) + .header("Authorization", auth_header(token)) + .send() + .await?; + if !roles_resp.status().is_success() { + let status = roles_resp.status(); + let body = roles_resp.text().await.unwrap_or_default(); + anyhow::bail!("Discord get guild roles failed ({status}): {body}"); + } + let guild_roles: Vec = roles_resp.json().await?; + + // Get the member's role IDs + let member_role_ids: Vec<&str> = member + .get("roles") + .and_then(|r| r.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::>()) + .unwrap_or_default(); + + // Compute base permissions from @everyone role + member roles + let mut permissions: u64 = 0; + for role in &guild_roles { + let role_id = role.get("id").and_then(|i| i.as_str()).unwrap_or(""); + let is_everyone = role_id == guild_id; // @everyone role ID == guild ID + let is_member_role = member_role_ids.contains(&role_id); + + if is_everyone || is_member_role { + if let Some(perms_str) = role.get("permissions").and_then(|p| p.as_str()) { + if let Ok(perms) = perms_str.parse::() { + permissions |= perms; + } + } + } + } + + // Administrator bypasses all permission checks + const ADMINISTRATOR: u64 = 1 << 3; + if permissions & ADMINISTRATOR != 0 { + return Ok(BotPermissionCheck { + can_view_channel: true, + can_send_messages: true, + can_read_message_history: true, + missing_permissions: vec![], + }); + } + + // Now check channel-level permission overwrites + let channel_url = format!("{DISCORD_API_BASE}/channels/{channel_id}"); + let ch_resp = build_client() + .get(&channel_url) + .header("Authorization", auth_header(token)) + .send() + .await?; + if !ch_resp.status().is_success() { + let status = ch_resp.status(); + let body = ch_resp.text().await.unwrap_or_default(); + anyhow::bail!("Discord get channel failed ({status}): {body}"); + } + let channel_data: serde_json::Value = ch_resp.json().await?; + if let Some(overwrites) = channel_data + .get("permission_overwrites") + .and_then(|o| o.as_array()) + { + let bot_user_id = member + .get("user") + .and_then(|u| u.get("id")) + .and_then(|i| i.as_str()) + .unwrap_or(""); + + let mut everyone_allow = 0_u64; + let mut everyone_deny = 0_u64; + let mut role_allow = 0_u64; + let mut role_deny = 0_u64; + let mut member_allow = 0_u64; + let mut member_deny = 0_u64; + + for overwrite in overwrites { + let ow_id = overwrite.get("id").and_then(|i| i.as_str()).unwrap_or(""); + let ow_type = overwrite.get("type").and_then(|t| t.as_u64()).unwrap_or(0); + let allow = overwrite + .get("allow") + .and_then(|a| a.as_str()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let deny = overwrite + .get("deny") + .and_then(|d| d.as_str()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + match ow_type { + // @everyone overwrite (role id == guild id) + 0 if ow_id == guild_id => { + everyone_allow = allow; + everyone_deny = deny; + } + // Aggregate all role overwrites + 0 if member_role_ids.contains(&ow_id) => { + role_allow |= allow; + role_deny |= deny; + } + // Member-specific overwrite + 1 if ow_id == bot_user_id => { + member_allow = allow; + member_deny = deny; + } + _ => {} + } + } + + // Apply Discord overwrite precedence: everyone -> roles -> member. + permissions &= !everyone_deny; + permissions |= everyone_allow; + permissions &= !role_deny; + permissions |= role_allow; + permissions &= !member_deny; + permissions |= member_allow; + } + + let can_view = permissions & VIEW_CHANNEL != 0; + let can_send = permissions & SEND_MESSAGES != 0; + let can_read_history = permissions & READ_MESSAGE_HISTORY != 0; + + let mut missing = Vec::new(); + if !can_view { + missing.push("VIEW_CHANNEL".to_string()); + } + if !can_send { + missing.push("SEND_MESSAGES".to_string()); + } + if !can_read_history { + missing.push("READ_MESSAGE_HISTORY".to_string()); + } + + tracing::debug!( + "[discord-api] permissions for channel {channel_id}: view={can_view}, send={can_send}, history={can_read_history}" + ); + + Ok(BotPermissionCheck { + can_view_channel: can_view, + can_send_messages: can_send, + can_read_message_history: can_read_history, + missing_permissions: missing, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn guild_deserializes() { + let json = r#"{"id":"123","name":"Test Server","icon":"abc123"}"#; + let guild: DiscordGuild = serde_json::from_str(json).unwrap(); + assert_eq!(guild.id, "123"); + assert_eq!(guild.name, "Test Server"); + assert_eq!(guild.icon, Some("abc123".to_string())); + } + + #[test] + fn guild_deserializes_without_icon() { + let json = r#"{"id":"456","name":"No Icon","icon":null}"#; + let guild: DiscordGuild = serde_json::from_str(json).unwrap(); + assert_eq!(guild.id, "456"); + assert!(guild.icon.is_none()); + } + + #[test] + fn text_channel_deserializes() { + let json = r#"{"id":"789","name":"general","type":0,"position":1,"parent_id":"100"}"#; + let ch: DiscordTextChannel = serde_json::from_str(json).unwrap(); + assert_eq!(ch.id, "789"); + assert_eq!(ch.name, "general"); + assert_eq!(ch.channel_type, 0); + assert_eq!(ch.position, 1); + assert_eq!(ch.parent_id, Some("100".to_string())); + } + + #[test] + fn text_channel_without_parent() { + let json = r#"{"id":"789","name":"general","type":0,"position":0,"parent_id":null}"#; + let ch: DiscordTextChannel = serde_json::from_str(json).unwrap(); + assert!(ch.parent_id.is_none()); + } + + #[test] + fn permission_check_serializes() { + let check = BotPermissionCheck { + can_view_channel: true, + can_send_messages: true, + can_read_message_history: false, + missing_permissions: vec!["READ_MESSAGE_HISTORY".to_string()], + }; + let json = serde_json::to_string(&check).unwrap(); + assert!(json.contains("READ_MESSAGE_HISTORY")); + } + + #[test] + fn permission_bits_are_correct() { + assert_eq!(VIEW_CHANNEL, 1024); + assert_eq!(SEND_MESSAGES, 2048); + assert_eq!(READ_MESSAGE_HISTORY, 65536); + } +} diff --git a/src/openhuman/channels/providers/discord.rs b/src/openhuman/channels/providers/discord/channel.rs similarity index 93% rename from src/openhuman/channels/providers/discord.rs rename to src/openhuman/channels/providers/discord/channel.rs index 52ba84675..b7e5c075f 100644 --- a/src/openhuman/channels/providers/discord.rs +++ b/src/openhuman/channels/providers/discord/channel.rs @@ -10,6 +10,7 @@ use uuid::Uuid; pub struct DiscordChannel { bot_token: String, guild_id: Option, + channel_id: Option, allowed_users: Vec, listen_to_bots: bool, mention_only: bool, @@ -20,6 +21,7 @@ impl DiscordChannel { pub fn new( bot_token: String, guild_id: Option, + channel_id: Option, allowed_users: Vec, listen_to_bots: bool, mention_only: bool, @@ -27,6 +29,7 @@ impl DiscordChannel { Self { bot_token, guild_id, + channel_id, allowed_users, listen_to_bots, mention_only, @@ -295,6 +298,7 @@ impl Channel for DiscordChannel { }); let guild_filter = self.guild_id.clone(); + let channel_filter = self.channel_id.clone(); loop { tokio::select! { @@ -385,6 +389,14 @@ impl Channel for DiscordChannel { } } + // Channel filter — only process messages from the configured channel + if let Some(ref cid) = channel_filter { + let msg_channel = d.get("channel_id").and_then(serde_json::Value::as_str).unwrap_or(""); + if msg_channel != cid { + continue; + } + } + let content = d.get("content").and_then(|c| c.as_str()).unwrap_or(""); let Some(clean_content) = normalize_incoming_content(content, self.mention_only, &bot_user_id) @@ -397,7 +409,7 @@ impl Channel for DiscordChannel { let channel_msg = ChannelMessage { id: if message_id.is_empty() { - Uuid::new_v4().to_string() + format!("discord_{}", Uuid::new_v4()) } else { format!("discord_{message_id}") }, @@ -476,7 +488,7 @@ mod tests { #[test] fn discord_channel_name() { - let ch = DiscordChannel::new("fake".into(), None, vec![], false, false); + let ch = DiscordChannel::new("fake".into(), None, None, vec![], false, false); assert_eq!(ch.name(), "discord"); } @@ -497,14 +509,14 @@ mod tests { #[test] fn empty_allowlist_denies_everyone() { - let ch = DiscordChannel::new("fake".into(), None, vec![], false, false); + let ch = DiscordChannel::new("fake".into(), None, None, vec![], false, false); assert!(!ch.is_user_allowed("12345")); assert!(!ch.is_user_allowed("anyone")); } #[test] fn wildcard_allows_everyone() { - let ch = DiscordChannel::new("fake".into(), None, vec!["*".into()], false, false); + let ch = DiscordChannel::new("fake".into(), None, None, vec!["*".into()], false, false); assert!(ch.is_user_allowed("12345")); assert!(ch.is_user_allowed("anyone")); } @@ -514,6 +526,7 @@ mod tests { let ch = DiscordChannel::new( "fake".into(), None, + None, vec!["111".into(), "222".into()], false, false, @@ -526,7 +539,7 @@ mod tests { #[test] fn allowlist_is_exact_match_not_substring() { - let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()], false, false); + let ch = DiscordChannel::new("fake".into(), None, None, vec!["111".into()], false, false); assert!(!ch.is_user_allowed("1111")); assert!(!ch.is_user_allowed("11")); assert!(!ch.is_user_allowed("0111")); @@ -534,7 +547,7 @@ mod tests { #[test] fn allowlist_empty_string_user_id() { - let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()], false, false); + let ch = DiscordChannel::new("fake".into(), None, None, vec!["111".into()], false, false); assert!(!ch.is_user_allowed("")); } @@ -543,6 +556,7 @@ mod tests { let ch = DiscordChannel::new( "fake".into(), None, + None, vec!["111".into(), "*".into()], false, false, @@ -553,7 +567,7 @@ mod tests { #[test] fn allowlist_case_sensitive() { - let ch = DiscordChannel::new("fake".into(), None, vec!["ABC".into()], false, false); + let ch = DiscordChannel::new("fake".into(), None, None, vec!["ABC".into()], false, false); assert!(ch.is_user_allowed("ABC")); assert!(!ch.is_user_allowed("abc")); assert!(!ch.is_user_allowed("Abc")); @@ -753,14 +767,14 @@ mod tests { #[test] fn typing_handle_starts_as_none() { - let ch = DiscordChannel::new("fake".into(), None, vec![], false, false); + let ch = DiscordChannel::new("fake".into(), None, None, vec![], false, false); let guard = ch.typing_handle.lock(); assert!(guard.is_none()); } #[tokio::test] async fn start_typing_sets_handle() { - let ch = DiscordChannel::new("fake".into(), None, vec![], false, false); + let ch = DiscordChannel::new("fake".into(), None, None, vec![], false, false); let _ = ch.start_typing("123456").await; let guard = ch.typing_handle.lock(); assert!(guard.is_some()); @@ -768,7 +782,7 @@ mod tests { #[tokio::test] async fn stop_typing_clears_handle() { - let ch = DiscordChannel::new("fake".into(), None, vec![], false, false); + let ch = DiscordChannel::new("fake".into(), None, None, vec![], false, false); let _ = ch.start_typing("123456").await; let _ = ch.stop_typing("123456").await; let guard = ch.typing_handle.lock(); @@ -777,14 +791,14 @@ mod tests { #[tokio::test] async fn stop_typing_is_idempotent() { - let ch = DiscordChannel::new("fake".into(), None, vec![], false, false); + let ch = DiscordChannel::new("fake".into(), None, None, vec![], false, false); assert!(ch.stop_typing("123456").await.is_ok()); assert!(ch.stop_typing("123456").await.is_ok()); } #[tokio::test] async fn start_typing_replaces_existing_task() { - let ch = DiscordChannel::new("fake".into(), None, vec![], false, false); + let ch = DiscordChannel::new("fake".into(), None, None, vec![], false, false); let _ = ch.start_typing("111").await; let _ = ch.start_typing("222").await; let guard = ch.typing_handle.lock(); @@ -950,4 +964,26 @@ mod tests { assert!(part.len() <= DISCORD_MAX_MESSAGE_LENGTH); } } + + // ── channel_id field tests ─────────────────────────────────── + + #[test] + fn channel_id_stored_in_struct() { + let ch = DiscordChannel::new( + "token".into(), + Some("guild1".into()), + Some("channel1".into()), + vec![], + false, + false, + ); + assert_eq!(ch.channel_id.as_deref(), Some("channel1")); + assert_eq!(ch.guild_id.as_deref(), Some("guild1")); + } + + #[test] + fn channel_id_defaults_to_none() { + let ch = DiscordChannel::new("token".into(), None, None, vec![], false, false); + assert!(ch.channel_id.is_none()); + } } diff --git a/src/openhuman/channels/providers/discord/mod.rs b/src/openhuman/channels/providers/discord/mod.rs new file mode 100644 index 000000000..4f87d61dc --- /dev/null +++ b/src/openhuman/channels/providers/discord/mod.rs @@ -0,0 +1,4 @@ +pub mod api; +pub mod channel; + +pub use channel::DiscordChannel; diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index ec526b106..88c9f4ef6 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -201,6 +201,7 @@ pub async fn start_channels(config: Config) -> Result<()> { channels.push(Arc::new(DiscordChannel::new( dc.bot_token.clone(), dc.guild_id.clone(), + dc.channel_id.clone(), dc.allowed_users.clone(), dc.listen_to_bots, dc.mention_only, diff --git a/src/openhuman/config/mod.rs b/src/openhuman/config/mod.rs index 407f61ffe..047e4037c 100644 --- a/src/openhuman/config/mod.rs +++ b/src/openhuman/config/mod.rs @@ -67,6 +67,7 @@ mod tests { let discord = DiscordConfig { bot_token: "token".into(), guild_id: Some("123".into()), + channel_id: None, allowed_users: vec![], listen_to_bots: false, mention_only: false, diff --git a/src/openhuman/config/schema/channels.rs b/src/openhuman/config/schema/channels.rs index ecff0629d..ce62503ac 100644 --- a/src/openhuman/config/schema/channels.rs +++ b/src/openhuman/config/schema/channels.rs @@ -82,6 +82,7 @@ pub struct TelegramConfig { pub struct DiscordConfig { pub bot_token: String, pub guild_id: Option, + pub channel_id: Option, #[serde(default)] pub allowed_users: Vec, #[serde(default)] @@ -372,3 +373,51 @@ pub struct QQConfig { #[serde(default)] pub allowed_users: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discord_config_deserializes_with_channel_id() { + let toml = r#" + bot_token = "test-token" + guild_id = "123" + channel_id = "456" + "#; + let config: DiscordConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.bot_token, "test-token"); + assert_eq!(config.guild_id.as_deref(), Some("123")); + assert_eq!(config.channel_id.as_deref(), Some("456")); + } + + #[test] + fn discord_config_deserializes_without_channel_id() { + let toml = r#" + bot_token = "test-token" + "#; + let config: DiscordConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.bot_token, "test-token"); + assert!(config.guild_id.is_none()); + assert!(config.channel_id.is_none()); + assert!(config.allowed_users.is_empty()); + assert!(!config.listen_to_bots); + assert!(!config.mention_only); + } + + #[test] + fn discord_config_roundtrip_json() { + let config = DiscordConfig { + bot_token: "tok".into(), + guild_id: Some("g1".into()), + channel_id: Some("c1".into()), + allowed_users: vec!["user1".into()], + listen_to_bots: true, + mention_only: false, + }; + let json = serde_json::to_string(&config).unwrap(); + let restored: DiscordConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.channel_id.as_deref(), Some("c1")); + assert_eq!(restored.allowed_users, vec!["user1"]); + } +} diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index a7e636709..5d6a82d93 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -277,6 +277,7 @@ async fn deliver_if_configured(config: &Config, job: &CronJob, output: &str) -> let channel = DiscordChannel::new( dc.bot_token.clone(), dc.guild_id.clone(), + dc.channel_id.clone(), dc.allowed_users.clone(), dc.listen_to_bots, dc.mention_only,