feat(channels): Discord server/channel wiring and picker (#289) (#349)

* feat(channels): add channel_id to DiscordConfig schema

Add optional channel_id field to DiscordConfig for restricting the bot
to a specific Discord channel, matching the pattern used by SlackConfig
and MattermostConfig.

Refs: #289

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(channels): add channel_id field to Discord definition

Expose channel_id as an optional field in the Discord BotToken auth mode
so users can specify a default channel for outbound messages via the UI.

Refs: #289

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(channels): create Discord API helper module for guild/channel discovery

Refactor discord.rs into discord/ folder module and add api.rs with:
- list_bot_guilds: GET /users/@me/guilds
- list_guild_channels: GET /guilds/{id}/channels (filtered to text channels)
- check_channel_permissions: compute bot permissions from roles + overwrites

Includes unit tests for type serialization and permission bit constants.

Refs: #289

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(channels): add Discord RPC handlers for guild/channel discovery

Add three new RPC endpoints:
- openhuman.channels_discord_list_guilds: list servers the bot is in
- openhuman.channels_discord_list_channels: list text channels in a guild
- openhuman.channels_discord_check_permissions: validate bot permissions

These retrieve the stored Discord bot token from credentials and call
the Discord REST API directly from the Rust core.

Refs: #289

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(channels): wire channel_id into Discord provider for channel filtering

Add channel_id field to DiscordChannel struct and update all construction
sites. When channel_id is set, the listen loop only processes messages
from that specific channel, enabling server+channel-scoped operation.

Refs: #289

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(channels): add Discord guild/channel API types and RPC methods

Add TypeScript types for DiscordGuild, DiscordTextChannel, and
BotPermissionCheck. Wire up three new RPC methods in channelConnectionsApi
for listing guilds, listing channels, and checking bot permissions.

Refs: #289

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(channels): create DiscordServerChannelPicker component

Add server and channel selection UI that loads guilds and channels from
the Discord API via the Rust core RPC. Includes permission checking with
visual feedback for missing permissions.

Refs: #289

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(channels): integrate server/channel picker into DiscordConfig

Show DiscordServerChannelPicker below the connect buttons when the
bot_token connection is active. Guild and channel selections flow back
into the credential field values for persistence.

Refs: #289

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(channels): add unit tests for Discord channel_id and config serde

Add tests for:
- DiscordConfig TOML/JSON deserialization with and without channel_id
- channel_id field storage on DiscordChannel struct
- Config roundtrip serialization

Refs: #289

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(channels): add Vitest tests for DiscordServerChannelPicker

Test guild loading, rendering, and placeholder states with mocked
RPC responses. Verifies the component renders heading, loads guilds
from the mock, and shows the select placeholder.

Refs: #289

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): resolve discord module conflict and format drift

