diff --git a/addons/no_twitch/demo/test_button.gd b/addons/no_twitch/demo/test_button.gd index 61a1962..ebc9937 100644 --- a/addons/no_twitch/demo/test_button.gd +++ b/addons/no_twitch/demo/test_button.gd @@ -5,5 +5,5 @@ extends Button func _pressed(): - OS.shell_open(twitch_connection.authenticate_with_twitch(["channel:read:redemptions", "chat:read", "chat:edit"])) + OS.shell_open(twitch_connection.authenticate_with_twitch()) diff --git a/addons/no_twitch/eventsub_socket.gd b/addons/no_twitch/eventsub_socket.gd index 682db60..1bed1ae 100644 --- a/addons/no_twitch/eventsub_socket.gd +++ b/addons/no_twitch/eventsub_socket.gd @@ -11,60 +11,103 @@ signal notif_received(data) signal welcome_received() +var keepalive_timer := 0 +var timeout_time : int + +## Dictionary used for storing all the subscribed events in the format "subscription_type : Twitch_Connection.EventSub_Subscription" +var subscribed_events : Dictionary + + +func _init(owner, timeout : int): -func _init(owner): - connection = owner - - packet_received.connect(data_received) - + timeout_time = timeout + + packet_received.connect(data_received) + + +## Overrides the default poll function for [Websocket_Client] to add functionality for a keepalive timer and reconnecting when the connection is lost. +func poll_socket(): + + super() + + keepalive_timer += connection.get_process_delta_time() + if keepalive_timer >= timeout_time: + + socket_closed.emit() + close() + connect_to_eventsub(subscribed_events.values()) + + + +## Handles setting up the connection to EventSub with an Array of the Events that should be subscribed to. +func connect_to_eventsub(events : Array[Twitch_Connection.EventSub_Subscription]): -func connect_to_eventsub(events : Array[Twitch_Connection.EventSub_Subscription] = []): - connect_to_url(eventsub_url) await welcome_received - - if events.is_empty(): - - return - - - var responses : Array[Twitch_Connection.HTTPResponse] - + for all in events: - - responses.append(connection.add_eventsub_subscription(all)) - - + + subscribed_events[all.subscription_type] = all + + + return await subscribe_to_events(events) + + +## Utility function for subscribing to multiple Twitch EventSub events at once. +func subscribe_to_events(events : Array[Twitch_Connection.EventSub_Subscription]): + + var responses : Array[Twitch_Connection.HTTPResponse] + + for all in events: + + responses.append(await connection.add_eventsub_subscription(all)) + + + if responses.size() == 1: + + return responses[0] + + return responses - + + +func new_eventsub_subscription(info, event_subscription : Twitch_Connection.EventSub_Subscription): + + if !event_subscription in subscribed_events: + + subscribed_events[event_subscription.subscription_type] = event_subscription + + event_subscription.sub_id = info.data.id + func data_received(packet : PackedByteArray): - + var info = JSON.parse_string(packet.get_string_from_utf8()) - + match info.metadata.message_type: - + "session_welcome": - + session_id = info.payload.session.id welcome_received.emit() - - - "session_ping": - - send_pong(info) - - - "notification": - - notif_received.emit(info) - - - -func send_pong(pong): - - pong.metadata.message_type = "session_pong" - send_text(str(pong)) - + + "session_ping": + + send_pong(info) + + + "notification": + + notif_received.emit(info) + + + "session_keepalive": + + keepalive_timer = 0 + + + + print(info) + diff --git a/addons/no_twitch/twitch_connection.gd b/addons/no_twitch/twitch_connection.gd index 9a1a675..8ce7c4a 100644 --- a/addons/no_twitch/twitch_connection.gd +++ b/addons/no_twitch/twitch_connection.gd @@ -12,10 +12,12 @@ signal chat_received_rich(chat_dict) @export var client_id := "qyjg1mtby1ycs5scm1pvctos7yvyc1" @export var redirect_uri := "http://localhost" -## Port that the redirect_uri will head to on your local system. Defaults to 80 for most cases. Linux tends to prefer 8000 or possibly 1338 +## Port that the redirect_uri will head to on your local system. Defaults to 80 for most cases. +## Linux tends to prefer 8000 or possibly 1338 @export var redirect_port := "8000" var auth_url := "https://id.twitch.tv/oauth2/authorize?response_type=token&" +var auth_scopes : Array[String] = ["chat:read", "chat:edit", "channel:read:redemptions"] var auth_server : TCPServer @@ -35,306 +37,330 @@ var user_info : Dictionary var responses : Array func _ready(): - + token_received.connect(cache_user_data.unbind(1)) - + ## Function that handles Caching the data of the Account tied to the Connections Token func cache_user_data(): - + if token.is_empty(): - + return - - + + var resp = request_user_info() resp.response_received.connect(func(data): - - user_info = data - print("User Info Cached") - - ) - - -## Handles the basic Twitch Authentication process to request and then later receive a Token (using [method check_auth_peer]). -## Returns the authentication URL. -func authenticate_with_twitch(client_id = client_id, scopes : Array[String] = ["chat:read", "chat:edit"]) -> String: + user_info = data.data + print("User Info Cached") + + ) + + + +## Handles the basic Twitch Authentication process to request and then later receive a Token +## (using [method check_auth_peer]). Returns the authentication URL. +func authenticate_with_twitch(client_id = client_id, scopes : Array[String] = auth_scopes) -> String: auth_server = TCPServer.new() - var url := create_auth_url(scopes) + auth_scopes = scopes + var url := create_auth_url() auth_server.listen(int(redirect_port)) return url -## Sets up the chat connection. Joining 1 room with [param token] as it's "PASS" specifying what account it's on. And the optional [param default_chat] specifying the default room to join. While [param nick] specifies the "nickname" (Not the username on Twitch) +## Sets up the chat connection. Joining 1 room with [param token] as it's "PASS" specifying what +## account it's on. And the optional [param default_chat] specifying the default room to join. +## While [param nick] specifies the "nickname" (Not the username on Twitch) func setup_chat_connection(default_chat : String = "", token : String = token, request_twitch_info = true, nick = "terribletwitch"): - + chat_socket = chat_socket_class.new(self) - + # Connects to the Twitch IRC server. chat_socket.connect_to_chat(token, request_twitch_info) await chat_socket.socket_open - + if !default_chat.is_empty(): - + chat_socket.join_chat(default_chat) - - + + ## Joins the given [param channel] over IRC. Essentially just sending JOIN #[param channel] func join_channel(channel : String): - + chat_socket.join_chat(channel) - + + +## Leaves or "PART"s the given [param channel] over IRC. (Sends PART #[param channel]) +func leave_channel(channel : String): + + chat_socket.leave_chat(channel) + func send_chat(msg : String, channel : String = ""): - - chat_socket.send_chat(msg, channel) - -## Sets up an EventSub connection to allow subscribing to EventSub events. Ex. Alerts, Channel Point Redemptions etc. + chat_socket.send_chat(msg, channel) + + +## Sets up an EventSub connection to allow subscribing to EventSub events. Ex. Alerts, Channel Point +## Redemptions etc. func setup_eventsub_connection(events : Array[EventSub_Subscription] = [], timeout_duration : int = 20): - - eventsub_socket = eventsub_socket_class.new(self) + + eventsub_socket = eventsub_socket_class.new(self, timeout_duration) var ret = await eventsub_socket.connect_to_eventsub(events) return ret - + +## Adds a new subscription to a given EventSub Event using a [Twitch_Connection.EventSub_Subscription] +## to handle the data. func add_eventsub_subscription(sub_type : EventSub_Subscription): - + return twitch_request("https://api.twitch.tv/helix/eventsub/subscriptions", HTTPClient.METHOD_POST, str(sub_type.return_request_dictionary())) - + func subscribe_to_channel_points(user_id : String): - + if !eventsub_socket.socket_open: - + push_error("NoTwitch Error: No EventSub Connection, please use setup_eventsub_connection first") - return - - - return add_eventsub_subscription(EventSub_Subscription.new("channel.channel_points_custom_reward_redemption.add", {"broadcaster_user_id" : user_id}, eventsub_socket.session_id)) - + return + + + return await add_eventsub_subscription(EventSub_Subscription.new("channel.channel_points_custom_reward_redemption.add", {"broadcaster_user_id" : user_id}, eventsub_socket.session_id)) + func _process(delta): - - if auth_server and auth_server.is_listening() and auth_server.is_connection_available(): - - check_auth_peer(auth_server.take_connection()) - - - if chat_socket: - - chat_socket.poll_socket() - - - if eventsub_socket: - - eventsub_socket.poll_socket() - - -## Utility function for creating a Twitch Authentication URL with the given -## [param scopes], Twitch Client ID ([param id]) and [param redirect_uri]. -## [param id] defaults to [member client_id] and both [param redirect] and + if auth_server and auth_server.is_listening() and auth_server.is_connection_available(): + + check_auth_peer(auth_server.take_connection()) + + + if chat_socket: + + chat_socket.poll_socket() + + + if eventsub_socket: + + eventsub_socket.poll_socket() + + + +## Utility function for creating a Twitch Authentication URL with the given +## [param scopes], Twitch Client ID ([param id]) and [param redirect_uri]. +## [param id] defaults to [member client_id] and both [param redirect] and ## [param redirect_port] -func create_auth_url(scopes : Array[String], port := redirect_port, id : String = client_id, redirect : String = redirect_uri) -> String: - +func create_auth_url(port := redirect_port, id : String = client_id, redirect : String = redirect_uri) -> String: + var str_scopes : String - - for all in scopes: - + + for all in auth_scopes: + str_scopes += " " + all str_scopes = str_scopes.strip_edges() - + var full_redirect_uri := redirect if !port.is_empty(): full_redirect_uri += ":" + port - + var url := auth_url + "client_id=" + id + "&redirect_uri=" + full_redirect_uri + "&scope=" + str_scopes + "&state=" + str(state) - + return url ## Utility Function for making a generic HTTP Request to Twitch func twitch_request(url : String, method : HTTPClient.Method = HTTPClient.METHOD_GET, body := ""): - + var headers : Array = ["Authorization: Bearer " + token, "Client-Id: " + client_id] - + if !body.is_empty(): - + headers.append("Content-Type: application/json") - - + + # Adds the Content type to the headers if we're actually sending some data along if method != HTTPClient.METHOD_GET: - + headers.append("Content-Type: application/json") - - + + var http := HTTPRequest.new() - + var resp = HTTPResponse.new(http, responses) add_child(http) http.request(url, headers, method, body) - + return resp - + ## Wrapper function around [method twitch_request] to handle doing a Twitch "Get Users" request. func request_user_info(users : Array[String] = []): - + var req_url := "https://api.twitch.tv/helix/users" - + if users.is_empty(): - + return twitch_request(req_url) - - + + var user_list : String = "?login=" + users[0] for all in users: - - user_list += "&login=" + all - - - return twitch_request(req_url + user_list) - -## Wrapper function around [method twitch_request] to grab information about a set of channels based off an Array of Channel IDs Ex. stream title, current category, current tags, etc. + user_list += "&login=" + all + + + return twitch_request(req_url + user_list) + + +## Wrapper function around [method twitch_request] to grab information about a set of channels based +## off an Array of Channel IDs Ex. stream title, current category, current tags, etc. func request_channel_info(channels : Array[String] = []): - + var req_url := "https://api.twitch.tv/helix/channels?broadcaster_id=" - + if channels.is_empty(): - + if user_info.is_empty(): - + return - - + + channels.append(user_info.id) - - + + var id_string := channels[0] for all in channels: - - id_string += "&broadcaster_id=" + all - - - return twitch_request(req_url + id_string) - -## Utility function for creating a "state" used for different requests for some extra security as a semi password. + id_string += "&broadcaster_id=" + all + + + return twitch_request(req_url + id_string) + + +## Utility function for creating a "state" used for different requests for some extra security as a +## semi password. func make_state(len : int = 16) -> String: - + var crypto = Crypto.new() var state = crypto.generate_random_bytes(len).hex_encode() - + return state - + func check_auth_peer(peer : StreamPeerTCP): - + var info = peer.get_utf8_string(peer.get_available_bytes()) printraw(info) - + var root := redirect_uri if !redirect_port.is_empty(): root += ":" + redirect_port root += "/" - + var script = "" - + peer.put_data(str("HTTP/1.1 200\n\n" + script).to_utf8_buffer()) - - + + var resp_state = info.split("&state=") - + # Ensures that the received state is correct. if !resp_state.size() > 1 or resp_state[0] == state: - + return - - + + var token = info.split("access_token=")[1].split("&scope=")[0].strip_edges() printraw("Token: ", token, "\n") self.token = token token_received.emit(token) - + func check_chat_socket(dict, rich = false): - - prints(dict.user, dict.message) - + if rich: - + chat_received_rich.emit(dict) return - + chat_received.emit(dict) - + ## "Promise" Class used for relaying the info received from the supplied [HTTPRequest] node. class HTTPResponse: - + signal response_received - + ## The infuriatingly needed data variable. Because you can't await AND get signal data. - var inf_data - + var inf_data + func _init(http : HTTPRequest, storage : Array): - + storage.append(self) http.request_completed.connect(request_complete.bind(http, storage)) - - + + func request_complete(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray, http, storage): - - var info = JSON.parse_string(body.get_string_from_utf8()) - + + var info : Dictionary + if !body.is_empty(): + info = JSON.parse_string(body.get_string_from_utf8()) + + info["result"] = result + info["code"] = response_code + info["headers"] = headers + if info.has("error"): - - push_error("NoTwitch Twitch API Error: " + info.error + " " + info.message) + + push_error("NoTwitch Twitch API Error: " + str(info)) return - - info = info.data[0] - + + + info["data"] = info.get("data", [[]])[0] + print("Response Received") inf_data = info response_received.emit(info) - + http.queue_free() storage.erase(self) - - -## Data handler class for making it easier to send the data used for EventSub Subscriptions over [member eventsub_socket] + + +## Data handler class for making it easier to send the data used for EventSub Subscriptions over +## [member eventsub_socket] class EventSub_Subscription: - + ## Specifies the type of subscription this is representing (Ex. "Channel Update" is channel.update) var subscription_type : String ## Used for setting the "version" the subscription that we're currently using. var version : String - ## Stores the "condition", AKA the condition for being able to subscribe to this Event. (Ex. The User ID of the channel you're listening to) + ## Stores the "condition", AKA the condition for being able to subscribe to this Event. + ## (Ex. The User ID of the channel you're listening to) var condition : Dictionary - ## Holds the "session" ID for this EventSub Subscription. Which is held in [member eventsub_socket.session_id] + ## Holds the "session" ID for this EventSub Subscription. Which is held in + ## [member eventsub_socket.session_id] var session_id : String - + ## Stores the subscription ID returned from the EventSub Subscription API Request + var sub_id + var method = "websocket" - - func _init(sub_type : String, cond : Dictionary, sess_id : String, vers : String = "1"): - + + func _init(sub_type : String, cond : Dictionary, sess_id : String = "", vers : String = "1"): + subscription_type = sub_type version = vers - condition = cond + condition = cond session_id = sess_id - - + + ## Returns back the information pertaining to the Subscription in the format needed for twitch. func return_request_dictionary(): - + var dict = {"type" : subscription_type, "version" : version, "condition" : condition, "transport" : {"method" : method, "session_id" : session_id}} - + return dict - - + + diff --git a/classes/connections/connections.gd b/classes/connections/connections.gd index e1d2606..bf0d70b 100644 --- a/classes/connections/connections.gd +++ b/classes/connections/connections.gd @@ -4,7 +4,12 @@ class_name Connections static var obs_websocket -static var twitch +static var twitch + +static func _twitch_eventsub_event_received(event_data : Dictionary): + + DeckHolder.send_event(&"twitch_eventsub", event_data) + static func _twitch_chat_received(msg_dict : Dictionary): diff --git a/classes/deck/nodes/twitch/twitch_add_eventsub_subscription.gd b/classes/deck/nodes/twitch/twitch_add_eventsub_subscription.gd new file mode 100644 index 0000000..04b1732 --- /dev/null +++ b/classes/deck/nodes/twitch/twitch_add_eventsub_subscription.gd @@ -0,0 +1,76 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +var subscription_data : Twitch_Connection.EventSub_Subscription + +func _init(): + name = "Twitch Add EventSub Subscription" + node_type = name.to_snake_case() + description = "Adds a subscription to a specific Twitch EventSub Event with the given dictionary 'condition' for the data needed." + + props_to_serialize = [] + + #Event Name + add_input_port(DeckType.Types.STRING, "Event Name", "field") + #Subscription Data + add_input_port(DeckType.Types.DICTIONARY, "Subscription Data") + #Trigger + add_input_port(DeckType.Types.ANY, "Add Subscription", "button") + + + + +func _receive(to_input_port, data: Variant, extra_data: Array = []): + + if to_input_port != 2: + + return + + + var input_data = await resolve_input_port_value_async(1) + + + if input_data == null or !"condition" in input_data.keys(): + DeckHolder.logger.log_node(name + ": Incorrect Subscription Data Connected, please supply a Dictionary with condition and if needed, version. Last supplied Data was: " + str(input_data), Logger.LogType.ERROR) + return + + + var sub_type = await resolve_input_port_value_async(0) + +# Creates an instance of Twitch_Connection.EventSub_Subscription to store the data with all the given inputs. + subscription_data = Twitch_Connection.EventSub_Subscription.new(sub_type, input_data.condition) +# Checks if the data has a version field, if so sets it on the EventSub_Subscription + if input_data.has("version"): + + subscription_data.version = input_data.version + + +# Calls the connection to add the Subscription + var req = await Connections.twitch.add_eventsub_subscription(subscription_data) + req.response_received.connect(eventsub_subscription_response) + + +## Handles checking the [Twitch_Connection.HTTPResponse] returned by [method Twitch_Connection.add_eventsub_subscription] to ensure that it succeeded. +func eventsub_subscription_response(data): + + match data.code: + + 202: + + var succ_string = name + ": EventSub Subscription Added for " + subscription_data.subscription_type + " successfully" + DeckHolder.logger.log_node(succ_string, Logger.LogType.INFO) + Connections.twitch.eventsub_socket.notif_received.connect(Connections._twitch_eventsub_event_received) + + + _: + + var error_string = name + ": Error" + data.code + " Received from Twitch when Subscribing to " + subscription_data.sub_type + " with " + str(subscription_data.return_request_dictionary) + + DeckHolder.logger.log_node(error_string, Logger.LogType.ERROR) + + + + diff --git a/classes/deck/nodes/twitch/twitch_eventsub_event.gd b/classes/deck/nodes/twitch/twitch_eventsub_event.gd new file mode 100644 index 0000000..236cfac --- /dev/null +++ b/classes/deck/nodes/twitch/twitch_eventsub_event.gd @@ -0,0 +1,56 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + +var cached_event_data : Dictionary + +func _init(): + name = "Twitch EventSub Event" + node_type = name.to_snake_case() + description = "Listens for a specific Event from Twitch EventSub" + + props_to_serialize = [] + +# Adds a port that allows specifying what type of event to listen for. + add_input_port(DeckType.Types.STRING, "Event Name", "field") +# Adds a port that outputs when the Event has been received + add_output_port(DeckType.Types.ANY, "Event Received") +# Adds a port that outputs the data received when the Event has been received. + add_output_port(DeckType.Types.DICTIONARY, "Event Data") + + + + +func _event_received(event_name: StringName, event_data: Dictionary = {}): + + if event_name != &"twitch_eventsub": + + return + + + var port_0 = await resolve_input_port_value_async(0) + print("Event Name ", event_data) + if port_0 == null or port_0 != event_data.payload.subscription.type: + + return + + +# Sends to indicate that the specified event has happened. + send(0, null) +# Sends the data along as well as the fact that the event happened. While also caching the event data for later access + cached_event_data = event_data +# Sends the data along as well as the fact that the event happened. While also caching the event data for later access + cached_event_data = event_data + send(1, event_data) + + +func _value_request(port): + + if port != 1: + + return + + + return cached_event_data + diff --git a/classes/deck/nodes/twitch/twitch_join_chat.gd b/classes/deck/nodes/twitch/twitch_join_chat.gd new file mode 100644 index 0000000..a162f22 --- /dev/null +++ b/classes/deck/nodes/twitch/twitch_join_chat.gd @@ -0,0 +1,30 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init(): + name = "Twitch Join Chat" + node_type = name.to_snake_case() + description = "Simple node to send a premade 'join' message to Twitch IRC allowing you to Join the given Channel and receive messages from it." + + props_to_serialize = [] + + # Adds Input port for channel name + add_input_port(DeckType.Types.STRING, "Channel", "field") + + # Adds Trigger for leaving the specified channel + add_input_port(DeckType.Types.ANY, "Leave Channel", "button") + + + +func _receive(to_input_port, data: Variant, extra_data: Array = []): + + if to_input_port != 1: + + return + + + Connections.twitch.join_channel(await resolve_input_port_value_async(0)) + diff --git a/classes/deck/nodes/twitch/twitch_leave_chat.gd b/classes/deck/nodes/twitch/twitch_leave_chat.gd new file mode 100644 index 0000000..58c663e --- /dev/null +++ b/classes/deck/nodes/twitch/twitch_leave_chat.gd @@ -0,0 +1,30 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init(): + name = "Twitch Leave Chat" + node_type = name.to_snake_case() + description = "Simple node to send a premade 'leave' message to Twitch IRC. Allowing you to leave the specified channel and stop receiving from it." + + props_to_serialize = [] + + # Adds Input port for channel name + add_input_port(DeckType.Types.STRING, "Channel", "field") + + # Adds Trigger for leaving the specified channel + add_input_port(DeckType.Types.ANY, "Leave Channel", "button") + + + +func _receive(to_input_port, data: Variant, extra_data: Array = []): + + if to_input_port != 1: + + return + + + Connections.twitch.leave_channel(await resolve_input_port_value_async(0)) + diff --git a/classes/deck/nodes/twitch/twitch_remove_eventsub_subscription.gd b/classes/deck/nodes/twitch/twitch_remove_eventsub_subscription.gd new file mode 100644 index 0000000..03f17ba --- /dev/null +++ b/classes/deck/nodes/twitch/twitch_remove_eventsub_subscription.gd @@ -0,0 +1,35 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init(): + name = "Twitch Remove EventSub Subscription" + node_type = name.to_snake_case() + description = "Removes the given EventSub Subscription by it's Type." + + props_to_serialize = [] + + add_input_port(DeckType.Types.STRING, "Subscription Type", "field") + add_input_port(DeckType.Types.ANY, "Remove Subscription", "button") + + + + +func _receive(to_input_port, data: Variant, extra_data: Array = []): + + if !to_input_port == 1: + + return + + + var sub_type = await resolve_input_port_value_async(0) + + Connections.twitch.remove_eventsub_subscription_type(sub_type).response_completed.connect(eventsub_response_received) + + +func eventsub_response_received(info): + + pass # TODO: Add Error Handling Later +