-- 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