diff --git a/src/infrastructure/daemon/irc/bot/PhabricatorIRCBot.php b/src/infrastructure/daemon/irc/bot/PhabricatorIRCBot.php index 9bffa665dd..1bd9f6e1b8 100644 --- a/src/infrastructure/daemon/irc/bot/PhabricatorIRCBot.php +++ b/src/infrastructure/daemon/irc/bot/PhabricatorIRCBot.php @@ -1,259 +1,265 @@ <?php /* * Copyright 2012 Facebook, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * 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}"); } if ($nickpass) { $this->writeCommand("NickServ IDENTIFY ", "{$nickpass}"); } $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) { foreach ($this->handlers as $handler) { try { $handler->receiveMessage($message); } catch (Exception $ex) { phlog($ex); } } } public function __destroy() { $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; } } diff --git a/src/infrastructure/daemon/irc/bot/__init__.php b/src/infrastructure/daemon/irc/bot/__init__.php index 33c26ab4d6..d160b058c3 100644 --- a/src/infrastructure/daemon/irc/bot/__init__.php +++ b/src/infrastructure/daemon/irc/bot/__init__.php @@ -1,18 +1,19 @@ <?php /** * This file is automatically generated. Lint this module to rebuild it. * @generated */ phutil_require_module('phabricator', 'infrastructure/daemon/base'); phutil_require_module('phabricator', 'infrastructure/daemon/irc/message'); phutil_require_module('phutil', 'conduit/client'); phutil_require_module('phutil', 'error'); phutil_require_module('phutil', 'filesystem'); +phutil_require_module('phutil', 'parser/uri'); phutil_require_module('phutil', 'utils'); phutil_require_source('PhabricatorIRCBot.php');