diff --git a/src/infrastructure/daemon/bot/PhabricatorBot.php b/src/infrastructure/daemon/bot/PhabricatorBot.php index bdc2092eaf..667c90f4f0 100644 --- a/src/infrastructure/daemon/bot/PhabricatorBot.php +++ b/src/infrastructure/daemon/bot/PhabricatorBot.php @@ -1,148 +1,151 @@ getArgv(); if (count($argv) !== 1) { throw new Exception('usage: PhabricatorBot '); } $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; } $ca_bundle = idx($config, 'https.cabundle'); if ($ca_bundle) { HTTPSFuture::setGlobalCABundleFromPath($ca_bundle); } $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_host = (string)$conduit_uri->setPath('/'); $conduit_uri = (string)$conduit_uri->setPath('/api/'); $conduit = new ConduitClient($conduit_uri); $response = $conduit->callMethodSynchronous( 'conduit.connect', array( 'client' => 'PhabricatorBot', 'clientVersion' => '1.0', 'clientDescription' => php_uname('n').':'.$nick, 'host' => $conduit_host, '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(); + + $this->protocolAdapter->disconnect(); } 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 (!$this->shouldExit()); + } public function writeMessage(PhabricatorBotMessage $message) { return $this->protocolAdapter->writeMessage($message); } private function routeMessage(PhabricatorBotMessage $message) { $ignore = $this->getConfig('ignore'); if ($ignore) { $sender = $message->getSender(); if ($sender && in_array($sender->getName(), $ignore)) { return; } } if ($message->getCommand() == 'LOG') { $this->log('[LOG] '.$message->getBody()); } foreach ($this->handlers as $handler) { try { $handler->receiveMessage($message); } catch (Exception $ex) { phlog($ex); } } } public function getAdapter() { return $this->protocolAdapter; } 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/bot/adapter/PhabricatorBaseProtocolAdapter.php b/src/infrastructure/daemon/bot/adapter/PhabricatorBaseProtocolAdapter.php index 4a8a62d092..e50edea7be 100644 --- a/src/infrastructure/daemon/bot/adapter/PhabricatorBaseProtocolAdapter.php +++ b/src/infrastructure/daemon/bot/adapter/PhabricatorBaseProtocolAdapter.php @@ -1,55 +1,62 @@ config = $config; return $this; } public function getConfig($key, $default = null) { return idx($this->config, $key, $default); } /** * Performs any connection logic necessary for the protocol */ abstract public function connect(); + /** + * Disconnect from the service. + */ + public function disconnect() { + return; + } + /** * 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 PhabricatorBotMessage $message The message to write */ abstract public function writeMessage(PhabricatorBotMessage $message); /** * String identifying the service type the adapter provides access to, like * "irc", "campfire", "flowdock", "hipchat", etc. */ abstract public function getServiceType(); /** * String identifying the service name the adapter is connecting to. This is * used to distinguish between instances of a service. For example, for IRC, * this should return the IRC network the client is connecting to. */ abstract public function getServiceName(); } diff --git a/src/infrastructure/daemon/bot/adapter/PhabricatorIRCProtocolAdapter.php b/src/infrastructure/daemon/bot/adapter/PhabricatorIRCProtocolAdapter.php index d2740209b0..504605982c 100644 --- a/src/infrastructure/daemon/bot/adapter/PhabricatorIRCProtocolAdapter.php +++ b/src/infrastructure/daemon/bot/adapter/PhabricatorIRCProtocolAdapter.php @@ -1,257 +1,278 @@ getConfig('network', $this->getConfig('server')); } // Hash map of command translations public static $commandTranslations = array( 'PRIVMSG' => 'MESSAGE'); public function connect() { $nick = $this->getConfig('nick', 'phabot'); $server = $this->getConfig('server'); $port = $this->getConfig('port', 6667); $pass = $this->getConfig('pass'); $ssl = $this->getConfig('ssl', false); $user = $this->getConfig('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; if ($pass) { $this->write("PASS {$pass}"); } $this->write("NICK {$nick}"); $this->write("USER {$user} 0 * :{$user}"); } 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())); + // We may have been interrupted by a signal, like a SIGINT. Try + // selecting again. If the second select works, conclude that the failure + // was most likely because we were signaled. + $ok = @stream_select($read, $write, $except, $timeout_sec = 0); + if ($ok === false) { + throw new Exception('stream_select() failed!'); + } } 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[] = id(new PhabricatorBotMessage()) ->setCommand('LOG') ->setBody('>>> '.$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 if ($len === 0) { + break; } else { $messages[] = id(new PhabricatorBotMessage()) ->setCommand('LOG') ->setBody('>>> '.substr($this->writeBuffer, 0, $len)); $this->writeBuffer = substr($this->writeBuffer, $len); } } while (strlen($this->writeBuffer)); } while (($m = $this->processReadBuffer()) !== false) { if ($m !== null) { $messages[] = $m; } } return $messages; } private function write($message) { $this->writeBuffer .= $message."\r\n"; return $this; } public function writeMessage(PhabricatorBotMessage $message) { switch ($message->getCommand()) { case 'MESSAGE': case 'PASTE': $name = $message->getTarget()->getName(); $body = $message->getBody(); $this->write("PRIVMSG {$name} :{$body}"); return true; default: return false; } } 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(\S+?))(?:!\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}"); } if ($this->handleIRCProtocol($matches)) { return null; } $command = $this->getBotCommand($matches['command']); list($target, $body) = $this->parseMessageData($command, $matches['data']); if (!strlen($matches['sender'])) { $sender = null; } else { $sender = id(new PhabricatorBotUser()) ->setName($matches['sender']); } $bot_message = id(new PhabricatorBotMessage()) ->setSender($sender) ->setCommand($command) ->setTarget($target) ->setBody($body); return $bot_message; } private function handleIRCProtocol(array $matches) { $data = $matches['data']; switch ($matches['command']) { case '433': // Nickname already in use // If we receive this error, try appending "-1", "-2", etc. to the nick $this->nickIncrement++; $nick = $this->getConfig('nick', 'phabot').'-'.$this->nickIncrement; $this->write("NICK {$nick}"); return true; case '422': // Error - no MOTD case '376': // End of MOTD $nickpass = $this->getConfig('nickpass'); if ($nickpass) { $this->write("PRIVMSG nickserv :IDENTIFY {$nickpass}"); } $join = $this->getConfig('join'); if (!$join) { throw new Exception('Not configured to join any channels!'); } foreach ($join as $channel) { $this->write("JOIN {$channel}"); } return true; case 'PING': $this->write("PONG {$data}"); return true; } return false; } private function getBotCommand($irc_command) { if (isset(self::$commandTranslations[$irc_command])) { return self::$commandTranslations[$irc_command]; } // We have no translation for this command, use as-is return $irc_command; } private function parseMessageData($command, $data) { switch ($command) { case 'MESSAGE': $matches = null; if (preg_match('/^(\S+)\s+:?(.*)$/', $data, $matches)) { $target_name = $matches[1]; if (strncmp($target_name, '#', 1) === 0) { $target = id(new PhabricatorBotChannel()) ->setName($target_name); } else { $target = id(new PhabricatorBotUser()) ->setName($target_name); } return array( $target, rtrim($matches[2], "\r\n")); } break; } // By default we assume there is no target, only a body return array( null, $data); } - public function __destruct() { - $this->write('QUIT Goodbye.'); - fclose($this->socket); + public function disconnect() { + // NOTE: FreeNode doesn't show quit messages if you've recently joined a + // channel, presumably to prevent some kind of abuse. If you're testing + // this, you may need to stay connected to the network for a few minutes + // before it works. If you disconnect too quickly, the server will replace + // your message with a "Client Quit" message. + + $quit = $this->getConfig('quit', pht('Shutting down.')); + $this->write("QUIT :{$quit}"); + + // Flush the write buffer. + while (strlen($this->writeBuffer)) { + $this->getNextMessages(0); + } + + @fclose($this->socket); + $this->socket = null; } }