├── example ├── Example.gd.uid ├── Example.tscn └── Example.gd ├── addons └── gift │ ├── plugin.gd.uid │ ├── api_connection.gd.uid │ ├── command_handler.gd.uid │ ├── icon_downloader.gd.uid │ ├── id_connection.gd.uid │ ├── irc_connection.gd.uid │ ├── plugin.gd │ ├── util │ ├── cmd_data.gd.uid │ ├── cmd_info.gd.uid │ ├── sender_data.gd.uid │ ├── http_request.gd.uid │ ├── token_cache.gd.uid │ ├── sender_data.gd │ ├── cmd_info.gd │ ├── http_request.gd │ ├── cmd_data.gd │ └── token_cache.gd │ ├── auth │ ├── tokens │ │ ├── token.gd.uid │ │ ├── app_token.gd.uid │ │ ├── user_token.gd.uid │ │ ├── refreshable_user_token.gd.uid │ │ ├── app_token.gd │ │ ├── user_token.gd │ │ ├── token.gd │ │ └── refreshable_user_token.gd │ └── grant_flows │ │ ├── auth_flow.gd.uid │ │ ├── implicit_grant_flow.gd.uid │ │ ├── redirecting_flow.gd.uid │ │ ├── device_code_grant_flow.gd.uid │ │ ├── authorization_code_grant_flow.gd.uid │ │ ├── client_credentials_grant_flow.gd.uid │ │ ├── auth_flow.gd │ │ ├── client_credentials_grant_flow.gd │ │ ├── implicit_grant_flow.gd │ │ ├── authorization_code_grant_flow.gd │ │ ├── device_code_grant_flow.gd │ │ └── redirecting_flow.gd │ ├── eventsub_connection.gd.uid │ ├── dummy_eventsub_connection.gd.uid │ ├── plugin.cfg │ ├── dummy_eventsub_connection.gd │ ├── id_connection.gd │ ├── icon_downloader.gd │ ├── command_handler.gd │ ├── eventsub_connection.gd │ ├── api_connection.gd │ └── irc_connection.gd ├── .gitignore └── LICENSE /example/Example.gd.uid: -------------------------------------------------------------------------------- 1 | uid://7d8ivhr0qbs4 2 | -------------------------------------------------------------------------------- /addons/gift/plugin.gd.uid: -------------------------------------------------------------------------------- 1 | uid://d0oke703ngpfs 2 | -------------------------------------------------------------------------------- /addons/gift/api_connection.gd.uid: -------------------------------------------------------------------------------- 1 | uid://r2a07wb16i2j 2 | -------------------------------------------------------------------------------- /addons/gift/command_handler.gd.uid: -------------------------------------------------------------------------------- 1 | uid://78tvgeym818c 2 | -------------------------------------------------------------------------------- /addons/gift/icon_downloader.gd.uid: -------------------------------------------------------------------------------- 1 | uid://dj5ki0jyiajhd 2 | -------------------------------------------------------------------------------- /addons/gift/id_connection.gd.uid: -------------------------------------------------------------------------------- 1 | uid://ds2mcc1b7avuj 2 | -------------------------------------------------------------------------------- /addons/gift/irc_connection.gd.uid: -------------------------------------------------------------------------------- 1 | uid://vyl3l2ak4ur0 2 | -------------------------------------------------------------------------------- /addons/gift/plugin.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends EditorPlugin 3 | -------------------------------------------------------------------------------- /addons/gift/util/cmd_data.gd.uid: -------------------------------------------------------------------------------- 1 | uid://dxk3xmerij33w 2 | -------------------------------------------------------------------------------- /addons/gift/util/cmd_info.gd.uid: -------------------------------------------------------------------------------- 1 | uid://c7hku6ihpqbr2 2 | -------------------------------------------------------------------------------- /addons/gift/util/sender_data.gd.uid: -------------------------------------------------------------------------------- 1 | uid://kqehn25q4ssx 2 | -------------------------------------------------------------------------------- /addons/gift/auth/tokens/token.gd.uid: -------------------------------------------------------------------------------- 1 | uid://c200i3xppoo7d 2 | -------------------------------------------------------------------------------- /addons/gift/eventsub_connection.gd.uid: -------------------------------------------------------------------------------- 1 | uid://1vgfsmxtcmgc 2 | -------------------------------------------------------------------------------- /addons/gift/util/http_request.gd.uid: -------------------------------------------------------------------------------- 1 | uid://bnd0gwj837xhx 2 | -------------------------------------------------------------------------------- /addons/gift/util/token_cache.gd.uid: -------------------------------------------------------------------------------- 1 | uid://c7l7qyb8xauhx 2 | -------------------------------------------------------------------------------- /addons/gift/auth/tokens/app_token.gd.uid: -------------------------------------------------------------------------------- 1 | uid://c8tucdpq2i6ur 2 | -------------------------------------------------------------------------------- /addons/gift/auth/tokens/user_token.gd.uid: -------------------------------------------------------------------------------- 1 | uid://dud41d1vllbvt 2 | -------------------------------------------------------------------------------- /addons/gift/dummy_eventsub_connection.gd.uid: -------------------------------------------------------------------------------- 1 | uid://toqhfnqoey6q 2 | -------------------------------------------------------------------------------- /addons/gift/auth/grant_flows/auth_flow.gd.uid: -------------------------------------------------------------------------------- 1 | uid://c32rep73xw0ld 2 | -------------------------------------------------------------------------------- /addons/gift/auth/grant_flows/implicit_grant_flow.gd.uid: -------------------------------------------------------------------------------- 1 | uid://tqqqd2houqdd 2 | -------------------------------------------------------------------------------- /addons/gift/auth/grant_flows/redirecting_flow.gd.uid: -------------------------------------------------------------------------------- 1 | uid://cmpe8ubqwjbp 2 | -------------------------------------------------------------------------------- /addons/gift/auth/tokens/refreshable_user_token.gd.uid: -------------------------------------------------------------------------------- 1 | uid://bub4gbs1vn1kr 2 | -------------------------------------------------------------------------------- /addons/gift/auth/grant_flows/device_code_grant_flow.gd.uid: -------------------------------------------------------------------------------- 1 | uid://dhvfqpckfxgbu 2 | -------------------------------------------------------------------------------- /addons/gift/auth/grant_flows/authorization_code_grant_flow.gd.uid: -------------------------------------------------------------------------------- 1 | uid://pymgk3cfm881 2 | -------------------------------------------------------------------------------- /addons/gift/auth/grant_flows/client_credentials_grant_flow.gd.uid: -------------------------------------------------------------------------------- 1 | uid://bw8jht7gqafll 2 | -------------------------------------------------------------------------------- /addons/gift/plugin.cfg: -------------------------------------------------------------------------------- 1 | [plugin] 2 | 3 | name = "GIFT" 4 | description = "Twitch Plugin for Godot" 5 | author = "issork" 6 | version = "2.1.1-alpha" 7 | script = "plugin.gd" 8 | -------------------------------------------------------------------------------- /addons/gift/auth/tokens/app_token.gd: -------------------------------------------------------------------------------- 1 | class_name AppAccessToken 2 | extends TwitchToken 3 | 4 | func _init(data : Dictionary, client_id) -> void: 5 | super._init(data, client_id, data["expires_in"]) 6 | -------------------------------------------------------------------------------- /addons/gift/auth/tokens/user_token.gd: -------------------------------------------------------------------------------- 1 | class_name UserAccessToken 2 | extends TwitchToken 3 | 4 | var scopes : PackedStringArray 5 | 6 | func _init(data : Dictionary, client_id : String) -> void: 7 | super._init(data, client_id) 8 | scopes = data["scope"] 9 | -------------------------------------------------------------------------------- /addons/gift/util/sender_data.gd: -------------------------------------------------------------------------------- 1 | class_name SenderData 2 | extends RefCounted 3 | 4 | var user : String 5 | var channel : String 6 | var tags : Dictionary 7 | 8 | func _init(usr : String, ch : String, tag_dict : Dictionary): 9 | user = usr 10 | channel = ch 11 | tags = tag_dict 12 | -------------------------------------------------------------------------------- /addons/gift/util/cmd_info.gd: -------------------------------------------------------------------------------- 1 | class_name CommandInfo 2 | extends RefCounted 3 | 4 | var sender_data : SenderData 5 | var command : String 6 | var whisper : bool 7 | 8 | func _init(sndr_dt : SenderData, cmd : String, whspr : bool): 9 | sender_data = sndr_dt 10 | command = cmd 11 | whisper = whspr 12 | -------------------------------------------------------------------------------- /addons/gift/auth/tokens/token.gd: -------------------------------------------------------------------------------- 1 | class_name TwitchToken 2 | extends RefCounted 3 | 4 | var last_client_id : String = "" 5 | var token : String 6 | var expires_in : int 7 | var fresh : bool = false 8 | 9 | func _init(data : Dictionary, client_id : String, expires_in : int = 0) -> void: 10 | token = data["access_token"] 11 | last_client_id = client_id 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Example auth file 2 | auth.txt 3 | 4 | # Godot 4+ specific ignores 5 | .godot/ 6 | 7 | # Godot-specific ignores 8 | .import/ 9 | 10 | # Imported translations (automatically generated from CSV files) 11 | *.translation 12 | 13 | # Mono-specific ignores 14 | .mono/ 15 | data_*/ 16 | mono_crash.*.json 17 | 18 | # System/tool-specific ignores 19 | .directory 20 | *~ 21 | -------------------------------------------------------------------------------- /addons/gift/util/http_request.gd: -------------------------------------------------------------------------------- 1 | class_name GiftRequest 2 | extends RefCounted 3 | 4 | var method : int 5 | var url : String 6 | var headers : PackedStringArray 7 | var body : String 8 | 9 | func _init(method : int, url : String, headers : PackedStringArray, body : String = "") -> void: 10 | self.method = method 11 | self.url = url 12 | self.headers = headers 13 | self.body = body 14 | -------------------------------------------------------------------------------- /addons/gift/auth/tokens/refreshable_user_token.gd: -------------------------------------------------------------------------------- 1 | class_name RefreshableUserAccessToken 2 | extends UserAccessToken 3 | 4 | var refresh_token : String 5 | var last_client_secret : String 6 | 7 | func _init(data : Dictionary, client_id : String, client_secret : String = "") -> void: 8 | super._init(data, client_id) 9 | refresh_token = data["refresh_token"] 10 | last_client_secret = client_secret 11 | -------------------------------------------------------------------------------- /addons/gift/util/cmd_data.gd: -------------------------------------------------------------------------------- 1 | class_name CommandData 2 | extends RefCounted 3 | 4 | var func_ref : Callable 5 | var permission_level : int 6 | var max_args : int 7 | var min_args : int 8 | var where : int 9 | 10 | func _init(f_ref : Callable, perm_lvl : int, mx_args : int, mn_args : int, whr : int): 11 | func_ref = f_ref 12 | permission_level = perm_lvl 13 | max_args = mx_args 14 | min_args = mn_args 15 | where = whr 16 | -------------------------------------------------------------------------------- /addons/gift/auth/grant_flows/auth_flow.gd: -------------------------------------------------------------------------------- 1 | class_name TwitchOAuthFlow 2 | extends RefCounted 3 | 4 | signal token_received(token_data) 5 | 6 | var peer : StreamPeerTCP 7 | 8 | func _create_peer() -> StreamPeerTCP: 9 | return null 10 | 11 | func poll() -> void: 12 | if (!peer): 13 | peer = _create_peer() 14 | if (peer && peer.get_status() == StreamPeerTCP.STATUS_CONNECTED): 15 | _poll_peer() 16 | elif (peer.get_status() == StreamPeerTCP.STATUS_CONNECTED): 17 | _poll_peer() 18 | 19 | func _poll_peer() -> void: 20 | peer.poll() 21 | if (peer.get_available_bytes() > 0): 22 | var response = peer.get_utf8_string(peer.get_available_bytes()) 23 | _process_response(response) 24 | 25 | func _process_response(response : String) -> void: 26 | pass 27 | -------------------------------------------------------------------------------- /addons/gift/dummy_eventsub_connection.gd: -------------------------------------------------------------------------------- 1 | class_name DummyEventSubConnection 2 | extends TwitchEventSubConnection 3 | 4 | ### HOW TO USE: 5 | ### 1. Download the TwitchCLI (https://github.com/twitchdev/twitch-cli/releases) 6 | ### 2. Start the websocket server by executing 'twitch event websocket start-server' 7 | ### 3. Connect to the websocket server using the connect_to_eventsub function 8 | ### 4. Copy the session id printed in the console of the websocket server 9 | ### 5. From a seperate console, you can now trigger events. e.g.: 'twitch event trigger channel.follow --session 10 | ### For more information, see https://github.com/twitchdev/twitch-cli/blob/main/docs/event.md 11 | func connect_to_eventsub(url : String = "ws://127.0.0.1:8080/ws", poll_signal : Signal = Engine.get_main_loop().process_frame) -> void: 12 | poll_signal.connect(poll) 13 | super(url) 14 | 15 | func subscribe_event(event_name : String, version : String, conditions : Dictionary) -> void: 16 | pass 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2023 Max Kross 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /addons/gift/auth/grant_flows/client_credentials_grant_flow.gd: -------------------------------------------------------------------------------- 1 | class_name ClientCredentialsGrantFlow 2 | extends TwitchOAuthFlow 3 | 4 | signal http_connected 5 | 6 | var http_client : HTTPClient 7 | var chunks : PackedByteArray = PackedByteArray() 8 | 9 | func poll() -> void: 10 | if (http_client != null): 11 | http_client.poll() 12 | if (http_client.get_status() == HTTPClient.STATUS_CONNECTED): 13 | http_connected.emit() 14 | if (!chunks.is_empty()): 15 | var response = chunks.get_string_from_utf8() 16 | token_received.emit(JSON.parse_string(response)) 17 | chunks.clear() 18 | http_client = null 19 | elif (http_client.get_status() == HTTPClient.STATUS_BODY): 20 | chunks += http_client.read_response_body_chunk() 21 | 22 | func login(client_id : String, client_secret : String) -> AppAccessToken: 23 | if (http_client == null): 24 | http_client = HTTPClient.new() 25 | http_client.connect_to_host("https://id.twitch.tv", -1, TLSOptions.client()) 26 | await(http_connected) 27 | http_client.request(HTTPClient.METHOD_POST, "/oauth2/token", ["Content-Type: application/x-www-form-urlencoded"], "client_id=%s&client_secret=%s&grant_type=client_credentials" % [client_id, client_secret]) 28 | var token : AppAccessToken = AppAccessToken.new(await(token_received), client_id) 29 | token.fresh = true 30 | return token 31 | -------------------------------------------------------------------------------- /addons/gift/auth/grant_flows/implicit_grant_flow.gd: -------------------------------------------------------------------------------- 1 | class_name ImplicitGrantFlow 2 | extends RedirectingFlow 3 | 4 | # Get an OAuth token from Twitch. Returns null if authentication failed. 5 | func login(client_id : String, scopes : PackedStringArray, force_verify : bool = false) -> UserAccessToken: 6 | start_tcp_server() 7 | OS.shell_open("https://id.twitch.tv/oauth2/authorize?response_type=token&client_id=%s&force_verify=%s&redirect_uri=%s&scope=%s" % [client_id, "true" if force_verify else "false", redirect_url, " ".join(scopes)].map(func (a : String): return a.uri_encode())) 8 | print("Waiting for user to login.") 9 | var token_data : Dictionary = await(token_received) 10 | server.stop() 11 | if (!token_data.is_empty()): 12 | var token : UserAccessToken = UserAccessToken.new(token_data, client_id) 13 | token.fresh = true 14 | return token 15 | return null 16 | 17 | func poll() -> void: 18 | if (!peer): 19 | peer = _create_peer() 20 | if (peer && peer.get_status() == StreamPeerTCP.STATUS_CONNECTED): 21 | _poll_peer() 22 | elif (peer.get_status() == StreamPeerTCP.STATUS_CONNECTED): 23 | _poll_peer() 24 | 25 | func _handle_empty_response() -> void: 26 | send_response("200 OK", "Twitch Login".to_utf8_buffer()) 27 | 28 | func _handle_success(data : Dictionary) -> void: 29 | super._handle_success(data) 30 | token_received.emit(data) 31 | 32 | func _handle_error(data : Dictionary) -> void: 33 | super._handle_error(data) 34 | token_received.emit({}) 35 | -------------------------------------------------------------------------------- /example/Example.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=2 format=3 uid="uid://bculs28gstcxk"] 2 | 3 | [ext_resource type="Script" uid="uid://7d8ivhr0qbs4" path="res://example/Example.gd" id="1_8267x"] 4 | 5 | [node name="Example" type="Control"] 6 | layout_mode = 3 7 | anchors_preset = 15 8 | anchor_right = 1.0 9 | anchor_bottom = 1.0 10 | grow_horizontal = 2 11 | grow_vertical = 2 12 | script = ExtResource("1_8267x") 13 | 14 | [node name="ChatContainer" type="VBoxContainer" parent="."] 15 | layout_mode = 0 16 | anchor_right = 1.0 17 | anchor_bottom = 1.0 18 | 19 | [node name="Chat" type="Panel" parent="ChatContainer"] 20 | show_behind_parent = true 21 | layout_mode = 2 22 | size_flags_horizontal = 3 23 | size_flags_vertical = 3 24 | 25 | [node name="ChatScrollContainer" type="ScrollContainer" parent="ChatContainer/Chat"] 26 | unique_name_in_owner = true 27 | layout_mode = 0 28 | anchor_right = 1.0 29 | anchor_bottom = 1.0 30 | follow_focus = true 31 | 32 | [node name="Messages" type="VBoxContainer" parent="ChatContainer/Chat/ChatScrollContainer"] 33 | unique_name_in_owner = true 34 | layout_mode = 2 35 | size_flags_horizontal = 3 36 | size_flags_vertical = 3 37 | 38 | [node name="HBoxContainer" type="HBoxContainer" parent="ChatContainer"] 39 | layout_mode = 2 40 | 41 | [node name="LineEdit" type="LineEdit" parent="ChatContainer/HBoxContainer"] 42 | unique_name_in_owner = true 43 | layout_mode = 2 44 | size_flags_horizontal = 3 45 | size_flags_vertical = 3 46 | caret_blink = true 47 | 48 | [node name="Button" type="Button" parent="ChatContainer/HBoxContainer"] 49 | unique_name_in_owner = true 50 | layout_mode = 2 51 | text = "Send" 52 | -------------------------------------------------------------------------------- /addons/gift/util/token_cache.gd: -------------------------------------------------------------------------------- 1 | class_name TokenCache 2 | extends RefCounted 3 | 4 | # Loads a token from file located at token_path. Returns null if the token was invalid. 5 | static func load_token(token_path : String, scopes : Array[String] = []) -> TwitchToken: 6 | var token : TwitchToken = null 7 | if (FileAccess.file_exists(token_path)): 8 | var file : FileAccess = FileAccess.open(token_path, FileAccess.READ) 9 | var data : Dictionary = JSON.parse_string(file.get_as_text()) 10 | if (data.has("scope")): 11 | var old_scopes = data["scope"] 12 | for scope in scopes: 13 | if (!old_scopes.has(scope)): 14 | return token 15 | if (data.has("refresh_token")): 16 | return RefreshableUserAccessToken.new(data, data["client_id"]) 17 | else: 18 | return UserAccessToken.new(data, data["client_id"]) 19 | else: 20 | return AppAccessToken.new(data, data["client_id"]) 21 | return token 22 | 23 | # Stores a token in a file located at token_path. The files contents will be overwritten. 24 | static func save_token(token_path : String, token : TwitchToken) -> void: 25 | DirAccess.make_dir_recursive_absolute(token_path.get_base_dir()) 26 | var file : FileAccess = FileAccess.open(token_path, FileAccess.WRITE) 27 | var data : Dictionary = {} 28 | data["client_id"] = token.last_client_id 29 | data["access_token"] = token.token 30 | if (token is UserAccessToken): 31 | data["scope"] = token.scopes 32 | if (token is RefreshableUserAccessToken): 33 | data["refresh_token"] = token.refresh_token 34 | if (token.last_client_secret != ""): 35 | data["client_secret"] = token.last_client_secret 36 | file.store_string(JSON.stringify(data)) 37 | -------------------------------------------------------------------------------- /addons/gift/auth/grant_flows/authorization_code_grant_flow.gd: -------------------------------------------------------------------------------- 1 | class_name AuthorizationCodeGrantFlow 2 | extends RedirectingFlow 3 | 4 | signal auth_code_received(token) 5 | signal http_connected 6 | 7 | var http_client : HTTPClient 8 | var chunks : PackedByteArray = PackedByteArray() 9 | 10 | func get_authorization_code(client_id : String, scopes : PackedStringArray, force_verify : bool = false) -> String: 11 | start_tcp_server() 12 | OS.shell_open("https://id.twitch.tv/oauth2/authorize?response_type=code&client_id=%s&scope=%s&redirect_uri=%s&force_verify=%s" % [client_id, " ".join(scopes).uri_encode(), redirect_url, "true" if force_verify else "false"].map(func (a : String): return a.uri_encode())) 13 | print("Waiting for user to login.") 14 | var code : String = await(auth_code_received) 15 | server.stop() 16 | return code 17 | 18 | func login(client_id : String, client_secret : String, auth_code : String = "", scopes : PackedStringArray = [], force_verify : bool = false) -> RefreshableUserAccessToken: 19 | if (auth_code == ""): 20 | auth_code = await(get_authorization_code(client_id, scopes, force_verify)) 21 | if (http_client == null): 22 | http_client = HTTPClient.new() 23 | http_client.connect_to_host("https://id.twitch.tv", -1, TLSOptions.client()) 24 | await(http_connected) 25 | http_client.request(HTTPClient.METHOD_POST, "/oauth2/token", ["Content-Type: application/x-www-form-urlencoded"], "client_id=%s&client_secret=%s&code=%s&grant_type=authorization_code&redirect_uri=%s" % [client_id, client_secret, auth_code, redirect_url]) 26 | print("Using auth token to login.") 27 | var token : RefreshableUserAccessToken = RefreshableUserAccessToken.new(await(token_received), client_id, client_secret) 28 | token.fresh = true 29 | return token 30 | 31 | func poll() -> void: 32 | if (server != null): 33 | super.poll() 34 | 35 | func _handle_empty_response() -> void: 36 | super._handle_empty_response() 37 | auth_code_received.emit("") 38 | 39 | func _handle_success(data : Dictionary) -> void: 40 | super._handle_success(data) 41 | auth_code_received.emit(data["code"]) 42 | 43 | func _handle_error(data : Dictionary) -> void: 44 | super._handle_error(data) 45 | auth_code_received.emit("") 46 | -------------------------------------------------------------------------------- /addons/gift/auth/grant_flows/device_code_grant_flow.gd: -------------------------------------------------------------------------------- 1 | class_name DeviceCodeGrantFlow 2 | extends TwitchOAuthFlow 3 | 4 | signal http_connected 5 | signal response_received 6 | 7 | var http_client : HTTPClient = HTTPClient.new() 8 | var chunks : PackedByteArray = PackedByteArray() 9 | var p_signal : Signal 10 | 11 | func _init(poll_signal : Signal = Engine.get_main_loop().process_frame) -> void: 12 | poll_signal.connect(poll) 13 | p_signal = poll_signal 14 | 15 | # https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#starting-the-dcf-flow-for-your-user 16 | func request_login(client_id : String, scopes : PackedStringArray) -> Dictionary: 17 | http_client.connect_to_host("https://id.twitch.tv", -1, TLSOptions.client()) 18 | await(http_connected) 19 | http_client.request(HTTPClient.METHOD_POST, "/oauth2/device", ["Content-Type: application/x-www-form-urlencoded"], "client_id=%s&scopes=%s" % [client_id, " ".join(scopes).uri_encode()]) 20 | return await(response_received) 21 | 22 | func poll() -> void: 23 | if (http_client != null): 24 | http_client.poll() 25 | if (http_client.get_status() == HTTPClient.STATUS_CONNECTED): 26 | http_connected.emit() 27 | if (!chunks.is_empty()): 28 | var response = chunks.get_string_from_utf8() 29 | response_received.emit(JSON.parse_string(response)) 30 | chunks.clear() 31 | elif (http_client.get_status() == HTTPClient.STATUS_BODY): 32 | chunks += http_client.read_response_body_chunk() 33 | 34 | func login(client_id : String, scopes : PackedStringArray, device_code : String) -> UserAccessToken: 35 | var response : Dictionary = {} 36 | while (response.is_empty() || (response.has("status") && response["status"] == 400)): 37 | http_client.request(HTTPClient.METHOD_POST, "/oauth2/token", ["Content-Type: application/x-www-form-urlencoded"], "location=%s&client_id=%s&scopes=%s&device_code=%s&grant_type=%s" % ["https://id.twitch.tv/oauth2/token", client_id, " ".join(scopes).uri_encode(), device_code, "urn:ietf:params:oauth:grant-type:device_code".uri_encode()]) 38 | response = await(response_received) 39 | if (!response.is_empty() && response.has("message") && response["message"] != "authorization_pending"): 40 | print("Could not login using the device code: " + response["message"]) 41 | return null 42 | await(p_signal) 43 | var token := RefreshableUserAccessToken.new(response, client_id) 44 | token.fresh = true 45 | return token 46 | -------------------------------------------------------------------------------- /addons/gift/auth/grant_flows/redirecting_flow.gd: -------------------------------------------------------------------------------- 1 | class_name RedirectingFlow 2 | extends TwitchOAuthFlow 3 | 4 | var server : TCPServer 5 | 6 | var tcp_port : int 7 | var redirect_url : String 8 | 9 | func _init(port : int = 18297, redirect : String = "http://localhost:%s" % port) -> void: 10 | tcp_port = port 11 | redirect_url = redirect 12 | 13 | func _create_peer() -> StreamPeerTCP: 14 | return server.take_connection() 15 | 16 | func start_tcp_server() -> void: 17 | if (server == null): 18 | server = TCPServer.new() 19 | if (server.listen(tcp_port) != OK): 20 | print("Could not listen to port %d" % tcp_port) 21 | 22 | func send_response(response : String, body : PackedByteArray) -> void: 23 | peer.put_data(("HTTP/1.1 %s\r\n" % response).to_utf8_buffer()) 24 | peer.put_data("Server: GIFT (Godot Engine)\r\n".to_utf8_buffer()) 25 | peer.put_data(("Content-Length: %d\r\n"% body.size()).to_utf8_buffer()) 26 | peer.put_data("Connection: close\r\n".to_utf8_buffer()) 27 | peer.put_data("Content-Type: text/html; charset=UTF-8\r\n".to_utf8_buffer()) 28 | peer.put_data("\r\n".to_utf8_buffer()) 29 | peer.put_data(body) 30 | 31 | func _process_response(response : String) -> void: 32 | if (response == ""): 33 | print("Empty response. Check if your redirect URL is set to %s." % redirect_url) 34 | return 35 | var start : int = response.substr(0, response.find("\n")).find("?") 36 | if (start == -1): 37 | _handle_empty_response() 38 | else: 39 | response = response.substr(start + 1, response.find(" ", start) - start) 40 | var data : Dictionary = {} 41 | for entry in response.split("&"): 42 | var pair = entry.split("=") 43 | data[pair[0]] = pair[1] if pair.size() > 0 else "" 44 | if (data.has("error")): 45 | _handle_error(data) 46 | else: 47 | _handle_success(data) 48 | peer.disconnect_from_host() 49 | peer = null 50 | 51 | func _handle_empty_response() -> void: 52 | print ("Response from Twitch does not contain the required data.") 53 | 54 | func _handle_success(data : Dictionary) -> void: 55 | data["scope"] = data["scope"].uri_decode().split(" ") 56 | print("Success.") 57 | send_response("200 OK", "Twitch LoginSuccess!".to_utf8_buffer()) 58 | 59 | func _handle_error(data : Dictionary) -> void: 60 | var msg = "Error %s: %s" % [data["error"], data["error_description"]] 61 | print(msg) 62 | send_response("400 BAD REQUEST", msg.to_utf8_buffer()) 63 | -------------------------------------------------------------------------------- /addons/gift/id_connection.gd: -------------------------------------------------------------------------------- 1 | class_name TwitchIDConnection 2 | extends RefCounted 3 | 4 | 5 | signal polled 6 | signal token_invalid 7 | signal token_refreshed(success) 8 | 9 | const ONE_HOUR_MS = 3600000 10 | 11 | var last_token : TwitchToken 12 | 13 | var id_client : HTTPClient = HTTPClient.new() 14 | var id_client_response : PackedByteArray = [] 15 | 16 | var next_check : int = 0 17 | 18 | func _init(token : TwitchToken, poll_signal : Signal = Engine.get_main_loop().process_frame) -> void: 19 | poll_signal.connect(poll) 20 | last_token = token 21 | if (last_token.fresh): 22 | next_check += ONE_HOUR_MS 23 | id_client.connect_to_host("https://id.twitch.tv", -1, TLSOptions.client()) 24 | 25 | func poll() -> void: 26 | if (id_client != null): 27 | id_client.poll() 28 | if (id_client.get_status() == HTTPClient.STATUS_CONNECTED): 29 | if (!id_client_response.is_empty()): 30 | var response = JSON.parse_string(id_client_response.get_string_from_utf8()) 31 | if (response.has("status") && (response["status"] == 401 || response["status"] == 400)): 32 | print("Token is invalid. Aborting.") 33 | token_invalid.emit() 34 | token_refreshed.emit(false) 35 | else: 36 | last_token.token = response.get("access_token", last_token.token) 37 | last_token.expires_in = response.get("expires_in", last_token.expires_in) 38 | if last_token is RefreshableUserAccessToken: 39 | last_token.refresh_token = response.get("refresh_token", last_token.refresh_token) 40 | token_refreshed.emit(true) 41 | if last_token is AppAccessToken: 42 | token_refreshed.emit(true) 43 | id_client_response.clear() 44 | if (next_check <= Time.get_ticks_msec()): 45 | check_token() 46 | next_check += ONE_HOUR_MS 47 | elif (id_client.get_status() == HTTPClient.STATUS_BODY): 48 | id_client_response += id_client.read_response_body_chunk() 49 | polled.emit() 50 | 51 | func check_token() -> void: 52 | id_client.request(HTTPClient.METHOD_GET, "/oauth2/validate", ["Authorization: OAuth %s" % last_token.token]) 53 | print("Validating token...") 54 | 55 | func refresh_token() -> void: 56 | if (last_token is RefreshableUserAccessToken): 57 | if (last_token.last_client_secret != ""): 58 | id_client.request(HTTPClient.METHOD_GET, "/oauth2/token", ["Content-Type: application/x-www-form-urlencoded"], "grant_type=refresh_token&refresh_token=%s&client_id=%s&client_secret=%s" % [last_token.refresh_token, last_token.last_client_id, last_token.last_client_secret]) 59 | else: 60 | id_client.request(HTTPClient.METHOD_GET, "/oauth2/token", ["Content-Type: application/x-www-form-urlencoded"], "grant_type=refresh_token&refresh_token=%s&client_id=%s" % [last_token.refresh_token, last_token.last_client_id]) 61 | elif (last_token is UserAccessToken): 62 | var auth : ImplicitGrantFlow = ImplicitGrantFlow.new() 63 | polled.connect(auth.poll) 64 | var last_token = await(auth.login(last_token.last_client_id, last_token.scopes)) 65 | token_refreshed.emit(true) 66 | else: 67 | id_client.request(HTTPClient.METHOD_POST, "/oauth2/token", ["Content-Type: application/x-www-form-urlencoded"], "client_id=%s&client_secret=%s&grant_type=client_credentials" % [last_token.client_id, last_token.client_secret]) 68 | if (last_token == null): 69 | print("Please check if you have all required scopes.") 70 | token_refreshed.emit(false) 71 | -------------------------------------------------------------------------------- /addons/gift/icon_downloader.gd: -------------------------------------------------------------------------------- 1 | class_name TwitchIconDownloader 2 | extends RefCounted 3 | 4 | signal fetched(texture) 5 | 6 | var api : TwitchAPIConnection 7 | 8 | const JTVNW_URL : String = "https://static-cdn.jtvnw.net" 9 | 10 | var jtvnw_client : HTTPClient = HTTPClient.new() 11 | var jtvnw_response : PackedByteArray = [] 12 | var jtvnw_queue : Array[String] = [] 13 | 14 | var cached_badges : Dictionary = {} 15 | 16 | var disk_cache : bool 17 | 18 | func _init(twitch_api : TwitchAPIConnection, disk_cache_enabled : bool = false) -> void: 19 | api = twitch_api 20 | api.id_conn.polled.connect(poll) 21 | disk_cache = disk_cache_enabled 22 | jtvnw_client.connect_to_host(JTVNW_URL) 23 | 24 | func poll() -> void: 25 | jtvnw_client.poll() 26 | var conn_status : HTTPClient.Status = jtvnw_client.get_status() 27 | if (conn_status == HTTPClient.STATUS_BODY): 28 | jtvnw_response += jtvnw_client.read_response_body_chunk() 29 | elif (!jtvnw_response.is_empty()): 30 | var img := Image.new() 31 | img.load_png_from_buffer(jtvnw_response) 32 | jtvnw_response.clear() 33 | var path = jtvnw_queue.pop_front() 34 | var texture : ImageTexture = ImageTexture.new() 35 | texture.set_image(img) 36 | texture.take_over_path(path) 37 | fetched.emit(texture) 38 | elif (!jtvnw_queue.is_empty()): 39 | if (conn_status == HTTPClient.STATUS_CONNECTED): 40 | jtvnw_client.request(HTTPClient.METHOD_GET, jtvnw_queue.front(), ["Accept: image/png"]) 41 | elif (conn_status == HTTPClient.STATUS_DISCONNECTED || conn_status == HTTPClient.STATUS_CONNECTION_ERROR): 42 | jtvnw_client.connect_to_host(JTVNW_URL) 43 | 44 | func get_badge(badge_id : String, channel_id : String = "_global", scale : String = "1x") -> Texture2D: 45 | var badge_data : PackedStringArray = badge_id.split("/", true, 1) 46 | if (!cached_badges.has(channel_id)): 47 | if (channel_id == "_global"): 48 | cache_badges(await(api.get_global_chat_badges()), channel_id) 49 | else: 50 | cache_badges(await(api.get_channel_chat_badges(channel_id)), channel_id) 51 | if (channel_id != "_global" && !cached_badges[channel_id].has(badge_data[0])): 52 | return await(get_badge(badge_id, "_global", scale)) 53 | var path : String = cached_badges[channel_id][badge_data[0]]["versions"][badge_data[1]]["image_url_%s" % scale].substr(JTVNW_URL.length()) 54 | if ResourceLoader.has_cached(path): 55 | return load(path) 56 | else: 57 | jtvnw_queue.append(path) 58 | var filepath : String = "user://badges/%s/%s_%s_%s.png" % [channel_id, badge_data[0], badge_data[1], scale] 59 | return await(wait_for_fetched(path, filepath)) 60 | 61 | func get_emote(emote_id : String, dark : bool = true, scale : String = "1.0") -> Texture2D: 62 | var path : String = "/emoticons/v2/%s/static/%s/%s" % [emote_id, "dark" if dark else "light", scale] 63 | if ResourceLoader.has_cached(path): 64 | return load(path) 65 | else: 66 | var filepath : String = "user://emotes/%s.png" % emote_id 67 | jtvnw_queue.append(path) 68 | return await(wait_for_fetched(path, filepath)) 69 | 70 | func cache_badges(result, channel_id) -> void: 71 | cached_badges[channel_id] = result 72 | var mappings : Dictionary = {} 73 | var badges : Array = cached_badges[channel_id]["data"] 74 | for entry in badges: 75 | if (!mappings.has(entry["set_id"])): 76 | mappings[entry["set_id"]] = {"versions" : {}} 77 | for version in entry["versions"]: 78 | mappings[entry["set_id"]]["versions"][version["id"]] = version 79 | cached_badges[channel_id] = mappings 80 | 81 | func wait_for_fetched(path : String, filepath : String) -> ImageTexture: 82 | var last_fetched : ImageTexture = null 83 | while (last_fetched == null || last_fetched.resource_path != path): 84 | last_fetched = await(fetched) 85 | last_fetched.take_over_path(path) 86 | return load(path) 87 | -------------------------------------------------------------------------------- /addons/gift/command_handler.gd: -------------------------------------------------------------------------------- 1 | class_name GIFTCommandHandler 2 | extends RefCounted 3 | 4 | signal cmd_invalid_argcount(command, sender_data, cmd_data, arg_ary) 5 | signal cmd_no_permission(command, sender_data, cmd_data, arg_ary) 6 | 7 | # Required permission to execute the command 8 | enum PermissionFlag { 9 | EVERYONE = 0, 10 | VIP = 1, 11 | SUB = 2, 12 | MOD = 4, 13 | STREAMER = 8, 14 | MOD_STREAMER = 12, # Mods and the streamer 15 | NON_REGULAR = 15 # Everyone but regular viewers 16 | } 17 | 18 | # Where the command should be accepted 19 | enum WhereFlag { 20 | CHAT = 1, 21 | WHISPER = 2, 22 | ANYWHERE = 3 23 | } 24 | 25 | # Messages starting with one of these symbols are handled as commands. '/' will be ignored, reserved by Twitch. 26 | var command_prefixes : Array[String] = ["!"] 27 | # Dictionary of commands, contains -> entries. 28 | var commands : Dictionary = {} 29 | 30 | # Registers a command on an object with a func to call, similar to connect(signal, instance, func). 31 | 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: 32 | commands[cmd_name] = CommandData.new(callable, permission_level, max_args, min_args, where) 33 | 34 | # Removes a single command or alias. 35 | func remove_command(cmd_name : String) -> void: 36 | commands.erase(cmd_name) 37 | 38 | # Removes a command and all associated aliases. 39 | func purge_command(cmd_name : String) -> void: 40 | var to_remove = commands.get(cmd_name) 41 | if(to_remove): 42 | var remove_queue = [] 43 | for command in commands.keys(): 44 | if(commands[command].func_ref == to_remove.func_ref): 45 | remove_queue.append(command) 46 | for queued in remove_queue: 47 | commands.erase(queued) 48 | 49 | # Add a command alias. The command specified in 'cmd_name' can now also be executed with the 50 | # command specified in 'alias'. 51 | func add_alias(cmd_name : String, alias : String) -> void: 52 | if(commands.has(cmd_name)): 53 | commands[alias] = commands.get(cmd_name) 54 | 55 | # Same as add_alias, but for multiple aliases at once. 56 | func add_aliases(cmd_name : String, aliases : PackedStringArray) -> void: 57 | for alias in aliases: 58 | add_alias(cmd_name, alias) 59 | 60 | func handle_command(sender_data : SenderData, msg : String, whisper : bool = false) -> void: 61 | if(command_prefixes.has(msg.left(1))): 62 | msg = msg.right(-1) 63 | var split = msg.split(" ", true, 1) 64 | var command : String = split[0] 65 | var cmd_data : CommandData = commands.get(command) 66 | if(cmd_data): 67 | if(whisper == true && cmd_data.where & WhereFlag.WHISPER != WhereFlag.WHISPER): 68 | return 69 | elif(whisper == false && cmd_data.where & WhereFlag.CHAT != WhereFlag.CHAT): 70 | return 71 | var arg_ary : PackedStringArray = PackedStringArray() 72 | if (split.size() > 1): 73 | arg_ary = split[1].split(" ") 74 | if(arg_ary.size() > cmd_data.max_args && cmd_data.max_args != -1 || arg_ary.size() < cmd_data.min_args): 75 | cmd_invalid_argcount.emit(command, sender_data, cmd_data, arg_ary) 76 | return 77 | if(cmd_data.permission_level != 0): 78 | var user_perm_flags = get_perm_flag_from_tags(sender_data.tags) 79 | if(user_perm_flags & cmd_data.permission_level == 0): 80 | cmd_no_permission.emit(command, sender_data, cmd_data, arg_ary) 81 | return 82 | if(arg_ary.size() == 0): 83 | if (cmd_data.min_args > 0): 84 | cmd_invalid_argcount.emit(command, sender_data, cmd_data, arg_ary) 85 | return 86 | cmd_data.func_ref.call(CommandInfo.new(sender_data, command, whisper)) 87 | else: 88 | cmd_data.func_ref.call(CommandInfo.new(sender_data, command, whisper), arg_ary) 89 | 90 | func get_perm_flag_from_tags(tags : Dictionary) -> int: 91 | var flag = 0 92 | var entry = tags.get("badges") 93 | if(entry): 94 | for badge in entry.split(","): 95 | if(badge.begins_with("vip")): 96 | flag += PermissionFlag.VIP 97 | if(badge.begins_with("broadcaster")): 98 | flag += PermissionFlag.STREAMER 99 | entry = tags.get("mod") 100 | if(entry): 101 | if(entry == "1"): 102 | flag += PermissionFlag.MOD 103 | entry = tags.get("subscriber") 104 | if(entry): 105 | if(entry == "1"): 106 | flag += PermissionFlag.SUB 107 | return flag 108 | -------------------------------------------------------------------------------- /addons/gift/eventsub_connection.gd: -------------------------------------------------------------------------------- 1 | class_name TwitchEventSubConnection 2 | extends RefCounted 3 | 4 | const TEN_MINUTES_S : int = 600 5 | 6 | # The id has been received from the welcome message. 7 | signal session_id_received(id) 8 | signal connection_failed 9 | signal disconnected(close_code, reason) 10 | signal events_revoked(type, status) 11 | signal event(type, event_data) 12 | 13 | enum ConnectionState { 14 | DISCONNECTED, 15 | CONNECTED, 16 | CONNECTION_FAILED, 17 | RECONNECTING 18 | } 19 | 20 | var connection_state : ConnectionState = ConnectionState.DISCONNECTED 21 | 22 | var eventsub_messages : Dictionary = {} 23 | var eventsub_reconnect_url : String = "" 24 | var session_id : String = "" 25 | var keepalive_timeout : int = 0 26 | var last_keepalive : int = 0 27 | 28 | var last_cleanup : int = 0 29 | 30 | var websocket : WebSocketPeer 31 | 32 | var api : TwitchAPIConnection 33 | 34 | func _init(twitch_api_connection : TwitchAPIConnection) -> void: 35 | api = twitch_api_connection 36 | api.id_conn.polled.connect(poll) 37 | 38 | func connect_to_eventsub(url : String = "wss://eventsub.wss.twitch.tv/ws") -> void: 39 | if (websocket == null): 40 | websocket = WebSocketPeer.new() 41 | websocket.connect_to_url(url) 42 | print("Connecting to Twitch EventSub") 43 | await(session_id_received) 44 | 45 | func poll() -> void: 46 | if (websocket != null): 47 | websocket.poll() 48 | var state := websocket.get_ready_state() 49 | match state: 50 | WebSocketPeer.STATE_OPEN: 51 | if (!connection_state == ConnectionState.CONNECTED): 52 | connection_state = ConnectionState.CONNECTED 53 | print("Connected to EventSub.") 54 | else: 55 | while (websocket.get_available_packet_count()): 56 | process_event(websocket.get_packet()) 57 | WebSocketPeer.STATE_CLOSED: 58 | if(connection_state != ConnectionState.CONNECTED): 59 | print("Could not connect to EventSub.") 60 | connection_failed.emit() 61 | websocket = null 62 | connection_state = ConnectionState.CONNECTION_FAILED 63 | elif(connection_state == ConnectionState.RECONNECTING): 64 | print("Reconnecting to EventSub") 65 | websocket.close() 66 | connect_to_eventsub(eventsub_reconnect_url) 67 | else: 68 | print("Disconnected from EventSub.") 69 | connection_state = ConnectionState.DISCONNECTED 70 | print("Connection closed! [%s]: %s"%[websocket.get_close_code(), websocket.get_close_reason()]) 71 | disconnected.emit(websocket.get_close_code(), websocket.get_close_reason()) 72 | websocket = null 73 | var t : int = Time.get_ticks_msec() / 1000 - TEN_MINUTES_S 74 | if (last_cleanup < t): 75 | last_cleanup = Time.get_ticks_msec() / 1000 76 | var to_remove : Array = [] 77 | for msg in eventsub_messages: 78 | if (eventsub_messages[msg] < Time.get_unix_time_from_system() - TEN_MINUTES_S): 79 | to_remove.append(msg) 80 | for e in to_remove: 81 | eventsub_messages.erase(e) 82 | 83 | func process_event(data : PackedByteArray) -> void: 84 | var msg : Dictionary = JSON.parse_string(data.get_string_from_utf8()) 85 | if (eventsub_messages.has(msg["metadata"]["message_id"]) || Time.get_unix_time_from_datetime_string(msg["metadata"]["message_timestamp"]) < Time.get_unix_time_from_system() - TEN_MINUTES_S): 86 | return 87 | eventsub_messages[msg["metadata"]["message_id"]] = Time.get_unix_time_from_datetime_string(msg["metadata"]["message_timestamp"]) 88 | var payload : Dictionary = msg["payload"] 89 | last_keepalive = Time.get_ticks_msec() 90 | match msg["metadata"]["message_type"]: 91 | "session_welcome": 92 | session_id = payload["session"]["id"] 93 | keepalive_timeout = payload["session"]["keepalive_timeout_seconds"] 94 | session_id_received.emit(session_id) 95 | "session_keepalive": 96 | if (payload.has("session")): 97 | keepalive_timeout = payload["session"]["keepalive_timeout_seconds"] 98 | "session_reconnect": 99 | connection_state = ConnectionState.RECONNECTING 100 | eventsub_reconnect_url = payload["session"]["reconnect_url"] 101 | "revocation": 102 | events_revoked.emit(payload["subscription"]["type"], payload["subscription"]["status"]) 103 | "notification": 104 | var event_data : Dictionary = payload["event"] 105 | event.emit(payload["subscription"]["type"], event_data) 106 | 107 | # Refer to https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/ for details on 108 | # which API versions are available and which conditions are required. 109 | func subscribe_event(event_name : String, version : String, conditions : Dictionary) -> void: 110 | var data : Dictionary = {} 111 | data["type"] = event_name 112 | data["version"] = version 113 | data["condition"] = conditions 114 | data["transport"] = { 115 | "method":"websocket", 116 | "session_id":session_id 117 | } 118 | var response = await(api.create_eventsub_subscription(data)) 119 | if (response.has("error")): 120 | print("Subscription failed for event '%s'. Error %s (%s): %s" % [event_name, response["status"], response["error"], response["message"]]) 121 | return 122 | elif (response.is_empty()): 123 | return 124 | print("Now listening to '%s' events." % response["data"][0]["type"]) 125 | -------------------------------------------------------------------------------- /addons/gift/api_connection.gd: -------------------------------------------------------------------------------- 1 | class_name TwitchAPIConnection 2 | extends RefCounted 3 | 4 | signal received_response(response : String) 5 | signal requested(request : GiftRequest) 6 | 7 | var id_conn : TwitchIDConnection 8 | 9 | var client : HTTPClient = HTTPClient.new() 10 | var client_response : PackedByteArray = [] 11 | var mock : bool = false 12 | 13 | var queue : Array = [] 14 | var current_request : GiftRequest 15 | 16 | func _init(id_connection : TwitchIDConnection, twitch_cli_url : String = "") -> void: 17 | id_conn = id_connection 18 | id_conn.polled.connect(poll) 19 | client.connect_to_host("https://api.twitch.tv", -1, TLSOptions.client()) 20 | 21 | func poll() -> void: 22 | client.poll() 23 | if (!queue.is_empty() && client.get_status() == HTTPClient.STATUS_CONNECTED && current_request == null): 24 | current_request = queue.pop_front() 25 | requested.emit(current_request) 26 | client.request(current_request.method, "/mock" if mock else "/helix" + current_request.url, current_request.headers, current_request.body) 27 | if (client.get_status() == HTTPClient.STATUS_BODY): 28 | client_response += client.read_response_body_chunk() 29 | elif (!client_response.is_empty()): 30 | received_response.emit(client_response.get_string_from_utf8()) 31 | client_response.clear() 32 | current_request = null 33 | 34 | func request(method : int, url : String, headers : PackedStringArray, body : String = "") -> Dictionary: 35 | var request : GiftRequest = GiftRequest.new(method, url, headers, body) 36 | queue.append(request) 37 | var req : GiftRequest = await(requested) 38 | while (req != request): 39 | req = await(requested) 40 | var str_response : String = await(received_response) 41 | var response = JSON.parse_string(str_response) if !str_response.is_empty() else {} 42 | var response_code: int = client.get_response_code() 43 | match (response_code): 44 | 200, 201, 202, 203, 204: 45 | return response 46 | _: 47 | if (response_code == 401): 48 | id_conn.token_invalid.emit() 49 | print("Token invalid. Attempting to fetch a new token.") 50 | if(await(id_conn.token_refreshed)): 51 | for i in headers.size(): 52 | if (headers[i].begins_with("Authorization: Bearer")): 53 | headers[i] = "Authorization: Bearer %s" % id_conn.last_token.token 54 | elif (headers[i].begins_with("Client-Id:")): 55 | headers[i] = "Client-Id: %s" % id_conn.last_token.last_client_id 56 | return await(request(method, url, headers, body)) 57 | var msg : String = "Error %s: %s while calling (%s). Please check the Twitch API documnetation." % [str(response_code), response.get("message", "without message"), url] 58 | return {} 59 | 60 | func get_channel_chat_badges(broadcaster_id : String) -> Dictionary: 61 | var headers : PackedStringArray = [ 62 | "Authorization: Bearer %s" % id_conn.last_token.token, 63 | "Client-Id: %s" % id_conn.last_token.last_client_id 64 | ] 65 | return await(request(HTTPClient.METHOD_GET, "/chat/badges?broadcaster_id=%s" % broadcaster_id, headers)) 66 | 67 | func get_global_chat_badges() -> Dictionary: 68 | var headers : PackedStringArray = [ 69 | "Authorization: Bearer %s" % id_conn.last_token.token, 70 | "Client-Id: %s" % id_conn.last_token.last_client_id 71 | ] 72 | return await(request(HTTPClient.METHOD_GET, "/chat/badges/global", headers)) 73 | 74 | # Create a eventsub subscription. For the data required, refer to https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/ 75 | func create_eventsub_subscription(subscription_data : Dictionary) -> Dictionary: 76 | var headers : PackedStringArray = [ 77 | "Authorization: Bearer %s" % id_conn.last_token.token, 78 | "Client-Id: %s" % id_conn.last_token.last_client_id, 79 | "Content-Type: application/json" 80 | ] 81 | return await(request(HTTPClient.METHOD_POST, "/eventsub/subscriptions", headers, JSON.stringify(subscription_data))) 82 | 83 | func get_users_by_id(ids : Array[String]) -> Dictionary: 84 | return await(get_users([], ids)) 85 | 86 | func get_users_by_name(names : Array[String]) -> Dictionary: 87 | return await(get_users(names, [])) 88 | 89 | func get_users(names : Array[String], ids : Array[String]) -> Dictionary: 90 | var headers : PackedStringArray = [ 91 | "Authorization: Bearer %s" % id_conn.last_token.token, 92 | "Client-Id: %s" % id_conn.last_token.last_client_id 93 | ] 94 | var response 95 | var params : String = "" 96 | if (!names.is_empty() || !ids.is_empty()): 97 | params = "?" 98 | if (names.size() > 0): 99 | params += "login=%s" % names.pop_back() 100 | while(names.size() > 0): 101 | params += "&login=%s" % names.pop_back() 102 | if (params.length() > 1): 103 | params += "&" 104 | if (ids.size() > 0): 105 | params += "id=%s" % ids.pop_back() 106 | while(ids.size() > 0): 107 | params += "&id=%s" % ids.pop_back() 108 | return await(request(HTTPClient.METHOD_GET, "/users/%s" % params, headers)) 109 | 110 | # Send a whisper from user_id to target_id with the specified message. 111 | # Returns true on success or if the message was silently dropped, false on failure. 112 | func send_whisper(from_user_id : String, to_user_id : String, message : String) -> Dictionary: 113 | var headers : PackedStringArray = [ 114 | "Authorization: Bearer %s" % id_conn.last_token.token, 115 | "Client-Id: %s" % id_conn.last_token.last_client_id, 116 | "Content-Type: application/json" 117 | ] 118 | var params: String = "?" 119 | params += "from_user_id=%s" % from_user_id 120 | params += "&to_user_id=%s" % to_user_id 121 | return await(request(HTTPClient.METHOD_POST, "/whispers" + params, headers, JSON.stringify({"message": message}))) 122 | -------------------------------------------------------------------------------- /example/Example.gd: -------------------------------------------------------------------------------- 1 | extends Control 2 | 3 | # Your client id. You can share this publicly. Default is my own client_id. 4 | # Please do not ship your project with my client_id, but feel free to test with it. 5 | # Visit https://dev.twitch.tv/console/apps/create to create a new application. 6 | # You can then find your client id at the bottom of the application console. 7 | # DO NOT SHARE THE CLIENT SECRET. If you do, regenerate it. 8 | @export var client_id : String = "9x951o0nd03na7moohwetpjjtds0or" 9 | # The name of the channel we want to connect to. 10 | @export var channel : String 11 | # The username of the bot account. 12 | @export var username : String 13 | 14 | var id : TwitchIDConnection 15 | var api : TwitchAPIConnection 16 | var irc : TwitchIRCConnection 17 | var eventsub : TwitchEventSubConnection 18 | 19 | var cmd_handler : GIFTCommandHandler = GIFTCommandHandler.new() 20 | 21 | var iconloader : TwitchIconDownloader 22 | 23 | func _ready() -> void: 24 | # We will login using the Device Grant Flow, which only requires a client_id. 25 | # Alternatively, you can use the Authorization Code Grant Flow or the Implicit Grant Flow. 26 | # Note that the Client Credentials Grant Flow will only return an AppAccessToken, which can not be used 27 | # for the majority of the Twitch API or to join a chat room. 28 | # We want to be able to read and write messages, so we request the required scopes. 29 | # See https://dev.twitch.tv/docs/authentication/scopes/#twitch-access-token-scopes 30 | var scopes : PackedStringArray = ["chat:read", "chat:edit", "moderator:read:followers"] 31 | var auth : DeviceCodeGrantFlow = DeviceCodeGrantFlow.new() 32 | var response := await(auth.request_login(client_id, scopes)) 33 | print(response["verification_uri"]) 34 | var token : UserAccessToken = await(auth.login(client_id, scopes, response["device_code"])) 35 | 36 | # Use the link printed to the console to login. 37 | # I recommend adding a QR-Code generator addon and displaying that on the screen to the user. 38 | if (token == null): 39 | # Authentication failed. Abort. 40 | return 41 | 42 | # Store the token in the ID connection, create all other connections. 43 | id = TwitchIDConnection.new(token) 44 | irc = TwitchIRCConnection.new(id) 45 | api = TwitchAPIConnection.new(id) 46 | iconloader = TwitchIconDownloader.new(api) 47 | 48 | # Connect to the Twitch chat. 49 | if(!await(irc.connect_to_irc(username))): 50 | # Authentication failed. Abort. 51 | return 52 | # Request the capabilities. By default only twitch.tv/commands and twitch.tv/tags are used. 53 | # Refer to https://dev.twitch.tv/docs/irc/capabilities/ for all available capapbilities. 54 | irc.request_capabilities() 55 | # Join the channel specified in the exported 'channel' variable. 56 | irc.join_channel(channel) 57 | 58 | # Add a helloworld command. 59 | cmd_handler.add_command("helloworld", hello) 60 | # The helloworld command can now also be executed with "hello"! 61 | cmd_handler.add_alias("helloworld", "hello") 62 | # Add a list command that accepts between 1 and infinite args. 63 | cmd_handler.add_command("list", list, -1, 1) 64 | 65 | # For the chat example to work, we forward the messages received to the put_chat function. 66 | irc.chat_message.connect(put_chat) 67 | 68 | # We also have to forward the messages to the command handler to handle them. 69 | irc.chat_message.connect(cmd_handler.handle_command) 70 | # If you also want to accept whispers, connect the signal and bind true as the last arg. 71 | irc.whisper_message.connect(cmd_handler.handle_command.bind(true)) 72 | 73 | # When we press enter on the chat bar or press the send button, we want to execute the send_message 74 | # function. 75 | %LineEdit.text_submitted.connect(send_message.unbind(1)) 76 | %Button.pressed.connect(send_message) 77 | 78 | # This part of the example only works if GIFT is logged in to your broadcaster account. 79 | # If you are, you can uncomment this to also try receiving follow events. 80 | # Don't forget to also add the 'moderator:read:followers' scope to your token. 81 | #eventsub = TwitchEventSubConnection.new(api) 82 | #await(eventsub.connect_to_eventsub()) 83 | #eventsub.event.connect(on_event) 84 | #var user_ids : Dictionary = await(api.get_users_by_name([username])) 85 | #if (user_ids.has("data") && user_ids["data"].size() > 0): 86 | #var user_id : String = user_ids["data"][0]["id"] 87 | #eventsub.subscribe_event("channel.follow", "2", {"broadcaster_user_id": user_id, "moderator_user_id": user_id}) 88 | 89 | func hello(cmd_info : CommandInfo) -> void: 90 | irc.chat("Hello World!") 91 | 92 | func list(cmd_info : CommandInfo, arg_ary : PackedStringArray) -> void: 93 | irc.chat(", ".join(arg_ary)) 94 | 95 | func on_event(type : String, data : Dictionary) -> void: 96 | match(type): 97 | "channel.follow": 98 | print("%s followed your channel!" % data["user_name"]) 99 | 100 | func send_message() -> void: 101 | irc.chat(%LineEdit.text) 102 | %LineEdit.text = "" 103 | 104 | func put_chat(senderdata : SenderData, msg : String): 105 | var bottom : bool = %ChatScrollContainer.scroll_vertical == %ChatScrollContainer.get_v_scroll_bar().max_value - %ChatScrollContainer.get_v_scroll_bar().get_rect().size.y 106 | var label : RichTextLabel = RichTextLabel.new() 107 | var time = Time.get_time_dict_from_system() 108 | label.fit_content = true 109 | label.selection_enabled = true 110 | label.push_font_size(12) 111 | label.push_color(Color.WEB_GRAY) 112 | label.add_text("%02d:%02d " % [time["hour"], time["minute"]]) 113 | label.pop() 114 | label.push_font_size(14) 115 | var badges : Array[Texture2D] 116 | for badge in senderdata.tags["badges"].split(",", false): 117 | label.add_image(await(iconloader.get_badge(badge, senderdata.tags["room-id"])), 0, 0, Color.WHITE, INLINE_ALIGNMENT_CENTER) 118 | label.push_bold() 119 | if (senderdata.tags["color"] != ""): 120 | label.push_color(Color(senderdata.tags["color"])) 121 | label.add_text(" %s" % senderdata.tags["display-name"]) 122 | label.push_color(Color.WHITE) 123 | label.push_normal() 124 | label.add_text(": ") 125 | var locations : Array[EmoteLocation] = [] 126 | if (senderdata.tags.has("emotes")): 127 | for emote in senderdata.tags["emotes"].split("/", false): 128 | var data : PackedStringArray = emote.split(":") 129 | for d in data[1].split(","): 130 | var start_end = d.split("-") 131 | locations.append(EmoteLocation.new(data[0], int(start_end[0]), int(start_end[1]))) 132 | locations.sort_custom(Callable(EmoteLocation, "smaller")) 133 | if (locations.is_empty()): 134 | label.add_text(msg) 135 | else: 136 | var offset = 0 137 | for loc in locations: 138 | label.add_text(msg.substr(offset, loc.start - offset)) 139 | label.add_image(await(iconloader.get_emote(loc.id)), 0, 0, Color.WHITE, INLINE_ALIGNMENT_CENTER) 140 | offset = loc.end + 1 141 | %Messages.add_child(label) 142 | await(get_tree().process_frame) 143 | if (bottom): 144 | %ChatScrollContainer.scroll_vertical = %ChatScrollContainer.get_v_scroll_bar().max_value 145 | 146 | class EmoteLocation extends RefCounted: 147 | var id : String 148 | var start : int 149 | var end : int 150 | 151 | func _init(emote_id, start_idx, end_idx): 152 | self.id = emote_id 153 | self.start = start_idx 154 | self.end = end_idx 155 | 156 | static func smaller(a : EmoteLocation, b : EmoteLocation): 157 | return a.start < b.start 158 | -------------------------------------------------------------------------------- /addons/gift/irc_connection.gd: -------------------------------------------------------------------------------- 1 | class_name TwitchIRCConnection 2 | extends RefCounted 3 | 4 | signal connection_state_changed(state) 5 | signal chat_message(sender_data, message) 6 | signal whisper_message(sender_data, message) 7 | signal channel_data_received(room) 8 | signal login_attempt(success) 9 | 10 | signal unhandled_message(message, tags) 11 | 12 | enum ConnectionState { 13 | DISCONNECTED, 14 | CONNECTED, 15 | CONNECTION_FAILED, 16 | RECONNECTING 17 | } 18 | 19 | var connection_state : ConnectionState = ConnectionState.DISCONNECTED: 20 | set(new_state): 21 | connection_state = new_state 22 | connection_state_changed.emit(new_state) 23 | 24 | var websocket : WebSocketPeer 25 | # Timestamp of the last message sent. 26 | var last_msg : int = Time.get_ticks_msec() 27 | # Time to wait in msec after each sent chat message. Values below ~310 might lead to a disconnect after 100 messages. 28 | var chat_timeout_ms : int = 320 29 | # Twitch disconnects connected clients if too many chat messages are being sent. (At about 100 messages/30s). 30 | # This queue makes sure messages aren't sent too quickly. 31 | var chat_queue : Array[String] = [] 32 | # Mapping of channels to their channel info, like available badges. 33 | var channels : Dictionary = {} 34 | # Last Userstate of the bot for channels. Contains -> entries. 35 | var last_state : Dictionary = {} 36 | var user_regex : RegEx = RegEx.create_from_string("(?<=!)[\\w]*(?=@)") 37 | 38 | var id : TwitchIDConnection 39 | 40 | var last_username : String 41 | var last_token : UserAccessToken 42 | var last_caps : PackedStringArray 43 | 44 | func _init(twitch_id_connection : TwitchIDConnection) -> void: 45 | id = twitch_id_connection 46 | id.polled.connect(poll) 47 | 48 | # Connect to Twitch IRC. Returns true on success, false if connection fails. 49 | func connect_to_irc(username : String) -> bool: 50 | last_username = username 51 | websocket = WebSocketPeer.new() 52 | websocket.connect_to_url("wss://irc-ws.chat.twitch.tv:443") 53 | print("Connecting to Twitch IRC.") 54 | if (await(connection_state_changed) != ConnectionState.CONNECTED): 55 | return false 56 | send("PASS oauth:%s" % id.last_token.token, true) 57 | send("NICK " + username.to_lower()) 58 | if (await(login_attempt)): 59 | print("Connected.") 60 | return true 61 | return false 62 | 63 | func poll() -> void: 64 | if (websocket != null && connection_state != ConnectionState.CONNECTION_FAILED && connection_state != ConnectionState.RECONNECTING): 65 | websocket.poll() 66 | var state := websocket.get_ready_state() 67 | match state: 68 | WebSocketPeer.STATE_OPEN: 69 | if (connection_state == ConnectionState.DISCONNECTED): 70 | connection_state = ConnectionState.CONNECTED 71 | print("Connected to Twitch.") 72 | else: 73 | while (websocket.get_available_packet_count()): 74 | data_received(websocket.get_packet()) 75 | if (!chat_queue.is_empty() && (last_msg + chat_timeout_ms) <= Time.get_ticks_msec()): 76 | send(chat_queue.pop_front()) 77 | last_msg = Time.get_ticks_msec() 78 | WebSocketPeer.STATE_CLOSED: 79 | if (connection_state == ConnectionState.DISCONNECTED): 80 | print("Could not connect to Twitch.") 81 | connection_state = ConnectionState.CONNECTION_FAILED 82 | elif(connection_state == ConnectionState.RECONNECTING): 83 | print("Reconnecting to Twitch...") 84 | await(reconnect()) 85 | else: 86 | connection_state = ConnectionState.DISCONNECTED 87 | print("Disconnected from Twitch. [%s]: %s"%[websocket.get_close_code(), websocket.get_close_reason()]) 88 | 89 | func data_received(data : PackedByteArray) -> void: 90 | var messages : PackedStringArray = data.get_string_from_utf8().strip_edges(false).split("\r\n") 91 | var tags = {} 92 | for message in messages: 93 | if(message.begins_with("@")): 94 | var msg : PackedStringArray = message.split(" ", false, 1) 95 | message = msg[1] 96 | for tag in msg[0].split(";"): 97 | var pair = tag.split("=") 98 | tags[pair[0]] = pair[1] 99 | if (OS.is_debug_build()): 100 | print("> " + message) 101 | handle_message(message, tags) 102 | 103 | # Sends a String to Twitch. 104 | func send(text : String, token : bool = false) -> void: 105 | websocket.send_text(text) 106 | if(OS.is_debug_build()): 107 | if(!token): 108 | print("< " + text.strip_edges(false)) 109 | else: 110 | print("< PASS oauth:******************************") 111 | 112 | # Request capabilities from twitch. 113 | func request_capabilities(caps : PackedStringArray = ["twitch.tv/commands", "twitch.tv/tags"]) -> void: 114 | last_caps = caps 115 | send("CAP REQ :" + " ".join(caps)) 116 | 117 | # Sends a chat message to a channel. Defaults to the only connected channel. 118 | func chat(message : String, channel : String = ""): 119 | var keys : Array = channels.keys() 120 | if(channel != ""): 121 | if (channel.begins_with("#")): 122 | channel = channel.right(-1) 123 | chat_queue.append("PRIVMSG #" + channel + " :" + message + "\r\n") 124 | if (last_state.has(channel)): 125 | chat_message.emit(SenderData.new(last_state[channel]["display-name"], channel, last_state[channels.keys()[0]]), message) 126 | elif(keys.size() == 1): 127 | chat_queue.append("PRIVMSG #" + channels.keys()[0] + " :" + message + "\r\n") 128 | if (last_state.has(channels.keys()[0])): 129 | chat_message.emit(SenderData.new(last_state[channels.keys()[0]]["display-name"], channels.keys()[0], last_state[channels.keys()[0]]), message) 130 | else: 131 | print("No channel specified.") 132 | 133 | func handle_message(message : String, tags : Dictionary) -> void: 134 | if(message == "PING :tmi.twitch.tv"): 135 | send("PONG :tmi.twitch.tv") 136 | return 137 | var msg : PackedStringArray = message.split(" ", true, 3) 138 | match msg[1]: 139 | "NOTICE": 140 | var info : String = msg[3].right(-1) 141 | if (info == "Login authentication failed" || info == "Login unsuccessful"): 142 | print("Authentication failed.") 143 | login_attempt.emit(false) 144 | elif (info == "You don't have permission to perform that action"): 145 | print("No permission. Attempting to obtain new token.") 146 | id.refresh_token() 147 | var success : bool = await(id.token_refreshed) 148 | if (!success): 149 | print("Please check if you have all required scopes.") 150 | websocket.close(1000, "Token became invalid.") 151 | return 152 | connection_state = ConnectionState.RECONNECTING 153 | else: 154 | unhandled_message.emit(message, tags) 155 | "001": 156 | print("Authentication successful.") 157 | login_attempt.emit(true) 158 | "PRIVMSG": 159 | var sender_data : SenderData = SenderData.new(user_regex.search(msg[0]).get_string(), msg[2], tags) 160 | chat_message.emit(sender_data, msg[3].right(-1)) 161 | "WHISPER": 162 | var sender_data : SenderData = SenderData.new(user_regex.search(msg[0]).get_string(), msg[2], tags) 163 | whisper_message.emit(sender_data, msg[3].right(-1)) 164 | "RECONNECT": 165 | connection_state = ConnectionState.RECONNECTING 166 | "USERSTATE", "ROOMSTATE": 167 | var room = msg[2].right(-1) 168 | if (!last_state.has(room)): 169 | last_state[room] = tags 170 | channel_data_received.emit(room) 171 | else: 172 | for key in tags: 173 | last_state[room][key] = tags[key] 174 | _: 175 | unhandled_message.emit(message, tags) 176 | 177 | func join_channel(channel : String) -> void: 178 | var lower_channel : String = channel.to_lower() 179 | channels[lower_channel] = {} 180 | send("JOIN #" + lower_channel) 181 | 182 | func leave_channel(channel : String) -> void: 183 | var lower_channel : String = channel.to_lower() 184 | send("PART #" + lower_channel) 185 | channels.erase(lower_channel) 186 | 187 | func reconnect() -> void: 188 | if(await(connect_to_irc(last_username))): 189 | request_capabilities(last_caps) 190 | for channel in channels.keys(): 191 | join_channel(channel) 192 | connection_state = ConnectionState.CONNECTED 193 | --------------------------------------------------------------------------------