From 1e78138992735101b748d9b4ca556916b5819485 Mon Sep 17 00:00:00 2001 From: Slfhstd Date: Wed, 11 Mar 2026 16:36:10 +0000 Subject: [PATCH] Changes to enable always on and trigger emthod. also updated to yaml config structure --- .env | 8 +++ README.md | 102 ++++++++++++++++++++++++++++++----- bot.py | 127 +++++++++++++++++++++++++++++++++++++++----- config.py | 73 +++++++++++++++++++++++-- docker-compose.yml | 2 +- example_config.yaml | 74 ++++++++++++++++++++++++++ requirements.txt | 1 + 7 files changed, 357 insertions(+), 30 deletions(-) create mode 100644 .env create mode 100644 example_config.yaml diff --git a/.env b/.env new file mode 100644 index 0000000..ae485c1 --- /dev/null +++ b/.env @@ -0,0 +1,8 @@ +# Example environment variables for TestPostsBot +REDDIT_CLIENT_ID=vzdZ6l330b_l7UWPoZ7tcQ +REDDIT_CLIENT_SECRET=Zh7gVG1glv8BNxlGFBHU1UAJnDjo9w +REDDIT_USERNAME=MinecraftHelpModBot +REDDIT_PASSWORD=t%FdW9$&CAdhQo6@Zz9v +REDDIT_USER_AGENT=TestPostsBot/0.1 on {REDDIT_USERNAME} +SUBREDDIT=MinecraftHelpModCopy +WIKI_PAGE=testpostsbot_config diff --git a/README.md b/README.md index a37235f..5998b95 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,98 @@ # Reddit TestPostsBot -## Usage +A Reddit bot that makes test posts to your subreddit when triggered via private messages by moderators. -1. Fill in your Reddit API credentials and subreddit name in `bot.py`. -2. Create a wiki page in your subreddit named `testpostsbot_config` with JSON like: +## How It Works -``` -{ - "posts": [ - {"title": "Test Post 1", "body": "Body for post 1"}, - {"title": "Test Post 2", "body": "Body for post 2"} - ] -} +1. The bot runs continuously and listens for private messages sent to its account +2. When a moderator sends a message containing a trigger keyword, the bot checks the wiki config +3. If the trigger is found and valid, the bot posts the configured posts to the subreddit +4. The bot replies to the moderator confirming success or failure + +## Setup + +### Environment Variables + +Set these variables via environment or directly in `bot.py`: + +- `REDDIT_CLIENT_ID`: Your Reddit app's client ID +- `REDDIT_CLIENT_SECRET`: Your Reddit app's client secret +- `REDDIT_USERNAME`: The bot account's username +- `REDDIT_PASSWORD`: The bot account's password +- `REDDIT_USER_AGENT`: (Optional) Custom user agent string +- `SUBREDDIT`: The subreddit to post to (without the /r/) +- `WIKI_PAGE`: (Optional) Wiki page for config (default: `testpostsbot_config`) + +### Wiki Configuration + +Create a wiki page in your subreddit named `testpostsbot_config` (or set `WIKI_PAGE` env var) with YAML formatted triggers and posts: + +```yaml +posts: + - trigger: "summer-schedule" + posts: + - title: "Summer Announcement Post" + body: "This is the summer announcement." + - title: "Summer Rules Update" + body: "New summer rules are now in effect." + + - trigger: "test" + posts: + - title: "Test Post" + body: "This is a test post." + + - trigger: "weekly-thread" + posts: + - title: "Weekly Discussion Thread" + body: | + This is the weekly discussion thread. + Feel free to discuss anything related to the subreddit. ``` -3. Build and run with Docker: +Each trigger can have one or multiple posts. Posts are made in order with a 2-second delay between each to avoid rate limiting. -``` +### Triggering Posts + +To trigger posts, send a private message to the bot account containing the trigger keyword. For example: + +- Message: "Can you run summer-schedule?" → Posts the summer schedule posts +- Message: "Trigger: test" → Posts the test post +- Message: "Please post weekly-thread" → Posts the weekly discussion thread + +Only moderators of the subreddit can trigger posts. + +## Running the Bot + +### Docker + +```bash docker build -t testpostsbot . -docker run --env REDDIT_CLIENT_ID=... --env REDDIT_CLIENT_SECRET=... --env REDDIT_USERNAME=... --env REDDIT_PASSWORD=... --env SUBREDDIT=... testpostsbot +docker run \ + --env REDDIT_CLIENT_ID=your_client_id \ + --env REDDIT_CLIENT_SECRET=your_client_secret \ + --env REDDIT_USERNAME=bot_username \ + --env REDDIT_PASSWORD=bot_password \ + --env SUBREDDIT=your_subreddit \ + testpostsbot ``` -Or edit the variables directly in `bot.py` for quick testing. +### Docker Compose + +```bash +# Edit docker-compose.yml with your credentials +docker-compose up +``` + +### Standalone + +```bash +pip install -r requirements.txt +python bot.py +``` + +## Configuration Notes + +- The wiki config is validated on startup. If the YAML is malformed, the bot will not start. +- The config is fetched fresh for each trigger, so you can update the wiki while the bot is running. +- Only the first matching trigger per message is processed. +- All processed messages are tracked in `DB/chat_wiki_requests.txt` to avoid duplicate processing. diff --git a/bot.py b/bot.py index bf44ad7..6e3b3e6 100644 --- a/bot.py +++ b/bot.py @@ -1,10 +1,11 @@ # Reddit Test Posts Bot -# This script reads config from a subreddit wiki page and makes test posts if the bot is a moderator. +# This script reads config from a subreddit wiki page and makes test posts +# when a moderator sends a chat message containing a configured trigger. import praw -import json import time import os -from config import fetch_config_from_wiki +import threading +from config import fetch_config_from_wiki, validate_config_from_wiki, get_trigger_posts REDDIT_CLIENT_ID = os.environ.get('REDDIT_CLIENT_ID') REDDIT_CLIENT_SECRET = os.environ.get('REDDIT_CLIENT_SECRET') @@ -17,22 +18,99 @@ WIKI_PAGE = os.environ.get('WIKI_PAGE', 'testpostsbot_config') def is_moderator(reddit, subreddit_name): + """Check if the bot is a moderator of the subreddit.""" subreddit = reddit.subreddit(subreddit_name) mods = [str(mod) for mod in subreddit.moderator()] return reddit.user.me().name in mods def make_posts(reddit, subreddit_name, posts): + """Make the specified posts to the subreddit.""" subreddit = reddit.subreddit(subreddit_name) for post in posts: title = post.get('title', 'Test Post') body = post.get('body', '') - print(f"Posting: {title}") - subreddit.submit(title, selftext=body) - time.sleep(2) # avoid rate limits + print(f"[POSTING] Posting: {title}") + try: + subreddit.submit(title, selftext=body) + time.sleep(2) # avoid rate limits + except Exception as e: + print(f"[POSTING] Error posting '{title}': {e}") + + +def chat_message_watcher(reddit, subreddit_name): + """ + Watches for chat messages from moderators containing trigger keywords. + When a trigger is found, posts the configured posts for that trigger. + """ + chat_requests_file = os.path.join(os.path.dirname(__file__), 'DB', 'chat_wiki_requests.txt') + os.makedirs(os.path.dirname(chat_requests_file), exist_ok=True) + processed_message_ids = set() + + # Load processed IDs from file + if os.path.exists(chat_requests_file): + with open(chat_requests_file, 'r', encoding='utf-8') as f: + for line in f: + processed_message_ids.add(line.strip()) + + subreddit = reddit.subreddit(subreddit_name) + + while True: + try: + for message in reddit.inbox.stream(): + if not hasattr(message, 'id') or message.id in processed_message_ids: + continue + + processed_message_ids.add(message.id) + # Save processed ID to file + with open(chat_requests_file, 'a', encoding='utf-8') as f: + f.write(message.id + '\n') + + if hasattr(message, 'body'): + message_body = message.body.lower() + # Check if sender is a moderator + author = getattr(message, 'author', None) + if author and author in subreddit.moderator(): + # Load current config to check for triggers + config = fetch_config_from_wiki(reddit, subreddit_name, WIKI_PAGE) + posts_config = config.get('posts', []) + + # Check if message contains any trigger + for post_config in posts_config: + if not isinstance(post_config, dict): + continue + + trigger = post_config.get('trigger', '').lower() + if trigger and trigger in message_body: + print(f"[CHAT WATCH] Moderator '{author}' triggered '{trigger}'.") + + # Get posts for this trigger + trigger_posts = get_trigger_posts(reddit, subreddit_name, WIKI_PAGE, trigger) + + if trigger_posts: + print(f"[CHAT WATCH] Found {len(trigger_posts)} post(s) for trigger '{trigger}'.") + make_posts(reddit, subreddit_name, trigger_posts) + reply_text = f"Successfully posted {len(trigger_posts)} post(s) for trigger '{trigger}'." + else: + print(f"[CHAT WATCH] No posts found for trigger '{trigger}'.") + reply_text = f"No posts configured for trigger '{trigger}'." + + try: + message.reply(reply_text) + print(f"[CHAT WATCH] Replied to chat message {message.id}.") + except Exception as e: + print(f"[CHAT WATCH] Error replying to chat message {message.id}: {e}") + + # Only process the first matching trigger per message + break + except Exception as e: + print(f"[CHAT WATCH] Chat message watcher error: {e}") + + time.sleep(30) def main(): + """Main function - runs bot constantly.""" reddit = praw.Reddit( client_id=REDDIT_CLIENT_ID, client_secret=REDDIT_CLIENT_SECRET, @@ -41,13 +119,38 @@ def main(): user_agent=REDDIT_USER_AGENT ) - config = fetch_config_from_wiki(reddit, SUBREDDIT, WIKI_PAGE) - posts = config.get('posts', []) + if not is_moderator(reddit, SUBREDDIT): + print(f"Bot is not a moderator of r/{SUBREDDIT}. Cannot continue.") + return - if is_moderator(reddit, SUBREDDIT): - make_posts(reddit, SUBREDDIT, posts) - else: - print(f"Bot is not a moderator of r/{SUBREDDIT}. No posts made.") + # Validate initial config + if not validate_config_from_wiki(reddit, SUBREDDIT, WIKI_PAGE): + print(f"Initial wiki config is invalid. Please fix the config at r/{SUBREDDIT}/wiki/{WIKI_PAGE}") + return + + print(f"[STARTUP] TestPostsBot started for r/{SUBREDDIT}") + print(f"[STARTUP] Config page: r/{SUBREDDIT}/wiki/{WIKI_PAGE}") + print(f"[STARTUP] Bot username: {reddit.user.me()}") + + # Start chat message watcher in background thread + chat_thread = threading.Thread( + target=chat_message_watcher, + args=(reddit, SUBREDDIT), + daemon=True + ) + chat_thread.start() + print("[STARTUP] Chat message watcher started.") + + # Keep main thread alive + try: + while True: + time.sleep(60) + except KeyboardInterrupt: + print("[SHUTDOWN] Bot shutting down...") + + +if __name__ == '__main__': + main() if __name__ == '__main__': diff --git a/config.py b/config.py index 1d844a2..f7d0d2e 100644 --- a/config.py +++ b/config.py @@ -1,14 +1,81 @@ # config.py -# Fetches config from subreddit wiki page -import json +# Fetches config from subreddit wiki page in YAML format +import yaml def fetch_config_from_wiki(reddit, subreddit_name, wiki_page): + """ + Fetches config from the wiki page as YAML. + Returns the parsed config dict, or empty dict if error. + """ subreddit = reddit.subreddit(subreddit_name) try: wiki = subreddit.wiki[wiki_page] config_text = wiki.content_md - config = json.loads(config_text) + config = yaml.safe_load(config_text) + + # If config is None or empty, return empty dict + if not config: + config = {'posts': []} + return config + except yaml.YAMLError as e: + print(f"Error parsing YAML config from wiki: {e}") + return {'posts': []} except Exception as e: print(f"Error fetching config from wiki: {e}") return {'posts': []} + + +def validate_config_from_wiki(reddit, subreddit_name, wiki_page): + """ + Validates the config from the wiki page. + Returns True if the config is valid YAML with 'posts' key, False otherwise. + """ + subreddit = reddit.subreddit(subreddit_name) + try: + wiki = subreddit.wiki[wiki_page] + config_text = wiki.content_md + config = yaml.safe_load(config_text) + + # Validate required structure + if not isinstance(config, dict) or 'posts' not in config: + print("Wiki config missing required 'posts' key or is not a dict.") + return False + + return True + except yaml.YAMLError as e: + print(f"Error parsing YAML config from wiki: {e}") + return False + except Exception as e: + print(f"Error fetching config from wiki: {e}") + return False + + +def get_trigger_posts(reddit, subreddit_name, wiki_page, trigger_name): + """ + Gets the posts associated with a specific trigger. + Returns a list of post dicts, or empty list if trigger not found. + """ + config = fetch_config_from_wiki(reddit, subreddit_name, wiki_page) + + posts_config = config.get('posts', []) + if not isinstance(posts_config, list): + print("Config 'posts' is not a list.") + return [] + + for post_config in posts_config: + if not isinstance(post_config, dict): + continue + + if post_config.get('trigger', '').lower() == trigger_name.lower(): + # Get the posts for this trigger + trigger_posts = post_config.get('posts', []) + if not isinstance(trigger_posts, list): + print(f"Trigger '{trigger_name}' posts is not a list.") + return [] + + return trigger_posts + + # Trigger not found + print(f"Trigger '{trigger_name}' not found in config.") + return [] diff --git a/docker-compose.yml b/docker-compose.yml index 9c31788..b6f4855 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: testpostbot: - image: slfhstd.uk/slfhstd/testpostbot:dev + image: slfhstd.uk/slfhstd/testpostbot:latest env_file: - .env restart: unless-stopped diff --git a/example_config.yaml b/example_config.yaml new file mode 100644 index 0000000..5b8f85a --- /dev/null +++ b/example_config.yaml @@ -0,0 +1,74 @@ +# Example TestPostsBot Wiki Configuration +# Copy this content to your subreddit wiki page (e.g., r/yoursubreddit/wiki/testpostsbot_config) +# Each trigger can post one or multiple posts when activated via moderator private message + +posts: + # Simple trigger with a single post + - trigger: "test" + posts: + - title: "Test Post" + body: "This is a test post to verify the bot is working correctly." + + # Trigger with multiple posts + - trigger: "weekly-thread" + posts: + - title: "Weekly Discussion Thread" + body: | + This is the weekly discussion thread for this week. + Feel free to discuss anything related to the subreddit! + + Some guidelines: + - Be respectful + - Stay on topic + - Report rule violations + + - title: "Weekly Off-Topic Thread" + body: | + This is the weekly off-topic discussion thread. + Talk about anything that's not related to the subreddit here! + + # Seasonal trigger with announcement posts + - trigger: "summer-schedule" + posts: + - title: "Summer 2026 Schedule Announcement" + body: | + The summer schedule for 2026 is now in effect! + + Key dates: + - June 21: Summer begins + - July 4: Holiday special event + - September 21: Summer ends + + We hope to see everyone participating! + + - title: "Summer Rules Update" + body: | + We've updated our rules for the summer season. + + Please review the updated sidebar for all changes. + Violations of the new rules may result in temporary bans. + + - title: "Summer Event Sign-ups Now Open" + body: | + Sign up for our summer events by replying to this post! + + Available events: + - Community game day + - AMAs with community members + - Photo contest + + See the sidebar for full details. + + # Maintenance trigger + - trigger: "maintenance" + posts: + - title: "Subreddit Maintenance Notice" + body: | + The subreddit will be undergoing maintenance in the next 24 hours. + + During this time: + - Post submissions may be temporarily disabled + - Automod rules may be updated + - Stylesheet changes will be applied + + We apologize for any inconvenience this may cause. diff --git a/requirements.txt b/requirements.txt index 6c81bc0..4887bc3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ praw +PyYAML