-- 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 http = require("coro-http")
local json = require("json")
local querystring = require("querystring")
local timer = require("timer")
local decode, encode = json.decode, json.encode
local insert = table.insert
local max, random = math.max, math.random
local ipairs = ipairs
local request = http.request
local resume = coroutine.resume
local running = coroutine.running
local setTimeout = timer.setTimeout
local sleep = timer.sleep
local tostring = tostring
local urlencode = querystring.urlencode
local yield = coroutine.yield
local endpoints = {
EventSubSubscriptions = "https://api.twitch.tv/helix/eventsub/subscriptions",
GetGames = "https://api.twitch.tv/helix/games",
GetStreams = "https://api.twitch.tv/helix/streams",
GetUsers = "https://api.twitch.tv/helix/users",
}
local function tprint (tbl, indent)
if not indent then indent = 0 end
for k, v in pairs(tbl) do
local formatting = string.rep(" ", indent) .. k .. ": "
if type(v) == "table" then
print(formatting)
tprint(v, indent+1)
elseif type(v) == 'boolean' then
print(formatting .. tostring(v))
else
print(formatting .. v)
end
end
end
local TwitchApi = {}
TwitchApi.__index = TwitchApi
function TwitchApi:__init(discordia, client, appId, appSecret)
self._client = client
self._discordia = discordia
self._clientId = appId
self._clientSecret = appSecret
self._isRequesting = false
self._waitingCoroutines = {}
end
function TwitchApi:Authenticate()
local parameters = {
client_id = self._clientId,
client_secret = self._clientSecret,
grant_type = "client_credentials"
}
local success, headerOrErr, body = pcall(http.request, "POST", "https://id.twitch.tv/oauth2/token", {{"Content-Type", "application/x-www-form-urlencoded"}}, querystring.stringify(parameters))
if (not success) then
print("Failed to request Twitch Token (is network down?): " .. headerOrErr)
return false, "NetworkError"
end
if (headerOrErr.code < 200 or headerOrErr.code > 299) then
p(body)
print("Failed to request Twitch Token (are credentials still valid?) (code " .. headerOrErr.code .. ")")
return false, body
end
local tokenData = assert(json.decode(body))
self.token = {
accessToken = tokenData.access_token,
expirationTime = os.time() + tonumber(tokenData.expires_in),
tokenType = tokenData.token_type
}
-- Twitch, vous tes des baltringues nuclaires
self.token.tokenType = self.token.tokenType:sub(1, 1):upper() .. self.token.tokenType:sub(2)
return true
end
function TwitchApi:Commit(method, url, headers, body, retries, forceAuth)
if (forceAuth or not self.token or os.time() > self.token.expirationTime) then
local success, err = self:Authenticate()
if (not success) then
error("Twitch authentication failed: " .. err)
end
end
headers = headers or {}
-- Discard authorization header if any
for k, header in pairs(headers) do
local key = header[1]:lower()
if (key == "authorization" or key == "client-id") then
headers[k] = nil
end
end
insert(headers, {"Authorization", self.token.tokenType .. " " .. self.token.accessToken})
insert(headers, {"Client-ID", self._clientId})
local success, res, msg = pcall(request, method, url, headers, body)
if (not success) then
self._client:error("Request failed : %s %s", method, url)
return nil, res, 100
end
for i, v in ipairs(res) do
res[string.lower(v[1])] = v[2]
res[i] = nil
end
local reset = res["ratelimit-reset"]
local remaining = res["ratelimit-remaining"]
local delay = 0 -- ?
if (reset and remaining == "0") then
local dt = os.difftime(reset, self._discordia.Date.parseHeader(res["date"]))
delay = max(dt * 1000, delay)
end
local contentType = res["content-type"]
local data = (contentType and contentType:find("application/json")) and decode(msg) or msg
if (res.code < 300) then
self._client:info("%i - %s : %s %s", res.code, res.reason, method, url)
return data, nil, delay
else
local maxRetries = 5
local retry
if (res.code == 429) then -- Too Many Requests
retry = retries < maxRetries
elseif (res.code >= 500) then -- Server error
delay = delay + random(2000)
retry = retries < maxRetries
elseif (res.code == 401) then -- Token error
delay = 100
retry = retries < maxRetries
forceAuth = true
end
if (retry) then
self._client:warning("%i - %s : retrying after %i ms : %s %s", res.code, res.reason, delay, method, url)
sleep(delay)
return self:Commit(method, url, headers, body, retries + 1, forceAuth)
end
p(msg)
self._client:error('%i - %s : %s %s', res.code, res.reason, method, url)
return nil, {code=res.code, msg=msg}, delay
end
end
function TwitchApi:Request(method, endpoint, parameters, headers)
headers = headers or {}
local body
if (parameters and not table.empty(parameters)) then
if (method == "GET" or method == "DELETE") then
local url = {endpoint}
for k, v in pairs(parameters) do
insert(url, #url == 1 and '?' or '&')
insert(url, urlencode(k))
insert(url, '=')
insert(url, urlencode(v))
end
endpoint = table.concat(url)
elseif (method == "POST") then
body = encode(parameters)
insert(headers, {"Content-Type", "application/json; charset=utf-8"})
insert(headers, {"Content-Length", #body})
else
error("Invalid method " .. method)
end
end
self:Lock()
local succeeded, data, err, delay = pcall(function () return self:Commit(method, endpoint, headers, body, 0) end)
self:Unlock(delay)
if (not succeeded) then
return nil, data
end
if (data) then
return data
else
return nil, err
end
end
function TwitchApi:Lock()
if (self._isRequesting) then
local co = running()
insert(self._waitingCoroutines, co)
yield(co)
end
self._isRequesting = true
end
function TwitchApi:Unlock()
if (#self._waitingCoroutines > 0) then
local co = table.remove(self._waitingCoroutines, 1)
assert(resume(co))
else
self._isRequesting = false
end
end
local unlock = TwitchApi.Unlock
function TwitchApi:UnlockAfter(delay)
setTimeout(delay, unlock, self)
end
function TwitchApi:GetGameById(gameId)
local body, err = self:Request("GET", endpoints.GetGames, {id = gameId})
if (body and body.data) then
return body.data[1]
else
return nil, err
end
end
function TwitchApi:GetGameByName(gameName)
local body, err = self:Request("GET", endpoints.GetGames, {name = gameName})
if (body and body.data) then
return body.data[1]
else
return nil, err
end
end
function TwitchApi:GetStreamByUserId(userId)
local body, err = self:Request("GET", endpoints.GetStreams, {user_id = userId})
if (body and body.data) then
return body.data[1]
else
return nil, err
end
end
function TwitchApi:GetStreamByUserName(userName)
local body, err = self:Request("GET", endpoints.GetStreams, {user_login = userName})
if (body and body.data) then
return body.data[1]
else
return nil, err
end
end
function TwitchApi:GetUserById(userId)
local body, err = self:Request("GET", endpoints.GetUsers, {id = userId})
if (body and body.data) then
return body.data[1]
else
return nil, err
end
end
function TwitchApi:GetUserByName(userName)
local body, err = self:Request("GET", endpoints.GetUsers, {login = userName})
if (body and body.data) then
return body.data[1]
else
return nil, err
end
end
function TwitchApi:ListSubscriptions()
return self:Request("GET", endpoints.EventSubSubscriptions)
end
function TwitchApi:SubscribeWebHook(type, userId, callback, secret)
local parameters = {
type = type,
version = "1",
condition = {
broadcaster_user_id = userId
},
transport = {
method = "webhook",
callback = callback,
secret = secret
}
}
return self:Request("POST", endpoints.EventSubSubscriptions, parameters)
end
function TwitchApi:SubscribeToStreamUp(userId, callback, secret)
return self:SubscribeWebHook("stream.online", userId, callback, secret)
end
function TwitchApi:Unsubscribe(subscriptionId)
return self:Request("DELETE", endpoints.EventSubSubscriptions, {id = subscriptionId})
end
function TwitchApi:__tostring()
return "TwitchApi"
end
return setmetatable({}, {
__call = function (self, ...)
local o = {}
setmetatable(o, TwitchApi)
o:__init(...)
return o
end,
__newindex = function (o, key, val)
error("Writing is prohibited")
end,
__tostring = function ()
return "TwitchApi"
end
})