[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/DigitalPulseSoftware/NotaBot/master/module_twitch.lua [Back]  [Original]

-- Copyright (C) 2018 Jrme Leclercq
-- This file is part of the "Not a Bot" application
-- For conditions of distribution and use, see copyright notice in LICENSE

local client = Client
local config = Config
local discordia = Discordia
local bot = Bot
local enums = discordia.enums
local wrap = coroutine.wrap

Module.Name = "twitch"

Module.GameCache = {}
Module.ProfileCache = {}

discordia.extensions()

local httpCodec = require('http-codec')
local net = require('coro-net')
local json = require('json')
local sha256 = require('sha256')
local querystring = require('querystring')
local timer = require("timer")
local twitchAPI = require('./twitchapi.lua')
	
local function tablesearchstr(tab, val)
	for k,v in pairs(tab) do
		if (tostring(v) == val) then
			return true
		end
	end

	return false
end

function Module:GetConfigTable()
	return {
		{
			Name = "TwitchConfig",
			Description = "List of watched channels with title patterns for messages to post on channel goes up",
			Type = bot.ConfigType.Custom,
			Default = {},
			ValidateConfig = function (value)
				if (type(value) ~= "table" or #value ~= 0) then
					return false, "TwitchConfig must be an object"
				end

				for channelId, notificationData in pairs(value) do
					if (not util.ValidateSnowflake(channelId)) then
						return false, "TwitchConfig keys must be channel snowflakes"
					end

					if (type(notificationData) ~= "table" or #notificationData ~= table.count(notificationData)) then
						return false, "TwitchConfig[" .. channelId .. "] must be an array"
					end

					for i, channelData in pairs(notificationData) do
						local hasChannel = false
						local hasMessage = false

						for fieldName, fieldValue in pairs(channelData) do
							if (fieldName == "AllowedGames") then
								if (type(fieldValue) ~= "table" or #fieldValue ~= table.count(fieldValue)) then
									return false, "TwitchConfig[" .. channelId .. "][" .. i .. "]." .. fieldName .. " must be an array"
								end

								for i, value in pairs(fieldValue) do
									if (type(value) ~= "number" or math.floor(value) ~= value) then
										return false, "TwitchConfig[" .. channelId .. "][" .. i .. "]." .. fieldName .. "[" .. i .. "] is not an integer"
									end
								end
							elseif (fieldName == "Channel") then
								if (not util.ValidateSnowflake(fieldValue)) then
									return false, "TwitchConfig[" .. channelId .. "][" .. i .. "]." .. fieldName .. " must be a channel snowflake"
								end

								hasChannel = true
							elseif (fieldName == "Message") then
								if (type(fieldValue) ~= "string") then
									return false, "TwitchConfig[" .. channelId .. "][" .. i .. "]." .. fieldName .. " must be a string"
								end

								hasMessage = true
							elseif (fieldName == "TitlePattern") then
								if type(fieldValue) == "table" and #fieldValue == table.count(fieldValue) then
									for j, value in pairs(fieldValue) do
										if type(value) ~= "string" then
											return false, "TwitchConfig[" .. channelId .. "][" .. i .. "]." .. fieldName .. "[" .. j .. "] is not a string"
										end
									end
								elseif type(fieldValue) ~= "string" then
									return false, "TwitchConfig[" .. channelId .. "][" .. i .. "]." .. fieldName .. " must be a string or a table of string"
								end
							elseif (fieldName == "ShouldCreateDiscordEvent") then
								if (type(fieldValue) ~= "boolean") then
									return false, "TwitchConfig[" .. channelId .. "][" .. i .. "]." .. fieldName .. " must be a boolean"
								end
							elseif (fieldName == "CreateDiscordEventDuration") then
								if (type(fieldValue) ~= "number") then
									return false, "TwitchConfig[" .. channelId .. "][" .. i .. "]." .. fieldName .. " must be a number"
								end
							else
								return false, "TwitchConfig[" .. channelId .. "][" .. i .. "]." .. fieldName .. " is not a valid field"
							end
						end

						if (not hasChannel) then
							return false, "TwitchConfig[" .. channelId .. "][" .. i .. "] is lacking a Channel field"
						end

						if (not hasMessage) then
							return false, "TwitchConfig[" .. channelId .. "][" .. i .. "] is lacking a Message field"
						end
					end
				end
		
				return true
			end
		},
		{
			Global = true,
			Name = "CallbackEndpoint",
			Description = "URI which will be sent to Twitch for channel events",
			Type = bot.ConfigType.String,
			Default = ""
		},
		{
			Global = true,
			Name = "ListenPort",
			Description = "Port on which internal server listens",
			Type = bot.ConfigType.Integer,
			Default = 14793
		},
		{
			Global = true,
			Name = "SilenceDuration",
			Description = "Duration during which a stream won't trigger other notifications after a notification",
			Type = bot.ConfigType.Duration,
			Default = 30 * 60
		},
		{
			Global = true,
			Name = "TwitchClientId",
			Description = "Twitch application client id",
			Type = bot.ConfigType.String,
			Default = "",
			Sensitive = true
		},
		{
			Global = true,
			Name = "TwitchClientSecret",
			Description = "Twitch application secret",
			Type = bot.ConfigType.String,
			Default = "",
			Sensitive = true
		}
	}
end

function Module:GetWatchedChannels()
	local persistentData = self:GetPersistentData(nil)
	persistentData.watchedChannels = persistentData.watchedChannels or {}

	return persistentData.watchedChannels
end

function Module:OnLoaded()
	self.API = twitchAPI(discordia, client, self.GlobalConfig.TwitchClientId, self.GlobalConfig.TwitchClientSecret)

	local secretLifespan = 24 * 60 * 60

	self.ChannelAlerts = {}

	for channelId, channelData in pairs(self:GetWatchedChannels()) do
		channelData.WaitingForConfirm = false
	end

	self.Clock = discordia.Clock()
	self.Clock:on("sec", function ()
		local watchedChannels = self:GetWatchedChannels()

		local now = os.time()
		for channelId, channelData in pairs(watchedChannels) do
			if (channelData.RenewTime  startDate) then
			self:LogInfo("Dismissed alert event because last one occured while the stream was active (%s ago)", util.FormatTime(now - channelData.LastAlert))
			return
		end

		-- There may be a race condition between Twitch notifying a stream started and stream info fetching, try multiple times with a small delay
		local streamData, err
		for i=1,10 do
			self:LogInfo("trying to retrieve stream info for %s (attempt %d/10)", channelId, i)
			streamData, err = self.API:GetStreamByUserId(channelId)
			if (streamData) then
				break
			else
				if (err) then
					self:LogError("couldn't retrieve stream info for %s: %s", channelId, err.msg)
				end
				timer.sleep(1000)
			end
		end

		if (not streamData) then
			return
		end
	
		channelData.LastAlert = now

		local title = streamData.title
		local gameId = streamData.game_id

		for guildId, guildPatterns in pairs(channelAlerts) do
			local guild = client:getGuild(guildId)
			if (guild) then
				local function CheckPattern(pattern)
					if pattern.TitlePattern then
						local doesMatch = false
						for _, titlePattern in ipairs(table.wrap(pattern.TitlePattern)) do
							if title:match(titlePattern) then
								doesMatch = true
								break
							end
						end

						if not doesMatch then
							return false
						end
					end

					if (pattern.AllowedGames) then
						if (not tablesearchstr(pattern.AllowedGames, gameId)) then
							return false
						end
					elseif (pattern.ForbiddenGames) then
						if (tablesearchstr(pattern.ForbiddenGames, gameId)) then
							return false
						end
					end
	
					return true
				end

				for _, pattern in pairs(guildPatterns) do
					if (CheckPattern(pattern)) then
						local channel = guild:getChannel(pattern.Channel)
						if (channel) then
							bot:CallModuleFunction(self, "SendChannelNotification", guild, channel, pattern.Message, streamData)
							if (pattern.ShouldCreateDiscordEvent) then
								bot:CallModuleFunction(self, "CreateScheduledEvent", guild, channelId, title, pattern.CreateDiscordEventDuration or 3600)
							end
						else
							self:LogError(guild, "Channel %s doesn't exist", pattern.Channel)
						end

						break
					end
				end
			end
		end
	else
		self:LogWarning("unexpected event %s for channel %s", type, channelId)
	end
end

function Module:CreateScheduledEvent(guild, channelId, title, duration)
	local profileData, err = self:GetProfileData(channelId)
	if (not profileData) then
		self:LogError("failed to query user %s info: %s", channelData.user_id, err.msg)
		return
	end

	local eventTitle = " Stream: " .. title
	if #eventTitle > 100 then
		eventTitle = eventTitle:sub(1, 97) .. "..."
	end

	local now = os.time()

	-- TODO: Add support for images
	local eventData = {
		entity_type = enums.scheduledEventsEntityTypes.external,
		entity_metadata = {
			location = "https://twitch.tv/" .. profileData.Name
		},
		name = eventTitle,
		description = profileData.Name .. " is currently streaming!",
		privacy_level = enums.scheduledEventsPrivacyLevel.guild_only,
		scheduled_start_time = os.date("!%Y-%m-%dT%TZ", now + 5),
		scheduled_end_time = os.date("!%Y-%m-%dT%TZ", now + duration),
	}

	local success, err = guild:createScheduledEvents(eventData)
	if not success then
		self:LogError(guild, "failed to create scheduled event")
	end
end

function Module:GetProfileData(userId)
	local now = os.time()

	local profileData = self.ProfileCache[userId]
	if (not profileData or now - profileData.CachedAt > 3600) then
		local userInfo, err = self.API:GetUserById(userId)
		if (err) then
			return nil, err
		end

		profileData = {}
		if (userInfo) then
			profileData.CachedAt = now
			profileData.DisplayName = userInfo.display_name
			profileData.Name = userInfo.login
			profileData.Image = userInfo.profile_image_url
		end

		self.ProfileCache[userId] = profileData
	end

	return profileData
end

function Module:GetGameData(gameId)
	local now = os.time()

	local gameData = self.GameCache[gameId]
	if (not gameData or now - gameData.CachedAt > 3600) then
		local gameInfo, err = self.API:GetGameById(gameId)
		if (err) then
			return nil, err
		end

		gameData = {}
		if (gameInfo) then
			gameData.CachedAt = now
			gameData.Id = gameInfo.id
			gameData.Image = gameInfo.box_art_url
			gameData.Name = gameInfo.name
		end

		self.GameCache[gameId] = gameData
	end

	return gameData
end

function Module:SendChannelNotification(guild, channel, message, channelData)
	local profileData, err = self:GetProfileData(channelData.user_id)
	if (not profileData) then
		self:LogError("Failed to query user %s info: %s", channelData.user_id, err.msg)
		return
	end

	local gameData, err = self:GetGameData(channelData.game_id)
	if (not gameData) then
		self:LogError("Failed to query game info about game %s: %s", channelData.game_id, err.msg)
	end

	local nonMentionableRoles = {}
	for roleId in message:gmatch("") do
		local role = guild:getRole(roleId)
		if (role) then
			if (not role.mentionable) then
				nonMentionableRoles[roleId] = role
			end
		else
			self:LogWarning(guild, "Role %s doesn't exist", roleId)
		end
	end

	local gameName = gameData and gameData.Name or string.format("", channelData.game_id)

	message = message:gsub("{([%w_]+)}", {
		display_name = profileData.DisplayName,
		game_name = gameName,
		title = channelData.title
	})

	local channelUrl = "https://www.twitch.tv/" .. profileData.Name
	local thumbnail = channelData.thumbnail_url .. "?" .. os.time() -- Bypass Discord image caching
	thumbnail = thumbnail:gsub("{width}", 320)
	thumbnail = thumbnail:gsub("{height}", 180)

	for roleId, role in pairs(nonMentionableRoles) do
		local success, err = role:enableMentioning()
		if (not success) then
			self:LogWarning(guild, "Failed to enable mentioning on role %s (%s): %s", roleId, role.name, err)
		end
	end

	local fields = {nil, nil, nil}
	if (gameData) then
		table.insert(fields, {
			name = "Game",
			value = gameName
		})
	end

	if (channelData.viewer_count > 0) then
		table.insert(fields, {
			name = "Viewers",
			value = channelData.viewer_count
		})
	end

	local now = os.time()
	local startDate = discordia.Date.parseISO(channelData.started_at)

	table.insert(fields, {
		name = "Started",
		value = util.DiscordRelativeTimestamp(startDate)
	})

	local success, err = channel:send({
		content = message,
		embed = {
			title = channelData.title,
			url = channelUrl,
			author = {
				name = profileData.Name,
				url = channelUrl,
				icon_url = profileData.Image
			},
			thumbnail = {
				url = profileData.Image
			},
			fields = fields,
			image = {
				url = thumbnail
			},
			timestamp = channelData.started_at
		}
	})

	if (not success) then
		self:LogError(guild, "Failed to send twitch notification message: %s", err)
	end

	for roleId, role in pairs(nonMentionableRoles) do
		local success, err = role:disableMentioning()
		if (not success) then
			self:LogWarning(guild, "Failed to re-disable mentioning on role %s (%s): %s", roleId, role.name, err)
		end
	end
end

function Module:SubscribeToTwitch(channelId)
	self:LogInfo("Subscribing to channel %s", channelId)

	local watchedChannels = self:GetWatchedChannels()
	local channelData = watchedChannels[channelId]
	assert(channelData)

	if (channelData.ChannelUpEventId) then
		self:UnsubscribeFromTwitch(channelId)
	end

	channelData.WaitingForConfirm = true

	channelData.Secret = self:GenerateSecret(32)

	local succeeded, ret, err = pcall(function () return self.API:SubscribeToStreamUp(channelId, self.GlobalConfig.CallbackEndpoint, channelData.Secret) end)

	if (not succeeded or not ret) then
		channelData.WaitingForConfirm = false
		if (err.code == 409) then -- conflict, this subscription already exists
			self:LogInfo("subscription already exist")
			-- Try to remove subscription
			local subscriptions, err = self.API:ListSubscriptions()
			if (not subscriptions) then
				self:LogError("failed to list current subscriptions: %s", err.msg)
				return false, err.msg
			end

			for _, subscription in pairs(subscriptions.data) do
				if (subscription.condition.broadcaster_user_id == channelId) then
					self.API:Unsubscribe(subscription.id)
					break
				end
			end

			-- Try again
			return
		end

		self:LogError("An error occurred: %s", err.msg)
		return false, err.msg
	end

	channelData.ChannelUpEventId = ret.data.id

	return ret, err
end

function Module:UnsubscribeFromTwitch(channelId)
	local channelData = watchedChannels[channelId]
	if (channelData and channelData.ChannelUpEventId) then
		self:LogInfo("Unsubscribing from channel %s", channelId)

		return self.API:Unsubscribe(channelData.ChannelUpEventId)
	end
end

Web Proxy Viewer  |  New URL  |  Original Page