Twitch API: v5 to Helix migration (#1560)

There's a document in src/providers/twitch/api which describes how we interact with the Twitch API.
Keeping this up to date might be a healthy way for us to ensure we keep using the right APIs for the right job.
This commit is contained in:
pajlada
2020-03-14 07:13:57 -04:00
committed by GitHub
parent 2e39dd4d9b
commit 9a8b85e338
25 changed files with 1076 additions and 572 deletions
+368
View File
@@ -0,0 +1,368 @@
#include "providers/twitch/api/Helix.hpp"
#include "common/Outcome.hpp"
namespace chatterino {
static Helix *instance = nullptr;
void Helix::fetchUsers(QStringList userIds, QStringList userLogins,
ResultCallback<std::vector<HelixUser>> successCallback,
HelixFailureCallback failureCallback)
{
QUrlQuery urlQuery;
for (const auto &id : userIds)
{
urlQuery.addQueryItem("id", id);
}
for (const auto &login : userLogins)
{
urlQuery.addQueryItem("login", login);
}
// TODO: set on success and on error
this->makeRequest("users", urlQuery)
.onSuccess([successCallback, failureCallback](auto result) -> Outcome {
auto root = result.parseJson();
auto data = root.value("data");
if (!data.isArray())
{
failureCallback();
return Failure;
}
std::vector<HelixUser> users;
for (const auto &jsonUser : data.toArray())
{
users.emplace_back(jsonUser.toObject());
}
successCallback(users);
return Success;
})
.onError([failureCallback](auto result) {
// TODO: make better xd
failureCallback();
})
.execute();
}
void Helix::getUserByName(QString userId,
ResultCallback<HelixUser> successCallback,
HelixFailureCallback failureCallback)
{
QStringList userIds;
QStringList userLogins{userId};
this->fetchUsers(
userIds, userLogins,
[successCallback,
failureCallback](const std::vector<HelixUser> &users) {
if (users.empty())
{
failureCallback();
return;
}
successCallback(users[0]);
},
failureCallback);
}
void Helix::getUserById(QString userId,
ResultCallback<HelixUser> successCallback,
HelixFailureCallback failureCallback)
{
QStringList userIds{userId};
QStringList userLogins;
this->fetchUsers(
userIds, userLogins,
[successCallback, failureCallback](const auto &users) {
if (users.empty())
{
failureCallback();
return;
}
successCallback(users[0]);
},
failureCallback);
}
void Helix::fetchUsersFollows(
QString fromId, QString toId,
ResultCallback<HelixUsersFollowsResponse> successCallback,
HelixFailureCallback failureCallback)
{
assert(!fromId.isEmpty() || !toId.isEmpty());
QUrlQuery urlQuery;
if (!fromId.isEmpty())
{
urlQuery.addQueryItem("from_id", fromId);
}
if (!toId.isEmpty())
{
urlQuery.addQueryItem("to_id", toId);
}
// TODO: set on success and on error
this->makeRequest("users/follows", urlQuery)
.onSuccess([successCallback, failureCallback](auto result) -> Outcome {
auto root = result.parseJson();
if (root.empty())
{
failureCallback();
return Failure;
}
successCallback(HelixUsersFollowsResponse(root));
return Success;
})
.onError([failureCallback](auto result) {
// TODO: make better xd
failureCallback();
})
.execute();
}
void Helix::getUserFollowers(
QString userId, ResultCallback<HelixUsersFollowsResponse> successCallback,
HelixFailureCallback failureCallback)
{
this->fetchUsersFollows("", userId, successCallback, failureCallback);
}
void Helix::getUserFollow(
QString userId, QString targetId,
ResultCallback<bool, HelixUsersFollowsRecord> successCallback,
HelixFailureCallback failureCallback)
{
this->fetchUsersFollows(
userId, targetId,
[successCallback](const auto &response) {
if (response.data.empty())
{
successCallback(false, HelixUsersFollowsRecord());
return;
}
successCallback(true, response.data[0]);
},
failureCallback);
}
void Helix::fetchStreams(
QStringList userIds, QStringList userLogins,
ResultCallback<std::vector<HelixStream>> successCallback,
HelixFailureCallback failureCallback)
{
QUrlQuery urlQuery;
for (const auto &id : userIds)
{
urlQuery.addQueryItem("user_id", id);
}
for (const auto &login : userLogins)
{
urlQuery.addQueryItem("user_login", login);
}
// TODO: set on success and on error
this->makeRequest("streams", urlQuery)
.onSuccess([successCallback, failureCallback](auto result) -> Outcome {
auto root = result.parseJson();
auto data = root.value("data");
if (!data.isArray())
{
failureCallback();
return Failure;
}
std::vector<HelixStream> streams;
for (const auto &jsonStream : data.toArray())
{
streams.emplace_back(jsonStream.toObject());
}
successCallback(streams);
return Success;
})
.onError([failureCallback](auto result) {
// TODO: make better xd
failureCallback();
})
.execute();
}
void Helix::getStreamById(QString userId,
ResultCallback<bool, HelixStream> successCallback,
HelixFailureCallback failureCallback)
{
QStringList userIds{userId};
QStringList userLogins;
this->fetchStreams(
userIds, userLogins,
[successCallback, failureCallback](const auto &streams) {
if (streams.empty())
{
successCallback(false, HelixStream());
return;
}
successCallback(true, streams[0]);
},
failureCallback);
}
void Helix::getStreamByName(QString userName,
ResultCallback<bool, HelixStream> successCallback,
HelixFailureCallback failureCallback)
{
QStringList userIds;
QStringList userLogins{userName};
this->fetchStreams(
userIds, userLogins,
[successCallback, failureCallback](const auto &streams) {
if (streams.empty())
{
successCallback(false, HelixStream());
return;
}
successCallback(true, streams[0]);
},
failureCallback);
}
///
void Helix::fetchGames(QStringList gameIds, QStringList gameNames,
ResultCallback<std::vector<HelixGame>> successCallback,
HelixFailureCallback failureCallback)
{
assert((gameIds.length() + gameNames.length()) > 0);
QUrlQuery urlQuery;
for (const auto &id : gameIds)
{
urlQuery.addQueryItem("id", id);
}
for (const auto &login : gameNames)
{
urlQuery.addQueryItem("name", login);
}
// TODO: set on success and on error
this->makeRequest("games", urlQuery)
.onSuccess([successCallback, failureCallback](auto result) -> Outcome {
auto root = result.parseJson();
auto data = root.value("data");
if (!data.isArray())
{
failureCallback();
return Failure;
}
std::vector<HelixGame> games;
for (const auto &jsonStream : data.toArray())
{
games.emplace_back(jsonStream.toObject());
}
successCallback(games);
return Success;
})
.onError([failureCallback](auto result) {
// TODO: make better xd
failureCallback();
})
.execute();
}
void Helix::getGameById(QString gameId,
ResultCallback<HelixGame> successCallback,
HelixFailureCallback failureCallback)
{
QStringList gameIds{gameId};
QStringList gameNames;
this->fetchGames(
gameIds, gameNames,
[successCallback, failureCallback](const auto &games) {
if (games.empty())
{
failureCallback();
return;
}
successCallback(games[0]);
},
failureCallback);
}
NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery)
{
assert(!url.startsWith("/"));
if (this->clientId.isEmpty())
{
qDebug()
<< "Helix::makeRequest called without a client ID set BabyRage";
// return boost::none;
}
if (this->oauthToken.isEmpty())
{
qDebug()
<< "Helix::makeRequest called without an oauth token set BabyRage";
// return boost::none;
}
const QString baseUrl("https://api.twitch.tv/helix/");
QUrl fullUrl(baseUrl + url);
fullUrl.setQuery(urlQuery);
return NetworkRequest(fullUrl)
.timeout(5 * 1000)
.header("Accept", "application/json")
.header("Client-ID", this->clientId)
.header("Authorization", "Bearer " + this->oauthToken);
}
void Helix::update(QString clientId, QString oauthToken)
{
this->clientId = clientId;
this->oauthToken = oauthToken;
}
void Helix::initialize()
{
assert(instance == nullptr);
instance = new Helix();
}
Helix *getHelix()
{
assert(instance != nullptr);
return instance;
}
} // namespace chatterino
+198
View File
@@ -0,0 +1,198 @@
#pragma once
#include "common/NetworkRequest.hpp"
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QUrlQuery>
#include <boost/noncopyable.hpp>
#include <boost/optional.hpp>
#include <functional>
#include <vector>
namespace chatterino {
using HelixFailureCallback = std::function<void()>;
template <typename... T>
using ResultCallback = std::function<void(T...)>;
struct HelixUser {
QString id;
QString login;
QString displayName;
QString description;
QString profileImageUrl;
int viewCount;
explicit HelixUser(QJsonObject jsonObject)
: id(jsonObject.value("id").toString())
, login(jsonObject.value("login").toString())
, displayName(jsonObject.value("display_name").toString())
, description(jsonObject.value("description").toString())
, profileImageUrl(jsonObject.value("profile_image_url").toString())
, viewCount(jsonObject.value("view_count").toInt())
{
}
};
struct HelixUsersFollowsRecord {
QString fromId;
QString fromName;
QString toId;
QString toName;
QString followedAt; // date time object
HelixUsersFollowsRecord()
: fromId("")
, fromName("")
, toId("")
, toName("")
, followedAt("")
{
}
explicit HelixUsersFollowsRecord(QJsonObject jsonObject)
: fromId(jsonObject.value("from_id").toString())
, fromName(jsonObject.value("from_name").toString())
, toId(jsonObject.value("to_id").toString())
, toName(jsonObject.value("to_name").toString())
, followedAt(jsonObject.value("followed_at").toString())
{
}
};
struct HelixUsersFollowsResponse {
int total;
std::vector<HelixUsersFollowsRecord> data;
explicit HelixUsersFollowsResponse(QJsonObject jsonObject)
: total(jsonObject.value("total").toInt())
{
const auto &jsonData = jsonObject.value("data").toArray();
std::transform(jsonData.begin(), jsonData.end(),
std::back_inserter(this->data),
[](const QJsonValue &record) {
return HelixUsersFollowsRecord(record.toObject());
});
}
};
struct HelixStream {
QString id; // stream id
QString userId;
QString userName;
QString gameId;
QString type;
QString title;
int viewerCount;
QString startedAt;
QString language;
QString thumbnailUrl;
HelixStream()
: id("")
, userId("")
, userName("")
, gameId("")
, type("")
, title("")
, viewerCount()
, startedAt("")
, language("")
, thumbnailUrl("")
{
}
explicit HelixStream(QJsonObject jsonObject)
: id(jsonObject.value("id").toString())
, userId(jsonObject.value("user_id").toString())
, userName(jsonObject.value("user_name").toString())
, gameId(jsonObject.value("game_id").toString())
, type(jsonObject.value("type").toString())
, title(jsonObject.value("title").toString())
, viewerCount(jsonObject.value("viewer_count").toInt())
, startedAt(jsonObject.value("started_at").toString())
, language(jsonObject.value("language").toString())
, thumbnailUrl(jsonObject.value("thumbnail_url").toString())
{
}
};
struct HelixGame {
QString id; // stream id
QString name;
QString boxArtUrl;
explicit HelixGame(QJsonObject jsonObject)
: id(jsonObject.value("id").toString())
, name(jsonObject.value("name").toString())
, boxArtUrl(jsonObject.value("box_art_url").toString())
{
}
};
class Helix final : boost::noncopyable
{
public:
// https://dev.twitch.tv/docs/api/reference#get-users
void fetchUsers(QStringList userIds, QStringList userLogins,
ResultCallback<std::vector<HelixUser>> successCallback,
HelixFailureCallback failureCallback);
void getUserByName(QString userName,
ResultCallback<HelixUser> successCallback,
HelixFailureCallback failureCallback);
void getUserById(QString userId, ResultCallback<HelixUser> successCallback,
HelixFailureCallback failureCallback);
// https://dev.twitch.tv/docs/api/reference#get-users-follows
void fetchUsersFollows(
QString fromId, QString toId,
ResultCallback<HelixUsersFollowsResponse> successCallback,
HelixFailureCallback failureCallback);
void getUserFollowers(
QString userId,
ResultCallback<HelixUsersFollowsResponse> successCallback,
HelixFailureCallback failureCallback);
void getUserFollow(
QString userId, QString targetId,
ResultCallback<bool, HelixUsersFollowsRecord> successCallback,
HelixFailureCallback failureCallback);
// https://dev.twitch.tv/docs/api/reference#get-streams
void fetchStreams(QStringList userIds, QStringList userLogins,
ResultCallback<std::vector<HelixStream>> successCallback,
HelixFailureCallback failureCallback);
void getStreamById(QString userId,
ResultCallback<bool, HelixStream> successCallback,
HelixFailureCallback failureCallback);
void getStreamByName(QString userName,
ResultCallback<bool, HelixStream> successCallback,
HelixFailureCallback failureCallback);
// https://dev.twitch.tv/docs/api/reference#get-games
void fetchGames(QStringList gameIds, QStringList gameNames,
ResultCallback<std::vector<HelixGame>> successCallback,
HelixFailureCallback failureCallback);
void getGameById(QString gameId, ResultCallback<HelixGame> successCallback,
HelixFailureCallback failureCallback);
void update(QString clientId, QString oauthToken);
static void initialize();
private:
NetworkRequest makeRequest(QString url, QUrlQuery urlQuery);
QString clientId;
QString oauthToken;
};
Helix *getHelix();
} // namespace chatterino
+104
View File
@@ -0,0 +1,104 @@
#include "providers/twitch/api/Kraken.hpp"
#include "common/Outcome.hpp"
#include "providers/twitch/TwitchCommon.hpp"
namespace chatterino {
static Kraken *instance = nullptr;
void Kraken::getChannel(QString userId,
ResultCallback<KrakenChannel> successCallback,
KrakenFailureCallback failureCallback)
{
assert(!userId.isEmpty());
this->makeRequest("channels/" + userId, {})
.onSuccess([successCallback, failureCallback](auto result) -> Outcome {
auto root = result.parseJson();
successCallback(root);
return Success;
})
.onError([failureCallback](auto result) {
// TODO: make better xd
failureCallback();
})
.execute();
}
void Kraken::getUser(QString userId, ResultCallback<KrakenUser> successCallback,
KrakenFailureCallback failureCallback)
{
assert(!userId.isEmpty());
this->makeRequest("users/" + userId, {})
.onSuccess([successCallback, failureCallback](auto result) -> Outcome {
auto root = result.parseJson();
successCallback(root);
return Success;
})
.onError([failureCallback](auto result) {
// TODO: make better xd
failureCallback();
})
.execute();
}
NetworkRequest Kraken::makeRequest(QString url, QUrlQuery urlQuery)
{
assert(!url.startsWith("/"));
if (this->clientId.isEmpty())
{
qDebug()
<< "Kraken::makeRequest called without a client ID set BabyRage";
}
const QString baseUrl("https://api.twitch.tv/kraken/");
QUrl fullUrl(baseUrl + url);
fullUrl.setQuery(urlQuery);
if (!this->oauthToken.isEmpty())
{
return NetworkRequest(fullUrl)
.timeout(5 * 1000)
.header("Accept", "application/vnd.twitchtv.v5+json")
.header("Client-ID", this->clientId)
.header("Authorization", "OAuth " + this->oauthToken);
}
return NetworkRequest(fullUrl)
.timeout(5 * 1000)
.header("Accept", "application/vnd.twitchtv.v5+json")
.header("Client-ID", this->clientId);
}
void Kraken::update(QString clientId, QString oauthToken)
{
this->clientId = clientId;
this->oauthToken = oauthToken;
}
void Kraken::initialize()
{
assert(instance == nullptr);
instance = new Kraken();
getKraken()->update(getDefaultClientID(), "");
}
Kraken *getKraken()
{
assert(instance != nullptr);
return instance;
}
} // namespace chatterino
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include "common/NetworkRequest.hpp"
#include <QString>
#include <QStringList>
#include <QUrlQuery>
#include <boost/noncopyable.hpp>
#include <functional>
namespace chatterino {
using KrakenFailureCallback = std::function<void()>;
template <typename... T>
using ResultCallback = std::function<void(T...)>;
struct KrakenChannel {
const QString status;
KrakenChannel(QJsonObject jsonObject)
: status(jsonObject.value("status").toString())
{
}
};
struct KrakenUser {
const QString createdAt;
KrakenUser(QJsonObject jsonObject)
: createdAt(jsonObject.value("created_at").toString())
{
}
};
class Kraken final : boost::noncopyable
{
public:
// https://dev.twitch.tv/docs/v5/reference/users#follow-channel
void getChannel(QString userId,
ResultCallback<KrakenChannel> resultCallback,
KrakenFailureCallback failureCallback);
// https://dev.twitch.tv/docs/v5/reference/users#get-user-by-id
void getUser(QString userId, ResultCallback<KrakenUser> resultCallback,
KrakenFailureCallback failureCallback);
void update(QString clientId, QString oauthToken);
static void initialize();
private:
NetworkRequest makeRequest(QString url, QUrlQuery urlQuery);
QString clientId;
QString oauthToken;
};
Kraken *getKraken();
} // namespace chatterino
+125
View File
@@ -0,0 +1,125 @@
# Twitch API
this folder describes what sort of API requests we do, what permissions are required for the requests etc
## Kraken (V5)
We use a bunch of Kraken (V5) in Chatterino2.
### Get User
URL: https://dev.twitch.tv/docs/v5/reference/users#get-user-by-id
Migration path: **Unknown**
* We implement this in `providers/twitch/api/Kraken.cpp getUser`
Used in:
* `UserInfoPopup` to get the "created at" date of a user
### Get Channel
URL: https://dev.twitch.tv/docs/v5/reference/channels#get-channel
Migration path: **Unknown**
* We implement this in `providers/twitch/api/Kraken.cpp getChannel`
Used in:
* `TwitchChannel::refreshTitle` to check the current stream title/game of offline channels
### Follow Channel
URL: https://dev.twitch.tv/docs/v5/reference/users#follow-channel
Requires `user_follows_edit` scope
Migration path: **Unknown**
* We implement this API in `providers/twitch/TwitchAccount.cpp followUser`
### Unfollow Channel
URL: https://dev.twitch.tv/docs/v5/reference/users#unfollow-channel
Requires `user_follows_edit` scope
Migration path: **Unknown**
* We implement this API in `providers/twitch/TwitchAccount.cpp unfollowUser`
### Get Cheermotes
URL: https://dev.twitch.tv/docs/v5/reference/bits#get-cheermotes
Migration path: **Not checked**
* We implement this API in `providers/twitch/TwitchChannel.cpp` to resolve a chats available cheer emotes. This helps us parse incoming messages like `pajaCheer1000`
### Get User Block List
URL: https://dev.twitch.tv/docs/v5/reference/users#get-user-block-list
Migration path: **Unknown**
* We use this in `providers/twitch/TwitchAccount.cpp loadIgnores`
### Block User
URL: https://dev.twitch.tv/docs/v5/reference/users#block-user
Requires `user_blocks_edit` scope
Migration path: **Unknown**
* We use this in `providers/twitch/TwitchAccount.cpp ignoreByID`
### Unblock User
URL: https://dev.twitch.tv/docs/v5/reference/users#unblock-user
Requires `user_blocks_edit` scope
Migration path: **Unknown**
* We use this in `providers/twitch/TwitchAccount.cpp unignoreByID`
### Get User Emotes
URL: https://dev.twitch.tv/docs/v5/reference/users#get-user-emotes
Requires `user_subscriptions` scope
Migration path: **Unknown**
* We use this in `providers/twitch/TwitchAccount.cpp loadEmotes` to figure out which emotes a user is allowed to use!
### AUTOMOD APPROVE
**Unofficial** documentation: https://discuss.dev.twitch.tv/t/allowing-others-aka-bots-to-use-twitchbot-reject/8508/2
* We use this in `providers/twitch/TwitchAccount.cpp autoModAllow` to approve an automod deny/allow question
### AUTOMOD DENY
**Unofficial** documentation: https://discuss.dev.twitch.tv/t/allowing-others-aka-bots-to-use-twitchbot-reject/8508/2
* We use this in `providers/twitch/TwitchAccount.cpp autoModDeny` to deny an automod deny/allow question
## Helix
Full Helix API reference: https://dev.twitch.tv/docs/api/reference
### Get Users
URL: https://dev.twitch.tv/docs/api/reference#get-users
* We implement this in `providers/twitch/api/Helix.cpp fetchUsers`.
Used in:
* `UserInfoPopup` to get ID and viewcount of username we clicked
* `CommandController` to power any commands that need to get a user ID
* `Toasts` to get the profile picture of a streamer who just went live
* `TwitchAccount` ignore and unignore features to translate user name to user ID
### Get Users Follows
URL: https://dev.twitch.tv/docs/api/reference#get-users-follows
* We implement this in `providers/twitch/api/Helix.cpp fetchUsersFollows`
Used in:
* `UserInfoPopup` to get number of followers a user has
### Get Streams
URL: https://dev.twitch.tv/docs/api/reference#get-streams
* We implement this in `providers/twitch/api/Helix.cpp fetchStreams`
Used in:
* `TwitchChannel` to get live status, game, title, and viewer count of a channel
* `NotificationController` to provide notifications for channels you might not have open in Chatterino, but are still interested in getting notifications for
## TMI
The TMI api is undocumented.
### Get Chatters
**Undocumented**
* We use this in `widgets/splits/Split.cpp showViewerList`
* We use this in `providers/twitch/TwitchChannel.cpp refreshChatters`