Run a one-off command on Twitch IRC with TMI.php

If you've ever interacted with Twitch Chat IRC, you probably have used tmi.js or a similar library.

Recently, because I mainly use PHP, I discovered the great TMI.php library by Ghostzero and I'm already using it to manage events from my chat, when I encountered a need: how can I send a one-off message (or any IRC-related action)?

The main issue is that you cannot run anything after connect() because it starts a blocking ReactPHP loop and the code under it will be executed only when the loop closes.

For example, take this code:

use GhostZero\Tmi\Client;
use GhostZero\Tmi\ClientOptions;

$client = new Client(new ClientOptions([
    'identity' => [
        'username' => 'ghostzero',
        'password' => 'oauth:...',
    ],
    'channels' => ['ghostzero'],
]));


$client->connect();

echo "I will be executed only when connect() ends";

If we cannot run our code after connecting, we can run it inside an event, right?

The solution

I found out that the WelcomeEvent is fired only once when your user connects to the chat, so it's the perfect place where to place our logic. We can write our one-off methods and then close the connection:

use GhostZero\Tmi\Events\Irc\WelcomeEvent;

$client
    ->on(WelcomeEvent::class, function () use ($client) {
        $client->say('ghostzero', 'Hello from my code');

        $client->close();
    })
    ->connect();

If you would run this code straight away the script would never close and the WelcomeEvent would trigger more than once. This happens because the package is smart enough to reconnect when the connection to the IRC channel is closed and, guess what, we're creating a beautiful loop! We send a message on Welcome -> we close the connection -> the package reconnects -> a Welcome event is triggered -> we send a message on Welcome -> ... and so on.

We can easily solve this by disabling the auto reconnection in our client settings:

$client = new Client(new ClientOptions([
    'connection' => [
        'reconnect' => false,
    ],
    'identity' => [
        'username' => 'ghostzero',
        'password' => 'oauth:...',
    ],
    'channels' => ['ghostzero'],
]));

If we now run our code it will... do nothing. It will But everything is correct now, doesn't it? It even printed "I will be executed only when connect() ends" !. Well, theoretically it does, but technically it does not for one simple reason: we close the loop before our code is fullfilled. To get it better, IRC methods like say() are not sync, that means that their execution does not mean that the desired action is already done. In our case, $client->close() disconnects the client and stops the loop before say() actually sends something.

The way I found that can solve this is to close the loop after a few seconds, just to be sure that our IRC message is actually sent. We can use the addTimer method of ReactPHP to achieve it:

$client
    ->on(WelcomeEvent::class, function () use ($client) {
        $client->say('ghostzero', 'Hello from my code');

        $client->getLoop()->addTimer(3, fn ()  => $client->close());
    })
    ->connect();

Putting everything back together we will get the following snippet. The loop runs, sends a message in our chat and then ends, showing the message I will be executed only when connect() ends.

use GhostZero\Tmi\Client;
use GhostZero\Tmi\ClientOptions;

$client = new Client(new ClientOptions([
    'connection' => [
        'reconnect' => false,
    ],
    'identity' => [
        'username' => 'ghostzero',
        'password' => 'oauth:...',
    ],
    'channels' => ['ghostzero'],
]));

$client
    ->on(WelcomeEvent::class, function () use ($client) {
        $client->say('ghostzero', 'Hello from my code');

        $client->getLoop()->addTimer(3, fn ()  => $client->close());
    })
    ->connect();

echo "I will be executed only when connect() ends";

Hopefully, in the future, the package will provide a shortcut to avoid this hacky way. Remember to leave a star to the TMI.php project!