mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 14:02:19 +00:00
* 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:
co-authored by
Claude Opus 4.6
parent
32c583aafd
commit
24b892439d
Generated
+1
-1
@@ -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 & 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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+48
-12
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user