# Copyright 2026 Logan Fick # # 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. """ Entry point for the Crabstero Discord bot. Provides an argparse CLI with environment variable and systemd credential fallbacks for --token and --database-path. """ import argparse import asyncio import logging import os import signal from pathlib import Path from crabstero import __version__ as crabstero_version from crabstero.bot import Crabstero logger = logging.getLogger("crabstero") def _read_credential(name: str) -> str | None: """ Read a value from a systemd credential file. Looks for a file named *name* inside the directory pointed to by the ``CREDENTIALS_DIRECTORY`` environment variable (set automatically by systemd when ``LoadCredential=`` or ``SetCredential=`` is used). :param name: Credential name to look up. :return: The credential value, or ``None`` if unavailable. """ credentials_dir = os.environ.get("CREDENTIALS_DIRECTORY") if credentials_dir is None: return None try: return Path(credentials_dir, name).read_text().strip() except OSError: return None def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: """ Parses command-line arguments with environment variable fallbacks. :param argv: Optional argument list (defaults to sys.argv[1:]). :return: Parsed arguments namespace. """ parser = argparse.ArgumentParser( prog="crabstero", description="Crabstero - the simple nonversation Discord bot.", ) parser.add_argument( "--token", default=os.environ.get("TOKEN") or _read_credential("token"), help="Discord bot token (default: TOKEN environment variable or systemd credential 'token').", ) parser.add_argument( "--database-path", "--database", default=os.environ.get("DATABASE_PATH", "crabstero.db"), help='Path to the SQLite database file (default: DATABASE_PATH environment variable or "crabstero.db").', ) parser.add_argument( "--ingestion-workers", type=int, default=int(os.environ.get("INGESTION_WORKERS", "4")), help="Number of concurrent ingestion workers (default: INGESTION_WORKERS environment variable or 4).", ) parser.add_argument( "--ingest-only", action="store_true", default=False, help="Run in ingest-only mode: ingest channel history and real-time messages but never respond.", ) args = parser.parse_args(argv) if args.token is None: parser.error( "a Discord bot token is required via --token, the TOKEN environment variable, or a systemd credential named 'token'" ) return args def main() -> None: """ Main entry point. Parses arguments, configures logging, and starts the bot. """ args = _parse_args() logging.basicConfig( level=logging.INFO, format="[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s", ) logger.info("Starting Crabstero %s...", crabstero_version) bot = Crabstero( token=args.token, database_path=args.database_path, ingestion_workers=args.ingestion_workers, ingest_only=args.ingest_only, ) async def _run() -> None: async with bot: await bot.start() # Make SIGTERM behave like SIGINT so systemd stop triggers the same # clean shutdown path (context manager __aexit__ -> bot.close()). signal.signal(signal.SIGTERM, signal.default_int_handler) try: asyncio.run(_run()) except KeyboardInterrupt: logger.info("Interrupted.") if __name__ == "__main__": main()