diff --git a/.gitignore b/.gitignore index efee9a79..c6dafd0c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ .idea/ venv/ -*.pyc \ No newline at end of file +*.pyc +pluralkit.conf +pluralkit.*.conf \ No newline at end of file diff --git a/README.md b/README.md index bb00737a..d22338e7 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,14 @@ PluralKit has a Discord server for support and discussion: https://discord.gg/Pc Running the bot requires Python (specifically version 3.6) and PostgreSQL. # Configuration -Configuring the bot is done through environment variables. +Configuring the bot is done through a configuration file. An example of the configuration format can be seen in [`pluralkit.conf.example`](https://github.com/xSke/PluralKit/blob/master/pluralkit.conf.example). -* TOKEN - the Discord bot token to connect with -* CLIENT_ID - the Discord bot client ID -* DATABASE_USER - the username to log into the database with -* DATABASE_PASS - the password to log into the database with -* DATABASE_NAME - the name of the database to use -* DATABASE_HOST - the hostname of the PostgreSQL instance to connect to -* DATABASE_PORT - the port of the PostgreSQL instance to connect to -* LOG_CHANNEL (optional) - a Discord channel ID the bot will post exception tracebacks in (make this private!) +The following keys are available: +* `token`: the Discord bot token to connect with +* `database_uri`: the URI of the database to connect to (format: `postgres://username:password@hostname:port/database_name`) +* `log_channel` (optional): a Discord channel ID the bot will post exception tracebacks in (make this private!) + +The environment variables `TOKEN` and `DATABASE_URI` will override the configuration file values when present. # Running @@ -25,17 +23,16 @@ Configuring the bot is done through environment variables. Running PluralKit is pretty easy with Docker. The repository contains a `docker-compose.yml` file ready to use. * Clone this repository: `git clone https://github.com/xSke/PluralKit` -* Create a `.env` file containing at least `TOKEN` and `CLIENT_ID` in `key=value` format +* Create a `pluralkit.conf` file in the same directory as `docker-compose.yml` containing at least a `token` field * Build the bot: `docker-compose build` * Run the bot: `docker-compose up` ## Manually -You'll need to pass configuration options through shell environment variables. - * Clone this repository: `git clone https://github.com/xSke/PluralKit` * Create a virtualenv: `virtualenv --python=python3.6 venv` * Install dependencies: `venv/bin/pip install -r requirements.txt` -* Run PluralKit with environment variables: `TOKEN=... CLIENT_ID=... DATABASE_USER=... venv/bin/python src/bot_main.py` +* Run PluralKit with the config file: `venv/bin/python src/bot_main.py` + * The bot optionally takes a parameter describing the location of the configuration file, defaulting to `./pluralkit.conf`. # License This project is under the Apache License, Version 2.0. It is available at the following link: https://www.apache.org/licenses/LICENSE-2.0 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ed78b337..b233579c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,18 +5,12 @@ services: entrypoint: - python - bot_main.py + volumes: + - "./pluralkit.conf:/app/pluralkit.conf:ro" + environment: + - "DATABASE_URI=postgres://postgres:postgres@db:5432/postgres" depends_on: - db - environment: - - CLIENT_ID - - TOKEN - - LOG_CHANNEL - - TUPPERWARE_ID - - "DATABASE_USER=postgres" - - "DATABASE_PASS=postgres" - - "DATABASE_NAME=postgres" - - "DATABASE_HOST=db" - - "DATABASE_PORT=5432" restart: always api: build: src/ @@ -29,11 +23,7 @@ services: ports: - "2939:8080" environment: - - "DATABASE_USER=postgres" - - "DATABASE_PASS=postgres" - - "DATABASE_NAME=postgres" - - "DATABASE_HOST=db" - - "DATABASE_PORT=5432" + - "DATABASE_URI=postgres://postgres:postgres@db:5432/postgres" db: image: postgres:alpine volumes: diff --git a/pluralkit.conf.example b/pluralkit.conf.example new file mode 100644 index 00000000..86339d5a --- /dev/null +++ b/pluralkit.conf.example @@ -0,0 +1,5 @@ +{ + "database_uri": "postgres://username:password@hostname:port/database_name", + "token": "BOT_TOKEN_GOES_HERE", + "log_channel": null +} \ No newline at end of file diff --git a/src/api_main.py b/src/api_main.py index 7fe9484c..e1cf1f1f 100644 --- a/src/api_main.py +++ b/src/api_main.py @@ -194,11 +194,7 @@ app.add_routes([ async def run(): app["pool"] = await db.connect( - os.environ["DATABASE_USER"], - os.environ["DATABASE_PASS"], - os.environ["DATABASE_NAME"], - os.environ["DATABASE_HOST"], - int(os.environ["DATABASE_PORT"]) + os.environ["DATABASE_URI"] ) return app diff --git a/src/bot_main.py b/src/bot_main.py index e8a9e0ab..b29ba94a 100644 --- a/src/bot_main.py +++ b/src/bot_main.py @@ -1,4 +1,7 @@ import asyncio +import json +import os +import sys try: # uvloop doesn't work on Windows, therefore an optional dependency @@ -7,5 +10,13 @@ try: except ImportError: pass -from pluralkit import bot -bot.run() \ No newline at end of file +with open(sys.argv[1] if len(sys.argv) > 1 else "pluralkit.conf") as f: + config = json.load(f) + +if "database_uri" not in config and "database_uri" not in os.environ: + print("Config file must contain key 'database_uri', or the environment variable DATABASE_URI must be present.") +elif "token" not in config and "token" not in os.environ: + print("Config file must contain key 'token', or the environment variable TOKEN must be present.") +else: + from pluralkit import bot + bot.run(os.environ.get("TOKEN", config["token"]), os.environ.get("DATABASE_URI", config["database_uri"]), int(config.get("log_channel", 0))) \ No newline at end of file diff --git a/src/pluralkit/bot/__init__.py b/src/pluralkit/bot/__init__.py index 042c8497..d160d2f4 100644 --- a/src/pluralkit/bot/__init__.py +++ b/src/pluralkit/bot/__init__.py @@ -13,36 +13,12 @@ from pluralkit.bot import commands, proxy, channel_logger, embeds logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s") -def connect_to_database() -> asyncpg.pool.Pool: - username = os.environ["DATABASE_USER"] - password = os.environ["DATABASE_PASS"] - name = os.environ["DATABASE_NAME"] - host = os.environ["DATABASE_HOST"] - port = os.environ["DATABASE_PORT"] - - if username is None or password is None or name is None or host is None or port is None: - print( - "Database credentials not specified. Please pass valid PostgreSQL database credentials in the DATABASE_[USER|PASS|NAME|HOST|PORT] environment variable.", - file=sys.stderr) - sys.exit(1) - - try: - port = int(port) - except ValueError: - print("Please pass a valid integer as the DATABASE_PORT environment variable.", file=sys.stderr) - sys.exit(1) - - return asyncio.get_event_loop().run_until_complete(db.connect( - username=username, - password=password, - database=name, - host=host, - port=port - )) +def connect_to_database(uri: str) -> asyncpg.pool.Pool: + return asyncio.get_event_loop().run_until_complete(db.connect(uri)) -def run(): - pool = connect_to_database() +def run(token: str, db_uri: str, log_channel_id: int): + pool = connect_to_database(db_uri) async def create_tables(): async with pool.acquire() as conn: @@ -51,7 +27,6 @@ def run(): asyncio.get_event_loop().run_until_complete(create_tables()) client = discord.Client() - logger = channel_logger.ChannelLogger(client) @client.event @@ -98,11 +73,14 @@ def run(): @client.event async def on_error(event_name, *args, **kwargs): - log_channel_id = os.environ.get("LOG_CHANNEL") + # Print it to stderr + logging.getLogger("pluralkit").exception("Exception while handling event {}".format(event_name)) + + # Then log it to the given log channel + # TODO: replace this with Sentry or something if not log_channel_id: return - - log_channel = client.get_channel(int(log_channel_id)) + log_channel = client.get_channel(log_channel_id) # If this is a message event, we can attach additional information in an event # ie. username, channel, content, etc @@ -124,14 +102,4 @@ def run(): if len(traceback.format_exc()) >= (2000 - len("```python\n```")): traceback_str = "```python\n...{}```".format(traceback.format_exc()[- (2000 - len("```python\n...```")):]) await log_channel.send(content=traceback_str, embed=embed) - - # Print it to stderr anyway, though - logging.getLogger("pluralkit").exception("Exception while handling event {}".format(event_name)) - - bot_token = os.environ["TOKEN"] - if not bot_token: - print("No token specified. Please pass a valid Discord bot token in the TOKEN environment variable.", - file=sys.stderr) - sys.exit(1) - - client.run(bot_token) + client.run(token) diff --git a/src/pluralkit/bot/commands/misc_commands.py b/src/pluralkit/bot/commands/misc_commands.py index 58a76019..8ecb3776 100644 --- a/src/pluralkit/bot/commands/misc_commands.py +++ b/src/pluralkit/bot/commands/misc_commands.py @@ -22,7 +22,7 @@ async def help_root(ctx: CommandContext): async def invite_link(ctx: CommandContext): - client_id = os.environ["CLIENT_ID"] + client_id = (await ctx.client.application_info()).id permissions = discord.Permissions() diff --git a/src/pluralkit/db.py b/src/pluralkit/db.py index 9990c345..e721f24b 100644 --- a/src/pluralkit/db.py +++ b/src/pluralkit/db.py @@ -12,10 +12,10 @@ from pluralkit.system import System from pluralkit.member import Member logger = logging.getLogger("pluralkit.db") -async def connect(username, password, database, host, port): +async def connect(uri): while True: try: - return await asyncpg.create_pool(user=username, password=password, database=database, host=host, port=port) + return await asyncpg.create_pool(uri) except (ConnectionError, asyncpg.exceptions.CannotConnectNowError): logger.exception("Failed to connect to database, retrying in 5 seconds...") time.sleep(5)