use std::{
collections::{HashMap, hash_map::DefaultHasher},
hash::{Hash, Hasher},
sync::{Arc, Mutex, RwLock},
};
mod lifecycle;
mod rest_actions;
use crate::config::{MicrophoneSensitivityDb, VoiceVolumePercent};
use crate::discord::ids::{
Id,
marker::{ChannelMarker, GuildMarker, UserMarker},
};
use reqwest::header::HeaderValue;
use tokio::{
sync::{Mutex as AsyncMutex, mpsc, watch},
task::JoinHandle,
};
use crate::{AppError, Result};
use super::{
ActivityInfo, ApplicationCommandInfo, ApplicationCommandInvocation, PresenceStatus,
application_commands::application_command_interaction_from_invocation,
events::{AppEvent, SequencedAppEvent},
gateway::{GatewayCommand, GatewayRuntime, run_gateway},
request_lifecycle::RequestLifecycle,
rest::DiscordRest,
state::{CurrentVoiceConnectionState, DiscordSnapshot, DiscordState, SnapshotRevision},
voice::{self, VoiceRuntimeEvent},
};
const MEMBER_SEARCH_MIN_QUERY_CHARS: usize = 2;
const MEMBER_SEARCH_MAX_QUERY_CHARS: usize = 64;
const MEMBER_SEARCH_MAX_LIMIT: u16 = 10;
const OFFICIAL_WORDLE_APPLICATION_ID: u64 = 1_211_781_489_931_452_447;
const DISCORD_LOCAL_APPLICATION_ID: &str = "-1";
const GATEWAY_COMMAND_CHANNEL_CLOSED: &str = "gateway command channel closed";
type ApplicationCommandCache = HashMap<Option<Id<GuildMarker>>, Vec<ApplicationCommandInfo>>;
type MemberListRange = (u32, u32);
type MemberListSubscriptionRequest = (
Id<GuildMarker>,
Id<ChannelMarker>,
u32,
Vec<MemberListRange>,
);
type DueMemberListSubscription = (Id<GuildMarker>, Id<ChannelMarker>, Vec<MemberListRange>);
#[derive(Clone, Debug)]
pub struct DiscordClient {
token: String,
rest: DiscordRest,
effects_tx: mpsc::Sender<SequencedAppEvent>,
effects_rx: Arc<Mutex<Option<mpsc::Receiver<SequencedAppEvent>>>>,
snapshots_tx: watch::Sender<SnapshotRevision>,
state: Arc<RwLock<DiscordState>>,
requested_voice: Arc<RwLock<Option<CurrentVoiceConnectionState>>>,
gateway_session_id: Arc<RwLock<Option<String>>>,
application_command_requests: Arc<Mutex<HashMap<Option<Id<GuildMarker>>, RequestState>>>,
application_commands: Arc<Mutex<ApplicationCommandCache>>,
request_lifecycle: Arc<Mutex<RequestLifecycle>>,
revision: Arc<RwLock<SnapshotRevision>>,
publish_lock: Arc<AsyncMutex<()>>,
gateway_commands_tx: mpsc::UnboundedSender<GatewayCommand>,
gateway_commands_rx: Arc<Mutex<Option<mpsc::UnboundedReceiver<GatewayCommand>>>>,
voice_events_tx: mpsc::UnboundedSender<VoiceRuntimeEvent>,
voice_events_rx: Arc<Mutex<Option<mpsc::UnboundedReceiver<VoiceRuntimeEvent>>>>,
}
impl DiscordClient {
pub fn new(token: String) -> Result<Self> {
validate_token_header(&token)?;
let rest = DiscordRest::new(token.clone());
let initial_state = DiscordState::default();
let (effects_tx, effects_rx) = mpsc::channel(4096);
let (snapshots_tx, _) = watch::channel(SnapshotRevision::default());
let (gateway_commands_tx, gateway_commands_rx) = mpsc::unbounded_channel();
let (voice_events_tx, voice_events_rx) = mpsc::unbounded_channel();
Ok(Self {
token,
rest,
effects_tx,
effects_rx: Arc::new(Mutex::new(Some(effects_rx))),
snapshots_tx,
state: Arc::new(RwLock::new(initial_state)),
requested_voice: Arc::new(RwLock::new(None)),
gateway_session_id: Arc::new(RwLock::new(None)),
application_command_requests: Arc::new(Mutex::new(HashMap::new())),
application_commands: Arc::new(Mutex::new(HashMap::new())),
request_lifecycle: Arc::new(Mutex::new(RequestLifecycle::default())),
revision: Arc::new(RwLock::new(SnapshotRevision::default())),
publish_lock: Arc::new(AsyncMutex::new(())),
gateway_commands_tx,
gateway_commands_rx: Arc::new(Mutex::new(Some(gateway_commands_rx))),
voice_events_tx,
voice_events_rx: Arc::new(Mutex::new(Some(voice_events_rx))),
})
}
pub fn take_effects(&self) -> mpsc::Receiver<SequencedAppEvent> {
self.effects_rx
.lock()
.expect("effect receiver mutex is not poisoned")
.take()
.expect("effect stream can only be taken once")
}
pub fn subscribe_snapshots(&self) -> watch::Receiver<SnapshotRevision> {
self.snapshots_tx.subscribe()
}
pub fn current_discord_snapshot(&self) -> DiscordSnapshot {
let state = self
.state
.read()
.expect("discord state lock is not poisoned");
let revision = *self
.revision
.read()
.expect("snapshot revision lock is not poisoned");
state.snapshot(revision)
}
pub fn current_user_id(&self) -> Option<Id<UserMarker>> {
self.state
.read()
.expect("discord state lock is not poisoned")
.current_user_id()
}
pub async fn publish_event(&self, event: AppEvent) {
self.record_request_lifecycle_event(&event);
publish_app_event(
&self.effects_tx,
&self.snapshots_tx,
&self.state,
&self.revision,
&self.publish_lock,
&event,
)
.await;
voice::forward_app_event(&self.voice_events_tx, &event);
}
pub fn start_gateway(&self) -> JoinHandle<()> {
let token = self.token.clone();
let effects_tx = self.effects_tx.clone();
let snapshots_tx = self.snapshots_tx.clone();
let state = Arc::clone(&self.state);
let revision = Arc::clone(&self.revision);
let gateway_session_id = Arc::clone(&self.gateway_session_id);
let publish_lock = Arc::clone(&self.publish_lock);
let gateway_commands = self
.gateway_commands_rx
.lock()
.expect("gateway command receiver mutex is not poisoned")
.take()
.expect("gateway can only be started once");
let voice_events_tx = self.voice_events_tx.clone();
let voice_status_publisher = voice::VoiceStatusPublisher::new(
self.effects_tx.clone(),
self.snapshots_tx.clone(),
Arc::clone(&self.state),
Arc::clone(&self.revision),
Arc::clone(&self.publish_lock),
);
if let Some(voice_events) = self
.voice_events_rx
.lock()
.expect("voice event receiver mutex is not poisoned")
.take()
{
tokio::spawn(voice::run_voice_runtime(
voice_events,
voice_events_tx.clone(),
voice_status_publisher,
));
}
tokio::spawn(async move {
let runtime = GatewayRuntime {
effects_tx,
snapshots_tx,
state,
revision,
gateway_session_id,
publish_lock,
voice_events_tx,
};
run_gateway(token, gateway_commands, runtime).await;
})
}
pub fn request_guild_members(
&self,
guild_id: Id<GuildMarker>,
) -> std::result::Result<(), String> {
self.send_gateway_command(GatewayCommand::RequestGuildMembers {
guild_id,
query: String::new(),
limit: 0,
presences: true,
nonce: None,
})
}
pub fn request_guild_members_by_ids(
&self,
guild_id: Id<GuildMarker>,
user_ids: Vec<Id<UserMarker>>,
) -> std::result::Result<(), String> {
if user_ids.is_empty() {
return Ok(());
}
self.send_gateway_command(GatewayCommand::RequestGuildMembersByIds {
guild_id,
user_ids,
presences: false,
})
}
pub fn search_guild_members(
&self,
guild_id: Id<GuildMarker>,
query: String,
limit: u16,
) -> std::result::Result<(), String> {
let Some(query) = normalize_member_search_query(&query) else {
return Ok(());
};
let limit = limit.min(MEMBER_SEARCH_MAX_LIMIT);
let nonce = format!("mention-ac-{}-{:016x}", guild_id.get(), query_hash(&query));
self.send_gateway_command(GatewayCommand::RequestGuildMembers {
guild_id,
query,
limit,
presences: true,
nonce: Some(nonce),
})
}
pub fn subscribe_direct_message(
&self,
channel_id: Id<ChannelMarker>,
) -> std::result::Result<(), String> {
self.send_gateway_command(GatewayCommand::SubscribeDirectMessage { channel_id })
}
pub fn subscribe_guild_channel(
&self,
guild_id: Id<GuildMarker>,
channel_id: Id<ChannelMarker>,
) -> std::result::Result<(), String> {
self.send_gateway_command(GatewayCommand::SubscribeGuildChannel {
guild_id,
channel_id,
})
}
pub fn update_member_list_subscription(
&self,
guild_id: Id<GuildMarker>,
channel_id: Id<ChannelMarker>,
ranges: Vec<(u32, u32)>,
) -> std::result::Result<(), String> {
self.send_gateway_command(GatewayCommand::UpdateMemberListSubscription {
guild_id,
channel_id,
ranges,
})
}
pub fn update_voice_state(
&self,
guild_id: Id<GuildMarker>,
channel_id: Option<Id<ChannelMarker>>,
self_mute: bool,
self_deaf: bool,
) -> std::result::Result<(), String> {
let mut requested = self
.requested_voice
.write()
.expect("requested voice lock is not poisoned");
if voice_state_request_is_duplicate(*requested, guild_id, channel_id, self_mute, self_deaf)
{
return Ok(());
}
if let Some(channel_id) = channel_id {
let requested_same_channel = requested
.filter(|voice| voice.guild_id == guild_id && voice.channel_id == channel_id)
.is_some();
if !requested_same_channel {
let state = self
.state
.read()
.expect("discord state lock is not poisoned");
let current_same_channel = state
.current_user_voice_connection()
.filter(|voice| voice.guild_id == guild_id && voice.channel_id == channel_id)
.is_some();
if !current_same_channel
&& let Some(channel) = state.channel(channel_id)
&& !state.can_connect_voice_channel(channel)
{
return Err("cannot connect to voice channel".to_owned());
}
}
}
let result = self.send_gateway_command(GatewayCommand::UpdateVoiceState {
guild_id,
channel_id,
self_mute,
self_deaf,
});
if result.is_ok() {
if let Some(channel_id) = channel_id {
let allow_microphone_transmit = requested
.filter(|voice| voice.guild_id == guild_id && voice.channel_id == channel_id)
.is_some_and(|voice| voice.allow_microphone_transmit);
let microphone_sensitivity = requested
.filter(|voice| voice.guild_id == guild_id && voice.channel_id == channel_id)
.map(|voice| voice.microphone_sensitivity)
.unwrap_or_default();
let microphone_volume = requested
.filter(|voice| voice.guild_id == guild_id && voice.channel_id == channel_id)
.map(|voice| voice.microphone_volume)
.unwrap_or_default();
let voice_output_volume = requested
.filter(|voice| voice.guild_id == guild_id && voice.channel_id == channel_id)
.map(|voice| voice.voice_output_volume)
.unwrap_or_default();
let voice = CurrentVoiceConnectionState {
guild_id,
channel_id,
self_mute,
self_deaf,
allow_microphone_transmit,
microphone_sensitivity,
microphone_volume,
voice_output_volume,
};
*requested = Some(voice);
let _ = self
.voice_events_tx
.send(VoiceRuntimeEvent::Requested(Some(voice)));
} else if requested.is_some_and(|voice| voice.guild_id == guild_id) {
*requested = None;
let _ = self
.voice_events_tx
.send(VoiceRuntimeEvent::Requested(None));
}
}
result
}
pub fn update_voice_capture_permission(
&self,
guild_id: Id<GuildMarker>,
channel_id: Id<ChannelMarker>,
allow_microphone_transmit: bool,
microphone_sensitivity: MicrophoneSensitivityDb,
microphone_volume: VoiceVolumePercent,
voice_output_volume: VoiceVolumePercent,
) {
let mut requested = self
.requested_voice
.write()
.expect("requested voice lock is not poisoned");
let Some(mut voice) = *requested else {
return;
};
if voice.guild_id != guild_id || voice.channel_id != channel_id {
return;
}
if voice.allow_microphone_transmit == allow_microphone_transmit
&& voice.microphone_sensitivity == microphone_sensitivity
&& voice.microphone_volume == microphone_volume
&& voice.voice_output_volume == voice_output_volume
{
return;
}
voice.allow_microphone_transmit = allow_microphone_transmit;
voice.microphone_sensitivity = microphone_sensitivity;
voice.microphone_volume = microphone_volume;
voice.voice_output_volume = voice_output_volume;
*requested = Some(voice);
let _ = self
.voice_events_tx
.send(VoiceRuntimeEvent::Requested(Some(voice)));
}
pub async fn update_presence_status(
&self,
status: PresenceStatus,
) -> Result<Vec<ActivityInfo>> {
let activities = self.current_user_activities();
self.rest.update_current_user_status(status).await?;
self.send_presence_update(status, activities.clone())?;
Ok(activities)
}
pub fn current_user_activities(&self) -> Vec<ActivityInfo> {
let state = self
.state
.read()
.expect("discord state lock is not poisoned");
state
.current_user_id()
.map(|user_id| state.user_activities(user_id).to_vec())
.unwrap_or_default()
}
pub fn update_presence_activity(
&self,
status: PresenceStatus,
activities: Vec<ActivityInfo>,
) -> Result<()> {
self.send_presence_update(status, activities)
}
fn send_presence_update(
&self,
status: PresenceStatus,
activities: Vec<ActivityInfo>,
) -> Result<()> {
self.send_gateway_command(GatewayCommand::UpdatePresence { status, activities })
.map_err(AppError::DiscordRequest)?;
Ok(())
}
pub fn current_or_requested_voice_connection(&self) -> Option<CurrentVoiceConnectionState> {
self.state
.read()
.expect("discord state lock is not poisoned")
.current_user_voice_connection()
.or_else(|| {
*self
.requested_voice
.read()
.expect("requested voice lock is not poisoned")
})
}
pub fn requested_voice_connection(&self) -> Option<CurrentVoiceConnectionState> {
*self
.requested_voice
.read()
.expect("requested voice lock is not poisoned")
}
pub fn shutdown_gateway(&self) -> std::result::Result<(), String> {
let _ = self.voice_events_tx.send(VoiceRuntimeEvent::Shutdown);
self.send_gateway_command(GatewayCommand::Shutdown)
}
pub async fn load_application_commands(
&self,
guild_id: Option<Id<GuildMarker>>,
) -> Result<Option<Vec<ApplicationCommandInfo>>> {
if !self.begin_application_command_request(guild_id) {
return Ok(None);
}
let result = self.rest.load_application_commands(guild_id).await;
match result {
Ok(commands) => Ok(Some(
self.record_application_commands_for_tui(guild_id, commands),
)),
Err(error) => {
self.clear_application_command_request(guild_id);
Err(error)
}
}
}
pub async fn run_application_command(
&self,
invocation: &ApplicationCommandInvocation,
) -> Result<()> {
let session_id = self
.gateway_session_id
.read()
.expect("gateway session id lock is not poisoned")
.clone()
.ok_or_else(|| AppError::DiscordRequest("gateway session is not ready".to_owned()))?;
let interaction = self.application_command_interaction(invocation)?;
self.rest
.run_application_command(&interaction, &session_id)
.await
}
fn application_command_interaction(
&self,
invocation: &ApplicationCommandInvocation,
) -> Result<super::ApplicationCommandInteraction> {
let commands = self
.application_commands
.lock()
.expect("application command cache lock is not poisoned");
let command = commands
.get(&invocation.guild_id)
.and_then(|commands| match invocation.command_identity {
Some(identity) => commands
.iter()
.find(|command| command.identity() == identity),
None => commands
.iter()
.find(|command| command.name == invocation.command_name),
})
.ok_or_else(|| {
AppError::DiscordRequest(format!(
"application command {} is not loaded",
invocation.command_name
))
})?;
application_command_interaction_from_invocation(invocation, command).ok_or_else(|| {
AppError::DiscordRequest(format!(
"application command {} options are incomplete or invalid",
invocation.command_name
))
})
}
fn send_gateway_command(&self, command: GatewayCommand) -> std::result::Result<(), String> {
self.gateway_commands_tx
.send(command)
.map_err(|_| GATEWAY_COMMAND_CHANNEL_CLOSED.to_owned())
}
}
pub(super) async fn publish_app_event(
effects_tx: &mpsc::Sender<SequencedAppEvent>,
snapshots_tx: &watch::Sender<SnapshotRevision>,
state: &Arc<RwLock<DiscordState>>,
revision: &Arc<RwLock<SnapshotRevision>>,
publish_lock: &Arc<AsyncMutex<()>>,
event: &AppEvent,
) {
let mutates_state = event.mutates_discord_state();
let needs_effect_delivery = event.needs_effect_delivery();
let voice_sound = {
let state = state.read().expect("discord state lock is not poisoned");
match event {
AppEvent::VoiceStateUpdate { state: voice_state } => {
state.voice_sound_for_state_update(voice_state)
}
_ => None,
}
};
let event_revision: SnapshotRevision;
{
let _publish_guard = publish_lock.lock().await;
event_revision = if mutates_state {
let next_revision = {
let mut state = state.write().expect("discord state lock is not poisoned");
let detail_revision_before = matches!(event, AppEvent::MessageCreate { .. })
.then(|| state.detail_revision_signature());
state.apply_event(event);
let mut revision = revision
.write()
.expect("snapshot revision lock is not poisoned");
if let Some(mut areas) = DiscordState::snapshot_areas_for_event(event) {
if let Some(before) = detail_revision_before {
areas.detail = state.detail_revision_signature() != before;
}
*revision = revision.advance(areas);
}
*revision
};
let _ = snapshots_tx.send(next_revision);
next_revision
} else {
*revision
.read()
.expect("snapshot revision lock is not poisoned")
};
if needs_effect_delivery {
let _ = effects_tx
.send(SequencedAppEvent {
revision: event_revision.global,
event: event.clone(),
})
.await;
}
if let Some(kind) = voice_sound {
let _ = effects_tx
.send(SequencedAppEvent {
revision: event_revision.global,
event: AppEvent::VoiceSound { kind },
})
.await;
}
}
}
pub(crate) fn validate_token_header(token: &str) -> Result<()> {
HeaderValue::from_str(token)
.map_err(|source| AppError::InvalidDiscordTokenHeader { source })?;
Ok(())
}
fn voice_state_request_is_duplicate(
requested: Option<CurrentVoiceConnectionState>,
guild_id: Id<GuildMarker>,
channel_id: Option<Id<ChannelMarker>>,
self_mute: bool,
self_deaf: bool,
) -> bool {
match (requested, channel_id) {
(Some(voice), Some(channel_id)) => {
voice.guild_id == guild_id
&& voice.channel_id == channel_id
&& voice.self_mute == self_mute
&& voice.self_deaf == self_deaf
}
(Some(voice), None) => voice.guild_id != guild_id,
(None, None) => true,
(None, Some(_)) => false,
}
}
fn normalize_member_search_query(query: &str) -> Option<String> {
let mut normalized = String::new();
let mut count = 0usize;
for ch in query.trim().chars() {
for lowered in ch.to_lowercase() {
if count >= MEMBER_SEARCH_MAX_QUERY_CHARS {
return (normalized.chars().count() >= MEMBER_SEARCH_MIN_QUERY_CHARS)
.then_some(normalized);
}
normalized.push(lowered);
count += 1;
}
}
(normalized.chars().count() >= MEMBER_SEARCH_MIN_QUERY_CHARS).then_some(normalized)
}
fn query_hash(query: &str) -> u64 {
let mut hasher = DefaultHasher::new();
query.hash(&mut hasher);
hasher.finish()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RequestState {
Requested,
Loaded,
}
impl DiscordClient {
fn begin_application_command_request(&self, guild_id: Option<Id<GuildMarker>>) -> bool {
let mut requests = self
.application_command_requests
.lock()
.expect("application command request lock is not poisoned");
if requests.contains_key(&guild_id) {
return false;
}
requests.insert(guild_id, RequestState::Requested);
true
}
fn record_application_commands_loaded(&self, guild_id: Option<Id<GuildMarker>>) {
self.application_command_requests
.lock()
.expect("application command request lock is not poisoned")
.insert(guild_id, RequestState::Loaded);
}
fn record_application_commands(
&self,
guild_id: Option<Id<GuildMarker>>,
commands: Vec<ApplicationCommandInfo>,
) {
self.application_commands
.lock()
.expect("application command cache lock is not poisoned")
.insert(guild_id, commands);
}
fn record_application_commands_for_tui(
&self,
guild_id: Option<Id<GuildMarker>>,
commands: Vec<ApplicationCommandInfo>,
) -> Vec<ApplicationCommandInfo> {
let commands = commands
.into_iter()
.filter(|command| !is_hidden_default_application_command(command))
.collect::<Vec<_>>();
self.record_application_commands(guild_id, commands.clone());
commands
.into_iter()
.map(ApplicationCommandInfo::without_raw)
.collect()
}
fn clear_application_command_request(&self, guild_id: Option<Id<GuildMarker>>) {
self.application_command_requests
.lock()
.expect("application command request lock is not poisoned")
.remove(&guild_id);
}
}
fn is_hidden_default_application_command(command: &ApplicationCommandInfo) -> bool {
match command.name.as_str() {
"giphy" | "msg" | "play" => is_discord_default_application(command),
"wordle" => command.application_id.get() == OFFICIAL_WORDLE_APPLICATION_ID,
_ => false,
}
}
fn is_discord_default_application(command: &ApplicationCommandInfo) -> bool {
command
.raw
.get("application_id")
.and_then(|value| value.as_str())
.is_some_and(|id| id == DISCORD_LOCAL_APPLICATION_ID)
|| command
.application_name
.as_deref()
.is_some_and(|name| name.eq_ignore_ascii_case("Discord"))
}
#[cfg(test)]
mod tests;