Migrate /w to Helix API (#4052)
This commit is contained in:
@@ -59,6 +59,7 @@
|
|||||||
- Minor: Migrated /raid command to Helix API. Chat command will continue to be used until February 11th 2023. (#4029)
|
- Minor: Migrated /raid command to Helix API. Chat command will continue to be used until February 11th 2023. (#4029)
|
||||||
- Minor: Migrated /ban to Helix API. (#4049)
|
- Minor: Migrated /ban to Helix API. (#4049)
|
||||||
- Minor: Migrated /timeout to Helix API. (#4049)
|
- Minor: Migrated /timeout to Helix API. (#4049)
|
||||||
|
- Minor: Migrated /w to Helix API. Chat command will continue to be used until February 11th 2023. (#4052)
|
||||||
- Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716)
|
- Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716)
|
||||||
- Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028)
|
- Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028)
|
||||||
- Bugfix: Fixed a crash that can occur when closing and quickly reopening a split, then running a command. (#3852)
|
- Bugfix: Fixed a crash that can occur when closing and quickly reopening a split, then running a command. (#3852)
|
||||||
|
|||||||
@@ -181,28 +181,151 @@ bool appendWhisperMessageWordsLocally(const QStringList &words)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool appendWhisperMessageStringLocally(const QString &textNoEmoji)
|
bool useIrcForWhisperCommand()
|
||||||
{
|
{
|
||||||
QString text = getApp()->emotes->emojis.replaceShortCodes(textNoEmoji);
|
switch (getSettings()->helixTimegateWhisper.getValue())
|
||||||
QStringList words = text.split(' ', Qt::SkipEmptyParts);
|
|
||||||
|
|
||||||
if (words.length() == 0)
|
|
||||||
{
|
{
|
||||||
return false;
|
case HelixTimegateOverride::Timegate: {
|
||||||
}
|
if (areIRCCommandsStillAvailable())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
QString commandName = words[0];
|
// fall through to Helix logic
|
||||||
|
|
||||||
if (TWITCH_WHISPER_COMMANDS.contains(commandName, Qt::CaseInsensitive))
|
|
||||||
{
|
|
||||||
if (words.length() > 2)
|
|
||||||
{
|
|
||||||
return appendWhisperMessageWordsLocally(words);
|
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case HelixTimegateOverride::AlwaysUseIRC: {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case HelixTimegateOverride::AlwaysUseHelix: {
|
||||||
|
// do nothing and fall through to Helix logic
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString runWhisperCommand(const QStringList &words, const ChannelPtr &channel)
|
||||||
|
{
|
||||||
|
if (words.size() < 3)
|
||||||
|
{
|
||||||
|
channel->addMessage(
|
||||||
|
makeSystemMessage("Usage: /w <username> <message>"));
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
auto currentUser = getApp()->accounts->twitch.getCurrent();
|
||||||
|
if (currentUser->isAnon())
|
||||||
|
{
|
||||||
|
channel->addMessage(
|
||||||
|
makeSystemMessage("You must be logged in to send a whisper!"));
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
auto target = words.at(1);
|
||||||
|
stripChannelName(target);
|
||||||
|
auto message = words.mid(2).join(' ');
|
||||||
|
|
||||||
|
if (useIrcForWhisperCommand())
|
||||||
|
{
|
||||||
|
if (channel->isTwitchChannel())
|
||||||
|
{
|
||||||
|
appendWhisperMessageWordsLocally(words);
|
||||||
|
sendWhisperMessage(words.join(' '));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
channel->addMessage(makeSystemMessage(
|
||||||
|
"You can only send whispers from Twitch channels."));
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
getHelix()->getUserByName(
|
||||||
|
target,
|
||||||
|
[channel, currentUser, target, message, words](const auto &targetUser) {
|
||||||
|
getHelix()->sendWhisper(
|
||||||
|
currentUser->getUserId(), targetUser.id, message,
|
||||||
|
[words] {
|
||||||
|
appendWhisperMessageWordsLocally(words);
|
||||||
|
},
|
||||||
|
[channel, target, targetUser](auto error, auto message) {
|
||||||
|
using Error = HelixWhisperError;
|
||||||
|
|
||||||
|
QString errorMessage = "Failed to send whisper - ";
|
||||||
|
|
||||||
|
switch (error)
|
||||||
|
{
|
||||||
|
case Error::NoVerifiedPhone: {
|
||||||
|
errorMessage +=
|
||||||
|
"Due to Twitch restrictions, you are now "
|
||||||
|
"required to have a verified phone number "
|
||||||
|
"to send whispers. You can add a phone "
|
||||||
|
"number in Twitch settings. "
|
||||||
|
"https://www.twitch.tv/settings/security";
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Error::RecipientBlockedUser: {
|
||||||
|
errorMessage +=
|
||||||
|
"The recipient doesn't allow whispers "
|
||||||
|
"from strangers or you directly.";
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Error::WhisperSelf: {
|
||||||
|
errorMessage += "You cannot whisper yourself.";
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Error::Forwarded: {
|
||||||
|
errorMessage += message;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Error::Ratelimited: {
|
||||||
|
errorMessage +=
|
||||||
|
"You may only whisper a maximum of 40 "
|
||||||
|
"unique recipients per day. Within the "
|
||||||
|
"per day limit, you may whisper a "
|
||||||
|
"maximum of 3 whispers per second and "
|
||||||
|
"a maximum of 100 whispers per minute.";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Error::UserMissingScope: {
|
||||||
|
// TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE
|
||||||
|
errorMessage += "Missing required scope. "
|
||||||
|
"Re-login with your "
|
||||||
|
"account and try again.";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Error::UserNotAuthorized: {
|
||||||
|
// TODO(pajlada): Phrase MISSING_PERMISSION
|
||||||
|
errorMessage += "You don't have permission to "
|
||||||
|
"perform that action.";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Error::Unknown: {
|
||||||
|
errorMessage += "An unknown error has occurred.";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
channel->addMessage(makeSystemMessage(errorMessage));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[channel] {
|
||||||
|
channel->addMessage(
|
||||||
|
makeSystemMessage("No user matching that username."));
|
||||||
|
});
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
using VariableReplacer = std::function<QString(
|
using VariableReplacer = std::function<QString(
|
||||||
const QString &, const ChannelPtr &, const Message *)>;
|
const QString &, const ChannelPtr &, const Message *)>;
|
||||||
|
|
||||||
@@ -2700,6 +2823,13 @@ void CommandController::initialize(Settings &, Paths &paths)
|
|||||||
|
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const auto &cmd : TWITCH_WHISPER_COMMANDS)
|
||||||
|
{
|
||||||
|
this->registerCommand(cmd, [](const QStringList &words, auto channel) {
|
||||||
|
return runWhisperCommand(words, channel);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void CommandController::save()
|
void CommandController::save()
|
||||||
@@ -2728,26 +2858,6 @@ QString CommandController::execCommand(const QString &textNoEmoji,
|
|||||||
|
|
||||||
QString commandName = words[0];
|
QString commandName = words[0];
|
||||||
|
|
||||||
// works in a valid Twitch channel and /whispers, etc...
|
|
||||||
if (!dryRun && channel->isTwitchChannel())
|
|
||||||
{
|
|
||||||
if (TWITCH_WHISPER_COMMANDS.contains(commandName, Qt::CaseInsensitive))
|
|
||||||
{
|
|
||||||
if (words.length() > 2)
|
|
||||||
{
|
|
||||||
appendWhisperMessageWordsLocally(words);
|
|
||||||
sendWhisperMessage(text);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
channel->addMessage(
|
|
||||||
makeSystemMessage("Usage: /w <username> <message>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
// check if user command exists
|
// check if user command exists
|
||||||
const auto it = this->userCommands_.find(commandName);
|
const auto it = this->userCommands_.find(commandName);
|
||||||
@@ -2811,7 +2921,7 @@ void CommandController::registerCommand(QString commandName,
|
|||||||
}
|
}
|
||||||
|
|
||||||
QString CommandController::execCustomCommand(
|
QString CommandController::execCustomCommand(
|
||||||
const QStringList &words, const Command &command, bool dryRun,
|
const QStringList &words, const Command &command, bool /* dryRun */,
|
||||||
ChannelPtr channel, const Message *message,
|
ChannelPtr channel, const Message *message,
|
||||||
std::unordered_map<QString, QString> context)
|
std::unordered_map<QString, QString> context)
|
||||||
{
|
{
|
||||||
@@ -2906,17 +3016,7 @@ QString CommandController::execCustomCommand(
|
|||||||
result = result.mid(1);
|
result = result.mid(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
auto res = result.replace("{{", "{");
|
return result.replace("{{", "{");
|
||||||
|
|
||||||
if (dryRun || !appendWhisperMessageStringLocally(res))
|
|
||||||
{
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
sendWhisperMessage(res);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QStringList CommandController::getDefaultChatterinoCommandList()
|
QStringList CommandController::getDefaultChatterinoCommandList()
|
||||||
|
|||||||
@@ -911,8 +911,20 @@ std::vector<MessagePtr> IrcMessageHandler::parseNoticeMessage(
|
|||||||
// default case
|
// default case
|
||||||
std::vector<MessagePtr> builtMessages;
|
std::vector<MessagePtr> builtMessages;
|
||||||
|
|
||||||
builtMessages.emplace_back(makeSystemMessage(
|
auto content = message->content();
|
||||||
message->content(), calculateMessageTime(message).time()));
|
if (content.startsWith(
|
||||||
|
"Your settings prevent you from sending this whisper",
|
||||||
|
Qt::CaseInsensitive) &&
|
||||||
|
getSettings()->helixTimegateWhisper.getValue() ==
|
||||||
|
HelixTimegateOverride::Timegate)
|
||||||
|
{
|
||||||
|
content =
|
||||||
|
content +
|
||||||
|
" Consider setting the \"Helix timegate /w "
|
||||||
|
"behaviour\" to \"Always use Helix\" in your Chatterino settings.";
|
||||||
|
}
|
||||||
|
builtMessages.emplace_back(
|
||||||
|
makeSystemMessage(content, calculateMessageTime(message).time()));
|
||||||
|
|
||||||
return builtMessages;
|
return builtMessages;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1812,6 +1812,112 @@ void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID,
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://dev.twitch.tv/docs/api/reference#send-whisper
|
||||||
|
void Helix::sendWhisper(
|
||||||
|
QString fromUserID, QString toUserID, QString message,
|
||||||
|
ResultCallback<> successCallback,
|
||||||
|
FailureCallback<HelixWhisperError, QString> failureCallback)
|
||||||
|
{
|
||||||
|
using Error = HelixWhisperError;
|
||||||
|
|
||||||
|
QUrlQuery urlQuery;
|
||||||
|
|
||||||
|
urlQuery.addQueryItem("from_user_id", fromUserID);
|
||||||
|
urlQuery.addQueryItem("to_user_id", toUserID);
|
||||||
|
|
||||||
|
QJsonObject payload;
|
||||||
|
payload["message"] = message;
|
||||||
|
|
||||||
|
this->makeRequest("whispers", urlQuery)
|
||||||
|
.type(NetworkRequestType::Post)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.payload(QJsonDocument(payload).toJson(QJsonDocument::Compact))
|
||||||
|
.onSuccess([successCallback](auto result) -> Outcome {
|
||||||
|
if (result.status() != 204)
|
||||||
|
{
|
||||||
|
qCWarning(chatterinoTwitch)
|
||||||
|
<< "Success result for sending a whisper was"
|
||||||
|
<< result.status() << "but we expected it to be 204";
|
||||||
|
}
|
||||||
|
// we don't care about the response
|
||||||
|
successCallback();
|
||||||
|
return Success;
|
||||||
|
})
|
||||||
|
.onError([failureCallback](auto result) {
|
||||||
|
auto obj = result.parseJson();
|
||||||
|
auto message = obj.value("message").toString();
|
||||||
|
|
||||||
|
switch (result.status())
|
||||||
|
{
|
||||||
|
case 400: {
|
||||||
|
if (message.startsWith("A user cannot whisper themself",
|
||||||
|
Qt::CaseInsensitive))
|
||||||
|
{
|
||||||
|
failureCallback(Error::WhisperSelf, message);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
failureCallback(Error::Forwarded, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 401: {
|
||||||
|
if (message.startsWith("Missing scope",
|
||||||
|
Qt::CaseInsensitive))
|
||||||
|
{
|
||||||
|
// Handle this error specifically because its API error is especially unfriendly
|
||||||
|
failureCallback(Error::UserMissingScope, message);
|
||||||
|
}
|
||||||
|
else if (message.startsWith("the sender does not have a "
|
||||||
|
"verified phone number",
|
||||||
|
Qt::CaseInsensitive))
|
||||||
|
{
|
||||||
|
failureCallback(Error::NoVerifiedPhone, message);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
failureCallback(Error::Forwarded, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 403: {
|
||||||
|
if (message.startsWith("The recipient's settings prevent "
|
||||||
|
"this sender from whispering them",
|
||||||
|
Qt::CaseInsensitive))
|
||||||
|
{
|
||||||
|
failureCallback(Error::RecipientBlockedUser, message);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
failureCallback(Error::UserNotAuthorized, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 404: {
|
||||||
|
failureCallback(Error::Forwarded, message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 429: {
|
||||||
|
failureCallback(Error::Ratelimited, message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
qCDebug(chatterinoTwitch)
|
||||||
|
<< "Unhandled error banning user:" << result.status()
|
||||||
|
<< result.getData() << obj;
|
||||||
|
failureCallback(Error::Unknown, message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery)
|
NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery)
|
||||||
{
|
{
|
||||||
assert(!url.startsWith("/"));
|
assert(!url.startsWith("/"));
|
||||||
|
|||||||
@@ -478,6 +478,19 @@ enum class HelixBanUserError { // /timeout, /ban
|
|||||||
Forwarded,
|
Forwarded,
|
||||||
}; // /timeout, /ban
|
}; // /timeout, /ban
|
||||||
|
|
||||||
|
enum class HelixWhisperError { // /w
|
||||||
|
Unknown,
|
||||||
|
UserMissingScope,
|
||||||
|
UserNotAuthorized,
|
||||||
|
Ratelimited,
|
||||||
|
NoVerifiedPhone,
|
||||||
|
RecipientBlockedUser,
|
||||||
|
WhisperSelf,
|
||||||
|
|
||||||
|
// The error message is forwarded directly from the Twitch API
|
||||||
|
Forwarded,
|
||||||
|
}; // /w
|
||||||
|
|
||||||
class IHelix
|
class IHelix
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -719,6 +732,13 @@ public:
|
|||||||
ResultCallback<> successCallback,
|
ResultCallback<> successCallback,
|
||||||
FailureCallback<HelixBanUserError, QString> failureCallback) = 0;
|
FailureCallback<HelixBanUserError, QString> failureCallback) = 0;
|
||||||
|
|
||||||
|
// Send a whisper
|
||||||
|
// https://dev.twitch.tv/docs/api/reference#send-whisper
|
||||||
|
virtual void sendWhisper(
|
||||||
|
QString fromUserID, QString toUserID, QString message,
|
||||||
|
ResultCallback<> successCallback,
|
||||||
|
FailureCallback<HelixWhisperError, QString> failureCallback) = 0;
|
||||||
|
|
||||||
virtual void update(QString clientId, QString oauthToken) = 0;
|
virtual void update(QString clientId, QString oauthToken) = 0;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
@@ -961,6 +981,13 @@ public:
|
|||||||
ResultCallback<> successCallback,
|
ResultCallback<> successCallback,
|
||||||
FailureCallback<HelixBanUserError, QString> failureCallback) final;
|
FailureCallback<HelixBanUserError, QString> failureCallback) final;
|
||||||
|
|
||||||
|
// Send a whisper
|
||||||
|
// https://dev.twitch.tv/docs/api/reference#send-whisper
|
||||||
|
void sendWhisper(
|
||||||
|
QString fromUserID, QString toUserID, QString message,
|
||||||
|
ResultCallback<> successCallback,
|
||||||
|
FailureCallback<HelixWhisperError, QString> failureCallback) final;
|
||||||
|
|
||||||
void update(QString clientId, QString oauthToken) final;
|
void update(QString clientId, QString oauthToken) final;
|
||||||
|
|
||||||
static void initialize();
|
static void initialize();
|
||||||
|
|||||||
@@ -414,6 +414,10 @@ public:
|
|||||||
"/misc/twitch/helix-timegate/raid",
|
"/misc/twitch/helix-timegate/raid",
|
||||||
HelixTimegateOverride::Timegate,
|
HelixTimegateOverride::Timegate,
|
||||||
};
|
};
|
||||||
|
EnumSetting<HelixTimegateOverride> helixTimegateWhisper = {
|
||||||
|
"/misc/twitch/helix-timegate/whisper",
|
||||||
|
HelixTimegateOverride::Timegate,
|
||||||
|
};
|
||||||
|
|
||||||
IntSetting emotesTooltipPreview = {"/misc/emotesTooltipPreview", 1};
|
IntSetting emotesTooltipPreview = {"/misc/emotesTooltipPreview", 1};
|
||||||
BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0};
|
BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0};
|
||||||
|
|||||||
@@ -766,6 +766,17 @@ void GeneralPage::initLayout(GeneralPageView &layout)
|
|||||||
helixTimegateRaid->setMinimumWidth(
|
helixTimegateRaid->setMinimumWidth(
|
||||||
helixTimegateRaid->minimumSizeHint().width());
|
helixTimegateRaid->minimumSizeHint().width());
|
||||||
|
|
||||||
|
auto *helixTimegateWhisper =
|
||||||
|
layout.addDropdown<std::underlying_type<HelixTimegateOverride>::type>(
|
||||||
|
"Helix timegate /w behaviour",
|
||||||
|
{"Timegate", "Always use IRC", "Always use Helix"},
|
||||||
|
s.helixTimegateWhisper,
|
||||||
|
helixTimegateGetValue, //
|
||||||
|
helixTimegateSetValue, //
|
||||||
|
false);
|
||||||
|
helixTimegateWhisper->setMinimumWidth(
|
||||||
|
helixTimegateWhisper->minimumSizeHint().width());
|
||||||
|
|
||||||
layout.addStretch();
|
layout.addStretch();
|
||||||
|
|
||||||
// invisible element for width
|
// invisible element for width
|
||||||
|
|||||||
@@ -345,6 +345,14 @@ public:
|
|||||||
(FailureCallback<HelixBanUserError, QString> failureCallback)),
|
(FailureCallback<HelixBanUserError, QString> failureCallback)),
|
||||||
(override)); // /timeout, /ban
|
(override)); // /timeout, /ban
|
||||||
|
|
||||||
|
// /w
|
||||||
|
// The extra parenthesis around the failure callback is because its type contains a comma
|
||||||
|
MOCK_METHOD(void, sendWhisper,
|
||||||
|
(QString fromUserID, QString toUserID, QString message,
|
||||||
|
ResultCallback<> successCallback,
|
||||||
|
(FailureCallback<HelixWhisperError, QString> failureCallback)),
|
||||||
|
(override)); // /w
|
||||||
|
|
||||||
MOCK_METHOD(void, update, (QString clientId, QString oauthToken),
|
MOCK_METHOD(void, update, (QString clientId, QString oauthToken),
|
||||||
(override));
|
(override));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user