aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorFuwn <[email protected]>2025-09-17 19:28:59 -0700
committerFuwn <[email protected]>2025-09-17 19:28:59 -0700
commit4eb16c473424f6844b1bb2cdc5d3cb5aff916d12 (patch)
tree116f76ea0627536bd7ce3d39e10aa7615ec33549 /src
parentrefactor(media_required): Rename to roleplay_media_required (diff)
downloadumabot-4eb16c473424f6844b1bb2cdc5d3cb5aff916d12.tar.xz
umabot-4eb16c473424f6844b1bb2cdc5d3cb5aff916d12.zip
feat(rules): Add intelligent roleplay moderator rule
Diffstat (limited to 'src')
-rw-r--r--src/umabot/bot.py9
-rw-r--r--src/umabot/config.py6
-rw-r--r--src/umabot/rules/__init__.py3
-rw-r--r--src/umabot/rules/intelligent_roleplay_moderator.py351
4 files changed, 363 insertions, 6 deletions
diff --git a/src/umabot/bot.py b/src/umabot/bot.py
index 87068a5..510ca7a 100644
--- a/src/umabot/bot.py
+++ b/src/umabot/bot.py
@@ -9,7 +9,7 @@ from socketserver import ThreadingMixIn
from loguru import logger
from .config import Config
-from .rules import SpamDetector, RoleplayLimiter, RoleplayMediaRequiredRule, RoleplayWordCountRule
+from .rules import SpamDetector, IntelligentRoleplayModerator
# from .rules import StaticRoleplayLimiter # Disabled by default - uncomment to use static limiting
@@ -76,9 +76,10 @@ class UmaBot:
# Initialize rules
self.rules = [
SpamDetector(config),
- RoleplayMediaRequiredRule(config), # Requires media for roleplay posts
- RoleplayWordCountRule(config), # Sends short roleplay posts to mod queue
- RoleplayLimiter(config, self.subreddit) # Surge-based roleplay limiter (default)
+ IntelligentRoleplayModerator(config, self.subreddit), # Intelligent roleplay moderation
+ # RoleplayMediaRequiredRule(config), # Disabled - using intelligent moderator
+ # RoleplayWordCountRule(config), # Disabled - using intelligent moderator
+ # RoleplayLimiter(config, self.subreddit) # Disabled - using intelligent moderator
# StaticRoleplayLimiter(config) # Uncomment to use static roleplay limiting instead
]
diff --git a/src/umabot/config.py b/src/umabot/config.py
index f49f936..ab03a25 100644
--- a/src/umabot/config.py
+++ b/src/umabot/config.py
@@ -19,6 +19,9 @@ class Config:
password: str
user_agent: str
+ # OpenAI API credentials
+ openai_api_key: str
+
# Subreddit configuration
subreddit_name: str
@@ -42,6 +45,7 @@ class Config:
username=os.getenv("REDDIT_USERNAME", ""),
password=os.getenv("REDDIT_PASSWORD", ""),
user_agent=os.getenv("REDDIT_USER_AGENT", "UmaBot/0.1.0"),
+ openai_api_key=os.getenv("OPENAI_API_KEY", ""),
subreddit_name=os.getenv("SUBREDDIT_NAME", ""),
roleplay_message=os.getenv(
"ROLEPLAY_MESSAGE",
@@ -59,7 +63,7 @@ class Config:
"""Validate that all required configuration is present."""
required_fields = [
"client_id", "client_secret", "username",
- "password", "subreddit_name"
+ "password", "subreddit_name", "openai_api_key"
]
missing_fields = []
diff --git a/src/umabot/rules/__init__.py b/src/umabot/rules/__init__.py
index 73f84ec..01827ce 100644
--- a/src/umabot/rules/__init__.py
+++ b/src/umabot/rules/__init__.py
@@ -5,5 +5,6 @@ from .spam_detector import SpamDetector
from .roleplay_limiter import RoleplayLimiter, StaticRoleplayLimiter
from .roleplay_media_required import RoleplayMediaRequiredRule
from .roleplay_word_count import RoleplayWordCountRule
+from .intelligent_roleplay_moderator import IntelligentRoleplayModerator
-__all__ = ["Rule", "SpamDetector", "RoleplayLimiter", "StaticRoleplayLimiter", "RoleplayMediaRequiredRule", "RoleplayWordCountRule"]
+__all__ = ["Rule", "SpamDetector", "RoleplayLimiter", "StaticRoleplayLimiter", "RoleplayMediaRequiredRule", "RoleplayWordCountRule", "IntelligentRoleplayModerator"]
diff --git a/src/umabot/rules/intelligent_roleplay_moderator.py b/src/umabot/rules/intelligent_roleplay_moderator.py
new file mode 100644
index 0000000..7343df5
--- /dev/null
+++ b/src/umabot/rules/intelligent_roleplay_moderator.py
@@ -0,0 +1,351 @@
+"""Intelligent roleplay moderator using GPT-5-nano."""
+
+import json
+import praw.models
+from openai import OpenAI
+from .base import Rule
+
+
+class IntelligentRoleplayModerator(Rule):
+ """Intelligent roleplay moderator that evaluates post quality and appropriateness."""
+
+ def __init__(self, config, subreddit):
+ """Initialize the intelligent roleplay moderator."""
+ super().__init__(config)
+ self.subreddit = subreddit
+ self.roleplay_flair_template_id = "311f0024-8302-11f0-9b41-46c005ad843c"
+ self.art_flair_template_id = "dd4589ba-7c43-11f0-8ef0-22eefb7f5b84"
+
+ # Initialize OpenAI client
+ self.openai_client = OpenAI(api_key=config.openai_api_key)
+
+ # Evaluation prompt
+ self.evaluation_prompt = """
+You are an expert moderator for a roleplay subreddit. Your job is to evaluate roleplay posts and determine:
+
+1. Whether this post would be better flaired as "Art" instead of "Roleplay"
+2. Whether this is low-effort content that should be removed
+
+For each post, respond with a JSON object containing:
+{{
+ "should_be_art": boolean,
+ "is_low_effort": boolean,
+ "confidence": float (0.0 to 1.0),
+ "reasoning": "Brief explanation of your decision"
+}}
+
+Guidelines:
+- A post should be flaired as "Art" if it's primarily showcasing artwork, images, or visual content with minimal roleplay text
+- A post is "low effort" if it lacks substance, creativity, or meaningful roleplay content
+- Consider factors like: word count, creativity, effort, engagement potential, originality
+- Be strict but fair - err on the side of allowing content unless it's clearly low quality
+- High confidence (0.8+) for clear cases, lower confidence for borderline cases
+
+Post to evaluate:
+Title: {title}
+Content: {content}
+Has Media: {has_media}
+Media Type: {media_type}
+Word Count: {word_count}
+"""
+
+ def should_remove(self, submission: praw.models.Submission) -> bool:
+ """Evaluate a roleplay post and take appropriate action."""
+ if not submission.author:
+ return False
+
+ # Check if this is a roleplay post
+ if not self._is_roleplay_post(submission):
+ return False
+
+ try:
+ # Evaluate the post using GPT-5-nano
+ evaluation = self._evaluate_post(submission)
+
+ if evaluation["should_be_art"]:
+ # Change flair to Art and notify user
+ self._change_flair_to_art(submission)
+ self._notify_art_flair_change(submission, evaluation)
+ return False # Don't remove, just change flair
+
+ elif evaluation["is_low_effort"]:
+ # Remove low effort post and notify user
+ self._notify_low_effort_removal(submission, evaluation)
+ return True # Remove the post
+
+ # Post is good quality roleplay - allow it
+ return False
+
+ except Exception as e:
+ self.logger.error(f"Error evaluating roleplay post {submission.id}: {e}")
+ return False # Don't remove on error
+
+ def get_removal_message(self, submission: praw.models.Submission) -> str:
+ """Get removal message for low effort posts."""
+ return "" # We send mod mail instead of commenting
+
+ def _evaluate_post(self, submission: praw.models.Submission) -> dict:
+ """Use GPT-5-nano to evaluate the post with retry logic."""
+ max_retries = 3
+ for attempt in range(max_retries):
+ try:
+ self.logger.info(f"Attempt {attempt + 1}/{max_retries} for submission {submission.id}")
+ return self._evaluate_post_attempt(submission)
+ except Exception as e:
+ self.logger.warning(f"Attempt {attempt + 1} failed for {submission.id}: {e}")
+ if attempt == max_retries - 1:
+ # Last attempt failed, return default evaluation
+ self.logger.error(f"All {max_retries} attempts failed for {submission.id}, returning default evaluation")
+ return {
+ "should_be_art": False,
+ "is_low_effort": False,
+ "confidence": 0.0,
+ "reasoning": f"Failed after {max_retries} attempts: {e}"
+ }
+ else:
+ self.logger.info(f"Retrying in 1 second...")
+ import time
+ time.sleep(1)
+
+ # This should never be reached, but just in case
+ return {
+ "should_be_art": False,
+ "is_low_effort": False,
+ "confidence": 0.0,
+ "reasoning": "Unexpected error in retry logic"
+ }
+
+ def _evaluate_post_attempt(self, submission: praw.models.Submission) -> dict:
+ """Single attempt to evaluate the post using GPT-5-nano."""
+ try:
+ # Extract post information
+ title = submission.title or ""
+ content = submission.selftext or ""
+ has_media = self._has_media(submission)
+ media_type = self._get_media_type(submission)
+ word_count = len(content.split()) if content else 0
+
+ # Prepare the prompt
+ prompt = self.evaluation_prompt.format(
+ title=title,
+ content=content,
+ has_media=has_media,
+ media_type=media_type,
+ word_count=word_count
+ )
+
+ # Call GPT-5-nano
+ response = self.openai_client.chat.completions.create(
+ model="gpt-5-nano",
+ messages=[
+ {"role": "system", "content": "You are an expert roleplay moderator. Always respond with valid JSON only. Do not include any text before or after the JSON object."},
+ {"role": "user", "content": prompt}
+ ],
+ max_completion_tokens=2000
+ )
+
+ # Parse the response
+ response_text = response.choices[0].message.content.strip()
+
+ # Clean up the response text to handle common JSON formatting issues
+ response_text = response_text.replace('\n', '').replace('\r', '')
+
+ # Try to extract JSON from the response if it's embedded in other text
+ if '```json' in response_text:
+ # Extract JSON from code blocks
+ start = response_text.find('```json') + 7
+ end = response_text.find('```', start)
+ if end != -1:
+ response_text = response_text[start:end].strip()
+ elif '```' in response_text:
+ # Extract JSON from generic code blocks
+ start = response_text.find('```') + 3
+ end = response_text.find('```', start)
+ if end != -1:
+ response_text = response_text[start:end].strip()
+
+ # Find JSON object boundaries
+ if '{' in response_text and '}' in response_text:
+ start = response_text.find('{')
+ end = response_text.rfind('}') + 1
+ response_text = response_text[start:end]
+
+ evaluation = json.loads(response_text)
+
+ self.logger.info(f"GPT-5-nano evaluation for {submission.id}: {evaluation}")
+ return evaluation
+
+ except json.JSONDecodeError as e:
+ self.logger.error(f"JSON parsing error for {submission.id}: {e}")
+ self.logger.error(f"Raw response: {response_text}")
+ # Return default evaluation on JSON error
+ return {
+ "should_be_art": False,
+ "is_low_effort": False,
+ "confidence": 0.0,
+ "reasoning": f"JSON parsing error: {e}"
+ }
+ except Exception as e:
+ self.logger.error(f"Error calling GPT-5-nano for {submission.id}: {e}")
+ # Return default evaluation on error
+ return {
+ "should_be_art": False,
+ "is_low_effort": False,
+ "confidence": 0.0,
+ "reasoning": f"Error occurred during evaluation: {e}"
+ }
+
+ def _change_flair_to_art(self, submission: praw.models.Submission) -> None:
+ """Change the post flair to Art."""
+ try:
+ submission.mod.flair(
+ text="Art",
+ flair_template_id=self.art_flair_template_id
+ )
+ self.logger.info(f"Changed flair to Art for post {submission.id}")
+ except Exception as e:
+ self.logger.error(f"Error changing flair for {submission.id}: {e}")
+
+ def _notify_art_flair_change(self, submission: praw.models.Submission, evaluation: dict) -> None:
+ """Send mod mail about flair change."""
+ try:
+ username = submission.author.name
+ subject = "Your post flair has been changed to Art"
+
+ message = f"""Hello u/{username},
+
+Your roleplay post has been automatically re-flaired as "Art" because it appears to be primarily showcasing artwork or visual content rather than roleplay.
+
+Reasoning: {evaluation['reasoning']}
+
+Post link: https://reddit.com{submission.permalink}
+
+If you believe this was done in error, please contact the moderators via Mod Mail.
+
+Thank you for understanding!"""
+
+ submission.author.message(subject, message)
+ self.logger.info(f"Sent art flair change notification to {username}")
+
+ except Exception as e:
+ self.logger.error(f"Error sending art flair notification for {submission.id}: {e}")
+
+ def _notify_low_effort_removal(self, submission: praw.models.Submission, evaluation: dict) -> None:
+ """Send mod mail about low effort removal."""
+ try:
+ username = submission.author.name
+ subject = "Your roleplay post has been removed for low effort"
+
+ message = f"""Hello u/{username},
+
+Your roleplay post has been removed because it was determined to be low-effort content.
+
+Reasoning: {evaluation['reasoning']}
+
+Post link: https://reddit.com{submission.permalink}
+
+To improve your roleplay posts, consider:
+- Adding more detailed descriptions
+- Creating engaging scenarios
+- Including meaningful character interactions
+- Ensuring your content adds value to the community
+
+If you believe this was done in error, please contact the moderators via Mod Mail.
+
+Thank you for understanding!"""
+
+ submission.author.message(subject, message)
+ self.logger.info(f"Sent low effort removal notification to {username}")
+
+ except Exception as e:
+ self.logger.error(f"Error sending low effort notification for {submission.id}: {e}")
+
+ def _is_roleplay_post(self, submission: praw.models.Submission) -> bool:
+ """Check if a submission has the roleplay flair."""
+ try:
+ # Check link flair template ID first (most reliable)
+ if hasattr(submission, 'link_flair_template_id') and submission.link_flair_template_id:
+ return submission.link_flair_template_id == self.roleplay_flair_template_id
+
+ # Fallback to flair text
+ if hasattr(submission, 'link_flair_text') and submission.link_flair_text:
+ return submission.link_flair_text.lower() == "roleplay"
+
+ return False
+
+ except Exception as e:
+ self.logger.error(f"Error checking flair for submission {submission.id}: {e}")
+ return False
+
+ def _has_media(self, submission: praw.models.Submission) -> bool:
+ """Check if a submission has media attached."""
+ try:
+ # Check for image/video posts
+ if submission.is_video or submission.is_self:
+ # For self posts, check if they contain media links
+ if submission.is_self and submission.selftext:
+ # Look for common media URLs in the text
+ media_indicators = [
+ 'imgur.com', 'i.imgur.com', 'redd.it', 'i.redd.it',
+ 'youtube.com', 'youtu.be', 'vimeo.com', 'gfycat.com',
+ 'streamable.com', 'twitter.com', 'x.com', 'tiktok.com',
+ 'instagram.com', 'facebook.com', 'discord.com', 'discordapp.com'
+ ]
+
+ text_lower = submission.selftext.lower()
+ return any(indicator in text_lower for indicator in media_indicators)
+ return False
+
+ # Check for link posts with media
+ if hasattr(submission, 'url') and submission.url:
+ # Check if URL points to media
+ url_lower = submission.url.lower()
+ media_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.gifv', '.mp4', '.webm', '.webp']
+ media_domains = [
+ 'imgur.com', 'i.imgur.com', 'redd.it', 'i.redd.it',
+ 'youtube.com', 'youtu.be', 'vimeo.com', 'gfycat.com',
+ 'streamable.com', 'twitter.com', 'x.com', 'tiktok.com',
+ 'instagram.com', 'facebook.com'
+ ]
+
+ # Check file extensions
+ if any(url_lower.endswith(ext) for ext in media_extensions):
+ return True
+
+ # Check media domains
+ if any(domain in url_lower for domain in media_domains):
+ return True
+
+ # Check for gallery posts
+ if hasattr(submission, 'is_gallery') and submission.is_gallery:
+ return True
+
+ return False
+
+ except Exception as e:
+ self.logger.error(f"Error checking media for submission {submission.id}: {e}")
+ return False
+
+ def _get_media_type(self, submission: praw.models.Submission) -> str:
+ """Get the type of media in the submission."""
+ try:
+ if submission.is_video:
+ return "video"
+ elif hasattr(submission, 'is_gallery') and submission.is_gallery:
+ return "gallery"
+ elif hasattr(submission, 'url') and submission.url:
+ url_lower = submission.url.lower()
+ if any(ext in url_lower for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']):
+ return "image"
+ elif any(ext in url_lower for ext in ['.mp4', '.webm', '.gifv']):
+ return "video"
+ elif any(domain in url_lower for domain in ['youtube.com', 'youtu.be', 'vimeo.com']):
+ return "video"
+ else:
+ return "link"
+ else:
+ return "text"
+
+ except Exception as e:
+ self.logger.error(f"Error getting media type for submission {submission.id}: {e}")
+ return "unknown"