use crate::ids::{ChannelId, ChatroomId, UserId};
use crate::lenient::{self, array, boolean, get, id_string, int, string, uint};
use serde::Serialize;
use serde_json::Value;
pub mod names {
pub const CHAT_MESSAGE: &str = r"App\Events\ChatMessageEvent";
pub const MESSAGE_DELETED: &str = r"App\Events\MessageDeletedEvent";
pub const USER_BANNED: &str = r"App\Events\UserBannedEvent";
pub const USER_UNBANNED: &str = r"App\Events\UserUnbannedEvent";
pub const CHATROOM_CLEAR: &str = r"App\Events\ChatroomClearEvent";
pub const PINNED_MESSAGE_CREATED: &str = r"App\Events\PinnedMessageCreatedEvent";
pub const PINNED_MESSAGE_DELETED: &str = r"App\Events\PinnedMessageDeletedEvent";
pub const SUBSCRIPTION: &str = r"App\Events\SubscriptionEvent";
pub const GIFTED_SUBSCRIPTIONS: &str = r"App\Events\GiftedSubscriptionsEvent";
pub const GIFTED_SUBSCRIPTIONS_PLAIN: &str = "GiftedSubscriptionsEvent";
pub const KICKS_GIFTED: &str = "KicksGifted";
pub const REWARD_REDEEMED: &str = "RewardRedeemedEvent";
pub const REWARD_REDEEMED_PREFIXED: &str = r"App\Events\RewardRedeemedEvent";
pub const STREAM_HOST: &str = r"App\Events\StreamHostEvent";
pub const STREAM_HOSTED: &str = r"App\Events\StreamHostedEvent";
pub const CHAT_MOVE_TO_SUPPORTED_CHANNEL: &str = r"App\Events\ChatMoveToSupportedChannelEvent";
pub const STREAMER_IS_LIVE: &str = r"App\Events\StreamerIsLive";
pub const STOP_STREAM_BROADCAST: &str = r"App\Events\StopStreamBroadcast";
pub const POLL_UPDATE: &str = r"App\Events\PollUpdateEvent";
pub const POLL_DELETE: &str = r"App\Events\PollDeleteEvent";
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
#[non_exhaustive]
pub struct UserRef {
pub id: Option<UserId>,
pub username: Option<String>,
pub slug: Option<String>,
}
impl UserRef {
fn from(v: &Value) -> Option<Self> {
let u = UserRef { id: uint(v, "id").map(UserId), username: string(v, "username"), slug: string(v, "slug") };
(u.id.is_some() || u.username.is_some() || u.slug.is_some()).then_some(u)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Badge {
pub kind: String,
pub text: Option<String>,
pub count: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct BadgeImage {
pub name: String,
pub badge_type: Option<String>,
pub image_url: String,
pub level: Option<i64>,
pub sort_order: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Sender {
pub id: Option<UserId>,
pub username: String,
pub slug: Option<String>,
pub color: Option<String>,
pub badges: Vec<Badge>,
pub badges_v2: Vec<BadgeImage>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct ReplyTo {
pub message_id: Option<String>,
pub content: Option<String>,
pub sender_id: Option<UserId>,
pub sender_username: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct ChatMessage {
pub id: String,
pub chatroom_id: Option<ChatroomId>,
pub content: String,
pub kind: String,
pub created_at: Option<String>,
pub sender: Sender,
pub reply_to: Option<ReplyTo>,
pub thread_parent_id: Option<String>,
pub message_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct KicksGift {
pub gift_id: Option<String>,
pub name: Option<String>,
pub amount: i64,
pub kind: Option<String>,
pub tier: Option<String>,
pub pinned_time: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Hosted {
pub channel_id: Option<ChannelId>,
pub slug: Option<String>,
pub username: Option<String>,
pub viewers: Option<i64>,
pub is_live: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum KickEvent {
ChatMessage(ChatMessage),
MessageDeleted {
message_id: String,
ai_moderated: Option<bool>,
},
UserBanned {
user: Option<UserRef>,
banned_by: Option<UserRef>,
permanent: bool,
duration: Option<i64>,
expires_at: Option<String>,
},
UserUnbanned {
user: Option<UserRef>,
unbanned_by: Option<UserRef>,
permanent: bool,
},
ChatroomCleared,
PinCreated {
message: Option<ChatMessage>,
pinned_by: Option<UserRef>,
duration: Option<i64>,
finish_at: Option<String>,
},
PinDeleted,
Subscription {
username: Option<String>,
months: Option<i64>,
},
GiftedSubscriptions {
gifter: Option<String>,
recipients: Vec<String>,
},
KicksGifted {
sender: Option<UserRef>,
sender_color: Option<String>,
gift: KicksGift,
message: Option<String>,
},
RewardRedeemed {
username: Option<String>,
reward: Option<String>,
input: Option<String>,
},
Host {
host_username: Option<String>,
viewers: Option<i64>,
message: Option<String>,
},
HostDuplicate,
HostingOut {
hosted: Hosted,
},
StreamLive {
title: Option<String>,
livestream_id: Option<u64>,
},
StreamEnded {
livestream_id: Option<u64>,
},
PollUpdated {
poll: Value,
},
PollDeleted,
Unknown {
name: String,
},
}
pub fn parse(name: &str, data: &Value) -> KickEvent {
let d = lenient::decode_twice(data);
use names::*;
let parsed = match name {
CHAT_MESSAGE => chat_message(&d).map(KickEvent::ChatMessage),
MESSAGE_DELETED => get(&d, "message")
.and_then(|m| id_string(m, "id"))
.or_else(|| id_string(&d, "message_id"))
.map(|message_id| KickEvent::MessageDeleted { message_id, ai_moderated: boolean(&d, "aiModerated") }),
USER_BANNED => Some(KickEvent::UserBanned {
user: get(&d, "user").and_then(UserRef::from),
banned_by: get(&d, "banned_by").and_then(UserRef::from),
permanent: boolean(&d, "permanent").unwrap_or(false),
duration: int(&d, "duration"),
expires_at: string(&d, "expires_at"),
}),
USER_UNBANNED => Some(KickEvent::UserUnbanned {
user: get(&d, "user").and_then(UserRef::from),
unbanned_by: get(&d, "unbanned_by").and_then(UserRef::from),
permanent: boolean(&d, "permanent").unwrap_or(false),
}),
CHATROOM_CLEAR => Some(KickEvent::ChatroomCleared),
PINNED_MESSAGE_CREATED => Some(pin(&d)),
PINNED_MESSAGE_DELETED => Some(KickEvent::PinDeleted),
SUBSCRIPTION => Some(KickEvent::Subscription { username: string(&d, "username"), months: int(&d, "months") }),
GIFTED_SUBSCRIPTIONS | GIFTED_SUBSCRIPTIONS_PLAIN => Some(KickEvent::GiftedSubscriptions {
gifter: string(&d, "gifter_username"),
recipients: array(&d, "gifted_usernames").iter().filter_map(|u| u.as_str().map(str::to_owned)).collect(),
}),
KICKS_GIFTED => Some(kicks(&d)),
REWARD_REDEEMED | REWARD_REDEEMED_PREFIXED => {
let reward = get(&d, "reward").cloned().unwrap_or(Value::Null);
Some(KickEvent::RewardRedeemed {
username: string(&d, "username").or_else(|| get(&d, "user").and_then(|u| string(u, "username"))),
reward: string(&d, "reward_title").or_else(|| string(&reward, "title")).or_else(|| string(&d, "title")),
input: string(&d, "user_input").or_else(|| string(&d, "input")).or_else(|| string(&d, "message")),
})
}
STREAM_HOST => Some(KickEvent::Host {
host_username: string(&d, "host_username"),
viewers: int(&d, "number_viewers"),
message: string(&d, "optional_message").filter(|m| !m.is_empty()),
}),
STREAM_HOSTED => Some(KickEvent::HostDuplicate),
CHAT_MOVE_TO_SUPPORTED_CHANNEL => {
let h = get(&d, "hosted").cloned().unwrap_or(Value::Null);
Some(KickEvent::HostingOut {
hosted: Hosted {
channel_id: uint(&h, "id").map(ChannelId),
slug: string(&h, "slug").or_else(|| string(&d, "slug")),
username: string(&h, "username"),
viewers: int(&h, "viewers_count"),
is_live: boolean(&h, "is_live"),
},
})
}
STREAMER_IS_LIVE => {
let l = get(&d, "livestream").cloned().unwrap_or(Value::Null);
Some(KickEvent::StreamLive { title: string(&l, "session_title"), livestream_id: uint(&l, "id") })
}
STOP_STREAM_BROADCAST => {
Some(KickEvent::StreamEnded { livestream_id: get(&d, "livestream").and_then(|l| uint(l, "id")) })
}
POLL_UPDATE => Some(KickEvent::PollUpdated { poll: get(&d, "poll").cloned().unwrap_or(Value::Null) }),
POLL_DELETE => Some(KickEvent::PollDeleted),
_ => None,
};
parsed.unwrap_or_else(|| KickEvent::Unknown { name: name.to_owned() })
}
pub fn chat_message(d: &Value) -> Option<ChatMessage> {
let sender = get(d, "sender")?;
let identity = get(sender, "identity").cloned().unwrap_or(Value::Null);
let metadata = get(d, "metadata").cloned().unwrap_or(Value::Null);
Some(ChatMessage {
id: id_string(d, "id")?,
chatroom_id: uint(d, "chatroom_id").map(ChatroomId),
content: string(d, "content")?,
kind: string(d, "type").unwrap_or_else(|| "message".into()),
created_at: string(d, "created_at"),
sender: Sender {
id: uint(sender, "id").map(UserId),
username: string(sender, "username")?,
slug: string(sender, "slug"),
color: string(&identity, "color"),
badges: array(&identity, "badges")
.iter()
.filter_map(|b| {
Some(Badge { kind: string(b, "type")?, text: string(b, "text"), count: int(b, "count") })
})
.collect(),
badges_v2: array(&identity, "badges_v2")
.iter()
.filter(|b| boolean(b, "selected") != Some(false))
.filter_map(|b| {
Some(BadgeImage {
name: string(b, "name")?,
badge_type: string(b, "badge_type"),
image_url: string(b, "image_url")?,
level: get(b, "metadata").and_then(|m| int(m, "level")),
sort_order: int(b, "sort_order").unwrap_or(0),
})
})
.collect(),
},
reply_to: get(&metadata, "original_message").map(|original| {
let parent_sender = get(&metadata, "original_sender").cloned().unwrap_or(Value::Null);
ReplyTo {
message_id: id_string(original, "id"),
content: string(original, "content"),
sender_id: uint(&parent_sender, "id").map(UserId),
sender_username: string(&parent_sender, "username"),
}
}),
thread_parent_id: id_string(d, "thread_parent_id"),
message_ref: id_string(&metadata, "message_ref"),
})
}
fn pin(d: &Value) -> KickEvent {
KickEvent::PinCreated {
message: get(d, "message").and_then(chat_message),
pinned_by: get(d, "pinnedBy").or_else(|| get(d, "pinned_by")).and_then(UserRef::from),
duration: int(d, "duration"),
finish_at: string(d, "finish_at"),
}
}
fn kicks(d: &Value) -> KickEvent {
let gift = get(d, "gift").cloned().unwrap_or(Value::Null);
let sender = get(d, "sender").cloned().unwrap_or(Value::Null);
KickEvent::KicksGifted {
sender: UserRef::from(&sender),
sender_color: string(&sender, "username_color"),
gift: KicksGift {
gift_id: id_string(&gift, "gift_id"),
name: string(&gift, "name"),
amount: int(&gift, "amount").unwrap_or(0),
kind: string(&gift, "type"),
tier: string(&gift, "tier"),
pinned_time: int(&gift, "pinned_time"),
},
message: string(d, "message").filter(|m| !m.is_empty()),
}
}
pub(crate) fn pin_from_history(d: &Value) -> Option<KickEvent> {
match pin(d) {
KickEvent::PinCreated { message: None, .. } => None,
event => Some(event),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn unknown_events_are_kept_by_name() {
assert_eq!(
parse("App\\Events\\Whatever", &json!("{}")),
KickEvent::Unknown { name: "App\\Events\\Whatever".into() }
);
}
#[test]
fn a_message_without_text_is_unknown_rather_than_an_error() {
let data = json!({"id": "x", "sender": {"username": "a"}});
assert!(matches!(parse(names::CHAT_MESSAGE, &data), KickEvent::Unknown { .. }));
}
#[test]
fn kicks_message_is_beside_the_gift() {
let data = json!({"sender": {"id": 1, "username": "a", "username_color": "#fff"},
"gift": {"gift_id": "rage_quit", "name": "Rage Quit", "amount": 500, "type": "LEVEL_UP", "tier": "MID", "pinned_time": 600},
"message": "gg"});
let KickEvent::KicksGifted { gift, message, .. } = parse(names::KICKS_GIFTED, &data) else { panic!() };
assert_eq!(gift.amount, 500);
assert_eq!(message.as_deref(), Some("gg"));
}
#[test]
fn hidden_badges_are_left_out() {
let data = json!({"id": "1", "content": "hi", "sender": {"id": 2, "username": "a", "identity": {"badges_v2": [
{"name": "level", "image_url": "u", "selected": false},
{"name": "sub", "image_url": "v", "metadata": {"level": 3}}]}}});
let KickEvent::ChatMessage(m) = parse(names::CHAT_MESSAGE, &data) else { panic!() };
assert_eq!(m.sender.badges_v2.len(), 1);
assert_eq!(m.sender.badges_v2[0].level, Some(3));
}
}