mirror of
https://github.com/Chatterino/chatterino2.git
synced 2024-11-13 19:49:51 +01:00
feat: Send messages using Helix API (#5200)
This commit is contained in:
parent
1e2c943ae9
commit
0cfd25ce8e
|
@ -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
|
||||
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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_;
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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 "
|
||||
|
|
Loading…
Reference in a new issue