Passed
Push — master ( d836c2...f4586f )
by Charlotte
02:24
created

cleanupNegativeResponseMessages()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 5
nc 3
nop 0
dl 0
loc 7
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * Livia
4
 * Copyright 2017-2019 Charlotte Dunois, All Rights Reserved
5
 *
6
 * Website: https://charuru.moe
7
 * License: https://github.com/CharlotteDunois/Livia/blob/master/LICENSE
8
*/
9
10
namespace CharlotteDunois\Livia;
11
12
/**
13
 * Handles parsing messages and running commands from them.
14
 *
15
 * @property \CharlotteDunois\Livia\LiviaClient  $client  The client which initiated the instance.
16
 */
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) {
361
        // Find the command to run by patterns
362
        foreach($this->client->registry->commands as $command) {
363
            if($command->patterns === null) {
364
                continue;
365
            }
366
            
367
            foreach($command->patterns as $ptrn) {
368
                \preg_match($ptrn, $message->content, $matches);
369
                if(!empty($matches)) {
370
                    return (new \CharlotteDunois\Livia\CommandMessage($this->client, $message, $command, null, $matches));
371
                }
372
            }
373
        }
374
        
375
        $prefix = $this->client->getGuildPrefix($message->guild);
376
        if(empty($this->commandPatterns[$prefix])) {
377
            $this->buildCommandPattern($prefix);
378
        }
379
        
380
        $cmdMessage = $this->matchDefault($message, $this->commandPatterns[$prefix], 2);
381
        if(!$cmdMessage && $message->guild === null) {
382
            $cmdMessage = $this->matchDefault($message, '/^([^\s]+)/i');
383
        }
384
        
385
        return $cmdMessage;
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() {
485
        $current = \time();
486
        $timeout = 5 * ((int) $this->client->getOption('negativeResponseThrottlingDuration'));
487
        
488
        foreach($this->negativeResponseThrottling as $key => $time) {
489
            if(($current - $time) >= $timeout) {
490
                unset($this->negativeResponseThrottling[$key]);
491
            }
492
        }
493
    }
494
}
495