From e82911769af95270e9e2715b0f93267df580ba1c Mon Sep 17 00:00:00 2001 From: Dillon Mulroy Date: Mon, 15 Apr 2024 10:09:04 -0400 Subject: [PATCH] add notification message parsing --- src/glitch.gleam | 6 +- src/glitch/api/transport.gleam | 23 -- src/glitch/eventsub/eventsub.gleam | 39 ++- src/glitch/eventsub/subscription.gleam | 0 src/glitch/eventsub/websocket_message.gleam | 348 +++++++++++++++++++- src/glitch/eventsub/websocket_server.gleam | 2 + src/glitch/extended/dynamic_ext.gleam | 88 ++++- src/glitch/types/subscription.gleam | 27 ++ src/glitch/types/transport.gleam | 14 +- 9 files changed, 505 insertions(+), 42 deletions(-) delete mode 100644 src/glitch/api/transport.gleam create mode 100644 src/glitch/eventsub/subscription.gleam diff --git a/src/glitch.gleam b/src/glitch.gleam index 413affb..a529711 100644 --- a/src/glitch.gleam +++ b/src/glitch.gleam @@ -16,6 +16,8 @@ pub fn get_access_token() { let assert Ok(client_secret) = env.get("CLIENT_SECRET") let scopes = [ scope.ChannelBot, + scope.ChannelManageRedemptions, + scope.ChannelReadRedemptions, scope.ChannelReadSubscriptions, scope.ChatRead, scope.UserBot, @@ -47,6 +49,8 @@ fn new_client() { let assert Ok(client_secret) = env.get("CLIENT_SECRET") let scopes = [ scope.ChannelBot, + scope.ChannelManageRedemptions, + scope.ChannelReadRedemptions, scope.ChannelReadSubscriptions, scope.ChatRead, scope.UserBot, @@ -93,6 +97,6 @@ pub fn test_eventsub() { pub fn main() { // get_access_token() - // let _ = test_chat() + // // let _ = test_chat() test_eventsub() } diff --git a/src/glitch/api/transport.gleam b/src/glitch/api/transport.gleam deleted file mode 100644 index baff859..0000000 --- a/src/glitch/api/transport.gleam +++ /dev/null @@ -1,23 +0,0 @@ -pub type Transport - -// Transport() - -pub type Method { - WebHook - WebSocket -} - -pub fn method_to_string(method: Method) -> String { - case method { - WebHook -> "webhook" - WebSocket -> "websocket" - } -} - -pub fn method_from_string(string: String) -> Result(Method, Nil) { - case string { - "webhook" -> Ok(WebHook) - "websocket" -> Ok(WebSocket) - _ -> Error(Nil) - } -} diff --git a/src/glitch/eventsub/eventsub.gleam b/src/glitch/eventsub/eventsub.gleam index 59fcd54..1c9ca68 100644 --- a/src/glitch/eventsub/eventsub.gleam +++ b/src/glitch/eventsub/eventsub.gleam @@ -60,6 +60,10 @@ fn handle(state: EventSub, message: WebSocketMessage) { io.println("Closed") io.debug(message) } + websocket_message.NotificationMessage(..) -> { + io.println("NotificationMessage") + io.debug(message) + } websocket_message.SessionKeepaliveMessage(..) -> { io.println("SessionKeepaliveMessage") io.debug(message) @@ -69,11 +73,40 @@ fn handle(state: EventSub, message: WebSocketMessage) { io.debug(message) } websocket_message.WelcomeMessage(_, payload) -> { + // let resp = + // eventsub.create_eventsub_subscription( + // state.client, + // CreateEventSubSubscriptionRequest( + // ChannelChatMessage, + // "1", + // Condition( + // Some("209286766"), + // None, + // None, + // None, + // None, + // Some(client.client_id(state.client)), + // None, + // Some("209286766"), + // ), + // Transport( + // WebSocket, + // None, + // None, + // Some(payload.session.id), + // None, + // None, + // None, + // ), + // ), + // ) + // let _ = io.debug(resp) + let resp = eventsub.create_eventsub_subscription( state.client, CreateEventSubSubscriptionRequest( - ChannelChatMessage, + subscription.ChannelPointsCustomRewardRedemptionAdd, "1", Condition( Some("209286766"), @@ -81,9 +114,9 @@ fn handle(state: EventSub, message: WebSocketMessage) { None, None, None, - Some(client.client_id(state.client)), None, - Some("209286766"), + None, + None, ), Transport( WebSocket, diff --git a/src/glitch/eventsub/subscription.gleam b/src/glitch/eventsub/subscription.gleam new file mode 100644 index 0000000..e69de29 diff --git a/src/glitch/eventsub/websocket_message.gleam b/src/glitch/eventsub/websocket_message.gleam index e5786c6..b2fc130 100644 --- a/src/glitch/eventsub/websocket_message.gleam +++ b/src/glitch/eventsub/websocket_message.gleam @@ -4,10 +4,14 @@ import gleam/option.{type Option} import gleam/result import gleam/uri.{type Uri} import glitch/extended/dynamic_ext -import glitch/types/subscription.{type SubscriptionType} +import glitch/types/subscription.{type Subscription, type SubscriptionType} pub type WebSocketMessage { Close + NotificationMessage( + metadata: SubscriptionMetadata, + payload: NotificationMessagePayload, + ) SessionKeepaliveMessage(metadata: Metadata) UnhandledMessage(raw_message: String) WelcomeMessage(metadata: Metadata, payload: WelcomeMessagePayload) @@ -18,13 +22,17 @@ pub fn from_json(json_string: String) -> Result(WebSocketMessage, DecodeError) { } pub fn decoder() -> Decoder(WebSocketMessage) { - dynamic.any([welcome_message_decoder(), session_keepalive_message_decoder()]) + dynamic.any([ + welcome_message_decoder(), + notification_message_decoder(), + session_keepalive_message_decoder(), + ]) } fn welcome_message_decoder() -> Decoder(WebSocketMessage) { dynamic.decode2( WelcomeMessage, - dynamic.field("metadata", metadate_decoder()), + dynamic.field("metadata", metadata_decoder()), dynamic.field("payload", welcome_message_payload_decoder()), ) } @@ -32,7 +40,15 @@ fn welcome_message_decoder() -> Decoder(WebSocketMessage) { fn session_keepalive_message_decoder() -> Decoder(WebSocketMessage) { dynamic.decode1( SessionKeepaliveMessage, - dynamic.field("metadata", metadate_decoder()), + dynamic.field("metadata", metadata_decoder()), + ) +} + +fn notification_message_decoder() -> Decoder(WebSocketMessage) { + dynamic.decode2( + NotificationMessage, + dynamic.field("metadata", subscription_metadata_decoder()), + dynamic.field("payload", notification_message_payload_decoder()), ) } @@ -89,7 +105,7 @@ pub type Metadata { ) } -fn metadate_decoder() -> Decoder(Metadata) { +fn metadata_decoder() -> Decoder(Metadata) { dynamic.decode3( Metadata, dynamic.field("message_id", dynamic.string), @@ -108,6 +124,17 @@ pub type SubscriptionMetadata { ) } +fn subscription_metadata_decoder() -> Decoder(SubscriptionMetadata) { + dynamic.decode5( + SubscriptionMetadata, + dynamic.field("message_id", dynamic.string), + dynamic.field("message_type", message_type_decoder()), + dynamic.field("message_timestamp", dynamic.string), + dynamic.field("subscription_type", subscription.subscription_type_decoder()), + dynamic.field("subscription_version", dynamic.string), + ) +} + pub type SessionStatus { Connected } @@ -172,3 +199,314 @@ fn welcome_message_payload_decoder() -> Decoder(WelcomeMessagePayload) { dynamic.field("session", session_decoder()), ) } + +pub type NotificationMessagePayload { + NotificationMessagePayload(subscription: Subscription, event: Event) +} + +fn notification_message_payload_decoder() -> Decoder(NotificationMessagePayload) { + dynamic.decode2( + NotificationMessagePayload, + dynamic.field("subscription", subscription.decoder()), + dynamic.field("event", event_decoder()), + ) +} + +pub type Event { + ChannelChatMessageEvent( + broadcaster_user_id: String, + broadcaster_user_login: String, + broadcaster_user_name: String, + chatter_user_id: String, + chatter_user_login: String, + chatter_user_name: String, + message_id: String, + message: ChannelChatMessage, + color: String, + badges: List(Badge), + message_type: ChannelChatMessageType, + cheer: Option(Cheer), + reply: Option(ChannelChatMessageReply), + channel_points_custom_reward_id: Option(String), + ) + RewardEvent(id: String, title: String, cost: Int, prompt: String) +} + +fn event_decoder() -> Decoder(Event) { + dynamic.any([channel_chat_messsage_event_decoder(), reward_event_decoder()]) +} + +fn channel_chat_messsage_event_decoder() -> Decoder(Event) { + dynamic_ext.decode14( + ChannelChatMessageEvent, + dynamic.field("broadcaster_user_id", dynamic.string), + dynamic.field("broadcaster_user_login", dynamic.string), + dynamic.field("broadcaster_user_name", dynamic.string), + dynamic.field("chatter_user_id", dynamic.string), + dynamic.field("chatter_user_login", dynamic.string), + dynamic.field("chatter_user_name", dynamic.string), + dynamic.field("message_id", dynamic.string), + dynamic.field("message", channel_chat_message_decoder()), + dynamic.field("color", dynamic.string), + dynamic.field("badges", dynamic.list(of: badge_decoder())), + dynamic.field("message_type", channel_chat_messsage_type_decoder()), + dynamic.optional_field("cheer", cheer_decoder()), + dynamic.optional_field("reply", channel_chat_message_reply_decoder()), + dynamic.optional_field("channel_points_custom_reward_id", dynamic.string), + ) +} + +pub type ChannelChatMessageType { + Text + ChannelPointsHighlighted + ChannelPointsSubOnly + UserIntro +} + +pub fn channel_chat_messsage_type_to_string( + channel_chat_messsage_type: ChannelChatMessageType, +) -> String { + case channel_chat_messsage_type { + Text -> "text" + ChannelPointsHighlighted -> "channel_point_highlighted" + ChannelPointsSubOnly -> "channel_points_sub_only" + UserIntro -> "user_intro" + } +} + +pub fn channel_chat_messsage_type_from_string( + string: String, +) -> Result(ChannelChatMessageType, Nil) { + case string { + "text" -> Ok(Text) + "channel_point_highlighted" -> Ok(ChannelPointsHighlighted) + "channel_points_sub_only" -> Ok(ChannelPointsSubOnly) + "user_intro" -> Ok(UserIntro) + _ -> Error(Nil) + } +} + +pub fn channel_chat_messsage_type_decoder() -> Decoder(ChannelChatMessageType) { + fn(data: dynamic.Dynamic) { + use string <- result.try(dynamic.string(data)) + + string + |> channel_chat_messsage_type_from_string + |> result.replace_error([ + dynamic.DecodeError( + expected: "ChannelChatMessageType", + found: "String(" <> string <> ")", + path: [], + ), + ]) + } +} + +pub type Badge { + Badge(set_id: String, id: String, info: String) +} + +pub fn badge_decoder() -> Decoder(Badge) { + dynamic.decode3( + Badge, + dynamic.field("set_id", dynamic.string), + dynamic.field("id", dynamic.string), + dynamic.field("info", dynamic.string), + ) +} + +pub type Cheer { + Cheer(bits: Int) +} + +pub fn cheer_decoder() -> Decoder(Cheer) { + dynamic.decode1(Cheer, dynamic.field("bits", dynamic.int)) +} + +pub type ChannelChatMessageReply { + ChannelChatMessageReply( + parent_message_id: String, + parent_message_body: String, + parent_user_id: String, + parent_user_name: String, + thread_message_id: String, + thread_user_id: String, + thread_user_name: String, + thread_user_login: String, + ) +} + +pub fn channel_chat_message_reply_decoder() -> Decoder(ChannelChatMessageReply) { + dynamic.decode8( + ChannelChatMessageReply, + dynamic.field("parent_message_id", dynamic.string), + dynamic.field("parent_message_body", dynamic.string), + dynamic.field("parent_message_body", dynamic.string), + dynamic.field("parent_user_name", dynamic.string), + dynamic.field("parent_user_name", dynamic.string), + dynamic.field("thread_user_id", dynamic.string), + dynamic.field("thread_user_id", dynamic.string), + dynamic.field("thread_user_login", dynamic.string), + ) +} + +pub type ChannelChatMessage { + ChannelChatMessage(text: String, fragments: List(MessageFragment)) +} + +pub fn channel_chat_message_decoder() -> Decoder(ChannelChatMessage) { + dynamic.decode2( + ChannelChatMessage, + dynamic.field("text", dynamic.string), + dynamic.field("fragments", dynamic.list(of: message_fragment_decoder())), + ) +} + +pub type MessageFragment { + MessageFragment( + fragment_type: FragmentType, + text: String, + cheermote: Option(Cheermote), + emote: Option(Emote), + mention: Option(Mention), + ) +} + +fn message_fragment_decoder() -> Decoder(MessageFragment) { + dynamic.decode5( + MessageFragment, + dynamic.field("type", fragment_type_decoder()), + dynamic.field("text", dynamic.string), + dynamic.optional_field("cheermote", cheermote_decoder()), + dynamic.optional_field("emote", emote_decoder()), + dynamic.optional_field("mention", mention_decoder()), + ) +} + +pub type FragmentType { + TextFragment + CheermoteFragment + EmoteFragment + MentionFragment +} + +pub fn fragment_type_to_string(fragment_type: FragmentType) -> String { + case fragment_type { + TextFragment -> "text" + CheermoteFragment -> "cheermote" + EmoteFragment -> "emote" + MentionFragment -> "mentiond" + } +} + +pub fn fragment_type_from_string(string: String) -> Result(FragmentType, Nil) { + case string { + "text" -> Ok(TextFragment) + "cheermote" -> Ok(CheermoteFragment) + "emote" -> Ok(EmoteFragment) + "mentiond" -> Ok(MentionFragment) + _ -> Error(Nil) + } +} + +pub fn fragment_type_decoder() -> Decoder(FragmentType) { + fn(data: dynamic.Dynamic) { + use string <- result.try(dynamic.string(data)) + + string + |> fragment_type_from_string + |> result.replace_error([ + dynamic.DecodeError( + expected: "FragmentType", + found: "String(" <> string <> ")", + path: [], + ), + ]) + } +} + +pub type Cheermote { + Cheermote(prefix: String, bits: Int, tier: Int) +} + +pub fn cheermote_decoder() -> Decoder(Cheermote) { + dynamic.decode3( + Cheermote, + dynamic.field("prefix", dynamic.string), + dynamic.field("bits", dynamic.int), + dynamic.field("tier", dynamic.int), + ) +} + +pub type Emote { + Emote(id: String, emote_set_id: String, owner_id: String, format: EmoteFormat) +} + +pub fn emote_decoder() -> Decoder(Emote) { + dynamic.decode4( + Emote, + dynamic.field("id", dynamic.string), + dynamic.field("emote_set_id", dynamic.string), + dynamic.field("owner_id", dynamic.string), + dynamic.field("format", emote_format_decoder()), + ) +} + +pub type EmoteFormat { + Animated + Static +} + +pub fn emote_format_to_string(emote_format: EmoteFormat) -> String { + case emote_format { + Animated -> "animated" + Static -> "static" + } +} + +pub fn emote_format_from_string(string: String) -> Result(EmoteFormat, Nil) { + case string { + "animated" -> Ok(Animated) + "static" -> Ok(Static) + _ -> Error(Nil) + } +} + +pub fn emote_format_decoder() -> Decoder(EmoteFormat) { + fn(data: dynamic.Dynamic) { + use string <- result.try(dynamic.string(data)) + + string + |> emote_format_from_string + |> result.replace_error([ + dynamic.DecodeError( + expected: "EmoteFormat", + found: "String(" <> string <> ")", + path: [], + ), + ]) + } +} + +pub type Mention { + Mention(user_id: String, user_name: String, user_login: String) +} + +pub fn mention_decoder() -> Decoder(Mention) { + dynamic.decode3( + Mention, + dynamic.field("user_id", dynamic.string), + dynamic.field("user_name", dynamic.string), + dynamic.field("user_login", dynamic.string), + ) +} + +fn reward_event_decoder() -> Decoder(Event) { + dynamic.decode4( + RewardEvent, + dynamic.field("id", dynamic.string), + dynamic.field("title", dynamic.string), + dynamic.field("cost", dynamic.int), + dynamic.field("prompt", dynamic.string), + ) +} diff --git a/src/glitch/eventsub/websocket_server.gleam b/src/glitch/eventsub/websocket_server.gleam index 6f829ba..0623c44 100644 --- a/src/glitch/eventsub/websocket_server.gleam +++ b/src/glitch/eventsub/websocket_server.gleam @@ -80,8 +80,10 @@ fn handle_start(state: WebSockerServer) { loop: fn(message, state, _conn) { case message { stratus.Text(message) -> { + io.debug(message) let decoded_message = websocket_message.from_json(message) + |> function.tap(io.debug) |> result.unwrap(UnhandledMessage(message)) process.send(state.mailbox, decoded_message) diff --git a/src/glitch/extended/dynamic_ext.gleam b/src/glitch/extended/dynamic_ext.gleam index 1881c20..6101b42 100644 --- a/src/glitch/extended/dynamic_ext.gleam +++ b/src/glitch/extended/dynamic_ext.gleam @@ -84,9 +84,18 @@ pub fn decode11( t10(x), t11(x) { - Ok(a), Ok(b), Ok(c), Ok(d), Ok(e), Ok(f), Ok(g), Ok(h), Ok(i), Ok(j), Ok( - k, - ) -> Ok(constructor(a, b, c, d, e, f, g, h, i, j, k)) + Ok(a), + Ok(b), + Ok(c), + Ok(d), + Ok(e), + Ok(f), + Ok(g), + Ok(h), + Ok(i), + Ok(j), + Ok(k) + -> Ok(constructor(a, b, c, d, e, f, g, h, i, j, k)) a, b, c, d, e, f, g, h, i, j, k -> Error( list.concat([ @@ -107,6 +116,79 @@ pub fn decode11( } } +pub fn decode14( + constructor: fn(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13, t14) -> + t, + t1: Decoder(t1), + t2: Decoder(t2), + t3: Decoder(t3), + t4: Decoder(t4), + t5: Decoder(t5), + t6: Decoder(t6), + t7: Decoder(t7), + t8: Decoder(t8), + t9: Decoder(t9), + t10: Decoder(t10), + t11: Decoder(t11), + t12: Decoder(t12), + t13: Decoder(t13), + t14: Decoder(t14), +) -> Decoder(t) { + fn(x: Dynamic) { + case + t1(x), + t2(x), + t3(x), + t4(x), + t5(x), + t6(x), + t7(x), + t8(x), + t9(x), + t10(x), + t11(x), + t12(x), + t13(x), + t14(x) + { + Ok(a), + Ok(b), + Ok(c), + Ok(d), + Ok(e), + Ok(f), + Ok(g), + Ok(h), + Ok(i), + Ok(j), + Ok(k), + Ok(l), + Ok(m), + Ok(n) + -> Ok(constructor(a, b, c, d, e, f, g, h, i, j, k, l, m, n)) + a, b, c, d, e, f, g, h, i, j, k, l, m, n -> + Error( + list.concat([ + all_errors(a), + all_errors(b), + all_errors(c), + all_errors(d), + all_errors(e), + all_errors(f), + all_errors(g), + all_errors(h), + all_errors(i), + all_errors(j), + all_errors(k), + all_errors(l), + all_errors(m), + all_errors(n), + ]), + ) + } + } +} + fn all_errors(result: Result(a, List(DecodeError))) -> List(DecodeError) { case result { Ok(_) -> [] diff --git a/src/glitch/types/subscription.gleam b/src/glitch/types/subscription.gleam index 6d2d8e5..dc62a51 100644 --- a/src/glitch/types/subscription.gleam +++ b/src/glitch/types/subscription.gleam @@ -3,6 +3,33 @@ import gleam/json.{ type DecodeError as JsonDecodeError, type Json, UnexpectedFormat, } import gleam/result +import glitch/types/condition.{type Condition} +import glitch/types/transport.{type Transport} + +pub type Subscription { + Subscription( + id: String, + subscription_status: SubscriptionStatus, + subscription_type: SubscriptionType, + version: String, + cost: Int, + condition: Condition, + transport: Transport, + ) +} + +pub fn decoder() -> Decoder(Subscription) { + dynamic.decode7( + Subscription, + dynamic.field("id", dynamic.string), + dynamic.field("status", subscription_status_decoder()), + dynamic.field("type", subscription_type_decoder()), + dynamic.field("version", dynamic.string), + dynamic.field("cost", dynamic.int), + dynamic.field("condition", condition.decoder()), + dynamic.field("transport", transport.decoder()), + ) +} pub type SubscriptionType { AutomodMessageHold diff --git a/src/glitch/types/transport.gleam b/src/glitch/types/transport.gleam index 59cea24..9a75c80 100644 --- a/src/glitch/types/transport.gleam +++ b/src/glitch/types/transport.gleam @@ -1,7 +1,7 @@ import gleam/dynamic.{type Decoder} +import gleam/json.{type DecodeError as JsonDecodeError, type Json} import gleam/option.{type Option} import gleam/result -import gleam/json.{type DecodeError as JsonDecodeError, type Json} import glitch/extended/json_ext pub type Transport { @@ -25,12 +25,12 @@ pub fn decoder() -> Decoder(Transport) { dynamic.decode7( Transport, dynamic.field("method", method_decoder()), - dynamic.field("callback", dynamic.optional(dynamic.string)), - dynamic.field("secret", dynamic.optional(dynamic.string)), - dynamic.field("session_id", dynamic.optional(dynamic.string)), - dynamic.field("connected_at", dynamic.optional(dynamic.string)), - dynamic.field("disconnected_at", dynamic.optional(dynamic.string)), - dynamic.field("conduit_id", dynamic.optional(dynamic.string)), + dynamic.optional_field("callback", dynamic.string), + dynamic.optional_field("secret", dynamic.string), + dynamic.optional_field("session_id", dynamic.string), + dynamic.optional_field("connected_at", dynamic.string), + dynamic.optional_field("disconnected_at", dynamic.string), + dynamic.optional_field("conduit_id", dynamic.string), ) }