From 0b33947be381d33600aa478552298de34d101e08 Mon Sep 17 00:00:00 2001 From: issork Date: Sat, 4 Feb 2023 23:50:59 +0100 Subject: [PATCH] ported to 4.x --- ChatContainer.gd | 45 ++--- ChatMessage.gd | 3 +- ChatMessage.tscn | 19 +- Gift.gd | 65 +++++-- LineEdit.gd | 6 + Node.gd | 26 --- Node.tscn | 79 +++----- README.md | 12 +- addons/gift/gift.gd | 2 +- addons/gift/gift_node.gd | 286 +++++++++++++++++++---------- addons/gift/icon.png.import | 30 +-- addons/gift/placeholder.png | Bin 90 -> 0 bytes addons/gift/placeholder.png.import | 13 -- addons/gift/plugin.cfg | 4 +- addons/gift/util/cmd_data.gd | 6 +- addons/gift/util/cmd_info.gd | 2 +- addons/gift/util/image_cache.gd | 195 -------------------- addons/gift/util/sender_data.gd | 2 +- icon.png.import | 30 +-- project.godot | 41 +---- 20 files changed, 350 insertions(+), 516 deletions(-) create mode 100644 LineEdit.gd delete mode 100644 addons/gift/placeholder.png delete mode 100644 addons/gift/placeholder.png.import delete mode 100644 addons/gift/util/image_cache.gd diff --git a/ChatContainer.gd b/ChatContainer.gd index 09aa1ea..a6cf688 100644 --- a/ChatContainer.gd +++ b/ChatContainer.gd @@ -1,32 +1,33 @@ extends VBoxContainer func put_chat(senderdata : SenderData, msg : String): - var msgnode : Control = preload("res://ChatMessage.tscn").instance() - var time = OS.get_time() + var msgnode : Control = preload("res://ChatMessage.tscn").instantiate() + var time = Time.get_time_dict_from_system() 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) + 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 = [] + 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) - yield(get_tree(), "idle_frame") + await(get_tree().process_frame) if (bottom): - $Chat/ScrollContainer.scroll_vertical = $Chat/ScrollContainer.get_v_scrollbar().max_value + $Chat/ScrollContainer.scroll_vertical = $Chat/ScrollContainer.get_v_scroll_bar().max_value -class EmoteLocation extends Reference: +class EmoteLocation extends RefCounted: var id : String var start : int var end : int diff --git a/ChatMessage.gd b/ChatMessage.gd index 9f3b14b..3e035df 100644 --- a/ChatMessage.gd +++ b/ChatMessage.gd @@ -1,4 +1,5 @@ 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 + $RichTextLabel.text = stamp + " " + badges + "[b][color="+ data.tags["color"] + "]" + data.tags["display-name"] +"[/color][/b]: " + msg + queue_sort() diff --git a/ChatMessage.tscn b/ChatMessage.tscn index fa6a8cd..df4bedf 100644 --- a/ChatMessage.tscn +++ b/ChatMessage.tscn @@ -1,23 +1,16 @@ -[gd_scene load_steps=2 format=2] +[gd_scene load_steps=2 format=3 uid="uid://g4ajgul65cwi"] -[ext_resource path="res://ChatMessage.gd" type="Script" id=1] +[ext_resource type="Script" path="res://ChatMessage.gd" 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 -} +script = ExtResource("1") [node name="RichTextLabel" type="RichTextLabel" parent="."] -margin_right = 400.0 -margin_bottom = 24.0 -focus_mode = 2 +layout_mode = 2 size_flags_horizontal = 3 -size_flags_vertical = 3 +focus_mode = 2 bbcode_enabled = true -fit_content_height = true +fit_content = true scroll_active = false selection_enabled = true diff --git a/Gift.gd b/Gift.gd index 36ab56c..04a50b6 100644 --- a/Gift.gd +++ b/Gift.gd @@ -1,6 +1,10 @@ extends Gift func _ready() -> void: + super._ready() + cmd_no_permission.connect(no_permission) + chat_message.connect(on_chat) + # 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 @@ -8,47 +12,44 @@ func _ready() -> void: # # # - var authfile := File.new() - authfile.open("./auth", File.READ) + var authfile := FileAccess.open("./auth", FileAccess.READ) var botname := authfile.get_line() var token := authfile.get_line() var initial_channel = authfile.get_line() connect_to_twitch() - yield(self, "twitch_connected") + await(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 + request_caps() + if(await(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 + # the second arg will contain all params in a PackedStringArray # This command can only be executed by VIPS/MODS/SUBS/STREAMER - add_command("test", get_parent(), "command_test", 0, 0, PermissionFlag.NON_REGULAR) + add_command("test", 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") + add_command("helloworld", hello_world) + add_command("greetme", greet_me) # This command can only be executed by the streamer - add_command("streamer_only", get_parent(), "streamer_only", 0, 0, PermissionFlag.STREAMER) + add_command("streamer_only", streamer_only, 0, 0, PermissionFlag.STREAMER) # Command that requires exactly 1 arg. - add_command("greet", get_parent(), "greet", 1, 1) + 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", get_parent(), "list", -1, 2) + add_command("list", list, -1, 2) # Adds a command alias add_alias("test","test1") @@ -70,10 +71,40 @@ func _ready() -> void: # Send a chat message to the only connected channel () # Fails, if connected to more than one channel. -# chat("TEST") +# gchat("TEST") # Send a chat message to channel # chat("TEST", initial_channel) # Send a whisper to target user # whisper("TEST", initial_channel) + +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/LineEdit.gd b/LineEdit.gd new file mode 100644 index 0000000..e186408 --- /dev/null +++ b/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/Node.gd b/Node.gd index f927ef2..e69de29 100644 --- a/Node.gd +++ b/Node.gd @@ -1,26 +0,0 @@ -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 index f7cf884..ec028d5 100644 --- a/Node.tscn +++ b/Node.tscn @@ -1,90 +1,59 @@ -[gd_scene load_steps=6 format=2] +[gd_scene load_steps=5 format=3 uid="uid://bculs28gstcxk"] -[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] +[ext_resource type="Script" path="res://Gift.gd" id="1"] +[ext_resource type="Script" path="res://Button.gd" id="3"] +[ext_resource type="Script" path="res://LineEdit.gd" id="3_6ey00"] +[ext_resource type="Script" path="res://ChatContainer.gd" id="5"] [node name="Node" type="Control"] +layout_mode = 3 +anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 -script = ExtResource( 6 ) -__meta__ = { -"_edit_use_anchors_": false -} +grow_horizontal = 2 +grow_vertical = 2 [node name="Gift" type="Node" parent="."] -script = ExtResource( 1 ) -__meta__ = { -"_editor_icon": ExtResource( 2 ) -} -get_images = true +script = ExtResource("1") +command_prefixes = ["!"] +disk_cache = true [node name="ChatContainer" type="VBoxContainer" parent="."] +unique_name_in_owner = true +layout_mode = 0 anchor_right = 1.0 anchor_bottom = 1.0 -script = ExtResource( 5 ) -__meta__ = { -"_edit_use_anchors_": false -} +script = ExtResource("5") [node name="Chat" type="Panel" parent="ChatContainer"] show_behind_parent = true -margin_right = 400.0 -margin_bottom = 572.0 +layout_mode = 2 size_flags_horizontal = 3 size_flags_vertical = 3 -__meta__ = { -"_edit_use_anchors_": false -} [node name="ScrollContainer" type="ScrollContainer" parent="ChatContainer/Chat"] +layout_mode = 0 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 +layout_mode = 2 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 +layout_mode = 2 [node name="LineEdit" type="LineEdit" parent="ChatContainer/HBoxContainer"] -margin_right = 296.0 -margin_bottom = 24.0 +layout_mode = 2 size_flags_horizontal = 3 size_flags_vertical = 3 caret_blink = true -caret_blink_speed = 0.5 -__meta__ = { -"_edit_use_anchors_": false -} +script = ExtResource("3_6ey00") [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 ) +unique_name_in_owner = true +layout_mode = 2 text = "Send" -script = ExtResource( 3 ) -__meta__ = { -"_edit_use_anchors_": false -} +script = ExtResource("3") diff --git a/README.md b/README.md index 62dc4f1..b22f210 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ Godot IRC For Twitch addon - [Examples](https://github.com/MennoMax/gift#Examples) - [API](https://github.com/MennoMax/gift#API) - - [Exported Variables](https://github.com/MennoMax/gift#Exported-Variables) - - [Signals](https://github.com/MennoMax/gift#Signals) - - [Functions](https://github.com/MennoMax/gift#Functions) - - [Utility Classes](https://github.com/MennoMax/gift#Utility-Classes) + - [Exported Variables](https://github.com/MennoMax/gift#Exported-Variables) + - [Signals](https://github.com/MennoMax/gift#Signals) + - [Functions](https://github.com/MennoMax/gift#Functions) + - [Utility Classes](https://github.com/MennoMax/gift#Utility-Classes) *** @@ -31,8 +31,8 @@ func _ready() -> void: # to generate a token with custom scopes. authenticate_oauth(, ) if(yield(self, "login_attempt") == false): - print("Invalid username or token.") - return + print("Invalid username or token.") + return join_channel() # Adds a command with a specified permission flag. diff --git a/addons/gift/gift.gd b/addons/gift/gift.gd index 9296ecc..31ccd61 100755 --- a/addons/gift/gift.gd +++ b/addons/gift/gift.gd @@ -1,4 +1,4 @@ -tool +@tool extends EditorPlugin func _enter_tree() -> void: diff --git a/addons/gift/gift_node.gd b/addons/gift/gift_node.gd index bebe5d6..0ed27f9 100644 --- a/addons/gift/gift_node.gd +++ b/addons/gift/gift_node.gd @@ -1,11 +1,11 @@ extends Node class_name Gift -# The underlying websocket sucessfully connected to twitch. +# 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. +# The connection to Twitch failed. signal twitch_unavailable # Twitch requested the client to reconnect. (Will be unavailable until next connect) signal twitch_reconnect @@ -23,32 +23,53 @@ signal cmd_invalid_argcount(cmd_name, sender_data, cmd_data, arg_ary) 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" +## Messages starting with one of these symbols are handled as commands. '/' will be ignored, reserved by Twitch. +@export +var command_prefixes : Array[String] = ["!"] -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() +## 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 + +## 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" + +# 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_userstate : Dictionary = {} +# Dictionary of commands, contains -> entries. var commands : Dictionary = {} -var image_cache : ImageCache + +var websocket : WebSocketPeer = WebSocketPeer.new() +var connected : bool = false +var user_regex : RegEx = RegEx.new() +var twitch_restarting : bool = false + +enum RequestType { + EMOTE, + BADGE, + BADGE_MAPPING +} + +var caches := { + RequestType.EMOTE: {}, + RequestType.BADGE: {}, + RequestType.BADGE_MAPPING: {} +} + +#var image_cache : ImageCache # Required permission to execute the command enum PermissionFlag { @@ -70,46 +91,68 @@ enum WhereFlag { } 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) + 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) 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") + websocket.connect_to_url("wss://irc-ws.chat.twitch.tv:443") + set_process(true) 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() + 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.") + 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_CLOSING: + pass + WebSocketPeer.STATE_CLOSED: + if (!connected): + twitch_unavailable.emit() + print_debug("Could not connect to Twitch.") + connected = false + if(twitch_restarting): + print_debug("Reconnecting to Twitch") + twitch_reconnect.emit() + connect_to_twitch() + await twitch_connected + for channel in channels.keys(): + join_channel(channel) + twitch_restarting = false + else: + set_process(false) + print_debug("Disconnected from Twitch.") + twitch_disconnected.emit() + print_debug("Connection closed! [%s]: %s"%[websocket.get_close_code(), websocket.get_close_reason()]) # 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() +# 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.get_peer(1).put_packet(text.to_utf8()) + websocket.send_text(text) if(OS.is_debug_build()): if(!token): print("< " + text.strip_edges(false)) @@ -120,21 +163,103 @@ func send(text : String, token : bool = false) -> void: 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") + if (channel.begins_with("#")): + channel = channel.right(-1) + chat_queue.append("PRIVMSG #" + channel + " :" + message + "\r\n") + chat_message.emit(SenderData.new(last_userstate[channels.keys()[0]]["display-name"], channel, last_userstate[channels.keys()[0]]), message) elif(keys.size() == 1): chat_queue.append("PRIVMSG #" + channels.keys()[0] + " :" + message + "\r\n") + chat_message.emit(SenderData.new(last_userstate[channels.keys()[0]]["display-name"], channels.keys()[0], last_userstate[channels.keys()[0]]), message) 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") +func get_emote(emote_id : String, scale : String = "1.0") -> Texture: + var texture : Texture + 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: GIFT/2.0.0 (Godot Engine)","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) + caches[RequestType.EMOTE][cachename] = texture + return caches[RequestType.EMOTE][cachename] + +func get_badge(badge_name : String, channel_id : String = "_global", scale : String = "1") -> Texture: + var badge_data : PackedStringArray = badge_name.split("/", true, 1) + var texture : Texture + 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: GIFT/2.0.0 (Godot Engine)","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)) + 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): + var filename : String = disk_cache_path + "/" + RequestType.keys()[RequestType.BADGE_MAPPING] + "/" + channel_id + ".json" + if (disk_cache && FileAccess.file_exists(filename)): + caches[RequestType.BADGE_MAPPING][channel_id] = JSON.parse_string(FileAccess.get_file_as_string(filename))["badge_sets"] + else: + var request : HTTPRequest = HTTPRequest.new() + add_child(request) + request.request("https://badges.twitch.tv/v1/badges/" + ("global" if channel_id == "_global" else "channels/" + channel_id) + "/display", ["User-Agent: GIFT/2.0.0 (Godot Engine)","Accept: */*"]) + var data = await(request.request_completed) + request.queue_free() + var buffer : PackedByteArray = data[3] + if !buffer.is_empty(): + caches[RequestType.BADGE_MAPPING][channel_id] = JSON.parse_string(buffer.get_string_from_utf8())["badge_sets"] + if (disk_cache): + var file : FileAccess = FileAccess.open(filename, FileAccess.WRITE) + file.store_buffer(buffer) + else: + return {} + return caches[RequestType.BADGE_MAPPING][channel_id] + +func data_received(data) -> 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 : PoolStringArray = message.split(" ", false, 1) + var msg : PackedStringArray = message.split(" ", false, 1) message = msg[1] for tag in msg[0].split(";"): var pair = tag.split("=") @@ -144,11 +269,8 @@ func data_received() -> void: 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) +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: @@ -169,40 +291,42 @@ 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: +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 == ":tmi.twitch.tv NOTICE * :Login authentication failed"): + if(message == ":tmi.twitch.tv NOTICE * :Login authentication failed" || message == ":tmi.twitch.tv NOTICE * :Login unsuccessful"): print_debug("Authentication failed.") - emit_signal("login_attempt", false) + login_attempt.emit(false) return if(message == "PING :tmi.twitch.tv"): send("PONG :tmi.twitch.tv") - emit_signal("pong") + pong.emit() return - var msg : PoolStringArray = message.split(" ", true, 3) + var msg : PackedStringArray = message.split(" ", true, 3) match msg[1]: "001": print_debug("Authentication successful.") - emit_signal("login_attempt", true) + 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)) - emit_signal("chat_message", sender_data, msg[3].right(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) - emit_signal("whisper_message", sender_data, msg[3].right(1)) + whisper_message.emit(sender_data, msg[3].right(-1)) "RECONNECT": twitch_restarting = true + "USERSTATE": + last_userstate[msg[2].right(-1)] = tags _: - emit_signal("unhandled_message", message, tags) + unhandled_message.emit(message, tags) -func handle_command(sender_data : SenderData, msg : PoolStringArray, whisper : bool = false) -> void: +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 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): @@ -210,21 +334,21 @@ func handle_command(sender_data : SenderData, msg : PoolStringArray, whisper : b 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(" ") + 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): - emit_signal("cmd_invalid_argcount", command, sender_data, cmd_data, arg_ary) + 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 != cmd_data.permission_level): - emit_signal("cmd_no_permission", command, sender_data, cmd_data, arg_ary) + 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_func(CommandInfo.new(sender_data, command, whisper)) + cmd_data.func_ref.call(CommandInfo.new(sender_data, command, whisper)) else: - cmd_data.func_ref.call_func(CommandInfo.new(sender_data, command, whisper), arg_ary) + 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 @@ -247,34 +371,10 @@ func get_perm_flag_from_tags(tags : Dictionary) -> int: func join_channel(channel : String) -> void: var lower_channel : String = channel.to_lower() - send("JOIN #" + lower_channel) 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) - -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.import b/addons/gift/icon.png.import index ecd1a3a..91fe9dd 100644 --- a/addons/gift/icon.png.import +++ b/addons/gift/icon.png.import @@ -1,8 +1,9 @@ [remap] importer="texture" -type="StreamTexture" -path="res://.import/icon.png-b5cf707f4ba91fefa5df60a746e02900.stex" +type="CompressedTexture2D" +uid="uid://rkm6ge1nohu1" +path="res://.godot/imported/icon.png-b5cf707f4ba91fefa5df60a746e02900.ctex" metadata={ "vram_texture": false } @@ -10,25 +11,24 @@ metadata={ [deps] source_file="res://addons/gift/icon.png" -dest_files=[ "res://.import/icon.png-b5cf707f4ba91fefa5df60a746e02900.stex" ] +dest_files=["res://.godot/imported/icon.png-b5cf707f4ba91fefa5df60a746e02900.ctex"] [params] compress/mode=0 compress/lossy_quality=0.7 -compress/hdr_mode=0 +compress/hdr_compression=1 compress/bptc_ldr=0 compress/normal_map=0 -flags/repeat=0 -flags/filter=true -flags/mipmaps=false -flags/anisotropic=false -flags/srgb=2 +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/HDR_as_SRGB=false -process/invert_color=false -stream=false -size_limit=0 -detect_3d=true -svg/scale=1.0 +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/placeholder.png b/addons/gift/placeholder.png deleted file mode 100644 index c156f907e0d6269a2a2ac3878ffc3cf399984eb4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 index 5f78185..c7f71f7 100644 --- a/addons/gift/util/sender_data.gd +++ b/addons/gift/util/sender_data.gd @@ -1,4 +1,4 @@ -extends Reference +extends RefCounted class_name SenderData var user : String diff --git a/icon.png.import b/icon.png.import index 96cbf46..cb26608 100644 --- a/icon.png.import +++ b/icon.png.import @@ -1,8 +1,9 @@ [remap] importer="texture" -type="StreamTexture" -path="res://.import/icon.png-487276ed1e3a0c39cad0279d744ee560.stex" +type="CompressedTexture2D" +uid="uid://ck2181giqo3ep" +path="res://.godot/imported/icon.png-487276ed1e3a0c39cad0279d744ee560.ctex" metadata={ "vram_texture": false } @@ -10,25 +11,24 @@ metadata={ [deps] source_file="res://icon.png" -dest_files=[ "res://.import/icon.png-487276ed1e3a0c39cad0279d744ee560.stex" ] +dest_files=["res://.godot/imported/icon.png-487276ed1e3a0c39cad0279d744ee560.ctex"] [params] compress/mode=0 compress/lossy_quality=0.7 -compress/hdr_mode=0 +compress/hdr_compression=1 compress/bptc_ldr=0 compress/normal_map=0 -flags/repeat=0 -flags/filter=true -flags/mipmaps=false -flags/anisotropic=false -flags/srgb=2 +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/HDR_as_SRGB=false -process/invert_color=false -stream=false -size_limit=0 -detect_3d=true -svg/scale=1.0 +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/project.godot b/project.godot index ac641e2..8aa4e52 100644 --- a/project.godot +++ b/project.godot @@ -6,52 +6,19 @@ ; [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": "" -} +config_version=5 [application] config/name="GIFT" run/main_scene="res://Node.tscn" +config/features=PackedStringArray("4.0") config/icon="res://icon.png" [debug] -gdscript/warnings/unused_argument=false gdscript/warnings/return_value_discarded=false +gdscript/warnings/unused_argument=false [display] @@ -59,7 +26,7 @@ window/size/width=400 [editor_plugins] -enabled=PoolStringArray( "gift" ) +enabled=PackedStringArray("gift") [rendering]