diff --git a/resources/ircbot/example_config.json b/resources/ircbot/example_config.json new file mode 100644 index 0000000000..e909689e13 --- /dev/null +++ b/resources/ircbot/example_config.json @@ -0,0 +1,11 @@ +{ + "server" : "irc.freenode.net", + "port" : 6667, + "nick" : "phabot", + "join" : [ + "#phabot-test" + ], + "handlers" : [ + "PhabricatorIRCProtocolHandler" + ] +} diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 570892e039..49a80f19dc 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -324,6 +324,10 @@ phutil_register_library_map(array( 'PhabricatorFileViewController' => 'applications/files/controller/view', 'PhabricatorGoodForNothingWorker' => 'infrastructure/daemon/workers/worker/goodfornothing', 'PhabricatorHandleObjectSelectorDataView' => 'applications/phid/handle/view/selector', + 'PhabricatorIRCBot' => 'infrastructure/daemon/irc/bot', + 'PhabricatorIRCHandler' => 'infrastructure/daemon/irc/handler/base', + 'PhabricatorIRCMessage' => 'infrastructure/daemon/irc/message', + 'PhabricatorIRCProtocolHandler' => 'infrastructure/daemon/irc/handler/protocol', 'PhabricatorJavelinLinter' => 'infrastructure/lint/linter/javelin', 'PhabricatorLintEngine' => 'infrastructure/lint/engine', 'PhabricatorLiskDAO' => 'applications/base/storage/lisk', @@ -748,6 +752,8 @@ phutil_register_library_map(array( 'PhabricatorFileUploadController' => 'PhabricatorFileController', 'PhabricatorFileViewController' => 'PhabricatorFileController', 'PhabricatorGoodForNothingWorker' => 'PhabricatorWorker', + 'PhabricatorIRCBot' => 'PhabricatorDaemon', + 'PhabricatorIRCProtocolHandler' => 'PhabricatorIRCHandler', 'PhabricatorJavelinLinter' => 'ArcanistLinter', 'PhabricatorLintEngine' => 'PhutilLintEngine', 'PhabricatorLiskDAO' => 'LiskDAO', diff --git a/src/infrastructure/daemon/control/PhabricatorDaemonControl.php b/src/infrastructure/daemon/control/PhabricatorDaemonControl.php index 26d3212737..48abaa785b 100644 --- a/src/infrastructure/daemon/control/PhabricatorDaemonControl.php +++ b/src/infrastructure/daemon/control/PhabricatorDaemonControl.php @@ -127,7 +127,7 @@ final class PhabricatorDaemonControl { **COMMAND REFERENCE** - **launch** [__n__] __daemon__ + **launch** [__n__] __daemon__ [argv ...] Start a daemon (or n copies of a daemon). **list** diff --git a/src/infrastructure/daemon/irc/bot/PhabricatorIRCBot.php b/src/infrastructure/daemon/irc/bot/PhabricatorIRCBot.php new file mode 100644 index 0000000000..8d5db14d92 --- /dev/null +++ b/src/infrastructure/daemon/irc/bot/PhabricatorIRCBot.php @@ -0,0 +1,193 @@ +getArgv(); + if (count($argv) !== 1) { + throw new Exception("usage: PhabricatorIRCBot "); + } + + $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); + $join = idx($config, 'join', array()); + $handlers = idx($config, 'handlers', array()); + + $nick = idx($config, 'nick', 'phabot'); + + if (!preg_match('/^[A-Za-z0-9_]+$/', $nick)) { + throw new Exception( + "Nickname '{$nick}' is invalid, must be alphanumeric!"); + } + + if (!$join) { + throw new Exception("No channels to 'join' in config!"); + } + + foreach ($handlers as $handler) { + $obj = newv($handler, array($this)); + $this->handlers[] = $obj; + } + + $errno = null; + $error = null; + $socket = fsockopen($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', "{$nick} 0 * :{$nick}"); + $this->writeCommand('NICK', "{$nick}"); + foreach ($join as $channel) { + $this->writeCommand('JOIN', "{$channel}"); + } + + $this->runSelectLoop(); + } + + private function runSelectLoop() { + do { + $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) { + 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)); + } + + $this->processReadBuffer(); + + } 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; + } + + $message = substr($this->readBuffer, 0, $until); + $this->readBuffer = substr($this->readBuffer, $until + 2); + + $pattern = + '/^'. + '(?:(?P:(\S+)) )?'. // This may not be present. + '(?P[A-Z0-9]+) '. + '(?P.*)'. + '$/'; + + $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); + } + + private function routeMessage(PhabricatorIRCMessage $message) { + foreach ($this->handlers as $handler) { + $handler->receiveMessage($message); + } + } + + public function __destroy() { + $this->write("QUIT Goodbye.\r\n"); + fclose($this->socket); + } + + private function debugLog($is_read, $message) { + echo $is_read ? '<<< ' : '>>> '; + echo addcslashes($message, "\0..\37\177..\377"); + echo "\n"; + } + +} diff --git a/src/infrastructure/daemon/irc/bot/__init__.php b/src/infrastructure/daemon/irc/bot/__init__.php new file mode 100644 index 0000000000..2bad43e7d8 --- /dev/null +++ b/src/infrastructure/daemon/irc/bot/__init__.php @@ -0,0 +1,16 @@ +bot = $irc_bot; + } + + final protected function write($command, $message) { + $this->bot->writeCommand($command, $message); + return $this; + } + + abstract public function receiveMessage(PhabricatorIRCMessage $message); + +} diff --git a/src/infrastructure/daemon/irc/handler/base/__init__.php b/src/infrastructure/daemon/irc/handler/base/__init__.php new file mode 100644 index 0000000000..6dcece1eb8 --- /dev/null +++ b/src/infrastructure/daemon/irc/handler/base/__init__.php @@ -0,0 +1,10 @@ +getCommand()) { + case 'PING': + $this->write('PONG', $message->getRawData()); + break; + } + } + +} diff --git a/src/infrastructure/daemon/irc/handler/protocol/__init__.php b/src/infrastructure/daemon/irc/handler/protocol/__init__.php new file mode 100644 index 0000000000..363b05eb4a --- /dev/null +++ b/src/infrastructure/daemon/irc/handler/protocol/__init__.php @@ -0,0 +1,12 @@ +sender = $sender; + $this->command = $command; + $this->data = $data; + } + + public function getRawSender() { + return $this->sender; + } + + public function getRawData() { + return $this->data; + } + + public function getCommand() { + return $this->command; + } + +} diff --git a/src/infrastructure/daemon/irc/message/__init__.php b/src/infrastructure/daemon/irc/message/__init__.php new file mode 100644 index 0000000000..cb0374cf0a --- /dev/null +++ b/src/infrastructure/daemon/irc/message/__init__.php @@ -0,0 +1,10 @@ +