diff --git a/README.md b/README.md index 5a76df7..b29dc30 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Plus, Cardinal is still in active development! Features are being added as quick ### Configuration -Copy the `config/config.example.json` (virtualenv) or `config/config.docker.json` (Docker) file to `config.json` (or, if you are using Cardinal on multiple networks, something like `config.freenode.json` -- you will need to pass the `--config` option in this case) and modify it to suit your needs, or view Cardinal's command line options with `./cardinal -h`. Not all options may be configured from the command line. +Copy the `config/config.example.json` (virtualenv) or `config/config.docker.json` (Docker) file to `config/config.json` (or, if you are using Cardinal on multiple networks, something like `config.freenode.json`) and modify it to suit your needs. You should also add your nick and vhost to the `plugins/admin/config.json` file in the format `nick@vhost` in order to take advantage of admin-only commands. @@ -43,17 +43,15 @@ You can run Cardinal as a Docker container, or install Cardinal inside of a [Pyt First, install [Docker](https://docs.docker.com/install/) and [docker-compose](https://docs.docker.com/compose/install/). -After configuring Cardinal (see above), simply run `docker-compose up -d` if you are storing your config as `config.json`. Otherwise, you will need to create a `docker-compose.override.yml` file like so: +After configuring Cardinal (see above), simply run `docker-compose up -d` if you are storing your config as `config/config.json`. Otherwise, you will need to create a `docker-compose.override.yml` file like so: ```yaml version: "2.1" services: cardinal: - command: --config /config/config.darkscience.json + command: config/config.darkscience.json ``` -The `config/` directory is mounted at `/config/`, so simply set the filename appropriately, then run `docker-compose up -d`. - #### virtualenv `virtualenv -p /usr/bin/python2.7 . && source bin/activate` @@ -62,7 +60,7 @@ Make sure you have Python 2.7 installed, and run `pip install -r requirements.tx **Note:** Make sure you have `libssl-dev` and `libffi-dev` installed on Debian (or the equivelant package for your distro) or installation of some dependencies may not work correctly. -After installation, simply type `./cardinal.py`. +After installation, simply type `./cardinal.py config/config.json` (change `config/config.json` to your config location). ## Writing Plugins diff --git a/cardinal.py b/cardinal.py index 810c440..eb4749f 100755 --- a/cardinal.py +++ b/cardinal.py @@ -5,61 +5,42 @@ import argparse import logging import logging.config -from getpass import getpass from twisted.internet import reactor from cardinal.config import ConfigParser, ConfigSpec from cardinal.bot import CardinalBotFactory -if __name__ == "__main__": +def setup_logging(config=None): + if config is None: + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + else: + logging.config.dictConfig(config) + + return logging.getLogger(__name__) + + +if __name__ == "__main__": # Create a new instance of ArgumentParser with a description about Cardinal arg_parser = argparse.ArgumentParser(description=""" Cardinal IRC bot -A Python/Twisted-powered modular IRC bot. Aimed to be simple to use, simple -to develop. For information on developing plugins, visit the project page -below. +A Twisted IRC bot designed to be simple to use and and easy to extend. https://github.com/JohnMaguire/Cardinal """, formatter_class=argparse.RawDescriptionHelpFormatter) - # Add all the possible arguments - arg_parser.add_argument('-n', '--nickname', metavar='nickname', - help='nickname to connect as') - - arg_parser.add_argument('--password', action='store_true', - help='set this flag to get a password prompt for ' - 'identifying') - - arg_parser.add_argument('-u', '--username', metavar='username', - help='username (ident) of the bot') - - arg_parser.add_argument('-r', '--realname', metavar='realname', - help='Real name of the bot') - - arg_parser.add_argument('-i', '--network', metavar='network', - help='network to connect to') - - arg_parser.add_argument('-o', '--port', type=int, metavar='port', - help='network port to connect to') - - arg_parser.add_argument('-P', '--spassword', metavar='server_password', - help='password to connect to the network with') - - arg_parser.add_argument('-s', '--ssl', action='store_true', - help='you must set this flag for SSL connections') - - arg_parser.add_argument('-c', '--channels', nargs='*', metavar='channel', - help='list of channels to connect to on startup') - - arg_parser.add_argument('-p', '--plugins', nargs='*', metavar='plugin', - help='list of plugins to load on startup') - - arg_parser.add_argument('--config', metavar='config', + arg_parser.add_argument('config', metavar='config', help='custom config location') + # Parse command-line arguments + args = arg_parser.parse_args() + config_file = args.config + # Define the config spec and create a parser for our internal config spec = ConfigSpec() spec.add_option('nickname', basestring, 'Cardinal') @@ -93,60 +74,32 @@ parser = ConfigParser(spec) - # Parse command-line arguments - args = arg_parser.parse_args() - - # Attempt to load config.json for config options - config_file = args.config - if config_file is None: - config_file = os.path.join( - os.path.dirname(os.path.realpath(__file__)), - 'config.json' - ) - # Load config file - parser.load_config(config_file) + try: + config = parser.load_config(config_file) + except Exception: + # Need to setup a logger early + logger = setup_logging() + logger.exception("Unable to load config: {}".format(config_file)) + os.exit(1) - # If SSL is set to false, set it to None (small hack - action 'store_true' - # in arg_parse defaults to False. False instead of None will overwrite our - # config settings.) - if not args.ssl: - args.ssl = None + # Config loaded, setup the logger + logger = setup_logging(config['logging']) - # If the password flag was set, let the user safely type in their password - if args.password: - args.password = getpass('NickServ password: ') - else: - args.password = None - - # Merge the args into the config object - config = parser.merge_argparse_args_into_config(args) - - # If user defined logging config, use it, otherwise use default - if config['logging'] is not None: - logging.config.dictConfig(config['logging']) - else: - # Set default log level to INFO and get some pretty formatting - logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' - ) + logger.info("Config loaded: {}".format(config_file)) - # Get a logger! - logger = logging.getLogger(__name__) - - # Set the storage directory + # Determine storage directory storage_path = None if config['storage'] is not None: if config['storage'].startswith('/'): storage_path = config['storage'] else: storage_path = os.path.join( - os.path.dirname(os.path.realpath(sys.argv[0])), + os.path.dirname(os.path.realpath(__file__)), config['storage'] ) - logger.info("Storage path set to %s" % storage_path) + logger.info("Storage path: {}".format(storage_path)) directories = [ os.path.join(storage_path, 'database'), @@ -156,20 +109,20 @@ for directory in directories: if not os.path.exists(directory): logger.info( - "Storage directory %s does not exist, creating it..", - directory) - + "Initializing storage directory: {}".format(directory)) os.makedirs(directory) - """If no username is supplied, set it to the nickname. """ + # If no username is supplied, default to nickname if config['username'] is None: config['username'] = config['nickname'] # Instance a new factory, and connect with/without SSL logger.debug("Instantiating CardinalBotFactory") - factory = CardinalBotFactory(config['network'], config['server_password'], + factory = CardinalBotFactory(config['network'], + config['server_password'], config['channels'], - config['nickname'], config['password'], + config['nickname'], + config['password'], config['username'], config['realname'], config['plugins'], diff --git a/cardinal/config.py b/cardinal/config.py index 1a7cd5a..5926f39 100644 --- a/cardinal/config.py +++ b/cardinal/config.py @@ -149,59 +149,16 @@ def load_config(self, file_): """ # Attempt to load and parse the config file - try: - f = open(file_, 'r') - json_config = self._utf8_json(json.load(f)) - f.close() - # File did not exist or we can't open it for another reason - except IOError: - self.logger.warning( - "Can't open %s (using defaults / command-line values)" % file_ - ) - # Thrown by json.load() when the content isn't valid JSON - except ValueError: - self.logger.warning( - "Invalid JSON in %s, (using defaults / command-line values)" % - file_ - ) - else: - # For every option, - for option in self.spec.options: - # If the option wasn't defined in the config, default - if option not in json_config: - json_config[option] = None - - self.config[option] = self.spec.return_value_or_default( - option, json_config[option]) - - # If we didn't load the config earlier, or there was nothing in it... - if self.config == {} and self.spec.options != {}: - for option in self.spec.options: - # Grab the default - self.config[option] = self.spec.options[option][1] + f = open(file_, 'r') + json_config = self._utf8_json(json.load(f)) + f.close() - return self.config - - def merge_argparse_args_into_config(self, args): - """Merges the args returned by argparse.ArgumentParser into the config. - - Keyword arguments: - args -- The args object returned by argsparse.parse_args(). - - Returns: - dict -- Dictionary object of the entire config. - - """ + # For every option, for option in self.spec.options: - try: - # If the value exists in args and is set, then update the - # config's value - value = getattr(args, option) - if value is not None: - self.config[option] = value - except AttributeError: - self.logger.debug( - "Option %s not in CLI arguments -- not updated" % option - ) + # If the option wasn't defined in the config, default + value = json_config[option] if option in json_config else None + + self.config[option] = self.spec.return_value_or_default( + option, value) return self.config diff --git a/cardinal/test_config.py b/cardinal/test_config.py index 224970f..1dd7589 100644 --- a/cardinal/test_config.py +++ b/cardinal/test_config.py @@ -57,9 +57,11 @@ def test_return_value_or_default_value(self): class TestConfigParser(object): + DEFAULT = '_default_' + def setup_method(self): config_spec = self.config_spec = ConfigSpec() - config_spec.add_option("not_in_json", basestring) + config_spec.add_option("not_in_json", basestring, default=self.DEFAULT) config_spec.add_option("string", basestring) config_spec.add_option("int", int) config_spec.add_option("bool", bool) @@ -72,26 +74,22 @@ def test_constructor(self): ConfigParser("not a ConfigSpec") def test_load_config_nonexistent_file(self): - # For some reason, this silently fails - self.config_parser.load_config( - os.path.join(FIXTURE_DIRECTORY, 'nonexistent.json')) + # if the config file doesn't exist, error + with pytest.raises(IOError): + self.config_parser.load_config( + os.path.join(FIXTURE_DIRECTORY, 'nonexistent.json')) - # should all be set to defaults - assert self.config_parser.config['string'] is None - assert self.config_parser.config['int'] is None - assert self.config_parser.config['bool'] is None - assert self.config_parser.config['dict'] is None + # nothing loaded + assert self.config_parser.config == {} def test_load_config_invalid_file(self): - # For some reason, this silently fails - self.config_parser.load_config( - os.path.join(FIXTURE_DIRECTORY, 'invalid-json-config.json')) + # if the config is invalid, error + with pytest.raises(ValueError): + self.config_parser.load_config( + os.path.join(FIXTURE_DIRECTORY, 'invalid-json-config.json')) - # should all be set to defaults - assert self.config_parser.config['string'] is None - assert self.config_parser.config['int'] is None - assert self.config_parser.config['bool'] is None - assert self.config_parser.config['dict'] is None + # nothing loaded + assert self.config_parser.config == {} def test_load_config_picks_up_values(self): self.config_parser.load_config( @@ -105,54 +103,8 @@ def test_load_config_picks_up_values(self): 'list': ['foo', 'bar', 'baz'], } - # This should get set to None when it's not found in the file - assert self.config_parser.config['not_in_json'] is None - - # This was in the file but not the spec and should not appear in config - assert 'ignored_string' not in self.config_parser.config - - def test_merge_argparse_args_into_config(self): - class args: - string = 'value' - int = 3 - bool = False - dict = {'foo': 'bar'} - ignored_string = 'asdf' - - self.config_parser.merge_argparse_args_into_config(args) - - assert self.config_parser.config['string'] == 'value' - assert self.config_parser.config['int'] == 3 - assert self.config_parser.config['bool'] is False - assert self.config_parser.config['dict'] == {'foo': 'bar'} - - # defaults only get set by load_config, not - # merge_argparse_args_into_config - assert 'not_in_json' not in self.config_parser.config + # If not found in the file, default + assert self.config_parser.config['not_in_json'] == self.DEFAULT # This was in the file but not the spec and should not appear in config assert 'ignored_string' not in self.config_parser.config - - def test_merge_argparse_args_into_config_overwrites_config(self): - self.config_parser.load_config( - os.path.join(FIXTURE_DIRECTORY, 'config.json')) - - assert self.config_parser.config['string'] == 'value' - assert self.config_parser.config['int'] == 3 - assert self.config_parser.config['bool'] is False - assert self.config_parser.config['dict'] == { - 'dict': {'string': 'value'}, - 'list': ['foo', 'bar', 'baz'], - } - - class args: - string = 'new_value' - int = 5 - dict = {'foo': 'bar'} - - self.config_parser.merge_argparse_args_into_config(args) - - assert self.config_parser.config['string'] == 'new_value' - assert self.config_parser.config['int'] == 5 - assert self.config_parser.config['bool'] is False # no value to update - assert self.config_parser.config['dict'] == {'foo': 'bar'} diff --git a/docker-compose.yml b/docker-compose.yml index cc8d487..a76279c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,8 @@ services: container_name: cardinal image: jmaguire/cardinal build: . - command: --config /config/config.json + command: config/config.json volumes: - - ./storage/:/storage/ - - ./config/:/config/ + - ./storage/:/usr/src/app/storage/ + - ./config/:/usr/src/app/config/ restart: unless-stopped diff --git a/etc/supervisord.conf b/etc/supervisord.conf index e886e67..066b4c7 100644 --- a/etc/supervisord.conf +++ b/etc/supervisord.conf @@ -1,8 +1,8 @@ [program:cardinal] -command=/usr/bin/python2 /PATH/TO/REPO/cardinal.py --config /PATH/TO/REPO/config.json ; Point to virtualenv Python if you have one -directory=/PATH/TO/REPO/Cardinal ; Directory Cardinal will run under +command=/usr/bin/python2 /PATH/TO/REPO/cardinal.py /PATH/TO/REPO/config.json ; Point to virtualenv Python if you have one +directory=/PATH/TO/REPO/Cardinal ; Directory Cardinal will run under autostart=true autorestart=true startretries=3 -stderr_logfile=/var/log/cardinal.log ; All logs go to stderr -user=USER ; Needs permissions to cardinal, config, etc. +stderr_logfile=/var/log/cardinal.log ; All logs go to stderr +user=USER ; Needs permissions to cardinal, config, etc.