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