1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Models;
7
8use App\Models\Traits\WithDbCursorHelper;
9use Carbon\Carbon;
10use Sentry\State\Scope;
11
12/**
13 * @property Beatmap $beatmap
14 * @property int|null $beatmap_id
15 * @property Beatmapset $beatmapset
16 * @property int|null $beatmapset_id
17 * @property \Carbon\Carbon $date
18 * @property int $epicfactor
19 * @property int $event_id
20 * @property int $private
21 * @property string $text
22 * @property string|null $text_clean
23 * @property User $user
24 * @property int|null $user_id
25 */
26class Event extends Model
27{
28 use WithDbCursorHelper;
29
30 protected const DEFAULT_SORT = 'id_desc';
31 protected const SORTS = [
32 'id_asc' => [
33 ['column' => 'event_id', 'order' => 'ASC'],
34 ],
35 'id_desc' => [
36 ['column' => 'event_id', 'order' => 'DESC'],
37 ],
38 ];
39
40 public ?array $details = null;
41 public $parsed = false;
42 public ?string $type = null;
43
44 public $patterns = [
45 'achievement' => "!^(?:<b>)+<a href='(?<userUrl>.+?)'>(?<userName>.+?)</a>(?:</b>)+ unlocked the \"<b>(?<achievementName>.+?)</b>\" achievement\!$!",
46 'beatmapPlaycount' => "!^<a href='(?<beatmapUrl>.+?)'>(?<beatmapTitle>.+?)</a> has been played (?<count>[\d,]+) times\!$!",
47 'beatmapsetApprove' => "!^<a href='(?<beatmapsetUrl>.+?)'>(?<beatmapsetTitle>.+?)</a> by <b><a href='(?<userUrl>.+?)'>(?<userName>.+?)</a></b> has just been (?<approval>ranked|approved|qualified|loved)\!$!",
48 'beatmapsetDelete' => "!^<a href='(?<beatmapsetUrl>.+?)'>(?<beatmapsetTitle>.*?)</a> has been deleted.$!",
49 'beatmapsetRevive' => "!^<a href='(?<beatmapsetUrl>.+?)'>(?<beatmapsetTitle>.*?)</a> has been revived from eternal slumber(?: by <b><a href='(?<userUrl>.+?)'>(?<userName>.+?)</a></b>)?\.$!",
50 'beatmapsetUpdate' => "!^<b><a href='(?<userUrl>.+?)'>(?<userName>.+?)</a></b> has updated the beatmap \"<a href='(?<beatmapsetUrl>.+?)'>(?<beatmapsetTitle>.*?)</a>\"$!",
51 'beatmapsetUpload' => "!^<b><a href='(?<userUrl>.+?)'>(?<userName>.+?)</a></b> has submitted a new beatmap \"<a href='(?<beatmapsetUrl>.+?)'>(?<beatmapsetTitle>.*?)</a>\"$!",
52 'medal' => "!^(?:<b>)+<a href='(?<userUrl>.+?)'>(?<userName>.+?)</a>(?:</b>)+ unlocked the \"<b>(?<achievementName>.+?)</b>\" medal\!$!",
53 'rank' => "!^<img src='/images/(?<scoreRank>.+?)_small\.png'/> <b><a href='(?<userUrl>.+?)'>(?<userName>.+?)</a></b> achieved (?:<b>)?rank #(?<rank>\d+?)(?:</b>)? on <a href='(?<beatmapUrl>.+?)'>(?<beatmapTitle>.+?)</a> \((?<mode>.+?)\)$!",
54 'rankLost' => "!^<b><a href='(?<userUrl>.+?)'>(?<userName>.+?)</a></b> has lost first place on <a href='(?<beatmapUrl>.+?)'>(?<beatmapTitle>.+?)</a> \((?<mode>.+?)\)$!",
55 'userSupportAgain' => "!^<b><a href='(?<userUrl>.+?)'>(?<userName>.+?)</a></b> has once again chosen to support osu\! - thanks for your generosity\!$!",
56 'userSupportFirst' => "!^<b><a href='(?<userUrl>.+?)'>(?<userName>.+?)</a></b> has become an osu\! supporter - thanks for your generosity\!$!",
57 'userSupportGift' => "!^<b><a href='(?<userUrl>.+?)'>(?<userName>.+?)</a></b> has received the gift of osu\! supporter\!$!",
58 'usernameChange' => "!^<b><a href='(?<userUrl>.+?)'>(?<previousUsername>.+?)</a></b> has changed their username to (?<userName>.+)\!$!",
59 ];
60
61 public $timestamps = false;
62
63 protected $casts = ['date' => 'datetime'];
64 protected $primaryKey = 'event_id';
65 protected $table = 'osu_events';
66
67 public static function generate($type, $options)
68 {
69 switch ($type) {
70 case 'achievement':
71 $achievement = $options['achievement'];
72 $user = $options['user'];
73
74 // not escaped because it's not in the old system either
75 $achievementName = $achievement->name;
76 $userUrl = e(route('users.show', $user, false));
77 $userName = e($user->username);
78
79 $params = [
80 // taken from medal
81 'text' => "<b><a href='{$userUrl}'>{$userName}</a></b> unlocked the \"<b>{$achievementName}</b>\" medal!",
82 'user_id' => $user->getKey(),
83 'private' => false,
84 'epicfactor' => 4,
85 ];
86
87 break;
88
89 case 'beatmapsetApprove':
90 $beatmapset = $options['beatmapset'];
91 $beatmapsetParams = static::beatmapsetParams($beatmapset);
92 $userParams = static::userParams($options['beatmapset']->user);
93 $approval = e($beatmapset->status());
94
95 $template = '%s by %s has just been %s!';
96 $params = [
97 'text' => sprintf($template, "<a href='{$beatmapsetParams['url']}'>{$beatmapsetParams['title']}</a>", "<b><a href='{$userParams['url']}'>{$userParams['username']}</a></b>", $approval),
98 'text_clean' => sprintf($template, "[{$beatmapsetParams['url_clean']} {$beatmapsetParams['title']}]", "[{$userParams['url_clean']} {$userParams['username']}]", $approval),
99 'beatmap_id' => 0,
100 'beatmapset_id' => $beatmapset->getKey(),
101 'user_id' => $beatmapset->user->getKey(),
102 'private' => false,
103 'epicfactor' => 8,
104 ];
105
106 break;
107
108 case 'beatmapsetDelete':
109 $beatmapset = $options['beatmapset'];
110 $beatmapsetParams = static::beatmapsetParams($beatmapset);
111
112 $params = [
113 'text' => "<a href='{$beatmapsetParams['url']}'>{$beatmapsetParams['title']}</a> has been deleted.",
114 'beatmapset_id' => $beatmapset->getKey(),
115 'user_id' => $options['user']->getKey(),
116 'private' => false,
117 'epicfactor' => 1,
118 ];
119
120 break;
121
122 case 'beatmapsetRevive':
123 $beatmapset = $options['beatmapset'];
124 $beatmapsetParams = static::beatmapsetParams($beatmapset);
125 $userParams = static::userParams($beatmapset->user);
126
127 $template = '%s has been revived from eternal slumber by %s.';
128 $params = [
129 'text' => sprintf($template, "<a href='{$beatmapsetParams['url']}'>{$beatmapsetParams['title']}</a>", "<b><a href='{$userParams['url']}'>{$userParams['username']}</a></b>"),
130 'text_clean' => sprintf($template, "[{$beatmapsetParams['url_clean']} {$beatmapsetParams['title']}]", "[{$userParams['url_clean']} {$userParams['username']}]"),
131 'beatmapset_id' => $beatmapset->getKey(),
132 'user_id' => $beatmapset->user->getKey(),
133 'private' => false,
134 'epicfactor' => 5,
135 ];
136
137 break;
138
139 case 'beatmapsetUpdate':
140 $beatmapset = $options['beatmapset'];
141 $beatmapsetParams = static::beatmapsetParams($beatmapset);
142 // retrieved separately from options because it doesn't necessarily need to be the same user
143 // as $beatmapset->user in some cases (see: direct guest difficulty update)
144 $user = $options['user'];
145 $userParams = static::userParams($user);
146
147 $template = '%s has updated the beatmap "%s"';
148 $params = [
149 'text' => sprintf($template, "<b><a href='{$userParams['url']}'>{$userParams['username']}</a></b>", "<a href='{$beatmapsetParams['url']}'>{$beatmapsetParams['title']}</a>"),
150 'text_clean' => sprintf($template, "[{$userParams['url_clean']} {$userParams['username']}]", "[{$beatmapsetParams['url_clean']} {$beatmapsetParams['title']}]"),
151 'beatmapset_id' => $beatmapset->getKey(),
152 'user_id' => $user->getKey(),
153 'private' => false,
154 'epicfactor' => 2,
155 ];
156
157 break;
158
159 case 'beatmapsetUpload':
160 $beatmapset = $options['beatmapset'];
161 $beatmapsetParams = static::beatmapsetParams($beatmapset);
162 $userParams = static::userParams($beatmapset->user);
163
164 $template = '%s has submitted a new beatmap "%s"';
165 $params = [
166 'text' => sprintf($template, "<b><a href='{$userParams['url']}'>{$userParams['username']}</a></b>", "<a href='{$beatmapsetParams['url']}'>{$beatmapsetParams['title']}</a>"),
167 'text_clean' => sprintf($template, "[{$userParams['url_clean']} {$userParams['username']}]", "[{$beatmapsetParams['url_clean']} {$beatmapsetParams['title']}]"),
168 'beatmapset_id' => $beatmapset->getKey(),
169 'user_id' => $beatmapset->user->getKey(),
170 'private' => false,
171 'epicfactor' => 4,
172 ];
173
174 break;
175
176 case 'usernameChange':
177 $user = static::userParams($options['user']);
178 $oldUsername = e($options['history']->username_last);
179 $newUsername = e($options['history']->username);
180 $params = [
181 'text' => "<b><a href='{$user['url']}'>{$oldUsername}</a></b> has changed their username to {$newUsername}!",
182 'user_id' => $user['id'],
183 'date' => $options['history']->timestamp,
184 'private' => false,
185 'epicfactor' => 4,
186 ];
187
188 break;
189
190 case 'userSupportGift':
191 $user = static::userParams($options['user']);
192 $params = [
193 'text' => "<b><a href='{$user['url']}'>{$user['username']}</a></b> has received the gift of osu! supporter!",
194 'user_id' => $user['id'],
195 'date' => $options['date'],
196 'private' => false,
197 'epicfactor' => 2,
198 ];
199
200 break;
201
202 case 'userSupportFirst':
203 $user = static::userParams($options['user']);
204 $params = [
205 'text' => "<b><a href='{$user['url']}'>{$user['username']}</a></b> has become an osu! supporter - thanks for your generosity!",
206 'user_id' => $user['id'],
207 'date' => $options['date'],
208 'private' => false,
209 'epicfactor' => 2,
210 ];
211
212 break;
213
214 case 'userSupportAgain':
215 $user = static::userParams($options['user']);
216 $params = [
217 'text' => "<b><a href='{$user['url']}'>{$user['username']}</a></b> has once again chosen to support osu! - thanks for your generosity!",
218 'user_id' => $user['id'],
219 'date' => $options['date'],
220 'private' => false,
221 'epicfactor' => 2,
222 ];
223
224 break;
225 }
226
227 if (isset($params)) {
228 if (!isset($params['date'])) {
229 $params['date'] = Carbon::now();
230 }
231
232 return static::create($params);
233 }
234 }
235
236 public function user()
237 {
238 return $this->belongsTo(User::class, 'user_id', 'user_id');
239 }
240
241 public function beatmap()
242 {
243 return $this->belongsTo(Beatmap::class, 'beatmap_id', 'beatmap_id');
244 }
245
246 public function beatmapset()
247 {
248 return $this->belongsTo(Beatmapset::class, 'beatmapset_id', 'beatmapset_id');
249 }
250
251 public function arrayBeatmap($matches)
252 {
253 $beatmapTitle = presence($matches['beatmapTitle'], '(no title)');
254
255 return [
256 'title' => html_entity_decode_better($beatmapTitle),
257 'url' => html_entity_decode_better($matches['beatmapUrl']),
258 ];
259 }
260
261 public function arrayBeatmapset($matches)
262 {
263 $beatmapsetTitle = presence($matches['beatmapsetTitle'], '(no title)');
264
265 return [
266 'title' => html_entity_decode_better($beatmapsetTitle),
267 'url' => html_entity_decode_better($matches['beatmapsetUrl']),
268 ];
269 }
270
271 public function arrayUser($matches)
272 {
273 if (isset($matches['userName'])) {
274 $username = html_entity_decode_better($matches['userName']);
275 $userUrl = html_entity_decode_better($matches['userUrl']);
276 } else {
277 $user = $this->user;
278 $username = $user->username;
279 $userUrl = route('users.show', $user->user_id);
280 }
281
282 return [
283 'username' => $username,
284 'url' => $userUrl,
285 ];
286 }
287
288 public function stringMode($mode)
289 {
290 switch ($mode) {
291 case 'osu!mania':
292 return 'mania';
293 case 'Taiko':
294 case 'osu!taiko':
295 return 'taiko';
296 case 'osu!':
297 return 'osu';
298 case 'Catch the Beat':
299 case 'osu!catch':
300 return 'fruits';
301 }
302 }
303
304 public function parseFailure($reason)
305 {
306 app('sentry')->getClient()->captureMessage(
307 'Failed parsing event',
308 null,
309 (new Scope())
310 ->setTag('reason', $reason)
311 ->setExtra('event', $this->toArray())
312 );
313
314 return ['parse_error' => true];
315 }
316
317 public function parseMatchesAchievement($matches)
318 {
319 $achievement = app('medals')->byNameIncludeDisabled($matches['achievementName']);
320 if ($achievement === null) {
321 return $this->parseFailure("unknown achievement ({$matches['achievementName']})");
322 }
323
324 return [
325 'achievement' => json_item($achievement, 'Achievement'),
326 'user' => $this->arrayUser($matches),
327 ];
328 }
329
330 public function parseMatchesBeatmapPlaycount($matches)
331 {
332 $count = intval(str_replace(',', '', $matches['count']));
333
334 return [
335 'beatmap' => $this->arrayBeatmap($matches),
336 'count' => $count,
337 ];
338 }
339
340 public function parseMatchesBeatmapsetApprove($matches)
341 {
342 return [
343 'approval' => $matches['approval'],
344 'beatmapset' => $this->arrayBeatmapset($matches),
345 'user' => $this->arrayUser($matches),
346 ];
347 }
348
349 public function parseMatchesBeatmapsetDelete($matches)
350 {
351 return [
352 'beatmapset' => $this->arrayBeatmapset($matches),
353 ];
354 }
355
356 public function parseMatchesBeatmapsetRevive($matches)
357 {
358 return [
359 'beatmapset' => $this->arrayBeatmapset($matches),
360 'user' => $this->arrayUser($matches),
361 ];
362 }
363
364 public function parseMatchesBeatmapsetUpdate($matches)
365 {
366 return [
367 'beatmapset' => $this->arrayBeatmapset($matches),
368 'user' => $this->arrayUser($matches),
369 ];
370 }
371
372 public function parseMatchesBeatmapsetUpload($matches)
373 {
374 return [
375 'beatmapset' => $this->arrayBeatmapset($matches),
376 'user' => $this->arrayUser($matches),
377 ];
378 }
379
380 public function parseMatchesMedal($matches)
381 {
382 $this->type = 'achievement';
383
384 return $this->parseMatchesAchievement($matches);
385 }
386
387 public function parseMatchesRank($matches)
388 {
389 $mode = $this->stringMode($matches['mode']);
390 if ($mode === null) {
391 return $this->parseFailure("unknown mode ({$matches['mode']})");
392 }
393
394 return [
395 'scoreRank' => $matches['scoreRank'],
396 'rank' => intval($matches['rank']),
397 'mode' => $mode,
398 'beatmap' => $this->arrayBeatmap($matches),
399 'user' => $this->arrayUser($matches),
400 ];
401 }
402
403 public function parseMatchesRankLost($matches)
404 {
405 $mode = $this->stringMode($matches['mode']);
406 if ($mode === null) {
407 return $this->parseFailure("unknown mode ({$matches['mode']})");
408 }
409
410 return [
411 'mode' => $mode,
412 'beatmap' => $this->arrayBeatmap($matches),
413 'user' => $this->arrayUser($matches),
414 ];
415 }
416
417 public function parseMatchesUsernameChange($matches)
418 {
419 return [
420 'user' => array_merge(
421 $this->arrayUser($matches),
422 ['previousUsername' => html_entity_decode_better($matches['previousUsername'])]
423 ),
424 ];
425 }
426
427 public function parseMatchesUserSupportAgain($matches)
428 {
429 return [
430 'user' => $this->arrayUser($matches),
431 ];
432 }
433
434 public function parseMatchesUserSupportFirst($matches)
435 {
436 return [
437 'user' => $this->arrayUser($matches),
438 ];
439 }
440
441 public function parseMatchesUserSupportGift($matches)
442 {
443 return [
444 'user' => $this->arrayUser($matches),
445 ];
446 }
447
448 public function parse()
449 {
450 if (!$this->parsed) {
451 foreach ($this->patterns as $name => $pattern) {
452 if (preg_match($pattern, $this->text, $matches) !== 1) {
453 continue;
454 }
455
456 $this->type = $name;
457 $fname = 'parseMatches'.ucfirst($name);
458
459 $this->details = $this->$fname($matches);
460 break;
461 }
462
463 if ($this->details === null) {
464 $this->details = $this->parseFailure('no matching pattern');
465 }
466
467 $this->parsed = true;
468 }
469
470 return $this;
471 }
472
473 public function scopeRecent($query)
474 {
475 return $query->orderBy('event_id', 'desc')->limit(5);
476 }
477
478 private static function userParams($user)
479 {
480 $url = e(route('users.show', $user, false));
481 return [
482 'id' => $user->getKey(),
483 'username' => e($user->username),
484 'url' => $url,
485 'url_clean' => $GLOBALS['cfg']['app']['url'].$url,
486 ];
487 }
488
489 private static function beatmapsetParams($beatmapset)
490 {
491 $url = e(route('beatmapsets.show', $beatmapset, false));
492 return [
493 'title' => e($beatmapset->artist.' - '.$beatmapset->title),
494 'url' => $url,
495 'url_clean' => $GLOBALS['cfg']['app']['url'].$url,
496 ];
497 }
498}