Making Phergie Talk to Slack

Phergie, an IRC bot that is written in PHP, is one of my favorite memories of working at DealerFire. One of my coworkers had wired up an old local box as a chat server and, after monkeying around a bit, got the bot up and running. And it was awesome. From random YouTube videos to more than a few risqué Urban Dictionary definitions, the little bot helped keep us sane during more than a few late nights in the office.

Nowadays I mostly use Slack for work (and non-work) communication. There are bots popping up left and right, written in all the cool and hip languages, yet I can't help but think of Phergie. It shouldn't be that hard to plug in the PHP-powered IRC bot into Slack, should it? Especially if I just wanted basic outgoing integration? Turns out that it is.

A few disclaimers first. Phergie has been re-written a few times and the most recent iterations, v3, uses ReactPHP. I knew nothing about ReactPHP before this and stumbled pretty hard multiple times. The implementation I went with probably does things in a sub-par way, but it kinda works, and that's good enough for me for now. Also, outside of learning a new and very different paradigm, there were more struggles getting custom ports set up on my development box and the listeners configured correctly. So there was a lot of deep sighs and staring at the screen with this project.

Anyways, onto the code stuff.

Phergie is very hard-wired to use IRC. It is broken into many little pieces, different repositories that depend on each other and can (mostly) be re-implemented to use custom logic (like, non-IRC specific stuff). My first goal was to figure out the bare minimum that needed to be broken out to get this to work. I settled on overriding "Phergie\Irc\Client\React\Client", which just needs to implement the ClientInterface and be configured in the main config.

  1. (in config.php)

  2. return [

  3. 'client' => new JacobEmerick\Slack\Client([

  4. 'token' => 'SLACK TOKEN HERE',

  5. ]),

  6. 'connections' => [

  7. new \Phergie\Irc\Connection([

  8. 'serverHostname' => '',

  9. 'serverPort' => 'YOUR PORT OF CHOICE',

  10. ]),

  11. ],

  12. 'plugins' => [

  13. new \Phergie\Irc\Plugin\React\Pong\Plugin,

  14. ],

  15. ];

The main configuration used by Phergie really only needs a key for connections and plugins, though you can add in more to override defaults. Also, it's worth pointing out that "Phergie\Irc\Connection" is being abused here. For IRC it's more important to have properly defined hostname and nick and pass, but for my custom client all I cared about was the listener.

See, Slack's outgoing webhooks are basically outbound POST requests that pass along some parameters and are looking for a JSON response. If the response is properly formatted, thats what is displayed in the channel. Phergie is much more sophisticated and can do IRC-specific things, jumping in between private messages and channels and performing admin tasks, none of which I was interested in. All I wanted was a JSON response for simple queries.

The next step was building out a listener to watch out for appropriate requests. Slack is sending POST requests with a shared token that can be used for validation. There are other params that are available for validation as well, the team url and id and more, but I just stuck with the token for now.

  1. namespace JacobEmerick\Slack;

  2. use Evenement\EventEmitterTrait;

  3. use Phergie\Irc\Client\React\ClientInterface;

  4. use Phergie\Irc\ConnectionInterface;

  5. use React\EventLoop\Factory as LoopFactory;

  6. use React\Http\Request;

  7. use React\Http\Response;

  8. use React\Http\Server as Http;

  9. use React\Socket\Server as Socket;

  10. class Client implements ClientInterface

  11. {

  12. protected $token;

  13. protected $loop;

  14. public function __construct(array $config)

  15. {

  16. $this->token = $config['token'];

  17. $this->loop = LoopFactory::create();

  18. }

  19. // enforced by ClientInterface

  20. // defines how to handle each configured connection

  21. public function addConnection(ConnectionInterface $connection)

  22. {

  23. $socket = new Socket($this->loop);

  24. $http = new Http($socket, $this->loop);

  25. $http->on(

  26. 'request',

  27. function ($request, $response) use ($connection) {

  28. if ($this->isValidRequest($request)) {

  29. $this->processRequest(

  30. $request,

  31. $response,

  32. $connection

  33. );

  34. }

  35. });

  36. // port and hostname defined in configuration

  37. $socket->listen(

  38. $connection->getServerPort(),

  39. $connection->getServerHostname()

  40. );

  41. }

  42. // quick check to make sure the request is legit

  43. protected function isValidRequest(Request $request)

  44. {

  45. if ($request->getMethod() != 'POST') {

  46. return false;

  47. }

  48. if (

  49. empty($request->getPost()['token']) ||

  50. $request->getPost()['token'] != $this->token

  51. ) {

  52. return false;

  53. }

  54. if (empty($request->getPost()['text'])) {

  55. return false;

  56. }

  57. return true;

  58. }

  59. // defined below

  60. protected function processRequest(

  61. Request $request,

  62. Response $response,

  63. ConnectionInterface $connection)

  64. {

  65. // add in request processing here

  66. }

  67. // enforced by ClientInterface

  68. // primary runner that makes all the things happen

  69. public function run($connections)

  70. {

  71. foreach ($connections as $connection) {

  72. $this->addConnection($connection);

  73. }

  74. $this->loop->run();

  75. }

  76. // pulls in event-specific methods needed by bot

  77. use EventEmitterTrait;

  78. }

