deps: add TwiCil, a library to interact with Twitch chat

master
Dominique Merle 4 years ago
parent 339d26f5e0
commit 100fe4ba77

@ -0,0 +1,8 @@
tool
extends EditorPlugin
func _enter_tree():
add_custom_type("TwiCIL", "Node", preload("godot_twicil.gd"), preload("./sprites/twicil-icon.png"))
func _exit_tree():
remove_custom_type("TwiCIL")

@ -0,0 +1,9 @@
[gd_scene load_steps=1 format=2]
[ext_resource path="res://godot_twicil.gd" type="Script" id=1]
[node name="GodotTwiCIL" type="Node"]
script = ExtResource( 1 )
CONNECT_WAIT_TIMEOUT = 1
COMMAND_WAIT_TIMEOUT = 0.3

@ -0,0 +1,248 @@
extends IrcClientSecure
class_name TwiCIL
signal raw_response_recieved(response)
signal user_appeared(user)
signal user_disappeared(user)
signal message_recieved(sender, text, emotes)
signal emote_recieved(user, emote_reference)
signal texture_recieved(texture)
enum IRCCommands {PING, PONG, PRIVMSG, JOIN, PART, NAMES}
const TWITCH_IRC_CHAT_HOST = 'wss://irc-ws.chat.twitch.tv'
const TWITCH_IRC_CHAT_PORT = 443
#const CONNECT_WAIT_TIMEOUT = 1
#const COMMAND_WAIT_TIMEOUT = 1.5
onready var tools = HelperTools.new()
onready var commands = InteractiveCommands.new()
onready var chat_list = ChatList.new()
var twitch_emotes_cache: TwitchEmotesCache
var bttv_emotes_cache: BttvEmotesCache
var ffz_emotes_cache: FfzEmotesCache
var twitch_api_wrapper: TwitchApiWrapper
var irc_commands = {
IRCCommands.PING: 'PING',
IRCCommands.PONG: 'PONG',
IRCCommands.PRIVMSG: 'PRIVMSG',
IRCCommands.JOIN: 'JOIN',
IRCCommands.PART: 'PART',
IRCCommands.NAMES: '/NAMES'
}
var curr_channel = ""
#{
# "emote_id": ["user_name#1", "user_name#2", ...]
# ...
#}
var user_emotes_queue := Dictionary()
# Public methods
func connect_to_twitch_chat():
.connect_to_host(TWITCH_IRC_CHAT_HOST, TWITCH_IRC_CHAT_PORT)
func connect_to_channel(channel, client_id, password, nickname, realname=''):
_connect_to(
channel,
nickname,
nickname if realname == '' else realname,
password,
client_id
)
bttv_emotes_cache.init_emotes(curr_channel)
twitch_api_wrapper.set_credentials(client_id, password)
func _connect_to(channel, nickname, realname, password, client_id):
.send_command('PASS %s' % password)
.send_command('NICK ' + nickname)
.send_command(str('USER ', client_id, ' ', _host, ' bla:', realname))
.send_command('JOIN #' + channel)
.send_command("CAP REQ :twitch.tv/tags twitch.tv/commands twitch.tv/membership")
curr_channel = channel
func send_message(text):
.send_command(str('PRIVMSG #', curr_channel, ' :', text))
func send_whisper(recepient, text):
send_message(str('/w ', recepient, ' ', text))
func request_twitch_emote(user_name: String, id: int) -> void:
if not user_emotes_queue.has(id):
user_emotes_queue[id] = []
user_emotes_queue[id].append(user_name)
twitch_emotes_cache.get_emote(id)
func request_bttv_emote(user_name: String, code: String) -> void:
var id: String = bttv_emotes_cache.available_emotes.get(code)
if not user_emotes_queue.has(id):
user_emotes_queue[id] = []
user_emotes_queue[id].append(user_name)
bttv_emotes_cache.get_emote(code)
func request_ffz_emote(user_name: String, code: String) -> void:
var id: String = ffz_emotes_cache.available_emotes.get(code, {}).get('id', '')
if not user_emotes_queue.has(id):
user_emotes_queue[id] = []
user_emotes_queue[id].append(user_name)
ffz_emotes_cache.get_emote(code)
func request_emote_from(emotes: Array, user_name: String, index: int) -> void:
if emotes.empty():
return
var emote = emotes[index]
if emote.get('type') == TwitchMessage.EmoteType.TWITCH:
var emote_id := int(emote.get('id'))
request_twitch_emote(user_name, emote_id)
elif emote.get('type') == TwitchMessage.EmoteType.BTTV:
var emote_code := emote.get('code') as String
request_bttv_emote(user_name, emote_code)
elif emote.get('type') == TwitchMessage.EmoteType.FFZ:
var emote_code := emote.get('code') as String
request_ffz_emote(user_name, emote_code)
# Private methods
func __init_emotes_caches() -> void:
twitch_emotes_cache = TwitchEmotesCache.new()
add_child(twitch_emotes_cache)
bttv_emotes_cache = BttvEmotesCache.new()
add_child(bttv_emotes_cache)
ffz_emotes_cache = FfzEmotesCache.new()
add_child(ffz_emotes_cache)
func __init_twitch_api() -> void:
twitch_api_wrapper = TwitchApiWrapper.new(http_request_queue, '')
func __connect_signals():
connect("message_recieved", commands, "_on_message_recieved")
connect("response_recieved", self, "_on_response_recieved")
connect("http_response_recieved", self, "_on_http_response_recieved")
twitch_emotes_cache.connect("emote_retrieved", self, "_on_emote_retrieved")
bttv_emotes_cache.connect("emote_retrieved", self, "_on_emote_retrieved")
ffz_emotes_cache.connect("emote_retrieved", self, "_on_emote_retrieved")
twitch_api_wrapper.connect("api_user_info", self, "_on_twitch_api_api_user_info")
func __parse(string: String) -> TwitchIrcServerMessage:
var args = []
var twitch_prefix = ''
var prefix = ''
var trailing = []
var command
if string == null:
return TwitchIrcServerMessage.new('', '','', [])
if string.substr(0, 1) == '@':
var temp = tools.split_string(string.substr(1, string.length() - 1), ' ', 1)
twitch_prefix = temp[0]
string = temp[1]
if string.substr(0, 1) == ':':
var temp = tools.split_string(string.substr(1, string.length() - 1), ' ', 1)
prefix = temp[0]
string = temp[1]
if string.find(' :') != -1:
var temp = tools.split_string(string, ' :', 1)
string = temp[0]
trailing = temp[1]
args = tools.split_string(string, [' ', '\t', '\n'])
args.append(trailing)
else:
args = tools.split_string(string, [' ', '\t', '\n'])
command = args[0]
args.pop_front()
return TwitchIrcServerMessage.new(twitch_prefix, prefix, command, args)
# Hooks
func _ready():
__init_twitch_api()
__init_emotes_caches()
__connect_signals()
# Events
func _on_response_recieved(response):
emit_signal("raw_response_recieved", response)
for single_response in response.split('\n', false):
single_response = __parse(single_response.strip_edges(false))
# Ping-Pong with server to let it know we're alive
if single_response.command == irc_commands[IRCCommands.PING]:
.send_command(str(irc_commands[IRCCommands.PONG], ' ', single_response.params[0]))
# Message received
elif single_response.command == irc_commands[IRCCommands.PRIVMSG]:
var twitch_message: TwitchMessage = TwitchMessage.new(
single_response,
bttv_emotes_cache.available_emotes,
ffz_emotes_cache.available_emotes
)
emit_signal(
"message_recieved",
twitch_message.chat_message.name,
twitch_message.chat_message.text,
twitch_message.emotes
)
elif single_response.command == irc_commands[IRCCommands.JOIN]:
var user_name = MessageWrapper.get_sender_name(single_response)
chat_list.add_user(user_name)
._log(str(user_name, " has joined chat"))
emit_signal("user_appeared", user_name)
elif single_response.command == irc_commands[IRCCommands.PART]:
var user_name = MessageWrapper.get_sender_name(single_response)
chat_list.remove_user(user_name)
._log(str(user_name, " has left chat"))
emit_signal("user_disappeared", user_name)
func _on_emote_retrieved(emote_reference: Reference) -> void:
var emote_id: String = emote_reference.id
var user: String = (user_emotes_queue.get(emote_id, []) as Array).pop_front()
emit_signal("emote_recieved", user, emote_reference)
func _on_twitch_api_api_user_info(data):
var user_id := str(data.get('data', [{}])[0].get('id', 0))
ffz_emotes_cache.init_emotes(user_id)

