diff options
| author | Fuwn <[email protected]> | 2025-08-27 17:49:43 -0700 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2025-08-27 17:49:43 -0700 |
| commit | 6650dcfee1c8aefedf7c9330566c0bb7ecb1a1d3 (patch) | |
| tree | 0fa54793cc077dc75b5086a2badf00457c6c8b25 | |
| download | umabot-6650dcfee1c8aefedf7c9330566c0bb7ecb1a1d3.tar.xz umabot-6650dcfee1c8aefedf7c9330566c0bb7ecb1a1d3.zip | |
feat: Initial commit
| -rw-r--r-- | .gitignore | 14 | ||||
| -rw-r--r-- | .python-version | 1 | ||||
| -rw-r--r-- | README.md | 226 | ||||
| -rw-r--r-- | env.example | 24 | ||||
| -rw-r--r-- | pyproject.toml | 36 | ||||
| -rw-r--r-- | render.yaml | 30 | ||||
| -rw-r--r-- | requirements-dev.lock | 63 | ||||
| -rw-r--r-- | requirements.lock | 37 | ||||
| -rw-r--r-- | requirements.txt | 4 | ||||
| -rw-r--r-- | src/umabot/__init__.py | 13 | ||||
| -rw-r--r-- | src/umabot/__main__.py | 6 | ||||
| -rw-r--r-- | src/umabot/bot.py | 136 | ||||
| -rw-r--r-- | src/umabot/cli.py | 107 | ||||
| -rw-r--r-- | src/umabot/config.py | 72 | ||||
| -rw-r--r-- | src/umabot/rules/__init__.py | 7 | ||||
| -rw-r--r-- | src/umabot/rules/base.py | 58 | ||||
| -rw-r--r-- | src/umabot/rules/example_rule.py | 56 | ||||
| -rw-r--r-- | src/umabot/rules/roleplay_limiter.py | 60 | ||||
| -rw-r--r-- | src/umabot/rules/spam_detector.py | 63 | ||||
| -rw-r--r-- | tests/__init__.py | 1 | ||||
| -rw-r--r-- | tests/test_config.py | 68 |
21 files changed, 1082 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..339d6aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# python generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# venv +.venv + +# Development +*.log +*.env diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c10780c --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13.1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e2fad8 --- /dev/null +++ b/README.md @@ -0,0 +1,226 @@ +# UmaBot + +A modular Reddit bot for automated post moderation built with Python and PRAW. + +## Features + +- **Spam Detection**: Automatically removes posts from users who post more than 3 times in 24 hours +- **Roleplay Limiter**: Prevents users from posting multiple times with the "Roleplay" flair +- **Modular Design**: Easy to add new moderation rules +- **Configurable Messages**: Customizable removal messages +- **Dry Run Mode**: Test the bot without actually removing posts +- **Comprehensive Logging**: Detailed logs for monitoring and debugging + +## Quick Start + +### 1. Install Dependencies + +```bash +# Using Rye (recommended) +rye sync + +# Or using pip +pip install -r requirements.txt +``` + +### 2. Set Up Reddit API + +1. Go to https://www.reddit.com/prefs/apps +2. Click "Create App" or "Create Another App" +3. Fill in the details: + - **Name**: UmaBot + - **Type**: Script + - **Description**: Reddit moderation bot + - **About URL**: (leave blank) + - **Redirect URI**: http://localhost:8080 +4. Note down the `client_id` (under the app name) and `client_secret` + +### 3. Configure Environment Variables + +Create a `.env` file in the project root: + +```env +# Reddit API Credentials +REDDIT_CLIENT_ID=your_client_id_here +REDDIT_CLIENT_SECRET=your_client_secret_here +REDDIT_USERNAME=your_reddit_username +REDDIT_PASSWORD=your_reddit_password +REDDIT_USER_AGENT=UmaBot/0.1.0 + +# Subreddit Configuration +SUBREDDIT_NAME=your_subreddit_name + +# Bot Messages +SPAM_MESSAGE=Your post has been removed for posting too frequently. Please wait before posting again. +ROLEPLAY_MESSAGE=Your post has been removed. Only one roleplay post is allowed per user. + +# Bot Settings +CHECK_INTERVAL=60 +MAX_POSTS_PER_DAY=3 +DRY_RUN=false +``` + +### 4. Test the Bot + +```bash +# Test Reddit connection +python -m umabot --test + +# Run in dry-run mode (won't actually remove posts) +python -m umabot --dry-run + +# Run the bot normally +python -m umabot +``` + +## Usage + +### Command Line Options + +```bash +python -m umabot [OPTIONS] + +Options: + --verbose, -v Enable verbose logging + --dry-run Run in dry-run mode (don't actually remove posts) + --test Test Reddit connection and exit + --help Show help message +``` + +### Adding New Rules + +The bot is designed to be modular. To add a new rule: + +1. Create a new file in `src/umabot/rules/` +2. Inherit from the `Rule` base class +3. Implement the required methods: + - `should_remove(submission)`: Return `True` if the post should be removed + - `get_removal_message(submission)`: Return the message to post when removing + +Example: + +```python +from .base import Rule + +class MyCustomRule(Rule): + def should_remove(self, submission): + # Your logic here + return False + + def get_removal_message(self, submission): + return "Your post was removed for violating our custom rule." +``` + +4. Add the rule to the bot in `src/umabot/bot.py`: + +```python +from .rules import MyCustomRule + +# In the __init__ method: +self.rules = [ + SpamDetector(config), + RoleplayLimiter(config), + MyCustomRule(config) # Add your new rule +] +``` + +## Deployment + +### Render (Recommended) + +1. Fork or clone this repository +2. Connect your repository to Render +3. Create a new Web Service +4. Configure the environment variables in Render's dashboard +5. Deploy! + +The `render.yaml` file is included for easy deployment. + +### Other Platforms + +The bot can be deployed on any platform that supports Python: + +- **Railway**: Use the `railway.toml` configuration +- **Heroku**: Use the `Procfile` and `requirements.txt` +- **PythonAnywhere**: Upload the code and set environment variables +- **Google Cloud**: Use Cloud Functions or App Engine + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `REDDIT_CLIENT_ID` | Reddit API client ID | Required | +| `REDDIT_CLIENT_SECRET` | Reddit API client secret | Required | +| `REDDIT_USERNAME` | Reddit bot username | Required | +| `REDDIT_PASSWORD` | Reddit bot password | Required | +| `REDDIT_USER_AGENT` | User agent string | `UmaBot/0.1.0` | +| `SUBREDDIT_NAME` | Target subreddit name | Required | +| `SPAM_MESSAGE` | Message for spam removals | Customizable | +| `ROLEPLAY_MESSAGE` | Message for roleplay removals | Customizable | +| `CHECK_INTERVAL` | Seconds between checks | `60` | +| `MAX_POSTS_PER_DAY` | Max posts per user per day | `3` | +| `DRY_RUN` | Enable dry-run mode | `false` | + +## Development + +### Project Structure + +``` +src/umabot/ +├── __init__.py # Main package entry point +├── __main__.py # Module entry point +├── bot.py # Main bot class +├── cli.py # Command-line interface +├── config.py # Configuration management +└── rules/ # Moderation rules + ├── __init__.py + ├── base.py # Base rule class + ├── spam_detector.py + └── roleplay_limiter.py +``` + +### Running Tests + +```bash +# Install dev dependencies +rye sync + +# Run tests +pytest + +# Format code +black src/ + +# Lint code +flake8 src/ +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Support + +If you encounter any issues: + +1. Check the logs in `umabot.log` +2. Verify your Reddit API credentials +3. Ensure the bot has moderator permissions in the subreddit +4. Check that the subreddit name is correct + +## Security Notes + +- Never commit your `.env` file or Reddit credentials +- Use environment variables for all sensitive data +- The bot account should have moderator permissions in the target subreddit +- Consider using Reddit's OAuth2 flow for production deployments diff --git a/env.example b/env.example new file mode 100644 index 0000000..1e19fd4 --- /dev/null +++ b/env.example @@ -0,0 +1,24 @@ +# Reddit API Credentials +# Get these from https://www.reddit.com/prefs/apps +REDDIT_CLIENT_ID=your_client_id_here +REDDIT_CLIENT_SECRET=your_client_secret_here +REDDIT_USERNAME=your_reddit_username +REDDIT_PASSWORD=your_reddit_password +REDDIT_USER_AGENT=UmaBot/0.1.0 + +# Subreddit Configuration +# The subreddit where the bot will operate (without the r/ prefix) +SUBREDDIT_NAME=your_subreddit_name + +# Bot Messages +# Customize these messages as needed +SPAM_MESSAGE=Your post has been removed for posting too frequently. Please wait before posting again. +ROLEPLAY_MESSAGE=Your post has been removed. Only one roleplay post is allowed per user. + +# Bot Settings +# How often to check for new posts (in seconds) +CHECK_INTERVAL=60 +# Maximum number of posts a user can make in 24 hours +MAX_POSTS_PER_DAY=3 +# Set to true to test without actually removing posts +DRY_RUN=false diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6577e62 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "umabot" +version = "0.1.0" +description = "A modular Reddit bot for post moderation" +authors = [ + { name = "Fuwn", email = "[email protected]" } +] +dependencies = [ + "praw>=7.7.0", + "python-dotenv>=1.0.0", + "schedule>=1.2.0", + "loguru>=0.7.0", +] +readme = "README.md" +requires-python = ">= 3.8" + +[project.scripts] +"umabot" = "umabot:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.rye] +managed = true +dev-dependencies = [ + "pytest>=7.0.0", + "black>=23.0.0", + "flake8>=6.0.0", +] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["src/umabot"] diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..437a022 --- /dev/null +++ b/render.yaml @@ -0,0 +1,30 @@ +services: + - type: web + name: umabot + env: python + plan: free + buildCommand: pip install -e . + startCommand: python -m umabot + envVars: + - key: REDDIT_CLIENT_ID + sync: false + - key: REDDIT_CLIENT_SECRET + sync: false + - key: REDDIT_USERNAME + sync: false + - key: REDDIT_PASSWORD + sync: false + - key: SUBREDDIT_NAME + sync: false + - key: REDDIT_USER_AGENT + value: UmaBot/0.1.0 + - key: CHECK_INTERVAL + value: "60" + - key: MAX_POSTS_PER_DAY + value: "3" + - key: DRY_RUN + value: "false" + - key: SPAM_MESSAGE + value: "Your post has been removed for posting too frequently. Please wait before posting again." + - key: ROLEPLAY_MESSAGE + value: "Your post has been removed. Only one roleplay post is allowed per user." diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 0000000..4e9eaa5 --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,63 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: false +# with-sources: false +# generate-hashes: false +# universal: false + +-e file:. +black==25.1.0 +certifi==2025.8.3 + # via requests +charset-normalizer==3.4.3 + # via requests +click==8.2.1 + # via black +flake8==7.3.0 +idna==3.10 + # via requests +iniconfig==2.1.0 + # via pytest +loguru==0.7.3 + # via umabot +mccabe==0.7.0 + # via flake8 +mypy-extensions==1.1.0 + # via black +packaging==25.0 + # via black + # via pytest +pathspec==0.12.1 + # via black +platformdirs==4.4.0 + # via black +pluggy==1.6.0 + # via pytest +praw==7.8.1 + # via umabot +prawcore==2.4.0 + # via praw +pycodestyle==2.14.0 + # via flake8 +pyflakes==3.4.0 + # via flake8 +pygments==2.19.2 + # via pytest +pytest==8.4.1 +python-dotenv==1.1.1 + # via umabot +requests==2.32.5 + # via prawcore + # via update-checker +schedule==1.2.2 + # via umabot +update-checker==0.18.0 + # via praw +urllib3==2.5.0 + # via requests +websocket-client==1.8.0 + # via praw diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..aa3738c --- /dev/null +++ b/requirements.lock @@ -0,0 +1,37 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: false +# with-sources: false +# generate-hashes: false +# universal: false + +-e file:. +certifi==2025.8.3 + # via requests +charset-normalizer==3.4.3 + # via requests +idna==3.10 + # via requests +loguru==0.7.3 + # via umabot +praw==7.8.1 + # via umabot +prawcore==2.4.0 + # via praw +python-dotenv==1.1.1 + # via umabot +requests==2.32.5 + # via prawcore + # via update-checker +schedule==1.2.2 + # via umabot +update-checker==0.18.0 + # via praw +urllib3==2.5.0 + # via requests +websocket-client==1.8.0 + # via praw diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2ea9c0c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +praw>=7.7.0 +python-dotenv>=1.0.0 +schedule>=1.2.0 +loguru>=0.7.0 diff --git a/src/umabot/__init__.py b/src/umabot/__init__.py new file mode 100644 index 0000000..1b6b859 --- /dev/null +++ b/src/umabot/__init__.py @@ -0,0 +1,13 @@ +"""UmaBot - A modular Reddit bot for post moderation.""" + +from .bot import UmaBot +from .config import Config + +__version__ = "0.1.0" +__all__ = ["UmaBot", "Config"] + + +def main(): + """Main entry point for the bot.""" + from .cli import run_bot + run_bot() diff --git a/src/umabot/__main__.py b/src/umabot/__main__.py new file mode 100644 index 0000000..61daa90 --- /dev/null +++ b/src/umabot/__main__.py @@ -0,0 +1,6 @@ +"""Main module entry point for UmaBot.""" + +from .cli import run_bot + +if __name__ == "__main__": + run_bot() diff --git a/src/umabot/bot.py b/src/umabot/bot.py new file mode 100644 index 0000000..3db29e9 --- /dev/null +++ b/src/umabot/bot.py @@ -0,0 +1,136 @@ +"""Main bot class for UmaBot.""" + +import time +import praw +from typing import List +from loguru import logger + +from .config import Config +from .rules import SpamDetector, RoleplayLimiter + + +class UmaBot: + """Main Reddit bot class.""" + + def __init__(self, config: Config): + """Initialize the bot with configuration.""" + self.config = config + self.logger = logger.bind(bot="UmaBot") + + # Initialize Reddit client + self.reddit = praw.Reddit( + client_id=config.client_id, + client_secret=config.client_secret, + username=config.username, + password=config.password, + user_agent=config.user_agent + ) + + # Get subreddit + self.subreddit = self.reddit.subreddit(config.subreddit_name) + + # Initialize rules + self.rules = [ + SpamDetector(config), + RoleplayLimiter(config) + ] + + # Track processed submissions to avoid processing old posts + self.processed_submissions = set() + self.initialized = False + + self.logger.info(f"Bot initialized for r/{config.subreddit_name}") + + def run(self): + """Run the bot continuously.""" + self.logger.info("Starting UmaBot...") + + try: + while True: + self._check_new_posts() + time.sleep(self.config.check_interval) + + except KeyboardInterrupt: + self.logger.info("Bot stopped by user") + except Exception as e: + self.logger.error(f"Bot crashed: {e}") + raise + + def _check_new_posts(self): + """Check for new posts and apply rules.""" + try: + # Get new submissions + new_submissions = list(self.subreddit.new(limit=25)) + + if not new_submissions: + self.logger.debug("No new submissions found") + return + + # On first run, just populate the processed set without processing + if not self.initialized: + self.logger.info("Initializing bot - marking existing posts as processed") + for submission in new_submissions: + self.processed_submissions.add(submission.id) + self.initialized = True + self.logger.info(f"Bot initialized with {len(self.processed_submissions)} existing posts marked as processed") + return + + # Filter out already processed submissions + truly_new_submissions = [] + for submission in new_submissions: + if submission.id not in self.processed_submissions: + truly_new_submissions.append(submission) + self.processed_submissions.add(submission.id) + + if not truly_new_submissions: + self.logger.debug("No truly new submissions found") + return + + self.logger.info(f"Processing {len(truly_new_submissions)} new submissions") + + # Apply rules to each new submission + for submission in truly_new_submissions: + self._apply_rules(submission) + + # Clean up old processed submissions periodically + self._cleanup_processed_submissions() + + except Exception as e: + self.logger.error(f"Error checking new posts: {e}") + + def _apply_rules(self, submission): + """Apply all rules to a submission.""" + try: + # Apply each rule + for rule in self.rules: + if rule.execute(submission): + # If a rule removes the submission, stop applying other rules + break + + except Exception as e: + self.logger.error(f"Error applying rules to {submission.id}: {e}") + + def add_rule(self, rule): + """Add a new rule to the bot.""" + self.rules.append(rule) + self.logger.info(f"Added new rule: {rule.__class__.__name__}") + + def test_connection(self): + """Test the Reddit API connection.""" + try: + # Try to access the subreddit + subreddit_name = self.subreddit.display_name + self.logger.info(f"Successfully connected to r/{subreddit_name}") + return True + except Exception as e: + self.logger.error(f"Failed to connect to Reddit: {e}") + return False + + def _cleanup_processed_submissions(self): + """Clean up old processed submission IDs to prevent memory bloat.""" + # Keep only the last 1000 processed submissions + if len(self.processed_submissions) > 1000: + # Convert to list, keep last 1000, convert back to set + submissions_list = list(self.processed_submissions) + self.processed_submissions = set(submissions_list[-1000:]) + self.logger.debug(f"Cleaned up processed submissions, keeping {len(self.processed_submissions)} most recent") diff --git a/src/umabot/cli.py b/src/umabot/cli.py new file mode 100644 index 0000000..29999ec --- /dev/null +++ b/src/umabot/cli.py @@ -0,0 +1,107 @@ +"""Command-line interface for UmaBot.""" + +import sys +import argparse +from loguru import logger + +from .config import Config +from .bot import UmaBot + + +def setup_logging(verbose: bool = False): + """Setup logging configuration.""" + log_level = "DEBUG" if verbose else "INFO" + + logger.remove() # Remove default handler + logger.add( + sys.stderr, + level=log_level, + format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>" + ) + + # Also log to file + logger.add( + "umabot.log", + rotation="1 day", + retention="7 days", + level=log_level, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}" + ) + + +def run_bot(): + """Main entry point for the bot.""" + parser = argparse.ArgumentParser( + description="UmaBot - A modular Reddit bot for post moderation" + ) + + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Enable verbose logging" + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Run in dry-run mode (don't actually remove posts)" + ) + + parser.add_argument( + "--test", + action="store_true", + help="Test Reddit connection and exit" + ) + + args = parser.parse_args() + + # Setup logging + setup_logging(args.verbose) + + try: + # Load configuration + config = Config.from_env() + + # Override dry-run if specified + if args.dry_run: + config.dry_run = True + + # Validate configuration + config.validate() + + # Create bot + bot = UmaBot(config) + + # Test connection if requested + if args.test: + if bot.test_connection(): + logger.info("Connection test successful!") + return 0 + else: + logger.error("Connection test failed!") + return 1 + + # Run the bot + bot.run() + + except ValueError as e: + logger.error(f"Configuration error: {e}") + logger.info("Please check your environment variables:") + logger.info(" REDDIT_CLIENT_ID") + logger.info(" REDDIT_CLIENT_SECRET") + logger.info(" REDDIT_USERNAME") + logger.info(" REDDIT_PASSWORD") + logger.info(" SUBREDDIT_NAME") + return 1 + + except KeyboardInterrupt: + logger.info("Bot stopped by user") + return 0 + + except Exception as e: + logger.error(f"Unexpected error: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(run_bot()) diff --git a/src/umabot/config.py b/src/umabot/config.py new file mode 100644 index 0000000..93ebf54 --- /dev/null +++ b/src/umabot/config.py @@ -0,0 +1,72 @@ +"""Configuration management for UmaBot.""" + +import os +from dataclasses import dataclass +from typing import Optional +from dotenv import load_dotenv + +load_dotenv() + + +@dataclass +class Config: + """Configuration for the Reddit bot.""" + + # Reddit API credentials + client_id: str + client_secret: str + username: str + password: str + user_agent: str + + # Subreddit configuration + subreddit_name: str + + # Bot messages + spam_message: str + roleplay_message: str + + # Bot settings + check_interval: int = 60 # seconds + max_posts_per_day: int = 3 + dry_run: bool = False + + @classmethod + def from_env(cls) -> "Config": + """Create configuration from environment variables.""" + return cls( + client_id=os.getenv("REDDIT_CLIENT_ID", ""), + client_secret=os.getenv("REDDIT_CLIENT_SECRET", ""), + username=os.getenv("REDDIT_USERNAME", ""), + password=os.getenv("REDDIT_PASSWORD", ""), + user_agent=os.getenv("REDDIT_USER_AGENT", "UmaBot/0.1.0"), + subreddit_name=os.getenv("SUBREDDIT_NAME", ""), + spam_message=os.getenv( + "SPAM_MESSAGE", + "Your post has been removed for posting too frequently. Please wait before posting again." + ), + roleplay_message=os.getenv( + "ROLEPLAY_MESSAGE", + "Your post has been removed. Only one roleplay post is allowed per user." + ), + check_interval=int(os.getenv("CHECK_INTERVAL", "60")), + max_posts_per_day=int(os.getenv("MAX_POSTS_PER_DAY", "3")), + dry_run=os.getenv("DRY_RUN", "false").lower() == "true", + ) + + def validate(self) -> None: + """Validate that all required configuration is present.""" + required_fields = [ + "client_id", "client_secret", "username", + "password", "subreddit_name" + ] + + missing_fields = [] + for field in required_fields: + if not getattr(self, field): + missing_fields.append(field) + + if missing_fields: + raise ValueError( + f"Missing required configuration: {', '.join(missing_fields)}" + ) diff --git a/src/umabot/rules/__init__.py b/src/umabot/rules/__init__.py new file mode 100644 index 0000000..e912e70 --- /dev/null +++ b/src/umabot/rules/__init__.py @@ -0,0 +1,7 @@ +"""Moderation rules for UmaBot.""" + +from .base import Rule +from .spam_detector import SpamDetector +from .roleplay_limiter import RoleplayLimiter + +__all__ = ["Rule", "SpamDetector", "RoleplayLimiter"] diff --git a/src/umabot/rules/base.py b/src/umabot/rules/base.py new file mode 100644 index 0000000..0318aaa --- /dev/null +++ b/src/umabot/rules/base.py @@ -0,0 +1,58 @@ +"""Base rule class for moderation rules.""" + +from abc import ABC, abstractmethod +from typing import List, Optional +import praw.models +from loguru import logger + + +class Rule(ABC): + """Base class for all moderation rules.""" + + def __init__(self, config): + """Initialize the rule with configuration.""" + self.config = config + self.logger = logger.bind(rule=self.__class__.__name__) + + @abstractmethod + def should_remove(self, submission: praw.models.Submission) -> bool: + """Determine if a submission should be removed.""" + pass + + @abstractmethod + def get_removal_message(self, submission: praw.models.Submission) -> str: + """Get the message to post when removing a submission.""" + pass + + def execute(self, submission: praw.models.Submission) -> bool: + """Execute the rule on a submission. + + Returns: + bool: True if the submission was removed, False otherwise. + """ + if not self.should_remove(submission): + return False + + removal_message = self.get_removal_message(submission) + + if self.config.dry_run: + self.logger.info( + f"[DRY RUN] Would remove submission {submission.id} by {submission.author}" + ) + self.logger.info(f"[DRY RUN] Would post message: {removal_message}") + return True + + try: + # Remove the submission + submission.mod.remove() + self.logger.info(f"Removed submission {submission.id} by {submission.author}") + + # Post removal message + submission.reply(removal_message) + self.logger.info(f"Posted removal message for {submission.id}") + + return True + + except Exception as e: + self.logger.error(f"Error executing rule on {submission.id}: {e}") + return False diff --git a/src/umabot/rules/example_rule.py b/src/umabot/rules/example_rule.py new file mode 100644 index 0000000..68957b4 --- /dev/null +++ b/src/umabot/rules/example_rule.py @@ -0,0 +1,56 @@ +"""Example rule demonstrating how to create custom moderation rules.""" + +import praw.models +from .base import Rule + + +class ExampleRule(Rule): + """Example rule that demonstrates the modular rule system. + + This rule shows how to create a custom rule that: + - Checks for specific keywords in post titles + - Removes posts containing banned words + - Posts a custom removal message + """ + + def __init__(self, config): + """Initialize the example rule.""" + super().__init__(config) + # Define banned words (customize as needed) + self.banned_words = ["spam", "advertisement", "buy now"] + + def should_remove(self, submission: praw.models.Submission) -> bool: + """Check if a submission contains banned words in the title.""" + if not submission.title: + return False + + title_lower = submission.title.lower() + + # Check if any banned words are in the title + for word in self.banned_words: + if word in title_lower: + self.logger.info( + f"Found banned word '{word}' in submission {submission.id}" + ) + return True + + return False + + def get_removal_message(self, submission: praw.models.Submission) -> str: + """Get the removal message for posts with banned words.""" + return ( + "Your post has been removed because it contains inappropriate content. " + "Please review our community guidelines before posting again." + ) + + +# To use this rule, add it to the bot in src/umabot/bot.py: +# +# from .rules import ExampleRule +# +# # In the __init__ method: +# self.rules = [ +# SpamDetector(config), +# RoleplayLimiter(config), +# ExampleRule(config) # Add the example rule +# ] diff --git a/src/umabot/rules/roleplay_limiter.py b/src/umabot/rules/roleplay_limiter.py new file mode 100644 index 0000000..2b88079 --- /dev/null +++ b/src/umabot/rules/roleplay_limiter.py @@ -0,0 +1,60 @@ +"""Roleplay post limiter rule.""" + +from typing import Set +import praw.models +from .base import Rule + + +class RoleplayLimiter(Rule): + """Limits users to one roleplay post.""" + + def __init__(self, config): + """Initialize the roleplay limiter.""" + super().__init__(config) + self.roleplay_users: Set[str] = set() + self.roleplay_flair = "Roleplay" + + def should_remove(self, submission: praw.models.Submission) -> bool: + """Check if a user has already posted a roleplay post.""" + if not submission.author: + return False + + # Check if this is a roleplay post + if not self._is_roleplay_post(submission): + return False + + username = submission.author.name + + # Check if user has already posted a roleplay post + if username in self.roleplay_users: + self.logger.info( + f"User {username} has already posted a roleplay post" + ) + return True + + # Add user to the set of roleplay posters + self.roleplay_users.add(username) + return False + + def get_removal_message(self, submission: praw.models.Submission) -> str: + """Get the roleplay removal message.""" + return self.config.roleplay_message + + def _is_roleplay_post(self, submission: praw.models.Submission) -> bool: + """Check if a submission has the roleplay flair.""" + try: + # Check link flair text + if hasattr(submission, 'link_flair_text') and submission.link_flair_text: + return submission.link_flair_text.lower() == self.roleplay_flair.lower() + + # Check flair template ID (if using new flair system) + if hasattr(submission, 'link_flair_template_id') and submission.link_flair_template_id: + # You might need to map flair template IDs to names + # For now, we'll just check the text + pass + + return False + + except Exception as e: + self.logger.error(f"Error checking flair for submission {submission.id}: {e}") + return False diff --git a/src/umabot/rules/spam_detector.py b/src/umabot/rules/spam_detector.py new file mode 100644 index 0000000..6f6da34 --- /dev/null +++ b/src/umabot/rules/spam_detector.py @@ -0,0 +1,63 @@ +"""Spam detection rule for limiting posts per user per day.""" + +import time +from datetime import datetime, timedelta +from typing import Dict, List +import praw.models +from .base import Rule + + +class SpamDetector(Rule): + """Detects and removes posts from users who post too frequently.""" + + def __init__(self, config): + """Initialize the spam detector.""" + super().__init__(config) + self.user_posts: Dict[str, List[float]] = {} + self.max_posts = config.max_posts_per_day + self.time_window = 24 * 60 * 60 # 24 hours in seconds + + def should_remove(self, submission: praw.models.Submission) -> bool: + """Check if a user has posted too frequently.""" + if not submission.author: + return False + + username = submission.author.name + current_time = time.time() + + # Clean old posts from tracking + self._clean_old_posts(username, current_time) + + # Count current posts in the time window + if username not in self.user_posts: + self.user_posts[username] = [] + + post_count = len(self.user_posts[username]) + + # Add current post to tracking + self.user_posts[username].append(current_time) + + # Check if this post exceeds the limit + if post_count >= self.max_posts: + self.logger.info( + f"User {username} has posted {post_count + 1} times in 24 hours " + f"(limit: {self.max_posts})" + ) + return True + + return False + + def get_removal_message(self, submission: praw.models.Submission) -> str: + """Get the spam removal message.""" + return self.config.spam_message + + def _clean_old_posts(self, username: str, current_time: float) -> None: + """Remove posts older than the time window from tracking.""" + if username not in self.user_posts: + return + + cutoff_time = current_time - self.time_window + self.user_posts[username] = [ + post_time for post_time in self.user_posts[username] + if post_time > cutoff_time + ] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..470a85f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for UmaBot.""" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..30ccda7 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,68 @@ +"""Tests for the configuration module.""" + +import os +import pytest +from unittest.mock import patch + +from umabot.config import Config + + +def test_config_from_env(): + """Test configuration loading from environment variables.""" + test_env = { + "REDDIT_CLIENT_ID": "test_client_id", + "REDDIT_CLIENT_SECRET": "test_client_secret", + "REDDIT_USERNAME": "test_username", + "REDDIT_PASSWORD": "test_password", + "SUBREDDIT_NAME": "test_subreddit", + "SPAM_MESSAGE": "Test spam message", + "ROLEPLAY_MESSAGE": "Test roleplay message", + } + + with patch.dict(os.environ, test_env): + config = Config.from_env() + + assert config.client_id == "test_client_id" + assert config.client_secret == "test_client_secret" + assert config.username == "test_username" + assert config.password == "test_password" + assert config.subreddit_name == "test_subreddit" + assert config.spam_message == "Test spam message" + assert config.roleplay_message == "Test roleplay message" + assert config.check_interval == 60 + assert config.max_posts_per_day == 3 + assert config.dry_run is False + + +def test_config_validation(): + """Test configuration validation.""" + config = Config( + client_id="", + client_secret="", + username="", + password="", + user_agent="test", + subreddit_name="", + spam_message="", + roleplay_message="" + ) + + with pytest.raises(ValueError, match="Missing required configuration"): + config.validate() + + +def test_config_validation_success(): + """Test successful configuration validation.""" + config = Config( + client_id="test", + client_secret="test", + username="test", + password="test", + user_agent="test", + subreddit_name="test", + spam_message="test", + roleplay_message="test" + ) + + # Should not raise an exception + config.validate() |