feat: Send messages using Helix API (#5200)
This commit is contained in:
@@ -161,6 +161,7 @@
|
|||||||
- Dev: Added signal to invalidate paint buffers of channel views without forcing a relayout. (#5123)
|
- Dev: Added signal to invalidate paint buffers of channel views without forcing a relayout. (#5123)
|
||||||
- Dev: Specialize `Atomic<std::shared_ptr<T>>` if underlying standard library supports it. (#5133)
|
- Dev: Specialize `Atomic<std::shared_ptr<T>>` if underlying standard library supports it. (#5133)
|
||||||
- Dev: Added the `developer_name` field to the Linux AppData specification. (#5138)
|
- Dev: Added the `developer_name` field to the Linux AppData specification. (#5138)
|
||||||
|
- Dev: Twitch messages can be sent using Twitch's Helix API instead of IRC (disabled by default). (#5200)
|
||||||
|
|
||||||
## 2.4.6
|
## 2.4.6
|
||||||
|
|
||||||
|
|||||||
@@ -392,6 +392,14 @@ public:
|
|||||||
(FailureCallback<HelixSendShoutoutError, QString> failureCallback)),
|
(FailureCallback<HelixSendShoutoutError, QString> failureCallback)),
|
||||||
(override));
|
(override));
|
||||||
|
|
||||||
|
// send message
|
||||||
|
MOCK_METHOD(
|
||||||
|
void, sendChatMessage,
|
||||||
|
(HelixSendMessageArgs args,
|
||||||
|
ResultCallback<HelixSentMessage> successCallback,
|
||||||
|
(FailureCallback<HelixSendMessageError, QString> failureCallback)),
|
||||||
|
(override));
|
||||||
|
|
||||||
MOCK_METHOD(void, update, (QString clientId, QString oauthToken),
|
MOCK_METHOD(void, update, (QString clientId, QString oauthToken),
|
||||||
(override));
|
(override));
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,94 @@ using namespace std::chrono_literals;
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
using namespace chatterino;
|
||||||
|
|
||||||
const QString BTTV_LIVE_UPDATES_URL = "wss://sockets.betterttv.net/ws";
|
const QString BTTV_LIVE_UPDATES_URL = "wss://sockets.betterttv.net/ws";
|
||||||
const QString SEVENTV_EVENTAPI_URL = "wss://events.7tv.io/v3";
|
const QString SEVENTV_EVENTAPI_URL = "wss://events.7tv.io/v3";
|
||||||
|
|
||||||
|
void sendHelixMessage(const std::shared_ptr<TwitchChannel> &channel,
|
||||||
|
const QString &message, const QString &replyParentId = {})
|
||||||
|
{
|
||||||
|
getHelix()->sendChatMessage(
|
||||||
|
{
|
||||||
|
.broadcasterID = channel->roomId(),
|
||||||
|
.senderID =
|
||||||
|
getIApp()->getAccounts()->twitch.getCurrent()->getUserId(),
|
||||||
|
.message = message,
|
||||||
|
.replyParentMessageID = replyParentId,
|
||||||
|
},
|
||||||
|
[weak = std::weak_ptr(channel)](const auto &res) {
|
||||||
|
auto chan = weak.lock();
|
||||||
|
if (!chan)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.isSent)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto errorMessage = [&] {
|
||||||
|
if (res.dropReason)
|
||||||
|
{
|
||||||
|
return makeSystemMessage(res.dropReason->message);
|
||||||
|
}
|
||||||
|
return makeSystemMessage("Your message was not sent.");
|
||||||
|
}();
|
||||||
|
chan->addMessage(errorMessage);
|
||||||
|
},
|
||||||
|
[weak = std::weak_ptr(channel)](auto error, const auto &message) {
|
||||||
|
auto chan = weak.lock();
|
||||||
|
if (!chan)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using Error = decltype(error);
|
||||||
|
|
||||||
|
auto errorMessage = [&]() -> QString {
|
||||||
|
switch (error)
|
||||||
|
{
|
||||||
|
case Error::MissingText:
|
||||||
|
return "You can't send an empty message.";
|
||||||
|
case Error::BadRequest:
|
||||||
|
return "Failed to send message: " + message;
|
||||||
|
case Error::Forbidden:
|
||||||
|
return "You are not allowed to send messages in this "
|
||||||
|
"channel.";
|
||||||
|
case Error::MessageTooLarge:
|
||||||
|
return "Your message was too long.";
|
||||||
|
case Error::UserMissingScope:
|
||||||
|
return "Missing required scope. Re-login with your "
|
||||||
|
"account and try again.";
|
||||||
|
case Error::Forwarded:
|
||||||
|
return message;
|
||||||
|
case Error::Unknown:
|
||||||
|
default:
|
||||||
|
return "Unknown error: " + message;
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
chan->addMessage(makeSystemMessage(errorMessage));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if chat messages should be sent over Helix
|
||||||
|
bool shouldSendHelixChat()
|
||||||
|
{
|
||||||
|
switch (getSettings()->chatSendProtocol)
|
||||||
|
{
|
||||||
|
case ChatSendProtocol::Helix:
|
||||||
|
return true;
|
||||||
|
case ChatSendProtocol::Default:
|
||||||
|
case ChatSendProtocol::IRC:
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
assert(false && "Invalid chat protocol value");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
namespace chatterino {
|
namespace chatterino {
|
||||||
@@ -139,13 +224,24 @@ std::shared_ptr<Channel> TwitchIrcServer::createChannel(
|
|||||||
// no Channel's should live
|
// no Channel's should live
|
||||||
// NOTE: CHANNEL_LIFETIME
|
// NOTE: CHANNEL_LIFETIME
|
||||||
std::ignore = channel->sendMessageSignal.connect(
|
std::ignore = channel->sendMessageSignal.connect(
|
||||||
[this, channel = channel.get()](auto &chan, auto &msg, bool &sent) {
|
[this, channel = std::weak_ptr(channel)](auto &chan, auto &msg,
|
||||||
this->onMessageSendRequested(channel, msg, sent);
|
bool &sent) {
|
||||||
|
auto c = channel.lock();
|
||||||
|
if (!c)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->onMessageSendRequested(c, msg, sent);
|
||||||
});
|
});
|
||||||
std::ignore = channel->sendReplySignal.connect(
|
std::ignore = channel->sendReplySignal.connect(
|
||||||
[this, channel = channel.get()](auto &chan, auto &msg, auto &replyId,
|
[this, channel = std::weak_ptr(channel)](auto &chan, auto &msg,
|
||||||
bool &sent) {
|
auto &replyId, bool &sent) {
|
||||||
this->onReplySendRequested(channel, msg, replyId, sent);
|
auto c = channel.lock();
|
||||||
|
if (!c)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->onReplySendRequested(c, msg, replyId, sent);
|
||||||
});
|
});
|
||||||
|
|
||||||
return channel;
|
return channel;
|
||||||
@@ -436,7 +532,8 @@ bool TwitchIrcServer::hasSeparateWriteConnection() const
|
|||||||
// return getSettings()->twitchSeperateWriteConnection;
|
// return getSettings()->twitchSeperateWriteConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool TwitchIrcServer::prepareToSend(TwitchChannel *channel)
|
bool TwitchIrcServer::prepareToSend(
|
||||||
|
const std::shared_ptr<TwitchChannel> &channel)
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> guard(this->lastMessageMutex_);
|
std::lock_guard<std::mutex> guard(this->lastMessageMutex_);
|
||||||
|
|
||||||
@@ -487,8 +584,9 @@ bool TwitchIrcServer::prepareToSend(TwitchChannel *channel)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void TwitchIrcServer::onMessageSendRequested(TwitchChannel *channel,
|
void TwitchIrcServer::onMessageSendRequested(
|
||||||
const QString &message, bool &sent)
|
const std::shared_ptr<TwitchChannel> &channel, const QString &message,
|
||||||
|
bool &sent)
|
||||||
{
|
{
|
||||||
sent = false;
|
sent = false;
|
||||||
|
|
||||||
@@ -498,13 +596,21 @@ void TwitchIrcServer::onMessageSendRequested(TwitchChannel *channel,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this->sendMessage(channel->getName(), message);
|
if (shouldSendHelixChat())
|
||||||
|
{
|
||||||
|
sendHelixMessage(channel, message);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this->sendMessage(channel->getName(), message);
|
||||||
|
}
|
||||||
|
|
||||||
sent = true;
|
sent = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void TwitchIrcServer::onReplySendRequested(TwitchChannel *channel,
|
void TwitchIrcServer::onReplySendRequested(
|
||||||
const QString &message,
|
const std::shared_ptr<TwitchChannel> &channel, const QString &message,
|
||||||
const QString &replyId, bool &sent)
|
const QString &replyId, bool &sent)
|
||||||
{
|
{
|
||||||
sent = false;
|
sent = false;
|
||||||
|
|
||||||
@@ -514,9 +620,15 @@ void TwitchIrcServer::onReplySendRequested(TwitchChannel *channel,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this->sendRawMessage("@reply-parent-msg-id=" + replyId + " PRIVMSG #" +
|
if (shouldSendHelixChat())
|
||||||
channel->getName() + " :" + message);
|
{
|
||||||
|
sendHelixMessage(channel, message, replyId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this->sendRawMessage("@reply-parent-msg-id=" + replyId + " PRIVMSG #" +
|
||||||
|
channel->getName() + " :" + message);
|
||||||
|
}
|
||||||
sent = true;
|
sent = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,12 +101,13 @@ protected:
|
|||||||
bool hasSeparateWriteConnection() const override;
|
bool hasSeparateWriteConnection() const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void onMessageSendRequested(TwitchChannel *channel, const QString &message,
|
void onMessageSendRequested(const std::shared_ptr<TwitchChannel> &channel,
|
||||||
bool &sent);
|
const QString &message, bool &sent);
|
||||||
void onReplySendRequested(TwitchChannel *channel, const QString &message,
|
void onReplySendRequested(const std::shared_ptr<TwitchChannel> &channel,
|
||||||
const QString &replyId, bool &sent);
|
const QString &message, const QString &replyId,
|
||||||
|
bool &sent);
|
||||||
|
|
||||||
bool prepareToSend(TwitchChannel *channel);
|
bool prepareToSend(const std::shared_ptr<TwitchChannel> &channel);
|
||||||
|
|
||||||
std::mutex lastMessageMutex_;
|
std::mutex lastMessageMutex_;
|
||||||
std::queue<std::chrono::steady_clock::time_point> lastMessagePleb_;
|
std::queue<std::chrono::steady_clock::time_point> lastMessagePleb_;
|
||||||
|
|||||||
@@ -2840,6 +2840,94 @@ void Helix::sendShoutout(
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://dev.twitch.tv/docs/api/reference/#send-chat-message
|
||||||
|
void Helix::sendChatMessage(
|
||||||
|
HelixSendMessageArgs args, ResultCallback<HelixSentMessage> successCallback,
|
||||||
|
FailureCallback<HelixSendMessageError, QString> failureCallback)
|
||||||
|
{
|
||||||
|
using Error = HelixSendMessageError;
|
||||||
|
|
||||||
|
QJsonObject json{{
|
||||||
|
{"broadcaster_id", args.broadcasterID},
|
||||||
|
{"sender_id", args.senderID},
|
||||||
|
{"message", args.message},
|
||||||
|
}};
|
||||||
|
if (!args.replyParentMessageID.isEmpty())
|
||||||
|
{
|
||||||
|
json["reply_parent_message_id"] = args.replyParentMessageID;
|
||||||
|
}
|
||||||
|
|
||||||
|
this->makePost("chat/messages", {})
|
||||||
|
.json(json)
|
||||||
|
.onSuccess([successCallback](const NetworkResult &result) {
|
||||||
|
if (result.status() != 200)
|
||||||
|
{
|
||||||
|
qCWarning(chatterinoTwitch)
|
||||||
|
<< "Success result for sending chat message was "
|
||||||
|
<< result.formatError() << "but we expected it to be 200";
|
||||||
|
}
|
||||||
|
auto json = result.parseJson();
|
||||||
|
|
||||||
|
successCallback(HelixSentMessage(
|
||||||
|
json.value("data").toArray().at(0).toObject()));
|
||||||
|
})
|
||||||
|
.onError([failureCallback](const NetworkResult &result) -> void {
|
||||||
|
if (!result.status())
|
||||||
|
{
|
||||||
|
failureCallback(Error::Unknown, result.formatError());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto obj = result.parseJson();
|
||||||
|
auto message =
|
||||||
|
obj["message"].toString(u"Twitch internal server error"_s);
|
||||||
|
|
||||||
|
switch (*result.status())
|
||||||
|
{
|
||||||
|
case 400: {
|
||||||
|
failureCallback(Error::Unknown, message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 401: {
|
||||||
|
if (message.startsWith("User access token requires the",
|
||||||
|
Qt::CaseInsensitive))
|
||||||
|
{
|
||||||
|
failureCallback(Error::UserMissingScope, message);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
failureCallback(Error::Forwarded, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 403: {
|
||||||
|
failureCallback(Error::Forbidden, message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 422: {
|
||||||
|
failureCallback(Error::MessageTooLarge, message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 500: {
|
||||||
|
failureCallback(Error::Unknown, message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
qCWarning(chatterinoTwitch)
|
||||||
|
<< "Helix send chat message, unhandled error data:"
|
||||||
|
<< result.formatError() << result.getData() << obj;
|
||||||
|
failureCallback(Error::Unknown, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
NetworkRequest Helix::makeRequest(const QString &url, const QUrlQuery &urlQuery,
|
NetworkRequest Helix::makeRequest(const QString &url, const QUrlQuery &urlQuery,
|
||||||
NetworkRequestType type)
|
NetworkRequestType type)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -412,6 +412,41 @@ struct HelixGlobalBadges {
|
|||||||
|
|
||||||
using HelixChannelBadges = HelixGlobalBadges;
|
using HelixChannelBadges = HelixGlobalBadges;
|
||||||
|
|
||||||
|
struct HelixDropReason {
|
||||||
|
QString code;
|
||||||
|
QString message;
|
||||||
|
|
||||||
|
explicit HelixDropReason(const QJsonObject &jsonObject)
|
||||||
|
: code(jsonObject["code"].toString())
|
||||||
|
, message(jsonObject["message"].toString())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HelixSentMessage {
|
||||||
|
QString id;
|
||||||
|
bool isSent;
|
||||||
|
std::optional<HelixDropReason> dropReason;
|
||||||
|
|
||||||
|
explicit HelixSentMessage(const QJsonObject &jsonObject)
|
||||||
|
: id(jsonObject["message_id"].toString())
|
||||||
|
, isSent(jsonObject["is_sent"].toBool())
|
||||||
|
, dropReason(jsonObject.contains("drop_reason")
|
||||||
|
? std::optional(HelixDropReason(
|
||||||
|
jsonObject["drop_reason"].toObject()))
|
||||||
|
: std::nullopt)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HelixSendMessageArgs {
|
||||||
|
QString broadcasterID;
|
||||||
|
QString senderID;
|
||||||
|
QString message;
|
||||||
|
/// Optional
|
||||||
|
QString replyParentMessageID;
|
||||||
|
};
|
||||||
|
|
||||||
enum class HelixAnnouncementColor {
|
enum class HelixAnnouncementColor {
|
||||||
Blue,
|
Blue,
|
||||||
Green,
|
Green,
|
||||||
@@ -696,6 +731,19 @@ enum class HelixGetGlobalBadgesError {
|
|||||||
Forwarded,
|
Forwarded,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum class HelixSendMessageError {
|
||||||
|
Unknown,
|
||||||
|
|
||||||
|
MissingText,
|
||||||
|
BadRequest,
|
||||||
|
Forbidden,
|
||||||
|
MessageTooLarge,
|
||||||
|
UserMissingScope,
|
||||||
|
|
||||||
|
// The error message is forwarded directly from the Twitch API
|
||||||
|
Forwarded,
|
||||||
|
};
|
||||||
|
|
||||||
struct HelixError {
|
struct HelixError {
|
||||||
/// Text version of the HTTP error that happened (e.g. Bad Request)
|
/// Text version of the HTTP error that happened (e.g. Bad Request)
|
||||||
QString error;
|
QString error;
|
||||||
@@ -1027,6 +1075,12 @@ public:
|
|||||||
ResultCallback<> successCallback,
|
ResultCallback<> successCallback,
|
||||||
FailureCallback<HelixSendShoutoutError, QString> failureCallback) = 0;
|
FailureCallback<HelixSendShoutoutError, QString> failureCallback) = 0;
|
||||||
|
|
||||||
|
/// https://dev.twitch.tv/docs/api/reference/#send-chat-message
|
||||||
|
virtual void sendChatMessage(
|
||||||
|
HelixSendMessageArgs args,
|
||||||
|
ResultCallback<HelixSentMessage> successCallback,
|
||||||
|
FailureCallback<HelixSendMessageError, QString> failureCallback) = 0;
|
||||||
|
|
||||||
virtual void update(QString clientId, QString oauthToken) = 0;
|
virtual void update(QString clientId, QString oauthToken) = 0;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
@@ -1341,6 +1395,12 @@ public:
|
|||||||
ResultCallback<> successCallback,
|
ResultCallback<> successCallback,
|
||||||
FailureCallback<HelixSendShoutoutError, QString> failureCallback) final;
|
FailureCallback<HelixSendShoutoutError, QString> failureCallback) final;
|
||||||
|
|
||||||
|
/// https://dev.twitch.tv/docs/api/reference/#send-chat-message
|
||||||
|
void sendChatMessage(
|
||||||
|
HelixSendMessageArgs args,
|
||||||
|
ResultCallback<HelixSentMessage> successCallback,
|
||||||
|
FailureCallback<HelixSendMessageError, QString> failureCallback) final;
|
||||||
|
|
||||||
void update(QString clientId, QString oauthToken) final;
|
void update(QString clientId, QString oauthToken) final;
|
||||||
|
|
||||||
static void initialize();
|
static void initialize();
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ enum UsernameRightClickBehavior : int {
|
|||||||
Ignore = 2,
|
Ignore = 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum class ChatSendProtocol : int {
|
||||||
|
Default = 0,
|
||||||
|
IRC = 1,
|
||||||
|
Helix = 2,
|
||||||
|
};
|
||||||
|
|
||||||
/// Settings which are availlable for reading and writing on the gui thread.
|
/// Settings which are availlable for reading and writing on the gui thread.
|
||||||
// These settings are still accessed concurrently in the code but it is bad practice.
|
// These settings are still accessed concurrently in the code but it is bad practice.
|
||||||
class Settings
|
class Settings
|
||||||
@@ -524,6 +530,9 @@ public:
|
|||||||
HelixTimegateOverride::Timegate,
|
HelixTimegateOverride::Timegate,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
EnumStringSetting<ChatSendProtocol> chatSendProtocol = {
|
||||||
|
"/misc/chatSendProtocol", ChatSendProtocol::Default};
|
||||||
|
|
||||||
BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0};
|
BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0};
|
||||||
|
|
||||||
EnumSetting<ThumbnailPreviewMode> emotesTooltipPreview = {
|
EnumSetting<ThumbnailPreviewMode> emotesTooltipPreview = {
|
||||||
|
|||||||
@@ -1243,6 +1243,13 @@ void GeneralPage::initLayout(GeneralPageView &layout)
|
|||||||
helixTimegateModerators->setMinimumWidth(
|
helixTimegateModerators->setMinimumWidth(
|
||||||
helixTimegateModerators->minimumSizeHint().width());
|
helixTimegateModerators->minimumSizeHint().width());
|
||||||
|
|
||||||
|
layout.addDropdownEnumClass<ChatSendProtocol>(
|
||||||
|
"Chat send protocol", magic_enum::enum_names<ChatSendProtocol>(),
|
||||||
|
s.chatSendProtocol,
|
||||||
|
"'Helix' will use Twitch's Helix API to send message. 'IRC' will use "
|
||||||
|
"IRC to send messages.",
|
||||||
|
{});
|
||||||
|
|
||||||
layout.addCheckbox(
|
layout.addCheckbox(
|
||||||
"Show send message button", s.showSendButton, false,
|
"Show send message button", s.showSendButton, false,
|
||||||
"Show a Send button next to each split input that can be "
|
"Show a Send button next to each split input that can be "
|
||||||
|
|||||||
Reference in New Issue
Block a user