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\Http\Controllers\Chat;
7
8use App\Libraries\Chat;
9use App\Libraries\UserChannelList;
10use App\Models\Chat\Message;
11use App\Models\User;
12use App\Models\UserAccountHistory;
13use App\Transformers\Chat\ChannelTransformer;
14use App\Transformers\Chat\MessageTransformer;
15use App\Transformers\Chat\UserSilenceTransformer;
16use Ds\Set;
17
18/**
19 * @group Chat
20 */
21class ChatController extends Controller
22{
23 public function __construct()
24 {
25 $this->middleware('require-scopes:chat.read', ['only' => ['ack']]);
26 $this->middleware('require-scopes:chat.write', ['only' => ['newConversation']]);
27
28 // TODO: remove as it's already defined in parent controller?
29 $this->middleware('auth');
30
31 parent::__construct();
32 }
33
34 /**
35 * Chat Keepalive
36 *
37 * Request periodically to reset chat activity timeout. Also returns an updated list of recent silences.
38 *
39 * See [Public channels and activity timeout](#public-channels-and-activity-timeout)
40 *
41 * ---
42 *
43 * ### Response Format
44 *
45 * Field | Type
46 * ---------------- | -----------------
47 * silences | [UserSilence](#usersilence)[]
48 *
49 * @queryParam history_since integer [UserSilence](#usersilence)s after the specified id to return.
50 * This field is preferred and takes precedence over `since`.
51 * @queryParam since integer [UserSilence](#usersilence)s after the specified [ChatMessage.message_id](#chatmessage) to return. No-example
52 */
53 public function ack()
54 {
55 Chat::ack(auth()->user());
56
57 $params = get_params(request()->all(), null, [
58 'history_since:int',
59 'since:int',
60 ], ['null_missing' => true]);
61
62 return [
63 'silences' => json_collection($this->getSilences($params['history_since'], $params['since'] ?? 0), new UserSilenceTransformer()),
64 ];
65 }
66
67 /**
68 * Create New PM
69 *
70 * This endpoint allows you to create a new PM channel.
71 *
72 * ---
73 *
74 * ### Response Format
75 *
76 * Field | Type
77 * ---------------- | -----------------
78 * channel | The new [ChatChannel](#chatchannel)
79 * message | the sent [ChatMessage](#chatmessage)
80 * new_channel_id | Deprecated; `channel_id` of newly created [ChatChannel](#chatchannel)
81 *
82 * <aside class="notice">
83 * This endpoint will only allow the creation of PMs initially, group chat support will come later.
84 * </aside>
85 *
86 * @bodyParam target_id integer required `user_id` of user to start PM with
87 * @bodyParam message string required message to send
88 * @bodyParam is_action boolean required whether the message is an action
89 * @bodyParam uuid string client-side message identifier which will be sent back in response and websocket json. Example: some-uuid-string
90 *
91 * @response {
92 * "channel": {
93 * "channel_id": 1234,
94 * "description": "",
95 * "icon": "https://a.ppy.sh/102?1500537068"
96 * "message_length_limit": 450,
97 * "moderated": false,
98 * "name": "peppy",
99 * "type": "PM",
100 * "uuid": null,
101 * "last_message_id": 9150005005,
102 * "users": [
103 * 101,
104 * 102
105 * ]
106 * },
107 * "message": {
108 * "channel_id": 1234,
109 * "content": "i can haz featured artist plz?",
110 * "is_action": false,
111 * "message_id": 9150005005,
112 * "sender_id": 102,
113 * "timestamp": "2024-12-23T01:23:45+00:00",
114 * "type": "plain",
115 * "uuid": "some-uuid-string",
116 * "sender": {
117 * "avatar_url": "https://a.ppy.sh/102?1500537068",
118 * "country_code": "AU",
119 * "default_group": "default",
120 * "id": 102,
121 * "is_active": true,
122 * "is_bot": false,
123 * "is_deleted": false,
124 * "is_online": true,
125 * "is_supporter": true
126 * "last_visit": "2024-12-23T01:23:45+00:00",
127 * "pm_friends_only": false,
128 * "profile_colour": "#333333",
129 * "username": "nekodex",
130 * }
131 * },
132 * "new_channel_id": 1234,
133 * }
134 */
135 public function newConversation()
136 {
137 $params = get_params(request()->all(), null, [
138 'is_action:bool',
139 'message',
140 'target_id:int',
141 'uuid',
142 ], ['null_missing' => true]);
143
144 $target = User::lookup($params['target_id'], 'id');
145 if ($target === null) {
146 abort(422, 'target user not found');
147 }
148
149 $sender = auth()->user();
150
151 /** @var Message $message */
152 $message = Chat::sendPrivateMessage(
153 $sender,
154 $target,
155 $params['message'],
156 $params['is_action'],
157 $params['uuid']
158 );
159
160 $channelJson = json_item($message->channel, ChannelTransformer::forUser($sender), ChannelTransformer::CONVERSATION_INCLUDES);
161
162 return [
163 'channel' => $channelJson,
164 'message' => json_item(
165 $message,
166 new MessageTransformer(),
167 ['sender']
168 ),
169 'new_channel_id' => $message->channel_id, // TODO: remove, there's channel already.
170 ];
171 }
172
173 // TODO: move the listing to channels.index
174 /**
175 * @deprecated
176 * @group Undocumented
177 */
178 public function presence()
179 {
180 return (new UserChannelList(auth()->user()))->get();
181 }
182
183 /**
184 * Get Updates
185 *
186 * Returns the list of channels the current User is in along with an updated list of [UserSilence](#usersilence)s.
187 *
188 * ---
189 *
190 * ### Response Format
191 *
192 * Field | Type
193 * ---------------- | -----------------
194 * messages | This field is not used and will be removed.
195 * presence | [ChatChannel](#chatchannel)[]?
196 * silences | [UserSilence](#usersilence)[]?
197 *
198 * @queryParam history_since integer [UserSilence](#usersilence)s after the specified id to return.
199 * This field is preferred and takes precedence over `since`.
200 * @queryParam includes string[] List of fields from `presence`, `silences` to include in the response. Returns all if not specified. No-example
201 * @queryParam since integer [UserSilence](#usersilence)s after the specified [ChatMessage.message_id](#chatmessage) to return. No-example
202 *
203 * @response {
204 * "presence": [
205 * {
206 * "channel_id": 5,
207 * "current_user_attributes": {
208 * "can_message": true,
209 * "can_message_error": null,
210 * "last_read_id": 9150005005
211 * },
212 * "name": "#osu",
213 * "description": "The official osu! channel (english only).",
214 * "type": "public",
215 * "last_read_id": 9150005005,
216 * "last_message_id": 9150005005
217 * },
218 * {
219 * "channel_id": 12345,
220 * "current_user_attributes": {
221 * "can_message": true,
222 * "can_message_error": null,
223 * "last_read_id": 9150001235
224 * },
225 * "type": "PM",
226 * "name": "peppy",
227 * "icon": "https://a.ppy.sh/2?1519081077.png",
228 * "users": [
229 * 2,
230 * 102
231 * ],
232 * "last_read_id": 9150001235,
233 * "last_message_id": 9150001234
234 * }
235 * ],
236 * "silences": [
237 * {
238 * "id": 1,
239 * "user_id": 2
240 * }
241 * ]
242 * }
243 */
244 public function updates()
245 {
246 static $availableIncludes;
247 $availableIncludes ??= new Set(['messages', 'presence', 'silences']);
248
249 $params = get_params(request()->all(), null, [
250 'history_since:int',
251 'includes:array',
252 'since:int',
253 ], ['null_missing' => true]);
254
255 if ($params['since'] === null) {
256 abort(422);
257 }
258
259 $includes = $params['includes'] !== null
260 ? $availableIncludes->intersect(new Set($params['includes']))
261 : $availableIncludes;
262
263 $response = [];
264
265 if ($includes->contains('presence')) {
266 $userChannelList = new UserChannelList(auth()->user());
267 $response['presence'] = $userChannelList->get();
268 }
269
270 if ($includes->contains('silences')) {
271 $silences = $this->getSilences($params['history_since'], $params['since']);
272 $response['silences'] = json_collection($silences, new UserSilenceTransformer());
273 }
274
275 // FIXME: empty array for compatibility with old lazer versions
276 if ($includes->contains('messages')) {
277 $response['messages'] = [];
278 }
279
280 return $response;
281 }
282
283 private function getSilences(?int $lastHistoryId, ?int $since)
284 {
285 $silenceQuery = UserAccountHistory::bans()->recentForChat()->limit(100);
286
287 if ($lastHistoryId === null) {
288 $previousMessage = Message::where('message_id', '<=', $since)->last();
289
290 if ($previousMessage === null) {
291 $silenceQuery->none();
292 } else {
293 $silenceQuery->where('timestamp', '>', $previousMessage->timestamp);
294 }
295 } else {
296 $silenceQuery->where('ban_id', '>', $lastHistoryId)->reorderBy('ban_id', 'DESC');
297 }
298
299 return $silenceQuery->get();
300 }
301}