1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-11-26 00:32:42 +01:00

First pass at decoupling Phabricator bot behavior from the protocol it's running on, this pulls the connection, reading, and writing functionalities out of the bot itself and into the adapter.

Summary:
Ugh, just wrote out a huge message, only to lose it with a fat-fingered ctrl-c. Le sigh.

First pass at decoupling the bot from the protocol. Noticeably absent is the command/message coupling. After this design pass I'll give that a go. Could use some advice, thinking that handlers should only create messages (which can be public or private) and not open ended, undefined 'commands'. The problem being that there needs to be some consistant api if we want handlers to be protocol agnostic. Perhaps that's a pipedream, what are your thoughts?

Secondly, a few notes, design review requests on the changes i did make:
 # Config. For now i'm passing config through to the adapter. This was mainly to remain backwards compatible on the config. I was thinking it should probably be namespaced into it's own subobject though to distinguish the adapter config from the bot config.
 # Adapter selection. This flavor is the one-bot-daemon, config specified protocol version. The upside is that in the future they won't have to run different daemons for this stuff, just have different config, and the door is open for multiple protocol adapters down the road if need be. The downside is that I had to rename the daemon (non-backwards compatible change) and there will need to be some sort of runtime evaluation for instatiation of the adapter. For now I just have a crude switch, but I was thinking of just taking the string they supply as the class name (ala `try { new $clasName(); } catch...`) so as to allow for homegrown adapters, but I wasn't sure how such runtime magic would go over. Also, an alternative would be to make the PhabricatorBot class a non-abstract non-final base class and have the adapters be accompanied by a bot class that just defines their adapter as a property. The upside of which is backwards compatibility (welcome back PhabricatorIRCBot) and perhaps a little bit clearer plugin path for homegrowners.
 # Logging. You'll notice I commented out two very important logging lines in the irc adapter. This isn't intended to remain commented out, but I'm not sure what the best way is to get logging at this layer. I'm wary of just composing the daemon back down into the adapter (bi-directional object composition makes my skin crawl), but something needs to happen, obviously. Advice?

That's it. After the feedback on the above, you can either merge down, or wait until i finish the command/message refactor if you don't think the diff will grow too large. Up to you, this all functions as is.

Test Plan: Ran an irc bot, connected, read input, and wrote output including handler integration.

Reviewers: epriestley

Reviewed By: epriestley

CC: aran, Korvin

Maniphest Tasks: T2462

Differential Revision: https://secure.phabricator.com/D4757
This commit is contained in:
indiefan 2013-02-05 18:45:20 -08:00 committed by epriestley
parent cb38ab27ce
commit 574bc3ba31
18 changed files with 408 additions and 298 deletions

View file

@ -7,12 +7,12 @@
],
"handlers" : [
"PhabricatorIRCProtocolHandler",
"PhabricatorIRCObjectNameHandler",
"PhabricatorIRCSymbolHandler",
"PhabricatorIRCLogHandler",
"PhabricatorIRCWhatsNewHandler",
"PhabricatorIRCDifferentialNotificationHandler",
"PhabricatorIRCMacroHandler"
"PhabricatorBotObjectNameHandler",
"PhabricatorBotSymbolHandler",
"PhabricatorBotLogHandler",
"PhabricatorBotWhatsNewHandler",
"PhabricatorBotDifferentialNotificationHandler",
"PhabricatorBotMacroHandler"
],
"conduit.uri" : null,

View file

