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 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}