Added Signals for streamer_token_validated and chatbot_token_validated. Added bool to prevent multiple runs of Getting live streams. Added function get_live_streamers_data() Fixed bug in get_user_by_id() and get_user(), if user is null, don't cache user result.
535 lines
16 KiB
GDScript
535 lines
16 KiB
GDScript
@tool
|
|
extends Twitcher
|
|
class_name TwitcherExtended
|
|
|
|
#region Signals
|
|
signal _waiting_for_authentication
|
|
signal streamer_token_validated()
|
|
signal chatbot_token_validated()
|
|
#endregion
|
|
|
|
#region Constants
|
|
const POLL_TIMEOUT_MS: int = 30000
|
|
#endregion
|
|
|
|
#region Static Exports
|
|
static var instance: TwitcherExtended
|
|
#endregion
|
|
|
|
#region Exports
|
|
@export_group("Twitcher Settings")
|
|
@export var oauth_settings: OAuthSetting = preload("res://addons/twitcher/twitch_oauth_setting.tres")
|
|
@export var scopes: OAuthScopes = preload("res://addons/twitcher/auth/preset_overlay_scopes.tres")
|
|
@export var streamer_token: OAuthToken
|
|
@export var chatbot_token: OAuthToken
|
|
|
|
#region Node Exports
|
|
@export_subgroup("Twitcher Nodes")
|
|
@export var service: TwitchService
|
|
@export var chat: TwitchChat
|
|
@export var bot: TwitchBot
|
|
@export var chatbot_auth: ChatbotAuthorization
|
|
#endregion
|
|
|
|
#region Twitcher Child Node Exports
|
|
@export_subgroup("Twitcher Child Nodes")
|
|
@export var eventsub: TwitchEventsub:
|
|
get():
|
|
if service:
|
|
return service.eventsub
|
|
return null
|
|
@export var api: TwitchAPI:
|
|
get():
|
|
if service:
|
|
return service.api
|
|
return null
|
|
@export var auth: TwitchAuth:
|
|
get():
|
|
if service:
|
|
return service.auth
|
|
return null
|
|
@export var media: TwitchMediaLoader:
|
|
get():
|
|
if service:
|
|
return service.media_loader
|
|
return null
|
|
#endregion
|
|
#endregion
|
|
|
|
#region Public Variables
|
|
var streamer_user: TwitchUser
|
|
var bot_user: TwitchUser
|
|
var streamer_token_loaded: bool = false
|
|
var chatbot_token_loaded: bool = false
|
|
#endregion
|
|
|
|
#region Private Variables
|
|
var _cache_users: Dictionary[String, TwitchUser] = {}
|
|
var _log: TwitchLogger = TwitchLogger.new("TwitcherExtended")
|
|
var _commands: Dictionary[String, TwitchCommand] = {}
|
|
var _is_processing_streams: bool = false
|
|
#endregion
|
|
|
|
enum AuthStatus {
|
|
UNAUTHORIZED,
|
|
AUTHORIZED,
|
|
NEEDS_REFRESH
|
|
}
|
|
|
|
#region Support Functions / Overriden Methods
|
|
func _init() -> void:
|
|
child_entered_tree.connect(_handle_child_entered)
|
|
child_exiting_tree.connect(_handle_child_exiting)
|
|
|
|
func _ensure_nodes() -> void:
|
|
if not service:
|
|
# Ensure Service
|
|
service = TwitchService.new()
|
|
service.name = "Service"
|
|
add_child(service)
|
|
service.owner = get_tree().edited_scene_root if Engine.is_editor_hint() else owner
|
|
var tn: Twitcher = null
|
|
|
|
# Ensure Auth
|
|
tn = TwitchAuth.new()
|
|
tn.name = "Auth"
|
|
service.add_child(tn)
|
|
tn.owner = get_tree().edited_scene_root if Engine.is_editor_hint() else owner
|
|
|
|
# Ensure API
|
|
tn = TwitchAPI.new()
|
|
tn.name = "API"
|
|
service.add_child(tn)
|
|
tn.owner = get_tree().edited_scene_root if Engine.is_editor_hint() else owner
|
|
|
|
# Ensure Eventsub
|
|
tn = TwitchEventsub.new()
|
|
tn.name = "Eventsub"
|
|
service.add_child(tn)
|
|
tn.owner = get_tree().edited_scene_root if Engine.is_editor_hint() else owner
|
|
|
|
# Ensure MediaLoader
|
|
tn = TwitchMediaLoader.new()
|
|
tn.name = "Media"
|
|
service.add_child(tn)
|
|
tn.owner = get_tree().edited_scene_root if Engine.is_editor_hint() else owner
|
|
|
|
if not chat:
|
|
chat = TwitchChat.new()
|
|
chat.name = "Chat"
|
|
chat.subscribe_on_ready = false
|
|
add_child(chat)
|
|
chat.owner = get_tree().edited_scene_root if Engine.is_editor_hint() else owner
|
|
|
|
if not bot:
|
|
bot = TwitchBot.new()
|
|
bot.name = "Bot"
|
|
add_child(bot)
|
|
bot.owner = get_tree().edited_scene_root if Engine.is_editor_hint() else owner
|
|
|
|
if not chatbot_auth:
|
|
chatbot_auth = ChatbotAuthorization.new()
|
|
chatbot_auth.name = "ChatbotAuth"
|
|
add_child(chatbot_auth)
|
|
chatbot_auth.owner = get_tree().edited_scene_root if Engine.is_editor_hint() else owner
|
|
|
|
func _check_nodes() -> bool:
|
|
if service == null: return false
|
|
if eventsub == null: return false
|
|
if api == null: return false
|
|
if auth == null: return false
|
|
if media == null: return false
|
|
return true
|
|
#endregion
|
|
|
|
#region Event Handlers
|
|
func _handle_child_entered(child: Node) -> void:
|
|
if child is TwitchService: service = child
|
|
if child is TwitchChat: chat = child
|
|
if child is TwitchBot: bot = child
|
|
if child is ChatbotAuthorization: chatbot_auth = child
|
|
|
|
func _handle_child_exiting(child: Node) -> void:
|
|
if child is TwitchService and child == service: service = null
|
|
if child is TwitchChat and child == chat: chat = null
|
|
if child is TwitchBot and child == bot: bot = null
|
|
if child is ChatbotAuthorization and child == chatbot_auth: chatbot_auth = null
|
|
#endregion
|
|
|
|
#region Godot Overrides
|
|
func _ready() -> void:
|
|
if not instance:
|
|
instance = self
|
|
_log.d("is ready")
|
|
_ensure_nodes()
|
|
# Ensure Settings are properly setup
|
|
service.oauth_setting = oauth_settings
|
|
service.scopes = scopes
|
|
service.token = streamer_token
|
|
chat.api = service.api
|
|
chat.eventsub = service.eventsub
|
|
chat.media_loader = service.media_loader
|
|
bot.oauth_setting = oauth_settings
|
|
chatbot_auth.oauth_setting = oauth_settings
|
|
chatbot_auth.token = chatbot_token
|
|
|
|
auth.token_handler.unauthenticated.connect(_waiting_for_authentication.emit)
|
|
auth.token_handler.token_resolved.connect(_waiting_for_authentication.emit)
|
|
#endregion
|
|
|
|
#region Streamer Private Functions
|
|
func _twitcher_setup() -> void:
|
|
_log.d("Setting up nodes...")
|
|
service.propagate_call(&"do_setup")
|
|
for child in service.get_children():
|
|
if child.has_method(&"wait_setup"):
|
|
await child.wait_setup()
|
|
#endregion
|
|
|
|
#region Streamer Public Functions
|
|
func load_streamer_token() -> AuthStatus:
|
|
_log.d("Loading streamer tokens...")
|
|
streamer_token_loaded = false
|
|
var res = streamer_token._load_tokens()
|
|
if not res:
|
|
_log.d("Token doesn't exist")
|
|
return AuthStatus.UNAUTHORIZED
|
|
if streamer_token.is_token_valid():
|
|
_log.d("Token authorized")
|
|
streamer_user = await service.get_current_user()
|
|
streamer_token_loaded = true
|
|
streamer_token_validated.emit()
|
|
return AuthStatus.AUTHORIZED
|
|
if streamer_token.has_refresh_token():
|
|
_log.d("Token needs refreshed")
|
|
streamer_token_loaded = true
|
|
return AuthStatus.NEEDS_REFRESH
|
|
_log.d("Token invalid")
|
|
return AuthStatus.UNAUTHORIZED
|
|
|
|
func is_streamer_authed() -> bool:
|
|
return auth.is_authenticated
|
|
|
|
func is_streamer_token_valid() -> bool:
|
|
return streamer_token.is_token_valid()
|
|
|
|
func authorize_streamer() -> bool:
|
|
return await auth.authorize()
|
|
|
|
func setup_streamer() -> bool:
|
|
_log.d("Setup streamer")
|
|
load_streamer_token()
|
|
if is_streamer_authed() and is_streamer_token_valid():
|
|
_log.d("Saved tokens validated.")
|
|
_twitcher_setup()
|
|
streamer_user = await service.get_current_user()
|
|
chat.broadcaster_user = streamer_user
|
|
bot.receiver = streamer_user
|
|
chat.subscribe()
|
|
return true
|
|
|
|
var res: bool = false
|
|
|
|
if streamer_token.has_refresh_token():
|
|
_log.d("Refreshing tokens...")
|
|
auth.refresh_token()
|
|
await _waiting_for_authentication
|
|
res = is_streamer_token_valid()
|
|
else:
|
|
_log.d("Acquiring token...")
|
|
res = await auth.authorize()
|
|
|
|
if res:
|
|
_log.d("Tokens validated.")
|
|
_twitcher_setup()
|
|
streamer_user = await service.get_current_user()
|
|
chat.broadcaster_user = streamer_user
|
|
chat.sender_user = streamer_user
|
|
bot.receiver = streamer_user
|
|
chat.subscribe()
|
|
|
|
return res
|
|
#endregion
|
|
|
|
#region Chatbot Functions
|
|
func load_chatbot_token() -> AuthStatus:
|
|
_log.d("Loading chatbot tokens...")
|
|
chatbot_token_loaded = false
|
|
var res = chatbot_token._load_tokens()
|
|
if not res:
|
|
_log.d("Token doesn't exist")
|
|
return AuthStatus.UNAUTHORIZED
|
|
if chatbot_token.is_token_valid():
|
|
_log.d("Token authroized")
|
|
bot_user = await chatbot_auth.get_user()
|
|
chatbot_token_loaded = true
|
|
chatbot_token_validated.emit()
|
|
return AuthStatus.AUTHORIZED
|
|
if chatbot_token.has_refresh_token():
|
|
_log.d("Token needs refreshed")
|
|
chatbot_token_loaded = true
|
|
return AuthStatus.NEEDS_REFRESH
|
|
_log.d("Token invalid")
|
|
return AuthStatus.UNAUTHORIZED
|
|
|
|
func is_chatbot_authed() -> bool:
|
|
return auth.is_authenticated
|
|
|
|
func is_chatbot_token_valid() -> bool:
|
|
return streamer_token.is_token_valid()
|
|
|
|
func authorize_chatbot() -> bool:
|
|
return await chatbot_auth.authenticate()
|
|
|
|
func setup_chatbot() -> bool:
|
|
_log.d("Setup chatbot")
|
|
load_chatbot_token()
|
|
var res: bool = await chatbot_auth.authenticate()
|
|
if res:
|
|
_log.d("Chatbot authorized.")
|
|
bot_user = await chatbot_auth.get_user()
|
|
bot.sender = bot_user
|
|
return res
|
|
#endregion
|
|
|
|
#region Public API shared between both Streamer and Chatbot as needed. (Twitcher standard methods)
|
|
func send_message(message: String, as_streamer: bool = false) -> void:
|
|
if as_streamer:
|
|
await chat.send_message(message)
|
|
else:
|
|
await bot.send_message(message)
|
|
|
|
func reply_message(message: String, msg_id: String, as_streamer: bool = false) -> void:
|
|
if as_streamer:
|
|
await chat.send_message(message, msg_id)
|
|
else:
|
|
await bot.send_message(message, msg_id)
|
|
|
|
func get_users_by_id(...user_ids: Array) -> Array[TwitchUser]:
|
|
var tusers: Array[TwitchUser] = []
|
|
var qusers: Array[String] = []
|
|
for user_id in user_ids:
|
|
if _cache_users.has(user_id):
|
|
tusers.append(_cache_users[user_id])
|
|
else:
|
|
qusers.append(user_id)
|
|
|
|
if not qusers.is_empty():
|
|
var opt = TwitchGetUsers.Opt.new()
|
|
opt.id = qusers
|
|
var tgur: TwitchGetUsers.Response = await api.get_users(opt)
|
|
if tgur.data.is_empty():
|
|
return tusers
|
|
for tgu in tgur.data:
|
|
_cache_users[tgu.id] = tgu
|
|
_cache_users[tgu.login] = tgu
|
|
tusers.append(tgu)
|
|
|
|
return tusers
|
|
|
|
func get_users(...usernames: Array) -> Array[TwitchUser]:
|
|
var tusers: Array[TwitchUser] = []
|
|
var qusers: Array[String] = []
|
|
|
|
for i in usernames.size():
|
|
usernames[i] = usernames[i].trim_prefix("@")
|
|
|
|
for username in usernames:
|
|
if _cache_users.has(username):
|
|
tusers.append(_cache_users[username])
|
|
else:
|
|
qusers.append(username)
|
|
|
|
if not qusers.is_empty():
|
|
var opt = TwitchGetUsers.Opt.new()
|
|
opt.login = qusers
|
|
var tgur: TwitchGetUsers.Response = await api.get_users(opt)
|
|
if tgur.data.is_empty():
|
|
return tusers
|
|
for tgu in tgur.data:
|
|
_cache_users[tgu.id] = tgu
|
|
_cache_users[tgu.login] = tgu
|
|
tusers.append(tgu)
|
|
|
|
return tusers
|
|
|
|
func get_user_by_id(user_id: String) -> TwitchUser:
|
|
if _cache_users.has(user_id): return _cache_users[user_id]
|
|
var user: TwitchUser = await service.get_user_by_id(user_id)
|
|
if user:
|
|
_cache_users[user_id] = user
|
|
_cache_users[user.login] = user
|
|
return user
|
|
|
|
func get_user(username: String) -> TwitchUser:
|
|
username = username.trim_prefix("@")
|
|
if _cache_users.has(username): return _cache_users[username]
|
|
var user: TwitchUser = await service.get_user(username)
|
|
if user:
|
|
_cache_users[user.id] = user
|
|
_cache_users[username] = user
|
|
return user
|
|
|
|
func subscribe_event(definition: TwitchEventsubDefinition, conditions: Dictionary) -> TwitchEventsubConfig:
|
|
if definition == null:
|
|
_log.e("TwitchEventsubDefinition is null")
|
|
return
|
|
|
|
var config = TwitchEventsubConfig.create(definition, conditions)
|
|
eventsub.subscribe(config)
|
|
return config
|
|
|
|
func wait_for_eventsub_connection() -> void:
|
|
if eventsub == null:
|
|
_log.e("TwitchEventsub Node is missing")
|
|
return
|
|
await eventsub.wait_for_connection()
|
|
|
|
func get_subscriptions() -> Array[TwitchEventsubConfig]:
|
|
if eventsub == null:
|
|
_log.e("TwitchEventsub Node is missing")
|
|
return []
|
|
return eventsub.get_subscriptions()
|
|
|
|
func shoutout(user: TwitchUser, broadcaster: TwitchUser = null, moderator: TwitchUser = null) -> void:
|
|
if not streamer_user:
|
|
return
|
|
|
|
if not broadcaster:
|
|
broadcaster = streamer_user
|
|
if not moderator:
|
|
moderator = bot_user
|
|
|
|
api.send_a_shoutout(broadcaster.id, moderator.id, user.id)
|
|
|
|
func announcement(message: String, color: TwitchAnnouncementColor = TwitchAnnouncementColor.PRIMARY, as_broadcaster: bool = true) -> void:
|
|
if not streamer_user:
|
|
return
|
|
var moderator: TwitchUser = streamer_user if as_broadcaster else bot_user
|
|
|
|
var body = TwitchSendChatAnnouncement.Body.new()
|
|
body.message = message
|
|
body.color = color.value
|
|
api.send_chat_announcement(body, moderator.id, streamer_user.id)
|
|
|
|
func add_command(command: String, callback: Callable, args_min: int = 0, args_max: int = -1,
|
|
permission_level: TwitchCommand.PermissionFlag = TwitchCommand.PermissionFlag.EVERYONE,
|
|
where: TwitchCommand.WhereFlag = TwitchCommand.WhereFlag.CHAT, user_cooldown: float = 0,
|
|
global_cooldown: float = 0) -> TwitchCommand:
|
|
var command_node = TwitchCommand.new()
|
|
command_node.command = command
|
|
command_node.command_received.connect(callback)
|
|
command_node.args_min = args_min
|
|
command_node.args_max = args_max
|
|
command_node.permission_level = permission_level
|
|
command_node.where = where
|
|
command_node.user_cooldown = user_cooldown
|
|
command_node.global_cooldown = global_cooldown
|
|
add_child(command_node)
|
|
_commands[command] = command_node
|
|
_log.i("Register command %s" % command)
|
|
return command_node
|
|
|
|
func remove_command(command: String) -> void:
|
|
_log.i("Remove command %s" % command)
|
|
var command_node: TwitchCommand = _commands.get(command, null)
|
|
if command_node:
|
|
command_node.queue_free()
|
|
_commands.erase(command)
|
|
|
|
func whisper(_message: String, _username: String, _as_streamer: bool = false) -> void:
|
|
_log.e("Whisper from bots aren't supported by Twitch anymore. See https://dev.twitch.tv/docs/irc/chat-commands/ at /w")
|
|
|
|
func get_emotes_data(channel_id: String = "global") -> Dictionary:
|
|
return await media.get_cached_emotes(channel_id)
|
|
|
|
func get_badges_data(channel_id: String = "global") -> Dictionary:
|
|
return await media.get_cached_badges(channel_id)
|
|
|
|
func get_emotes(ids: Array[String]) -> Dictionary[String, SpriteFrames]:
|
|
return await media.get_emotes(ids)
|
|
|
|
func get_emotes_by_definition(emotes: Array[TwitchEmoteDefinition]) -> Dictionary[TwitchEmoteDefinition, SpriteFrames]:
|
|
return await media.get_emotes_by_definition(emotes)
|
|
|
|
func poll(title: String, choices: Array[String], duration: int = 60, channel_points_voting_enabled: bool = false, channel_points_per_vote: int = 1000) -> Dictionary:
|
|
var body_choices: Array[TwitchCreatePoll.BodyChoices] = []
|
|
for choice: String in choices:
|
|
var body_choice = TwitchCreatePoll.BodyChoices.create(choice)
|
|
body_choices.append(body_choice)
|
|
duration = clamp(duration, 15, 1800)
|
|
var poll_body: TwitchCreatePoll.Body = TwitchCreatePoll.Body.create(streamer_user.id, title, body_choices, duration)
|
|
if channel_points_voting_enabled:
|
|
poll_body.channel_points_per_vote = channel_points_per_vote
|
|
poll_body.channel_points_voting_enabled = channel_points_voting_enabled
|
|
var poll_response: TwitchCreatePoll.Response = await api.create_poll(poll_body)
|
|
if poll_response.response.response_code != 200:
|
|
var error_message: String = poll_response.response.response_data.get_string_from_utf8()
|
|
push_error("Can't create poll response cause of ", error_message)
|
|
return {}
|
|
var _poll: TwitchPoll = poll_response.data[0]
|
|
var poll_end_time: int = Time.get_ticks_msec() + duration * 1000 * POLL_TIMEOUT_MS
|
|
var event: TwitchEventsub.Event
|
|
if eventsub && eventsub.has_subscription(TwitchEventsubDefinition.CHANNEL_POLL_END, {&"broadcaster_user_id": streamer_user.id}):
|
|
var poll_ended: bool
|
|
while not poll_ended:
|
|
if poll_end_time < Time.get_ticks_msec():
|
|
return {}
|
|
event = await eventsub.event_received
|
|
if event.type != TwitchEventsubDefinition.CHANNEL_POLL_END: continue
|
|
if event.data[&"id"] != _poll.id: continue
|
|
break
|
|
else:
|
|
_log.i("Can't wait for poll end. Either eventsub is not set to it not listening to ending polls")
|
|
return {}
|
|
return event.data
|
|
|
|
func get_cheermote_data() -> Array[TwitchCheermote]:
|
|
if media == null:
|
|
_log.e("TwitchMediaLoader was not set within %s" % get_tree_string())
|
|
return []
|
|
await media.preload_cheemote()
|
|
return media.all_cheermotes()
|
|
|
|
func get_cheermote(definition: TwitchCheermoteDefinition) -> Dictionary:
|
|
return await media.get_cheermotes(definition)
|
|
#endregion
|
|
|
|
#region Extended Methods
|
|
func get_live_streamers_data(user_ids: Array = []) -> Dictionary[String, TwitchStream]:
|
|
if _is_processing_streams:
|
|
return {}
|
|
_is_processing_streams = true
|
|
if user_ids.is_empty():
|
|
var known := Globals.context.get_known_streamers()
|
|
user_ids = known.map(func(x: Chatter): return x.twitch_id)
|
|
|
|
var streams_data: Dictionary[String, TwitchStream] = {}
|
|
var opt := TwitchGetStreams.Opt.new()
|
|
opt.type = "live"
|
|
|
|
var iter: int = 0
|
|
const MAX_ITER = 100
|
|
while not user_ids.is_empty():
|
|
iter += 1
|
|
if iter > MAX_ITER:
|
|
_log.e("Reached max iterations while getting live stream data.")
|
|
break
|
|
|
|
var new_batch: Array[String] = []
|
|
new_batch.assign(user_ids.slice(0,99))
|
|
user_ids = user_ids.slice(99)
|
|
opt.user_id = new_batch
|
|
var streams_iterator := await api.get_streams(opt)
|
|
print("Fetching live...")
|
|
for stream_promise in streams_iterator:
|
|
var stream_data: TwitchStream = await stream_promise
|
|
if stream_data:
|
|
print("%s(%s) is live" % [stream_data.user_name, stream_data.user_id])
|
|
streams_data[stream_data.user_id] = stream_data
|
|
else:
|
|
print("WTF!!!! Iter is null?")
|
|
print("Fetching is done.")
|
|
_is_processing_streams = false
|
|
return streams_data
|
|
#endregion
|