@ -702,6 +702,18 @@ phutil_register_library_map(array(
'PhabricatorBarePageExample' => 'applications/uiexample/examples/PhabricatorBarePageExample.php',
'PhabricatorBarePageView' => 'view/page/PhabricatorBarePageView.php',
'PhabricatorBaseEnglishTranslation' => 'infrastructure/internationalization/PhabricatorBaseEnglishTranslation.php',
'PhabricatorBaseProtocolAdapter' => 'infrastructure/daemon/bot/adapter/PhabricatorBaseProtocolAdapter.php',
'PhabricatorBot' => 'infrastructure/daemon/bot/PhabricatorBot.php',
'PhabricatorBotDebugLogHandler' => 'infrastructure/daemon/bot/handler/PhabricatorBotDebugLogHandler.php',
'PhabricatorBotDifferentialNotificationHandler' => 'infrastructure/daemon/bot/handler/PhabricatorBotDifferentialNotificationHandler.php',
'PhabricatorBotFeedNotificationHandler' => 'infrastructure/daemon/bot/handler/PhabricatorBotFeedNotificationHandler.php',
'PhabricatorBotHandler' => 'infrastructure/daemon/bot/handler/PhabricatorBotHandler.php',
'PhabricatorBotLogHandler' => 'infrastructure/daemon/bot/handler/PhabricatorBotLogHandler.php',
'PhabricatorBotMacroHandler' => 'infrastructure/daemon/bot/handler/PhabricatorBotMacroHandler.php',
'PhabricatorBotMessage' => 'infrastructure/daemon/bot/PhabricatorBotMessage.php',
'PhabricatorBotObjectNameHandler' => 'infrastructure/daemon/bot/handler/PhabricatorBotObjectNameHandler.php',
'PhabricatorBotSymbolHandler' => 'infrastructure/daemon/bot/handler/PhabricatorBotSymbolHandler.php',
'PhabricatorBotWhatsNewHandler' => 'infrastructure/daemon/bot/handler/PhabricatorBotWhatsNewHandler.php',
'PhabricatorBuiltinPatchList' => 'infrastructure/storage/patch/PhabricatorBuiltinPatchList.php',
'PhabricatorButtonsExample' => 'applications/uiexample/examples/PhabricatorButtonsExample.php',
'PhabricatorCacheDAO' => 'applications/cache/storage/PhabricatorCacheDAO.php',
@ -917,17 +929,9 @@ phutil_register_library_map(array(
'PhabricatorHeaderView' => 'view/layout/PhabricatorHeaderView.php',
'PhabricatorHelpController' => 'applications/help/controller/PhabricatorHelpController.php',
'PhabricatorHelpKeyboardShortcutController' => 'applications/help/controller/PhabricatorHelpKeyboardShortcutController.php',
'PhabricatorIRCBot' => 'infrastructure/daemon/irc/PhabricatorIRCBot.php',
'PhabricatorIRCDifferentialNotificationHandler' => 'infrastructure/daemon/irc/handler/PhabricatorIRCDifferentialNotificationHandler.php',
'PhabricatorIRCFeedNotificationHandler' => 'infrastructure/daemon/irc/handler/PhabricatorIRCFeedNotificationHandler.php',
'PhabricatorIRCHandler' => 'infrastructure/daemon/irc/handler/PhabricatorIRCHandler.php',
'PhabricatorIRCLogHandler' => 'infrastructure/daemon/irc/handler/PhabricatorIRCLogHandler.php',
'PhabricatorIRCMacroHandler' => 'infrastructure/daemon/irc/handler/PhabricatorIRCMacroHandler.php',
'PhabricatorIRCMessage' => 'infrastructure/daemon/irc/PhabricatorIRCMessage.php',
'PhabricatorIRCObjectNameHandler' => 'infrastructure/daemon/irc/handler/PhabricatorIRCObjectNameHandler.php',
'PhabricatorIRCProtocolHandler' => 'infrastructure/daemon/irc/handler/PhabricatorIRCProtocolHandler.php',
'PhabricatorIRCSymbolHandler' => 'infrastructure/daemon/irc/handler/PhabricatorIRCSymbolHandler.php',
'PhabricatorIRCWhatsNewHandler' => 'infrastructure/daemon/irc/handler/PhabricatorIRCWhatsNewHandler.php',
'PhabricatorIRCBot' => 'infrastructure/daemon/bot/PhabricatorIRCBot.php',
'PhabricatorIRCProtocolAdapter' => 'infrastructure/daemon/bot/adapter/PhabricatorIRCProtocolAdapter.php',
'PhabricatorIRCProtocolHandler' => 'infrastructure/daemon/bot/handler/PhabricatorIRCProtocolHandler.php',
'PhabricatorImageTransformer' => 'applications/files/PhabricatorImageTransformer.php',
'PhabricatorInfrastructureTestCase' => 'infrastructure/__tests__/PhabricatorInfrastructureTestCase.php',
'PhabricatorInlineCommentController' => 'infrastructure/diff/PhabricatorInlineCommentController.php',
@ -2144,6 +2148,15 @@ phutil_register_library_map(array(
'PhabricatorBarePageExample' => 'PhabricatorUIExample',
'PhabricatorBarePageView' => 'AphrontPageView',
'PhabricatorBaseEnglishTranslation' => 'PhabricatorTranslation',
'PhabricatorBot' => 'PhabricatorDaemon',
'PhabricatorBotDebugLogHandler' => 'PhabricatorBotHandler',
'PhabricatorBotDifferentialNotificationHandler' => 'PhabricatorBotHandler',
'PhabricatorBotFeedNotificationHandler' => 'PhabricatorBotHandler',
'PhabricatorBotLogHandler' => 'PhabricatorBotHandler',
'PhabricatorBotMacroHandler' => 'PhabricatorBotHandler',
'PhabricatorBotObjectNameHandler' => 'PhabricatorBotHandler',
'PhabricatorBotSymbolHandler' => 'PhabricatorBotHandler',
'PhabricatorBotWhatsNewHandler' => 'PhabricatorBotHandler',
'PhabricatorBuiltinPatchList' => 'PhabricatorSQLPatchList',
'PhabricatorButtonsExample' => 'PhabricatorUIExample',
'PhabricatorCacheDAO' => 'PhabricatorLiskDAO',
@ -2353,14 +2366,8 @@ phutil_register_library_map(array(
'PhabricatorHelpController' => 'PhabricatorController',
'PhabricatorHelpKeyboardShortcutController' => 'PhabricatorHelpController',
'PhabricatorIRCBot' => 'PhabricatorDaemon',
'PhabricatorIRCDifferentialNotificationHandler' => 'PhabricatorIRCHandler',
'PhabricatorIRCFeedNotificationHandler' => 'PhabricatorIRCHandler',
'PhabricatorIRCLogHandler' => 'PhabricatorIRCHandler',
'PhabricatorIRCMacroHandler' => 'PhabricatorIRCHandler',
'PhabricatorIRCObjectNameHandler' => 'PhabricatorIRCHandler',
'PhabricatorIRCProtocolHandler' => 'PhabricatorIRCHandler',
'PhabricatorIRCSymbolHandler' => 'PhabricatorIRCHandler',
'PhabricatorIRCWhatsNewHandler' => 'PhabricatorIRCHandler',
'PhabricatorIRCProtocolAdapter' => 'PhabricatorBaseProtocolAdapter',
'PhabricatorIRCProtocolHandler' => 'PhabricatorBotHandler',
'PhabricatorInfrastructureTestCase' => 'PhabricatorTestCase',
'PhabricatorInlineCommentController' => 'PhabricatorController',
'PhabricatorInlineCommentInterface' => 'PhabricatorMarkupInterface',

View file

@ -0,0 +1,132 @@
<?php
/**
* Simple IRC bot which runs as a Phabricator daemon. Although this bot is
* somewhat useful, it is also intended to serve as a demo of how to write
* "system agents" which communicate with Phabricator over Conduit, so you can
* script system interactions and integrate with other systems.
*
* NOTE: This is super janky and experimental right now.
*
* @group irc
*/
final class PhabricatorBot extends PhabricatorDaemon {
private $handlers;
private $conduit;
private $config;
private $pollFrequency;
public function run() {
$argv = $this->getArgv();
if (count($argv) !== 1) {
throw new Exception("usage: PhabricatorBot <json_config_file>");
}
$json_raw = Filesystem::readFile($argv[0]);
$config = json_decode($json_raw, true);
if (!is_array($config)) {
throw new Exception("File '{$argv[0]}' is not valid JSON!");
}
$nick = idx($config, 'nick', 'phabot');
$handlers = idx($config, 'handlers', array());
$protocol_adapter_class = idx(
$config,
'protocol-adapter',
'PhabricatorIRCProtocolAdapter');
$this->pollFrequency = idx($config, 'poll-frequency', 1);
$this->config = $config;
foreach ($handlers as $handler) {
$obj = newv($handler, array($this));
$this->handlers[] = $obj;
}
$conduit_uri = idx($config, 'conduit.uri');
if ($conduit_uri) {
$conduit_user = idx($config, 'conduit.user');
$conduit_cert = idx($config, 'conduit.cert');
// Normalize the path component of the URI so users can enter the
// domain without the "/api/" part.
$conduit_uri = new PhutilURI($conduit_uri);
$conduit_uri->setPath('/api/');
$conduit_uri = (string)$conduit_uri;
$conduit = new ConduitClient($conduit_uri);
$response = $conduit->callMethodSynchronous(
'conduit.connect',
array(
'client' => 'PhabricatorBot',
'clientVersion' => '1.0',
'clientDescription' => php_uname('n').':'.$nick,
'user' => $conduit_user,
'certificate' => $conduit_cert,
));
$this->conduit = $conduit;
}
// Instantiate Protocol Adapter, for now follow same technique as
// handler instantiation
$this->protocolAdapter = newv($protocol_adapter_class, array());
$this->protocolAdapter
->setConfig($this->config)
->connect();
$this->runLoop();
}
public function getConfig($key, $default = null) {
return idx($this->config, $key, $default);
}
private function runLoop() {
do {
$this->stillWorking();
$messages = $this->protocolAdapter->getNextMessages($this->pollFrequency);
if (count($messages) > 0) {
foreach ($messages as $message) {
$this->routeMessage($message);
}
}
foreach ($this->handlers as $handler) {
$handler->runBackgroundTasks();
}
} while (true);
}
public function writeCommand($command, $message) {
return $this->protocolAdapter->writeCommand($command, $message);
}
private function routeMessage(PhabricatorBotMessage $message) {
$ignore = $this->getConfig('ignore');
if ($ignore && in_array($message->getSenderNickName(), $ignore)) {
return;
}
foreach ($this->handlers as $handler) {
try {
$handler->receiveMessage($message);
} catch (Exception $ex) {
phlog($ex);
}
}
}
public function getConduit() {
if (empty($this->conduit)) {
throw new Exception(
"This bot is not configured with a Conduit uplink. Set 'conduit.uri', ".
"'conduit.user' and 'conduit.cert' in the configuration to connect.");
}
return $this->conduit;
}
}

View file

@ -1,6 +1,6 @@
<?php
final class PhabricatorIRCMessage {
final class PhabricatorBotMessage {
private $sender;
private $command;

View file

@ -0,0 +1,11 @@
<?php
/**
* Placeholder to let people know that the bot has been renamed
*/
final class PhabricatorIRCBot extends PhabricatorDaemon {
public function run() {
throw new Exception(
"This daemon has been deprecated, use `PhabricatorBot` instead.");
}
}

View file

@ -0,0 +1,37 @@
<?php
/**
* Defines the api for protocol adapters for @{class:PhabricatorBot}
*/
abstract class PhabricatorBaseProtocolAdapter {
protected $config;
public function setConfig($config) {
$this->config = $config;
return $this;
}
/**
* Performs any connection logic necessary for the protocol
*/
abstract public function connect();
/**
* This is the spout for messages coming in from the protocol.
* This will be called in the main event loop of the bot daemon
* So if if doesn't implement some sort of blocking timeout
* (e.g. select-based socket polling), it should at least sleep
* for some period of time in order to not overwhelm the processor.
*
* @param Int $poll_frequency The number of seconds between polls
*/
abstract public function getNextMessages($poll_frequency);
/**
* This is the output mechanism for the protocol.
*
* @param String $command The command for the message
* @param String $message The contents of the message itself
*/
abstract public function writeCommand($command, $message);
}

View file

@ -0,0 +1,156 @@
<?php
// TODO: Write PhabricatorBaseSocketProtocolAdapter
final class PhabricatorIRCProtocolAdapter
extends PhabricatorBaseProtocolAdapter {
private $socket;
private $writeBuffer;
private $readBuffer;
public function connect() {
$nick = idx($this->config, 'nick', 'phabot');
$server = idx($this->config, 'server');
$port = idx($this->config, 'port', 6667);
$pass = idx($this->config, 'pass');
$ssl = idx($this->config, 'ssl', false);
$user = idx($this->config, 'user', $nick);
if (!preg_match('/^[A-Za-z0-9_`[{}^|\]\\-]+$/', $nick)) {
throw new Exception(
"Nickname '{$nick}' is invalid!");
}
$errno = null;
$error = null;
if (!$ssl) {
$socket = fsockopen($server, $port, $errno, $error);
} else {
$socket = fsockopen('ssl://'.$server, $port, $errno, $error);
}
if (!$socket) {
throw new Exception("Failed to connect, #{$errno}: {$error}");
}
$ok = stream_set_blocking($socket, false);
if (!$ok) {
throw new Exception("Failed to set stream nonblocking.");
}
$this->socket = $socket;
$this->writeCommand('USER', "{$user} 0 * :{$user}");
if ($pass) {
$this->writeCommand('PASS', "{$pass}");
}
$this->writeCommand('NICK', "{$nick}");
}
public function getNextMessages($poll_frequency) {
$messages = array();
$read = array($this->socket);
if (strlen($this->writeBuffer)) {
$write = array($this->socket);
} else {
$write = array();
}
$except = array();
$ok = @stream_select($read, $write, $except, $timeout_sec = 1);
if ($ok === false) {
throw new Exception(
"socket_select() failed: ".socket_strerror(socket_last_error()));
}
if ($read) {
// Test for connection termination; in PHP, fread() off a nonblocking,
// closed socket is empty string.
if (feof($this->socket)) {
// This indicates the connection was terminated on the other side,
// just exit via exception and let the overseer restart us after a
// delay so we can reconnect.
throw new Exception("Remote host closed connection.");
}
do {
$data = fread($this->socket, 4096);
if ($data === false) {
throw new Exception("fread() failed!");
} else {
$messages[] = new PhabricatorBotMessage(
null,
"LOG",
"<<< ".$data
);
$this->readBuffer .= $data;
}
} while (strlen($data));
}
if ($write) {
do {
$len = fwrite($this->socket, $this->writeBuffer);
if ($len === false) {
throw new Exception("fwrite() failed!");
} else {
$messages[] = new PhabricatorBotMessage(
null,
"LOG",
">>> ".substr($this->writeBuffer, 0, $len));
$this->writeBuffer = substr($this->writeBuffer, $len);
}
} while (strlen($this->writeBuffer));
}
while ($m = $this->processReadBuffer()) {
$messages[] = $m;
}
return $messages;
}
private function write($message) {
$this->writeBuffer .= $message;
return $this;
}
public function writeCommand($command, $message) {
return $this->write($command.' '.$message."\r\n");
}
private function processReadBuffer() {
$until = strpos($this->readBuffer, "\r\n");
if ($until === false) {
return false;
}
$message = substr($this->readBuffer, 0, $until);
$this->readBuffer = substr($this->readBuffer, $until + 2);
$pattern =
'/^'.
'(?:(?P<sender>:(\S+)) )?'. // This may not be present.
'(?P<command>[A-Z0-9]+) '.
'(?P<data>.*)'.
'$/';
$matches = null;
if (!preg_match($pattern, $message, $matches)) {
throw new Exception("Unexpected message from server: {$message}");
}
$irc_message = new PhabricatorBotMessage(
idx($matches, 'sender'),
$matches['command'],
$matches['data']);
return $irc_message;
}
public function __destruct() {
$this->write("QUIT Goodbye.\r\n");
fclose($this->socket);
}
}

View file

@ -0,0 +1,17 @@
<?php
/**
* Logs messages to stdout
*/
final class PhabricatorBotDebugLogHandler extends PhabricatorBotHandler {
public function receiveMessage(PhabricatorBotMessage $message) {
switch ($message->getCommand()) {
case 'LOG':
echo addcslashes(
$message->getRawData(),
"\0..\37\177..\377");
echo "\n";
break;
}
}
}

View file

@ -3,12 +3,12 @@
/**
* @group irc
*/
final class PhabricatorIRCDifferentialNotificationHandler
extends PhabricatorIRCHandler {
final class PhabricatorBotDifferentialNotificationHandler
extends PhabricatorBotHandler {
private $skippedOldEvents;
public function receiveMessage(PhabricatorIRCMessage $message) {
public function receiveMessage(PhabricatorBotMessage $message) {
return;
}

View file

@ -5,8 +5,8 @@
*
* @group irc
*/
final class PhabricatorIRCFeedNotificationHandler
extends PhabricatorIRCHandler {
final class PhabricatorBotFeedNotificationHandler
extends PhabricatorBotHandler {
private $startupDelay = 30;
private $lastSeenChronoKey = 0;
@ -82,7 +82,7 @@ final class PhabricatorIRCFeedNotificationHandler
return false;
}
public function receiveMessage(PhabricatorIRCMessage $message) {
public function receiveMessage(PhabricatorBotMessage $message) {
return;
}

View file

@ -2,15 +2,15 @@
/**
* Responds to IRC messages. You plug a bunch of these into a
* @{class:PhabricatorIRCBot} to give it special behavior.
* @{class:PhabricatorBot} to give it special behavior.
*
* @group irc
*/
abstract class PhabricatorIRCHandler {
abstract class PhabricatorBotHandler {
private $bot;
final public function __construct(PhabricatorIRCBot $irc_bot) {
final public function __construct(PhabricatorBot $irc_bot) {
$this->bot = $irc_bot;
}
@ -37,7 +37,7 @@ abstract class PhabricatorIRCHandler {
return (strncmp($name, '#', 1) === 0);
}
abstract public function receiveMessage(PhabricatorIRCMessage $message);
abstract public function receiveMessage(PhabricatorBotMessage $message);
public function runBackgroundTasks() {
return;

View file

@ -5,11 +5,11 @@
*
* @group irc
*/
final class PhabricatorIRCLogHandler extends PhabricatorIRCHandler {
final class PhabricatorBotLogHandler extends PhabricatorBotHandler {
private $futures = array();
public function receiveMessage(PhabricatorIRCMessage $message) {
public function receiveMessage(PhabricatorBotMessage $message) {
switch ($message->getCommand()) {
case 'PRIVMSG':

View file

@ -3,7 +3,7 @@
/**
* @group irc
*/
final class PhabricatorIRCMacroHandler extends PhabricatorIRCHandler {
final class PhabricatorBotMacroHandler extends PhabricatorBotHandler {
private $macros;
private $regexp;
@ -40,7 +40,7 @@ final class PhabricatorIRCMacroHandler extends PhabricatorIRCHandler {
return true;
}
public function receiveMessage(PhabricatorIRCMessage $message) {
public function receiveMessage(PhabricatorBotMessage $message) {
if (!$this->init()) {
return;
}

View file

@ -5,7 +5,7 @@
*
* @group irc
*/
final class PhabricatorIRCObjectNameHandler extends PhabricatorIRCHandler {
final class PhabricatorBotObjectNameHandler extends PhabricatorBotHandler {
/**
* Map of PHIDs to the last mention of them (as an epoch timestamp); prevents
@ -13,7 +13,7 @@ final class PhabricatorIRCObjectNameHandler extends PhabricatorIRCHandler {
*/
private $recentlyMentioned = array();
public function receiveMessage(PhabricatorIRCMessage $message) {
public function receiveMessage(PhabricatorBotMessage $message) {
switch ($message->getCommand()) {
case 'PRIVMSG':

View file

@ -5,9 +5,9 @@
*
* @group irc
*/
final class PhabricatorIRCSymbolHandler extends PhabricatorIRCHandler {
final class PhabricatorBotSymbolHandler extends PhabricatorBotHandler {
public function receiveMessage(PhabricatorIRCMessage $message) {
public function receiveMessage(PhabricatorBotMessage $message) {
switch ($message->getCommand()) {
case 'PRIVMSG':

View file

@ -5,11 +5,11 @@
*
* @group irc
*/
final class PhabricatorIRCWhatsNewHandler extends PhabricatorIRCHandler {
final class PhabricatorBotWhatsNewHandler extends PhabricatorBotHandler {
private $floodblock = 0;
public function receiveMessage(PhabricatorIRCMessage $message) {
public function receiveMessage(PhabricatorBotMessage $message) {
switch ($message->getCommand()) {
case 'PRIVMSG':

View file

@ -5,9 +5,9 @@
*
* @group irc
*/
final class PhabricatorIRCProtocolHandler extends PhabricatorIRCHandler {
final class PhabricatorIRCProtocolHandler extends PhabricatorBotHandler {
public function receiveMessage(PhabricatorIRCMessage $message) {
public function receiveMessage(PhabricatorBotMessage $message) {
switch ($message->getCommand()) {
case '422': // Error - no MOTD
case '376': // End of MOTD

View file

@ -1,250 +0,0 @@
<?php
/**
* Simple IRC bot which runs as a Phabricator daemon. Although this bot is
* somewhat useful, it is also intended to serve as a demo of how to write
* "system agents" which communicate with Phabricator over Conduit, so you can
* script system interactions and integrate with other systems.
*
* NOTE: This is super janky and experimental right now.
*
* @group irc
*/
final class PhabricatorIRCBot extends PhabricatorDaemon {
private $socket;
private $handlers;
private $writeBuffer;
private $readBuffer;
private $conduit;
private $config;
public function run() {
$argv = $this->getArgv();
if (count($argv) !== 1) {
throw new Exception("usage: PhabricatorIRCBot <json_config_file>");
}
$json_raw = Filesystem::readFile($argv[0]);
$config = json_decode($json_raw, true);
if (!is_array($config)) {
throw new Exception("File '{$argv[0]}' is not valid JSON!");
}
$server = idx($config, 'server');
$port = idx($config, 'port', 6667);
$handlers = idx($config, 'handlers', array());
$pass = idx($config, 'pass');
$nick = idx($config, 'nick', 'phabot');
$user = idx($config, 'user', $nick);
$ssl = idx($config, 'ssl', false);
$nickpass = idx($config, 'nickpass');
$this->config = $config;
if (!preg_match('/^[A-Za-z0-9_`[{}^|\]\\-]+$/', $nick)) {
throw new Exception(
"Nickname '{$nick}' is invalid!");
}
foreach ($handlers as $handler) {
$obj = newv($handler, array($this));
$this->handlers[] = $obj;
}
$conduit_uri = idx($config, 'conduit.uri');
if ($conduit_uri) {
$conduit_user = idx($config, 'conduit.user');
$conduit_cert = idx($config, 'conduit.cert');
// Normalize the path component of the URI so users can enter the
// domain without the "/api/" part.
$conduit_uri = new PhutilURI($conduit_uri);
$conduit_uri->setPath('/api/');
$conduit_uri = (string)$conduit_uri;
$conduit = new ConduitClient($conduit_uri);
$response = $conduit->callMethodSynchronous(
'conduit.connect',
array(
'client' => 'PhabricatorIRCBot',
'clientVersion' => '1.0',
'clientDescription' => php_uname('n').':'.$nick,
'user' => $conduit_user,
'certificate' => $conduit_cert,
));
$this->conduit = $conduit;
}
$errno = null;
$error = null;
if (!$ssl) {
$socket = fsockopen($server, $port, $errno, $error);
} else {
$socket = fsockopen('ssl://'.$server, $port, $errno, $error);
}
if (!$socket) {
throw new Exception("Failed to connect, #{$errno}: {$error}");
}
$ok = stream_set_blocking($socket, false);
if (!$ok) {
throw new Exception("Failed to set stream nonblocking.");
}
$this->socket = $socket;
$this->writeCommand('USER', "{$user} 0 * :{$user}");
if ($pass) {
$this->writeCommand('PASS', "{$pass}");
}
$this->writeCommand('NICK', "{$nick}");
$this->runSelectLoop();
}
public function getConfig($key, $default = null) {
return idx($this->config, $key, $default);
}
private function runSelectLoop() {
do {
$this->stillWorking();
$read = array($this->socket);
if (strlen($this->writeBuffer)) {
$write = array($this->socket);
} else {
$write = array();
}
$except = array();
$ok = @stream_select($read, $write, $except, $timeout_sec = 1);
if ($ok === false) {
throw new Exception(
"socket_select() failed: ".socket_strerror(socket_last_error()));
}
if ($read) {
// Test for connection termination; in PHP, fread() off a nonblocking,
// closed socket is empty string.
if (feof($this->socket)) {
// This indicates the connection was terminated on the other side,
// just exit via exception and let the overseer restart us after a
// delay so we can reconnect.
throw new Exception("Remote host closed connection.");
}
do {
$data = fread($this->socket, 4096);
if ($data === false) {
throw new Exception("fread() failed!");
} else {
$this->debugLog(true, $data);
$this->readBuffer .= $data;
}
} while (strlen($data));
}
if ($write) {
do {
$len = fwrite($this->socket, $this->writeBuffer);
if ($len === false) {
throw new Exception("fwrite() failed!");
} else {
$this->debugLog(false, substr($this->writeBuffer, 0, $len));
$this->writeBuffer = substr($this->writeBuffer, $len);
}
} while (strlen($this->writeBuffer));
}
do {
$routed_message = $this->processReadBuffer();
} while ($routed_message);
foreach ($this->handlers as $handler) {
$handler->runBackgroundTasks();
}
} while (true);
}
private function write($message) {
$this->writeBuffer .= $message;
return $this;
}
public function writeCommand($command, $message) {
return $this->write($command.' '.$message."\r\n");
}
private function processReadBuffer() {
$until = strpos($this->readBuffer, "\r\n");
if ($until === false) {
return false;
}
$message = substr($this->readBuffer, 0, $until);
$this->readBuffer = substr($this->readBuffer, $until + 2);
$pattern =
'/^'.
'(?:(?P<sender>:(\S+)) )?'. // This may not be present.
'(?P<command>[A-Z0-9]+) '.
'(?P<data>.*)'.
'$/';
$matches = null;
if (!preg_match($pattern, $message, $matches)) {
throw new Exception("Unexpected message from server: {$message}");
}
$irc_message = new PhabricatorIRCMessage(
idx($matches, 'sender'),
$matches['command'],
$matches['data']);
$this->routeMessage($irc_message);
return true;
}
private function routeMessage(PhabricatorIRCMessage $message) {
$ignore = $this->getConfig('ignore');
if ($ignore && in_array($message->getSenderNickName(), $ignore)) {
return;
}
foreach ($this->handlers as $handler) {
try {
$handler->receiveMessage($message);
} catch (Exception $ex) {
phlog($ex);
}
}
}
public function __destruct() {
$this->write("QUIT Goodbye.\r\n");
fclose($this->socket);
}
private function debugLog($is_read, $message) {
if ($this->getTraceMode()) {
echo $is_read ? '<<< ' : '>>> ';
echo addcslashes($message, "\0..\37\177..\377");
echo "\n";
}
}
public function getConduit() {
if (empty($this->conduit)) {
throw new Exception(
"This bot is not configured with a Conduit uplink. Set 'conduit.uri', ".
"'conduit.user' and 'conduit.cert' in the configuration to connect.");
}
return $this->conduit;
}
}