the browser-facing portion of osu!
at master 19 kB view raw
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}