commit 5a9c6a45ed0fe130c61aef3e2078bd2aab6d2247 Author: Xargana Date: Sun Jun 8 22:44:44 2025 +0300 dont even know if it works lol didnt even test it diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8065e67 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +TOKEN= # Your Discord token +PREFIX= # Command prefixes, seperated with whitespace +WEBHOOK_URL= # URL for the webhook to leak to +CHANNEL_IDS= # ID of the guild to use for commands, seperated with comma +IGNORED_USER_IDS= # IDs of users to ignore, seperated with comma \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ddbfc02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,180 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# testing shit +notrack_* +config.json +AGENT.md +config.toml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..61caf60 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +dont redistribute or we'll fuckin sue you faggot \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c7fa16 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ + +# Faucet + +Discord selfbot for automatically leaking messages from servers. + +Most of the code is skidded from [Selfbot V2](https://git.xargana.tr/glitchy/selfbot). + +## Setup + +1. Python 3.8 or higher is required +2. Install virtualenv if you haven't already: + +```bash +pip install venv +``` + +## Installation + +1. Clone the repository: + +```bash +git clone https://git.xargana.tr/glitchy/selfbot.git +cd selfbot +``` + +2. Create and activate a virtual environment: + +```bash +# Windows +python -m venv venv +venv\Scripts\activate + +# Linux/macOS +python3 -m venv venv +source venv/bin/activate +``` + +3. Install the required packages: + +```bash +pip install -r requirements.txt +``` + +4. Edit `config.toml` with your preferred settings and add your Discord bot token. If you have an existing `config.json` file, it will be automatically migrated to TOML format when you first run the bot. + +## Running the Bot + +1. Start the bot: + +```bash +python main.py +``` + +The bot should now be running and connected to Discord. You can verify this by checking the console output for the login confirmation message, or by doing .help in any channel. diff --git a/TOML_CONFIG.md b/TOML_CONFIG.md new file mode 100644 index 0000000..6fd4e37 --- /dev/null +++ b/TOML_CONFIG.md @@ -0,0 +1,75 @@ +# TOML Configuration Guide + +## Structure + +The configuration file uses TOML format with three main sections: + +1. `[metadata]` - Global configuration metadata +2. `[settings]` - The actual configuration values +3. `[value_metadata]` - Metadata for individual configuration keys + +## Metadata + +The global metadata section contains information about the configuration file itself: + +```toml +[metadata] +# Configuration metadata +last_updated = "2025-05-17 12:00:00" # When the config was last saved +version = "1.0" # Config file format version +last_modified_key = "afk_status" # The most recently modified key (if applicable) +modified_by = "username" # Who made the last change (if available) +``` + +Additional metadata may include: +- `hooks_passed` - List of hooks that were triggered during the last save +- `migration_source` - The source of a configuration migration + +## Settings + +The settings section contains all the actual configuration values: + +```toml +[settings] +# User settings +command_response_type = "edit_invoking" +afk_status_message = "AFK!" +# ... other settings +``` + +## Value Metadata + +This section stores metadata for individual configuration keys: + +```toml +[value_metadata] +# Individual value metadata + +[value_metadata.afk_status_message] +last_updated = "2025-05-17 12:00:00" +updated_by = "glitchy#1234 (123456789012345678)" + +[value_metadata.command_response_type] +last_updated = "2025-05-17 12:00:00" +updated_by = "system" +``` + +## Commands + +The following commands help manage and inspect the configuration: + +- `.show_config` - Shows all configuration settings with their descriptions +- `.set_config key value` - Sets a configuration value with full metadata tracking +- `.config_meta` - Shows global configuration metadata +- `.config_meta key` - Shows detailed metadata for a specific configuration key +- `.migrate_config` - Manually triggers migration from JSON to TOML format + +## Testing + +To test your TOML configuration, run the included test script: + +```bash +./test_toml_config.py +``` + +This script can help diagnose and fix issues with the TOML format, and performs migration from JSON to TOML if needed. \ No newline at end of file diff --git a/cogs/listener.py b/cogs/listener.py new file mode 100644 index 0000000..13e4e88 --- /dev/null +++ b/cogs/listener.py @@ -0,0 +1,53 @@ +import discord +from discord.ext import commands +import aiohttp +import os +from dotenv import load_dotenv + +load_dotenv() + +class MessageLogger(commands.Cog): + def __init__(self, bot): + self.bot = bot + + # Load webhook URL + self.webhook_url = os.getenv("WEBHOOK_URL") + if not self.webhook_url: + raise ValueError("WEBHOOK_URL environment variable not set.") + + # Load ignored user IDs + ignored_ids_str = os.getenv("IGNORED_USER_IDS", "") + self.ignored_user_ids = [int(uid) for uid in ignored_ids_str.split(",") if uid.strip().isdigit()] + + # Load allowed channel IDs + channel_ids_str = os.getenv("CHANNEL_IDS", "") + self.allowed_channel_ids = [int(cid) for cid in channel_ids_str.split(",") if cid.strip().isdigit()] + + @commands.Cog.listener() + async def on_message(self, message): + if message.author == self.bot.user: + return + + if message.author.id in self.ignored_user_ids: + return + + if message.channel.id not in self.allowed_channel_ids: + return + + embed = { + "title": "Message Log", + "description": message.content, + "color": 3426654, + "timestamp": message.created_at.isoformat(), + "author": { + "name": message.author.name, + "icon_url": message.author.avatar.url if message.author.avatar else "" + }, + "fields": [ + {"name": "Channel", "value": message.channel.mention, "inline": False}, + {"name": "User ID", "value": str(message.author.id), "inline": False} + ] + } + + async with aiohttp.ClientSession() as session: + await session.post(self.webhook_url, json={"embeds": [embed]}) diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..7279eeb --- /dev/null +++ b/config.toml.example @@ -0,0 +1,44 @@ +# Selfbot v2 Configuration + +[metadata] +# Configuration metadata +last_updated = "2025-05-17 12:00:00" +version = "1.0" + +[settings] +# User settings +command_response_type = "edit_invoking" # Options: edit_invoking, send_in_invoking_channel, reply_to_invoking + +# AFK settings +afk_reply_in_server = true +afk_status_message = "AFK!" +afk_set_status = true +afk_custom_reply_message = "Heya, I'm not at my computer right now. If you're requesting something, please follow . I'll let you know when I'm back :) \n\n-# This action was automated." +afk_mention_reply_cooldown = 300 # in seconds + +# Fun settings +fun_auto_horse_enabled = false +fun_horse_reaction = "ud83dudc34" +fun_save_reaction_settings = true + +# API settings +gato_api_url = "https://some-random-api.com/animal/cat" +gato_fallback_url = "https://http.cat/404" + +# Stalker settings +stalker_update_format = "Update on {username}:\n{updates}" +stalker_status_format = "**Status**: `{old_status}` u2192 `{new_status}`" +stalker_activity_started_format = "**Started**: {activities}" +stalker_activity_stopped_format = "**Stopped**: {activities}" +stalker_cooldown = 5 # in seconds + +[value_metadata] +# Individual value metadata + +[value_metadata.afk_status_message] +last_updated = "2025-05-17 12:00:00" +updated_by = "glitchy#1234 (123456789012345678)" + +[value_metadata.command_response_type] +last_updated = "2025-05-17 12:00:00" +updated_by = "system" \ No newline at end of file diff --git a/config_specification.json b/config_specification.json new file mode 100644 index 0000000..fa1d296 --- /dev/null +++ b/config_specification.json @@ -0,0 +1,98 @@ +{ + "command_response_type": { + "default": "edit_invoking", + "types": ["str"], + "description": "Default response method for commands. Options: edit_invoking, send_in_invoking_channel, reply_to_invoking" + }, + + "afk_reply_in_server": { + "default": true, + "types": ["bool"], + "description": "If you get pinged in a server while AFK, should the bot respond then." + }, + "afk_status_message": { + "default": "AFK!", + "types": ["str"], + "description": "What should your status be if you go AFK?" + }, + "afk_set_status": { + "default": true, + "types": ["bool"], + "description": "Whether .afk should overwrite your status." + }, + "afk_custom_reply_message": { + "default": "Heya, I'm not at my computer right now. If you're requesting something, please follow . I'll let you know when I'm back :) \n\n-# This action was automated.", + "types": ["str"], + "description": "The message sent when replying to mentions while AFK." + }, + "afk_mention_reply_cooldown": { + "default": 300, + "types": ["int"], + "description": "Cooldown in seconds before replying to the same user again while AFK." + }, + "fun_auto_horse_enabled": { + "default": false, + "types": ["bool"], + "description": "Whether horse reactions should be enabled by default in all channels." + }, + "fun_horse_reaction": { + "default": "🐴", + "types": ["str"], + "description": "Default emoji to use for horsin." + }, + "fun_save_reaction_settings": { + "default": true, + "types": ["bool"], + "description": "Whether to save reaction settings between bot restarts." + }, + "gato_api_url": { + "default": "https://some-random-api.com/animal/cat", + "types": ["str"], + "description": "API endpoint for fetching cat images." + }, + "gato_fallback_url": { + "default": "https://http.cat/404", + "types": ["str"], + "description": "Fallback URL for when the cat API is unavailable." + }, + "notify_keywords": { + "default": [], + "types": ["list"], + "description": "Comma-separated list of keywords that trigger notifications." + }, + "notification_webhook_url": { + "default": "", + "types": ["str"], + "description": "Webhook URL for sending notifications." + }, + "stalker_update_format": { + "default": "Update on {username}:\n{updates}", + "types": ["str"], + "description": "Format for stalker updates. Use {username}, {mention}, {updates} placeholders." + }, + "stalker_status_format": { + "default": "**Status**: `{old_status}` → `{new_status}`", + "types": ["str"], + "description": "Format for status updates in stalker messages." + }, + "stalker_activity_started_format": { + "default": "**Started**: {activities}", + "types": ["str"], + "description": "Format for new activities in stalker messages." + }, + "stalker_activity_stopped_format": { + "default": "**Stopped**: {activities}", + "types": ["str"], + "description": "Format for stopped activities in stalker messages." + }, + "stalker_cooldown": { + "default": 5, + "types": ["int"], + "description": "Minimum time in seconds between stalker updates for the same user." + }, + "util_not_tracked_users": { + "default": "", + "types": ["str"], + "description": "A list of comma-seperated user ids that should not be tracked by the stalker command." + } +} diff --git a/image.png b/image.png new file mode 100644 index 0000000..caa6fa8 Binary files /dev/null and b/image.png differ diff --git a/log.py b/log.py new file mode 100644 index 0000000..59e0d8f --- /dev/null +++ b/log.py @@ -0,0 +1,36 @@ +import logging +from colorama import Fore, Style, init + +# Initialize colorama for cross-platform color support +init(autoreset=True) + +class ColorFormatter(logging.Formatter): + COLORS = { + logging.DEBUG: Fore.CYAN, + logging.INFO: Fore.GREEN, + logging.WARNING: Fore.YELLOW, + logging.ERROR: Fore.RED, + logging.CRITICAL: Fore.MAGENTA + Style.BRIGHT, + } + + def format(self, record): + color = self.COLORS.get(record.levelno, "") + reset = Style.RESET_ALL + base_message = super().format(record) + return f"{color}{base_message}{reset}" + +# Create your custom logger +logger = logging.getLogger("Selfbot") +logger.setLevel(logging.DEBUG) +logger.propagate = False # Don't leak into root logger + +# Add a stream handler if not already present +if not logger.handlers: + handler = logging.StreamHandler() + handler.setLevel(logging.DEBUG) + formatter = ColorFormatter( + fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%H:%M:%S" + ) + handler.setFormatter(formatter) + logger.addHandler(handler) diff --git a/main.py b/main.py new file mode 100644 index 0000000..7a5074b --- /dev/null +++ b/main.py @@ -0,0 +1,129 @@ +import discord +from discord.ext import commands +import asyncio +import os +from dotenv import load_dotenv +from log import logger +import signal +import argparse + +# Load .env +load_dotenv() +TOKEN = os.getenv("TOKEN") +PREFIX = os.getenv("PREFIX", "! .").split(" ") + +# Set up argument parsing +parser = argparse.ArgumentParser(description="Selfbot for Discord") +parser.add_argument("--useragent", type=str, help="Specify the user agent") +parser.add_argument("--loglevel", type=str, choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], help="Set the logging level") +args = parser.parse_args() + +# Configure logging level based on argument +if args.loglevel: + logger.setLevel(args.loglevel) + +async def load_cogs(bot, directory="cogs"): + tasks = [] + for filename in os.listdir(directory): + if filename.endswith(".py") and not filename.startswith("_"): + ext = f"{directory}.{filename[:-3]}" + task = asyncio.create_task(bot.load_extension(ext)) + task.ext = ext # attach for error reporting + tasks.append(task) + logger.info(f"Prepared to load {ext}") + + for task in tasks: + try: + await task + logger.info(f"Successfully loaded {task.ext}") + except Exception as e: + logger.error(f"Failed to load {task.ext}", exc_info=e) + +class SelfBot(commands.Bot): + def __init__(self): + super().__init__(command_prefix=PREFIX, self_bot=True) + self.logger = logger + self.self_bot = True + self.timeout = 10.0 # Default timeout for commands in seconds + self.config = {} + + async def setup_hook(self): + await load_cogs(self) + logger.info("Cogs loaded, bot is ready") + + async def on_ready(self): + logger.info(f"Logged in as {self.user} (ID: {self.user.id})") + logger.info("------") + await self.change_presence(afk=True) + + async def on_message(self, message): + if message.author != self.user: + return + + await self.process_commands(message) + + def get_config(self, key, default=None): + if not hasattr(self, 'config'): + self.logger.warning(f"Config not available, using default for {key}") + return default + + config_entry = self.config.get(key, None) + return default if config_entry is None else config_entry + +async def shutdown_bot(): + if bot: + logger.info("Unloading all cogs") + try: + await asyncio.gather(*(bot.unload_extension(ext) for ext in list(bot.extensions))) + except Exception as e: + logger.error("Error unloading cogs during shutdown", exc_info=e) + + logger.info("Closing bot connection...") + await bot.close() + +def handle_exit(sig, frame): + logger.info(f"Received signal {sig}, shutting down gracefully...") + loop = asyncio.get_event_loop() + loop.create_task(shutdown_bot()) + +async def main(): + global bot + if not TOKEN: + raise RuntimeError("DISCORD_TOKEN not found in .env file") + + bot = SelfBot() + if getattr(args, "useragent", None): + bot.http.super_properties["browser_user_agent"] = args.useragent + async with bot: + max_retries = 5 + retry_count = 0 + backoff_time = 5 + + while retry_count < max_retries: + try: + await bot.start(TOKEN) + break + except discord.errors.ConnectionClosed as e: + retry_count += 1 + logger.error(f"Connection closed. Retrying ({retry_count}/{max_retries}) in {backoff_time}s: {e}") + if retry_count < max_retries: + await asyncio.sleep(backoff_time) + backoff_time *= 2 + else: + logger.critical("Maximum retries reached. Shutting down.") + except discord.errors.HTTPException as e: + if hasattr(e, "retry_after"): + retry_after = getattr(e, "retry_after", 5) + logger.warning(f"Rate limited. Retrying in {retry_after}s") + await asyncio.sleep(retry_after) + else: + logger.error(f"HTTP Error: {e}") + break + except Exception as e: + logger.error("Unexpected exception during bot start", exc_info=e) + break + +if __name__ == "__main__": + signal.signal(signal.SIGINT, handle_exit) + signal.signal(signal.SIGTERM, handle_exit) + asyncio.run(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..40c7a77 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,32 @@ +aiohappyeyeballs==2.6.1 +aiohttp==3.11.18 +aiosignal==1.3.2 +astroid==3.3.10 +attrs==25.3.0 +audioop-lts==0.2.1 +beautifulsoup4==4.13.4 +colorama==0.4.6 +dill==0.4.0 +discord-protos==0.0.2 +discord.py-self==2.0.1 +dotenv==0.9.9 +frozenlist==1.6.0 +idna==3.10 +iniconfig==2.1.0 +isort==6.0.1 +mccabe==0.7.0 +multidict==6.4.3 +packaging==25.0 +platformdirs==4.3.8 +pluggy==1.5.0 +propcache==0.3.1 +protobuf==4.21.12 +pylint==3.3.7 +pytest==7.4.4 +pytest-asyncio==0.23.5 +pytest-mock==3.12.0 +python-dotenv==1.1.0 +soupsieve==2.7 +tomlkit==0.13.2 +typing_extensions==4.13.2 +yarl==1.20.0 diff --git a/test_toml_config.py b/test_toml_config.py new file mode 100644 index 0000000..5c1d225 --- /dev/null +++ b/test_toml_config.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 + +import tomlkit +import json +import os + +# File paths +JSON_CONFIG_PATH = "config.json" +TOML_CONFIG_PATH = "config.toml" + +def test_read_json(): + """Read the JSON configuration file""" + if not os.path.exists(JSON_CONFIG_PATH): + print(f"\n[u2718] JSON config file {JSON_CONFIG_PATH} not found") + return None + + with open(JSON_CONFIG_PATH, "r") as f: + try: + data = json.load(f) + print(f"\n[u2713] Successfully read JSON config with {len(data)} entries:") + for key, value in data.items(): + print(f" - {key}: {value} ({type(value).__name__})") + return data + except Exception as e: + print(f"\n[u2718] Error parsing JSON config: {e}") + return None + +def test_read_toml(): + """Read the TOML configuration file""" + if not os.path.exists(TOML_CONFIG_PATH): + print(f"\n[✘] TOML config file {TOML_CONFIG_PATH} not found") + return None + + if os.path.getsize(TOML_CONFIG_PATH) == 0: + print(f"\n[✘] TOML config file {TOML_CONFIG_PATH} is empty") + return None + + with open(TOML_CONFIG_PATH, "r") as f: + try: + content = f.read() + print(f"\n[i] Raw TOML content (first 100 chars): {content[:100]}...") + data = tomlkit.parse(content) + + # Check for new structured format with metadata and settings + has_settings = "settings" in data + has_metadata = "metadata" in data + has_value_metadata = "value_metadata" in data + + if has_metadata: + print("\n[i] Global config metadata:") + metadata = data["metadata"] + for key, value in metadata.items(): + print(f" - {key}: {value}") + + # Extract the actual settings + if has_settings: + print("\n[i] Using structured TOML format with [settings] table") + settings = data["settings"] + dict_data = {k: v for k, v in settings.items()} + else: + print("\n[i] Using legacy flat TOML format") + # Exclude metadata if present in flat format + dict_data = {k: v for k, v in data.items() if k != "metadata" and k != "value_metadata"} + + # Show individual value metadata if present + if has_value_metadata: + print("\n[i] Individual value metadata:") + value_metadata = data["value_metadata"] + for key, meta in value_metadata.items(): + print(f" [Value] {key}:") + for meta_key, meta_value in meta.items(): + print(f" - {meta_key}: {meta_value}") + + print(f"\n[✓] Successfully read TOML config with {len(dict_data)} setting entries:") + for key, value in dict_data.items(): + print(f" - {key}: {value} ({type(value).__name__})") + return dict_data + except Exception as e: + print(f"\n[✘] Error parsing TOML config: {e}") + return None + +def test_write_toml(data): + """Write data to TOML format""" + if not data: + print("\n[u2718] No data to write") + return False + + try: + # Create a TOML document with metadata + doc = tomlkit.document() + + # Add metadata section + from datetime import datetime + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + metadata = tomlkit.table() + metadata.add(tomlkit.comment("Configuration metadata")) + metadata["last_updated"] = now + metadata["version"] = "1.0" + metadata["migration_source"] = "test_toml_config.py" + doc["metadata"] = metadata + + # Add settings section + settings = tomlkit.table() + settings.add(tomlkit.comment("User settings")) + for key, value in data.items(): + settings[key] = value + doc["settings"] = settings + + # Add value metadata section + value_metadata = tomlkit.table() + value_metadata.add(tomlkit.comment("Individual value metadata")) + + # Add some sample metadata for a few keys + count = 0 + for key in data.keys(): + if count >= 3: # Only add metadata for first 3 keys as examples + break + + key_meta = tomlkit.table() + key_meta["last_updated"] = now + key_meta["updated_by"] = "test_toml_config.py" + value_metadata[key] = key_meta + count += 1 + + doc["value_metadata"] = value_metadata + + with open(TOML_CONFIG_PATH, "w") as f: + f.write(tomlkit.dumps(doc)) + print(f"\n[u2713] Successfully wrote {len(data)} entries to TOML config with metadata") + return True + except Exception as e: + print(f"\n[u2718] Error writing TOML config: {e}") + return False + +def main(): + print("=== TOML Configuration Test ===\n") + + # Test reading JSON config + json_data = test_read_json() + + # Test reading TOML config + toml_data = test_read_toml() + + # If TOML doesn't exist or is empty but JSON exists, try migration + if json_data and not toml_data: + print("\n[i] Attempting to migrate from JSON to TOML...") + if test_write_toml(json_data): + # Verify the migration + new_toml_data = test_read_toml() + if new_toml_data and len(new_toml_data) == len(json_data): + print("\n[u2713] Migration successful!") + else: + print("\n[u274c] Migration verification failed") + + print("\n=== Test Completed ===") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/time_parser.py b/utils/time_parser.py new file mode 100644 index 0000000..35e53d4 --- /dev/null +++ b/utils/time_parser.py @@ -0,0 +1,40 @@ +import re + +time_regex = re.compile(r'(\d+)\s*([smhd]|seconds?|minutes?|hours?|days?)') + + +def parse_time(time_str): + """ + Parse time strings like "4m2s", "1h30m" into seconds. + + Args: + time_str: String in format like "4m2s", "1h30m", "15m" + + Returns: + Integer of total seconds or None if invalid + """ + units = { + 'second': 1, 'seconds': 1, 's': 1, + 'minute': 60, 'minutes': 60, 'm': 60, + 'hour': 3600, 'hours': 3600, 'h': 3600, + 'day': 86400, 'days': 86400, 'd': 86400, + 'week': 604800, 'weeks': 604800, 'w': 604800 + } + + try: + matches = time_regex.findall(time_str) + if not matches: + return None + + total_seconds = 0 + for amount, unit in matches: + if unit not in units and len(unit) > 0: + unit = unit[0] + + multiplier = units.get(unit.lower(), 1) + total_seconds += int(amount) * multiplier + + return total_seconds if total_seconds > 0 else None + except Exception as e: + print(f"Time parsing error: {e}") + return None \ No newline at end of file