pub mod events;
use crate::{user::UserId, Command, CommandKind, Error};
use serde::{Deserialize, Serialize};
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct Party {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<(u32, u32)>,
#[serde(skip_serializing_if = "Option::is_none")]
pub privacy: Option<PartyPrivacy>,
}
#[derive(
serde_repr::Serialize_repr, serde_repr::Deserialize_repr, PartialEq, Eq, Debug, Copy, Clone,
)]
#[repr(u8)]
pub enum PartyPrivacy {
Private = 0,
Public = 1,
}
pub trait IntoTimestamp {
fn into_timestamp(self) -> i64;
}
impl IntoTimestamp for std::time::SystemTime {
fn into_timestamp(self) -> i64 {
match self.duration_since(std::time::SystemTime::UNIX_EPOCH) {
Ok(dur) => dur.as_secs() as i64,
Err(_) => 0,
}
}
}
impl IntoTimestamp for time::OffsetDateTime {
fn into_timestamp(self) -> i64 {
self.unix_timestamp()
}
}
impl IntoTimestamp for i64 {
fn into_timestamp(self) -> i64 {
self
}
}
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct Assets {
#[serde(skip_serializing_if = "Option::is_none")]
pub large_image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub large_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub small_image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub small_text: Option<String>,
}
impl Assets {
#[inline]
fn validate_key(key: &str) -> bool {
key.len() <= 32 || key.starts_with("http://") || key.starts_with("https://")
}
pub fn large(mut self, key: impl Into<String>, text: Option<impl Into<String>>) -> Self {
let key = key.into();
if !Self::validate_key(&key) {
tracing::warn!("Large Image Key '{key}' is invalid, disregarding");
return self;
}
self.large_image = Some(key);
self.large_text = truncate(text, "Large Image Text");
self
}
pub fn small(mut self, key: impl Into<String>, text: Option<impl Into<String>>) -> Self {
let key = key.into();
if !Self::validate_key(&key) {
tracing::warn!("Small Image Key '{key}' is invalid, disregarding");
return self;
}
self.small_image = Some(key);
self.small_text = truncate(text, "Small Image Text");
self
}
}
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct Timestamps {
#[serde(skip_serializing_if = "Option::is_none")]
pub start: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<i64>,
}
#[derive(
serde_repr::Serialize_repr, serde_repr::Deserialize_repr, PartialEq, Eq, Debug, Copy, Clone,
)]
#[repr(u8)]
pub enum ActivityKind {
Playing = 0,
Streaming = 1,
Listening = 2,
Watching = 3,
Custom = 4,
Competing = 5,
}
impl Default for ActivityKind {
fn default() -> Self {
Self::Playing
}
}
#[derive(
serde_repr::Serialize_repr, serde_repr::Deserialize_repr, PartialEq, Eq, Debug, Copy, Clone,
)]
#[repr(u8)]
pub enum ActivityActionKind {
Join = 1,
Spectate = 2,
}
#[derive(Deserialize, Debug)]
#[cfg_attr(test, derive(Serialize))]
pub struct ActivityInvite {
pub user: crate::user::User,
pub activity: InviteActivity,
#[serde(rename = "type")]
pub kind: ActivityActionKind,
pub channel_id: crate::types::ChannelId,
pub message_id: crate::types::MessageId,
}
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
pub enum JoinRequestReply {
No,
Yes,
Ignore,
}
impl From<bool> for JoinRequestReply {
fn from(b: bool) -> Self {
if b {
Self::Yes
} else {
Self::No
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct Activity {
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamps: Option<Timestamps>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assets: Option<Assets>,
#[serde(skip_serializing_if = "Option::is_none")]
pub party: Option<Party>,
#[serde(skip_serializing_if = "Option::is_none", flatten)]
pub buttons_or_secrets: Option<ButtonsOrSecrets>,
#[serde(rename = "type")]
pub kind: ActivityKind,
#[serde(default)]
pub instance: bool,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(test, derive(Serialize))]
pub struct InviteActivity {
pub session_id: String,
#[serde(skip_serializing, with = "crate::util::datetime_opt")]
pub created_at: Option<time::OffsetDateTime>,
#[serde(flatten)]
pub details: Activity,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct SetActivity {
#[serde(flatten)]
activity: Activity,
name: Option<String>,
#[serde(deserialize_with = "crate::util::string::deserialize_opt")]
application_id: Option<crate::AppId>,
}
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct Secrets {
#[serde(skip_serializing_if = "Option::is_none")]
pub r#match: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub join: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub spectate: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Button {
pub label: String,
pub url: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ButtonKind {
Label(String),
Link(Button),
}
#[derive(Serialize, Debug)]
pub struct ActivityArgs {
pid: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub activity: Option<Activity>,
}
impl Default for ActivityArgs {
fn default() -> Self {
Self {
pid: std::process::id(),
activity: None,
}
}
}
impl From<ActivityBuilder> for ActivityArgs {
#[inline]
fn from(ab: ActivityBuilder) -> Self {
ab.inner
}
}
#[derive(Default, Debug)]
pub struct ActivityBuilder {
pub(crate) inner: ActivityArgs,
}
impl ActivityBuilder {
pub fn new() -> Self {
Self::default()
}
#[cfg(test)]
pub fn with_pid(pid: u32) -> Self {
Self {
inner: ActivityArgs {
pid,
activity: None,
},
}
}
pub fn state(mut self, state: impl Into<String>) -> Self {
let state = truncate(Some(state), "State");
match &mut self.inner.activity {
Some(activity) => activity.state = state,
None => {
self.inner.activity = Some(Activity {
state,
..Default::default()
});
}
}
self
}
pub fn details(mut self, details: impl Into<String>) -> Self {
let details = truncate(Some(details), "Details");
match &mut self.inner.activity {
Some(activity) => activity.details = details,
None => {
self.inner.activity = Some(Activity {
details,
..Default::default()
});
}
}
self
}
pub fn start_timestamp(mut self, timestamp: impl IntoTimestamp) -> Self {
match &mut self.inner.activity {
Some(activity) => {
match &mut activity.timestamps {
Some(timestamps) => {
timestamps.start = Some(timestamp.into_timestamp());
}
None => {
activity.timestamps = Some(Timestamps {
start: Some(timestamp.into_timestamp()),
end: None,
});
}
}
}
None => {
self.inner.activity = Some(Activity {
timestamps: Some(Timestamps {
start: Some(timestamp.into_timestamp()),
end: None,
}),
..Default::default()
});
}
}
self
}
pub fn end_timestamp(mut self, timestamp: impl IntoTimestamp) -> Self {
match &mut self.inner.activity {
Some(activity) => {
match &mut activity.timestamps {
Some(timestamps) => {
let timestamp = timestamp.into_timestamp();
let start = timestamps.start.unwrap_or(0);
if start > timestamp {
tracing::warn!(
"End timestamp must be greater than the start timestamp"
);
} else {
timestamps.end = Some(timestamp.into_timestamp());
}
}
None => {
activity.timestamps = Some(Timestamps {
start: None,
end: Some(timestamp.into_timestamp()),
});
}
}
}
None => {
self.inner.activity = Some(Activity {
timestamps: Some(Timestamps {
start: None,
end: Some(timestamp.into_timestamp()),
}),
..Default::default()
});
}
}
self
}
pub fn timestamps(
mut self,
start: Option<impl IntoTimestamp>,
end: Option<impl IntoTimestamp>,
) -> Self {
if let Some(st) = start {
self = self.start_timestamp(st);
}
if let Some(et) = end {
self = self.end_timestamp(et);
}
self
}
pub fn assets(mut self, assets: Assets) -> Self {
if assets.large_image.is_none() && assets.small_image.is_none() {
return self;
}
let assets = Some(assets);
match &mut self.inner.activity {
Some(activity) => activity.assets = assets,
None => {
self.inner.activity = Some(Activity {
assets,
..Default::default()
});
}
}
self
}
pub fn party(
mut self,
id: impl Into<String>,
current_size: Option<std::num::NonZeroU32>,
max_size: Option<std::num::NonZeroU32>,
privacy: PartyPrivacy,
) -> Self {
let id = truncate(Some(id), "Party Id").unwrap();
let size = match (current_size, max_size) {
(Some(cur), Some(max)) => {
let cur = cur.get();
let max = max.get();
if cur > max {
tracing::warn!(
"The current size of the party was larger than the maximum size"
);
None
} else {
Some((cur, max))
}
}
_ => None,
};
let party = Some(Party {
id,
size,
privacy: Some(privacy),
});
match &mut self.inner.activity {
Some(activity) => activity.party = party,
None => {
self.inner.activity = Some(Activity {
party,
..Default::default()
});
}
}
self
}
pub fn instance(mut self, is_instance: bool) -> Self {
match &mut self.inner.activity {
Some(activity) => activity.instance = is_instance,
None => {
self.inner.activity = Some(Activity {
instance: is_instance,
..Default::default()
});
}
}
self
}
pub fn button(mut self, button: Button) -> Self {
let button = ButtonKind::Link(button);
match &mut self.inner.activity {
Some(Activity {
buttons_or_secrets, ..
}) => match buttons_or_secrets {
Some(ButtonsOrSecrets::Buttons { buttons }) => buttons.push(button),
buttons_or_secrets => {
*buttons_or_secrets = Some(ButtonsOrSecrets::Buttons {
buttons: vec![button],
});
}
},
None => {
self.inner.activity = Some(Activity {
buttons_or_secrets: Some(ButtonsOrSecrets::Buttons {
buttons: vec![button],
}),
..Default::default()
});
}
}
self
}
pub fn secrets(mut self, secrets: Secrets) -> Self {
if secrets.join.is_none() && secrets.r#match.is_none() && secrets.spectate.is_none() {
return self;
}
match &mut self.inner.activity {
Some(activity) => {
activity.buttons_or_secrets = Some(ButtonsOrSecrets::Secrets { secrets });
}
None => {
self.inner.activity = Some(Activity {
buttons_or_secrets: Some(ButtonsOrSecrets::Secrets { secrets }),
..Default::default()
});
}
}
self
}
pub fn kind(mut self, kind: ActivityKind) -> Self {
match &mut self.inner.activity {
Some(activity) => activity.kind = kind,
None => {
self.inner.activity = Some(Activity {
kind,
..Default::default()
});
}
}
self
}
}
impl crate::Discord {
pub async fn update_activity(
&self,
activity: impl Into<ActivityArgs>,
) -> Result<Option<Activity>, Error> {
let rx = self.send_rpc(CommandKind::SetActivity, activity.into())?;
handle_response!(rx, Command::SetActivity(sa) => {
Ok(sa.map(|sa| sa.activity))
})
}
pub async fn invite_user(
&self,
user_id: UserId,
message: impl Into<String>,
kind: ActivityActionKind,
) -> Result<(), Error> {
#[derive(Serialize)]
struct Invite {
pid: u32,
user_id: UserId,
content: String,
#[serde(rename = "type")]
kind: ActivityActionKind,
}
let rx = self.send_rpc(
CommandKind::ActivityInviteUser,
Invite {
pid: std::process::id(),
user_id,
content: message.into(),
kind,
},
)?;
handle_response!(rx, Command::ActivityInviteUser => {
Ok(())
})
}
pub async fn accept_invite(&self, invite: &impl AsRef<ActivityInvite>) -> Result<(), Error> {
#[derive(Serialize)]
struct Accept<'stack> {
user_id: UserId,
#[serde(rename = "type")]
kind: ActivityActionKind,
session_id: &'stack str,
channel_id: crate::types::ChannelId,
message_id: crate::types::MessageId,
}
let invite = invite.as_ref();
let rx = self.send_rpc(
CommandKind::AcceptActivityInvite,
Accept {
user_id: invite.user.id,
kind: invite.kind,
session_id: &invite.activity.session_id,
channel_id: invite.channel_id,
message_id: invite.message_id,
},
)?;
handle_response!(rx, Command::AcceptActivityInvite => {
Ok(())
})
}
pub async fn clear_activity(&self) -> Result<Option<Activity>, Error> {
let rx = self.send_rpc(CommandKind::SetActivity, ActivityArgs::default())?;
handle_response!(rx, Command::SetActivity(sa) => {
Ok(sa.map(|sa| sa.activity))
})
}
pub async fn send_join_request_reply(
&self,
user_id: UserId,
reply: impl Into<JoinRequestReply>,
) -> Result<(), Error> {
let reply = reply.into();
let kind = match reply {
JoinRequestReply::Yes => CommandKind::SendActivityJoinInvite,
JoinRequestReply::No | JoinRequestReply::Ignore => {
CommandKind::CloseActivityJoinRequest
}
};
#[derive(Serialize)]
struct JoinReply {
user_id: UserId,
}
let rx = self.send_rpc(kind, JoinReply { user_id })?;
match reply {
JoinRequestReply::Yes => {
handle_response!(rx, Command::SendActivityJoinInvite => {
Ok(())
})
}
JoinRequestReply::No | JoinRequestReply::Ignore => {
handle_response!(rx, Command::CloseActivityJoinRequest => {
Ok(())
})
}
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ButtonsOrSecrets {
Buttons { buttons: Vec<ButtonKind> },
Secrets { secrets: Secrets },
}
#[inline]
fn truncate(text: Option<impl Into<String>>, name: &str) -> Option<String> {
text.and_then(|text| {
let mut text = text.into();
if text.len() > 128 {
tracing::warn!("{name} '{text}' is too long and will be truncated");
let lower_bound = 125;
let new_index = text.as_bytes()[125..=128].iter().rposition(|b| {
(*b as i8) >= -0x40
});
text.truncate(
lower_bound + new_index.expect("character boundary not found within 4 bytes"),
);
}
if text.trim().is_empty() {
None
} else {
Some(text)
}
})
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn serde() {
let args: ActivityArgs = ActivityBuilder::with_pid(9999)
.details("deetz")
.state(" ")
.start_timestamp(1628629161811)
.end_timestamp(1628629327961)
.party(
"parrrrty",
std::num::NonZeroU32::new(1),
std::num::NonZeroU32::new(2),
PartyPrivacy::Private,
)
.secrets(Secrets {
join: Some("sekret".to_owned()),
..Default::default()
})
.kind(ActivityKind::Listening)
.into();
let cmd = crate::proto::Rpc {
cmd: CommandKind::SetActivity,
nonce: 2.to_string(),
evt: None,
args: Some(args),
};
insta::assert_json_snapshot!(cmd);
}
#[test]
fn truncate() {
let s = super::truncate(
Some("xäääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääää"),
"test",
)
.unwrap();
assert_eq!(
s,
"xäääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääää"
);
}
#[test]
fn asset_keys() {
assert!(Assets::validate_key("tiny_key"));
assert!(Assets::validate_key("_-_thirtytwocharacterassetkey_-_"));
assert!(!Assets::validate_key("_-_thirtythreecharacterassetkey_-"));
assert!(Assets::validate_key("http://shortboi.com/image"));
assert!(Assets::validate_key(
"https://superlongboibutthatsokbecauseitshttps.com/image"
));
}
}