1 #include "providers/twitch/TwitchChannel.hpp"
2 
3 #include "Application.hpp"
4 #include "common/Common.hpp"
5 #include "common/Env.hpp"
6 #include "common/NetworkRequest.hpp"
7 #include "common/QLogging.hpp"
8 #include "controllers/accounts/AccountController.hpp"
9 #include "controllers/notifications/NotificationController.hpp"
10 #include "messages/Message.hpp"
11 #include "providers/bttv/BttvEmotes.hpp"
12 #include "providers/bttv/LoadBttvChannelEmote.hpp"
13 #include "providers/twitch/IrcMessageHandler.hpp"
14 #include "providers/twitch/PubsubClient.hpp"
15 #include "providers/twitch/TwitchCommon.hpp"
16 #include "providers/twitch/TwitchMessageBuilder.hpp"
17 #include "providers/twitch/api/Helix.hpp"
18 #include "providers/twitch/api/Kraken.hpp"
19 #include "singletons/Emotes.hpp"
20 #include "singletons/Settings.hpp"
21 #include "singletons/Toasts.hpp"
22 #include "singletons/WindowManager.hpp"
23 #include "util/FormatTime.hpp"
24 #include "util/PostToThread.hpp"
25 #include "util/QStringHash.hpp"
26 #include "widgets/Window.hpp"
27 
28 #include <rapidjson/document.h>
29 #include <IrcConnection>
30 #include <QJsonArray>
31 #include <QJsonObject>
32 #include <QJsonValue>
33 #include <QThread>
34 #include <QTimer>
35 
36 namespace chatterino {
37 namespace {
38     constexpr char MAGIC_MESSAGE_SUFFIX[] = u8" \U000E0000";
39     constexpr int TITLE_REFRESH_PERIOD = 10000;
40     constexpr int CLIP_CREATION_COOLDOWN = 5000;
41     const QString CLIPS_LINK("https://clips.twitch.tv/%1");
42     const QString CLIPS_FAILURE_CLIPS_DISABLED_TEXT(
43         "Failed to create a clip - the streamer has clips disabled entirely or "
44         "requires a certain subscriber or follower status to create clips.");
45     const QString CLIPS_FAILURE_NOT_AUTHENTICATED_TEXT(
46         "Failed to create a clip - you need to re-authenticate.");
47     const QString CLIPS_FAILURE_UNKNOWN_ERROR_TEXT(
48         "Failed to create a clip - an unknown error occurred.");
49     const QString LOGIN_PROMPT_TEXT("Click here to add your account again.");
50     const Link ACCOUNTS_LINK(Link::OpenAccountsPage, QString());
51 
52     // convertClearchatToNotice takes a Communi::IrcMessage that is a CLEARCHAT command and converts it to a readable NOTICE message
53     // This has historically been done in the Recent Messages API, but this functionality is being moved to Chatterino instead
convertClearchatToNotice(Communi::IrcMessage * message)54     auto convertClearchatToNotice(Communi::IrcMessage *message)
55     {
56         auto channelName = message->parameter(0);
57         QString noticeMessage{};
58         if (message->tags().contains("target-user-id"))
59         {
60             auto target = message->parameter(1);
61 
62             if (message->tags().contains("ban-duration"))
63             {
64                 // User was timed out
65                 noticeMessage =
66                     QString("%1 has been timed out for %2.")
67                         .arg(target)
68                         .arg(formatTime(
69                             message->tag("ban-duration").toString()));
70             }
71             else
72             {
73                 // User was permanently banned
74                 noticeMessage =
75                     QString("%1 has been permanently banned.").arg(target);
76             }
77         }
78         else
79         {
80             // Chat was cleared
81             noticeMessage = "Chat has been cleared by a moderator.";
82         }
83 
84         // rebuild the raw irc message so we can convert it back to an ircmessage again!
85         // this could probably be done in a smarter way
86 
87         auto s = QString(":tmi.twitch.tv NOTICE %1 :%2")
88                      .arg(channelName)
89                      .arg(noticeMessage);
90 
91         auto newMessage = Communi::IrcMessage::fromData(s.toUtf8(), nullptr);
92         newMessage->setTags(message->tags());
93 
94         return newMessage;
95     }
96 
97     // parseRecentMessages takes a json object and returns a vector of
98     // Communi IrcMessages
parseRecentMessages(const QJsonObject & jsonRoot,ChannelPtr channel)99     auto parseRecentMessages(const QJsonObject &jsonRoot, ChannelPtr channel)
100     {
101         QJsonArray jsonMessages = jsonRoot.value("messages").toArray();
102         std::vector<Communi::IrcMessage *> messages;
103 
104         if (jsonMessages.empty())
105             return messages;
106 
107         for (const auto jsonMessage : jsonMessages)
108         {
109             auto content = jsonMessage.toString().toUtf8();
110 
111             auto message = Communi::IrcMessage::fromData(content, nullptr);
112 
113             if (message->command() == "CLEARCHAT")
114             {
115                 message = convertClearchatToNotice(message);
116             }
117 
118             messages.emplace_back(std::move(message));
119         }
120 
121         return messages;
122     }
parseChatters(const QJsonObject & jsonRoot)123     std::pair<Outcome, std::unordered_set<QString>> parseChatters(
124         const QJsonObject &jsonRoot)
125     {
126         static QStringList categories = {"broadcaster", "vips",   "moderators",
127                                          "staff",       "admins", "global_mods",
128                                          "viewers"};
129 
130         auto usernames = std::unordered_set<QString>();
131 
132         // parse json
133         QJsonObject jsonCategories = jsonRoot.value("chatters").toObject();
134 
135         for (const auto &category : categories)
136         {
137             for (auto jsonCategory : jsonCategories.value(category).toArray())
138             {
139                 usernames.insert(jsonCategory.toString());
140             }
141         }
142 
143         return {Success, std::move(usernames)};
144     }
145 }  // namespace
146 
TwitchChannel(const QString & name)147 TwitchChannel::TwitchChannel(const QString &name)
148     : Channel(name, Channel::Type::Twitch)
149     , ChannelChatters(*static_cast<Channel *>(this))
150     , nameOptions{name, name}
151     , subscriptionUrl_("https://www.twitch.tv/subs/" + name)
152     , channelUrl_("https://twitch.tv/" + name)
153     , popoutPlayerUrl_("https://player.twitch.tv/?parent=twitch.tv&channel=" +
154                        name)
155     , bttvEmotes_(std::make_shared<EmoteMap>())
156     , ffzEmotes_(std::make_shared<EmoteMap>())
157     , mod_(false)
158 {
159     qCDebug(chatterinoTwitch) << "[TwitchChannel" << name << "] Opened";
160 
__anonf5c6fb0c0202null161     this->managedConnect(getApp()->accounts->twitch.currentUserChanged, [=] {
162         this->setMod(false);
163     });
164 
165     // pubsub
__anonf5c6fb0c0302null166     this->managedConnect(getApp()->accounts->twitch.currentUserChanged, [=] {
167         this->refreshPubsub();
168     });
169     this->refreshPubsub();
__anonf5c6fb0c0402null170     this->userStateChanged.connect([this] {
171         this->refreshPubsub();
172     });
173 
174     // room id loaded -> refresh live status
__anonf5c6fb0c0502() 175     this->roomIdChanged.connect([this]() {
176         this->refreshPubsub();
177         this->refreshTitle();
178         this->refreshLiveStatus();
179         this->refreshBadges();
180         this->refreshCheerEmotes();
181         this->refreshFFZChannelEmotes(false);
182         this->refreshBTTVChannelEmotes(false);
183     });
184 
185     // timers
__anonf5c6fb0c0602null186     QObject::connect(&this->chattersListTimer_, &QTimer::timeout, [=] {
187         this->refreshChatters();
188     });
189     this->chattersListTimer_.start(5 * 60 * 1000);
190 
__anonf5c6fb0c0702null191     QObject::connect(&this->liveStatusTimer_, &QTimer::timeout, [=] {
192         this->refreshLiveStatus();
193     });
194     this->liveStatusTimer_.start(60 * 1000);
195 
196     // debugging
197 #if 0
198     for (int i = 0; i < 1000; i++) {
199         this->addMessage(makeSystemMessage("asef"));
200     }
201 #endif
202 }
203 
initialize()204 void TwitchChannel::initialize()
205 {
206     this->fetchDisplayName();
207     this->refreshChatters();
208     this->refreshBadges();
209 }
210 
isEmpty() const211 bool TwitchChannel::isEmpty() const
212 {
213     return this->getName().isEmpty();
214 }
215 
canSendMessage() const216 bool TwitchChannel::canSendMessage() const
217 {
218     return !this->isEmpty();
219 }
220 
getDisplayName() const221 const QString &TwitchChannel::getDisplayName() const
222 {
223     return this->nameOptions.displayName;
224 }
225 
setDisplayName(const QString & name)226 void TwitchChannel::setDisplayName(const QString &name)
227 {
228     this->nameOptions.displayName = name;
229 }
230 
getLocalizedName() const231 const QString &TwitchChannel::getLocalizedName() const
232 {
233     return this->nameOptions.localizedName;
234 }
235 
setLocalizedName(const QString & name)236 void TwitchChannel::setLocalizedName(const QString &name)
237 {
238     this->nameOptions.localizedName = name;
239 }
240 
refreshBTTVChannelEmotes(bool manualRefresh)241 void TwitchChannel::refreshBTTVChannelEmotes(bool manualRefresh)
242 {
243     BttvEmotes::loadChannel(
244         weakOf<Channel>(this), this->roomId(), this->getLocalizedName(),
245         [this, weak = weakOf<Channel>(this)](auto &&emoteMap) {
246             if (auto shared = weak.lock())
247                 this->bttvEmotes_.set(
248                     std::make_shared<EmoteMap>(std::move(emoteMap)));
249         },
250         manualRefresh);
251 }
252 
refreshFFZChannelEmotes(bool manualRefresh)253 void TwitchChannel::refreshFFZChannelEmotes(bool manualRefresh)
254 {
255     FfzEmotes::loadChannel(
256         weakOf<Channel>(this), this->roomId(),
257         [this, weak = weakOf<Channel>(this)](auto &&emoteMap) {
258             if (auto shared = weak.lock())
259                 this->ffzEmotes_.set(
260                     std::make_shared<EmoteMap>(std::move(emoteMap)));
261         },
262         [this, weak = weakOf<Channel>(this)](auto &&modBadge) {
263             if (auto shared = weak.lock())
264             {
265                 this->ffzCustomModBadge_.set(std::move(modBadge));
266             }
267         },
268         [this, weak = weakOf<Channel>(this)](auto &&vipBadge) {
269             if (auto shared = weak.lock())
270             {
271                 this->ffzCustomVipBadge_.set(std::move(vipBadge));
272             }
273         },
274         manualRefresh);
275 }
276 
addChannelPointReward(const ChannelPointReward & reward)277 void TwitchChannel::addChannelPointReward(const ChannelPointReward &reward)
278 {
279     assertInGuiThread();
280 
281     if (!reward.hasParsedSuccessfully)
282     {
283         return;
284     }
285 
286     if (!reward.isUserInputRequired)
287     {
288         MessageBuilder builder;
289         TwitchMessageBuilder::appendChannelPointRewardMessage(
290             reward, &builder, this->isMod(), this->isBroadcaster());
291         this->addMessage(builder.release());
292         return;
293     }
294 
295     bool result;
296     {
297         auto channelPointRewards = this->channelPointRewards_.access();
298         result = channelPointRewards->try_emplace(reward.id, reward).second;
299     }
300     if (result)
301     {
302         this->channelPointRewardAdded.invoke(reward);
303     }
304 }
305 
isChannelPointRewardKnown(const QString & rewardId)306 bool TwitchChannel::isChannelPointRewardKnown(const QString &rewardId)
307 {
308     const auto &pointRewards = this->channelPointRewards_.accessConst();
309     const auto &it = pointRewards->find(rewardId);
310     return it != pointRewards->end();
311 }
312 
channelPointReward(const QString & rewardId) const313 boost::optional<ChannelPointReward> TwitchChannel::channelPointReward(
314     const QString &rewardId) const
315 {
316     auto rewards = this->channelPointRewards_.accessConst();
317     auto it = rewards->find(rewardId);
318 
319     if (it == rewards->end())
320         return boost::none;
321     return it->second;
322 }
323 
sendMessage(const QString & message)324 void TwitchChannel::sendMessage(const QString &message)
325 {
326     auto app = getApp();
327 
328     if (!app->accounts->twitch.isLoggedIn())
329     {
330         if (message.isEmpty())
331         {
332             return;
333         }
334 
335         const auto linkColor = MessageColor(MessageColor::Link);
336         const auto accountsLink = Link(Link::OpenAccountsPage, QString());
337         const auto currentUser = getApp()->accounts->twitch.getCurrent();
338         const auto expirationText =
339             QString("You need to log in to send messages. You can link your "
340                     "Twitch account");
341         const auto loginPromptText = QString("in the settings.");
342 
343         auto builder = MessageBuilder();
344         builder.message().flags.set(MessageFlag::System);
345         builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
346 
347         builder.emplace<TimestampElement>();
348         builder.emplace<TextElement>(expirationText, MessageElementFlag::Text,
349                                      MessageColor::System);
350         builder
351             .emplace<TextElement>(loginPromptText, MessageElementFlag::Text,
352                                   linkColor)
353             ->setLink(accountsLink);
354 
355         this->addMessage(builder.release());
356 
357         return;
358     }
359 
360     qCDebug(chatterinoTwitch)
361         << "[TwitchChannel" << this->getName() << "] Send message:" << message;
362 
363     // Do last message processing
364     QString parsedMessage = app->emotes->emojis.replaceShortCodes(message);
365 
366     parsedMessage = parsedMessage.trimmed();
367 
368     if (parsedMessage.isEmpty())
369     {
370         return;
371     }
372 
373     if (!this->hasHighRateLimit())
374     {
375         if (getSettings()->allowDuplicateMessages)
376         {
377             if (parsedMessage == this->lastSentMessage_)
378             {
379                 auto spaceIndex = parsedMessage.indexOf(' ');
380                 if (spaceIndex == -1)
381                 {
382                     // no spaces found, fall back to old magic character
383                     parsedMessage.append(MAGIC_MESSAGE_SUFFIX);
384                 }
385                 else
386                 {
387                     // replace the space we found in spaceIndex with two spaces
388                     parsedMessage.replace(spaceIndex, 1, "  ");
389                 }
390             }
391         }
392     }
393 
394     bool messageSent = false;
395     this->sendMessageSignal.invoke(this->getName(), parsedMessage, messageSent);
396 
397     if (messageSent)
398     {
399         qCDebug(chatterinoTwitch) << "sent";
400         this->lastSentMessage_ = parsedMessage;
401     }
402 }
403 
isMod() const404 bool TwitchChannel::isMod() const
405 {
406     return this->mod_;
407 }
408 
isVip() const409 bool TwitchChannel::isVip() const
410 {
411     return this->vip_;
412 }
413 
isStaff() const414 bool TwitchChannel::isStaff() const
415 {
416     return this->staff_;
417 }
418 
setMod(bool value)419 void TwitchChannel::setMod(bool value)
420 {
421     if (this->mod_ != value)
422     {
423         this->mod_ = value;
424 
425         this->userStateChanged.invoke();
426     }
427 }
428 
setVIP(bool value)429 void TwitchChannel::setVIP(bool value)
430 {
431     if (this->vip_ != value)
432     {
433         this->vip_ = value;
434 
435         this->userStateChanged.invoke();
436     }
437 }
438 
setStaff(bool value)439 void TwitchChannel::setStaff(bool value)
440 {
441     if (this->staff_ != value)
442     {
443         this->staff_ = value;
444 
445         this->userStateChanged.invoke();
446     }
447 }
448 
isBroadcaster() const449 bool TwitchChannel::isBroadcaster() const
450 {
451     auto app = getApp();
452 
453     return this->getName() == app->accounts->twitch.getCurrent()->getUserName();
454 }
455 
hasHighRateLimit() const456 bool TwitchChannel::hasHighRateLimit() const
457 {
458     return this->isMod() || this->isBroadcaster() || this->isVip();
459 }
460 
canReconnect() const461 bool TwitchChannel::canReconnect() const
462 {
463     return true;
464 }
465 
reconnect()466 void TwitchChannel::reconnect()
467 {
468     getApp()->twitch.server->connect();
469 }
470 
roomId() const471 QString TwitchChannel::roomId() const
472 {
473     return *this->roomID_.access();
474 }
475 
setRoomId(const QString & id)476 void TwitchChannel::setRoomId(const QString &id)
477 {
478     if (*this->roomID_.accessConst() != id)
479     {
480         *this->roomID_.access() = id;
481         this->roomIdChanged.invoke();
482         this->loadRecentMessages();
483     }
484 }
485 
486 SharedAccessGuard<const TwitchChannel::RoomModes>
accessRoomModes() const487     TwitchChannel::accessRoomModes() const
488 {
489     return this->roomModes_.accessConst();
490 }
491 
setRoomModes(const RoomModes & _roomModes)492 void TwitchChannel::setRoomModes(const RoomModes &_roomModes)
493 {
494     this->roomModes_ = _roomModes;
495 
496     this->roomModesChanged.invoke();
497 }
498 
isLive() const499 bool TwitchChannel::isLive() const
500 {
501     return this->streamStatus_.access()->live;
502 }
503 
504 SharedAccessGuard<const TwitchChannel::StreamStatus>
accessStreamStatus() const505     TwitchChannel::accessStreamStatus() const
506 {
507     return this->streamStatus_.accessConst();
508 }
509 
bttvEmote(const EmoteName & name) const510 boost::optional<EmotePtr> TwitchChannel::bttvEmote(const EmoteName &name) const
511 {
512     auto emotes = this->bttvEmotes_.get();
513     auto it = emotes->find(name);
514 
515     if (it == emotes->end())
516         return boost::none;
517     return it->second;
518 }
519 
ffzEmote(const EmoteName & name) const520 boost::optional<EmotePtr> TwitchChannel::ffzEmote(const EmoteName &name) const
521 {
522     auto emotes = this->ffzEmotes_.get();
523     auto it = emotes->find(name);
524 
525     if (it == emotes->end())
526         return boost::none;
527     return it->second;
528 }
529 
bttvEmotes() const530 std::shared_ptr<const EmoteMap> TwitchChannel::bttvEmotes() const
531 {
532     return this->bttvEmotes_.get();
533 }
534 
ffzEmotes() const535 std::shared_ptr<const EmoteMap> TwitchChannel::ffzEmotes() const
536 {
537     return this->ffzEmotes_.get();
538 }
539 
subscriptionUrl()540 const QString &TwitchChannel::subscriptionUrl()
541 {
542     return this->subscriptionUrl_;
543 }
544 
channelUrl()545 const QString &TwitchChannel::channelUrl()
546 {
547     return this->channelUrl_;
548 }
549 
popoutPlayerUrl()550 const QString &TwitchChannel::popoutPlayerUrl()
551 {
552     return this->popoutPlayerUrl_;
553 }
554 
chatterCount()555 int TwitchChannel::chatterCount()
556 {
557     return this->chatterCount_;
558 }
559 
setLive(bool newLiveStatus)560 void TwitchChannel::setLive(bool newLiveStatus)
561 {
562     bool gotNewLiveStatus = false;
563     {
564         auto guard = this->streamStatus_.access();
565         if (guard->live != newLiveStatus)
566         {
567             gotNewLiveStatus = true;
568             if (newLiveStatus)
569             {
570                 if (getApp()->notifications->isChannelNotified(
571                         this->getName(), Platform::Twitch))
572                 {
573                     if (Toasts::isEnabled())
574                     {
575                         getApp()->toasts->sendChannelNotification(
576                             this->getName(), Platform::Twitch);
577                     }
578                     if (getSettings()->notificationPlaySound)
579                     {
580                         getApp()->notifications->playSound();
581                     }
582                     if (getSettings()->notificationFlashTaskbar)
583                     {
584                         getApp()->windows->sendAlert();
585                     }
586                 }
587                 // Channel live message
588                 MessageBuilder builder;
589                 TwitchMessageBuilder::liveSystemMessage(this->getDisplayName(),
590                                                         &builder);
591                 this->addMessage(builder.release());
592 
593                 // Message in /live channel
594                 MessageBuilder builder2;
595                 TwitchMessageBuilder::liveMessage(this->getDisplayName(),
596                                                   &builder2);
597                 getApp()->twitch2->liveChannel->addMessage(builder2.release());
598 
599                 // Notify on all channels with a ping sound
600                 if (getSettings()->notificationOnAnyChannel &&
601                     !(isInStreamerMode() &&
602                       getSettings()->streamerModeSuppressLiveNotifications))
603                 {
604                     getApp()->notifications->playSound();
605                 }
606             }
607             else
608             {
609                 // Channel offline message
610                 MessageBuilder builder;
611                 TwitchMessageBuilder::offlineSystemMessage(
612                     this->getDisplayName(), &builder);
613                 this->addMessage(builder.release());
614 
615                 // "delete" old 'CHANNEL is live' message
616                 LimitedQueueSnapshot<MessagePtr> snapshot =
617                     getApp()->twitch2->liveChannel->getMessageSnapshot();
618                 int snapshotLength = snapshot.size();
619 
620                 // MSVC hates this code if the parens are not there
621                 int end = (std::max)(0, snapshotLength - 200);
622                 auto liveMessageSearchText =
623                     QString("%1 is live!").arg(this->getDisplayName());
624 
625                 for (int i = snapshotLength - 1; i >= end; --i)
626                 {
627                     auto &s = snapshot[i];
628 
629                     if (s->messageText == liveMessageSearchText)
630                     {
631                         s->flags.set(MessageFlag::Disabled);
632                         break;
633                     }
634                 }
635             }
636             guard->live = newLiveStatus;
637         }
638     }
639 
640     if (gotNewLiveStatus)
641     {
642         this->liveStatusChanged.invoke();
643     }
644 }
645 
refreshTitle()646 void TwitchChannel::refreshTitle()
647 {
648     // timer has never started, proceed and start it
649     if (!this->titleRefreshedTimer_.isValid())
650     {
651         this->titleRefreshedTimer_.start();
652     }
653     else if (this->roomId().isEmpty() ||
654              this->titleRefreshedTimer_.elapsed() < TITLE_REFRESH_PERIOD)
655     {
656         return;
657     }
658     this->titleRefreshedTimer_.restart();
659 
660     getHelix()->getChannel(
661         this->roomId(),
662         [this, weak = weakOf<Channel>(this)](HelixChannel channel) {
663             ChannelPtr shared = weak.lock();
664 
665             if (!shared)
666             {
667                 return;
668             }
669 
670             {
671                 auto status = this->streamStatus_.access();
672                 status->title = channel.title;
673             }
674 
675             this->liveStatusChanged.invoke();
676         },
677         [] {
678             // failure
679         });
680 }
681 
refreshLiveStatus()682 void TwitchChannel::refreshLiveStatus()
683 {
684     auto roomID = this->roomId();
685 
686     if (roomID.isEmpty())
687     {
688         qCDebug(chatterinoTwitch) << "[TwitchChannel" << this->getName()
689                                   << "] Refreshing live status (Missing ID)";
690         this->setLive(false);
691         return;
692     }
693 
694     getHelix()->getStreamById(
695         roomID,
696         [this, weak = weakOf<Channel>(this)](bool live, const auto &stream) {
697             ChannelPtr shared = weak.lock();
698             if (!shared)
699             {
700                 return;
701             }
702 
703             this->parseLiveStatus(live, stream);
704         },
705         [] {
706             // failure
707         });
708 }
709 
parseLiveStatus(bool live,const HelixStream & stream)710 void TwitchChannel::parseLiveStatus(bool live, const HelixStream &stream)
711 {
712     if (!live)
713     {
714         this->setLive(false);
715         return;
716     }
717 
718     {
719         auto status = this->streamStatus_.access();
720         status->viewerCount = stream.viewerCount;
721         if (status->gameId != stream.gameId)
722         {
723             status->gameId = stream.gameId;
724 
725             // Resolve game ID to game name
726             getHelix()->getGameById(
727                 stream.gameId,
728                 [this, weak = weakOf<Channel>(this)](const auto &game) {
729                     ChannelPtr shared = weak.lock();
730                     if (!shared)
731                     {
732                         return;
733                     }
734 
735                     {
736                         auto status = this->streamStatus_.access();
737                         status->game = game.name;
738                     }
739 
740                     this->liveStatusChanged.invoke();
741                 },
742                 [] {
743                     // failure
744                 });
745         }
746         status->title = stream.title;
747         QDateTime since = QDateTime::fromString(stream.startedAt, Qt::ISODate);
748         auto diff = since.secsTo(QDateTime::currentDateTime());
749         status->uptime = QString::number(diff / 3600) + "h " +
750                          QString::number(diff % 3600 / 60) + "m";
751 
752         status->rerun = false;
753         status->streamType = stream.type;
754     }
755 
756     this->setLive(true);
757 
758     // Signal all listeners that the stream status has been updated
759     this->liveStatusChanged.invoke();
760 }
761 
loadRecentMessages()762 void TwitchChannel::loadRecentMessages()
763 {
764     if (!getSettings()->loadTwitchMessageHistoryOnConnect)
765     {
766         return;
767     }
768 
769     QUrl url(Env::get().recentMessagesApiUrl.arg(this->getName()));
770     QUrlQuery urlQuery(url);
771     if (!urlQuery.hasQueryItem("limit"))
772     {
773         urlQuery.addQueryItem(
774             "limit", QString::number(getSettings()->twitchMessageHistoryLimit));
775     }
776     url.setQuery(urlQuery);
777 
778     auto weak = weakOf<Channel>(this);
779 
780     NetworkRequest(url)
781         .onSuccess([this, weak](NetworkResult result) -> Outcome {
782             auto shared = weak.lock();
783             if (!shared)
784                 return Failure;
785 
786             auto root = result.parseJson();
787             auto messages = parseRecentMessages(root, shared);
788 
789             auto &handler = IrcMessageHandler::instance();
790 
791             std::vector<MessagePtr> allBuiltMessages;
792 
793             for (auto message : messages)
794             {
795                 if (message->tags().contains("rm-received-ts"))
796                 {
797                     QDate msgDate = QDateTime::fromMSecsSinceEpoch(
798                                         message->tags()
799                                             .value("rm-received-ts")
800                                             .toLongLong())
801                                         .date();
802                     if (msgDate != shared.get()->lastDate_)
803                     {
804                         shared.get()->lastDate_ = msgDate;
805                         auto msg = makeSystemMessage(
806                             QLocale().toString(msgDate, QLocale::LongFormat),
807                             QTime(0, 0));
808                         msg->flags.set(MessageFlag::RecentMessage);
809                         allBuiltMessages.emplace_back(msg);
810                     }
811                 }
812 
813                 for (auto builtMessage :
814                      handler.parseMessage(shared.get(), message))
815                 {
816                     builtMessage->flags.set(MessageFlag::RecentMessage);
817                     allBuiltMessages.emplace_back(builtMessage);
818                 }
819             }
820 
821             postToThread([this, shared, root,
822                           messages = std::move(allBuiltMessages)]() mutable {
823                 shared->addMessagesAtStart(messages);
824 
825                 // Notify user about a possible gap in logs if it returned some messages
826                 // but isn't currently joined to a channel
827                 if (QString errorCode = root.value("error_code").toString();
828                     !errorCode.isEmpty())
829                 {
830                     qCDebug(chatterinoTwitch)
831                         << QString("rm error_code=%1, channel=%2")
832                                .arg(errorCode, this->getName());
833                     if (errorCode == "channel_not_joined" && !messages.empty())
834                     {
835                         shared->addMessage(makeSystemMessage(
836                             "Message history service recovering, there may be "
837                             "gaps in the message history."));
838                     }
839                 }
840             });
841 
842             return Success;
843         })
844         .onError([weak](NetworkResult result) {
845             auto shared = weak.lock();
846             if (!shared)
847                 return;
848 
849             shared->addMessage(makeSystemMessage(
850                 QString("Message history service unavailable (Error %1)")
851                     .arg(result.status())));
852         })
853         .execute();
854 }
855 
refreshPubsub()856 void TwitchChannel::refreshPubsub()
857 {
858     auto roomId = this->roomId();
859     if (roomId.isEmpty())
860         return;
861 
862     auto account = getApp()->accounts->twitch.getCurrent();
863     getApp()->twitch2->pubsub->listenToChannelModerationActions(roomId,
864                                                                 account);
865     getApp()->twitch2->pubsub->listenToAutomod(roomId, account);
866     getApp()->twitch2->pubsub->listenToChannelPointRewards(roomId, account);
867 }
868 
refreshChatters()869 void TwitchChannel::refreshChatters()
870 {
871     // setting?
872     const auto streamStatus = this->accessStreamStatus();
873     const auto viewerCount = static_cast<int>(streamStatus->viewerCount);
874     if (getSettings()->onlyFetchChattersForSmallerStreamers)
875     {
876         if (streamStatus->live &&
877             viewerCount > getSettings()->smallStreamerLimit)
878         {
879             return;
880         }
881     }
882 
883     // get viewer list
884     NetworkRequest("https://tmi.twitch.tv/group/user/" + this->getName() +
885                    "/chatters")
886 
887         .onSuccess(
888             [this, weak = weakOf<Channel>(this)](auto result) -> Outcome {
889                 // channel still exists?
890                 auto shared = weak.lock();
891                 if (!shared)
892                 {
893                     return Failure;
894                 }
895 
896                 auto data = result.parseJson();
897                 this->chatterCount_ = data.value("chatter_count").toInt();
898 
899                 auto pair = parseChatters(std::move(data));
900                 if (pair.first)
901                 {
902                     this->updateOnlineChatters(pair.second);
903                 }
904 
905                 return pair.first;
906             })
907         .execute();
908 }
909 
fetchDisplayName()910 void TwitchChannel::fetchDisplayName()
911 {
912     getHelix()->getUserByName(
913         this->getName(),
914         [weak = weakOf<Channel>(this)](const auto &user) {
915             auto shared = weak.lock();
916             if (!shared)
917                 return;
918             auto channel = static_cast<TwitchChannel *>(shared.get());
919             if (QString::compare(user.displayName, channel->getName(),
920                                  Qt::CaseInsensitive) == 0)
921             {
922                 channel->setDisplayName(user.displayName);
923                 channel->setLocalizedName(user.displayName);
924             }
925             else
926             {
927                 channel->setLocalizedName(QString("%1(%2)")
928                                               .arg(channel->getName())
929                                               .arg(user.displayName));
930             }
931             channel->addRecentChatter(channel->getDisplayName());
932             channel->displayNameChanged.invoke();
933         },
934         [] {});
935 }
936 
refreshBadges()937 void TwitchChannel::refreshBadges()
938 {
939     auto url = Url{"https://badges.twitch.tv/v1/badges/channels/" +
940                    this->roomId() + "/display?language=en"};
941     NetworkRequest(url.string)
942 
943         .onSuccess([this,
944                     weak = weakOf<Channel>(this)](auto result) -> Outcome {
945             auto shared = weak.lock();
946             if (!shared)
947                 return Failure;
948 
949             auto badgeSets = this->badgeSets_.access();
950 
951             auto jsonRoot = result.parseJson();
952 
953             auto _ = jsonRoot["badge_sets"].toObject();
954             for (auto jsonBadgeSet = _.begin(); jsonBadgeSet != _.end();
955                  jsonBadgeSet++)
956             {
957                 auto &versions = (*badgeSets)[jsonBadgeSet.key()];
958 
959                 auto _set = jsonBadgeSet->toObject()["versions"].toObject();
960                 for (auto jsonVersion_ = _set.begin();
961                      jsonVersion_ != _set.end(); jsonVersion_++)
962                 {
963                     auto jsonVersion = jsonVersion_->toObject();
964                     auto emote = std::make_shared<Emote>(Emote{
965                         EmoteName{},
966                         ImageSet{
967                             Image::fromUrl(
968                                 {jsonVersion["image_url_1x"].toString()}, 1),
969                             Image::fromUrl(
970                                 {jsonVersion["image_url_2x"].toString()}, .5),
971                             Image::fromUrl(
972                                 {jsonVersion["image_url_4x"].toString()}, .25)},
973                         Tooltip{jsonVersion["description"].toString()},
974                         Url{jsonVersion["clickURL"].toString()}});
975 
976                     versions.emplace(jsonVersion_.key(), emote);
977                 };
978             }
979 
980             return Success;
981         })
982         .execute();
983 }
984 
refreshCheerEmotes()985 void TwitchChannel::refreshCheerEmotes()
986 {
987     getHelix()->getCheermotes(
988         this->roomId(),
989         [this, weak = weakOf<Channel>(this)](
990             const std::vector<HelixCheermoteSet> &cheermoteSets) -> Outcome {
991             auto shared = weak.lock();
992             if (!shared)
993             {
994                 return Failure;
995             }
996 
997             std::vector<CheerEmoteSet> emoteSets;
998 
999             for (const auto &set : cheermoteSets)
1000             {
1001                 auto cheerEmoteSet = CheerEmoteSet();
1002                 cheerEmoteSet.regex = QRegularExpression(
1003                     "^" + set.prefix + "([1-9][0-9]*)$",
1004                     QRegularExpression::CaseInsensitiveOption);
1005 
1006                 for (const auto &tier : set.tiers)
1007                 {
1008                     CheerEmote cheerEmote;
1009 
1010                     cheerEmote.color = QColor(tier.color);
1011                     cheerEmote.minBits = tier.minBits;
1012                     cheerEmote.regex = cheerEmoteSet.regex;
1013 
1014                     // TODO(pajlada): We currently hardcode dark here :|
1015                     // We will continue to do so for now since we haven't had to
1016                     // solve that anywhere else
1017 
1018                     // Combine the prefix (e.g. BibleThump) with the tier (1, 100 etc.)
1019                     auto emoteTooltip =
1020                         set.prefix + tier.id + "<br>Twitch Cheer Emote";
1021                     cheerEmote.animatedEmote = std::make_shared<Emote>(
1022                         Emote{EmoteName{"cheer emote"},
1023                               ImageSet{
1024                                   tier.darkAnimated.imageURL1x,
1025                                   tier.darkAnimated.imageURL2x,
1026                                   tier.darkAnimated.imageURL4x,
1027                               },
1028                               Tooltip{emoteTooltip}, Url{}});
1029                     cheerEmote.staticEmote = std::make_shared<Emote>(
1030                         Emote{EmoteName{"cheer emote"},
1031                               ImageSet{
1032                                   tier.darkStatic.imageURL1x,
1033                                   tier.darkStatic.imageURL2x,
1034                                   tier.darkStatic.imageURL4x,
1035                               },
1036                               Tooltip{emoteTooltip}, Url{}});
1037 
1038                     cheerEmoteSet.cheerEmotes.emplace_back(
1039                         std::move(cheerEmote));
1040                 }
1041 
1042                 // Sort cheermotes by cost
1043                 std::sort(cheerEmoteSet.cheerEmotes.begin(),
1044                           cheerEmoteSet.cheerEmotes.end(),
1045                           [](const auto &lhs, const auto &rhs) {
1046                               return lhs.minBits > rhs.minBits;
1047                           });
1048 
1049                 emoteSets.emplace_back(std::move(cheerEmoteSet));
1050             }
1051 
1052             *this->cheerEmoteSets_.access() = std::move(emoteSets);
1053 
1054             return Success;
1055         },
1056         [] {
1057             // Failure
1058             return Failure;
1059         });
1060 }
1061 
createClip()1062 void TwitchChannel::createClip()
1063 {
1064     if (!this->isLive())
1065     {
1066         this->addMessage(makeSystemMessage(
1067             "Cannot create clip while the channel is offline!"));
1068         return;
1069     }
1070 
1071     // timer has never started, proceed and start it
1072     if (!this->clipCreationTimer_.isValid())
1073     {
1074         this->clipCreationTimer_.start();
1075     }
1076     else if (this->clipCreationTimer_.elapsed() < CLIP_CREATION_COOLDOWN ||
1077              this->isClipCreationInProgress)
1078     {
1079         return;
1080     }
1081 
1082     this->addMessage(makeSystemMessage("Creating clip..."));
1083     this->isClipCreationInProgress = true;
1084 
1085     getHelix()->createClip(
1086         this->roomId(),
1087         // successCallback
1088         [this](const HelixClip &clip) {
1089             MessageBuilder builder;
1090             QString text(
1091                 "Clip created! Copy link to clipboard or edit it in browser.");
1092             builder.message().messageText = text;
1093             builder.message().searchText = text;
1094             builder.message().flags.set(MessageFlag::System);
1095 
1096             builder.emplace<TimestampElement>();
1097             // text
1098             builder.emplace<TextElement>("Clip created!",
1099                                          MessageElementFlag::Text,
1100                                          MessageColor::System);
1101             // clip link
1102             builder
1103                 .emplace<TextElement>("Copy link to clipboard",
1104                                       MessageElementFlag::Text,
1105                                       MessageColor::Link)
1106                 ->setLink(Link(Link::CopyToClipboard, CLIPS_LINK.arg(clip.id)));
1107             // separator text
1108             builder.emplace<TextElement>("or", MessageElementFlag::Text,
1109                                          MessageColor::System);
1110             // edit link
1111             builder
1112                 .emplace<TextElement>("edit it in browser.",
1113                                       MessageElementFlag::Text,
1114                                       MessageColor::Link)
1115                 ->setLink(Link(Link::Url, clip.editUrl));
1116 
1117             this->addMessage(builder.release());
1118         },
1119         // failureCallback
1120         [this](auto error) {
1121             MessageBuilder builder;
1122             QString text;
1123             builder.message().flags.set(MessageFlag::System);
1124 
1125             builder.emplace<TimestampElement>();
1126 
1127             switch (error)
1128             {
1129                 case HelixClipError::ClipsDisabled: {
1130                     builder.emplace<TextElement>(
1131                         CLIPS_FAILURE_CLIPS_DISABLED_TEXT,
1132                         MessageElementFlag::Text, MessageColor::System);
1133                     text = CLIPS_FAILURE_CLIPS_DISABLED_TEXT;
1134                 }
1135                 break;
1136 
1137                 case HelixClipError::UserNotAuthenticated: {
1138                     builder.emplace<TextElement>(
1139                         CLIPS_FAILURE_NOT_AUTHENTICATED_TEXT,
1140                         MessageElementFlag::Text, MessageColor::System);
1141                     builder
1142                         .emplace<TextElement>(LOGIN_PROMPT_TEXT,
1143                                               MessageElementFlag::Text,
1144                                               MessageColor::Link)
1145                         ->setLink(ACCOUNTS_LINK);
1146                     text = QString("%1 %2").arg(
1147                         CLIPS_FAILURE_NOT_AUTHENTICATED_TEXT,
1148                         LOGIN_PROMPT_TEXT);
1149                 }
1150                 break;
1151 
1152                 // This would most likely happen if the service is down, or if the JSON payload returned has changed format
1153                 case HelixClipError::Unknown:
1154                 default: {
1155                     builder.emplace<TextElement>(
1156                         CLIPS_FAILURE_UNKNOWN_ERROR_TEXT,
1157                         MessageElementFlag::Text, MessageColor::System);
1158                     text = CLIPS_FAILURE_UNKNOWN_ERROR_TEXT;
1159                 }
1160                 break;
1161             }
1162 
1163             builder.message().messageText = text;
1164             builder.message().searchText = text;
1165 
1166             this->addMessage(builder.release());
1167         },
1168         // finallyCallback - this will always execute, so clip creation won't ever be stuck
1169         [this] {
1170             this->clipCreationTimer_.restart();
1171             this->isClipCreationInProgress = false;
1172         });
1173 }
1174 
twitchBadge(const QString & set,const QString & version) const1175 boost::optional<EmotePtr> TwitchChannel::twitchBadge(
1176     const QString &set, const QString &version) const
1177 {
1178     auto badgeSets = this->badgeSets_.access();
1179     auto it = badgeSets->find(set);
1180     if (it != badgeSets->end())
1181     {
1182         auto it2 = it->second.find(version);
1183         if (it2 != it->second.end())
1184         {
1185             return it2->second;
1186         }
1187     }
1188     return boost::none;
1189 }
1190 
ffzCustomModBadge() const1191 boost::optional<EmotePtr> TwitchChannel::ffzCustomModBadge() const
1192 {
1193     return this->ffzCustomModBadge_.get();
1194 }
1195 
ffzCustomVipBadge() const1196 boost::optional<EmotePtr> TwitchChannel::ffzCustomVipBadge() const
1197 {
1198     return this->ffzCustomVipBadge_.get();
1199 }
1200 
cheerEmote(const QString & string)1201 boost::optional<CheerEmote> TwitchChannel::cheerEmote(const QString &string)
1202 {
1203     auto sets = this->cheerEmoteSets_.access();
1204     for (const auto &set : *sets)
1205     {
1206         auto match = set.regex.match(string);
1207         if (!match.hasMatch())
1208         {
1209             continue;
1210         }
1211         QString amount = match.captured(1);
1212         bool ok = false;
1213         int bitAmount = amount.toInt(&ok);
1214         if (!ok)
1215         {
1216             qCDebug(chatterinoTwitch)
1217                 << "Error parsing bit amount in cheerEmote";
1218         }
1219         for (const auto &emote : set.cheerEmotes)
1220         {
1221             if (bitAmount >= emote.minBits)
1222             {
1223                 return emote;
1224             }
1225         }
1226     }
1227     return boost::none;
1228 }
1229 
1230 }  // namespace chatterino
1231