feat: Send messages using Helix API (#5200)

This commit is contained in:
nerix 2024-02-25 14:45:55 +01:00 committed by GitHub
parent 1e2c943ae9
commit 0cfd25ce8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 306 additions and 20 deletions

View file

@ -161,6 +161,7 @@
- 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: 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

View file

@ -392,6 +392,14 @@ public:
(FailureCallback<HelixSendShoutoutError, QString> failureCallback)),
(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),
(override));

View file

@ -31,9 +31,94 @@ using namespace std::chrono_literals;
namespace {
using namespace chatterino;
const QString BTTV_LIVE_UPDATES_URL = "wss://sockets.betterttv.net/ws";
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 chatterino {
@ -139,13 +224,24 @@ std::shared_ptr<Channel> TwitchIrcServer::createChannel(
// no Channel's should live
// NOTE: CHANNEL_LIFETIME
std::ignore = channel->sendMessageSignal.connect(
[this, channel = channel.get()](auto &chan, auto &msg, bool &sent) {
this->onMessageSendRequested(channel, msg, sent);
[this, channel = std::weak_ptr(channel)](auto &chan, auto &msg,
bool &sent) {
auto c = channel.lock();
if (!c)
{
return;
}
this->onMessageSendRequested(c, msg, sent);
});
std::ignore = channel->sendReplySignal.connect(
[this, channel = channel.get()](auto &chan, auto &msg, auto &replyId,
bool &sent) {
this->onReplySendRequested(channel, msg, replyId, sent);
[this, channel = std::weak_ptr(channel)](auto &chan, auto &msg,
auto &replyId, bool &sent) {
auto c = channel.lock();
if (!c)
{
return;
}
this->onReplySendRequested(c, msg, replyId, sent);
});
return channel;
@ -436,7 +532,8 @@ bool TwitchIrcServer::hasSeparateWriteConnection() const
// 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_);
@ -487,8 +584,9 @@ bool TwitchIrcServer::prepareToSend(TwitchChannel *channel)
return true;
}
void TwitchIrcServer::onMessageSendRequested(TwitchChannel *channel,
const QString &message, bool &sent)
void TwitchIrcServer::onMessageSendRequested(
const std::shared_ptr<TwitchChannel> &channel, const QString &message,
bool &sent)
{
sent = false;
@ -498,12 +596,20 @@ void TwitchIrcServer::onMessageSendRequested(TwitchChannel *channel,
return;
}
if (shouldSendHelixChat())
{
sendHelixMessage(channel, message);
}
else
{
this->sendMessage(channel->getName(), message);
}
sent = true;
}
void TwitchIrcServer::onReplySendRequested(TwitchChannel *channel,
const QString &message,
void TwitchIrcServer::onReplySendRequested(
const std::shared_ptr<TwitchChannel> &channel, const QString &message,
const QString &replyId, bool &sent)
{
sent = false;
@ -514,9 +620,15 @@ void TwitchIrcServer::onReplySendRequested(TwitchChannel *channel,
return;
}
if (shouldSendHelixChat())
{
sendHelixMessage(channel, message, replyId);
}
else
{
this->sendRawMessage("@reply-parent-msg-id=" + replyId + " PRIVMSG #" +
channel->getName() + " :" + message);
}
sent = true;
}

View file

@ -101,12 +101,13 @@ protected:
bool hasSeparateWriteConnection() const override;
private:
void onMessageSendRequested(TwitchChannel *channel, const QString &message,
void onMessageSendRequested(const std::shared_ptr<TwitchChannel> &channel,
const QString &message, bool &sent);
void onReplySendRequested(const std::shared_ptr<TwitchChannel> &channel,
const QString &message, const QString &replyId,
bool &sent);
void onReplySendRequested(TwitchChannel *channel, const QString &message,
const QString &replyId, bool &sent);
bool prepareToSend(TwitchChannel *channel);
bool prepareToSend(const std::shared_ptr<TwitchChannel> &channel);
std::mutex lastMessageMutex_;
std::queue<std::chrono::steady_clock::time_point> lastMessagePleb_;

View file

@ -2840,6 +2840,94 @@ void Helix::sendShoutout(
.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,
NetworkRequestType type)
{

View file

@ -412,6 +412,41 @@ struct 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 {
Blue,
Green,
@ -696,6 +731,19 @@ enum class HelixGetGlobalBadgesError {
Forwarded,
};
enum class HelixSendMessageError {
Unknown,
MissingText,
BadRequest,
Forbidden,
MessageTooLarge,
UserMissingScope,
// The error message is forwarded directly from the Twitch API
Forwarded,
};
struct HelixError {
/// Text version of the HTTP error that happened (e.g. Bad Request)
QString error;
@ -1027,6 +1075,12 @@ public:
ResultCallback<> successCallback,
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;
protected:
@ -1341,6 +1395,12 @@ public:
ResultCallback<> successCallback,
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;
static void initialize();

View file

@ -62,6 +62,12 @@ enum UsernameRightClickBehavior : int {
Ignore = 2,
};
enum class ChatSendProtocol : int {
Default = 0,
IRC = 1,
Helix = 2,
};
/// 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.
class Settings
@ -524,6 +530,9 @@ public:
HelixTimegateOverride::Timegate,
};
EnumStringSetting<ChatSendProtocol> chatSendProtocol = {
"/misc/chatSendProtocol", ChatSendProtocol::Default};
BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0};
EnumSetting<ThumbnailPreviewMode> emotesTooltipPreview = {

View file

@ -1243,6 +1243,13 @@ void GeneralPage::initLayout(GeneralPageView &layout)
helixTimegateModerators->setMinimumWidth(
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(
"Show send message button", s.showSendButton, false,
"Show a Send button next to each split input that can be "