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\Chat; 7 8use App\Events\ChatChannelEvent; 9use App\Exceptions\API; 10use App\Exceptions\InvariantException; 11use App\Jobs\Notifications\ChannelAnnouncement; 12use App\Jobs\Notifications\ChannelMessage; 13use App\Libraries\AuthorizationResult; 14use App\Libraries\Chat\MessageTask; 15use App\Models\LegacyMatch\LegacyMatch; 16use App\Models\Multiplayer\Room; 17use App\Models\User; 18use App\Traits\Memoizes; 19use App\Traits\Validatable; 20use Carbon\Carbon; 21use Illuminate\Database\Eloquent\Collection; 22use Illuminate\Support\Str; 23use LaravelRedis; 24use Redis; 25 26/** 27 * @property int[] $allowed_groups 28 * @property int $channel_id 29 * @property Carbon $creation_time 30 * @property-read string $creation_time_json 31 * @property string $description 32 * @property int|null $last_message_id 33 * @property-read Collection<Message> $messages 34 * @property int|null $match_id 35 * @property bool $moderated 36 * @property-read \App\Models\LegacyMatch\LegacyMatch|null $multiplayerMatch 37 * @property string $name 38 * @property int|null $room_id 39 * @property string $type 40 * @property-read Collection<UserChannel> $userChannels 41 * @method static \Illuminate\Database\Eloquent\Builder PM() 42 * @method static \Illuminate\Database\Eloquent\Builder public() 43 */ 44class Channel extends Model 45{ 46 use Memoizes { 47 Memoizes::resetMemoized as origResetMemoized; 48 } 49 50 use Validatable; 51 52 const ANNOUNCE_MESSAGE_LENGTH_LIMIT = 1024; // limited by column length 53 const CHAT_ACTIVITY_TIMEOUT = 60; // in seconds. 54 55 const MAX_FIELD_LENGTHS = [ 56 'description' => 255, 57 'name' => 50, 58 ]; 59 60 public ?string $uuid = null; 61 62 protected $attributes = [ 63 'description' => '', 64 ]; 65 66 protected $casts = [ 67 'creation_time' => 'datetime', 68 'moderated' => 'boolean', 69 ]; 70 71 protected $primaryKey = 'channel_id'; 72 73 private ?Collection $pmUsers; 74 private array $preloadedUserChannels = []; 75 76 const TYPES = [ 77 'announce' => 'ANNOUNCE', 78 'public' => 'PUBLIC', 79 'private' => 'PRIVATE', 80 'multiplayer' => 'MULTIPLAYER', 81 'spectator' => 'SPECTATOR', 82 'temporary' => 'TEMPORARY', 83 'pm' => 'PM', 84 'group' => 'GROUP', 85 ]; 86 87 public static function ack(int $channelId, int $userId, ?int $timestamp = null, ?Redis $redis = null): void 88 { 89 $timestamp ??= time(); 90 $redis ??= LaravelRedis::client(); 91 $key = static::getAckKey($channelId); 92 $redis->zadd($key, $timestamp, $userId); 93 $redis->expire($key, static::CHAT_ACTIVITY_TIMEOUT * 10); 94 } 95 96 /** 97 * Creates a chat broadcast Channel and associated UserChannels. 98 * 99 * @param Collection<User> $users 100 */ 101 public static function createAnnouncement(Collection $users, array $rawParams, ?string $uuid = null): static 102 { 103 $params = get_params($rawParams, null, [ 104 'description:string', 105 'name:string', 106 ], ['null_missing' => true]); 107 108 $params['moderated'] = true; 109 $params['type'] = static::TYPES['announce']; 110 111 $channel = new static($params); 112 $connection = $channel->getConnection(); 113 $connection->transaction(function () use ($channel, $connection, $users, $uuid) { 114 $channel->saveOrExplode(); 115 $channel->uuid = $uuid; 116 $userChannels = $channel->userChannels()->createMany($users->map(fn ($user) => ['user_id' => $user->getKey()])); 117 foreach ($userChannels as $userChannel) { 118 // preset to avoid extra queries during permission check. 119 $userChannel->setRelation('channel', $channel); 120 $userChannel->channel->setUserChannel($userChannel); 121 } 122 123 // TODO: only the sender needs this now. 124 foreach ($users as $user) { 125 (new ChatChannelEvent($channel, $user, 'join'))->broadcast(true); 126 } 127 128 $connection->afterCommit(fn () => datadog_increment('chat.channel.create', ['type' => $channel->type])); 129 }); 130 131 return $channel; 132 } 133 134 public static function createMultiplayer(Room $room) 135 { 136 if (!$room->exists) { 137 throw new InvariantException('cannot create Channel for a Room that has not been persisted.'); 138 } 139 140 return static::create([ 141 'name' => "#lazermp_{$room->getKey()}", 142 'type' => static::TYPES['multiplayer'], 143 'description' => $room->name, 144 ]); 145 } 146 147 public static function createPM(User $user1, User $user2) 148 { 149 $channel = new static([ 150 'name' => static::getPMChannelName($user1, $user2), 151 'type' => static::TYPES['pm'], 152 'description' => '', // description is not nullable 153 ]); 154 155 $connection = $channel->getConnection(); 156 $connection->transaction(function () use ($channel, $connection, $user1, $user2) { 157 $channel->saveOrExplode(); 158 $channel->addUser($user1); 159 $channel->addUser($user2); 160 $channel->setPmUsers([$user1, $user2]); 161 162 $connection->afterCommit(fn () => datadog_increment('chat.channel.create', ['type' => $channel->type])); 163 }); 164 165 return $channel; 166 } 167 168 public static function findPM(User $user1, User $user2) 169 { 170 $channelName = static::getPMChannelName($user1, $user2); 171 172 $channel = static::where('name', $channelName)->first(); 173 174 $channel?->setPmUsers([$user1, $user2]); 175 176 return $channel; 177 } 178 179 public static function getAckKey(int $channelId) 180 { 181 return "chat:channel:{$channelId}"; 182 } 183 184 public static function getPMChannelName(User $user1, User $user2): string 185 { 186 $userIds = [$user1->getKey(), $user2->getKey()]; 187 sort($userIds); 188 189 return '#pm_'.implode('-', $userIds); 190 } 191 192 public function activeUserIds() 193 { 194 return $this->isPublic() 195 ? LaravelRedis::zrangebyscore(static::getAckKey($this->getKey()), now()->subSeconds(static::CHAT_ACTIVITY_TIMEOUT)->timestamp, 'inf') 196 : $this->userIds(); 197 } 198 199 /** 200 * This check is used for whether the user can enter into the input box for the channel, 201 * not if a message is actually allowed to be sent. 202 */ 203 public function checkCanMessage(User $user): AuthorizationResult 204 { 205 return priv_check_user($user, 'ChatChannelCanMessage', $this); 206 } 207 208 public function displayIconFor(?User $user): ?string 209 { 210 return $this->pmTargetFor($user)?->user_avatar; 211 } 212 213 public function displayNameFor(?User $user): ?string 214 { 215 if (!$this->isPM()) { 216 return $this->name; 217 } 218 219 return $this->pmTargetFor($user)?->username; 220 } 221 222 public function setDescriptionAttribute(?string $value) 223 { 224 $this->attributes['description'] = trim($value ?? ''); 225 } 226 227 public function setNameAttribute(?string $value) 228 { 229 $this->attributes['name'] = presence(trim($value)); 230 } 231 232 public function isVisibleFor(User $user): bool 233 { 234 if (!$this->isPM()) { 235 return true; 236 } 237 238 $targetUser = $this->pmTargetFor($user); 239 240 return !( 241 $targetUser === null 242 || $user->hasBlocked($targetUser) 243 && !($targetUser->isBot() || $targetUser->isModerator() || $targetUser->isAdmin()) 244 ); 245 } 246 247 /** 248 * Preset the UserChannel with Channel::setUserChannel when handling multiple channels. 249 * UserChannelList will automatically do this. 250 */ 251 public function lastReadIdFor(?User $user): ?int 252 { 253 if ($user === null) { 254 return null; 255 } 256 257 return $this->userChannelFor($user)?->last_read_id; 258 } 259 260 public function messages() 261 { 262 return $this->hasMany(Message::class); 263 } 264 265 public function userChannels() 266 { 267 return $this->hasMany(UserChannel::class); 268 } 269 270 public function userIds(): array 271 { 272 return $this->memoize(__FUNCTION__, function () { 273 // 4 = strlen('#pm_') 274 if ($this->isPM() && substr($this->name, 0, 4) === '#pm_') { 275 $userIds = get_arr(explode('-', substr($this->name, 4)), 'get_int'); 276 } 277 278 return $userIds ?? $this->userChannels()->pluck('user_id')->all(); 279 }); 280 } 281 282 public function users(): Collection 283 { 284 return $this->memoize(__FUNCTION__, function () { 285 if ($this->isPM() && isset($this->pmUsers)) { 286 return $this->pmUsers; 287 } 288 289 // This isn't a has-many-through because the User and UserChannel are in different databases. 290 return User::whereIn('user_id', $this->userIds())->get(); 291 }); 292 } 293 294 public function visibleUsers(): Collection 295 { 296 return $this->isPM() ? $this->users() : new Collection(); 297 } 298 299 public function scopePublic($query) 300 { 301 return $query->where('type', static::TYPES['public']); 302 } 303 304 public function scopePM($query) 305 { 306 return $query->where('type', static::TYPES['pm']); 307 } 308 309 public function getAttribute($key) 310 { 311 return match ($key) { 312 'channel_id', 313 'description', 314 'last_message_id', 315 'name', 316 'type' => $this->getRawAttribute($key), 317 318 'moderated' => (bool) $this->getRawAttribute($key), 319 320 'allowed_groups' => $this->getAllowedGroups(), 321 'match_id' => $this->getMatchId(), 322 'room_id' => $this->getRoomId(), 323 324 'creation_time' => $this->getTimeFast($key), 325 326 'creation_time_json' => $this->getJsonTimeFast($key), 327 328 'messages', 329 'multiplayerMatch', 330 'userChannels' => $this->getRelationValue($key), 331 }; 332 } 333 334 public function isAnnouncement() 335 { 336 return $this->type === static::TYPES['announce']; 337 } 338 339 public function isHideable() 340 { 341 return $this->isPM() || $this->isAnnouncement(); 342 } 343 344 public function isMultiplayer() 345 { 346 return $this->type === static::TYPES['multiplayer']; 347 } 348 349 public function isPublic() 350 { 351 return $this->type === static::TYPES['public']; 352 } 353 354 public function isPrivate() 355 { 356 return $this->type === static::TYPES['private']; 357 } 358 359 public function isPM() 360 { 361 return $this->type === static::TYPES['pm']; 362 } 363 364 public function isGroup() 365 { 366 return $this->type === static::TYPES['group']; 367 } 368 369 public function isBanchoMultiplayerChat() 370 { 371 return $this->type === static::TYPES['temporary'] && starts_with($this->name, ['#mp_', '#spect_']); 372 } 373 374 public function isValid() 375 { 376 $this->validationErrors()->reset(); 377 378 if ($this->name === null) { 379 $this->validationErrors()->add('name', 'required'); 380 } 381 382 $this->validateDbFieldLengths(); 383 384 return $this->validationErrors()->isEmpty(); 385 } 386 387 public function messageLengthLimit(): int 388 { 389 return $this->isAnnouncement() 390 ? static::ANNOUNCE_MESSAGE_LENGTH_LIMIT 391 : $GLOBALS['cfg']['osu']['chat']['message_length_limit']; 392 } 393 394 public function multiplayerMatch() 395 { 396 return $this->belongsTo(LegacyMatch::class, 'match_id'); 397 } 398 399 public function pmTargetFor(?User $user): ?User 400 { 401 if (!$this->isPM() || $user === null) { 402 return null; 403 } 404 405 $userId = $user->getKey(); 406 407 return $this->memoize(__FUNCTION__.':'.$userId, function () use ($userId) { 408 return $this->users()->firstWhere('user_id', '<>', $userId); 409 }); 410 } 411 412 public function receiveMessage(User $sender, ?string $content, bool $isAction = false, ?string $uuid = null) 413 { 414 if (!$this->isAnnouncement()) { 415 $content = str_replace(["\r", "\n"], ' ', trim($content)); 416 } 417 418 if (!present($content)) { 419 throw new API\ChatMessageEmptyException(osu_trans('api.error.chat.empty')); 420 } 421 422 $maxLength = $this->messageLengthLimit(); 423 if (mb_strlen($content, 'UTF-8') > $maxLength) { 424 throw new API\ChatMessageTooLongException(osu_trans('api.error.chat.too_long')); 425 } 426 427 if ($this->isPM()) { 428 $limit = $GLOBALS['cfg']['osu']['chat']['rate_limits']['private']['limit']; 429 $window = $GLOBALS['cfg']['osu']['chat']['rate_limits']['private']['window']; 430 $keySuffix = 'PM'; 431 } else { 432 $limit = $GLOBALS['cfg']['osu']['chat']['rate_limits']['public']['limit']; 433 $window = $GLOBALS['cfg']['osu']['chat']['rate_limits']['public']['window']; 434 $keySuffix = 'PUBLIC'; 435 } 436 437 $key = "message_throttle:{$sender->user_id}:{$keySuffix}"; 438 $now = now(); 439 440 // This works by keeping a sorted set of when the last messages were sent by the user (per message type). 441 // The timestamp of the message is used as the score, which allows for zremrangebyscore to cull old messages 442 // in a rolling window fashion. 443 [,$sent] = LaravelRedis::transaction() 444 ->zremrangebyscore($key, 0, $now->timestamp - $window) 445 ->zrange($key, 0, -1, 'WITHSCORES') 446 ->zadd($key, $now->timestamp, (string) Str::uuid()) 447 ->expire($key, $window) 448 ->exec(); 449 450 if (count($sent) >= $limit) { 451 throw new API\ExcessiveChatMessagesException(osu_trans('api.error.chat.limit_exceeded')); 452 } 453 454 $message = new Message([ 455 'content' => $this->isAnnouncement() ? $content : app('chat-filters')->filter($content), 456 'is_action' => $isAction, 457 'timestamp' => $now, 458 ]); 459 460 $message->sender()->associate($sender)->channel()->associate($this) 461 ->uuid = $uuid; // relay any message uuid back. 462 463 $message->getConnection()->transaction(function () use ($message, $sender) { 464 $message->save(); 465 466 $this->update(['last_message_id' => $message->getKey()]); 467 468 $userChannel = $this->userChannelFor($sender); 469 470 if ($userChannel) { 471 $userChannel->markAsRead($message->message_id); 472 } 473 474 $this->unhide(); 475 476 if ($this->isPM()) { 477 (new ChannelMessage($message, $sender))->dispatch(); 478 } elseif ($this->isAnnouncement()) { 479 (new ChannelAnnouncement($message, $sender))->dispatch(); 480 } 481 482 MessageTask::dispatch($message); 483 }); 484 485 datadog_increment('chat.channel.send', ['target' => $this->type]); 486 487 return $message; 488 } 489 490 public function addUser(User $user) 491 { 492 if ($this->isPublic()) { 493 static::ack($this->getKey(), $user->getKey()); 494 } 495 496 $userChannel = $this->userChannelFor($user); 497 498 if ($userChannel !== null) { 499 // No check for sending join event, assumming non-hideable channels don't get hidden. 500 if (!$userChannel->isHidden()) { 501 return; 502 } 503 504 $userChannel->update(['hidden' => false]); 505 } else { 506 $userChannel = new UserChannel(); 507 $userChannel->user()->associate($user); 508 $userChannel->channel()->associate($this); 509 $userChannel->save(); 510 $this->resetMemoized(); 511 } 512 513 (new ChatChannelEvent($this, $user, 'join'))->broadcast(true); 514 515 datadog_increment('chat.channel.join', ['type' => $this->type]); 516 } 517 518 public function removeUser(User $user) 519 { 520 $userChannel = $this->userChannelFor($user); 521 522 if ($userChannel === null) { 523 return; 524 } 525 526 if ($this->isHideable()) { 527 if ($userChannel->isHidden()) { 528 return; 529 } 530 531 $userChannel->update(['hidden' => true]); 532 } else { 533 $userChannel->delete(); 534 } 535 536 $this->resetMemoized(); 537 538 (new ChatChannelEvent($this, $user, 'part'))->broadcast(true); 539 540 datadog_increment('chat.channel.part', ['type' => $this->type]); 541 } 542 543 public function hasUser(User $user) 544 { 545 return $this->userChannelFor($user) !== null; 546 } 547 548 public function save(array $options = []) 549 { 550 return $this->isValid() && parent::save($options); 551 } 552 553 public function setPmUsers(array $users) 554 { 555 $this->pmUsers = new Collection($users); 556 } 557 558 public function setUserChannel(UserChannel $userChannel) 559 { 560 if ($userChannel->channel_id !== $this->getKey()) { 561 throw new InvariantException('userChannel does not belong to the channel.'); 562 } 563 564 $this->preloadedUserChannels[$userChannel->user_id] = $userChannel; 565 } 566 567 /** 568 * Unhides UserChannels as necessary when receiving messages. 569 * 570 * @return void 571 */ 572 public function unhide(?User $user = null) 573 { 574 if (!$this->isHideable()) { 575 return; 576 } 577 578 $params = [ 579 'channel_id' => $this->channel_id, 580 'hidden' => true, 581 ]; 582 583 if ($user !== null) { 584 $params['user_id'] = $user->getKey(); 585 } 586 587 $count = UserChannel::where($params)->update([ 588 'hidden' => false, 589 ]); 590 591 if ($count > 0) { 592 datadog_increment('chat.channel.join', ['type' => $this->type], $count); 593 } 594 } 595 596 public function validationErrorsTranslationPrefix(): string 597 { 598 return 'chat.channel'; 599 } 600 601 protected function resetMemoized(): void 602 { 603 $this->origResetMemoized(); 604 // simpler to reset preloads since its use-cases are more specific, 605 // rather than trying to juggle them to ensure userChannelFor returns as expected. 606 $this->preloadedUserChannels = []; 607 } 608 609 private function getAllowedGroups(): array 610 { 611 $value = $this->getRawAttribute('allowed_groups'); 612 613 return $value === null ? [] : array_map('intval', explode(',', $value)); 614 } 615 616 private function getMatchId() 617 { 618 // TODO: add lazer mp support? 619 if ($this->isBanchoMultiplayerChat()) { 620 return intval(str_replace('#mp_', '', $this->name)); 621 } 622 } 623 624 private function getRoomId() 625 { 626 // 9 = strlen('#lazermp_') 627 if ($this->isMultiplayer() && substr($this->name, 0, 9) === '#lazermp_') { 628 return get_int(substr($this->name, 9)); 629 } 630 } 631 632 private function userChannelFor(User $user): ?UserChannel 633 { 634 $userId = $user->getKey(); 635 636 return $this->memoize(__FUNCTION__.':'.$userId, function () use ($user, $userId) { 637 $userChannel = $this->preloadedUserChannels[$userId] ?? UserChannel::where([ 638 'channel_id' => $this->channel_id, 639 'user_id' => $userId, 640 ])->first(); 641 642 $userChannel?->setRelation('user', $user); 643 644 return $userChannel; 645 }); 646 } 647}