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