@tool class_name VSTChat extends Node # TODO: rename to past simple? signal Connected(_channel) signal OnMessage(chatter: VSTChatter) var _channel: VSTChannel var _chatClient: WebSocketPeer var _hasConnected:= false enum RequestType { EMOTE, BADGE, BADGE_MAPPING } var caches := { RequestType.EMOTE: {}, RequestType.BADGE: {}, RequestType.BADGE_MAPPING: {} } var _client_id: String var _twitch_chat_url: String var _twitch_chat_port: int var _use_cache: bool var _cache_path: String var _use_anon_connection:= false var _chat_queue : Array[String] = [] var _last_msg : int = Time.get_ticks_msec() var _chat_timeout_ms: int const USER_AGENT : String = "User-Agent: VSTC/0.1.0 (Godot Engine)" func _process(_delta: float): if !_chatClient: return _chatClient.poll() var state = _chatClient.get_ready_state() match state: WebSocketPeer.STATE_OPEN: if (!_hasConnected): onChatConnected() while _chatClient.get_available_packet_count(): onReceivedData(_chatClient.get_packet()) if !_chat_queue.is_empty() and _last_msg + (_last_msg + _chat_timeout_ms) <= Time.get_ticks_msec(): _chatClient.send_text(_chat_queue.pop_front()) _last_msg = Time.get_ticks_msec() WebSocketPeer.STATE_CLOSED: if _hasConnected: _hasConnected = false var code = _chatClient.get_close_code() var reason = _chatClient.get_close_reason() print('Disconnected from twitch chat') print("WebSocket closed with code: %d, reason %s. Clean: %s" % [code, reason, code != -1]) print("Reconnecting") start_chat_client() func start_chat_client(): get_settings() if _chatClient: _chatClient.close() _chatClient = WebSocketPeer.new() _chatClient.connect_to_url("%s:%d" % [_twitch_chat_url, _twitch_chat_port]) func login_anon(channel_name: String): _channel = VSTChannel.new() _channel.login = channel_name.to_lower() _use_anon_connection = true start_chat_client() func login(twitch_channel: VSTChannel): _channel = twitch_channel start_chat_client() func onChatConnected(): if !_channel: return _hasConnected = true _chatClient.send_text("CAP REQ :twitch.tv/tags twitch.tv/commands") if _use_anon_connection: _chatClient.send_text('PASS ' + VSTSettings.get_setting(VSTSettings.settings.twitch_anon_pass)) _chatClient.send_text('NICK ' + VSTSettings.get_setting(VSTSettings.settings.twitch_anon_user)) else: _chatClient.send_text('PASS oauth:' + _channel.token) _chatClient.send_text('NICK ' + _channel.login.to_lower()) pass _chatClient.send_text('JOIN ' + '#' + _channel.login.to_lower()) Connected.emit() func send_message(message: String): _chat_queue.append("PRIVMSG #" + _channel.login.to_lower() + " :" + message + "\r\n") func onReceivedData(payload: PackedByteArray): var message = payload.get_string_from_utf8() var splittled_messages = message.split("\n") for n in splittled_messages: handle_message(n) #TODO: move this to parse helper? func parse_message_from_twtich_IRC(message: String) -> PackedStringArray: return message.split(" ", true, 4) # We might need more than 3 func handle_message(message: String): if message.begins_with("PING"): _chatClient.send_text(message.replace("PING", "PONG")) return var parsed_message: PackedStringArray = parse_message_from_twtich_IRC(message) if parsed_message.size() < 2: return match parsed_message[2]: "NOTICE": var info : String = parsed_message[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: pass #unhandled_message.emit(message, tags) "001": print_debug("Authentication successful.") _chatClient.send_text('ROOMSTATE '+ '#' + _channel.login.to_lower()) #login_attempt.emit(true) "PRIVMSG": handle_privmsg(parsed_message) #handle_command(sender_data, msg[3].split(" ", true, 1)) #chat_message.emit(sender_data, msg[3].right(-1)) "ROOMSTATE": if _use_anon_connection: var parsed_tags:VSTIRCTags = VSTParseHelper.parse_tags(parsed_message[0]) _channel.id = parsed_tags.user_id func parse_message_to_chatter(message: PackedStringArray) -> VSTChatter: var chatter = VSTChatter.new() chatter.login = VSTParseHelper.parse_login(message[1]) chatter.channel = VSTParseHelper.parse_channel(message[3]) chatter.message = VSTParseHelper.parse_message(message[4]) chatter.tags = VSTParseHelper.parse_tags(message[0]) chatter.date_time_dict = Time.get_datetime_dict_from_system() if chatter.tags.color_hex.is_empty(): chatter.tags.color_hex = VSTUtils.get_random_name_color(chatter.login) return chatter func handle_privmsg(msg: PackedStringArray): var chatter = parse_message_to_chatter(msg) OnMessage.emit(chatter) func get_emote(emote_id: String, scale: String = "1.0") -> Texture2D: var texture: Texture2D var cachename: String = emote_id + "_" + scale var filename: String = _cache_path + "/" + RequestType.keys()[RequestType.EMOTE] + "/" + cachename + ".png" if !caches[RequestType.EMOTE].has(cachename): if _use_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 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) var img: Image = Image.new() img.load_png_from_buffer(data[3]) texture = ImageTexture.create_from_image(img) if _use_cache: DirAccess.make_dir_recursive_absolute(filename.get_base_dir()) texture.get_image().save_png(filename) request.queue_free() texture.take_over_path(filename) caches[RequestType.EMOTE][cachename] = texture return caches[RequestType.EMOTE][cachename] func get_badge(badge_name: String, badge_level: String, channel_id: String = "_global", scale: String = "1") -> Texture2D: if _use_anon_connection: return var texture: Texture2D var cachename = badge_name + "_" + badge_level + "_" + 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 _use_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_name): var request: HTTPRequest = HTTPRequest.new() add_child(request) request.request(map[badge_name]["versions"][badge_level]["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, badge_level, "_global", scale)) elif channel_id != "_global": return await(get_badge(badge_name, badge_level, "_global", scale)) if _use_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 _use_anon_connection: return {} if caches[RequestType.BADGE_MAPPING].has(channel_id): return caches[RequestType.BADGE_MAPPING][channel_id] var filename: String = _cache_path + "/" + RequestType.keys()[RequestType.BADGE_MAPPING] + "/" + channel_id + ".json" if _use_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 " + _channel.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 _use_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 get_settings(): _client_id = VSTSettings.get_setting(VSTSettings.settings.client_id) _twitch_chat_url = VSTSettings.get_setting(VSTSettings.settings.twitch_chat_url) _twitch_chat_port = VSTSettings.get_setting(VSTSettings.settings.twitch_port) _use_cache = VSTSettings.get_setting(VSTSettings.settings.disk_cache) _cache_path = VSTSettings.get_setting(VSTSettings.settings.disk_cache_path) _chat_timeout_ms = VSTSettings.get_setting(VSTSettings.settings.twitch_timeout_ms) # stops chat socket from tts server func disconnect_api(): if _chatClient: _chatClient.close() _hasConnected = false