1 #include "MessageBuilder.hpp"
2 
3 #include "Application.hpp"
4 #include "common/LinkParser.hpp"
5 #include "controllers/accounts/AccountController.hpp"
6 #include "messages/Image.hpp"
7 #include "messages/Message.hpp"
8 #include "messages/MessageElement.hpp"
9 #include "providers/LinkResolver.hpp"
10 #include "providers/twitch/PubsubActions.hpp"
11 #include "singletons/Emotes.hpp"
12 #include "singletons/Resources.hpp"
13 #include "singletons/Theme.hpp"
14 #include "util/FormatTime.hpp"
15 
16 #include <QDateTime>
17 #include <QImageReader>
18 
19 namespace chatterino {
20 
makeSystemMessage(const QString & text)21 MessagePtr makeSystemMessage(const QString &text)
22 {
23     return MessageBuilder(systemMessage, text).release();
24 }
25 
makeSystemMessage(const QString & text,const QTime & time)26 MessagePtr makeSystemMessage(const QString &text, const QTime &time)
27 {
28     return MessageBuilder(systemMessage, text, time).release();
29 }
30 
makeAutomodInfoMessage(const AutomodInfoAction & action)31 MessagePtr makeAutomodInfoMessage(const AutomodInfoAction &action)
32 {
33     auto builder = MessageBuilder();
34     QString text("AutoMod: ");
35 
36     builder.emplace<TimestampElement>();
37     builder.message().flags.set(MessageFlag::PubSub);
38 
39     // AutoMod shield badge
40     builder
41         .emplace<ImageElement>(Image::fromPixmap(getResources().twitch.automod),
42                                MessageElementFlag::BadgeChannelAuthority)
43         ->setTooltip("AutoMod");
44     // AutoMod "username"
45     builder.emplace<TextElement>("AutoMod:", MessageElementFlag::BoldUsername,
46                                  MessageColor(QColor("blue")),
47                                  FontStyle::ChatMediumBold);
48     builder.emplace<TextElement>(
49         "AutoMod:", MessageElementFlag::NonBoldUsername,
50         MessageColor(QColor("blue")));
51     switch (action.type)
52     {
53         case AutomodInfoAction::OnHold: {
54             QString info("Hey! Your message is being checked "
55                          "by mods and has not been sent.");
56             text += info;
57             builder.emplace<TextElement>(info, MessageElementFlag::Text,
58                                          MessageColor::Text);
59         }
60         break;
61         case AutomodInfoAction::Denied: {
62             QString info("Mods have removed your message.");
63             text += info;
64             builder.emplace<TextElement>(info, MessageElementFlag::Text,
65                                          MessageColor::Text);
66         }
67         break;
68         case AutomodInfoAction::Approved: {
69             QString info("Mods have accepted your message.");
70             text += info;
71             builder.emplace<TextElement>(info, MessageElementFlag::Text,
72                                          MessageColor::Text);
73         }
74         break;
75     }
76 
77     builder.message().flags.set(MessageFlag::AutoMod);
78     builder.message().messageText = text;
79     builder.message().searchText = text;
80 
81     auto message = builder.release();
82 
83     return message;
84 }
85 
makeAutomodMessage(const AutomodAction & action)86 std::pair<MessagePtr, MessagePtr> makeAutomodMessage(
87     const AutomodAction &action)
88 {
89     MessageBuilder builder, builder2;
90 
91     //
92     // Builder for AutoMod message with explanation
93     builder.emplace<TimestampElement>();
94     builder.message().loginName = "automod";
95     builder.message().flags.set(MessageFlag::PubSub);
96 
97     // AutoMod shield badge
98     builder
99         .emplace<ImageElement>(Image::fromPixmap(getResources().twitch.automod),
100                                MessageElementFlag::BadgeChannelAuthority)
101         ->setTooltip("AutoMod");
102     // AutoMod "username"
103     builder.emplace<TextElement>("AutoMod:", MessageElementFlag::BoldUsername,
104                                  MessageColor(QColor("blue")),
105                                  FontStyle::ChatMediumBold);
106     builder.emplace<TextElement>(
107         "AutoMod:", MessageElementFlag::NonBoldUsername,
108         MessageColor(QColor("blue")));
109     // AutoMod header message
110     builder.emplace<TextElement>(
111         ("Held a message for reason: " + action.reason +
112          ". Allow will post it in chat. "),
113         MessageElementFlag::Text, MessageColor::Text);
114     // Allow link button
115     builder
116         .emplace<TextElement>("Allow", MessageElementFlag::Text,
117                               MessageColor(QColor("green")),
118                               FontStyle::ChatMediumBold)
119         ->setLink({Link::AutoModAllow, action.msgID});
120     // Deny link button
121     builder
122         .emplace<TextElement>(" Deny", MessageElementFlag::Text,
123                               MessageColor(QColor("red")),
124                               FontStyle::ChatMediumBold)
125         ->setLink({Link::AutoModDeny, action.msgID});
126     // ID of message caught by AutoMod
127     //    builder.emplace<TextElement>(action.msgID, MessageElementFlag::Text,
128     //                                 MessageColor::Text);
129     builder.message().flags.set(MessageFlag::AutoMod);
130     auto text1 =
131         QString("AutoMod: Held a message for reason: %1. Allow will post "
132                 "it in chat. Allow Deny")
133             .arg(action.reason);
134     builder.message().messageText = text1;
135     builder.message().searchText = text1;
136 
137     auto message1 = builder.release();
138 
139     //
140     // Builder for offender's message
141     builder2.emplace<TimestampElement>();
142     builder2.emplace<TwitchModerationElement>();
143     builder2.message().loginName = action.target.login;
144     builder2.message().flags.set(MessageFlag::PubSub);
145 
146     // sender username
147     builder2
148         .emplace<TextElement>(
149             action.target.displayName + ":", MessageElementFlag::BoldUsername,
150             MessageColor(action.target.color), FontStyle::ChatMediumBold)
151         ->setLink({Link::UserInfo, action.target.login});
152     builder2
153         .emplace<TextElement>(action.target.displayName + ":",
154                               MessageElementFlag::NonBoldUsername,
155                               MessageColor(action.target.color))
156         ->setLink({Link::UserInfo, action.target.login});
157     // sender's message caught by AutoMod
158     builder2.emplace<TextElement>(action.message, MessageElementFlag::Text,
159                                   MessageColor::Text);
160     builder2.message().flags.set(MessageFlag::AutoMod);
161     auto text2 =
162         QString("%1: %2").arg(action.target.displayName, action.message);
163     builder2.message().messageText = text2;
164     builder2.message().searchText = text2;
165 
166     auto message2 = builder2.release();
167 
168     return std::make_pair(message1, message2);
169 }
170 
MessageBuilder()171 MessageBuilder::MessageBuilder()
172     : message_(std::make_shared<Message>())
173 {
174 }
175 
MessageBuilder(SystemMessageTag,const QString & text,const QTime & time)176 MessageBuilder::MessageBuilder(SystemMessageTag, const QString &text,
177                                const QTime &time)
178     : MessageBuilder()
179 {
180     this->emplace<TimestampElement>(time);
181 
182     // check system message for links
183     // (e.g. needed for sub ticket message in sub only mode)
184     const QStringList textFragments = text.split(QRegularExpression("\\s"));
185     for (const auto &word : textFragments)
186     {
187         const auto linkString = this->matchLink(word);
188         if (linkString.isEmpty())
189         {
190             this->emplace<TextElement>(word, MessageElementFlag::Text,
191                                        MessageColor::System);
192         }
193         else
194         {
195             this->addLink(word, linkString);
196         }
197     }
198     this->message().flags.set(MessageFlag::System);
199     this->message().flags.set(MessageFlag::DoNotTriggerNotification);
200     this->message().messageText = text;
201     this->message().searchText = text;
202 }
203 
MessageBuilder(TimeoutMessageTag,const QString & systemMessageText,int times,const QTime & time)204 MessageBuilder::MessageBuilder(TimeoutMessageTag,
205                                const QString &systemMessageText, int times,
206                                const QTime &time)
207     : MessageBuilder()
208 {
209     QString username = systemMessageText.split(" ").at(0);
210     QString remainder = systemMessageText.mid(username.length() + 1);
211 
212     QString text;
213 
214     this->emplace<TimestampElement>(time);
215     this->emplaceSystemTextAndUpdate(username, text)
216         ->setLink({Link::UserInfo, username});
217     this->emplaceSystemTextAndUpdate(
218         QString("%1 (%2 times)").arg(remainder.trimmed()).arg(times), text);
219 
220     this->message().messageText = text;
221     this->message().searchText = text;
222 }
223 
MessageBuilder(TimeoutMessageTag,const QString & username,const QString & durationInSeconds,bool multipleTimes,const QTime & time)224 MessageBuilder::MessageBuilder(TimeoutMessageTag, const QString &username,
225                                const QString &durationInSeconds,
226                                bool multipleTimes, const QTime &time)
227     : MessageBuilder()
228 {
229     QString fullText;
230     QString text;
231 
232     this->emplace<TimestampElement>(time);
233     this->emplaceSystemTextAndUpdate(username, fullText)
234         ->setLink({Link::UserInfo, username});
235 
236     if (!durationInSeconds.isEmpty())
237     {
238         text.append("has been timed out");
239 
240         // TODO: Implement who timed the user out
241 
242         text.append(" for ");
243         bool ok = true;
244         int timeoutSeconds = durationInSeconds.toInt(&ok);
245         if (ok)
246         {
247             text.append(formatTime(timeoutSeconds));
248         }
249     }
250     else
251     {
252         text.append("has been permanently banned");
253     }
254 
255     text.append(".");
256 
257     if (multipleTimes)
258     {
259         text.append(" (multiple times)");
260     }
261 
262     this->message().flags.set(MessageFlag::System);
263     this->message().flags.set(MessageFlag::Timeout);
264     this->message().flags.set(MessageFlag::DoNotTriggerNotification);
265     this->message().timeoutUser = username;
266 
267     this->emplaceSystemTextAndUpdate(text, fullText);
268     this->message().messageText = fullText;
269     this->message().searchText = fullText;
270 }
271 
272 // XXX: This does not belong in the MessageBuilder, this should be part of the TwitchMessageBuilder
MessageBuilder(const BanAction & action,uint32_t count)273 MessageBuilder::MessageBuilder(const BanAction &action, uint32_t count)
274     : MessageBuilder()
275 {
276     auto current = getApp()->accounts->twitch.getCurrent();
277 
278     this->emplace<TimestampElement>();
279     this->message().flags.set(MessageFlag::System);
280     this->message().flags.set(MessageFlag::Timeout);
281     this->message().timeoutUser = action.target.login;
282     this->message().count = count;
283 
284     QString text;
285 
286     if (action.target.id == current->getUserId())
287     {
288         this->emplaceSystemTextAndUpdate("You were", text);
289         if (action.isBan())
290         {
291             this->emplaceSystemTextAndUpdate("banned", text);
292         }
293         else
294         {
295             this->emplaceSystemTextAndUpdate(
296                 QString("timed out for %1").arg(formatTime(action.duration)),
297                 text);
298         }
299 
300         if (!action.source.login.isEmpty())
301         {
302             this->emplaceSystemTextAndUpdate("by", text);
303             this->emplaceSystemTextAndUpdate(
304                     action.source.login + (action.reason.isEmpty() ? "." : ":"),
305                     text)
306                 ->setLink({Link::UserInfo, action.source.login});
307         }
308 
309         if (!action.reason.isEmpty())
310         {
311             this->emplaceSystemTextAndUpdate(
312                 QString("\"%1\".").arg(action.reason), text);
313         }
314     }
315     else
316     {
317         if (action.isBan())
318         {
319             this->emplaceSystemTextAndUpdate(action.source.login, text)
320                 ->setLink({Link::UserInfo, action.source.login});
321             this->emplaceSystemTextAndUpdate("banned", text);
322             if (action.reason.isEmpty())
323             {
324                 this->emplaceSystemTextAndUpdate(action.target.login, text)
325                     ->setLink({Link::UserInfo, action.target.login});
326             }
327             else
328             {
329                 this->emplaceSystemTextAndUpdate(action.target.login + ":",
330                                                  text)
331                     ->setLink({Link::UserInfo, action.target.login});
332                 this->emplaceSystemTextAndUpdate(
333                     QString("\"%1\".").arg(action.reason), text);
334             }
335         }
336         else
337         {
338             this->emplaceSystemTextAndUpdate(action.source.login, text)
339                 ->setLink({Link::UserInfo, action.source.login});
340             this->emplaceSystemTextAndUpdate("timed out", text);
341             this->emplaceSystemTextAndUpdate(action.target.login, text)
342                 ->setLink({Link::UserInfo, action.target.login});
343             if (action.reason.isEmpty())
344             {
345                 this->emplaceSystemTextAndUpdate(
346                     QString("for %1.").arg(formatTime(action.duration)), text);
347             }
348             else
349             {
350                 this->emplaceSystemTextAndUpdate(
351                     QString("for %1: \"%2\".")
352                         .arg(formatTime(action.duration))
353                         .arg(action.reason),
354                     text);
355             }
356 
357             if (count > 1)
358             {
359                 this->emplaceSystemTextAndUpdate(
360                     QString("(%1 times)").arg(count), text);
361             }
362         }
363     }
364 
365     this->message().messageText = text;
366     this->message().searchText = text;
367 }
368 
MessageBuilder(const UnbanAction & action)369 MessageBuilder::MessageBuilder(const UnbanAction &action)
370     : MessageBuilder()
371 {
372     this->emplace<TimestampElement>();
373     this->message().flags.set(MessageFlag::System);
374     this->message().flags.set(MessageFlag::Untimeout);
375 
376     this->message().timeoutUser = action.target.login;
377 
378     QString text;
379 
380     this->emplaceSystemTextAndUpdate(action.source.login, text)
381         ->setLink({Link::UserInfo, action.source.login});
382     this->emplaceSystemTextAndUpdate(
383         action.wasBan() ? "unbanned" : "untimedout", text);
384     this->emplaceSystemTextAndUpdate(action.target.login, text)
385         ->setLink({Link::UserInfo, action.target.login});
386 
387     this->message().messageText = text;
388     this->message().searchText = text;
389 }
390 
MessageBuilder(const AutomodUserAction & action)391 MessageBuilder::MessageBuilder(const AutomodUserAction &action)
392     : MessageBuilder()
393 {
394     this->emplace<TimestampElement>();
395     this->message().flags.set(MessageFlag::System);
396 
397     QString text;
398     switch (action.type)
399     {
400         case AutomodUserAction::AddPermitted: {
401             text = QString("%1 added %2 as a permitted term on AutoMod.")
402                        .arg(action.source.login, action.message);
403         }
404         break;
405 
406         case AutomodUserAction::AddBlocked: {
407             text = QString("%1 added %2 as a blocked term on AutoMod.")
408                        .arg(action.source.login, action.message);
409         }
410         break;
411 
412         case AutomodUserAction::RemovePermitted: {
413             text = QString("%1 removed %2 as a permitted term on AutoMod.")
414                        .arg(action.source.login, action.message);
415         }
416         break;
417 
418         case AutomodUserAction::RemoveBlocked: {
419             text = QString("%1 removed %2 as a blocked term on AutoMod.")
420                        .arg(action.source.login, action.message);
421         }
422         break;
423 
424         case AutomodUserAction::Properties: {
425             text = QString("%1 modified the AutoMod properties.")
426                        .arg(action.source.login);
427         }
428         break;
429     }
430     this->message().messageText = text;
431     this->message().searchText = text;
432 
433     this->emplace<TextElement>(text, MessageElementFlag::Text,
434                                MessageColor::System);
435 }
436 
operator ->()437 Message *MessageBuilder::operator->()
438 {
439     return this->message_.get();
440 }
441 
message()442 Message &MessageBuilder::message()
443 {
444     return *this->message_;
445 }
446 
release()447 MessagePtr MessageBuilder::release()
448 {
449     std::shared_ptr<Message> ptr;
450     this->message_.swap(ptr);
451     return ptr;
452 }
453 
weakOf()454 std::weak_ptr<Message> MessageBuilder::weakOf()
455 {
456     return this->message_;
457 }
458 
append(std::unique_ptr<MessageElement> element)459 void MessageBuilder::append(std::unique_ptr<MessageElement> element)
460 {
461     this->message().elements.push_back(std::move(element));
462 }
463 
matchLink(const QString & string)464 QString MessageBuilder::matchLink(const QString &string)
465 {
466     LinkParser linkParser(string);
467 
468     static QRegularExpression httpRegex(
469         "\\bhttps?://", QRegularExpression::CaseInsensitiveOption);
470     static QRegularExpression ftpRegex(
471         "\\bftps?://", QRegularExpression::CaseInsensitiveOption);
472     static QRegularExpression spotifyRegex(
473         "\\bspotify:", QRegularExpression::CaseInsensitiveOption);
474 
475     if (!linkParser.hasMatch())
476     {
477         return QString();
478     }
479 
480     QString captured = linkParser.getCaptured();
481 
482     if (!captured.contains(httpRegex) && !captured.contains(ftpRegex) &&
483         !captured.contains(spotifyRegex))
484     {
485         captured.insert(0, "http://");
486     }
487 
488     return captured;
489 }
490 
addLink(const QString & origLink,const QString & matchedLink)491 void MessageBuilder::addLink(const QString &origLink,
492                              const QString &matchedLink)
493 {
494     static QRegularExpression domainRegex(
495         R"(^(?:(?:ftp|http)s?:\/\/)?([^\/]+)(?:\/.*)?$)",
496         QRegularExpression::CaseInsensitiveOption);
497 
498     QString lowercaseLinkString;
499     auto match = domainRegex.match(origLink);
500     if (match.isValid())
501     {
502         lowercaseLinkString = origLink.mid(0, match.capturedStart(1)) +
503                               match.captured(1).toLower() +
504                               origLink.mid(match.capturedEnd(1));
505     }
506     else
507     {
508         lowercaseLinkString = origLink;
509     }
510     auto linkElement = Link(Link::Url, matchedLink);
511 
512     auto textColor = MessageColor(MessageColor::Link);
513     auto linkMELowercase =
514         this->emplace<TextElement>(lowercaseLinkString,
515                                    MessageElementFlag::LowercaseLink, textColor)
516             ->setLink(linkElement);
517     auto linkMEOriginal =
518         this->emplace<TextElement>(origLink, MessageElementFlag::OriginalLink,
519                                    textColor)
520             ->setLink(linkElement);
521 
522     LinkResolver::getLinkInfo(
523         matchedLink, nullptr,
524         [weakMessage = this->weakOf(), linkMELowercase, linkMEOriginal,
525          matchedLink](QString tooltipText, Link originalLink,
526                       ImagePtr thumbnail) {
527             auto shared = weakMessage.lock();
528             if (!shared)
529             {
530                 return;
531             }
532             if (!tooltipText.isEmpty())
533             {
534                 linkMELowercase->setTooltip(tooltipText);
535                 linkMEOriginal->setTooltip(tooltipText);
536             }
537             if (originalLink.value != matchedLink &&
538                 !originalLink.value.isEmpty())
539             {
540                 linkMELowercase->setLink(originalLink)->updateLink();
541                 linkMEOriginal->setLink(originalLink)->updateLink();
542             }
543             linkMELowercase->setThumbnail(thumbnail);
544             linkMELowercase->setThumbnailType(
545                 MessageElement::ThumbnailType::Link_Thumbnail);
546             linkMEOriginal->setThumbnail(thumbnail);
547             linkMEOriginal->setThumbnailType(
548                 MessageElement::ThumbnailType::Link_Thumbnail);
549         });
550 }
551 
emplaceSystemTextAndUpdate(const QString & text,QString & toUpdate)552 TextElement *MessageBuilder::emplaceSystemTextAndUpdate(const QString &text,
553                                                         QString &toUpdate)
554 {
555     toUpdate.append(text + " ");
556     return this->emplace<TextElement>(text, MessageElementFlag::Text,
557                                       MessageColor::System);
558 }
559 
560 }  // namespace chatterino
561