* fix(reviews): address CodeRabbit Discord picker and permission issues

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
oxoxDev
2026-04-06 17:35:55 +05:30
committed by GitHub
co-authored by Claude Opus 4.6
parent 32c583aafd
commit 24b892439d
17 changed files with 1013 additions and 13 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "OpenHuman"
version = "0.51.6"
version = "0.51.8"
dependencies = [
"env_logger",
"log",
@@ -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
</button>
</div>
{/* Server + Channel picker — shown after successful bot_token connection */}
{spec.mode === 'bot_token' && status === 'connected' && (
<DiscordServerChannelPicker
selectedGuildId={fieldValues[compositeKey]?.guild_id ?? ''}
selectedChannelId={fieldValues[compositeKey]?.channel_id ?? ''}
onGuildSelected={guildId => {
updateField(compositeKey, 'guild_id', guildId);
updateField(compositeKey, 'channel_id', '');
}}
onChannelSelected={channelId => updateField(compositeKey, 'channel_id', channelId)}
/>
)}
</div>
);
})}
@@ -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<PickerState>('idle');
const [guilds, setGuilds] = useState<DiscordGuild[]>([]);
const [channels, setChannels] = useState<DiscordTextChannel[]>([]);
const [selectedGuildId, setSelectedGuildId] = useState<string>(selectedGuildIdProp ?? '');
const [selectedChannelId, setSelectedChannelId] = useState<string>(selectedChannelIdProp ?? '');
const [permissions, setPermissions] = useState<BotPermissionCheck | null>(null);
const [error, setError] = useState<string | null>(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<Record<string, DiscordTextChannel[]>>((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 (
<div className="mt-3 space-y-3">
<p className="text-xs font-medium text-stone-600">Server &amp; Channel Selection</p>
{/* Error banner */}
{error && (
<div className="rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700">
{error}
</div>
)}
{/* Guild selector */}
<div>
<label htmlFor="discord-guild-select" className="block text-xs text-stone-500 mb-1">
Server
</label>
<select
id="discord-guild-select"
value={selectedGuildId}
onChange={e => handleGuildChange(e.target.value)}
disabled={isLoading || guilds.length === 0}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:border-primary-500 focus:outline-none disabled:opacity-50">
<option value="">
{state === 'loading_guilds'
? 'Loading servers...'
: guilds.length === 0
? 'No servers found'
: 'Select a server'}
</option>
{guilds.map(g => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
{guilds.length === 0 && state === 'guilds_loaded' && (
<p className="mt-1 text-xs text-stone-400">
The bot is not in any servers. Invite it using the Discord Developer Portal.
</p>
)}
</div>
{/* Channel selector */}
{selectedGuildId && (
<div>
<label htmlFor="discord-channel-select" className="block text-xs text-stone-500 mb-1">
Channel
</label>
<select
id="discord-channel-select"
value={selectedChannelId}
onChange={e => handleChannelChange(e.target.value)}
disabled={isLoading || channels.length === 0}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:border-primary-500 focus:outline-none disabled:opacity-50">
<option value="">
{state === 'loading_channels'
? 'Loading channels...'
: channels.length === 0
? 'No text channels found'
: 'Select a channel'}
</option>
{Object.entries(groupedChannels).map(([categoryId, chs]) => {
if (categoryId === '__uncategorized') {
return chs.map(ch => (
<option key={ch.id} value={ch.id}>
# {ch.name}
</option>
));
}
return (
<optgroup key={categoryId} label={`Category ${categoryId}`}>
{chs.map(ch => (
<option key={ch.id} value={ch.id}>
# {ch.name}
</option>
))}
</optgroup>
);
})}
</select>
</div>
)}
{/* Permission check result */}
{state === 'checking_permissions' && (
<div className="flex items-center gap-2 text-xs text-stone-500">
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-stone-300 border-t-primary-500" />
Checking permissions...
</div>
)}
{permissions && state === 'ready' && (
<div
className={`rounded-lg border px-3 py-2 text-xs ${
permissions.missing_permissions.length === 0
? 'border-sage-200 bg-sage-50 text-sage-700'
: 'border-amber-200 bg-amber-50 text-amber-700'
}`}>
{permissions.missing_permissions.length === 0 ? (
<span>Bot has all required permissions in this channel.</span>
) : (
<div>
<span className="font-medium">Missing permissions: </span>
{permissions.missing_permissions.join(', ')}
</div>
)}
</div>
)}
</div>
);
};
export default DiscordServerChannelPicker;
@@ -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(<DiscordServerChannelPicker />);
expect(screen.getByText('Server & Channel Selection')).toBeInTheDocument();
});
it('loads and displays guilds', async () => {
renderWithProviders(<DiscordServerChannelPicker />);
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(<DiscordServerChannelPicker />);
await waitFor(() => {
expect(screen.getByRole('combobox', { name: /server/i })).toBeInTheDocument();
});
});
});
@@ -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<DiscordGuild[]> => {
return callCoreRpc<DiscordGuild[]>({
method: 'openhuman.channels_discord_list_guilds',
params: {},
});
},
/** List text channels in a Discord server. */
listDiscordChannels: async (guildId: string): Promise<DiscordTextChannel[]> => {
return callCoreRpc<DiscordTextChannel[]>({
method: 'openhuman.channels_discord_list_channels',
params: { guildId },
});
},
/** Check bot permissions in a Discord channel. */
checkDiscordPermissions: async (
guildId: string,
channelId: string
): Promise<BotPermissionCheck> => {
return callCoreRpc<BotPermissionCheck>({
method: 'openhuman.channels_discord_check_permissions',
params: { guildId, channelId },
});
},
/** Placeholder for default channel preference sync. */
updatePreferences: async (defaultMessagingChannel: ChannelType): Promise<void> => {
void defaultMessagingChannel;
+23
View File
@@ -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[];
}
+1
View File
@@ -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,
@@ -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,
},
+83
View File
@@ -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<String, String> {
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<Vec<crate::openhuman::channels::providers::discord::api::DiscordGuild>>,
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<Vec<crate::openhuman::channels::providers::discord::api::DiscordTextChannel>>,
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<crate::openhuman::channels::providers::discord::api::BotPermissionCheck>,
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::*;
@@ -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<ControllerSchema> {
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<RegisteredController> {
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<String, Value>) -> ControllerFuture {
})
}
fn handle_discord_list_guilds(_params: Map<String, Value>) -> 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<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let p = deserialize_params::<DiscordListChannelsParams>(params)?;
to_json(ops::discord_list_channels(&config, p.guild_id.trim()).await?)
})
}
fn handle_discord_check_permissions(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let p = deserialize_params::<DiscordCheckPermissionsParams>(params)?;
to_json(
ops::discord_check_permissions(&config, p.guild_id.trim(), p.channel_id.trim()).await?,
)
})
}
fn handle_send_message(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
@@ -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<String>,
}
/// 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<String>,
}
/// 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<String>,
}
// 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<Vec<DiscordGuild>> {
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<DiscordGuild> = 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<Vec<DiscordTextChannel>> {
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<DiscordTextChannel> = resp.json().await?;
// Filter to text channels (type 0) and sort by position
let mut text_channels: Vec<DiscordTextChannel> = 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<BotPermissionCheck> {
// 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<serde_json::Value> = 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::<Vec<&str>>())
.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::<u64>() {
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::<u64>().ok())
.unwrap_or(0);
let deny = overwrite
.get("deny")
.and_then(|d| d.as_str())
.and_then(|s| s.parse::<u64>().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);
}
}
@@ -10,6 +10,7 @@ use uuid::Uuid;
pub struct DiscordChannel {
bot_token: String,
guild_id: Option<String>,
channel_id: Option<String>,
allowed_users: Vec<String>,
listen_to_bots: bool,
mention_only: bool,
@@ -20,6 +21,7 @@ impl DiscordChannel {
pub fn new(
bot_token: String,
guild_id: Option<String>,
channel_id: Option<String>,
allowed_users: Vec<String>,
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());
}
}
@@ -0,0 +1,4 @@
pub mod api;
pub mod channel;
pub use channel::DiscordChannel;
@@ -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,
+1
View File
@@ -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,
+49
View File
@@ -82,6 +82,7 @@ pub struct TelegramConfig {
pub struct DiscordConfig {
pub bot_token: String,
pub guild_id: Option<String>,
pub channel_id: Option<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
#[serde(default)]
@@ -372,3 +373,51 @@ pub struct QQConfig {
#[serde(default)]
pub allowed_users: Vec<String>,
}
#[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"]);
}
}
+1
View File
@@ -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,