Skip to main content

concord/discord/
commands.rs

1use std::{
2    io,
3    path::{Path, PathBuf},
4};
5
6use crate::discord::ids::{
7    Id,
8    marker::{ChannelMarker, EmojiMarker, GuildMarker, MessageMarker, UserMarker},
9};
10
11use super::application_commands::ApplicationCommandInvocation;
12use super::message::MessageInfo;
13use super::{ActivityInfo, PresenceStatus};
14
15pub const MAX_UPLOAD_FILE_BYTES: u64 = 10 * 1024 * 1024;
16pub const MAX_UPLOAD_TOTAL_BYTES: u64 = 25 * 1024 * 1024;
17pub const MAX_UPLOAD_ATTACHMENT_COUNT: usize = 10;
18pub const MAX_PROFILE_AVATAR_BYTES: u64 = 10 * 1024 * 1024;
19
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct MessageAttachmentUpload {
22    source: UploadSource,
23    pub filename: String,
24    pub size_bytes: u64,
25}
26
27#[derive(Clone, Debug, Default, Eq, PartialEq)]
28pub struct GlobalUserProfileUpdate {
29    pub display_name: Option<String>,
30    pub pronouns: Option<String>,
31    pub avatar: Option<ProfileAvatarUpload>,
32}
33
34impl GlobalUserProfileUpdate {
35    pub fn is_empty(&self) -> bool {
36        self.display_name.is_none() && self.pronouns.is_none() && self.avatar.is_none()
37    }
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct ProfileAvatarUpload {
42    source: UploadSource,
43    pub filename: String,
44    pub size_bytes: u64,
45}
46
47#[derive(Clone, Debug, Eq, PartialEq)]
48enum UploadSource {
49    File(PathBuf),
50    Bytes(Vec<u8>),
51}
52
53impl UploadSource {
54    fn path(&self) -> Option<&Path> {
55        match self {
56            Self::File(path) => Some(path),
57            Self::Bytes(_) => None,
58        }
59    }
60
61    fn bytes(&self) -> Option<&[u8]> {
62        match self {
63            Self::File(_) => None,
64            Self::Bytes(bytes) => Some(bytes),
65        }
66    }
67}
68
69impl ProfileAvatarUpload {
70    pub fn from_path(path: PathBuf) -> Self {
71        let filename = path
72            .file_name()
73            .and_then(|name| name.to_str())
74            .unwrap_or("avatar")
75            .to_owned();
76        Self {
77            source: UploadSource::File(path),
78            filename,
79            size_bytes: 0,
80        }
81    }
82
83    pub fn from_bytes(filename: String, bytes: Vec<u8>) -> Self {
84        Self {
85            size_bytes: bytes.len() as u64,
86            source: UploadSource::Bytes(bytes),
87            filename,
88        }
89    }
90
91    pub fn from_message_attachment(upload: MessageAttachmentUpload) -> Self {
92        Self {
93            source: upload.source,
94            filename: upload.filename,
95            size_bytes: upload.size_bytes,
96        }
97    }
98
99    pub fn path(&self) -> Option<&Path> {
100        self.source.path()
101    }
102
103    pub fn bytes(&self) -> Option<&[u8]> {
104        self.source.bytes()
105    }
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct GuildUserProfileUpdate {
110    pub guild_id: Id<GuildMarker>,
111    pub nickname: Option<String>,
112    pub pronouns: Option<String>,
113}
114
115impl GuildUserProfileUpdate {
116    pub fn is_empty(&self) -> bool {
117        self.nickname.is_none() && self.pronouns.is_none()
118    }
119}
120
121#[derive(Clone, Debug, Eq, PartialEq)]
122pub struct UserProfileUpdate {
123    pub user_id: Id<UserMarker>,
124    pub guild_id: Option<Id<GuildMarker>>,
125    pub global: GlobalUserProfileUpdate,
126    pub guild: Option<GuildUserProfileUpdate>,
127}
128
129impl UserProfileUpdate {
130    pub fn is_empty(&self) -> bool {
131        self.global.is_empty()
132            && self
133                .guild
134                .as_ref()
135                .is_none_or(GuildUserProfileUpdate::is_empty)
136    }
137}
138
139impl MessageAttachmentUpload {
140    pub fn from_path(path: PathBuf, filename: String, size_bytes: u64) -> Self {
141        Self {
142            source: UploadSource::File(path),
143            filename,
144            size_bytes,
145        }
146    }
147
148    pub fn from_existing_path(path: PathBuf) -> io::Result<Self> {
149        let metadata = path.metadata()?;
150        let filename = path
151            .file_name()
152            .and_then(|name| name.to_str())
153            .unwrap_or("attachment")
154            .to_owned();
155        Ok(Self::from_path(path, filename, metadata.len()))
156    }
157
158    pub fn from_bytes(filename: String, bytes: Vec<u8>) -> Self {
159        Self {
160            size_bytes: bytes.len() as u64,
161            source: UploadSource::Bytes(bytes),
162            filename,
163        }
164    }
165
166    pub fn path(&self) -> Option<&Path> {
167        self.source.path()
168    }
169
170    pub fn bytes(&self) -> Option<&[u8]> {
171        self.source.bytes()
172    }
173}
174
175#[derive(Clone, Debug, Eq, PartialEq)]
176pub enum ReactionEmoji {
177    Unicode(String),
178    Custom {
179        id: Id<EmojiMarker>,
180        name: Option<String>,
181        animated: bool,
182    },
183}
184
185#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
186pub enum ForumPostArchiveState {
187    #[default]
188    Active,
189    Archived,
190}
191
192impl ForumPostArchiveState {
193    pub fn as_query_value(self) -> &'static str {
194        match self {
195            Self::Active => "false",
196            Self::Archived => "true",
197        }
198    }
199
200    pub fn as_log_label(self) -> &'static str {
201        match self {
202            Self::Active => "active",
203            Self::Archived => "archived",
204        }
205    }
206}
207
208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209pub enum MuteDuration {
210    Minutes(u64),
211    Permanent,
212}
213
214#[derive(Clone, Copy, Debug, Eq, PartialEq)]
215pub enum MessageSearchHas {
216    Link,
217    Embed,
218    File,
219    Video,
220    Image,
221    Sound,
222    Sticker,
223}
224
225impl MessageSearchHas {
226    pub fn from_input(value: &str) -> Option<Self> {
227        match normalized_search_token(value).as_str() {
228            "link" | "links" => Some(Self::Link),
229            "embed" | "embeds" => Some(Self::Embed),
230            "file" | "files" | "attachment" | "attachments" => Some(Self::File),
231            "video" | "videos" => Some(Self::Video),
232            "image" | "images" | "img" => Some(Self::Image),
233            "sound" | "sounds" | "audio" => Some(Self::Sound),
234            "sticker" | "stickers" => Some(Self::Sticker),
235            _ => None,
236        }
237    }
238
239    pub fn as_query_value(self) -> &'static str {
240        match self {
241            Self::Link => "link",
242            Self::Embed => "embed",
243            Self::File => "file",
244            Self::Video => "video",
245            Self::Image => "image",
246            Self::Sound => "sound",
247            Self::Sticker => "sticker",
248        }
249    }
250}
251
252#[derive(Clone, Copy, Debug, Eq, PartialEq)]
253pub enum MessageSearchAuthorType {
254    User,
255    Bot,
256    Webhook,
257}
258
259impl MessageSearchAuthorType {
260    pub fn from_input(value: &str) -> Option<Self> {
261        match normalized_search_token(value).as_str() {
262            "user" | "person" | "people" => Some(Self::User),
263            "bot" | "bots" => Some(Self::Bot),
264            "webhook" | "webhooks" => Some(Self::Webhook),
265            _ => None,
266        }
267    }
268
269    pub fn as_query_value(self) -> &'static str {
270        match self {
271            Self::User => "user",
272            Self::Bot => "bot",
273            Self::Webhook => "webhook",
274        }
275    }
276}
277
278#[derive(Clone, Debug, Default, Eq, PartialEq)]
279pub struct MessageSearchQuery {
280    pub guild_id: Option<Id<GuildMarker>>,
281    pub channel_id: Option<Id<ChannelMarker>>,
282    pub author_id: Option<Id<UserMarker>>,
283    pub mentions_user_id: Option<Id<UserMarker>>,
284    pub content: Option<String>,
285    pub has: Vec<MessageSearchHas>,
286    pub date: Option<String>,
287    pub author_type: Vec<MessageSearchAuthorType>,
288    pub pinned: Option<bool>,
289    pub offset: usize,
290}
291
292impl MessageSearchQuery {
293    pub fn is_empty(&self) -> bool {
294        self.channel_id.is_none()
295            && self.author_id.is_none()
296            && self.mentions_user_id.is_none()
297            && self.content.as_deref().is_none_or(str::is_empty)
298            && self.has.is_empty()
299            && self.date.as_deref().is_none_or(str::is_empty)
300            && self.author_type.is_empty()
301            && self.pinned.is_none()
302    }
303}
304
305#[derive(Clone, Debug, Eq, PartialEq)]
306pub struct MessageSearchPage {
307    pub query: MessageSearchQuery,
308    pub messages: Vec<MessageInfo>,
309    pub total_results: Option<usize>,
310    pub has_more: bool,
311}
312
313impl MuteDuration {
314    pub fn minutes(self) -> Option<u64> {
315        match self {
316            Self::Minutes(minutes) => Some(minutes),
317            Self::Permanent => None,
318        }
319    }
320
321    pub fn selected_time_window_seconds(self) -> i64 {
322        match self {
323            Self::Minutes(minutes) => i64::try_from(minutes.saturating_mul(60)).unwrap_or(i64::MAX),
324            Self::Permanent => -1,
325        }
326    }
327}
328
329impl ReactionEmoji {
330    pub fn status_label(&self) -> String {
331        match self {
332            Self::Unicode(emoji) => emoji.clone(),
333            Self::Custom { name, .. } => name
334                .as_deref()
335                .map(|name| format!(":{name}:"))
336                .unwrap_or_else(|| ":custom:".to_owned()),
337        }
338    }
339
340    pub fn custom_image_url(&self) -> Option<String> {
341        let Self::Custom { id, animated, .. } = self else {
342            return None;
343        };
344        let extension = if *animated { "gif" } else { "png" };
345        Some(format!(
346            "https://cdn.discordapp.com/emojis/{}.{}",
347            id.get(),
348            extension
349        ))
350    }
351
352    pub(crate) fn route_component(&self) -> String {
353        match self {
354            Self::Unicode(name) => percent_encode_path_segment(name),
355            Self::Custom { id, name, .. } => percent_encode_path_segment(&format!(
356                "{}:{id}",
357                name.as_deref().unwrap_or_default()
358            )),
359        }
360    }
361}
362
363fn percent_encode_path_segment(value: &str) -> String {
364    let mut encoded = String::new();
365    for byte in value.bytes() {
366        match byte {
367            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
368                encoded.push(char::from(byte));
369            }
370            _ => encoded.push_str(&format!("%{byte:02X}")),
371        }
372    }
373    encoded
374}
375
376#[derive(Clone, Debug, Eq, PartialEq)]
377pub enum AppCommand {
378    LoadMessageHistory {
379        channel_id: Id<ChannelMarker>,
380        before: Option<Id<MessageMarker>>,
381    },
382    RefreshMessageHistory {
383        channel_id: Id<ChannelMarker>,
384    },
385    LoadMessageHistoryAfter {
386        channel_id: Id<ChannelMarker>,
387        after: Id<MessageMarker>,
388    },
389    CatchUpMessageHistoryAfter {
390        channel_id: Id<ChannelMarker>,
391        after: Id<MessageMarker>,
392    },
393    LoadMessageHistoryAround {
394        channel_id: Id<ChannelMarker>,
395        message_id: Id<MessageMarker>,
396    },
397    LoadThreadPreview {
398        channel_id: Id<ChannelMarker>,
399        message_id: Id<MessageMarker>,
400    },
401    LoadForumPosts {
402        guild_id: Id<GuildMarker>,
403        channel_id: Id<ChannelMarker>,
404        archive_state: ForumPostArchiveState,
405        offset: usize,
406    },
407    SearchMessages {
408        query: MessageSearchQuery,
409    },
410    LoadGuildMembers {
411        guild_id: Id<GuildMarker>,
412    },
413    LoadGuildMembersByIds {
414        guild_id: Id<GuildMarker>,
415        user_ids: Vec<Id<UserMarker>>,
416    },
417    SearchGuildMembers {
418        guild_id: Id<GuildMarker>,
419        query: String,
420    },
421    SetSelectedGuild {
422        guild_id: Option<Id<GuildMarker>>,
423    },
424    LeaveGuild {
425        guild_id: Id<GuildMarker>,
426        label: String,
427    },
428    SetSelectedMessageChannel {
429        channel_id: Option<Id<ChannelMarker>>,
430    },
431    SubscribeDirectMessage {
432        channel_id: Id<ChannelMarker>,
433    },
434    SubscribeGuildChannel {
435        guild_id: Id<GuildMarker>,
436        channel_id: Id<ChannelMarker>,
437    },
438    /// Resubscribe an active op-37 channel subscription with a wider set of
439    /// member-list ranges as the user scrolls through the member sidebar.
440    UpdateMemberListSubscription {
441        guild_id: Id<GuildMarker>,
442        channel_id: Id<ChannelMarker>,
443        ranges: Vec<(u32, u32)>,
444    },
445    JoinVoiceChannel {
446        guild_id: Id<GuildMarker>,
447        channel_id: Id<ChannelMarker>,
448        self_mute: bool,
449        self_deaf: bool,
450        allow_microphone_transmit: bool,
451        microphone_sensitivity: crate::config::MicrophoneSensitivityDb,
452        microphone_volume: crate::config::VoiceVolumePercent,
453        voice_output_volume: crate::config::VoiceVolumePercent,
454    },
455    UpdateVoiceState {
456        guild_id: Id<GuildMarker>,
457        channel_id: Id<ChannelMarker>,
458        self_mute: bool,
459        self_deaf: bool,
460    },
461    UpdateVoiceCapturePermission {
462        guild_id: Id<GuildMarker>,
463        channel_id: Id<ChannelMarker>,
464        allow_microphone_transmit: bool,
465        microphone_sensitivity: crate::config::MicrophoneSensitivityDb,
466        microphone_volume: crate::config::VoiceVolumePercent,
467        voice_output_volume: crate::config::VoiceVolumePercent,
468    },
469    LeaveVoiceChannel {
470        guild_id: Id<GuildMarker>,
471        self_mute: bool,
472        self_deaf: bool,
473    },
474    LoadAttachmentPreview {
475        url: String,
476    },
477    LoadProfileAvatarPreview {
478        key: String,
479        upload: ProfileAvatarUpload,
480    },
481    SendMessage {
482        channel_id: Id<ChannelMarker>,
483        content: String,
484        reply_to: Option<Id<MessageMarker>>,
485        attachments: Vec<MessageAttachmentUpload>,
486    },
487    LoadApplicationCommands {
488        guild_id: Option<Id<GuildMarker>>,
489    },
490    RunApplicationCommand {
491        invocation: ApplicationCommandInvocation,
492    },
493    EditMessage {
494        channel_id: Id<ChannelMarker>,
495        message_id: Id<MessageMarker>,
496        content: String,
497    },
498    DeleteMessage {
499        channel_id: Id<ChannelMarker>,
500        message_id: Id<MessageMarker>,
501    },
502    OpenUrl {
503        url: String,
504    },
505    DownloadAttachment {
506        url: String,
507        filename: String,
508        source: DownloadAttachmentSource,
509    },
510    AddReaction {
511        channel_id: Id<ChannelMarker>,
512        message_id: Id<MessageMarker>,
513        emoji: ReactionEmoji,
514    },
515    RemoveReaction {
516        channel_id: Id<ChannelMarker>,
517        message_id: Id<MessageMarker>,
518        emoji: ReactionEmoji,
519    },
520    LoadReactionUsers {
521        channel_id: Id<ChannelMarker>,
522        message_id: Id<MessageMarker>,
523        reactions: Vec<ReactionEmoji>,
524    },
525    LoadPinnedMessages {
526        channel_id: Id<ChannelMarker>,
527    },
528    SetMessagePinned {
529        channel_id: Id<ChannelMarker>,
530        message_id: Id<MessageMarker>,
531        pinned: bool,
532    },
533    VotePoll {
534        channel_id: Id<ChannelMarker>,
535        message_id: Id<MessageMarker>,
536        answer_ids: Vec<u8>,
537    },
538    LoadUserProfile {
539        user_id: Id<UserMarker>,
540        guild_id: Option<Id<GuildMarker>>,
541    },
542    LoadUserNote {
543        user_id: Id<UserMarker>,
544    },
545    UpdateUserProfile {
546        update: UserProfileUpdate,
547    },
548    UpdateCurrentUserStatus {
549        status: PresenceStatus,
550    },
551    UpdateCurrentUserActivity {
552        status: PresenceStatus,
553        activities: Vec<ActivityInfo>,
554    },
555    AckChannel {
556        channel_id: Id<ChannelMarker>,
557        message_id: Id<MessageMarker>,
558    },
559    ScheduleAckChannel {
560        channel_id: Id<ChannelMarker>,
561        message_id: Id<MessageMarker>,
562    },
563    SetGuildMuted {
564        guild_id: Id<GuildMarker>,
565        muted: bool,
566        duration: Option<MuteDuration>,
567        label: String,
568    },
569    SetChannelMuted {
570        guild_id: Option<Id<GuildMarker>>,
571        channel_id: Id<ChannelMarker>,
572        muted: bool,
573        duration: Option<MuteDuration>,
574        label: String,
575    },
576    AckChannels {
577        targets: Vec<(Id<ChannelMarker>, Id<MessageMarker>)>,
578    },
579}
580
581fn normalized_search_token(value: &str) -> String {
582    value.trim().trim_start_matches(':').to_ascii_lowercase()
583}
584
585#[derive(Clone, Copy, Debug, Eq, PartialEq)]
586pub enum DownloadAttachmentSource {
587    AttachmentViewer,
588}