IRCClient::_readSocket()   B
last analyzed

Complexity

Conditions 10
Paths 10

Size

Total Lines 38
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 21
dl 0
loc 38
rs 7.6666
c 0
b 0
f 0
cc 10
nc 10
nop 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace App\Services;
4
5
/**
6
 * Basic IRC client for fetching IRCScraper.
7
 *
8
 *
9
 * Class IRCClient
10
 */
11
class IRCClient
12
{
13
    /**
14
     * Hostname IRC server used when connecting.
15
     */
16
    protected string $_remote_host = '';
17
18
    /**
19
     * Port number IRC server.
20
     */
21
    protected int $_remote_port = 6667;
22
23
    /**
24
     * Socket transport type for the IRC server.
25
     */
26
    protected string $_remote_transport = 'tcp';
27
28
    /**
29
     * Hostname the IRC server sent us back.
30
     */
31
    protected string $_remote_host_received = '';
32
33
    /**
34
     * String used when creating the stream socket.
35
     */
36
    protected string $_remote_socket_string = '';
37
38
    /**
39
     * Are we using tls/ssl?
40
     */
41
    protected bool $_remote_tls = false;
42
43
    /**
44
     * Time in seconds to timeout on connect.
45
     */
46
    protected int $_remote_connection_timeout = 30;
47
48
    /**
49
     * Time in seconds before we timeout when sending/receiving a command.
50
     */
51
    protected int $_socket_timeout = 180;
52
53
    /**
54
     * How many times to retry when connecting to IRC.
55
     */
56
    protected int $_reconnectRetries = 3;
57
58
    /**
59
     * Seconds to delay when reconnecting fails.
60
     */
61
    protected int $_reconnectDelay = 5;
62
63
    /**
64
     * Stream socket client.
65
     *
66
     * @var resource
67
     */
68
    protected $_socket = null;
69
70
    /**
71
     * Buffer contents.
72
     */
73
    protected ?string $_buffer = null;
74
75
    /**
76
     * When someone types something into a channel, buffer it.
77
     * array(
78
     *     'nickname' => string(The nick name of the person who posted.),
79
     *     'channel'  => string(The channel name.),
80
     *     'message'  => string(The message the person posted.)
81
     * );.
82
     *
83
     * @note   Used with the processChannelMessages() function.
84
     */
85
    protected array $_channelData = [];
86
87
    /**
88
     * Nickname when we log in.
89
     */
90
    protected string $_nickName;
91
92
    /**
93
     * Username when we log in.
94
     */
95
    protected string $_userName;
96
97
    /**
98
     * "Real" name when we log in.
99
     */
100
    protected string $_realName;
101
102
    /**
103
     * Password when we log in.
104
     */
105
    protected ?string $_password = null;
106
107
    /**
108
     * List of channels and passwords to join.
109
     */
110
    protected array $_channels;
111
112
    /**
113
     * Last time we received a ping or sent a ping to the server.
114
     */
115
    protected int $_lastPing;
116
117
    /**
118
     * How many times we've tried to reconnect to IRC.
119
     */
120
    protected int $_currentRetries = 0;
121
122
    /**
123
     * Turns on or off debugging.
124
     */
125
    protected bool $_debug = true;
126
127
    /**
128
     * Are we already logged in to IRC?
129
     */
130
    protected bool $_alreadyLoggedIn = false;
131
132
    /**
133
     * Disconnect from IRC.
134
     */
135
    public function __destruct()
136
    {
137
        $this->quit();
138
    }
139
140
    /**
141
     * Time before giving up when trying to read or write to the IRC server.
142
     * The default is fine, it will ping the server if the server does not ping us
143
     * within this time to keep the connection alive.
144
     *
145
     * @param  int  $timeout  Seconds.
146
     */
147
    public function setSocketTimeout(int $timeout)
148
    {
149
        $this->_socket_timeout = $timeout;
150
    }
151
152
    /**
153
     * Amount of time to wait before giving up when connecting.
154
     *
155
     * @param  int  $timeout  Seconds.
156
     */
157
    public function setConnectionTimeout(int $timeout)
158
    {
159
        $this->_remote_connection_timeout = $timeout;
160
    }
161
162
    /**
163
     * Amount of times to retry before giving up when connecting.
164
     */
165
    public function setConnectionRetries(int $retries)
166
    {
167
        $this->_reconnectRetries = $retries;
168
    }
169
170
    /**
171
     * Amount of time to wait between failed connects.
172
     *
173
     * @param  int  $delay  Seconds.
174
     */
175
    public function setReConnectDelay(int $delay)
176
    {
177
        $this->_reconnectDelay = $delay;
178
    }
179
180
    /**
181
     * Connect to a IRC server.
182
     *
183
     * @param  string  $hostname  Host name of the IRC server (can be a IP or a name).
184
     * @param  int  $port  Port number of the IRC server.
185
     * @param  bool  $tls  Use encryption for the socket transport? (make sure the port is right).
186
     */
187
    public function connect(string $hostname, int $port = 6667, bool $tls = false): bool
188
    {
189
        $this->_alreadyLoggedIn = false;
190
        $transport = ($tls === true ? 'tls' : 'tcp');
191
192
        $socket_string = $transport.'://'.$hostname.':'.$port;
193
        if ($socket_string !== $this->_remote_socket_string || ! $this->_connected()) {
194
            if ($hostname === '') {
195
                echo 'ERROR: IRC host name must not be empty!'.PHP_EOL;
196
197
                return false;
198
            }
199
200
            $this->_remote_host = $hostname;
201
            $this->_remote_port = $port;
202
            $this->_remote_transport = $transport;
203
            $this->_remote_tls = $tls;
204
            $this->_remote_socket_string = $socket_string;
205
206
            // Try to connect until we run out of retries.
207
            while ($this->_reconnectRetries >= $this->_currentRetries++) {
208
                $this->_initiateStream();
209
                if ($this->_connected()) {
210
                    break;
211
                }
212
213
                // Sleep between retries.
214
                sleep($this->_reconnectDelay);
215
            }
216
        } else {
217
            $this->_alreadyLoggedIn = true;
218
        }
219
220
        // Set last ping time to now.
221
        $this->_lastPing = time();
222
        // Reset retries counter.
223
        $this->_currentRetries = 0;
224
225
        return $this->_connected();
226
    }
227
228
    /**
229
     * Log in to a IRC server.
230
     *
231
     * @param  string  $nickName  The nick name - visible in the channel.
232
     * @param  string  $userName  The user name - visible in the host name.
233
     * @param  string  $realName  The real name - visible in the WhoIs.
234
     * @param  string|null  $password  The password - some servers require a password.
235
     */
236
    public function login(string $nickName, string $userName, string $realName, ?string $password = null): bool
237
    {
238
        if (! $this->_connected()) {
239
            echo 'ERROR: You must connect to IRC first!'.PHP_EOL;
240
241
            return false;
242
        }
243
244
        if (empty($nickName) || empty($userName) || empty($realName)) {
245
            echo 'ERROR: nick/user/real name must not be empty!'.PHP_EOL;
246
247
            return false;
248
        }
249
250
        $this->_nickName = $nickName;
251
        $this->_userName = $userName;
252
        $this->_realName = $realName;
253
        $this->_password = $password;
254
255
        // Send PASS only if provided to avoid null-to-string deprecation and follow IRC spec.
256
        if ($password !== null && $password !== '' && ! $this->_writeSocket('PASS '.$password)) {
257
            return false;
258
        }
259
260
        if (! $this->_writeSocket('NICK '.$nickName)) {
261
            return false;
262
        }
263
264
        if (! $this->_writeSocket('USER '.$userName.' 0 * :'.$realName)) {
265
            return false;
266
        }
267
268
        // Loop over the socket buffer until we find "001".
269
        while (true) {
270
            $this->_readSocket();
271
272
            // We got pinged, reply with a pong.
273
            if (preg_match('/^PING\s*:(.+?)$/', (string) $this->_buffer, $hits)) {
274
                $this->_pong($hits[1]);
275
            } elseif (preg_match('/^:(.*?)\s+(\d+).*?(:.+?)?$/', (string) $this->_buffer, $hits)) {
276
                // We found 001, which means we are logged in.
277
                if ((int) $hits[2] === 1) {
278
                    $this->_remote_host_received = $hits[1];
279
                    break;
280
281
                    // We got 464, which means we need to send a password.
282
                }
283
284
                if ((int) $hits[2] === 464) {
285
                    // Before the lower check, set the password : username:password
286
                    $tempPass = $userName.':'.$password;
287
288
                    // Check if the user has his password in this format: username/server:password
289
                    if (is_string($password) && preg_match('/^.+?\/.+?:.+?$/', $password)) {
290
                        $tempPass = $password;
291
                    }
292
293
                    if ($password !== null && $password !== '' && ! $this->_writeSocket('PASS '.$tempPass)) {
294
                        return false;
295
                    }
296
297
                    if (isset($hits[3]) && stripos($hits[3], 'invalid password') !== false) {
298
                        echo 'Invalid password or username for ('.$this->_remote_host.').';
299
300
                        return false;
301
                    }
302
                }
303
                // ERROR: Closing Link: kevin123[100.100.100.100] (This server is full.)
304
            } elseif (preg_match('/^ERROR\s*:/', (string) $this->_buffer)) {
305
                echo $this->_buffer.PHP_EOL;
306
307
                return false;
308
            }
309
        }
310
311
        return true;
312
    }
313
314
    public function quit(?string $message = null): bool
315
    {
316
        if ($this->_connected()) {
317
            $this->_writeSocket('QUIT'.($message === null ? '' : ' :'.$message));
318
        }
319
        $this->_closeStream();
320
321
        return $this->_connected();
322
    }
323
324
    /**
325
     * Read the incoming buffer in a loop.
326
     */
327
    public function readIncoming(): void
328
    {
329
        while (true) {
330
            $this->_readSocket();
331
332
            // If the server pings us, return it a pong.
333
            if (preg_match('/^PING\s*:(.+?)$/', $this->_buffer, $hits)) {
334
                if ($hits[1] === $this->_remote_host_received) {
335
                    $this->_pong($hits[1]);
336
                }
337
338
                // Check for a channel message.
339
            } elseif (preg_match(
340
                '/^:(?P<nickname>.+?)!.+?\s+PRIVMSG\s+(?P<channel>#.+?)\s+:\s*(?P<message>.+?)\s*$/',
341
                $this->_stripControlCharacters($this->_buffer),
342
                $hits
343
            )
344
            ) {
345
                $this->_channelData =
346
                    [
347
                        'nickname' => $hits['nickname'],
348
                        'channel' => $hits['channel'],
349
                        'message' => $hits['message'],
350
                    ];
351
352
                $this->processChannelMessages();
353
            }
354
355
            // Ping the server if it has not sent us a ping in a while.
356
            if ((time() - $this->_lastPing) > ($this->_socket_timeout / 2)) {
357
                $this->_ping($this->_remote_host_received);
358
            }
359
360
            // Small sleep to prevent CPU spinning when no data is available
361
            // Only sleep if buffer was empty
362
            if (empty($this->_buffer)) {
363
                usleep(100000); // 100ms
364
            }
365
        }
366
    }
367
368
    /**
369
     * Join a channel or multiple channels.
370
     *
371
     * @param  array  $channels  Array of channels with their passwords (null if the channel doesn't need a password).
372
     *                           array( '#exampleChannel' => 'thePassword', '#exampleChan2' => null );
373
     */
374
    public function joinChannels(array $channels = []): bool
375
    {
376
        $this->_channels = $channels;
377
378
        if (! $this->_connected()) {
379
            echo 'ERROR: You must connect to IRC first!'.PHP_EOL;
380
381
            return false;
382
        }
383
384
        if (! empty($channels)) {
385
            foreach ($channels as $channel => $password) {
386
                $this->_joinChannel($channel, $password ?? '');
387
            }
388
        }
389
390
        return true;
391
    }
392
393
    /**
394
     * Implementation.
395
     * Extended classes will use this function to parse the messages in the channel using $this->_channelData.
396
     */
397
    protected function processChannelMessages() {}
398
399
    /**
400
     * Join a channel.
401
     */
402
    protected function _joinChannel(string $channel, string $password): void
403
    {
404
        $this->_writeSocket('JOIN '.$channel.(empty($password) ? '' : ' '.$password));
405
    }
406
407
    /**
408
     * Send PONG to a host.
409
     */
410
    protected function _pong(string $host): void
411
    {
412
        if (! $this->_writeSocket('PONG '.$host)) {
413
            $this->_reconnect();
414
        }
415
416
        // If we got a ping from the IRC server, set the last ping time to now.
417
        if ($host === $this->_remote_host_received) {
418
            $this->_lastPing = time();
419
        }
420
    }
421
422
    /**
423
     * Send PING to a host.
424
     */
425
    protected function _ping(string $host): void
426
    {
427
        $pong = $this->_writeSocket('PING '.$host);
428
429
        // Check if there's a connection error.
430
        if (
431
            $pong === false
432
            || (
433
                (time() - $this->_lastPing) > ($this->_socket_timeout / 2)
434
                && strpos((string) $this->_buffer, 'PONG') !== 0
435
            )
436
        ) {
437
            $this->_reconnect();
438
        }
439
440
        // If sent a ping from the IRC server, set the last ping time to now.
441
        if ($host === $this->_remote_host_received) {
442
            $this->_lastPing = time();
443
        }
444
    }
445
446
    /**
447
     * Attempt to reconnect to IRC.
448
     */
449
    protected function _reconnect(): void
450
    {
451
        if (! $this->connect($this->_remote_host, $this->_remote_port, $this->_remote_tls)) {
452
            exit('FATAL: Could not reconnect to ('.$this->_remote_host.') after ('.$this->_reconnectRetries.') tries.'.PHP_EOL);
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
453
        }
454
455
        if (! $this->_alreadyLoggedIn) {
456
            if (! $this->login($this->_nickName, $this->_userName, $this->_realName, $this->_password)) {
457
                exit('FATAL: Could not log in to ('.$this->_remote_host.')!'.PHP_EOL);
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
458
            }
459
460
            $this->joinChannels($this->_channels);
461
        }
462
    }
463
464
    /**
465
     * Read response from the IRC server.
466
     */
467
    protected function _readSocket(): void
468
    {
469
        $buffer = '';
470
        do {
471
            // Use a shorter timeout for reading to be more responsive
472
            stream_set_timeout($this->_socket, 5);
473
            $line = fgets($this->_socket, 1024);
474
475
            // Check if the connection is still alive and handle timeout/errors
476
            if ($line === false) {
477
                $meta = stream_get_meta_data($this->_socket);
478
479
                // If stream timed out, it's OK - just no data available
480
                if ($meta['timed_out']) {
481
                    // Check if the connection is still valid
482
                    if (! $this->_connected()) {
483
                        echo 'Connection lost (timeout), attempting to reconnect...'.PHP_EOL;
484
                        $this->_reconnect();
485
                    }
486
                    break;
487
                }
488
489
                // If EOF or other error, the connection is dead
490
                if ($meta['eof'] || ! $this->_connected()) {
491
                    echo 'Connection lost, attempting to reconnect...'.PHP_EOL;
492
                    $this->_reconnect();
493
                    break;
494
                }
495
496
                // No data available but connection is fine
497
                break;
498
            }
499
            $buffer .= $line;
500
        } while (! empty($buffer) && ! preg_match('/\v+$/', $buffer));
501
        $this->_buffer = trim($buffer);
502
503
        if ($this->_debug && $this->_buffer !== '') {
504
            echo 'RECV '.$this->_buffer.PHP_EOL;
505
        }
506
    }
507
508
    /**
509
     * Send a command to the IRC server.
510
     */
511
    protected function _writeSocket(string $command): bool
512
    {
513
        $command .= "\r\n";
514
        for ($written = 0, $writtenMax = \strlen($command); $written < $writtenMax; $written += $fWrite) {
515
            stream_set_timeout($this->_socket, $this->_socket_timeout);
516
            $fWrite = $this->_writeSocketChar(substr($command, $written));
517
518
            // http://www.php.net/manual/en/function.fwrite.php#96951 | fwrite can return 0 causing an infinite loop.
519
            if ($fWrite === false || $fWrite <= 0) {
520
                // If it failed, try a second time.
521
                $fWrite = $this->_writeSocketChar(substr($command, $written));
522
                if ($fWrite === false || $fWrite <= 0) {
523
                    echo 'ERROR: Could no write to socket! (the IRC server might have closed the connection)'.PHP_EOL;
524
525
                    return false;
526
                }
527
            }
528
        }
529
530
        if ($this->_debug) {
531
            echo 'SEND :'.$command;
532
        }
533
534
        return true;
535
    }
536
537
    /**
538
     * Write a single character to the socket.
539
     *
540
     * @param  string  $character(char)  $character  A single character.
541
     * @return int|bool Number of bytes written or false.
542
     */
543
    protected function _writeSocketChar(string $character): bool|int
544
    {
545
        return @fwrite($this->_socket, $character);
546
    }
547
548
    /**
549
     * Initiate stream socket to IRC server.
550
     */
551
    protected function _initiateStream(): void
552
    {
553
        $this->_closeStream();
554
555
        // Create SSL/TLS context if using secure connection
556
        $context = $this->_remote_tls
557
            ? stream_context_create(streamSslContextOptions(true))
558
            : null;
559
560
        $socket = stream_socket_client(
561
            $this->_remote_socket_string,
562
            $error_number,
563
            $error_string,
564
            $this->_remote_connection_timeout,
565
            STREAM_CLIENT_CONNECT,
566
            $context
567
        );
568
569
        if ($socket === false) {
570
            $protocol = $this->_remote_tls ? 'TLS/SSL' : 'TCP';
571
            echo "ERROR: Failed to connect to IRC server via {$protocol}: {$error_string} ({$error_number})".PHP_EOL;
572
        } else {
573
            $this->_socket = $socket;
574
            // Set blocking mode with timeout for proper connection handling
575
            stream_set_blocking($this->_socket, true);
576
            stream_set_timeout($this->_socket, $this->_socket_timeout);
577
        }
578
    }
579
580
    /**
581
     * Close the socket.
582
     */
583
    protected function _closeStream(): void
584
    {
585
        if ($this->_socket !== null) {
586
            if (\is_resource($this->_socket)) {
587
                @fclose($this->_socket);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for fclose(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

587
                /** @scrutinizer ignore-unhandled */ @fclose($this->_socket);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
588
            }
589
            $this->_socket = null;
590
        }
591
    }
592
593
    /**
594
     * Check if we are connected to the IRC server.
595
     */
596
    protected function _connected(): bool
597
    {
598
        return \is_resource($this->_socket) && ! feof($this->_socket);
599
    }
600
601
    /**
602
     * Strips control characters from a IRC message.
603
     */
604
    protected function _stripControlCharacters(string $text): string
605
    {
606
        $result = preg_replace(
607
            [
608
                '/\x03\d{1,2}(?:,\d{1,2})?/', // Color code with foreground and optional background
609
                '/\x03/', // Color code without parameters (reset)
610
                '/\x02/', // Bold
611
                '/\x0F/', // Reset/Normal
612
                '/\x16/', // Reverse/Italic
613
                '/\x1F/', // Underline
614
                '/\x1D/', // Italic
615
                '/\x11/', // Monospace
616
            ],
617
            '',
618
            $text
619
        );
620
621
        return is_string($result) ? $result : $text;
0 ignored issues
show
introduced by
The condition is_string($result) is always true.
Loading history...
622
    }
623
}
624