pokepurple/addons/twitcher/irc/twitch_irc.gd

462 lines
16 KiB
GDScript3
Raw Permalink Normal View History

@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 channels 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 thats sending the whisper message.[br]
## to_user - The user thats 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