TWITCH INTEGRATION (#13)

Implemented a basic Twitch Connection, + Twitch Receive Chat and Twitch Send Chat

Co-authored-by: Eroax <eroaxe.business@gmail.com>
Reviewed-on: https://codeberg.org/Eroax/StreamGraph/pulls/13
This commit is contained in:
Eroax 2023-12-08 09:53:06 +00:00
parent dbdda4614a
commit c071e4d644
18 changed files with 709 additions and 3 deletions

View file

@ -0,0 +1,166 @@
extends "res://addons/no_twitch/websocket_client.gd"
## Wrapper class around [Websocket_Client] that handles Twitch Chat
signal chat_received
signal chat_received_raw
signal chat_received_rich
signal chat_connected
var channels : Array[String]
var extra_info : bool
var user_regex := RegEx.new()
var user_pattern := r":([\w]+)!"
func _init():
packet_received.connect(data_received)
user_regex.compile(user_pattern)
## Connects to the Twitch IRC server.
func connect_to_chat(token, extra = false, nick = "terribletwitch"):
extra_info = extra
connect_to_url("wss://irc-ws.chat.twitch.tv:443")
await socket_open
send_text("PASS oauth:" + token)
send_text("NICK " + nick)
if extra:
send_text("CAP REQ :twitch.tv/commands twitch.tv/tags")
## Handles checking the received packet from [signal Websocket_Client.packet_received]
func data_received(packet : PackedByteArray):
# Gets the text from the packet, strips the end and splits the different lines
var messages = packet.get_string_from_utf8().strip_edges(false).split("\r\n")
for msg in messages:
# Checks if this is a message that has tags enabled and if so, parses out the tags.
var tags : Dictionary
if msg.begins_with("@"):
# Grabs the actual end of the string with the message in it.
var real_msg = msg.split(" ", false, 1)
msg = real_msg[1]
# Loops through all the tags splitting them up by ; and then by = to get the keys and values.
for tag in real_msg[0].split(";"):
var key_value = tag.split("=")
tags[key_value[0]] = key_value[1]
parse_chat_msg(msg, tags)
#@badge-info=subscriber/34;badges=broadcaster/1,subscriber/6,game-developer/1;client-nonce=b5009ae3ee034a7706d86fe221882925;color=#2E8B57;display-name=EroAxee;emotes=;first-msg=0;flags=;id=be05dae8-4067-4edf-83f2-e6be02974904;mod=0;returning-chatter=0;room-id=160349129;subscriber=1;tmi-sent-ts=1702009303249;turbo=0;user-id=160349129;user-type= :eroaxee!eroaxee@eroaxee.tmi.twitch.tv PRIVMSG #eroaxee :More
## Parses the given [param msg] [String]
func parse_chat_msg(msg : String, tags : Dictionary):
var msg_dict : Dictionary
if msg == "PING :tmi.twitch.tv":
send_text(msg.replace("PING", "PONG"))
return
var msg_notice = msg.split(" ")[1]
match msg_notice:
"PRIVMSG":
var space_split = msg.split(" ", true, 3)
msg_dict["username"] = user_regex.search(msg).get_string(1)
msg_dict["message"] = space_split[3].trim_prefix(":")
msg_dict["channel"] = space_split[2].trim_prefix("#")
msg_dict.merge(parse_tags(tags))
prints(msg_dict.username, msg_dict.message, msg_dict.channel)
#(__username_regex.search(split[0]).get_string(1), split[3].right(1), split[2], tags)
if !tags.is_empty():
chat_received_rich.emit(msg_dict)
return
chat_received.emit(msg_dict)
# Connection Message
"001":
prints("Connection Established", msg)
chat_connected.emit()
# Chat Joining Message
"JOIN":
pass
# Chat Leaving Message
"PART":
pass
#@badge-info=subscriber/34;badges=broadcaster/1,subscriber/6,game-developer/1;client-nonce=02d73777ab1fab1aee33ada1830d52b5;color=#2E8B57;display-name=EroAxee;emotes=;first-msg=0;flags=;id=4ff91a8c-b965-43f8-85a1-ddd541a2b438;mod=0;returning-chatter=0;room-id=160349129;subscriber=1;tmi-sent-ts=1701850826667;turbo=0;user-id=160349129;user-type= :eroaxee!eroaxee@eroaxee.tmi.twitch.tv PRIVMSG #eroaxee :Stuff
## Utility function that takes a Dictionary of tags from a Twitch message and parses them to be slightly more usable.
func parse_tags(tags : Dictionary):
var new_tags : Dictionary
for all in tags.keys():
if all == "badges":
tags[all] = tags[all].split(",")
new_tags[all.replace("-", "_")] = tags[all]
return new_tags
#{ "@badge-info": "subscriber/34", "badges": "broadcaster/1,subscriber/6,game-developer/1", "client-nonce": "b2e3524806f51c94cadd61d338bc14ed", "color": "#2E8B57", "display-name": "EroAxee", "emotes": "", "first-msg": "0", "flags": "", "id": "494dc47e-0d9c-4407-83ec-309764e1adf3", "mod": "0", "returning-chatter": "0", "room-id": "160349129", "subscriber": "1", "tmi-sent-ts": "1701853794297", "turbo": "0", "user-id": "160349129", "user-type": "" }
## Wrapper function around [method WebSocketPeer.send_text]
func send_chat(msg : String, channel : String = ""):
if channel.is_empty():
channel = channels[0]
channel = channel.strip_edges()
send_text("PRIVMSG #" + channel + " :" + msg + "\r\n")
## Utility function that handles joining the supplied [param channel]'s chat.
func join_chat(channel : String):
send_text("JOIN #" + channel + "\r\n")
channels.append(channel)
## Utility function that handles leaving the supplied [param channel]'s chat.
func leave_chat(channel : String):
send_chat("PART #" + channel)
channels.erase(channel)

View file

@ -0,0 +1,33 @@
extends HBoxContainer
var channel : String
func _ready():
%Channel_Input.text_changed.connect(update_channel)
%Join_Chat.pressed.connect(join_chat)
%Start_Chat_Connection.pressed.connect(start_chat_connection)
%Chat_Msg.text_submitted.connect(send_chat)
%Send_Button.pressed.connect(func(): send_chat(%Chat_Msg.text))
func update_channel(new_channel):
channel = new_channel
func start_chat_connection():
%Twitch_Connection.setup_chat_connection(channel)
func join_chat():
%Twitch_Connection.join_channel(channel)
func send_chat(chat : String):
%Twitch_Connection.send_chat_to_channel(chat, %Channel.text)

View file

@ -0,0 +1,27 @@
extends Control
func _ready():
$Twitch_Connection.token_received.connect(save_token)
load_token()
func save_token(token):
var res = TokenSaver.new()
res.token = token
ResourceSaver.save(res, "user://token.tres")
func load_token():
if !FileAccess.file_exists("user://token.tres"):
return
var res = ResourceLoader.load("user://token.tres")
$Twitch_Connection.token = res.token

View file

@ -0,0 +1,81 @@
[gd_scene load_steps=5 format=3 uid="uid://dhss3lpo1mhke"]
[ext_resource type="Script" path="res://addons/no_twitch/twitch_connection.gd" id="1_13a4v"]
[ext_resource type="Script" path="res://addons/no_twitch/demo/demo_scene.gd" id="1_ebv0f"]
[ext_resource type="Script" path="res://addons/no_twitch/demo/test_button.gd" id="1_hhhwv"]
[ext_resource type="Script" path="res://addons/no_twitch/demo/Chat_Join.gd" id="2_b8f4l"]
[node name="Control" type="Control"]
layout_mode = 3
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_ebv0f")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -206.0
offset_top = -68.0
offset_right = 211.0
offset_bottom = 68.0
grow_horizontal = 2
grow_vertical = 2
[node name="Authenticate" type="Button" parent="VBoxContainer"]
layout_mode = 2
text = "Authenticate"
script = ExtResource("1_hhhwv")
[node name="Start_Chat_Connection" type="Button" parent="VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Start Connection"
[node name="Chat_Join" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
script = ExtResource("2_b8f4l")
[node name="Channel_Input" type="LineEdit" parent="VBoxContainer/Chat_Join"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Channel"
[node name="Join_Chat" type="Button" parent="VBoxContainer/Chat_Join"]
unique_name_in_owner = true
layout_mode = 2
text = "Join"
[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="Channel" type="LineEdit" parent="VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_stretch_ratio = 0.45
placeholder_text = "Channel"
[node name="Chat_Msg" type="LineEdit" parent="VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Chat Message"
[node name="Send_Button" type="Button" parent="VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Send"
[node name="Twitch_Connection" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("1_13a4v")

View file

@ -0,0 +1,9 @@
extends Button
@onready var twitch_connection : Twitch_Connection = $"../../Twitch_Connection"
func _pressed():
twitch_connection.authenticate_with_twitch()

View file

@ -0,0 +1,4 @@
extends Resource
class_name TokenSaver
@export var token : String

View file

@ -0,0 +1,7 @@
[plugin]
name="No Twitch"
description=""
author="Eroax"
version="0.0.1"
script="plugin.gd"

View file

@ -0,0 +1,12 @@
@tool
extends EditorPlugin
func _enter_tree():
# Initialization of the plugin goes here.
pass
func _exit_tree():
# Clean-up of the plugin goes here.
pass

View file

@ -0,0 +1,131 @@
extends Node
class_name Twitch_Connection
## Handler for a Connection to a Single Twitch Account
##
## Used for getting authentication and handling websocket connections.
signal token_received(token : String)
@export var client_id := "qyjg1mtby1ycs5scm1pvctos7yvyc1"
@export var redirect_uri := "http://localhost"
## Port that the redirect_uri will head to on your local system. Defaults to 80 for most cases. Linux tends to prefer 8000 or possibly 1338
@export var redirect_port := "80"
var twitch_url := "https://id.twitch.tv/oauth2/authorize?response_type=token&"
var auth_server : TCPServer
## Websocket used for handling the chat connection.
var chat_socket = preload("res://addons/no_twitch/chat_socket.gd").new()
var state := make_state()
var token : String
func _ready():
chat_socket.chat_received.connect(check_chat_socket)
## Handles the basic Twitch Authentication process to request and then later receive a Token (using [method check_auth_peer])
func authenticate_with_twitch(client_id = client_id):
auth_server = TCPServer.new()
OS.shell_open(create_auth_url())
auth_server.listen(int(redirect_port))
## Sets up a single chat connection. Joining 1 room with [param token] as it's "PASS" specifying what account it's on. And the optional [param default_chat] specifying the default room to join. While [param nick] specifies the "nickname" (Not the username on Twitch)
func setup_chat_connection(default_chat : String = "", token : String = token, request_twitch_info = true, nick = "terribletwitch"):
# Temporary, closes the socket to allow connecting multiple times without needing to reset.
chat_socket.close()
# Connects to the Twitch IRC server.
chat_socket.connect_to_chat(token, request_twitch_info)
await chat_socket.socket_open
if !default_chat.is_empty():
chat_socket.join_chat(default_chat)
## Joins the given [param channel] over IRC. Essentially just sending JOIN #[param channel]
func join_channel(channel : String):
chat_socket.join_chat(channel)
func send_chat(msg : String, channel : String = ""):
chat_socket.send_chat(msg, channel)
func _process(delta):
if auth_server and auth_server.is_listening() and auth_server.is_connection_available():
check_auth_peer(auth_server.take_connection())
if chat_socket:
chat_socket.poll_socket()
## Utility function for creating a Twitch Authentication URL with the given
## [param scopes], Twitch Client ID ([param id]) and [param redirect_uri].
## [param id] defaults to [member client_id] and both [param redirect] and
## [param redirect_port]
func create_auth_url(scopes : Array[String] = ["chat:read", "chat:edit"], port := redirect_port, id : String = client_id, redirect : String = redirect_uri):
var str_scopes : String
for all in scopes:
str_scopes += " " + all
str_scopes = str_scopes.strip_edges()
var url = twitch_url + "client_id=" + id + "&redirect_uri=" + redirect + "&scope=" + str_scopes + "&state=" + str(state)
return url
## Utility function for creating a "state" used for different requests for some extra security as a semi password.
func make_state(len : int = 16) -> String:
var crypto = Crypto.new()
var state = crypto.generate_random_bytes(len).hex_encode()
return state
func check_auth_peer(peer : StreamPeerTCP):
var info = peer.get_utf8_string(peer.get_available_bytes())
printraw(info)
var script = "<script>fetch('http://localhost/' + window.location.hash.substr(1))</script>"
peer.put_data(str("HTTP/1.1 200\n\n" + script).to_utf8_buffer())
var resp_state = info.split("&state=")
# Ensures that the received state is correct.
if !resp_state.size() > 1 or resp_state[0] == state:
return
var token = info.split("access_token=")[1].split("&scope=")[0].strip_edges()
printraw("Token: ", token, "\n")
self.token = token
token_received.emit(token)
func check_chat_socket(dict):
prints(dict.user, dict.message)

View file

@ -0,0 +1,58 @@
extends WebSocketPeer
class_name Websocket_Client
## Helper Class for handling the freaking polling of a WebSocketPeer
## Emitted when [method WebSocketPeer.get_ready_state()] returns
## [member WebSocketPeer.STATE_OPEN]
signal socket_open
## Emitted when [method WebSocketPeer.get_ready_state()] returns
## [member WebSocketPeer.STATE_CLOSED]
signal socket_closed(close_code, close_reason)
## Emitted when [method WebSocketPeer.get_ready_state()] returns
## [member WebSocketPeer.STATE_CONNECTING]
signal socket_connecting
## Emitted when [method WebSocketPeer.get_ready_state()] returns
## [member WebSocketPeer.STATE_CLOSING]
signal socket_closing
## Emitted when [method WebSocketPeer.get_available_packets()] returns greater
## than 0. Or, when a packet has been received.
signal packet_received(packet_data)
## Works as a wrapper around [method WebSocketPeer.poll] to handle the logic of
## checking get_ready_state() more simply.
func poll_socket():
poll()
var state = get_ready_state()
match state:
STATE_OPEN:
socket_open.emit(get_connected_host(), get_connected_port())
if get_available_packet_count() > 0:
packet_received.emit(get_packet())
STATE_CONNECTING:
socket_connecting.emit()
STATE_CLOSING:
socket_closing.emit()
STATE_CLOSED:
socket_closed.emit(get_close_code(), get_close_reason())

View file

@ -1,3 +1,9 @@
class_name Connections
static var obs_websocket
static var twitch
static func _twitch_chat_received(msg_dict : Dictionary):
DeckHolder.send_event(&"twitch_chat", msg_dict)

View file

@ -0,0 +1,28 @@
extends DeckNode
func _init():
name = "Twitch Chat Received"
node_type = "twitch_chat_received"
description = "Receives Twitch Chat Events from a Twitch Connection"
category = "twitch"
add_output_port(DeckType.Types.STRING, "Username")
add_output_port(DeckType.Types.STRING, "Message")
add_output_port(DeckType.Types.STRING, "Channel")
add_output_port(DeckType.Types.DICTIONARY, "Tags")
func _event_received(event_name : StringName, event_data : Dictionary = {}):
if event_name != &"twitch_chat":
return
send(0, event_data.username)
send(1, event_data.message)
send(2, event_data.channel)
send(3, event_data)

View file

@ -0,0 +1,57 @@
extends DeckNode
func _init():
name = "Twitch Send Chat"
node_type = "twitch_send_chat"
description = "Sends Twitch chat Messages"
category = "twitch"
add_input_port(
DeckType.Types.STRING,
"Message",
"field"
)
add_input_port(
DeckType.Types.STRING,
"Channel",
"field"
)
add_input_port(
DeckType.Types.BOOL,
"Send",
"button"
)
func _receive(to_input_port, data: Variant, extra_data: Array = []):
if to_input_port != 2:
return
var msg = get_value_for_input_port(0)
var channel = get_value_for_input_port(1)
if channel == null:
channel = ""
if msg == null:
return
Connections.twitch.send_chat(msg, channel)
func get_value_for_input_port(port: int) -> Variant:
if request_value(port) != null:
return request_value(port)
elif get_input_ports()[port].value_callback.get_object() && get_input_ports()[port].value_callback.call() != null:
return get_input_ports()[port].value_callback.call()
else:
return null

View file

@ -32,6 +32,7 @@ enum FileMenuId {
enum ConnectionsMenuId {
OBS,
TWITCH,
}
## Weak Reference to the Deck that is currently going to be saved.
@ -40,9 +41,11 @@ var _deck_to_save: WeakRef
@onready var no_obsws: NoOBSWS = %NoOBSWS as NoOBSWS
@onready var obs_setup_dialog: OBSWebsocketSetupDialog = $OBSWebsocketSetupDialog as OBSWebsocketSetupDialog
@onready var twitch_setup_dialog : TwitchSetupDialog = $Twitch_Setup_Dialog as TwitchSetupDialog
func _ready() -> void:
tab_container.add_button_pressed.connect(add_empty_deck)
tab_container.tab_close_requested.connect(
@ -53,6 +56,10 @@ func _ready() -> void:
file_dialog.canceled.connect(disconnect_file_dialog_signals)
Connections.obs_websocket = no_obsws
Connections.twitch = %Twitch_Connection
Connections.twitch.chat_socket.chat_received_rich.connect(Connections._twitch_chat_received)
file_popup_menu.set_item_shortcut(FileMenuId.NEW, new_deck_shortcut)
file_popup_menu.set_item_shortcut(FileMenuId.OPEN, open_deck_shortcut)
@ -172,6 +179,8 @@ func _on_connections_id_pressed(id: int) -> void:
match id:
ConnectionsMenuId.OBS:
obs_setup_dialog.popup_centered()
ConnectionsMenuId.TWITCH:
twitch_setup_dialog.popup_centered()
func _on_obs_websocket_setup_dialog_connect_button_pressed(state: OBSWebsocketSetupDialog.ConnectionState) -> void:

View file

@ -1,10 +1,12 @@
[gd_scene load_steps=16 format=3 uid="uid://duaah5x0jhkn6"]
[gd_scene load_steps=18 format=3 uid="uid://duaah5x0jhkn6"]
[ext_resource type="Script" path="res://graph_node_renderer/deck_holder_renderer.gd" id="1_67g2g"]
[ext_resource type="PackedScene" uid="uid://b84f2ngtcm5b8" path="res://graph_node_renderer/tab_container_custom.tscn" id="1_s3ug2"]
[ext_resource type="Theme" uid="uid://dqqdqscid2iem" path="res://graph_node_renderer/default_theme.tres" id="1_tgul2"]
[ext_resource type="Script" path="res://addons/no-obs-ws/NoOBSWS.gd" id="4_nu72u"]
[ext_resource type="Script" path="res://addons/no_twitch/twitch_connection.gd" id="5_3n36q"]
[ext_resource type="PackedScene" uid="uid://eioso6jb42jy" path="res://graph_node_renderer/obs_websocket_setup_dialog.tscn" id="5_uo2gj"]
[ext_resource type="PackedScene" uid="uid://bq2lxmbnic4lc" path="res://graph_node_renderer/twitch_setup_dialog.tscn" id="7_7rhap"]
[sub_resource type="InputEventKey" id="InputEventKey_giamc"]
device = -1
@ -114,9 +116,11 @@ unique_name_in_owner = true
[node name="Connections" type="PopupMenu" parent="MarginContainer/VSplitContainer/VBoxContainer/MenuBar"]
unique_name_in_owner = true
item_count = 1
item_count = 2
item_0/text = "OBS..."
item_0/id = 0
item_1/text = "Twitch.."
item_1/id = 1
[node name="TabContainerCustom" parent="MarginContainer/VSplitContainer/VBoxContainer" instance=ExtResource("1_s3ug2")]
unique_name_in_owner = true
@ -137,7 +141,15 @@ use_native_dialog = true
unique_name_in_owner = true
script = ExtResource("4_nu72u")
[node name="Twitch_Connection" type="Node" parent="Connections"]
unique_name_in_owner = true
script = ExtResource("5_3n36q")
[node name="OBSWebsocketSetupDialog" parent="." instance=ExtResource("5_uo2gj")]
visible = false
[node name="Twitch_Setup_Dialog" parent="." instance=ExtResource("7_7rhap")]
visible = false
[connection signal="id_pressed" from="MarginContainer/VSplitContainer/VBoxContainer/MenuBar/File" to="." method="_on_file_id_pressed"]
[connection signal="id_pressed" from="MarginContainer/VSplitContainer/VBoxContainer/MenuBar/Connections" to="." method="_on_connections_id_pressed"]

View file

@ -0,0 +1,18 @@
extends ConfirmationDialog
class_name TwitchSetupDialog
func _ready():
%Authenticate.pressed.connect(authenticate_twitch)
confirmed.connect(connect_to_chat)
func authenticate_twitch():
Connections.twitch.authenticate_with_twitch(%Client_ID.text)
func connect_to_chat():
Connections.twitch.setup_chat_connection(%Default_Chat.text)

View file

@ -0,0 +1,48 @@
[gd_scene load_steps=2 format=3 uid="uid://bq2lxmbnic4lc"]
[ext_resource type="Script" path="res://graph_node_renderer/twitch_setup_dialog.gd" id="1_xx7my"]
[node name="twitch_setup_dialog" type="ConfirmationDialog"]
title = "Twitch Connection Setup"
position = Vector2i(0, 36)
size = Vector2i(375, 158)
visible = true
min_size = Vector2i(375, 150)
script = ExtResource("1_xx7my")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
anchors_preset = 5
anchor_left = 0.5
anchor_right = 0.5
offset_left = -179.5
offset_top = 8.0
offset_right = 179.5
offset_bottom = 109.0
grow_horizontal = 2
size_flags_horizontal = 4
[node name="Authentication" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="Client_ID" type="LineEdit" parent="VBoxContainer/Authentication"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
tooltip_text = "The Twitch Client ID used by this Connection for it's Authentication. Defaults to the built in Client ID that the program ships with."
placeholder_text = "Custom Client ID (Optional)"
[node name="Authenticate" type="Button" parent="VBoxContainer/Authentication"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_stretch_ratio = 0.25
text = "Authenticate"
[node name="CheckBox" type="CheckBox" parent="VBoxContainer"]
layout_mode = 2
text = "Extra Chat Info"
[node name="Default_Chat" type="LineEdit" parent="VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
placeholder_text = "Default Chat Channel"

View file

@ -25,7 +25,7 @@ NodeDB="*res://classes/deck/node_db.gd"
[editor_plugins]
enabled=PackedStringArray("res://addons/no-obs-ws/plugin.cfg")
enabled=PackedStringArray("res://addons/no-obs-ws/plugin.cfg", "res://addons/no_twitch/plugin.cfg")
[input]