Yup, I nested an event loop to watch for incoming requests to my defined port. I'm not sure if that's proper, as it's technically a loop within a loop (Phergie is one giant loop). It works, though. Everytime a request comes in to that port this code will pick it up, validate it against some basic checks including a token verification, and then pass the request object onto a process method.

The process method is a bit hairy. I needed to define a success route (which, for Slack, is to dump the message out in JSON format) and figure out how to run the request against all registered plugins. Turns out that this whole thing runs on emitted events, something that I've had little to no experience programming with in PHP. The code above, where it says "$http->on('request', )" is a listener for a certain event to occur within the http object. Yes, I need to play with React more after this.

  1. ... add more aliases

  2. use Phergie\Irc\Client\React\WriteStream;

  3. use Phergie\Irc\Parser;

  4. class Client implements ClientInterface

  5. {

  6. ...

  7. // primary handler of valid requests

  8. protected function processRequest(

  9. Request $request,

  10. Response $response,

  11. ConnectionInterface $connection)

  12. {

  13. // what to do when we have something to send to chat

  14. $write = new WriteStream();

  15. $write->once(

  16. 'data',

  17. function ($message) use ($response) {

  18. $response->writeHead(

  19. 200,

  20. ['Content-Type' => 'text/json']

  21. );

  22. $response->end(

  23. json_encode(['text' => $message])

  24. );

  25. });

  26. // parse message in a way that plugins can ingest

  27. $parser = new Parser();

  28. $message = $request->getPost()['text'];

  29. $message = preg_replace('/^phergie /i', '', $message);

  30. $message .= "\r\n";

  31. $message = $parser->parse($message);

  32. if (

  33. isset($message['params'] &&

  34. !is_array($message['params'])

  35. ) {

  36. $message['params'] = (array) $message['params'];

  37. }

  38. $this->emit('irc.received', [$message, $write, $connection]);

  39. }

  40. ...

  41. }

"Phergie\Irc\Client\React\WriteStream" is a class that I struggled with. I really wanted to override it, to pass in an object that would natively just output the message, but the bot specifically required an instance of this class. Technically any success response will still get sent to an IRC-specific location using who knows what configuration. Thanks to emitted events, though, I can 'listen' to when this happens and piggy-back my output, a simple JSON, which Slack will pick up.

The parser was also a bit of a headache. In order to keep the plugins simple there is a lot of parsing that occurs, parsing that assumes that the incoming text is heavily flavored with IRC-specific syntax and uses one of the longest prefixed regexes that I've ever seen. I thought about overriding it, as there is nothing specific that requires its usage in the core bot, and took one look at the regex builder and ran away. I've noticed some oddities in the way that Slack passes URLs over already that should be addressed with a dedicated parser… Maybe if I clean up this code and try to support it on the Githubs.

So far I've confirmed that the 'pong' plugin works and that weird things happen with other plugins. Most of this seems to happen with the plethora of event listeners used and some of the IRC-specific behavior sprinkled around. I'm currently torn between hacking through these inconsistencies, building a brand new client-agnostic bot in PHP, or maybe (gasp) diving into a Slack bot written in a different language and adding in plugins. For now I managed to get Phergie talking to Slack, even if the communication is a bit forced, and I'm pretty happy with that.