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}