From e5a055c667f9504087abcf34a5ffdc3a15ed18ae Mon Sep 17 00:00:00 2001 From: Clevertop <41929769+Clevertop@users.noreply.github.com> Date: Sat, 18 May 2024 12:40:43 +1000 Subject: [PATCH] twitch integration addon --- .gitignore | 1 + LICENSE | 21 + addons/gift/gift.gd | 8 + addons/gift/gift_node.gd | 710 ++++++++++++++++++++++++++++++++ addons/gift/icon.png | Bin 0 -> 188 bytes addons/gift/icon.png.import | 34 ++ addons/gift/plugin.cfg | 7 + addons/gift/util/cmd_data.gd | 16 + addons/gift/util/cmd_info.gd | 12 + addons/gift/util/sender_data.gd | 11 + example/Button.gd | 7 + example/ChatContainer.gd | 42 ++ example/ChatMessage.gd | 5 + example/ChatMessage.tscn | 16 + example/Example.tscn | 60 +++ example/Gift.gd | 115 ++++++ example/LineEdit.gd | 6 + example/default_env.tres | 7 + example/icon.png | Bin 0 -> 271 bytes example/icon.png.import | 34 ++ project.godot | 4 + 21 files changed, 1116 insertions(+) create mode 100644 LICENSE create mode 100644 addons/gift/gift.gd create mode 100644 addons/gift/gift_node.gd create mode 100644 addons/gift/icon.png create mode 100644 addons/gift/icon.png.import create mode 100644 addons/gift/plugin.cfg create mode 100644 addons/gift/util/cmd_data.gd create mode 100644 addons/gift/util/cmd_info.gd create mode 100644 addons/gift/util/sender_data.gd create mode 100644 example/Button.gd create mode 100644 example/ChatContainer.gd create mode 100644 example/ChatMessage.gd create mode 100644 example/ChatMessage.tscn create mode 100644 example/Example.tscn create mode 100644 example/Gift.gd create mode 100644 example/LineEdit.gd create mode 100644 example/default_env.tres create mode 100644 example/icon.png create mode 100644 example/icon.png.import diff --git a/.gitignore b/.gitignore index d9aac21..3931acc 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ export_presets.cfg .mono/ data_*/ mono_crash.*.json +Notes.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..70ab652 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-2023 Max Kross + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/addons/gift/gift.gd b/addons/gift/gift.gd new file mode 100644 index 0000000..31ccd61 --- /dev/null +++ b/addons/gift/gift.gd @@ -0,0 +1,8 @@ +@tool +extends EditorPlugin + +func _enter_tree() -> void: + add_custom_type("Gift", "Node", preload("gift_node.gd"), preload("icon.png")) + +func _exit_tree() -> void: + remove_custom_type("Gift") diff --git a/addons/gift/gift_node.gd b/addons/gift/gift_node.gd new file mode 100644 index 0000000..f1acc08 --- /dev/null +++ b/addons/gift/gift_node.gd @@ -0,0 +1,710 @@ +extends Node +class_name Gift + +# The underlying websocket sucessfully connected to Twitch IRC. +signal twitch_connected +# The connection has been closed. Not emitted if Twitch IRC announced a reconnect. +signal twitch_disconnected +# The connection to Twitch IRC failed. +signal twitch_unavailable +# Twitch IRC requested the client to reconnect. (Will be unavailable until next connect) +signal twitch_reconnect +# User token from Twitch has been fetched. +signal user_token_received(token_data) +# User token is valid. +signal user_token_valid +# User token is no longer valid. +signal user_token_invalid +# The client tried to login to Twitch IRC. Returns true if successful, else false. +signal login_attempt(success) +# User sent a message in chat. +signal chat_message(sender_data, message) +# User sent a whisper message. +signal whisper_message(sender_data, message) +# Initial channel data received +signal channel_data_received(channel_name) +# Unhandled data passed through +signal unhandled_message(message, tags) +# A command has been called with invalid arg count +signal cmd_invalid_argcount(cmd_name, sender_data, cmd_data, arg_ary) +# A command has been called with insufficient permissions +signal cmd_no_permission(cmd_name, sender_data, cmd_data, arg_ary) +# Twitch IRC ping is about to be answered with a pong. +signal pong + + +# The underlying websocket sucessfully connected to Twitch EventSub. +signal events_connected +# The connection to Twitch EventSub failed. +signal events_unavailable +# The underlying websocket disconnected from Twitch EventSub. +signal events_disconnected +# The id has been received from the welcome message. +signal events_id(id) +# Twitch directed the bot to reconnect to a different URL +signal events_reconnect +# Twitch revoked a event subscription +signal events_revoked(event, reason) + +# Refer to https://dev.twitch.tv/docs/eventsub/eventsub-reference/ data contained in the data dictionary. +signal event(type, data) + +@export_category("IRC") + +## Messages starting with one of these symbols are handled as commands. '/' will be ignored, reserved by Twitch. +@export var command_prefixes : Array[String] = ["!"] + +## Time to wait in msec after each sent chat message. Values below ~310 might lead to a disconnect after 100 messages. +@export var chat_timeout_ms : int = 320 + +## Scopes to request for the token. Look at https://dev.twitch.tv/docs/authentication/scopes/ for a list of all available scopes. +@export var scopes : Array[String] = ["chat:edit", "chat:read"] + +@export_category("Emotes/Badges") + +## If true, caches emotes/badges to disk, so that they don't have to be redownloaded on every restart. +## This however means that they might not be updated if they change until you clear the cache. +@export var disk_cache : bool = false + +## Disk Cache has to be enbaled for this to work +@export_file var disk_cache_path : String = "user://gift/cache" + +var client_id : String = "" +var client_secret : String = "" +var username : String = "" +var user_id : String = "" +var token : Dictionary = {} + +# Twitch disconnects connected clients if too many chat messages are being sent. (At about 100 messages/30s). +# This queue makes sure messages aren't sent too quickly. +var chat_queue : Array[String] = [] +var last_msg : int = Time.get_ticks_msec() +# Mapping of channels to their channel info, like available badges. +var channels : Dictionary = {} +# Last Userstate of the bot for channels. Contains -> entries. +var last_state : Dictionary = {} +# Dictionary of commands, contains -> entries. +var commands : Dictionary = {} + +var eventsub : WebSocketPeer +var eventsub_messages : Dictionary = {} +var eventsub_connected : bool = false +var eventsub_restarting : bool = false +var eventsub_reconnect_url : String = "" +var session_id : String = "" +var keepalive_timeout : int = 0 +var last_keepalive : int = 0 + +var websocket : WebSocketPeer +var server : TCPServer = TCPServer.new() +var peer : StreamPeerTCP +var connected : bool = false +var user_regex : RegEx = RegEx.new() +var twitch_restarting : bool = false + +const USER_AGENT : String = "User-Agent: GIFT/4.1.5 (Godot Engine)" + +enum RequestType { + EMOTE, + BADGE, + BADGE_MAPPING +} + +var caches := { + RequestType.EMOTE: {}, + RequestType.BADGE: {}, + RequestType.BADGE_MAPPING: {} +} + +# Required permission to execute the command +enum PermissionFlag { + EVERYONE = 0, + VIP = 1, + SUB = 2, + MOD = 4, + STREAMER = 8, + # Mods and the streamer + MOD_STREAMER = 12, + # Everyone but regular viewers + NON_REGULAR = 15 +} + +# Where the command should be accepted +enum WhereFlag { + CHAT = 1, + WHISPER = 2 +} + +func _init(): + user_regex.compile("(?<=!)[\\w]*(?=@)") + if (disk_cache): + for key in RequestType.keys(): + if (!DirAccess.dir_exists_absolute(disk_cache_path + "/" + key)): + DirAccess.make_dir_recursive_absolute(disk_cache_path + "/" + key) + +# Authenticate to authorize GIFT to use your account to process events and messages. +func authenticate(client_id, client_secret) -> void: + self.client_id = client_id + self.client_secret = client_secret + print("Checking token...") + if (FileAccess.file_exists("user://gift/auth/user_token")): + var file : FileAccess = FileAccess.open_encrypted_with_pass("user://gift/auth/user_token", FileAccess.READ, client_secret) + token = JSON.parse_string(file.get_as_text()) + if (token.has("scope") && scopes.size() != 0): + if (scopes.size() != token["scope"].size()): + get_token() + token = await(user_token_received) + else: + for scope in scopes: + if (!token["scope"].has(scope)): + get_token() + token = await(user_token_received) + else: + get_token() + token = await(user_token_received) + username = await(is_token_valid(token["access_token"])) + while (username == ""): + print("Token invalid.") + var refresh : String = token.get("refresh_token", "") + if (refresh != ""): + refresh_access_token(refresh) + else: + get_token() + token = await(user_token_received) + username = await(is_token_valid(token["access_token"])) + print("Token verified.") + user_token_valid.emit() + refresh_token() + +func refresh_access_token(refresh : String) -> void: + print("Refreshing access token.") + var request : HTTPRequest = HTTPRequest.new() + add_child(request) + request.request("https://id.twitch.tv/oauth2/token", [USER_AGENT, "Content-Type: application/x-www-form-urlencoded"], HTTPClient.METHOD_POST, "grant_type=refresh_token&refresh_token=%s&client_id=%s&client_secret=%s" % [refresh.uri_encode(), client_id, client_secret]) + var reply : Array = await(request.request_completed) + request.queue_free() + var response : Dictionary = JSON.parse_string(reply[3].get_string_from_utf8()) + if (response.has("error")): + print("Refresh failed, requesting new token.") + get_token() + else: + token = response + var file : FileAccess = FileAccess.open_encrypted_with_pass("user://gift/auth/user_token", FileAccess.WRITE, client_secret) + file.store_string(reply[3].get_string_from_utf8()) + user_token_received.emit(response) + +# Gets a new auth token from Twitch. +func get_token() -> void: + print("Fetching new token.") + var scope = "" + for i in scopes.size() - 1: + scope += scopes[i] + scope += " " + if (scopes.size() > 0): + scope += scopes[scopes.size() - 1] + scope = scope.uri_encode() + OS.shell_open("https://id.twitch.tv/oauth2/authorize?response_type=code&client_id=" + client_id +"&redirect_uri=http://localhost:18297&scope=" + scope) + server.listen(18297) + print("Waiting for user to login.") + while(!peer): + peer = server.take_connection() + OS.delay_msec(100) + while(peer.get_status() == peer.STATUS_CONNECTED): + peer.poll() + if (peer.get_available_bytes() > 0): + var response = peer.get_utf8_string(peer.get_available_bytes()) + if (response == ""): + print("Empty response. Check if your redirect URL is set to http://localhost:18297.") + return + var start : int = response.find("?") + response = response.substr(start + 1, response.find(" ", start) - start) + var data : Dictionary = {} + for entry in response.split("&"): + var pair = entry.split("=") + data[pair[0]] = pair[1] if pair.size() > 0 else "" + if (data.has("error")): + var msg = "Error %s: %s" % [data["error"], data["error_description"]] + print(msg) + send_response(peer, "400 BAD REQUEST", msg.to_utf8_buffer()) + peer.disconnect_from_host() + break + else: + print("Success.") + send_response(peer, "200 OK", "Success!".to_utf8_buffer()) + peer.disconnect_from_host() + var request : HTTPRequest = HTTPRequest.new() + add_child(request) + request.request("https://id.twitch.tv/oauth2/token", [USER_AGENT, "Content-Type: application/x-www-form-urlencoded"], HTTPClient.METHOD_POST, "client_id=" + client_id + "&client_secret=" + client_secret + "&code=" + data["code"] + "&grant_type=authorization_code&redirect_uri=http://localhost:18297") + var answer = await(request.request_completed) + if (!DirAccess.dir_exists_absolute("user://gift/auth")): + DirAccess.make_dir_recursive_absolute("user://gift/auth") + var file : FileAccess = FileAccess.open_encrypted_with_pass("user://gift/auth/user_token", FileAccess.WRITE, client_secret) + var token_data = answer[3].get_string_from_utf8() + file.store_string(token_data) + request.queue_free() + user_token_received.emit(JSON.parse_string(token_data)) + break + OS.delay_msec(100) + +func send_response(peer : StreamPeer, response : String, body : PackedByteArray) -> void: + peer.put_data(("HTTP/1.1 %s\r\n" % response).to_utf8_buffer()) + peer.put_data("Server: GIFT (Godot Engine)\r\n".to_utf8_buffer()) + peer.put_data(("Content-Length: %d\r\n"% body.size()).to_utf8_buffer()) + peer.put_data("Connection: close\r\n".to_utf8_buffer()) + peer.put_data("Content-Type: text/plain; charset=UTF-8\r\n".to_utf8_buffer()) + peer.put_data("\r\n".to_utf8_buffer()) + peer.put_data(body) + +# If the token is valid, returns the username of the token bearer. Returns an empty String if the token was invalid. +func is_token_valid(token : String) -> String: + var request : HTTPRequest = HTTPRequest.new() + add_child(request) + request.request("https://id.twitch.tv/oauth2/validate", [USER_AGENT, "Authorization: OAuth " + token]) + var data = await(request.request_completed) + request.queue_free() + if (data[1] == 200): + var payload : Dictionary = JSON.parse_string(data[3].get_string_from_utf8()) + user_id = payload["user_id"] + return payload["login"] + return "" + +func refresh_token() -> void: + await(get_tree().create_timer(3600).timeout) + if (await(is_token_valid(token["access_token"])) == ""): + user_token_invalid.emit() + return + else: + refresh_token() + var to_remove : Array[String] = [] + for entry in eventsub_messages.keys(): + if (Time.get_ticks_msec() - eventsub_messages[entry] > 600000): + to_remove.append(entry) + for n in to_remove: + eventsub_messages.erase(n) + +func _process(delta : float) -> void: + if (websocket): + websocket.poll() + var state := websocket.get_ready_state() + match state: + WebSocketPeer.STATE_OPEN: + if (!connected): + twitch_connected.emit() + connected = true + print_debug("Connected to Twitch.") + else: + while (websocket.get_available_packet_count()): + data_received(websocket.get_packet()) + if (!chat_queue.is_empty() && (last_msg + chat_timeout_ms) <= Time.get_ticks_msec()): + send(chat_queue.pop_front()) + last_msg = Time.get_ticks_msec() + WebSocketPeer.STATE_CLOSED: + if (!connected): + twitch_unavailable.emit() + print_debug("Could not connect to Twitch.") + websocket = null + elif(twitch_restarting): + print_debug("Reconnecting to Twitch...") + twitch_reconnect.emit() + connect_to_irc() + await(twitch_connected) + for channel in channels.keys(): + join_channel(channel) + twitch_restarting = false + else: + print_debug("Disconnected from Twitch.") + twitch_disconnected.emit() + connected = false + print_debug("Connection closed! [%s]: %s"%[websocket.get_close_code(), websocket.get_close_reason()]) + if (eventsub): + eventsub.poll() + var state := eventsub.get_ready_state() + match state: + WebSocketPeer.STATE_OPEN: + if (!eventsub_connected): + events_connected.emit() + eventsub_connected = true + print_debug("Connected to EventSub.") + else: + while (eventsub.get_available_packet_count()): + process_event(eventsub.get_packet()) + WebSocketPeer.STATE_CLOSED: + if(!eventsub_connected): + print_debug("Could not connect to EventSub.") + events_unavailable.emit() + eventsub = null + elif(eventsub_restarting): + print_debug("Reconnecting to EventSub") + eventsub.close() + connect_to_eventsub(eventsub_reconnect_url) + await(eventsub_connected) + eventsub_restarting = false + else: + print_debug("Disconnected from EventSub.") + events_disconnected.emit() + eventsub_connected = false + print_debug("Connection closed! [%s]: %s"%[websocket.get_close_code(), websocket.get_close_reason()]) + +func process_event(data : PackedByteArray) -> void: + var msg : Dictionary = JSON.parse_string(data.get_string_from_utf8()) + if (eventsub_messages.has(msg["metadata"]["message_id"])): + return + eventsub_messages[msg["metadata"]["message_id"]] = Time.get_ticks_msec() + var payload : Dictionary = msg["payload"] + last_keepalive = Time.get_ticks_msec() + match msg["metadata"]["message_type"]: + "session_welcome": + session_id = payload["session"]["id"] + keepalive_timeout = payload["session"]["keepalive_timeout_seconds"] + events_id.emit(session_id) + "session_keepalive": + if (payload.has("session")): + keepalive_timeout = payload["session"]["keepalive_timeout_seconds"] + "session_reconnect": + eventsub_restarting = true + eventsub_reconnect_url = payload["session"]["reconnect_url"] + events_reconnect.emit() + "revocation": + events_revoked.emit(payload["subscription"]["type"], payload["subscription"]["status"]) + "notification": + var event_data : Dictionary = payload["event"] + event.emit(payload["subscription"]["type"], event_data) + +# Connect to Twitch IRC. Make sure to authenticate first. +func connect_to_irc() -> bool: + websocket = WebSocketPeer.new() + websocket.connect_to_url("wss://irc-ws.chat.twitch.tv:443") + print("Connecting to Twitch IRC.") + await(twitch_connected) + send("PASS oauth:%s" % [token["access_token"]], true) + send("NICK " + username.to_lower()) + var success = await(login_attempt) + if (success): + connected = true + return success + +# Connect to Twitch EventSub. Make sure to authenticate first. +func connect_to_eventsub(url : String = "wss://eventsub.wss.twitch.tv/ws") -> void: + eventsub = WebSocketPeer.new() + eventsub.connect_to_url(url) + print("Connecting to Twitch EventSub.") + await(events_id) + events_connected.emit() + +# Refer to https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/ for details on +# which API versions are available and which conditions are required. +func subscribe_event(event_name : String, version : int, conditions : Dictionary) -> void: + var data : Dictionary = {} + data["type"] = event_name + data["version"] = str(version) + data["condition"] = conditions + data["transport"] = { + "method":"websocket", + "session_id":session_id + } + var request : HTTPRequest = HTTPRequest.new() + add_child(request) + request.request("https://api.twitch.tv/helix/eventsub/subscriptions", [USER_AGENT, "Authorization: Bearer " + token["access_token"], "Client-Id:" + client_id, "Content-Type: application/json"], HTTPClient.METHOD_POST, JSON.stringify(data)) + var reply : Array = await(request.request_completed) + request.queue_free() + var response : Dictionary = JSON.parse_string(reply[3].get_string_from_utf8()) + if (response.has("error")): + print("Subscription failed for event '%s'. Error %s (%s): %s" % [event_name, response["status"], response["error"], response["message"]]) + return + print("Now listening to '%s' events." % event_name) + +# Request capabilities from twitch. +func request_caps(caps : String = "twitch.tv/commands twitch.tv/tags twitch.tv/membership") -> void: + send("CAP REQ :" + caps) + +# Sends a String to Twitch. +func send(text : String, token : bool = false) -> void: + websocket.send_text(text) + if(OS.is_debug_build()): + if(!token): + print("< " + text.strip_edges(false)) + else: + print("< PASS oauth:******************************") + +# Sends a chat message to a channel. Defaults to the only connected channel. +func chat(message : String, channel : String = ""): + var keys : Array = channels.keys() + if(channel != ""): + if (channel.begins_with("#")): + channel = channel.right(-1) + chat_queue.append("PRIVMSG #" + channel + " :" + message + "\r\n") + chat_message.emit(SenderData.new(last_state[channels.keys()[0]]["display-name"], channel, last_state[channels.keys()[0]]), message) + elif(keys.size() == 1): + chat_queue.append("PRIVMSG #" + channels.keys()[0] + " :" + message + "\r\n") + chat_message.emit(SenderData.new(last_state[channels.keys()[0]]["display-name"], channels.keys()[0], last_state[channels.keys()[0]]), message) + else: + print_debug("No channel specified.") + +# Send a whisper message to a user by username. Returns a empty dictionary on success. If it failed, "status" will be present in the Dictionary. +func whisper(message : String, target : String) -> Dictionary: + var user_data : Dictionary = await(user_data_by_name(target)) + if (user_data.has("status")): + return user_data + var response : int = await(whisper_by_uid(message, user_data["id"])) + if (response != HTTPClient.RESPONSE_NO_CONTENT): + return {"status": response} + return {} + +# Send a whisper message to a user by UID. Returns the response code. +func whisper_by_uid(message : String, target_id : String) -> int: + var request : HTTPRequest = HTTPRequest.new() + add_child(request) + request.request("https://api.twitch.tv/helix/whispers", [USER_AGENT, "Authorization: Bearer " + token["access_token"], "Client-Id:" + client_id, "Content-Type: application/json"], HTTPClient.METHOD_POST, JSON.stringify({"from_user_id": user_id, "to_user_id": target_id, "message": message})) + var reply : Array = await(request.request_completed) + request.queue_free() + if (reply[1] != HTTPClient.RESPONSE_NO_CONTENT): + print("Error sending the whisper: " + reply[3].get_string_from_utf8()) + return reply[0] + +# Returns the response as Dictionary. If it failed, "error" will be present in the Dictionary. +func user_data_by_name(username : String) -> Dictionary: + var request : HTTPRequest = HTTPRequest.new() + add_child(request) + request.request("https://api.twitch.tv/helix/users?login=" + username, [USER_AGENT, "Authorization: Bearer " + token["access_token"], "Client-Id:" + client_id, "Content-Type: application/json"], HTTPClient.METHOD_GET) + var reply : Array = await(request.request_completed) + var response : Dictionary = JSON.parse_string(reply[3].get_string_from_utf8()) + request.queue_free() + if (response.has("error")): + print("Error fetching user data: " + reply[3].get_string_from_utf8()) + return response + else: + return response["data"][0] + +func get_emote(emote_id : String, scale : String = "1.0") -> Texture2D: + var texture : Texture2D + var cachename : String = emote_id + "_" + scale + var filename : String = disk_cache_path + "/" + RequestType.keys()[RequestType.EMOTE] + "/" + cachename + ".png" + if !caches[RequestType.EMOTE].has(cachename): + if (disk_cache && FileAccess.file_exists(filename)): + texture = ImageTexture.new() + var img : Image = Image.new() + img.load_png_from_buffer(FileAccess.get_file_as_bytes(filename)) + texture.create_from_image(img) + else: + var request : HTTPRequest = HTTPRequest.new() + add_child(request) + request.request("https://static-cdn.jtvnw.net/emoticons/v1/" + emote_id + "/" + scale, [USER_AGENT,"Accept: */*"]) + var data = await(request.request_completed) + request.queue_free() + var img : Image = Image.new() + img.load_png_from_buffer(data[3]) + texture = ImageTexture.create_from_image(img) + texture.take_over_path(filename) + if (disk_cache): + DirAccess.make_dir_recursive_absolute(filename.get_base_dir()) + texture.get_image().save_png(filename) + caches[RequestType.EMOTE][cachename] = texture + return caches[RequestType.EMOTE][cachename] + +func get_badge(badge_name : String, channel_id : String = "_global", scale : String = "1") -> Texture2D: + var badge_data : PackedStringArray = badge_name.split("/", true, 1) + var texture : Texture2D + var cachename = badge_data[0] + "_" + badge_data[1] + "_" + scale + var filename : String = disk_cache_path + "/" + RequestType.keys()[RequestType.BADGE] + "/" + channel_id + "/" + cachename + ".png" + if (!caches[RequestType.BADGE].has(channel_id)): + caches[RequestType.BADGE][channel_id] = {} + if (!caches[RequestType.BADGE][channel_id].has(cachename)): + if (disk_cache && FileAccess.file_exists(filename)): + var img : Image = Image.new() + img.load_png_from_buffer(FileAccess.get_file_as_bytes(filename)) + texture = ImageTexture.create_from_image(img) + texture.take_over_path(filename) + else: + var map : Dictionary = caches[RequestType.BADGE_MAPPING].get(channel_id, await(get_badge_mapping(channel_id))) + if (!map.is_empty()): + if(map.has(badge_data[0])): + var request : HTTPRequest = HTTPRequest.new() + add_child(request) + request.request(map[badge_data[0]]["versions"][badge_data[1]]["image_url_" + scale + "x"], [USER_AGENT,"Accept: */*"]) + var data = await(request.request_completed) + var img : Image = Image.new() + img.load_png_from_buffer(data[3]) + texture = ImageTexture.create_from_image(img) + texture.take_over_path(filename) + request.queue_free() + elif channel_id != "_global": + return await(get_badge(badge_name, "_global", scale)) + elif (channel_id != "_global"): + return await(get_badge(badge_name, "_global", scale)) + if (disk_cache): + DirAccess.make_dir_recursive_absolute(filename.get_base_dir()) + texture.get_image().save_png(filename) + texture.take_over_path(filename) + caches[RequestType.BADGE][channel_id][cachename] = texture + return caches[RequestType.BADGE][channel_id][cachename] + +func get_badge_mapping(channel_id : String = "_global") -> Dictionary: + if caches[RequestType.BADGE_MAPPING].has(channel_id): + return caches[RequestType.BADGE_MAPPING][channel_id] + + var filename : String = disk_cache_path + "/" + RequestType.keys()[RequestType.BADGE_MAPPING] + "/" + channel_id + ".json" + if (disk_cache && FileAccess.file_exists(filename)): + var cache = JSON.parse_string(FileAccess.get_file_as_string(filename)) + if "badge_sets" in cache: + return cache["badge_sets"] + + var request : HTTPRequest = HTTPRequest.new() + add_child(request) + request.request("https://api.twitch.tv/helix/chat/badges" + ("/global" if channel_id == "_global" else "?broadcaster_id=" + channel_id), [USER_AGENT, "Authorization: Bearer " + token["access_token"], "Client-Id:" + client_id, "Content-Type: application/json"], HTTPClient.METHOD_GET) + var reply : Array = await(request.request_completed) + var response : Dictionary = JSON.parse_string(reply[3].get_string_from_utf8()) + var mappings : Dictionary = {} + for entry in response["data"]: + if (!mappings.has(entry["set_id"])): + mappings[entry["set_id"]] = {"versions": {}} + for version in entry["versions"]: + mappings[entry["set_id"]]["versions"][version["id"]] = version + request.queue_free() + if (reply[1] == HTTPClient.RESPONSE_OK): + caches[RequestType.BADGE_MAPPING][channel_id] = mappings + if (disk_cache): + DirAccess.make_dir_recursive_absolute(filename.get_base_dir()) + var file : FileAccess = FileAccess.open(filename, FileAccess.WRITE) + file.store_string(JSON.stringify(mappings)) + else: + print("Could not retrieve badge mapping for channel_id " + channel_id + ".") + return {} + return caches[RequestType.BADGE_MAPPING][channel_id] + +func data_received(data : PackedByteArray) -> void: + var messages : PackedStringArray = data.get_string_from_utf8().strip_edges(false).split("\r\n") + var tags = {} + for message in messages: + if(message.begins_with("@")): + var msg : PackedStringArray = message.split(" ", false, 1) + message = msg[1] + for tag in msg[0].split(";"): + var pair = tag.split("=") + tags[pair[0]] = pair[1] + if (OS.is_debug_build()): + print("> " + message) + handle_message(message, tags) + +# Registers a command on an object with a func to call, similar to connect(signal, instance, func). +func add_command(cmd_name : String, callable : Callable, max_args : int = 0, min_args : int = 0, permission_level : int = PermissionFlag.EVERYONE, where : int = WhereFlag.CHAT) -> void: + commands[cmd_name] = CommandData.new(callable, permission_level, max_args, min_args, where) + +# Removes a single command or alias. +func remove_command(cmd_name : String) -> void: + commands.erase(cmd_name) + +# Removes a command and all associated aliases. +func purge_command(cmd_name : String) -> void: + var to_remove = commands.get(cmd_name) + if(to_remove): + var remove_queue = [] + for command in commands.keys(): + if(commands[command].func_ref == to_remove.func_ref): + remove_queue.append(command) + for queued in remove_queue: + commands.erase(queued) + +func add_alias(cmd_name : String, alias : String) -> void: + if(commands.has(cmd_name)): + commands[alias] = commands.get(cmd_name) + +func add_aliases(cmd_name : String, aliases : PackedStringArray) -> void: + for alias in aliases: + add_alias(cmd_name, alias) + +func handle_message(message : String, tags : Dictionary) -> void: + if(message == "PING :tmi.twitch.tv"): + send("PONG :tmi.twitch.tv") + pong.emit() + return + var msg : PackedStringArray = message.split(" ", true, 3) + match msg[1]: + "NOTICE": + var info : String = msg[3].right(-1) + if (info == "Login authentication failed" || info == "Login unsuccessful"): + print_debug("Authentication failed.") + login_attempt.emit(false) + elif (info == "You don't have permission to perform that action"): + print_debug("No permission. Check if access token is still valid. Aborting.") + user_token_invalid.emit() + set_process(false) + else: + unhandled_message.emit(message, tags) + "001": + print_debug("Authentication successful.") + login_attempt.emit(true) + "PRIVMSG": + var sender_data : SenderData = SenderData.new(user_regex.search(msg[0]).get_string(), msg[2], tags) + handle_command(sender_data, msg[3].split(" ", true, 1)) + chat_message.emit(sender_data, msg[3].right(-1)) + "WHISPER": + var sender_data : SenderData = SenderData.new(user_regex.search(msg[0]).get_string(), msg[2], tags) + handle_command(sender_data, msg[3].split(" ", true, 1), true) + whisper_message.emit(sender_data, msg[3].right(-1)) + "RECONNECT": + twitch_restarting = true + "USERSTATE", "ROOMSTATE": + var room = msg[2].right(-1) + if (!last_state.has(room)): + last_state[room] = tags + channel_data_received.emit(room) + else: + for key in tags: + last_state[room][key] = tags[key] + _: + unhandled_message.emit(message, tags) + +func handle_command(sender_data : SenderData, msg : PackedStringArray, whisper : bool = false) -> void: + if(command_prefixes.has(msg[0].substr(1, 1))): + var command : String = msg[0].right(-2) + var cmd_data : CommandData = commands.get(command) + if(cmd_data): + if(whisper == true && cmd_data.where & WhereFlag.WHISPER != WhereFlag.WHISPER): + return + elif(whisper == false && cmd_data.where & WhereFlag.CHAT != WhereFlag.CHAT): + return + var args = "" if msg.size() == 1 else msg[1] + var arg_ary : PackedStringArray = PackedStringArray() if args == "" else args.split(" ") + if(arg_ary.size() > cmd_data.max_args && cmd_data.max_args != -1 || arg_ary.size() < cmd_data.min_args): + cmd_invalid_argcount.emit(command, sender_data, cmd_data, arg_ary) + print_debug("Invalid argcount!") + return + if(cmd_data.permission_level != 0): + var user_perm_flags = get_perm_flag_from_tags(sender_data.tags) + if(user_perm_flags & cmd_data.permission_level == 0): + cmd_no_permission.emit(command, sender_data, cmd_data, arg_ary) + print_debug("No Permission for command!") + return + if(arg_ary.size() == 0): + cmd_data.func_ref.call(CommandInfo.new(sender_data, command, whisper)) + else: + cmd_data.func_ref.call(CommandInfo.new(sender_data, command, whisper), arg_ary) + +func get_perm_flag_from_tags(tags : Dictionary) -> int: + var flag = 0 + var entry = tags.get("badges") + if(entry): + for badge in entry.split(","): + if(badge.begins_with("vip")): + flag += PermissionFlag.VIP + if(badge.begins_with("broadcaster")): + flag += PermissionFlag.STREAMER + entry = tags.get("mod") + if(entry): + if(entry == "1"): + flag += PermissionFlag.MOD + entry = tags.get("subscriber") + if(entry): + if(entry == "1"): + flag += PermissionFlag.SUB + return flag + +func join_channel(channel : String) -> void: + var lower_channel : String = channel.to_lower() + channels[lower_channel] = {} + send("JOIN #" + lower_channel) + +func leave_channel(channel : String) -> void: + var lower_channel : String = channel.to_lower() + send("PART #" + lower_channel) + channels.erase(lower_channel) diff --git a/addons/gift/icon.png b/addons/gift/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..73b70ff1e2e73942687419726d43c7aa24b32af5 GIT binary patch literal 188 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPE^4e$wZ1=7j0|Hrg`iq3nO6|gJF zcCx*F$&yKm(m)Buk|4ie28U-i(tw;$PZ!6Kid)GJY&o0(RyTDReU?i}x2rb%NIaRg z(qW^Dg1pAEV-r@e2pI|`dxRCUc3I9eRGDqbskzFsS>m9VQ`*6VwuuI1Y|&C3jD}Jt g8X~4mV_U+QQ6Xvc^Ze@PKnoZ=UHx3vIVCg!0MYk5egFUf literal 0 HcmV?d00001 diff --git a/addons/gift/icon.png.import b/addons/gift/icon.png.import new file mode 100644 index 0000000..7dfa23c --- /dev/null +++ b/addons/gift/icon.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://rkm6ge1nohu1" +path="res://.godot/imported/icon.png-b5cf707f4ba91fefa5df60a746e02900.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gift/icon.png" +dest_files=["res://.godot/imported/icon.png-b5cf707f4ba91fefa5df60a746e02900.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/addons/gift/plugin.cfg b/addons/gift/plugin.cfg new file mode 100644 index 0000000..81e8e64 --- /dev/null +++ b/addons/gift/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Godot IRC For Twitch" +description="Godot websocket implementation for Twitch IRC." +author="issork" +version="4.1.5" +script="gift.gd" diff --git a/addons/gift/util/cmd_data.gd b/addons/gift/util/cmd_data.gd new file mode 100644 index 0000000..dc05469 --- /dev/null +++ b/addons/gift/util/cmd_data.gd @@ -0,0 +1,16 @@ +extends RefCounted +class_name CommandData + +var func_ref : Callable +var permission_level : int +var max_args : int +var min_args : int +var where : int + +func _init(f_ref : Callable, perm_lvl : int, mx_args : int, mn_args : int, whr : int): + func_ref = f_ref + permission_level = perm_lvl + max_args = mx_args + min_args = mn_args + where = whr + diff --git a/addons/gift/util/cmd_info.gd b/addons/gift/util/cmd_info.gd new file mode 100644 index 0000000..8bf94ef --- /dev/null +++ b/addons/gift/util/cmd_info.gd @@ -0,0 +1,12 @@ +extends RefCounted +class_name CommandInfo + +var sender_data : SenderData +var command : String +var whisper : bool + +func _init(sndr_dt, cmd, whspr): + sender_data = sndr_dt + command = cmd + whisper = whspr + diff --git a/addons/gift/util/sender_data.gd b/addons/gift/util/sender_data.gd new file mode 100644 index 0000000..c7f71f7 --- /dev/null +++ b/addons/gift/util/sender_data.gd @@ -0,0 +1,11 @@ +extends RefCounted +class_name SenderData + +var user : String +var channel : String +var tags : Dictionary + +func _init(usr : String, ch : String, tag_dict : Dictionary): + user = usr + channel = ch + tags = tag_dict diff --git a/example/Button.gd b/example/Button.gd new file mode 100644 index 0000000..dfb5f21 --- /dev/null +++ b/example/Button.gd @@ -0,0 +1,7 @@ +extends Button + +func _pressed(): + %Gift.chat(%LineEdit.text) + var channel : String = %Gift.channels.keys()[0] + %Gift.handle_command(SenderData.new(%Gift.username, channel, %Gift.last_state[channel]), (":" + %LineEdit.text).split(" ", true, 1)) + %LineEdit.text = "" diff --git a/example/ChatContainer.gd b/example/ChatContainer.gd new file mode 100644 index 0000000..7de508f --- /dev/null +++ b/example/ChatContainer.gd @@ -0,0 +1,42 @@ +extends VBoxContainer + +func put_chat(senderdata : SenderData, msg : String): + var msgnode : Control = preload("res://example/ChatMessage.tscn").instantiate() + var time = Time.get_time_dict_from_system() + var badges : String = "" + for badge in senderdata.tags["badges"].split(",", false): + var result = await(%Gift.get_badge(badge, senderdata.tags["room-id"])) + badges += "[img=center]" + result.resource_path + "[/img] " + var locations : Array = [] + if (senderdata.tags.has("emotes")): + for emote in senderdata.tags["emotes"].split("/", false): + var data : Array = emote.split(":") + for d in data[1].split(","): + var start_end = d.split("-") + locations.append(EmoteLocation.new(data[0], int(start_end[0]), int(start_end[1]))) + locations.sort_custom(Callable(EmoteLocation, "smaller")) + var offset = 0 + for loc in locations: + var result = await(%Gift.get_emote(loc.id)) + var emote_string = "[img=center]" + result.resource_path +"[/img]" + msg = msg.substr(0, loc.start + offset) + emote_string + msg.substr(loc.end + offset + 1) + offset += emote_string.length() + loc.start - loc.end - 1 + var bottom : bool = $Chat/ScrollContainer.scroll_vertical == $Chat/ScrollContainer.get_v_scroll_bar().max_value - $Chat/ScrollContainer.get_v_scroll_bar().get_rect().size.y + msgnode.set_msg("%02d:%02d" % [time["hour"], time["minute"]], senderdata, msg, badges) + $Chat/ScrollContainer/ChatMessagesContainer.add_child(msgnode) + await(get_tree().process_frame) + if (bottom): + $Chat/ScrollContainer.scroll_vertical = $Chat/ScrollContainer.get_v_scroll_bar().max_value + +class EmoteLocation extends RefCounted: + var id : String + var start : int + var end : int + + func _init(emote_id, start_idx, end_idx): + self.id = emote_id + self.start = start_idx + self.end = end_idx + + static func smaller(a : EmoteLocation, b : EmoteLocation): + return a.start < b.start diff --git a/example/ChatMessage.gd b/example/ChatMessage.gd new file mode 100644 index 0000000..3e035df --- /dev/null +++ b/example/ChatMessage.gd @@ -0,0 +1,5 @@ +extends HBoxContainer + +func set_msg(stamp : String, data : SenderData, msg : String, badges : String) -> void: + $RichTextLabel.text = stamp + " " + badges + "[b][color="+ data.tags["color"] + "]" + data.tags["display-name"] +"[/color][/b]: " + msg + queue_sort() diff --git a/example/ChatMessage.tscn b/example/ChatMessage.tscn new file mode 100644 index 0000000..32967a0 --- /dev/null +++ b/example/ChatMessage.tscn @@ -0,0 +1,16 @@ +[gd_scene load_steps=2 format=3 uid="uid://g4ajgul65cwi"] + +[ext_resource type="Script" path="res://example/ChatMessage.gd" id="1"] + +[node name="ChatMessage" type="HBoxContainer"] +size_flags_horizontal = 3 +script = ExtResource("1") + +[node name="RichTextLabel" type="RichTextLabel" parent="."] +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 2 +bbcode_enabled = true +fit_content = true +scroll_active = false +selection_enabled = true diff --git a/example/Example.tscn b/example/Example.tscn new file mode 100644 index 0000000..2d4f7f0 --- /dev/null +++ b/example/Example.tscn @@ -0,0 +1,60 @@ +[gd_scene load_steps=5 format=3 uid="uid://bculs28gstcxk"] + +[ext_resource type="Script" path="res://example/Gift.gd" id="1_yfglq"] +[ext_resource type="Script" path="res://example/ChatContainer.gd" id="2_knohk"] +[ext_resource type="Script" path="res://example/LineEdit.gd" id="3_oafvo"] +[ext_resource type="Script" path="res://example/Button.gd" id="4_wrvcq"] + +[node name="Example" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Gift" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("1_yfglq") +scopes = Array[String](["chat:edit", "chat:read", "moderator:read:followers"]) + +[node name="ChatContainer" type="VBoxContainer" parent="."] +unique_name_in_owner = true +layout_mode = 0 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("2_knohk") + +[node name="Chat" type="Panel" parent="ChatContainer"] +show_behind_parent = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="ScrollContainer" type="ScrollContainer" parent="ChatContainer/Chat"] +layout_mode = 0 +anchor_right = 1.0 +anchor_bottom = 1.0 +follow_focus = true + +[node name="ChatMessagesContainer" type="VBoxContainer" parent="ChatContainer/Chat/ScrollContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="HBoxContainer" type="HBoxContainer" parent="ChatContainer"] +layout_mode = 2 + +[node name="LineEdit" type="LineEdit" parent="ChatContainer/HBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +caret_blink = true +script = ExtResource("3_oafvo") + +[node name="Button" type="Button" parent="ChatContainer/HBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "Send" +script = ExtResource("4_wrvcq") diff --git a/example/Gift.gd b/example/Gift.gd new file mode 100644 index 0000000..5d43acb --- /dev/null +++ b/example/Gift.gd @@ -0,0 +1,115 @@ +extends Gift + +func _ready() -> void: + cmd_no_permission.connect(no_permission) + chat_message.connect(on_chat) + event.connect(on_event) + + # I use a file in the working directory to store auth data + # so that I don't accidentally push it to the repository. + # Replace this or create a auth file with 3 lines in your + # project directory: + # + # + # + var authfile := FileAccess.open("./example/auth.txt", FileAccess.READ) + client_id = authfile.get_line() + client_secret = authfile.get_line() + var initial_channel = authfile.get_line() + + # When calling this method, a browser will open. + # Log in to the account that should be used. + await(authenticate(client_id, client_secret)) + var success = await(connect_to_irc()) + if (success): + request_caps() + join_channel(initial_channel) + await(channel_data_received) + await(connect_to_eventsub()) # Only required if you want to receive EventSub events. + # Refer to https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/ for details on + # what events exist, which API versions are available and which conditions are required. + # Make sure your token has all required scopes for the event. + subscribe_event("channel.follow", 2, {"broadcaster_user_id": user_id, "moderator_user_id": user_id}) + + # Adds a command with a specified permission flag. + # All implementations must take at least one arg for the command info. + # Implementations that recieve args requrires two args, + # the second arg will contain all params in a PackedStringArray + # This command can only be executed by VIPS/MODS/SUBS/STREAMER + add_command("test", command_test, 0, 0, PermissionFlag.NON_REGULAR) + + # These two commands can be executed by everyone + add_command("helloworld", hello_world) + add_command("greetme", greet_me) + + # This command can only be executed by the streamer + add_command("streamer_only", streamer_only, 0, 0, PermissionFlag.STREAMER) + + # Command that requires exactly 1 arg. + add_command("greet", greet, 1, 1) + + # Command that prints every arg seperated by a comma (infinite args allowed), at least 2 required + add_command("list", list, -1, 2) + + # Adds a command alias + add_alias("test","test1") + add_alias("test","test2") + add_alias("test","test3") + # Or do it in a single line + # add_aliases("test", ["test1", "test2", "test3"]) + + # Remove a single command + remove_command("test2") + + # Now only knows commands "test", "test1" and "test3" + remove_command("test") + # Now only knows commands "test1" and "test3" + + # Remove all commands that call the same function as the specified command + purge_command("test1") + # Now no "test" command is known + + # Send a chat message to the only connected channel () + # Fails, if connected to more than one channel. +# chat("TEST") + + # Send a chat message to channel +# chat("TEST", initial_channel) + + # Send a whisper to target user (requires user:manage:whispers scope) +# whisper("TEST", initial_channel) + +func on_event(type : String, data : Dictionary) -> void: + match(type): + "channel.follow": + print("%s followed your channel!" % data["user_name"]) + +func on_chat(data : SenderData, msg : String) -> void: + %ChatContainer.put_chat(data, msg) + +# Check the CommandInfo class for the available info of the cmd_info. +func command_test(cmd_info : CommandInfo) -> void: + print("A") + +func hello_world(cmd_info : CommandInfo) -> void: + chat("HELLO WORLD!") + +func streamer_only(cmd_info : CommandInfo) -> void: + chat("Streamer command executed") + +func no_permission(cmd_info : CommandInfo) -> void: + chat("NO PERMISSION!") + +func greet(cmd_info : CommandInfo, arg_ary : PackedStringArray) -> void: + chat("Greetings, " + arg_ary[0]) + +func greet_me(cmd_info : CommandInfo) -> void: + chat("Greetings, " + cmd_info.sender_data.tags["display-name"] + "!") + +func list(cmd_info : CommandInfo, arg_ary : PackedStringArray) -> void: + var msg = "" + for i in arg_ary.size() - 1: + msg += arg_ary[i] + msg += ", " + msg += arg_ary[arg_ary.size() - 1] + chat(msg) diff --git a/example/LineEdit.gd b/example/LineEdit.gd new file mode 100644 index 0000000..e186408 --- /dev/null +++ b/example/LineEdit.gd @@ -0,0 +1,6 @@ +extends LineEdit + +func _input(event : InputEvent): + if (event is InputEventKey): + if (event.pressed && event.keycode == KEY_ENTER): + %Button._pressed() diff --git a/example/default_env.tres b/example/default_env.tres new file mode 100644 index 0000000..20207a4 --- /dev/null +++ b/example/default_env.tres @@ -0,0 +1,7 @@ +[gd_resource type="Environment" load_steps=2 format=2] + +[sub_resource type="ProceduralSky" id=1] + +[resource] +background_mode = 2 +background_sky = SubResource( 1 ) diff --git a/example/icon.png b/example/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4a866c82454876ea9a7c22283004ea778e409aa7 GIT binary patch literal 271 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDI(Ey(iS0J4{`+rR9r|7(gSpmC( zY$w~>mn@m2C=HZgED7=pW^j0RBMrz|;pyTSQgQ3;)q|Xe4Fp^Qw@(ne(-iH&op z^a-&S*bNote9ri%nYQ))mI;s7PMLRco&$r}dcphE42%^O;nPdP&)7CH@O)7`&GlLK z0CU36HPhM;FDePM<$l0iv#R(@nTO7IKL-5-yPo{5zmgNrJ!JjByzA$|nwURn=YBKn zV{l(`+KPVz`vt}ivI-6j42*vo-z~IfyeB7efGHq@_eBzeyMtnHC4=