@ -0,0 +1,88 @@
extends Object
class_name TwitchApiWrapper
signal api_response_recieved(rquest_id, response)
signal api_response_failed(response_code, http_headers)
signal api_user_info(data)
const API_REQUEST_USER_INFO = 'user_info'
const API_URLS = {
API_REQUEST_USER_INFO: {
'template': 'https://api.twitch.tv/helix/users?login={{login}}',
'params': [
'{{login}}'
]
}
}
var client_id := ''
var oauth := ''
var http_request_queue: HttpRequestQueue
# hooks
func _init(http_request_queue: HttpRequestQueue, client_id: String) -> void:
self.client_id = client_id
self.http_request_queue = http_request_queue
__connect_signals()
# public
func set_credentials(client_id: String, raw_oauth_string: String) -> void:
self.client_id = client_id
self.oauth = raw_oauth_string.split(':')[1]
func get_raw_response(request_id: String, url: String):
var headers: PoolStringArray = [
'Client-ID: ' + client_id,
'Authentication: Bearer ' + oauth
]
http_request_queue.enqueue_request(request_id, url, headers)
func get_api_url(url_id: String, params: Array) -> String:
var url: String
var url_info: Dictionary = API_URLS.get(url_id, {})
var url_template: String = url_info.get('template', '')
var url_params: Array = url_info.get('params', [])
if params.size() < url_params.size():
return str('Wrong params count. Expected ', url_params.size(), ' but got ', params.size(), ' instead.')
url = url_template
for i in range(url_params.size()):
url = url.replace(url_params[i], params[i])
return url
func get_user_info(user_name: String):
var url: String = get_api_url(API_REQUEST_USER_INFO, [user_name])
get_raw_response(API_REQUEST_USER_INFO, url)
# private
func __connect_signals() -> void:
http_request_queue.connect("request_completed_ex", self, "_on_http_request_queue_request_completed")
# events
func _on_http_request_queue_request_completed(id: String, result: int, response_code: int, http_headers: HttpHeaders, body: PoolByteArray) -> void:
if result == HTTPRequest.RESULT_SUCCESS:
if http_headers.get('Content-Type') == HttpHeaders.HTTP_CONTENT_TYPE_JSON_UTF8:
var data = parse_json(body.get_string_from_utf8())
emit_signal("api_response_recieved", id, data)
if response_code == 200:
match id:
API_REQUEST_USER_INFO:
emit_signal("api_user_info", data)
emit_signal("api_response_failed", response_code, http_headers)

@ -0,0 +1,102 @@
extends Node
class_name BaseEmotesCache
signal downloaded(content)
var http_request_queue: HttpRequestQueue
var ready_to_deliver_emotes := false
class DownloadedContent:
const CONTENT_TYPE_IMAGE_PNG = 'image/png'
const CONTENT_TYPE_IMAGE_JPEG = 'image/jpeg'
var id: String
var type: String
var data: PoolByteArray
var image: Image
func _init(id: String, type: String, data: PoolByteArray):
self.id = id
self.type = type
self.data = data
func get_image_from_data() -> Image:
var image: Image = Image.new()
if self.type == CONTENT_TYPE_IMAGE_PNG:
image.load_png_from_buffer(data)
elif self.type == CONTENT_TYPE_IMAGE_JPEG:
image.load_jpg_from_buffer(data)
return image
class BaseEmote:
const TEXTURE_NO_FLAGS = 0
static func create_texture_from_image(image: Image) -> ImageTexture:
var image_texture := ImageTexture.new()
image_texture.create_from_image(image)
image_texture.flags -= ImageTexture.FLAG_FILTER + ImageTexture.FLAG_REPEAT
return image_texture
# hooks
func _ready() -> void:
__initialize()
__initialize_http_request_queue()
__connect_signals()
func _downloaded(downloaded_content: BaseEmotesCache.DownloadedContent) -> void:
"""
Override to define behaviour on emote content downloaded.
"""
pass
func _get_emote_url(code: String) -> String:
"""
Override to prepare the emote retrieval URL by code.
"""
return ''
# private
func __initialize() -> void:
"""
Override for initialization, instead of _ready.
"""
pass
func __connect_signals() -> void:
http_request_queue.connect("request_completed", self, "_on_http_request_queue_request_complete")
func __initialize_http_request_queue() -> void:
http_request_queue = HttpRequestQueue.new()
add_child(http_request_queue)
func __cache_emote(code) -> void:
var url: String = _get_emote_url(code)
__download(code, url)
func __download(id: String, url: String) -> void:
http_request_queue.enqueue_request(id, url)
# events
func _on_http_request_queue_request_complete(id: String, result: int, response_code: int, headers: PoolStringArray, body: PoolByteArray) -> void:
var downloaded_content := DownloadedContent.new(id, '', body)
if result == HTTPRequest.RESULT_SUCCESS:
var pretty_headers := HttpHeaders.new(headers)
var content_type := pretty_headers.headers.get('Content-Type') as String
downloaded_content.type = content_type
# TODO: Convert the image by it's conntent type right away!
_downloaded(downloaded_content)

@ -0,0 +1,112 @@
extends BaseEmotesCache
class_name BttvEmotesCache
signal emote_retrieved(emote)
class BttvEmote:
var id: String
var code: String
var texture: ImageTexture
func _init(id: String, code: String, image: Image):
self.id = id
self.code = code
self.texture = BaseEmotesCache.BaseEmote.create_texture_from_image(image)
const DEFAULT_URL_PROTOCOL = 'https://'
const CHANNEL_NAME_PLACEHOLDER = '{{channel_name}}'
const EMOTE_ID_PLACEHOLDER = '{{id}}'
const EMOTE_SIZE_PLACEHOLDER = '{{image}}'
# Can be: 1x, 2x, 3x
const DEAFULT_EMOTE_IMAGE_SIZE = '1x'
const GLOBAL_EMOTES_REQUEST_ID = 'global_emotes'
const CHANNEL_EMOTES_REQUEST_ID = 'channel_emotes'
const EMOTES_REQUEST_IDS = [GLOBAL_EMOTES_REQUEST_ID, CHANNEL_EMOTES_REQUEST_ID]
const GLOBAL_EMOTES_URL = 'https://api.betterttv.net/2/emotes/'
const CHANNEL_EMOTES_URL_TEMPLATE = 'https://api.betterttv.net/2/channels/{{channel_name}}/'
# {
# "code": "id"
# }
# code -- text to replace
# id -- internal bttv emote id
var available_emotes := Dictionary()
var emote_download_url_template: String
var cache := Dictionary()
var available_emotes_parsed_count := 0
# hooks
func _downloaded(downloaded_content: BaseEmotesCache.DownloadedContent) -> void:
if downloaded_content.id in EMOTES_REQUEST_IDS:
__parse_available_emotes(downloaded_content)
available_emotes_parsed_count += 1
ready_to_deliver_emotes = available_emotes_parsed_count >= EMOTES_REQUEST_IDS.size()
else:
var code: String = str(downloaded_content.id)
var id: String = available_emotes.get(code)
var image: Image = downloaded_content.get_image_from_data()
cache[code] = BttvEmote.new(id, code, image)
emit_signal("emote_retrieved", cache.get(code))
func _get_emote_url(code: String) -> String:
var id: String = available_emotes.get(code)
if not id:
return ''
var url := emote_download_url_template.replace(
EMOTE_ID_PLACEHOLDER, id
).replace(
EMOTE_SIZE_PLACEHOLDER, DEAFULT_EMOTE_IMAGE_SIZE
)
return url
# public
func init_emotes(channel_name: String) -> void:
http_request_queue.enqueue_request(GLOBAL_EMOTES_REQUEST_ID, GLOBAL_EMOTES_URL)
http_request_queue.enqueue_request(
CHANNEL_EMOTES_REQUEST_ID,
CHANNEL_EMOTES_URL_TEMPLATE.replace(CHANNEL_NAME_PLACEHOLDER, channel_name)
)
# public
func get_emote(code: String) -> void:
if not ready_to_deliver_emotes:
return
if cache.has(code):
emit_signal("emote_retrieved", cache.get(code))
else:
__cache_emote(code)
func get_available_emotes_codes() -> Array:
return available_emotes.keys()
# private
# private
func __parse_available_emotes(download_content: BaseEmotesCache.DownloadedContent) -> void:
if download_content.type != HttpHeaders.HTTP_CONTENT_TYPE_JSON_UTF8:
return
var data = parse_json(download_content.data.get_string_from_utf8())
emote_download_url_template = data.get('urlTemplate', '').replace('//', DEFAULT_URL_PROTOCOL)
for emote in data.get('emotes', []):
available_emotes[emote.get('code')] = emote.get('id')

@ -0,0 +1,117 @@
extends BaseEmotesCache
class_name FfzEmotesCache
signal emote_retrieved(emote)
class FfzEmote:
var id: String
var code: String
var texture: ImageTexture
func _init(id: String, code: String, image: Image):
self.id = id
self.code = code
self.texture = BaseEmotesCache.BaseEmote.create_texture_from_image(image)
const DEFAULT_URL_PROTOCOL = 'https://'
const USER_ID_PLACEHOLDER = '{{user_id}}'
const EMOTE_ID_PLACEHOLDER = '{{id}}'
const EMOTE_SIZE_PLACEHOLDER = '{{image}}'
# Can be: 1x, 2x, 3x
const DEAFULT_EMOTE_IMAGE_SIZE = '1x'
const GLOBAL_EMOTES_REQUEST_ID = 'global_emotes'
const CHANNEL_EMOTES_REQUEST_ID = 'channel_emotes'
const EMOTES_REQUEST_IDS = [GLOBAL_EMOTES_REQUEST_ID, CHANNEL_EMOTES_REQUEST_ID]
const GLOBAL_EMOTES_URL = 'https://api.frankerfacez.com/v1/set/global'
const CHANNEL_EMOTES_URL_TEMPLATE = 'https://api.frankerfacez.com/v1/room/id/{{user_id}}'
# {
# "code": {
# "id": "",
# "url": ""
# }
# }
# code -- text to replace
# id -- internal ffz emote id
# url -- direct image url
var available_emotes := Dictionary()
var cache := Dictionary()
var available_emotes_parsed_count := 0
var user_id: String
# hooks
func _downloaded(downloaded_content: BaseEmotesCache.DownloadedContent) -> void:
if downloaded_content.id in EMOTES_REQUEST_IDS:
__parse_available_emotes(downloaded_content)
available_emotes_parsed_count += 1
ready_to_deliver_emotes = available_emotes_parsed_count >= EMOTES_REQUEST_IDS.size()
else:
var code: String = str(downloaded_content.id)
var id: String = available_emotes.get(code, {}).get('id')
var image: Image = downloaded_content.get_image_from_data()
cache[code] = FfzEmote.new(id, code, image)
emit_signal("emote_retrieved", cache.get(code))
func _get_emote_url(code: String) -> String:
var url: String = available_emotes.get(code, {}).get('url', '')
return url
# public
func init_emotes(user_id: String, force: bool=false) -> void:
if self.user_id == null or self.user_id != user_id or force:
user_id = user_id
http_request_queue.enqueue_request(GLOBAL_EMOTES_REQUEST_ID, GLOBAL_EMOTES_URL)
http_request_queue.enqueue_request(
CHANNEL_EMOTES_REQUEST_ID,
CHANNEL_EMOTES_URL_TEMPLATE.replace(USER_ID_PLACEHOLDER, user_id)
)
# public
func get_emote(code: String) -> void:
if not ready_to_deliver_emotes:
return
if cache.has(code):
emit_signal("emote_retrieved", cache.get(code))
else:
__cache_emote(code)
func get_available_emotes_codes() -> Array:
return available_emotes.keys()
# private
func __parse_available_emotes(download_content: BaseEmotesCache.DownloadedContent) -> void:
if download_content.type != HttpHeaders.HTTP_CONTENT_TYPE_JSON:
return
var data = parse_json(download_content.data.get_string_from_utf8())
var sets := data.get('sets') as Dictionary
for set in sets.values():
var emotes := set.get('emoticons') as Array
for emote in emotes:
var emote_url: String = emote.get('urls', {}).get('1', '').replace(
'//', DEFAULT_URL_PROTOCOL
)
var id := str(emote.get('id'), '')
available_emotes[emote.get('name')] = {
'id': id,
'url': emote_url
}

@ -0,0 +1,59 @@
extends BaseEmotesCache
class_name TwitchEmotesCache
signal emote_retrieved(emote)
class TwitchEmote:
var id: int
var code: String
var texture: ImageTexture
func _init(id: int, code: String, image: Image):
self.id = id
self.code = code
self.texture = BaseEmotesCache.BaseEmote.create_texture_from_image(image)
const EMOTE_URL_TEMPLATE = 'https://static-cdn.jtvnw.net/emoticons/v1/{emote_id}/1.0'
# { id: Emote
# ...
# }
var cache := Dictionary()
# hooks
func _ready():
._ready()
ready_to_deliver_emotes = true
# public
func get_emote(id: int):
if not ready_to_deliver_emotes:
return
if cache.has(id):
emit_signal("emote_retrieved", cache.get(id))
else:
__cache_emote(str(id))
# hooks
func _get_emote_url(code) -> String:
var string_id := str(code)
var url := EMOTE_URL_TEMPLATE.replace('{emote_id}', string_id)
return url
func _downloaded(downloaded_content: BaseEmotesCache.DownloadedContent) -> void:
var id_ := int(downloaded_content.id)
var image: Image = downloaded_content.get_image_from_data()
cache[id_] = TwitchEmote.new(id_, '', image)
emit_signal("emote_retrieved", cache.get(id_))

@ -0,0 +1,32 @@
class_name ChatList
var __list := Dictionary()
func add_user(name: String) -> void:
if name in __list:
return
__list[name] = ChatUser.new(name)
func remove_user(name: String) -> void:
if name in __list:
__list.erase(name)
func get_user_details(name: String) -> ChatUser:
if name in __list:
return __list[name] as ChatUser
return null
func get_names() -> Array:
return __list.keys()
func size() -> int:
return __list.size()
func clear() -> void:
__list.clear()
func has(name: String) -> bool:
return name in __list

@ -0,0 +1,8 @@
class_name ChatUser
var name: String
func _init(name: String):
self.name = name

@ -0,0 +1,33 @@
extends Object
class_name HttpHeaders
const HTTP_CONTENT_TYPE_JSON_UTF8 = 'application/json; charset=utf-8'
const HTTP_CONTENT_TYPE_JSON = 'application/json'
var headers: Dictionary
func _init(raw_headers: PoolStringArray):
for raw_header in raw_headers:
var header_parts := (raw_header as String).split(':', true, 1) as Array
var header_name := (header_parts[0] as String).lstrip(' ').rstrip(' ')
var header_value := (header_parts[1] as String).lstrip(' ').rstrip(' ')
headers[header_name] = header_value
func get(key: String, ignore_case: bool=true) -> String:
for header_key in headers:
if header_key.to_lower() == key.to_lower():
return headers.get(header_key)
return '{no such header}'
static func to_pool_string_array(headers: Dictionary) -> PoolStringArray:
var raw_headers: PoolStringArray
for header in headers:
var header_value: String = headers.get(header)
raw_headers.append(header + ': ' + header_value)
return raw_headers

@ -0,0 +1,78 @@
extends Node
class_name HttpRequestQueue
signal http_response_recieved(content_type, body)
signal http_response_failed(error_code)
signal request_completed(id, result, response_code, headers, body)
signal request_completed_ex(id, result, response_code, http_headers, body)
const REQUEST_ID_NO_ID = '{no_id}'
var _http_request: HTTPRequest
var request_queue = Array()
var busy = false
var current_request_id: String = REQUEST_ID_NO_ID
# hooks
func _ready() -> void:
__initialize_http_request()
# public
func enqueue_request(id: String, url: String, headers: PoolStringArray=PoolStringArray()) -> void:
request_queue.append({'id': id, 'url': url, 'headers': headers})
if not busy:
__process_request_queue()
# private
func __initialize_http_request() -> void:
_http_request = HTTPRequest.new()
add_child(_http_request)
_http_request.use_threads = true
_http_request.connect("request_completed", self, "_on_http_request_completed")
# private
func __process_request_queue() -> void:
if request_queue.empty():
busy = false
return
if busy:
return
busy = true
var request_data := request_queue.pop_front() as Dictionary
var request_url: String = request_data.get('url')
var request_headers: PoolStringArray = request_data.get('headers')
current_request_id = request_data.get('id')
_http_request.request(request_url, request_headers)
# events
func _on_http_request_completed(result: int, response_code: int, headers: PoolStringArray, body: PoolByteArray) -> void:
var http_headers := HttpHeaders.new(headers)
emit_signal("request_completed", current_request_id, result, response_code, headers, body)
emit_signal("request_completed_ex", current_request_id, result, response_code, http_headers, body)
if result == HTTPRequest.RESULT_SUCCESS:
var content_type := http_headers.get('Content-Type') as String
emit_signal("http_response_recieved", content_type, body)
else:
emit_signal("http_response_failed", response_code)
current_request_id = REQUEST_ID_NO_ID
busy = false
__process_request_queue()

@ -0,0 +1,68 @@
class_name InteractiveCommands
class FuncRefEx extends FuncRef:
func _init(instance: Object, method: String):
.set_instance(instance)
.set_function(method)
class InteractiveCommand:
var func_ref: FuncRef
var params_count: int
var variable_params_count: int
func _init(func_ref: FuncRef, params_count: int, variable_params_count: bool=false):
self.func_ref = func_ref
self.params_count = params_count
self.variable_params_count = variable_params_count
func call_command(params: Array) -> void:
func_ref.call_func(params)
var interactive_commands = {}
# Public methods
func add(
chat_command: String,
target: Object,
method_name: String,
params_count: int=1,
variable_params_count: bool=false
) -> void:
interactive_commands[chat_command] = InteractiveCommand.new(
FuncRefEx.new(target, method_name) as FuncRef, params_count, variable_params_count)
func add_aliases(chat_command: String, new_aliases: Array) -> void:
if interactive_commands.has(chat_command):
for new_alias in new_aliases:
interactive_commands[new_alias] = interactive_commands[chat_command]
func remove(chat_command: String) -> void:
if interactive_commands.has(chat_command):
interactive_commands[chat_command]
interactive_commands.erase(chat_command)
# Events
func _on_message_recieved(sender: String, text: String, emotes: Array) -> void:
var input_cmd: Array = text.split(' ')
for cmd in interactive_commands:
if input_cmd[0] == cmd:
if not interactive_commands[cmd].variable_params_count \
and input_cmd.size() - 1 < interactive_commands[cmd].params_count:
# TODO: React to invalid command params in chat
return
var params: Array = [sender]
var params_count: int = clamp(
input_cmd.size() - 1,
0,
interactive_commands[cmd].params_count
)
if params_count >= 1:
for i in range(params_count):
params.append(input_cmd[i + 1])
interactive_commands[cmd].call_command(params)

@ -0,0 +1,9 @@
class_name IrcChatMessage
var name: String
var text: String
func _init(name: String, text: String):
self.name = name
self.text = text

@ -0,0 +1,138 @@
extends Node
class_name IrcClientEx
signal response_recieved(response)
signal http_response_recieved(type, response)
signal http_response_failed(error_code)
export(float) var CONNECT_WAIT_TIMEOUT = 1
export(float) var COMMAND_WAIT_TIMEOUT = 0.3
onready var __stream_peer = StreamPeerTCP.new()
onready var queue := Queue.new()
var http_request_queue: HttpRequestQueue
#onready var processing_thread = Thread.new()
var processing = false
var _host: String
var _port: int
var __time_passed := 0.0
var __last_command_time := 0.0
var __log := false
# public
func set_logging(state: bool) -> void:
__log = state
func connect_to_host(host: String, port: int) -> bool:
_host = host
_port = port
return __stream_peer.connect_to_host(_host, _port) == OK
func send_command(command: String) -> void:
queue.append(command)
func abort_processing() -> void:
processing = false
# private
func _log(text: String) -> void:
if __log:
prints('[%s] %s' % [__get_time_str(), text])
func __get_time_str() -> String:
var time = OS.get_time()
return str(time.hour, ':', time.minute, ':', time.second)
func __send_command(command: String) -> void:
var command_chunck_bytes := PoolByteArray()
var chunck_size := 8
var chuncks_count: int = command.length() / chunck_size
var appendix_length: int = command.length() % chunck_size
_log('<< %s' % command)
for i in range(chuncks_count):
command_chunck_bytes = command.substr(i * chunck_size, chunck_size).to_utf8()
__stream_peer.put_data(command_chunck_bytes)
if appendix_length > 0:
command_chunck_bytes = command.substr(chunck_size * chuncks_count, appendix_length).to_utf8()
__stream_peer.put_data(command_chunck_bytes)
command_chunck_bytes = ('\r\n').to_utf8()
__stream_peer.put_data(command_chunck_bytes)
func __process() -> void:
while processing:
__process_commands()
__process_input()
func __process_commands() -> void:
if queue.is_empty() or \
__time_passed - __last_command_time < COMMAND_WAIT_TIMEOUT:
return
__send_command(queue.pop_next() as String)
__last_command_time = __time_passed
func __process_input() -> void:
var bytes_available: int = __stream_peer.get_available_bytes()
if not (__stream_peer.is_connected_to_host() and bytes_available > 0):
return
var data := __stream_peer.get_utf8_string(bytes_available) as String
_log('>> %s' % data)
emit_signal('response_recieved', data)
func __parse_server_message(data):
pass
func __initialize_http_request_queue() -> void:
http_request_queue = HttpRequestQueue.new()
add_child(http_request_queue)
# http_request_queue.connect("http_response_recieved", self, "_on_http_response_recieved")
# hooks
func _ready() -> void:
set_process(true)
__initialize_http_request_queue()
# processing_thread.start(self, "__process")
func _process(delta: float) -> void:
__time_passed += delta
processing = __time_passed > CONNECT_WAIT_TIMEOUT
if not processing:
return
__process_commands()
__process_input()
# events
func _on_http_response_recieved(content_type: String, data: PoolByteArray) -> void:
emit_signal("http_response_recieved", content_type, data)
func _on_http_response_failed(error_code: int) -> void:
emit_signal("http_response_failed", error_code)

@ -0,0 +1,140 @@
extends Node
class_name IrcClientSecure
signal response_recieved(response)
signal http_response_recieved(type, response)
signal http_response_failed(error_code)
export(float) var CONNECT_WAIT_TIMEOUT = 2.0
export(float) var COMMAND_WAIT_TIMEOUT = 0.3
onready var __websocket_client = WebSocketClient.new()
onready var command_queue := Array()
var http_request_queue: HttpRequestQueue
var __websocket_peer: WebSocketPeer
var processing = false
var _host: String
var _port: int
var __time_passed := 0.0
var __last_command_time := 0.0
var connection_status: int = -1
var __log := false
# public
func set_logging(state: bool) -> void:
__log = state
func connect_to_host(host: String, port: int) -> bool:
_host = host
_port = port
__websocket_client.set_verify_ssl_enabled(false)
var result: int = __websocket_client.connect_to_url(str(_host, ':', _port))
__websocket_peer = __websocket_client.get_peer(1)
__websocket_peer.set_write_mode(WebSocketPeer.WRITE_MODE_TEXT)
set_process(true)
return result == OK
func send_command(command: String) -> void:
command_queue.append(command)
# private
func _log(text: String) -> void:
if __log:
prints('[%s] %s' % [__get_time_str(), text])
func __get_time_str() -> String:
var time = OS.get_time()
return str(time.hour, ':', time.minute, ':', time.second)
func __send_command(command: String) -> int:
var result: int = __websocket_peer.put_packet(command.to_utf8())
return result
func __process_commands() -> void:
var next_command_time: bool = __time_passed - __last_command_time >= COMMAND_WAIT_TIMEOUT
if command_queue.empty() or not next_command_time:
return
__send_command(command_queue.pop_front() as String)
__last_command_time = __time_passed
func __process_incoming_data() -> void:
var available_packets_count := __websocket_peer.get_available_packet_count()
var recieved_string: String = ''
while available_packets_count > 0:
var packet = __websocket_peer.get_packet()
recieved_string += packet.get_string_from_utf8()
available_packets_count -= 1
if recieved_string:
_log('>> %s' % recieved_string)
emit_signal('response_recieved', recieved_string)
func __parse_server_message(data):
pass
func __initialize_http_request_queue() -> void:
http_request_queue = HttpRequestQueue.new()
add_child(http_request_queue)
# hooks
func _ready() -> void:
set_process(false)
__initialize_http_request_queue()
func _process(delta: float) -> void:
__time_passed += delta
if __websocket_client.get_connection_status() != connection_status:
connection_status = __websocket_client.get_connection_status()
if connection_status == WebSocketClient.CONNECTION_CONNECTING:
_log('Connecting to server...')
if connection_status == WebSocketClient.CONNECTION_CONNECTED:
_log('Connected.')
if connection_status == WebSocketClient.CONNECTION_DISCONNECTED:
_log('Disconnected.')
var is_connecting: bool = connection_status == WebSocketClient.CONNECTION_CONNECTING
var is_connected: bool = connection_status == WebSocketClient.CONNECTION_CONNECTED
if is_connecting or is_connected:
__websocket_client.poll()
var is_peer_connected: bool = __websocket_peer.is_connected_to_host()
if is_peer_connected and __time_passed > CONNECT_WAIT_TIMEOUT:
__process_commands()
__process_incoming_data()
# events
func _on_http_response_recieved(content_type: String, data: PoolByteArray) -> void:
emit_signal("http_response_recieved", content_type, data)
func _on_http_response_failed(error_code: int) -> void:
emit_signal("http_response_failed", error_code)

@ -0,0 +1,14 @@
class_name MessageWrapper
static func wrap(server_irc_message: TwitchIrcServerMessage) -> IrcChatMessage:
var res = IrcChatMessage.new('', '')
res.name = get_sender_name(server_irc_message)
res.text = server_irc_message.params[1]
res.text = res.text.substr(1, res.text.length() - 1)
return res
static func get_sender_name(server_irc_message: TwitchIrcServerMessage) -> String:
return server_irc_message.prefix.split('!')[0]

@ -0,0 +1,32 @@
extends Object
class_name Queue
var __queue = []
var busy = false
func append(element) -> void:
if busy:
return
busy = true
__queue.append(element)
busy = false
func pop_next():
if busy:
return
busy = true
var element = __queue[0]
__queue.pop_front()
busy = false
return element
func is_empty() -> bool:
return __queue.size() == 0

@ -0,0 +1,46 @@
class_name HelperTools
func __equals_string(str1: String, str2: String) -> bool:
return str1 == str2
func __equals_one_of_strings(str1: String, str_list: Array) -> bool:
return str1 in str_list
func split_string(string: String, splitter, splits_count: int=0):
var res: Array = []
var curr_substring := ''
var occurances := 0
var splitter_length := 1
var matches := FuncRef.new()
matches.set_instance(self)
if typeof(splitter) == TYPE_STRING:
matches.set_function('__equals_string')
splitter_length = splitter.length()
elif typeof(splitter) == TYPE_ARRAY:
matches.set_function('__equals_one_of_strings')
for i in range(string.length()):
if matches.call_func(string.substr(i, splitter_length), splitter):
# if curr_substring != '':
# res.append(curr_substring.substr(splitter_length, curr_substring.length() - splitter_length))
# else:
res.append(curr_substring)
curr_substring = ''
occurances += 1
if splits_count > 0 and occurances == splits_count:
res.append(string.substr(i + 1, string.length() - i - 1))
return res
continue
curr_substring += string[i]
res.append(curr_substring)
return res

@ -0,0 +1,13 @@
class_name TwitchIrcServerMessage
var message_prefix: String
var prefix: String
var command: String
var params: Array
func _init(message_prefix: String, prefix: String, command: String, params: Array):
self.message_prefix = message_prefix
self.prefix = prefix
self.command = command
self.params = params

@ -0,0 +1,88 @@
class_name TwitchMessage
enum EmoteType {TWITCH, BTTV, FFZ}
const emote_id_methods = {
EmoteType.BTTV: '__get_bttv_emote_id',
EmoteType.FFZ: '__get_ffz_emote_id'
}
var chat_message: IrcChatMessage
#[
# {
# 'code': 'emote_code',
# 'id': 'emote_id'
# 'type': 0 # EmoteType enum
# },
# ...
#]
#
var emotes: Array
func _init(server_irc_message: TwitchIrcServerMessage, bttv_emotes: Dictionary, ffz_emotes):
chat_message = MessageWrapper.wrap(server_irc_message)
emotes.clear()
__parse_twitch_emotes(server_irc_message.message_prefix)
__parse_bttv_emotes(bttv_emotes)
__parse_ffz_emotes(ffz_emotes)
# private
func __parse_twitch_emotes(message_prefix: String):
var prefix_params := message_prefix.split(';', false)
var emotes_param: String
for param in prefix_params:
if (param as String).begins_with('emotes'):
var emotes_prefix_param: Array = (param as String).split('=', false, 1)
if emotes_prefix_param.size() <= 1:
return
emotes_param = emotes_prefix_param[1]
for emote in emotes_param.split('/', false):
var emote_data: Array = emote.split(':', false)
var id := int(emote_data[0])
var positions: Array = emote_data[1].split(',', false)[0].split('-', false)
var start := int(positions[0])
var end := int(positions[1])
var code: String = chat_message.text.substr(start, end - start + 1)
emotes.append({
'id': id,
'code': code,
'type': EmoteType.TWITCH
})
static func __get_bttv_emote_id(available_emotes: Dictionary, emote_code: String):
return available_emotes.get(emote_code)
static func __get_ffz_emote_id(available_emotes: Dictionary, emote_code: String):
return available_emotes.get(emote_code, {}).get('id')
func __parse_emotes(available_emotes: Dictionary, type: int) -> void:
var message: String = ' ' + chat_message.text + ' '
for emote_code in available_emotes:
var parse_emote_code: String = ' ' + emote_code + ' '
if message.find(parse_emote_code) >= 0:
emotes.append({
'id': callv(emote_id_methods.get(type), [available_emotes, emote_code]),
'code': emote_code,
'type': type
})
func __parse_bttv_emotes(available_emotes: Dictionary) -> void:
__parse_emotes(available_emotes, EmoteType.BTTV)
func __parse_ffz_emotes(available_emotes: Dictionary) -> void:
__parse_emotes(available_emotes, EmoteType.FFZ)

@ -0,0 +1,7 @@
[plugin]
name="Godot TwiCIL"
description="GodotTwiCIL Godot Twitch Chat Interaction Layer"
author="Kyrylo Omelchenko"
version="2.0.0"
script="godot-twicil-init.gd"

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 B

@ -0,0 +1,34 @@
[remap]
importer="texture"
type="StreamTexture"
path="res://.import/twicil-icon.png-3d81cc01d9b335ea90c2f9002d704ad8.stex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/godot-twicil/sprites/twicil-icon.png"
dest_files=[ "res://.import/twicil-icon.png-3d81cc01d9b335ea90c2f9002d704ad8.stex" ]
[params]
compress/mode=0
compress/lossy_quality=0.7
compress/hdr_mode=0
compress/bptc_ldr=0
compress/normal_map=0
flags/repeat=0
flags/filter=true
flags/mipmaps=false
flags/anisotropic=false
flags/srgb=2
process/fix_alpha_border=true
process/premult_alpha=false
process/HDR_as_SRGB=false
process/invert_color=false
stream=false
size_limit=0
detect_3d=true
svg/scale=1.0
Loading…
Cancel
Save