461 lines
16 KiB
GDScript
461 lines
16 KiB
GDScript
@icon("res://addons/twitcher/assets/chat-icon.svg")
|
||
@tool
|
||
extends Twitcher
|
||
|
||
## @deprecated: Twitch plans to remove IRC so use the eventsub solution instead
|
||
class_name TwitchIRC
|
||
|
||
static var log: TwitchLogger = TwitchLogger.new("TwitchIRC")
|
||
|
||
## Sent when the bot or moderator removes all messages from the chat room or removes all messages for the specified user.
|
||
signal received_clearchat(channel_name: String, banned_or_timeout_user: String, tags: TwitchTags.ClearChat)
|
||
## Sent when the bot removes a single message from the chat room.
|
||
signal received_clearmsg(channel_name: String, chat_message_removed: String, tags: TwitchTags.ClearMsg)
|
||
## Sent after the bot authenticates with the server.
|
||
signal received_global_userstate(tags: TwitchTags.GlobalUserState)
|
||
## Sent when a channel starts or stops hosting viewers from another channel.
|
||
signal received_host_target(channel_being_hosted: String, hosting_channel: String, number_of_viewers: int)
|
||
## Sent to indicate the outcome of an action like banning a user.[br]
|
||
## handled = when the Twitcher already handled the message.
|
||
signal received_notice(channel_name: String, message: String, tags: TwitchTags.Notice, handled: bool)
|
||
## Sent when the Twitch IRC server needs to terminate the connection for maintenance reasons. This gives your bot a chance to perform minimal clean up and save state before the server terminates the connection. The amount of time between receiving the message and the server closing the connection is indeterminate.
|
||
signal received_reconnect
|
||
## Sent when the bot joins a channel or when the channel’s chat settings change.
|
||
signal received_roomstate(channel_name: String, tags: TwitchTags.Roomstate)
|
||
## Sent when events like someone subscribing to the channel occurs. [br]
|
||
## - A user subscribes to the channel, re-subscribes to the channel, or gifts a subscription to another user.[br]
|
||
## - Another broadcaster raids the channel. Raid is a Twitch feature that lets broadcasters send their viewers to another channel to help support and grow other members in the community.[br]
|
||
## - A viewer milestone is celebrated such as a new viewer chatting for the first time.
|
||
signal received_usernotice(channel_name: String, message: String, tags: TwitchTags.Usernotice)
|
||
## Sent when the bot joins a channel or sends a PRIVMSG message.
|
||
signal received_userstate(channel_name: String, tags: TwitchTags.Userstate)
|
||
## Sent when a WHISPER message is directed specifically to your bot. Your bot will never receive whispers sent to other users.[br]
|
||
## from_user - The user that’s sending the whisper message.[br]
|
||
## to_user - The user that’s receiving the whisper message.
|
||
signal received_whisper(from_user: String, to_user: String, message: String, tags: TwitchTags.Whisper)
|
||
## The Twitch IRC server sends this message after a user posts a message to the chat room.
|
||
signal received_privmsg(channel_name: String, username: String, message: String, tags: TwitchTags.PrivMsg)
|
||
## When the token isn't valid anymore
|
||
signal unauthenticated
|
||
## When the token doesn't have enough permissions to join IRC
|
||
signal unauthorized
|
||
## Called when the connection got opened and the authorization was done
|
||
signal connection_opened
|
||
## Called when the connection got closed
|
||
signal connection_closed
|
||
|
||
|
||
enum JoinState {
|
||
NOT_JOINED, JOINING, JOINED
|
||
}
|
||
|
||
class ChannelData extends RefCounted:
|
||
|
||
signal has_joined
|
||
|
||
var join_state: JoinState = JoinState.NOT_JOINED
|
||
var channel_name: String
|
||
var nodes: Array[TwitchIrcChannel] = []
|
||
var user_state: TwitchTags.Userstate
|
||
var room_state: TwitchTags.Roomstate:
|
||
set(val):
|
||
room_state = val
|
||
if join_state != JoinState.JOINED && val != null:
|
||
join_state = JoinState.JOINED
|
||
has_joined.emit()
|
||
|
||
func _init(channel: String) -> void:
|
||
channel_name = channel
|
||
|
||
func leave() -> void:
|
||
room_state = null
|
||
join_state = JoinState.NOT_JOINED
|
||
for node in nodes: node.leave()
|
||
|
||
func is_joined() -> void:
|
||
if join_state != JoinState.JOINED: await has_joined
|
||
|
||
|
||
class ParsedMessage extends RefCounted:
|
||
## Parses all of the messages of IRC
|
||
## Group1: Tags
|
||
## Group2: Server / To User (Whisper) / From User (Chat)
|
||
## Group3: Command
|
||
## Group4: Channel / From User (Whisper)
|
||
## Group5: Message / Payload
|
||
var _irc_message_parser_regex = RegEx.create_from_string("(@.*? )?:(.*?)( [A-Z0-9]*)( #?.*?)?( :.*?)?$")
|
||
|
||
var tags: String:
|
||
get: return tags.trim_prefix("@")
|
||
|
||
## Server / To User (Whisper) / From User (Chat)
|
||
var server: String
|
||
|
||
var command: String:
|
||
get: return command.strip_edges()
|
||
|
||
## Channel / From User (Whisper)
|
||
var channel: String:
|
||
get: return channel.strip_edges().trim_prefix("#")
|
||
|
||
## Message / Payload
|
||
var message: String:
|
||
get: return message.strip_edges().trim_prefix(":")
|
||
|
||
func _init(msg: String) -> void:
|
||
var matches = _irc_message_parser_regex.search(msg)
|
||
if matches != null:
|
||
tags = matches.get_string(1)
|
||
server = matches.get_string(2)
|
||
command = matches.get_string(3)
|
||
channel = matches.get_string(4)
|
||
message = matches.get_string(5)
|
||
|
||
|
||
class EmoteLocation extends RefCounted:
|
||
var id : Variant
|
||
var start : int
|
||
var end : int
|
||
var sprite_frames: SpriteFrames
|
||
|
||
func _init(emote_id: Variant, start_idx: int, end_idx: int):
|
||
self.id = emote_id
|
||
self.start = start_idx
|
||
self.end = end_idx
|
||
|
||
static func smaller(a : EmoteLocation, b : EmoteLocation):
|
||
return a.start < b.start
|
||
|
||
|
||
@export var setting: TwitchIrcSetting = TwitchIrcSetting.new():
|
||
set(val):
|
||
setting = val
|
||
_client.connection_url = setting.server
|
||
update_configuration_warnings()
|
||
@export var token: OAuthToken:
|
||
set(val):
|
||
token = val
|
||
update_configuration_warnings()
|
||
@export var irc_send_message_delay: int = 360
|
||
## All connected channels of the bot.
|
||
## Key: channel_name as StringName | Value: ChannelData
|
||
var _channels := {}
|
||
|
||
## will automatically reconnect in case of authorization problems
|
||
var _auto_reconnect: bool
|
||
var _ready_to_send: bool
|
||
var _client := WebsocketClient.new()
|
||
|
||
## Timestamp when the next message should be sent.
|
||
var _next_message := Time.get_ticks_msec()
|
||
|
||
## Messages to send with an interval for disconnection protection
|
||
## see TwitchIrcSetting.send_message_delay_ms.
|
||
var _chat_queue : Array[String] = []
|
||
|
||
|
||
func _init() -> void:
|
||
_client.name = "Websocket"
|
||
_client.message_received.connect(_data_received)
|
||
_client.connection_established.connect(_on_connection_established)
|
||
_client.connection_closed.connect(_on_connection_closed)
|
||
|
||
|
||
func _ready() -> void:
|
||
token.authorized.connect(_on_authorized)
|
||
add_child(_client)
|
||
|
||
|
||
func _on_authorized() -> void:
|
||
log.i("Token got authorized reconnect to irc? Client Closed: %s, Auto Reconnect enabled: %s" % [_client.is_closed, _client.auto_reconnect])
|
||
if _client.is_closed and _auto_reconnect:
|
||
open_connection()
|
||
|
||
|
||
## Propergated call from TwitchService
|
||
func do_setup() -> void:
|
||
await open_connection()
|
||
log.i("IRC setup")
|
||
|
||
|
||
func open_connection() -> void:
|
||
_auto_reconnect = true
|
||
log.i("Irc open connection")
|
||
await _client.open_connection()
|
||
|
||
|
||
func close_connection() -> void:
|
||
_auto_reconnect = false
|
||
_client.close(1000, "intentionally closed")
|
||
log.i("Irc closed connection")
|
||
|
||
|
||
func _on_connection_established() -> void:
|
||
_login()
|
||
_reconnect_to_channels()
|
||
connection_opened.emit()
|
||
|
||
|
||
func _on_connection_closed() -> void:
|
||
_ready_to_send = false
|
||
connection_closed.emit()
|
||
for channel_name: StringName in _channels:
|
||
_channels[channel_name].leave()
|
||
|
||
|
||
func _process(delta: float) -> void:
|
||
if _ready_to_send: _send_messages()
|
||
|
||
|
||
## Sends the login message for authorization pupose and sets an username
|
||
func _login() -> void:
|
||
_client.send_text("PASS oauth:%s" % await token.get_access_token())
|
||
_send("NICK " + setting.username)
|
||
_send("CAP REQ :" + " ".join(setting.irc_capabilities))
|
||
|
||
|
||
## Reconnect to all channels the bot was joined before (in case programatically joined channels)
|
||
func _reconnect_to_channels():
|
||
for channel_name in _channels: join_channel(channel_name)
|
||
|
||
|
||
func _join_channels_on_connect():
|
||
for channel_name: StringName in setting.auto_join_channels:
|
||
var channel = join_channel(channel_name)
|
||
await channel.is_joined()
|
||
log.i("%s joined" % channel_name)
|
||
|
||
|
||
## Receives data on the websocket aka new messages
|
||
func _data_received(data : PackedByteArray) -> void:
|
||
var messages : PackedStringArray = data.get_string_from_utf8().strip_edges(false).split("\r\n")
|
||
for message: String in messages:
|
||
# Reminder PONG messages is just different cant use match for it...
|
||
if message.begins_with("PING"):
|
||
_send("PONG :tmi.twitch.tv")
|
||
continue
|
||
|
||
var parsed_message = ParsedMessage.new(message)
|
||
_handle_message(parsed_message)
|
||
|
||
|
||
## Tries to send messages as long as the websocket is open
|
||
func _send_messages() -> void:
|
||
if _chat_queue.is_empty():
|
||
return
|
||
|
||
if not _client.is_open:
|
||
log.e("Can't send message. Connection not open.")
|
||
# Maybe buggy when the websocket got opened but not authorized yet
|
||
# Can possible happen when we have a lot of load and a reconnect in the socket
|
||
return
|
||
|
||
if _next_message <= Time.get_ticks_msec():
|
||
var msg_to_send = _chat_queue.pop_front()
|
||
_send(msg_to_send)
|
||
_next_message = Time.get_ticks_msec() + irc_send_message_delay
|
||
|
||
|
||
## Sends join channel message
|
||
func join_channel(channel_name : StringName) -> ChannelData:
|
||
var lower_channel = channel_name.to_lower()
|
||
if not _channels.has(channel_name):
|
||
_channels[channel_name] = ChannelData.new(lower_channel)
|
||
|
||
if _channels[channel_name].join_state == JoinState.NOT_JOINED:
|
||
_chat_queue.append("JOIN #" + lower_channel)
|
||
_channels[channel_name].join_state = JoinState.JOINING
|
||
|
||
return _channels[channel_name]
|
||
|
||
|
||
## Sends leave channel message
|
||
func leave_channel(channel_name : StringName) -> void:
|
||
if not _channels.has(channel_name):
|
||
log.e("Can't leave %s channel cause we are not joined" % channel_name)
|
||
return
|
||
|
||
var lower_channel : StringName = channel_name.to_lower()
|
||
_chat_queue.append("PART #" + lower_channel)
|
||
_channels.erase(lower_channel)
|
||
|
||
|
||
## Sends a chat message to a channel. Defaults to the only connected channel.
|
||
## Channel should be always without '#'.
|
||
func chat(message : String, channel_name : StringName = &""):
|
||
var channel_names : Array = _channels.keys()
|
||
if channel_name == &"" && channel_names.size() == 1:
|
||
channel_name = channel_names[0]
|
||
|
||
if channel_name == &"":
|
||
log.e("No channel is specified to send %s" % message)
|
||
return
|
||
|
||
_chat_queue.append("PRIVMSG #%s :%s\r\n" % [channel_name, message])
|
||
|
||
# Call it defered otherwise the response of the bot will be send before the command.
|
||
_send_message_to_channel.call_deferred(channel_name, message)
|
||
|
||
|
||
## send the message of the bot to the channel for display purpose
|
||
func _send_message_to_channel(channel_name: StringName, message: String) -> void:
|
||
if _channels.has(channel_name):
|
||
var channel = _channels[channel_name] as ChannelData
|
||
var username = channel.user_state.display_name
|
||
# Convert the tags in a dirty way
|
||
var tag = TwitchTags.PrivMsg.new(channel.user_state._raw)
|
||
tag.room_id = channel.room_state.room_id
|
||
received_privmsg.emit(channel_name, username, message, tag)
|
||
|
||
|
||
## Sends a string message to Twitch.
|
||
func _send(text : String) -> void:
|
||
_client.send_text(text)
|
||
log.i("< " + text.strip_edges(false))
|
||
|
||
|
||
## Handles all the messages. Tags can be empty when not requested via capabilities
|
||
func _handle_message(parsed_message : ParsedMessage) -> void:
|
||
if parsed_message.command != "WHISPER":
|
||
log.i("> [%15s] %s: %s" % [parsed_message.command, parsed_message.server, parsed_message.message])
|
||
|
||
match parsed_message.command:
|
||
"001":
|
||
log.i("Authentication successful.")
|
||
_join_channels_on_connect()
|
||
_ready_to_send = true
|
||
|
||
"CLEARCHAT":
|
||
var clear_chat_tags = TwitchTags.ClearChat.new(parsed_message.tags)
|
||
var user_to_ban_or_timeout = parsed_message.message
|
||
received_clearchat.emit(parsed_message.channel, parsed_message.message, clear_chat_tags)
|
||
|
||
"CLEARMSG":
|
||
var clear_msg_tags = TwitchTags.ClearMsg.new(parsed_message.tags)
|
||
var message_to_remove = parsed_message.message
|
||
received_clearmsg.emit(parsed_message.channel, message_to_remove, clear_msg_tags)
|
||
|
||
"GLOBALUSERSTATE":
|
||
var global_userstate = TwitchTags.GlobalUserState.new(parsed_message.tags)
|
||
received_global_userstate.emit(global_userstate)
|
||
|
||
"HOSTTARGET":
|
||
# Example: [-|<channel>] <number-of-viewers>
|
||
var host_target_message = parsed_message.message.split(" ")
|
||
var channel_being_hosted = host_target_message[0]
|
||
var number_of_viewers = int(host_target_message[1])
|
||
var hosting_channel = parsed_message.channel
|
||
received_host_target.emit(channel_being_hosted, hosting_channel, number_of_viewers)
|
||
|
||
"NOTICE":
|
||
var notice_tags = TwitchTags.Notice.new(parsed_message.tags)
|
||
var message = parsed_message.message
|
||
var handled := false
|
||
if not await _handle_cmd_notice(message):
|
||
handled = true
|
||
received_notice.emit(parsed_message.channel, message, notice_tags, handled)
|
||
|
||
"RECONNECT":
|
||
received_reconnect.emit()
|
||
|
||
"ROOMSTATE":
|
||
var roomstate_tags = TwitchTags.Roomstate.new(parsed_message.tags)
|
||
var channel_name = parsed_message.channel
|
||
received_roomstate.emit(channel_name, roomstate_tags)
|
||
|
||
var channel = _channels[channel_name] as ChannelData
|
||
channel.room_state = roomstate_tags
|
||
|
||
"USERNOTICE":
|
||
var user_notice_tags = TwitchTags.Usernotice.new(parsed_message.tags)
|
||
received_usernotice.emit(parsed_message.channel, parsed_message.message, user_notice_tags)
|
||
|
||
"USERSTATE":
|
||
var userstate_tags = TwitchTags.Userstate.new(parsed_message.tags)
|
||
var channel_name = parsed_message.channel
|
||
received_usernotice.emit(channel_name, userstate_tags)
|
||
|
||
var channel = _channels[channel_name] as ChannelData
|
||
channel.user_state = userstate_tags
|
||
|
||
"WHISPER":
|
||
var whisper_tags = TwitchTags.Whisper.new(parsed_message.tags)
|
||
# Example: :<to-user>!<to-user>@<to-user>.tmi.twitch.tv
|
||
var to_user = parsed_message.server
|
||
to_user = to_user.substr(0, to_user.find("!"))
|
||
# Special case for whisper
|
||
var from_user = parsed_message.channel
|
||
received_whisper.emit(from_user, to_user, parsed_message.message, whisper_tags)
|
||
|
||
"PRIVMSG":
|
||
var privmsg_tags = TwitchTags.PrivMsg.new(parsed_message.tags)
|
||
var from_user = parsed_message.server
|
||
from_user = from_user.substr(0, from_user.find("!"))
|
||
received_privmsg.emit(parsed_message.channel, from_user, parsed_message.message, privmsg_tags)
|
||
|
||
|
||
## Handles the update of rooms when joining the channel or a moderator
|
||
## updates it (Example :tmi.twitch.tv ROOMSTATE #bar)
|
||
func _handle_cmd_state(command: String, channel_name: StringName, tags: Dictionary) -> void:
|
||
# right(-1) -> Remove the preceding # of the channel name
|
||
channel_name = channel_name.right(-1).to_lower()
|
||
if not _channels.has(channel_name):
|
||
_channels[channel_name] = _create_channel(channel_name)
|
||
|
||
var channel: TwitchIrcChannel = _channels[channel_name]
|
||
channel.update_state(command, tags)
|
||
#channel_data_updated.emit(channel_name, channel.data)
|
||
log.i("Channel updated %s" % channel_name)
|
||
|
||
|
||
func _create_channel(channel_name: StringName) -> TwitchIrcChannel:
|
||
var channel = TwitchIrcChannel.new()
|
||
channel.channel_name = channel_name
|
||
_channels[channel_name] = channel
|
||
Engine.get_main_loop().root.add_child(channel)
|
||
return channel
|
||
|
||
|
||
## Tracks the channel.
|
||
func add_channel(channel: TwitchIrcChannel):
|
||
var channel_name = channel.channel_name
|
||
if not _channels.has(channel_name):
|
||
join_channel(channel_name)
|
||
var nodes = _channels[channel_name].nodes as Array[TwitchIrcChannel]
|
||
nodes.append(channel)
|
||
|
||
|
||
## Remove the channel from getting tracked within the service
|
||
func remove_channel(channel: TwitchIrcChannel):
|
||
var channel_name = channel.channel_name
|
||
var channel_data = _channels[channel_name] as ChannelData
|
||
channel_data.nodes.erase(channel)
|
||
if channel_data.nodes.is_empty():
|
||
leave_channel(channel_name)
|
||
|
||
|
||
func _handle_cmd_notice(info: String) -> bool:
|
||
if info == "Login authentication failed" || info == "Login unsuccessful":
|
||
log.e("Authentication failed.")
|
||
unauthenticated.emit()
|
||
_client.close(1000, "Unauthenticated.")
|
||
return true
|
||
elif info == "You don't have permission to perform that action":
|
||
log.i("No permission. Please check if you have all required scopes (chat:read or chat:write).")
|
||
unauthorized.emit()
|
||
_client.close(1000, "Token became invalid.")
|
||
return true
|
||
return false
|
||
|
||
|
||
func get_client() -> WebsocketClient:
|
||
return _client
|
||
|
||
|
||
func _get_configuration_warnings() -> PackedStringArray:
|
||
var result: Array[String] = []
|
||
if token == null:
|
||
result.append("Token is missing")
|
||
if setting == null:
|
||
result.append("IRC Settings are missing")
|
||
return result
|