diff --git a/README.md b/README.md index 3289150..25e47b3 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,10 @@ defmodule MyBot do end ``` +## EventSub + +https://twitchapps.com/tokengen + #### Available handler callbacks: handle_connected(server, port) diff --git a/lib/tmi/event_sub/events.ex b/lib/tmi/event_sub/events.ex new file mode 100644 index 0000000..59becb5 --- /dev/null +++ b/lib/tmi/event_sub/events.ex @@ -0,0 +1,78 @@ +defmodule TMI.EventSub.Events do + require Logger + + @events %{ + "channel.follow" => TMI.Events.Follow, + "channel.shoutout.create" => TMI.Events.ShoutoutCreate, + "channel.shoutout.receive" => TMI.Events.ShoutoutReceive + } + + @doc """ + Take a payload and return a `TMI.Event` struct. + + ## Examples + + iex> payload = %{ + ...> "broadcaster_user_id" => "146616692", + ...> "broadcaster_user_login" => "ryanwinchester_", + ...> "broadcaster_user_name" => "RyanWinchester_", + ...> "followed_at" => "2024-01-19T03:32:41.640955348Z", + ...> "user_id" => "589368619", + ...> "user_login" => "foobar", + ...> "user_name" => "FooBar" + ...> } + iex> from_payload("channel.follow", payload) + %TMI.Events.Follow{ + broadcaster_user_id: "146616692", + broadcaster_user_login: "ryanwinchester_", + broadcaster_user_name: "RyanWinchester_", + followed_at: ~U[2024-01-19 03:32:41.640955Z], + user_id: "589368619", + user_login: "foobar", + user_name: "FooBar" + } + + """ + @spec from_payload(String.t(), map()) :: TMI.Event.event() + def from_payload(event_type, payload) + + for {event, module} <- @events do + def from_payload(unquote(event), payload) do + payload + |> Enum.map(&payload_map/1) + |> then(&struct(unquote(module), &1)) + end + end + + # TODO: Map the field names similar to in the IRC client. + # e.g. from "broadcaster_user_name" to :broadcaster_display_name. + + defp payload_map({"followed_at", val}) do + followed_at = + case DateTime.from_iso8601(val) do + {:ok, dt, 0} -> dt + {:ok, _dt, offset} -> raise "unexpected offset (#{offset}) parsing #{val}" + _bad -> raise "Bad datetime #{val}" + end + + {:followed_at, followed_at} + end + + # TODO: For now, use to_atom, because Modules aren't loaded during dev + # until used. + defp payload_map({field, val}), do: {String.to_atom(field), val} + + # defp payload_map({field, val}) do + # try do + # {String.to_atom(field), val} + # rescue + # ArgumentError -> + # Logger.warning(""" + # You have found an unexpected field: #{inspect({field, val})}. + # Please open an issue at + # """) + + # {field, val} + # end + # end +end diff --git a/lib/tmi/event_sub/socket.ex b/lib/tmi/event_sub/socket.ex new file mode 100644 index 0000000..9dc4176 --- /dev/null +++ b/lib/tmi/event_sub/socket.ex @@ -0,0 +1,333 @@ +defmodule TMI.EventSub.Socket do + @moduledoc false + use WebSockex + + require Logger + + alias TMI.Twitch.Client + + @default_url "wss://eventsub.wss.twitch.tv/ws" + + @default_keepalive_timeout 30 + + @required_opts ~w[user_id client_id access_token handler]a + @allowed_opts @required_opts ++ ~w[subscriptions] + + @default_subs ~w[ + channel.ad_break.begin channel.cheer channel.follow channel.subscription.end + channel.channel_points_custom_reward_redemption.add + channel.channel_points_custom_reward_redemption.update + channel.charity_campaign.donate channel.charity_campaign.progress + channel.goal.begin channel.goal.progress channel.goal.end + channel.hype_train.begin channel.hype_train.progress channel.hype_train.end + channel.shoutout.create channel.shoutout.receive + stream.online stream.offline + ] + + # TODO: `extension.bits_transaction.create` requires extension_client_id + + @doc """ + Starts the connection to the EventSub WebSocket server. + + ## Options + + * `:client_id` - Twitch app client id. + * `:access_token` - Twitch app access token with required scopes for the + provided `:subscriptions`. + * `:subscriptions` - The subscriptions for EventSub. + * `:url` - A websocket URL to connect to. `Defaults to "wss://eventsub.wss.twitch.tv/ws"`. + * `:keepalive_timeout` - The keepalive timeout in seconds. Specifying an invalid, + but numeric value will return the nearest acceptable value. Defaults to `10`. + + """ + def start_link(opts) do + Logger.info("[TMI.EventSub.Socket] connecting...") + + if not Enum.all?(@required_opts, &Keyword.has_key?(opts, &1)) do + raise ArgumentError, + message: "missing one of the required options, got: #{inspect(Keyword.keys(opts))}" + end + + keepalive = Keyword.get(opts, :keepalive_timeout, @default_keepalive_timeout) + query = URI.encode_query(keepalive_timeout_seconds: keepalive) + + url = + opts + |> Keyword.get(:url, @default_url) + |> URI.parse() + |> URI.append_query(query) + |> URI.to_string() + + state = + opts + |> Keyword.take(@allowed_opts) + |> Keyword.merge(url: url) + |> Map.new() + + WebSockex.start_link(url, __MODULE__, state) + end + + # ---------------------------------------------------------------------------- + # Callbacks + # ---------------------------------------------------------------------------- + + @impl WebSockex + def handle_frame({:text, msg}, state) do + case Jason.decode(msg) do + {:ok, %{"metadata" => metadata, "payload" => payload}} -> + handle_message(metadata, payload, state) + {:ok, state} + + _ -> + Logger.warning("[TMI.EventSub.Socket] Unhandled message: #{msg}") + {:ok, state} + end + end + + @impl WebSockex + def handle_frame({type, msg}, state) do + Logger.debug( + "[TMI.EventSub.Socket] unhandled message type: #{inspect(type)}, msg: #{inspect(msg)}" + ) + + {:ok, state} + end + + @impl WebSockex + def handle_cast({:send, {type, msg} = frame}, state) do + Logger.debug("[TMI.EventSub.Socket] sending #{type} frame with payload: #{msg}") + {:reply, frame, state} + end + + # ---------------------------------------------------------------------------- + # Helpers + # ---------------------------------------------------------------------------- + + # ## Welcome message + # + # When you connect, Twitch replies with a welcome message. + # The `message_type` field is set to `session_welcome`. This message contains + # the WebSocket session’s ID that you use when subscribing to events. + # + # **IMPORTANT** By default, you have 10 seconds from the time you receive + # the Welcome message to subscribe to an event, unless otherwise specified + # when connecting. If you don’t subscribe within this timeframe, the + # server closes the connection. + # + # { + # "metadata": { + # "message_id": "96a3f3b5-5dec-4eed-908e-e11ee657416c", + # "message_type": "session_welcome", + # "message_timestamp": "2023-07-19T14:56:51.634234626Z" + # }, + # "payload": { + # "session": { + # "id": "AQoQILE98gtqShGmLD7AM6yJThAB", + # "status": "connected", + # "connected_at": "2023-07-19T14:56:51.616329898Z", + # "keepalive_timeout_seconds": 10, + # "reconnect_url": null + # } + # } + # } + # + defp handle_message(%{"message_type" => "session_welcome"}, payload, state) do + Logger.info("[TMI.EventSub.Socket] connected") + + session_id = get_in(payload, ["session", "id"]) + client_id = state.client_id + user_id = state.user_id + access_token = state.access_token + subscriptions = Map.get(state, :subscriptions, @default_subs) + + # TODO: Supervised task anyone? + Enum.each(subscriptions, fn type -> + Client.create_subscription(type, user_id, session_id, client_id, access_token) + end) + end + + # ## Keepalive message + # + # The keepalive messages indicate that the WebSocket connection is healthy. + # The server sends this message if Twitch doesn’t deliver an event + # notification within the keepalive_timeout_seconds window specified in + # the Welcome message. + # + # If your client doesn’t receive an event or keepalive message for longer + # than keepalive_timeout_seconds, you should assume the connection is lost + # and reconnect to the server and resubscribe to the events. The keepalive + # timer is reset with each notification or keepalive message. + # + # { + # "metadata": { + # "message_id": "84c1e79a-2a4b-4c13-ba0b-4312293e9308", + # "message_type": "session_keepalive", + # "message_timestamp": "2023-07-19T10:11:12.634234626Z" + # }, + # "payload": {} + # } + # + defp handle_message(%{"message_type" => "session_keepalive"}, _payload, _state) do + Logger.info("[TMI.EventSub.Socket] keepalive") + end + + # ## Notification message + # + # A notification message is sent when an event that you subscribe to occurs. + # The message contains the event’s details. + # + # { + # "metadata": { + # "message_id": "befa7b53-d79d-478f-86b9-120f112b044e", + # "message_type": "notification", + # "message_timestamp": "2022-11-16T10:11:12.464757833Z", + # "subscription_type": "channel.follow", + # "subscription_version": "1" + # }, + # "payload": { + # "subscription": { + # "id": "f1c2a387-161a-49f9-a165-0f21d7a4e1c4", + # "status": "enabled", + # "type": "channel.follow", + # "version": "1", + # "cost": 1, + # "condition": { + # "broadcaster_user_id": "12826" + # }, + # "transport": { + # "method": "websocket", + # "session_id": "AQoQexAWVYKSTIu4ec_2VAxyuhAB" + # }, + # "created_at": "2022-11-16T10:11:12.464757833Z" + # }, + # "event": { + # "user_id": "1337", + # "user_login": "awesome_user", + # "user_name": "Awesome_User", + # "broadcaster_user_id": "12826", + # "broadcaster_user_login": "twitch", + # "broadcaster_user_name": "Twitch", + # "followed_at": "2023-07-15T18:16:11.17106713Z" + # } + # } + # } + # + defp handle_message(%{"message_type" => "notification", "subscription_type" => type}, %{"event" => payload}, state) do + Logger.debug("[TMI.EventSub.Socket] got notification: " <> inspect(payload, pretty: true)) + + type + |> TMI.EventSub.Events.from_payload(payload) + |> state.handler.handle_event() + end + + # ## Reconnect message + # + # A reconnect message is sent if the edge server that the client is connected + # to needs to be swapped. This message is sent 30 seconds prior to closing the + # connection, specifying a new URL for the client to connect to. Following the + # reconnect flow will ensure no messages are dropped in the process. + # + # The message includes a URL in the `reconnect_url` field that you should + # immediately use to create a new connection. The connection will include the + # same subscriptions that the old connection had. You should not close the old + # connection until you receive a Welcome message on the new connection. + # + # **NOTE** Use the reconnect URL as is; do not modify it. + # + # The old connection receives events up until you connect to the new URL and + # receive the welcome message to ensure an uninterrupted flow of notifications. + # + # **NOTE** Twitch sends the old connection a close frame with code `4004` if + # you connect to the new socket but never disconnect from the old socket or + # you don’t connect to the new socket within the specified timeframe. + # + # { + # "metadata": { + # "message_id": "84c1e79a-2a4b-4c13-ba0b-4312293e9308", + # "message_type": "session_reconnect", + # "message_timestamp": "2022-11-18T09:10:11.634234626Z" + # }, + # "payload": { + # "session": { + # "id": "AQoQexAWVYKSTIu4ec_2VAxyuhAB", + # "status": "reconnecting", + # "keepalive_timeout_seconds": null, + # "reconnect_url": "wss://eventsub.wss.twitch.tv?...", + # "connected_at": "2022-11-16T10:11:12.634234626Z" + # } + # } + # } + # + defp handle_message(%{"message_type" => "session_reconnect"}, _payload, _state) do + Logger.debug("[TMI.EventSub.Socket] reconnect message") + end + + # ## Revocation message + # + # A revocation message is sent if Twitch revokes a subscription. The + # `subscription` object’s `type` field identifies the subscription that was + # revoked, and the `status` field identifies the reason why the subscription was + # revoked. Twitch revokes your subscription in the following cases: + # + # - The user mentioned in the subscription no longer exists. The + # notification’s `status` field is set to user_removed. + # - The user revoked the authorization token that the subscription relied on. + # The notification’s `status` field is set to `authorization_revoked`. + # - The subscribed to subscription type and version is no longer supported. + # The notification’s `status` field is set to `version_removed`. + # + # You’ll receive this message once and then no longer receive messages for the + # specified user and subscription type. + # + # Twitch reserves the right to revoke a subscription at any time. + # + # { + # "metadata": { + # "message_id": "84c1e79a-2a4b-4c13-ba0b-4312293e9308", + # "message_type": "revocation", + # "message_timestamp": "2022-11-16T10:11:12.464757833Z", + # "subscription_type": "channel.follow", + # "subscription_version": "1" + # }, + # "payload": { + # "subscription": { + # "id": "f1c2a387-161a-49f9-a165-0f21d7a4e1c4", + # "status": "authorization_revoked", + # "type": "channel.follow", + # "version": "1", + # "cost": 1, + # "condition": { + # "broadcaster_user_id": "12826" + # }, + # "transport": { + # "method": "websocket", + # "session_id": "AQoQexAWVYKSTIu4ec_2VAxyuhAB" + # }, + # "created_at": "2022-11-16T10:11:12.464757833Z" + # } + # } + # } + # + defp handle_message(%{"message_type" => "revocation"}, payload, _state) do + Logger.error("[TMI.EventSub.Socket] sub revoked: #{inspect(payload)}") + end + + # ## Close message + # + # Twitch sends a Close frame when it closes the connection. The following table lists the reasons for closing the connection. + # + # Code Reason Notes + # 4000 Internal server error Indicates a problem with the server (similar to an HTTP 500 status code). + # 4001 Client sent inbound traffic Sending outgoing messages to the server is prohibited with the exception of pong messages. + # 4002 Client failed ping-pong You must respond to ping messages with a pong message. See Ping message. + # 4003 Connection unused When you connect to the server, you must create a subscription within 10 seconds or the connection is closed. The time limit is subject to change. + # 4004 Reconnect grace time expired When you receive a session_reconnect message, you have 30 seconds to reconnect to the server and close the old connection. See Reconnect message. + # 4005 Network timeout Transient network timeout. + # 4006 Network error Transient network error. + # 4007 Invalid reconnect The reconnect URL is invalid. + # + defp handle_message(_metadata, payload, _state) do + # TODO: match ^ + Logger.error("[TMI.EventSub.Socket] closed: #{inspect(payload)}") + end +end diff --git a/lib/tmi/events/follow.ex b/lib/tmi/events/follow.ex new file mode 100644 index 0000000..1fb221e --- /dev/null +++ b/lib/tmi/events/follow.ex @@ -0,0 +1,13 @@ +defmodule TMI.Events.Follow do + @moduledoc false + use TMI.Event, + fields: [ + :broadcaster_user_id, + :broadcaster_user_login, + :broadcaster_user_name, + :followed_at, + :user_id, + :user_login, + :user_name + ] +end diff --git a/lib/tmi/events/shoutout_create.ex b/lib/tmi/events/shoutout_create.ex new file mode 100644 index 0000000..99be6ae --- /dev/null +++ b/lib/tmi/events/shoutout_create.ex @@ -0,0 +1,19 @@ +defmodule TMI.Events.ShoutoutCreate do + @moduledoc false + use TMI.Event, + fields: [ + :broadcaster_user_id, + :broadcaster_user_login, + :broadcaster_user_name, + :cooldown_ends_at, + :moderator_user_id, + :moderator_user_login, + :moderator_user_name, + :started_at, + :target_cooldown_ends_at, + :to_broadcaster_user_id, + :to_broadcaster_user_login, + :to_broadcaster_user_name, + :viewer_count + ] +end diff --git a/lib/tmi/events/shoutout_receive.ex b/lib/tmi/events/shoutout_receive.ex new file mode 100644 index 0000000..a27e6c9 --- /dev/null +++ b/lib/tmi/events/shoutout_receive.ex @@ -0,0 +1,14 @@ +defmodule TMI.Events.ShoutoutReceive do + @moduledoc false + use TMI.Event, + fields: [ + :broadcaster_user_id, + :broadcaster_user_login, + :broadcaster_user_name, + :from_broadcaster_user_id, + :from_broadcaster_user_login, + :from_broadcaster_user_name, + :started_at, + :viewer_count + ] +end diff --git a/lib/tmi/fields.ex b/lib/tmi/fields.ex index 9556db1..13d46af 100644 --- a/lib/tmi/fields.ex +++ b/lib/tmi/fields.ex @@ -3,6 +3,41 @@ defmodule TMI.Fields do Fields derived from Twitch IRC tags. """ + @typedoc """ + Found in TMI EventSub subscriptions `channel.shoutout.receive`. + """ + @type broadcaster_user_id :: String.t() + + @typedoc """ + Found in TMI EventSub subscriptions `channel.shoutout.receive`. + """ + @type broadcaster_user_login :: String.t() + + @typedoc """ + Found in TMI EventSub subscriptions `channel.shoutout.receive`. + """ + @type broadcaster_user_name :: String.t() + + @typedoc """ + Found in TMI EventSub subscriptions `channel.shoutout.receive`. + """ + @type from_broadcaster_user_id :: String.t() + + @typedoc """ + Found in TMI EventSub subscriptions `channel.shoutout.receive`. + """ + @type from_broadcaster_user_login :: String.t() + + @typedoc """ + Found in TMI EventSub subscriptions `channel.shoutout.receive`. + """ + @type from_broadcaster_user_name :: String.t() + + @typedoc """ + Found in TMI EventSub subscriptions `channel.shoutout.receive`. + """ + @type started_at :: DateTime.t() + @typedoc """ Twitch IRC tag `badge-info`. Contains metadata related to the chat badges in the badges tag. Currently, @@ -373,6 +408,7 @@ defmodule TMI.Fields do @typedoc """ Twitch IRC tag `msg-param-viewerCount`. + Also found in EventSub subscriptions `channel.shoutout.receive` payload. Included only with `raid` notices. The number of viewers raiding this channel from the broadcaster’s channel. """ @@ -609,7 +645,7 @@ defmodule TMI.Fields do * `:global_mod` — A global moderator * `:staff` — A Twitch employee * `{:unknown, String.t()}` - Any types that don't match, since Twitch doesn't - fully document their stuff. + fully document their stuff. """ @type user_type :: :normal | :mod | :admin | :global_mod | :staff | {:unknown, String.t()} diff --git a/lib/tmi/supervisor.ex b/lib/tmi/supervisor.ex index ae2729b..39b8c22 100644 --- a/lib/tmi/supervisor.ex +++ b/lib/tmi/supervisor.ex @@ -18,20 +18,23 @@ defmodule TMI.Supervisor do @impl true def init({bot, opts}) do + # IRC Bot config. {is_verified, opts} = Keyword.pop(opts, :is_verified, false) {mod_channels, opts} = Keyword.pop(opts, :mod_channels, []) - {:ok, client} = TMI.IRC.Client.start_link(Keyword.take(opts, [:debug])) conn = build_irc_conn(client, opts) - dynamic_supervisor = TMI.IRC.MessageServer.supervisor_name(bot) + # EventSub config. + eventsub_config = Application.fetch_env!(:abesai_bot, TMI.EventSub.Socket) + children = [ {DynamicSupervisor, strategy: :one_for_one, name: dynamic_supervisor}, {TMI.IRC.ChannelServer, {bot, conn, is_verified, mod_channels}}, {TMI.IRC.ConnectionServer, {bot, conn}}, {TMI.IRC.WhisperServer, {bot, conn}}, - {bot, conn} + {bot, conn}, + {TMI.EventSub.Socket, eventsub_config} ] Supervisor.init(children, strategy: :one_for_one) diff --git a/lib/tmi/twitch/client.ex b/lib/tmi/twitch/client.ex new file mode 100644 index 0000000..e438ffb --- /dev/null +++ b/lib/tmi/twitch/client.ex @@ -0,0 +1,197 @@ +defmodule TMI.Twitch.Client do + @moduledoc """ + Twitch API. + """ + + require Logger + + @base_url "https://api.twitch.tv" + + @subscriptions %{ + "channel.update" => 2, + "channel.follow" => 2, + "channel.ad_break.begin" => 1, + "channel.chat.clear" => 1, + "channel.chat.clear_user_messages" => 1, + "channel.chat.message_delete" => 1, + "channel.chat.notification" => 1, + "channel.chat_settings.update" => "beta", + "channel.subscribe" => 1, + "channel.subscription.end" => 1, + "channel.subscription.gift" => 1, + "channel.subscription.message" => 1, + "channel.cheer" => 1, + "channel.raid" => 1, + "channel.ban" => 1, + "channel.unban" => 1, + "channel.moderator.add" => 1, + "channel.moderator.remove" => 1, + "channel.guest_star_session.begin" => "beta", + "channel.guest_star_session.end" => "beta", + "channel.guest_star_guest.update" => "beta", + "channel.guest_star_settings.update" => "beta", + "channel.channel_points_custom_reward.add" => 1, + "channel.channel_points_custom_reward.update" => 1, + "channel.channel_points_custom_reward.remove" => 1, + "channel.channel_points_custom_reward_redemption.add" => 1, + "channel.channel_points_custom_reward_redemption.update" => 1, + "channel.poll.begin" => 1, + "channel.poll.progress" => 1, + "channel.poll.end" => 1, + "channel.prediction.begin" => 1, + "channel.prediction.progress" => 1, + "channel.prediction.lock" => 1, + "channel.prediction.end" => 1, + "channel.charity_campaign.donate" => 1, + "channel.charity_campaign.start" => 1, + "channel.charity_campaign.progress" => 1, + "channel.charity_campaign.stop" => 1, + "drop.entitlement.grant" => 1, + "extension.bits_transaction.create" => 1, + "channel.goal.begin" => 1, + "channel.goal.progress" => 1, + "channel.goal.end" => 1, + "channel.hype_train.begin" => 1, + "channel.hype_train.progress" => 1, + "channel.hype_train.end" => 1, + "channel.shield_mode.begin" => 1, + "channel.shield_mode.end" => 1, + "channel.shoutout.create" => 1, + "channel.shoutout.receive" => 1, + "stream.online" => 1, + "stream.offline" => 1, + "user.authorization.grant" => 1, + "user.authorization.revoke" => 1, + "user.update" => 1 + } + + @subscription_types Map.keys(@subscriptions) + + @doc """ + List all of the available subscription types. + """ + def subscription_types, do: @subscription_types + + @doc """ + `Req` request (client?) for Twitch API requests. + """ + def client(client_id, access_token) do + headers = + %{ + "client-id" => client_id, + "content-type" => "application/json" + } + + auth = access_token && {:bearer, access_token} + + Req.new(base_url: @base_url, headers: headers, auth: auth) + end + + @doc """ + Revoke an access token. + """ + def revoke_token!(client_id, token) do + params = [client_id: client_id, token: token] + Req.post!("https://id.twitch.tv/oauth2/revoke", form: params) + end + + @doc """ + Create an eventsub subscription using websockets. + See: https://dev.twitch.tv/docs/api/reference/#create-eventsub-subscription + """ + def create_subscription(type, user_id, session_id, client_id, access_token) + when type in @subscription_types do + params = %{ + "type" => type, + "version" => Map.fetch!(@subscriptions, type), + "condition" => condition(type, user_id), + "transport" => %{ + "method" => "websocket", + "session_id" => session_id + } + } + + resp = + client(client_id, access_token) + |> Req.post(url: "/helix/eventsub/subscriptions", json: params) + + case resp do + {:ok, %{status: 202, headers: _headers, body: body}} -> + Logger.debug("[TMI.Twitch.Client] #{type} subscription created:\n#{inspect(body)}") + {:ok, body} + + {:ok, %{status: 429, headers: %{"ratelimit-reset" => resets_at}}} -> + Logger.warning("[TMI.Twitch] rate-limited; resets at #{resets_at}") + {:error, resp} + + {:ok, %{status: _status} = resp} -> + Logger.error("[TMI.Twitch] unexpected response: #{inspect(resp)}") + {:error, resp} + + {:error, error} -> + Logger.error("[TMI.Twitch] error making resquest: #{inspect(error)}") + {:error, error} + end + end + + @doc """ + List all eventsub subscriptions. + """ + def list_subscriptions(client_id, access_token, params \\ %{}) do + resp = + client(client_id, access_token) + |> Req.get(url: "/helix/eventsub/subscriptions", json: params) + + case resp do + {:ok, %{status: 200, headers: _headers, body: body}} -> + {:ok, body} + + {:ok, %{status: 429, headers: %{"ratelimit-reset" => resets_at}}} -> + Logger.warning("[TMI.Twitch] rate-limited; resets at #{resets_at}") + {:error, resp} + + {:ok, %{status: _status} = resp} -> + Logger.error("[TMI.Twitch] unexpected response: #{inspect(resp)}") + {:error, resp} + + {:error, error} -> + Logger.error("[TMI.Twitch] error making resquest: #{inspect(error)}") + {:error, error} + end + end + + ## Different subscription types require different conditions. + + defp condition("channel.chat.notification", user_id) do + %{ + "broadcaster_user_id" => user_id, + "user_id" => user_id + } + end + + defp condition("channel.raid", user_id) do + %{ + "to_broadcaster_user_id" => user_id + } + end + + defp condition("channel.follow" <> _, user_id) do + %{ + "broadcaster_user_id" => user_id, + "moderator_user_id" => user_id + } + end + + defp condition("channel.shoutout" <> _, user_id) do + %{ + "broadcaster_user_id" => user_id, + "moderator_user_id" => user_id + } + end + + defp condition(_type, user_id) do + %{ + "broadcaster_user_id" => user_id + } + end +end diff --git a/mix.exs b/mix.exs index 9f85c49..22f118b 100644 --- a/mix.exs +++ b/mix.exs @@ -31,6 +31,7 @@ defmodule TMI.MixProject do {:exirc, "~> 2.0"}, {:nimble_parsec, "~> 1.0"}, {:req, "~> 0.4"}, + {:websockex, "~> 0.4.3"}, # Dev {:ex_doc, "~> 0.28", only: :dev, runtime: false} diff --git a/mix.lock b/mix.lock index c80ed61..756c165 100644 --- a/mix.lock +++ b/mix.lock @@ -16,4 +16,5 @@ "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, "req": {:hex, :req, "0.4.8", "2b754a3925ddbf4ad78c56f30208ced6aefe111a7ea07fb56c23dccc13eb87ae", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "7146e51d52593bb7f20d00b5308a5d7d17d663d6e85cd071452b613a8277100c"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"}, } diff --git a/test/support/data/eventsub/follow.exs b/test/support/data/eventsub/follow.exs new file mode 100644 index 0000000..09f959f --- /dev/null +++ b/test/support/data/eventsub/follow.exs @@ -0,0 +1,11 @@ +%{ + "event" => %{ + "broadcaster_user_id" => "146616692", + "broadcaster_user_login" => "ryanwinchester_", + "broadcaster_user_name" => "RyanWinchester_", + "followed_at" => "2024-01-19T03:32:41.640955348Z", + "user_id" => "589368619", + "user_login" => "fumingmax", + "user_name" => "fumingmax" + } +} diff --git a/test/support/data/eventsub/shoutout_create.exs b/test/support/data/eventsub/shoutout_create.exs new file mode 100644 index 0000000..873482a --- /dev/null +++ b/test/support/data/eventsub/shoutout_create.exs @@ -0,0 +1,33 @@ +%{ + "event" => %{ + "broadcaster_user_id" => "146616692", + "broadcaster_user_login" => "ryanwinchester_", + "broadcaster_user_name" => "RyanWinchester_", + "cooldown_ends_at" => "2024-01-19T03:20:50Z", + "moderator_user_id" => "146616692", + "moderator_user_login" => "ryanwinchester_", + "moderator_user_name" => "RyanWinchester_", + "started_at" => "2024-01-19T03:18:50Z", + "target_cooldown_ends_at" => "2024-01-19T04:18:50Z", + "to_broadcaster_user_id" => "64210215", + "to_broadcaster_user_login" => "cmgriffing", + "to_broadcaster_user_name" => "cmgriffing", + "viewer_count" => 28 + }, + "subscription" => %{ + "condition" => %{ + "broadcaster_user_id" => "146616692", + "moderator_user_id" => "146616692" + }, + "cost" => 0, + "created_at" => "2024-01-19T03:13:22.260571266Z", + "id" => "ad14b557-8f1c-440d-861f-8dfd5d18644c", + "status" => "enabled", + "transport" => %{ + "method" => "websocket", + "session_id" => "AgoQtRIfi0QQS5K5M4GLdY6g3xIGY2VsbC1i" + }, + "type" => "channel.shoutout.create", + "version" => "1" + } +} diff --git a/test/support/data/eventsub/shoutout_receive.exs b/test/support/data/eventsub/shoutout_receive.exs new file mode 100644 index 0000000..ae3d216 --- /dev/null +++ b/test/support/data/eventsub/shoutout_receive.exs @@ -0,0 +1,28 @@ +%{ + "event" => %{ + "broadcaster_user_id" => "146616692", + "broadcaster_user_login" => "ryanwinchester_", + "broadcaster_user_name" => "RyanWinchester_", + "from_broadcaster_user_id" => "64210215", + "from_broadcaster_user_login" => "cmgriffing", + "from_broadcaster_user_name" => "cmgriffing", + "started_at" => "2024-01-19T03:18:41Z", + "viewer_count" => 40 + }, + "subscription" => %{ + "condition" => %{ + "broadcaster_user_id" => "146616692", + "moderator_user_id" => "146616692" + }, + "cost" => 0, + "created_at" => "2024-01-19T03:13:22.386597362Z", + "id" => "9fb28d52-3f4a-4002-9d72-530d91ef4c11", + "status" => "enabled", + "transport" => %{ + "method" => "websocket", + "session_id" => "AgoQtRIfi0QQS5K5M4GLdY6g3xIGY2VsbC1i" + }, + "type" => "channel.shoutout.receive", + "version" => "1" + } +} diff --git a/test/tmi/eventsub/events_test.exs b/test/tmi/eventsub/events_test.exs new file mode 100644 index 0000000..eac5ced --- /dev/null +++ b/test/tmi/eventsub/events_test.exs @@ -0,0 +1,4 @@ +defmodule TMI.Eventsub.EventsTest do + use ExUnit.Case, async: true + doctest TMI.EventSub.Events, import: true +end