From 603867f9f02902c318a7c0fc577562271080cb1e Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Sat, 6 Aug 2022 20:46:18 -0400 Subject: [PATCH] initial commit Signed-off-by: Xe Iaso --- .gitignore | 17 ++ Button.gd | 5 + ChatContainer.gd | 40 +++++ ChatMessage.gd | 4 + ChatMessage.tscn | 23 +++ Gift.gd | 79 ++++++++ Node.gd | 26 +++ Node.tscn | 90 ++++++++++ addons/gift/gift.gd | 8 + addons/gift/gift_node.gd | 280 +++++++++++++++++++++++++++++ addons/gift/icon.png | Bin 0 -> 188 bytes addons/gift/icon.png.import | 35 ++++ addons/gift/placeholder.png | Bin 0 -> 90 bytes addons/gift/placeholder.png.import | 13 ++ addons/gift/plugin.cfg | 7 + addons/gift/util/cmd_data.gd | 16 ++ addons/gift/util/cmd_info.gd | 12 ++ addons/gift/util/image_cache.gd | 195 ++++++++++++++++++++ addons/gift/util/sender_data.gd | 11 ++ default_env.tres | 7 + export_presets.cfg | 24 +++ icon.png | Bin 0 -> 3305 bytes icon.png.import | 35 ++++ project.godot | 70 ++++++++ scenes/2DOverlay.tscn | 8 + scenes/3DOverlay.tscn | 13 ++ secrets/.gitignore | 2 + 27 files changed, 1020 insertions(+) create mode 100644 .gitignore create mode 100644 Button.gd create mode 100644 ChatContainer.gd create mode 100644 ChatMessage.gd create mode 100644 ChatMessage.tscn create mode 100644 Gift.gd create mode 100644 Node.gd create mode 100644 Node.tscn 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/placeholder.png create mode 100644 addons/gift/placeholder.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/image_cache.gd create mode 100644 addons/gift/util/sender_data.gd create mode 100644 default_env.tres create mode 100644 export_presets.cfg create mode 100644 icon.png create mode 100644 icon.png.import create mode 100644 project.godot create mode 100644 scenes/2DOverlay.tscn create mode 100644 scenes/3DOverlay.tscn create mode 100644 secrets/.gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bded6b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Godot 4+ specific ignores +.godot/ + +# Godot-specific ignores +.import/ + +# Imported translations (automatically generated from CSV files) +*.translation + +# Mono-specific ignores +.mono/ +data_*/ +mono_crash.*.json + +# System/tool-specific ignores +.directory +*~ diff --git a/Button.gd b/Button.gd new file mode 100644 index 0000000..11f7090 --- /dev/null +++ b/Button.gd @@ -0,0 +1,5 @@ +extends Button + +func _pressed(): + $"../../../Gift".chat($"../LineEdit".text) + $"../LineEdit".text = "" diff --git a/ChatContainer.gd b/ChatContainer.gd new file mode 100644 index 0000000..09aa1ea --- /dev/null +++ b/ChatContainer.gd @@ -0,0 +1,40 @@ +extends VBoxContainer + +func put_chat(senderdata : SenderData, msg : String): + var msgnode : Control = preload("res://ChatMessage.tscn").instance() + var time = OS.get_time() + var badges : String = "" + if ($"../Gift".image_cache): + for badge in senderdata.tags["badges"].split(",", false): + badges += "[img=center]" + $"../Gift".image_cache.get_badge(badge, senderdata.tags["room-id"]).resource_path + "[/img] " + var locations : Array = [] + 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(EmoteLocation, "smaller") + var offset = 0 + for loc in locations: + var emote_string = "[img=center]" + $"../Gift".image_cache.get_emote(loc.id).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_scrollbar().max_value - $Chat/ScrollContainer.get_v_scrollbar().rect_size.y + msgnode.set_msg(str(time["hour"]) + ":" + ("0" + str(time["minute"]) if time["minute"] < 10 else str(time["minute"])), senderdata, msg, badges) + $Chat/ScrollContainer/ChatMessagesContainer.add_child(msgnode) + yield(get_tree(), "idle_frame") + if (bottom): + $Chat/ScrollContainer.scroll_vertical = $Chat/ScrollContainer.get_v_scrollbar().max_value + +class EmoteLocation extends Reference: + 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/ChatMessage.gd b/ChatMessage.gd new file mode 100644 index 0000000..9f3b14b --- /dev/null +++ b/ChatMessage.gd @@ -0,0 +1,4 @@ +extends HBoxContainer + +func set_msg(stamp : String, data : SenderData, msg : String, badges : String) -> void: + $RichTextLabel.bbcode_text = stamp + " " + badges + "[b][color="+ data.tags["color"] + "]" + data.tags["display-name"] +"[/color][/b]: " + msg diff --git a/ChatMessage.tscn b/ChatMessage.tscn new file mode 100644 index 0000000..fa6a8cd --- /dev/null +++ b/ChatMessage.tscn @@ -0,0 +1,23 @@ +[gd_scene load_steps=2 format=2] + +[ext_resource path="res://ChatMessage.gd" type="Script" id=1] + +[node name="ChatMessage" type="HBoxContainer"] +margin_right = 400.0 +margin_bottom = 24.0 +size_flags_horizontal = 3 +script = ExtResource( 1 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="RichTextLabel" type="RichTextLabel" parent="."] +margin_right = 400.0 +margin_bottom = 24.0 +focus_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +bbcode_enabled = true +fit_content_height = true +scroll_active = false +selection_enabled = true diff --git a/Gift.gd b/Gift.gd new file mode 100644 index 0000000..36ab56c --- /dev/null +++ b/Gift.gd @@ -0,0 +1,79 @@ +extends Gift + +func _ready() -> void: + # 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 := File.new() + authfile.open("./auth", File.READ) + var botname := authfile.get_line() + var token := authfile.get_line() + var initial_channel = authfile.get_line() + + connect_to_twitch() + yield(self, "twitch_connected") + + # Login using your username and an oauth token. + # You will have to either get a oauth token yourself or use + # https://twitchapps.com/tokengen/ + # to generate a token with custom scopes. + authenticate_oauth(botname, token) + if(yield(self, "login_attempt") == false): + print("Invalid username or token.") + return + join_channel(initial_channel) + + connect("cmd_no_permission", get_parent(), "no_permission") + connect("chat_message", get_parent(), "chat_message") + + # 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 PoolStringArray + # This command can only be executed by VIPS/MODS/SUBS/STREAMER + add_command("test", get_parent(), "command_test", 0, 0, PermissionFlag.NON_REGULAR) + + # These two commands can be executed by everyone + add_command("helloworld", get_parent(), "hello_world") + add_command("greetme", get_parent(), "greet_me") + + # This command can only be executed by the streamer + add_command("streamer_only", get_parent(), "streamer_only", 0, 0, PermissionFlag.STREAMER) + + # Command that requires exactly 1 arg. + add_command("greet", get_parent(), "greet", 1, 1) + + # Command that prints every arg seperated by a comma (infinite args allowed), at least 2 required + add_command("list", get_parent(), "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 +# whisper("TEST", initial_channel) diff --git a/Node.gd b/Node.gd new file mode 100644 index 0000000..f927ef2 --- /dev/null +++ b/Node.gd @@ -0,0 +1,26 @@ +extends Control + +func chat_message(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: + $Gift.chat("HELLO WORLD!") + +func streamer_only(cmd_info : CommandInfo) -> void: + $Gift.chat("Streamer command executed") + +func no_permission(cmd_info : CommandInfo) -> void: + $Gift.chat("NO PERMISSION!") + +func greet(cmd_info : CommandInfo, arg_ary : PoolStringArray) -> void: + $Gift.chat("Greetings, " + arg_ary[0]) + +func greet_me(cmd_info : CommandInfo) -> void: + $Gift.chat("Greetings, " + cmd_info.sender_data.tags["display-name"] + "!") + +func list(cmd_info : CommandInfo, arg_ary : PoolStringArray) -> void: + $Gift.chat(arg_ary.join(", ")) diff --git a/Node.tscn b/Node.tscn new file mode 100644 index 0000000..f7cf884 --- /dev/null +++ b/Node.tscn @@ -0,0 +1,90 @@ +[gd_scene load_steps=6 format=2] + +[ext_resource path="res://Gift.gd" type="Script" id=1] +[ext_resource path="res://addons/gift/icon.png" type="Texture" id=2] +[ext_resource path="res://Button.gd" type="Script" id=3] +[ext_resource path="res://ChatContainer.gd" type="Script" id=5] +[ext_resource path="res://Node.gd" type="Script" id=6] + +[node name="Node" type="Control"] +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource( 6 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Gift" type="Node" parent="."] +script = ExtResource( 1 ) +__meta__ = { +"_editor_icon": ExtResource( 2 ) +} +get_images = true + +[node name="ChatContainer" type="VBoxContainer" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource( 5 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Chat" type="Panel" parent="ChatContainer"] +show_behind_parent = true +margin_right = 400.0 +margin_bottom = 572.0 +size_flags_horizontal = 3 +size_flags_vertical = 3 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="ScrollContainer" type="ScrollContainer" parent="ChatContainer/Chat"] +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 10.0 +margin_top = 10.0 +margin_right = -10.0 +margin_bottom = -10.0 +follow_focus = true +scroll_horizontal_enabled = false +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="ChatMessagesContainer" type="VBoxContainer" parent="ChatContainer/Chat/ScrollContainer"] +margin_right = 380.0 +margin_bottom = 552.0 +size_flags_horizontal = 3 +size_flags_vertical = 3 +custom_constants/separation = 6 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="HBoxContainer" type="HBoxContainer" parent="ChatContainer"] +margin_top = 576.0 +margin_right = 400.0 +margin_bottom = 600.0 + +[node name="LineEdit" type="LineEdit" parent="ChatContainer/HBoxContainer"] +margin_right = 296.0 +margin_bottom = 24.0 +size_flags_horizontal = 3 +size_flags_vertical = 3 +caret_blink = true +caret_blink_speed = 0.5 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Button" type="Button" parent="ChatContainer/HBoxContainer"] +margin_left = 300.0 +margin_right = 400.0 +margin_bottom = 24.0 +rect_min_size = Vector2( 100, 0 ) +text = "Send" +script = ExtResource( 3 ) +__meta__ = { +"_edit_use_anchors_": false +} diff --git a/addons/gift/gift.gd b/addons/gift/gift.gd new file mode 100644 index 0000000..9296ecc --- /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..bebe5d6 --- /dev/null +++ b/addons/gift/gift_node.gd @@ -0,0 +1,280 @@ +extends Node +class_name Gift + +# The underlying websocket sucessfully connected to twitch. +signal twitch_connected +# The connection has been closed. Not emitted if twitch announced a reconnect. +signal twitch_disconnected +# The connection to twitch failed. +signal twitch_unavailable +# Twitch requested the client to reconnect. (Will be unavailable until next connect) +signal twitch_reconnect +# The client tried to login. 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) +# 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's ping is about to be answered with a pong. +signal pong +# Emote has been downloaded +signal emote_downloaded(emote_id) +# Badge has been downloaded +signal badge_downloaded(badge_name) + +# Messages starting with one of these symbols are handled as commands. '/' will be ignored, reserved by Twitch. +export(Array, String) var command_prefixes : Array = ["!"] +# Time to wait in msec after each sent chat message. Values below ~310 might lead to a disconnect after 100 messages. +export(int) var chat_timeout_ms = 320 +export(bool) var get_images : bool = false +# 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(bool) var disk_cache : bool = false +# Disk Cache has to be enbaled for this to work +export(String, FILE) var disk_cache_path = "user://gift/cache" + +var websocket := WebSocketClient.new() +var user_regex := RegEx.new() +var twitch_restarting +# Twitch disconnects connected clients if too many chat messages are being sent. (At about 100 messages/30s) +var chat_queue = [] +var last_msg = OS.get_ticks_msec() +# Mapping of channels to their channel info, like available badges. +var channels : Dictionary = {} +var commands : Dictionary = {} +var image_cache : ImageCache + +# 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(): + websocket.verify_ssl = true + user_regex.compile("(?<=!)[\\w]*(?=@)") + +func _ready() -> void: + websocket.connect("data_received", self, "data_received") + websocket.connect("connection_established", self, "connection_established") + websocket.connect("connection_closed", self, "connection_closed") + websocket.connect("server_close_request", self, "sever_close_request") + websocket.connect("connection_error", self, "connection_error") + if(get_images): + image_cache = ImageCache.new(disk_cache, disk_cache_path) + +func connect_to_twitch() -> void: + if(websocket.connect_to_url("wss://irc-ws.chat.twitch.tv:443") != OK): + print_debug("Could not connect to Twitch.") + emit_signal("twitch_unavailable") + +func _process(delta : float) -> void: + if(websocket.get_connection_status() != NetworkedMultiplayerPeer.CONNECTION_DISCONNECTED): + websocket.poll() + if (!chat_queue.empty() && (last_msg + chat_timeout_ms) <= OS.get_ticks_msec()): + send(chat_queue.pop_front()) + last_msg = OS.get_ticks_msec() + +# Login using a oauth token. +# You will have to either get a oauth token yourself or use +# https://twitchapps.com/tokengen/ +# to generate a token with custom scopes. +func authenticate_oauth(nick : String, token : String) -> void: + websocket.get_peer(1).set_write_mode(WebSocketPeer.WRITE_MODE_TEXT) + send("PASS " + ("" if token.begins_with("oauth:") else "oauth:") + token, true) + send("NICK " + nick.to_lower()) + request_caps() + +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.get_peer(1).put_packet(text.to_utf8()) + 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 != ""): + chat_queue.append("PRIVMSG " + ("" if channel.begins_with("#") else "#") + channel + " :" + message + "\r\n") + elif(keys.size() == 1): + chat_queue.append("PRIVMSG #" + channels.keys()[0] + " :" + message + "\r\n") + else: + print_debug("No channel specified.") + +func whisper(message : String, target : String) -> void: + chat("/w " + target + " " + message) + +func data_received() -> void: + var messages : PoolStringArray = websocket.get_peer(1).get_packet().get_string_from_utf8().strip_edges(false).split("\r\n") + var tags = {} + for message in messages: + if(message.begins_with("@")): + var msg : PoolStringArray = 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, instance : Object, instance_func : String, max_args : int = 0, min_args : int = 0, permission_level : int = PermissionFlag.EVERYONE, where : int = WhereFlag.CHAT) -> void: + var func_ref = FuncRef.new() + func_ref.set_instance(instance) + func_ref.set_function(instance_func) + commands[cmd_name] = CommandData.new(func_ref, 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 : PoolStringArray) -> void: + for alias in aliases: + add_alias(cmd_name, alias) + +func handle_message(message : String, tags : Dictionary) -> void: + if(message == ":tmi.twitch.tv NOTICE * :Login authentication failed"): + print_debug("Authentication failed.") + emit_signal("login_attempt", false) + return + if(message == "PING :tmi.twitch.tv"): + send("PONG :tmi.twitch.tv") + emit_signal("pong") + return + var msg : PoolStringArray = message.split(" ", true, 3) + match msg[1]: + "001": + print_debug("Authentication successful.") + emit_signal("login_attempt", 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)) + emit_signal("chat_message", 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) + emit_signal("whisper_message", sender_data, msg[3].right(1)) + "RECONNECT": + twitch_restarting = true + _: + emit_signal("unhandled_message", message, tags) + +func handle_command(sender_data : SenderData, msg : PoolStringArray, 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 : PoolStringArray = PoolStringArray() 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): + emit_signal("cmd_invalid_argcount", 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 != cmd_data.permission_level): + emit_signal("cmd_no_permission", command, sender_data, cmd_data, arg_ary) + print_debug("No Permission for command!") + return + if(arg_ary.size() == 0): + cmd_data.func_ref.call_func(CommandInfo.new(sender_data, command, whisper)) + else: + cmd_data.func_ref.call_func(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() + send("JOIN #" + lower_channel) + channels[lower_channel] = {} + +func leave_channel(channel : String) -> void: + var lower_channel : String = channel.to_lower() + send("PART #" + lower_channel) + channels.erase(lower_channel) + +func connection_established(protocol : String) -> void: + print_debug("Connected to Twitch.") + emit_signal("twitch_connected") + +func connection_closed(was_clean_close : bool) -> void: + if(twitch_restarting): + print_debug("Reconnecting to Twitch") + emit_signal("twitch_reconnect") + connect_to_twitch() + yield(self, "twitch_connected") + for channel in channels.keys(): + join_channel(channel) + twitch_restarting = false + else: + print_debug("Disconnected from Twitch.") + emit_signal("twitch_disconnected") + +func connection_error() -> void: + print_debug("Twitch is unavailable.") + emit_signal("twitch_unavailable") + +func server_close_request(code : int, reason : String) -> void: + pass 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..d29b8a4 --- /dev/null +++ b/addons/gift/icon.png.import @@ -0,0 +1,35 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon.png-b5cf707f4ba91fefa5df60a746e02900.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gift/icon.png" +dest_files=[ "res://.import/icon.png-b5cf707f4ba91fefa5df60a746e02900.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +process/normal_map_invert_y=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/gift/placeholder.png b/addons/gift/placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..c156f907e0d6269a2a2ac3878ffc3cf399984eb4 GIT binary patch literal 90 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!D3?x-;bCrM;TYyi9>;M1%QyiCu9-6rjD8ldQ m;uunKEBVLy2HA%b7#QmI^M4FXP3ZtiF?hQAxvX void: + self.disk_cache = do_disk_cache + self.cache_path = cache_path + thread.start(self, "start") + +func start(params) -> void: + var f : File = File.new() + var d : Directory = Directory.new() + if (disk_cache): + for type in caches.keys(): + var cache_dir = RequestType.keys()[type] + caches[cache_dir] = {} + var error := d.make_dir_recursive(cache_path + "/" + cache_dir) + while active: + if (!queue.empty()): + mutex.lock() + var entry : Entry = queue.pop_front() + mutex.unlock() + var buffer : PoolByteArray = http_request(entry.path, entry.type) + if (disk_cache): + if !d.dir_exists(entry.filename.get_base_dir()): + d.make_dir(entry.filename.get_base_dir()) + f.open(entry.filename, File.WRITE) + f.store_buffer(buffer) + f.close() + var texture = ImageTexture.new() + var img : Image = Image.new() + img.load_png_from_buffer(buffer) + if entry.type == RequestType.BADGE: + caches[RequestType.BADGE][entry.data[0]][entry.data[1]].create_from_image(img, 0) + elif entry.type == RequestType.EMOTE: + caches[RequestType.EMOTE][entry.data[0]].create_from_image(img, 0) + yield(Engine.get_main_loop(), "idle_frame") + +# Gets badge mappings for the specified channel. Default: _global (global mappings) +func get_badge_mapping(channel_id : String = "_global") -> Dictionary: + if !caches[RequestType.BADGE_MAPPING].has(channel_id): + var filename : String = cache_path + "/" + RequestType.keys()[RequestType.BADGE_MAPPING] + "/" + channel_id + ".json" + if !disk_cache && file.file_exists(filename): + file.open(filename, File.READ) + caches[RequestType.BADGE_MAPPING][channel_id] = parse_json(file.get_as_text())["badge_sets"] + file.close() + var buffer : PoolByteArray = http_request(channel_id, RequestType.BADGE_MAPPING) + if !buffer.empty(): + caches[RequestType.BADGE_MAPPING][channel_id] = parse_json(buffer.get_string_from_utf8())["badge_sets"] + if (disk_cache): + file.open(filename, File.WRITE) + file.store_buffer(buffer) + file.close() + else: + return {} + return caches[RequestType.BADGE_MAPPING][channel_id] + +func get_badge(badge_name : String, channel_id : String = "_global", scale : String = "1") -> ImageTexture: + var badge_data : PoolStringArray = badge_name.split("/", true, 1) + var texture : ImageTexture = ImageTexture.new() + var cachename = badge_data[0] + "_" + badge_data[1] + "_" + scale + var filename : String = 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 && file.file_exists(filename): + file.open(filename, File.READ) + var img : Image = Image.new() + img.load_png_from_buffer(file.get_buffer(file.get_len())) + texture.create_from_image(img) + file.close() + else: + var map : Dictionary = caches[RequestType.BADGE_MAPPING].get(channel_id, get_badge_mapping(channel_id)) + if !map.empty(): + if map.has(badge_data[0]): + mutex.lock() + queue.append(Entry.new(map[badge_data[0]]["versions"][badge_data[1]]["image_url_" + scale + "x"].substr("https://static-cdn.jtvnw.net/badges/v1/".length()), RequestType.BADGE, filename, [channel_id, cachename])) + mutex.unlock() + var img = preload("res://addons/gift/placeholder.png") + texture.create_from_image(img) + elif channel_id != "_global": + return get_badge(badge_name, "_global", scale) + elif channel_id != "_global": + return get_badge(badge_name, "_global", scale) + texture.take_over_path(filename) + caches[RequestType.BADGE][channel_id][cachename] = texture + return caches[RequestType.BADGE][channel_id][cachename] + +func get_emote(emote_id : String, scale = "1.0") -> ImageTexture: + var texture : ImageTexture = ImageTexture.new() + var cachename : String = emote_id + "_" + scale + var filename : String = cache_path + "/" + RequestType.keys()[RequestType.EMOTE] + "/" + cachename + ".png" + if !caches[RequestType.EMOTE].has(cachename): + if !disk_cache && file.file_exists(filename): + file.open(filename, File.READ) + var img : Image = Image.new() + img.load_png_from_buffer(file.get_buffer(file.get_len())) + texture.create_from_image(img) + file.close() + else: + mutex.lock() + queue.append(Entry.new(emote_id + "/" + scale, RequestType.EMOTE, filename, [cachename])) + mutex.unlock() + var img = preload("res://addons/gift/placeholder.png") + texture.create_from_image(img) + texture.take_over_path(filename) + caches[RequestType.EMOTE][cachename] = texture + return caches[RequestType.EMOTE][cachename] + +func http_request(path : String, type : int) -> PoolByteArray: + var error := 0 + var buffer = PoolByteArray() + var new_host : String + match type: + RequestType.BADGE_MAPPING: + new_host = "badges.twitch.tv" + path = "/v1/badges/" + ("global" if path == "_global" else "channels/" + path) + "/display" + RequestType.BADGE, RequestType.EMOTE: + new_host = "static-cdn.jtvnw.net" + if type == RequestType.BADGE: + path = "/badges/v1/" + path + else: + path = "/emoticons/v1/" + path + if (host != new_host): + error = http_client.connect_to_host(new_host, 443, true) + while http_client.get_status() == HTTPClient.STATUS_CONNECTING or http_client.get_status() == HTTPClient.STATUS_RESOLVING: + http_client.poll() + delay(100) + if (error != OK): + print("Could not connect to " + new_host + ". Images disabled.") + active = false + return buffer + host = new_host + http_client.request(HTTPClient.METHOD_GET, path, HEADERS) + while (http_client.get_status() == HTTPClient.STATUS_REQUESTING): + http_client.poll() + delay(50) + if !(http_client.get_status() == HTTPClient.STATUS_BODY or http_client.get_status() == HTTPClient.STATUS_CONNECTED): + print("Request failed. Skipped " + path + " (" + RequestType.keys()[type] + ")") + return buffer + while (http_client.get_status() == HTTPClient.STATUS_BODY): + http_client.poll() + delay(1) + var chunk = http_client.read_response_body_chunk() + if (chunk.size() == 0): + delay(1) + else: + buffer += chunk + return buffer + +func delay(delay : int): + if (OS.has_feature("web")): + yield(Engine.get_main_loop(), "idle_frame") + else: + OS.delay_msec(delay) + +class Entry extends Reference: + var path : String + var type : int + var filename : String + var data : Array + + func _init(path : String, type : int, filename : String, data : Array): + self.path = path + self.type = type + self.filename = filename + self.data = data diff --git a/addons/gift/util/sender_data.gd b/addons/gift/util/sender_data.gd new file mode 100644 index 0000000..5f78185 --- /dev/null +++ b/addons/gift/util/sender_data.gd @@ -0,0 +1,11 @@ +extends Reference +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/default_env.tres b/default_env.tres new file mode 100644 index 0000000..20207a4 --- /dev/null +++ b/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/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..7e85c2b --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,24 @@ +[preset.0] + +name="Linux/X11" +platform="Linux/X11" +runnable=true +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="/home/mennomax/Documents/godot/GIFT.x86_64" +patch_list=PoolStringArray( ) +script_export_mode=1 +script_encryption_key="" + +[preset.0.options] + +texture_format/bptc=false +texture_format/s3tc=true +texture_format/etc=false +texture_format/etc2=false +texture_format/no_bptc_fallbacks=true +binary_format/64_bits=true +custom_template/release="" +custom_template/debug="" diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c98fbb601c83c81ec8c22b1dba7d1d57c62b323c GIT binary patch literal 3305 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 literal 0 HcmV?d00001 diff --git a/icon.png.import b/icon.png.import new file mode 100644 index 0000000..a4c02e6 --- /dev/null +++ b/icon.png.import @@ -0,0 +1,35 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon.png-487276ed1e3a0c39cad0279d744ee560.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.png" +dest_files=[ "res://.import/icon.png-487276ed1e3a0c39cad0279d744ee560.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +process/normal_map_invert_y=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..b30d032 --- /dev/null +++ b/project.godot @@ -0,0 +1,70 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=4 + +_global_script_classes=[ { +"base": "Reference", +"class": "CommandData", +"language": "GDScript", +"path": "res://addons/gift/util/cmd_data.gd" +}, { +"base": "Reference", +"class": "CommandInfo", +"language": "GDScript", +"path": "res://addons/gift/util/cmd_info.gd" +}, { +"base": "Node", +"class": "Gift", +"language": "GDScript", +"path": "res://addons/gift/gift_node.gd" +}, { +"base": "Resource", +"class": "ImageCache", +"language": "GDScript", +"path": "res://addons/gift/util/image_cache.gd" +}, { +"base": "Reference", +"class": "SenderData", +"language": "GDScript", +"path": "res://addons/gift/util/sender_data.gd" +} ] +_global_script_class_icons={ +"CommandData": "", +"CommandInfo": "", +"Gift": "", +"ImageCache": "", +"SenderData": "" +} + +[application] + +config/name="Stream Overlay" +run/main_scene="res://scenes/3DOverlay.tscn" +config/icon="res://icon.png" + +[display] + +window/size/width=1920 +window/size/height=1080 +window/size/resizable=false +window/dpi/allow_hidpi=true +window/per_pixel_transparency/allowed=true +window/per_pixel_transparency/enabled=true + +[gui] + +common/drop_mouse_on_gui_input_disabled=true + +[physics] + +common/enable_pause_aware_picking=true + +[rendering] + +environment/default_environment="res://default_env.tres" diff --git a/scenes/2DOverlay.tscn b/scenes/2DOverlay.tscn new file mode 100644 index 0000000..d0396f9 --- /dev/null +++ b/scenes/2DOverlay.tscn @@ -0,0 +1,8 @@ +[gd_scene format=2] + +[node name="Overlay" type="Node2D"] + +[node name="Bluescreen" type="ColorRect" parent="."] +margin_right = 1920.0 +margin_bottom = 1080.0 +color = Color( 0, 0, 1, 1 ) diff --git a/scenes/3DOverlay.tscn b/scenes/3DOverlay.tscn new file mode 100644 index 0000000..119cc46 --- /dev/null +++ b/scenes/3DOverlay.tscn @@ -0,0 +1,13 @@ +[gd_scene load_steps=2 format=2] + +[sub_resource type="Environment" id=1] +background_mode = 1 +background_color = Color( 0, 0, 1, 1 ) + +[node name="Spatial" type="Spatial"] + +[node name="ClippedCamera" type="ClippedCamera" parent="."] +transform = Transform( 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.522264, 2.07885 ) + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource( 1 ) diff --git a/secrets/.gitignore b/secrets/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/secrets/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore