Total Complexity | 79 |
Total Lines | 474 |
Duplicated Lines | 0 % |
Changes | 0 |
Complex classes like CommandDispatcher often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use CommandDispatcher, and based on these observations, apply Extract Interface, too.
1 | <?php |
||
17 | class CommandDispatcher implements \Serializable { |
||
18 | /** |
||
19 | * The client which initiated the instance. |
||
20 | * @var \CharlotteDunois\Livia\LiviaClient |
||
21 | */ |
||
22 | protected $client; |
||
23 | |||
24 | /** |
||
25 | * Functions that can block commands from running. |
||
26 | * @var callable[] |
||
27 | */ |
||
28 | protected $inhibitors = array(); |
||
29 | |||
30 | /** |
||
31 | * AuthorID+ChannelID combination waiting for responses |
||
32 | * @var string[] |
||
33 | */ |
||
34 | protected $awaiting = array(); |
||
35 | |||
36 | /** |
||
37 | * Patterns of command. |
||
38 | * @var string[] |
||
39 | */ |
||
40 | protected $commandPatterns = array(); |
||
41 | |||
42 | /** |
||
43 | * Command results. |
||
44 | * @var \CharlotteDunois\Collect\Collection |
||
45 | */ |
||
46 | protected $results; |
||
47 | |||
48 | /** |
||
49 | * Contains an array of authorID-channelID-command => timestamps, used for throttling throttling or other some sort of "failure" messages. |
||
50 | * @var array |
||
51 | */ |
||
52 | protected $negativeResponseThrottling = array(); |
||
53 | |||
54 | /** |
||
55 | * @internal |
||
56 | */ |
||
57 | function __construct(\CharlotteDunois\Livia\LiviaClient $client) { |
||
58 | $this->client = $client; |
||
59 | |||
60 | $this->results = new \CharlotteDunois\Collect\Collection(); |
||
61 | } |
||
62 | |||
63 | /** |
||
64 | * @param string $name |
||
65 | * @return bool |
||
66 | * @throws \Exception |
||
67 | * @internal |
||
68 | */ |
||
69 | function __isset($name) { |
||
70 | try { |
||
71 | return $this->$name !== null; |
||
72 | } catch (\RuntimeException $e) { |
||
73 | if($e->getTrace()[0]['function'] === '__get') { |
||
74 | return false; |
||
75 | } |
||
76 | |||
77 | throw $e; |
||
78 | } |
||
79 | } |
||
80 | |||
81 | /** |
||
82 | * @param string $name |
||
83 | * @return mixed |
||
84 | * @throws \RuntimeException |
||
85 | * @internal |
||
86 | */ |
||
87 | function __get($name) { |
||
88 | if(\property_exists($this, $name)) { |
||
89 | return $this->$name; |
||
90 | } |
||
91 | |||
92 | throw new \RuntimeException('Unknown property '.\get_class($this).'::$'.$name); |
||
93 | } |
||
94 | |||
95 | /** |
||
96 | * @return string |
||
97 | * @internal |
||
98 | */ |
||
99 | function serialize() { |
||
100 | $vars = \get_object_vars($this); |
||
101 | |||
102 | unset($vars['client'], $vars['inhibitors']); |
||
103 | |||
104 | return \serialize($vars); |
||
105 | } |
||
106 | |||
107 | /** |
||
108 | * @return void |
||
109 | * @internal |
||
110 | */ |
||
111 | function unserialize($vars) { |
||
112 | if(\CharlotteDunois\Yasmin\Models\ClientBase::$serializeClient === null) { |
||
113 | throw new \Exception('Unable to unserialize a class without ClientBase::$serializeClient being set'); |
||
114 | } |
||
115 | |||
116 | $vars = \unserialize($vars); |
||
117 | |||
118 | foreach($vars as $name => $val) { |
||
119 | $this->$name = $val; |
||
120 | } |
||
121 | |||
122 | $this->client = \CharlotteDunois\Yasmin\Models\ClientBase::$serializeClient; |
||
123 | } |
||
124 | |||
125 | /** |
||
126 | * Adds an inhibitor. |
||
127 | * |
||
128 | * The inhibitor is supposed to return false, if the command should not be blocked. Otherwise it should return a string (as reason) or an array, containing as first element the reason and as second element a Promise (which resolves to a Message), a Message instance or null. |
||
129 | * The inhibitor can return a Promise (for async computation), but has to resolve with `false` or reject with array or string. |
||
130 | * |
||
131 | * Callable specification: |
||
132 | * ``` |
||
133 | * function (\CharlotteDunois\Livia\CommandMessage $message): array|string|false|ExtendedPromiseInterface |
||
134 | * ``` |
||
135 | * |
||
136 | * @param callable $inhibitor |
||
137 | * @return $this |
||
138 | */ |
||
139 | function addInhibitor(callable $inhibitor) { |
||
140 | if(!\in_array($inhibitor, $this->inhibitors, true)) { |
||
141 | $this->inhibitors[] = $inhibitor; |
||
142 | } |
||
143 | |||
144 | return $this; |
||
145 | } |
||
146 | |||
147 | /** |
||
148 | * Removes an inhibitor. |
||
149 | * @param callable $inhibitor |
||
150 | * @return $this |
||
151 | */ |
||
152 | function removeInhibitor(callable $inhibitor) { |
||
153 | $key = \array_search($inhibitor, $this->inhibitors, true); |
||
154 | if($key !== false) { |
||
155 | unset($this->inhibitors[$key]); |
||
156 | } |
||
157 | |||
158 | return $this; |
||
159 | } |
||
160 | |||
161 | /** |
||
162 | * Handles an incoming message. |
||
163 | * @param \CharlotteDunois\Yasmin\Models\Message $message |
||
164 | * @param \CharlotteDunois\Yasmin\Models\Message|null $oldMessage |
||
165 | * @return \React\Promise\ExtendedPromiseInterface |
||
166 | */ |
||
167 | function handleMessage(\CharlotteDunois\Yasmin\Models\Message $message, \CharlotteDunois\Yasmin\Models\Message $oldMessage = null) { |
||
168 | return (new \React\Promise\Promise(function (callable $resolve) use ($message, $oldMessage) { |
||
169 | try { |
||
170 | if(!$this->shouldHandleMessage($message, $oldMessage)) { |
||
171 | return $resolve(); |
||
172 | } |
||
173 | |||
174 | $cmdMessage = null; |
||
175 | $oldCmdMessage = null; |
||
176 | |||
177 | if($oldMessage !== null) { |
||
178 | $oldCmdMessage = $this->results->get($oldMessage->id); |
||
179 | if($oldCmdMessage === null && !$this->client->getOption('nonCommandEditable')) { |
||
180 | return $resolve(); |
||
181 | } |
||
182 | |||
183 | $cmdMessage = $this->parseMessage($message); |
||
184 | if($cmdMessage && $oldCmdMessage) { |
||
185 | $cmdMessage->setResponses($oldCmdMessage->responses); |
||
186 | } |
||
187 | } else { |
||
188 | $cmdMessage = $this->parseMessage($message); |
||
189 | } |
||
190 | |||
191 | if($cmdMessage) { |
||
192 | $this->inhibit($cmdMessage)->done(function () use ($message, $oldMessage, $cmdMessage, $resolve) { |
||
193 | if($cmdMessage->command) { |
||
194 | if($cmdMessage->command->isEnabledIn($message->guild)) { |
||
195 | $cmdMessage->run()->done(function ($responses = null) use ($message, $oldMessage, $cmdMessage, $resolve) { |
||
196 | if($responses !== null && !\is_array($responses)) { |
||
197 | $responses = array($responses); |
||
198 | } |
||
199 | |||
200 | $cmdMessage->finalize($responses); |
||
201 | $this->cacheCommandMessage($message, $oldMessage, $cmdMessage, $responses); |
||
202 | $resolve(); |
||
203 | }); |
||
204 | } else { |
||
205 | $message->reply('The command `'.$cmdMessage->command->name.'` is disabled.')->done(function ($response) use ($message, $oldMessage, $cmdMessage, $resolve) { |
||
206 | $responses = array($response); |
||
207 | $cmdMessage->finalize($responses); |
||
208 | |||
209 | $this->cacheCommandMessage($message, $oldMessage, $cmdMessage, $responses); |
||
210 | $resolve(); |
||
211 | }); |
||
212 | } |
||
213 | } else { |
||
214 | $this->client->emit('unknownCommand', $cmdMessage); |
||
215 | if(((bool) $this->client->getOption('unknownCommandResponse', true))) { |
||
216 | $message->reply('Unknown command. Use '.\CharlotteDunois\Livia\Commands\Command::anyUsage('help').'.')->done(function ($response) use ($message, $oldMessage, $cmdMessage, $resolve) { |
||
217 | $responses = array($response); |
||
218 | $cmdMessage->finalize($responses); |
||
219 | |||
220 | $this->cacheCommandMessage($message, $oldMessage, $cmdMessage, $responses); |
||
221 | $resolve(); |
||
222 | }); |
||
223 | } |
||
224 | } |
||
225 | }, function ($inhibited) use ($message, $oldMessage, $cmdMessage, $resolve) { |
||
226 | if(!\is_array($inhibited)) { |
||
227 | $inhibited = array($inhibited, null); |
||
228 | } |
||
229 | |||
230 | $this->client->emit('commandBlocked', $cmdMessage, $inhibited[0]); |
||
231 | |||
232 | if(!($inhibited[1] instanceof \React\Promise\PromiseInterface)) { |
||
233 | $inhibited[1] = \React\Promise\resolve($inhibited[1]); |
||
234 | } |
||
235 | |||
236 | $inhibited[1]->done(function ($responses) use ($message, $oldMessage, $cmdMessage, $resolve) { |
||
237 | if($responses !== null) { |
||
238 | $responses = array($responses); |
||
239 | } |
||
240 | |||
241 | $cmdMessage->finalize($responses); |
||
242 | $this->cacheCommandMessage($message, $oldMessage, $cmdMessage, $responses); |
||
243 | $resolve(); |
||
244 | }); |
||
245 | }); |
||
246 | } elseif($oldCmdMessage) { |
||
247 | $oldCmdMessage->finalize(null); |
||
248 | if(!$this->client->getOption('nonCommandEditable')) { |
||
249 | $this->results->delete($message->id); |
||
250 | } |
||
251 | |||
252 | $this->cacheCommandMessage($message, $oldMessage, $cmdMessage, array()); |
||
253 | $resolve(); |
||
254 | } |
||
255 | } catch (\Throwable $error) { |
||
256 | $this->client->emit('error', $error); |
||
257 | throw $error; |
||
258 | } |
||
259 | })); |
||
260 | } |
||
261 | |||
262 | /** |
||
263 | * Check whether a message should be handled. |
||
264 | * @param \CharlotteDunois\Yasmin\Models\Message $message |
||
265 | * @param \CharlotteDunois\Yasmin\Models\Message|null $oldMessage |
||
266 | * @return bool |
||
267 | */ |
||
268 | protected function shouldHandleMessage(\CharlotteDunois\Yasmin\Models\Message $message, \CharlotteDunois\Yasmin\Models\Message $oldMessage = null) { |
||
269 | if($message->author->bot || $message->author->id === $this->client->user->id) { |
||
270 | return false; |
||
271 | } |
||
272 | |||
273 | if($message->guild !== null && !$message->guild->available) { |
||
274 | return false; |
||
275 | } |
||
276 | |||
277 | // Ignore messages from users that the bot is already waiting for input from |
||
278 | if(\in_array($message->author->id.$message->channel->id, $this->awaiting)) { |
||
279 | return false; |
||
280 | } |
||
281 | |||
282 | if($oldMessage !== null && $message->content === $oldMessage->content) { |
||
283 | return false; |
||
284 | } |
||
285 | |||
286 | $editableDuration = (int) $this->client->getOption('commandEditableDuration'); |
||
287 | if($message->editedTimestamp !== null && ($editableDuration <= 0 || ($message->editedTimestamp - $message->createdTimestamp) >= $editableDuration)) { |
||
288 | return false; |
||
289 | } |
||
290 | |||
291 | return true; |
||
292 | } |
||
293 | |||
294 | /** |
||
295 | * Inhibits a command message. Resolves with false or array (reason, ?response (Promise (-> Message), Message instance or null)). |
||
296 | * @param \CharlotteDunois\Livia\CommandMessage $message |
||
297 | * @return \React\Promise\ExtendedPromiseInterface |
||
298 | */ |
||
299 | protected function inhibit(\CharlotteDunois\Livia\CommandMessage $message) { |
||
300 | return (new \React\Promise\Promise(function (callable $resolve, callable $reject) use ($message) { |
||
301 | $promises = array(); |
||
302 | |||
303 | foreach($this->inhibitors as $inhib) { |
||
304 | $inhibited = $inhib($message); |
||
305 | if(!($inhibited instanceof \React\Promise\PromiseInterface)) { |
||
306 | if($inhibited === false) { |
||
307 | $inhibited = \React\Promise\resolve($inhibited); |
||
308 | } else { |
||
309 | $inhibited = \React\Promise\reject($inhibited); |
||
310 | } |
||
311 | } |
||
312 | |||
313 | $promises[] = $inhibited; |
||
314 | } |
||
315 | |||
316 | \React\Promise\all($promises)->done(function ($values) use ($resolve, $reject) { |
||
317 | foreach($values as $value) { |
||
318 | if($value !== false) { |
||
319 | return $reject($value); |
||
320 | } |
||
321 | } |
||
322 | |||
323 | $resolve(); |
||
324 | }, $reject); |
||
325 | })); |
||
326 | } |
||
327 | |||
328 | /** |
||
329 | * Caches a command message to be editable. |
||
330 | * @param \CharlotteDunois\Yasmin\Models\Message $message Triggering message. |
||
331 | * @param \CharlotteDunois\Yasmin\Models\Message|null $oldMessage Triggering message's old version. |
||
332 | * @param \CharlotteDunois\Livia\CommandMessage|null $cmdMsg Command message to cache. |
||
333 | * @param \CharlotteDunois\Yasmin\Models\Message[]|null $responses Responses to the message. |
||
334 | * @return void |
||
335 | */ |
||
336 | protected function cacheCommandMessage($message, $oldMessage, $cmdMsg, $responses) { |
||
337 | $duration = (int) $this->client->getOption('commandEditableDuration', 0); |
||
338 | |||
339 | if($duration <= 0 || $cmdMsg === null) { |
||
340 | return; |
||
341 | } |
||
342 | |||
343 | if($responses !== null) { |
||
344 | $this->results->set($message->id, $cmdMsg); |
||
345 | if($oldMessage === null) { |
||
346 | $this->client->addTimer($duration, function () use ($message) { |
||
347 | $this->results->delete($message->id); |
||
348 | }); |
||
349 | } |
||
350 | } else { |
||
351 | $this->results->delete($message->id); |
||
352 | } |
||
353 | } |
||
354 | |||
355 | /** |
||
356 | * Parses a message to find details about command usage in it. |
||
357 | * @param \CharlotteDunois\Yasmin\Models\Message $message |
||
358 | * @return \CharlotteDunois\Livia\CommandMessage|null |
||
359 | */ |
||
360 | protected function parseMessage(\CharlotteDunois\Yasmin\Models\Message $message) { |
||
386 | } |
||
387 | |||
388 | /** |
||
389 | * Matches a message against a guild command pattern. |
||
390 | * @param \CharlotteDunois\Yasmin\Models\Message $message |
||
391 | * @param string $pattern The pattern to match against. |
||
392 | * @param int $commandNameIndex The index of the command name in the pattern matches. |
||
393 | * @return \CharlotteDunois\Livia\CommandMessage|null |
||
394 | */ |
||
395 | protected function matchDefault(\CharlotteDunois\Yasmin\Models\Message $message, string $pattern, int $commandNameIndex = 1) { |
||
396 | \preg_match($pattern, $message->content, $matches); |
||
397 | if(!empty($matches)) { |
||
398 | $commands = $this->client->registry->findCommands($matches[$commandNameIndex], true); |
||
399 | if(\count($commands) !== 1 || $commands[0]->defaultHandling === false) { |
||
400 | return (new \CharlotteDunois\Livia\CommandMessage($this->client, $message, null)); |
||
401 | } |
||
402 | |||
403 | $argString = (string) \mb_substr($message->content, (\mb_strlen($matches[1]) + (!empty($matches[2]) ? \mb_strlen($matches[2]) : 0))); |
||
404 | return (new \CharlotteDunois\Livia\CommandMessage($this->client, $message, $commands[0], $argString)); |
||
405 | } |
||
406 | |||
407 | return null; |
||
408 | } |
||
409 | |||
410 | /** |
||
411 | * Creates a regular expression to match the command prefix and name in a message. |
||
412 | * @param string|null $prefix |
||
413 | * @return string |
||
414 | * @internal |
||
415 | */ |
||
416 | function buildCommandPattern(string $prefix = null) { |
||
417 | $pattern = ''; |
||
418 | if($prefix !== null) { |
||
419 | $escapedPrefix = \preg_quote($prefix, '/'); |
||
420 | $pattern = '/^(<@!?'.$this->client->user->id.'>\s+(?:'.$escapedPrefix.'\s*)?|'.$escapedPrefix.'\s*)([^\s]+)/iu'; |
||
421 | } else { |
||
422 | $pattern = '/^(<@!?'.$this->client->user->id.'>\s+)([^\s]+)/iu'; |
||
423 | } |
||
424 | |||
425 | $this->commandPatterns[$prefix] = $pattern; |
||
426 | |||
427 | $this->client->emit('debug', 'Built command pattern for prefix "'.$prefix.'": '.$pattern); |
||
428 | return $pattern; |
||
429 | } |
||
430 | |||
431 | /** |
||
432 | * Sets the awaiting context for the message. |
||
433 | * @param \CharlotteDunois\Livia\CommandMessage $message |
||
434 | * @return void |
||
435 | * @internal |
||
436 | */ |
||
437 | function setAwaiting(\CharlotteDunois\Livia\CommandMessage $message) { |
||
438 | $this->awaiting[] = $message->message->author->id.$message->message->channel->id; |
||
439 | } |
||
440 | |||
441 | /** |
||
442 | * Removes the awaiting context for the message. |
||
443 | * @param \CharlotteDunois\Livia\CommandMessage $message |
||
444 | * @return void |
||
445 | * @internal |
||
446 | */ |
||
447 | function unsetAwaiting(\CharlotteDunois\Livia\CommandMessage $message) { |
||
448 | $key = \array_search($message->message->author->id.$message->message->channel->id, $this->awaiting, true); |
||
449 | if($key !== false) { |
||
450 | unset($this->awaiting[$key]); |
||
451 | } |
||
452 | } |
||
453 | |||
454 | /** |
||
455 | * Throttles negative response messages (such as throttling, not a nsfw channel, command blocked, etc.). Used exclusively for and by `CommandMessage`. |
||
456 | * @param \CharlotteDunois\Livia\CommandMessage $message |
||
457 | * @param string $response |
||
458 | * @param callable $resolve |
||
459 | * @param callable $reject |
||
460 | * @return void |
||
461 | */ |
||
462 | function throttleNegativeResponseMessage(\CharlotteDunois\Livia\CommandMessage $message, string $response, callable $resolve, callable $reject) { |
||
463 | if($message->command === null) { |
||
464 | return $resolve(array()); |
||
465 | } |
||
466 | |||
467 | $key = $message->message->author->id.'-'.$message->message->channel->id.'-'.$message->command->name; |
||
468 | $timestamp = $this->negativeResponseThrottling[$key] ?? 0; |
||
469 | $timeout = (int) $this->client->getOption('negativeResponseThrottlingDuration'); |
||
470 | |||
471 | if($timeout >= (\time() - $timestamp)) { |
||
472 | return $resolve(array()); |
||
473 | } |
||
474 | |||
475 | $this->negativeResponseThrottling[$key] = \time(); |
||
476 | $message->reply($response)->done($resolve, $reject); |
||
477 | } |
||
478 | |||
479 | /** |
||
480 | * Cleans up too hold negative response messages (5 * duration). |
||
481 | * @return void |
||
482 | * @internal |
||
483 | */ |
||
484 | function cleanupNegativeResponseMessages() { |
||
491 | } |
||
492 | } |
||
493 | } |
||
494 | } |
||
495 |