From 9719a9b5f943616718c20af14d0613536f08fa2e Mon Sep 17 00:00:00 2001 From: Slfhstd Date: Tue, 10 Mar 2026 18:37:11 +0000 Subject: [PATCH] Updates pending full V1 release --- .env.example | 1 + DB/commented_posts.txt | 13 ++++ ModGuide.md | 82 +++++++++++++++++++++ README.md | 40 +++++++--- config.py | 5 +- docker-compose.yml | 2 +- modreplybot.py | 162 ++++++++++++++++++++++++++--------------- 7 files changed, 230 insertions(+), 75 deletions(-) create mode 100644 ModGuide.md diff --git a/.env.example b/.env.example index 4a1bcf0..48f9c91 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,4 @@ REDDIT_PASSWORD=your_password REDDIT_USER_AGENT=modreplybot on /u/your_username REDDIT_SUBREDDIT=your_subreddit REDDIT_WIKI_PAGE=modreplybot-config +LOG_LEVEL=Default # Options: Default, Debug diff --git a/DB/commented_posts.txt b/DB/commented_posts.txt index d559b8f..1741e0f 100644 --- a/DB/commented_posts.txt +++ b/DB/commented_posts.txt @@ -12,3 +12,16 @@ 1rpyvk2 1rpz5j5 1rpzgxq +1rq014r +1rpyv9d +1rpxb6z +1rpsp6z +1rpsgbi +1rps6ab +1rprg5y +1rpqfb7 +1rpju64 +1rpir3o +1rq2nve +1rq2nwm +1rq2nwm diff --git a/ModGuide.md b/ModGuide.md new file mode 100644 index 0000000..94871f3 --- /dev/null +++ b/ModGuide.md @@ -0,0 +1,82 @@ +# ModReplyBot Moderator Guide + +## Wiki Configuration Page + +All bot configuration is managed via your subreddit wiki page. The page name is set by the `REDDIT_WIKI_PAGE` environment variable (default: `modreplybot-config`). + +**Always use the old.reddit.com wiki link for editing and referencing your config:** + +``` +https://old.reddit.com/r//wiki/ +``` + +## Triggers + +Triggers allow moderators to perform bot actions by commenting with a trigger phrase or by reporting a post with a trigger phrase in the report reason. Triggers must start with a `!` (ex: `!help`). + +### Example Trigger Configuration +``` +triggers: + - trigger: help + comment: | + Thank you for your report! + This post is now approved. + status: enabled + - trigger: wc + comment: | + Welcome to the community! + status: log-only +``` + +- **trigger**: The phrase (without the `!`) that mods use in comments or report reasons. +- **comment**: The text the bot will post as a stickied comment. +- **status**: + - `enabled`: Bot will approve the post and comment. + - `log-only`: Bot will approve the post but not comment. + - `disabled`: Bot will not act on this trigger. + +## Auto-Post Tags + +Auto-post tags allow the bot to comment automatically on new posts with specific tags in the title. + +### Example Auto-Post Tag Configuration +``` +post_tags: + - tag: Bedrock, Java + comment: | + __[Click here if your post says "Sorry, this post was removed by Reddit’s filters"](...)__ + status: enabled +``` + +- **tag**: Comma-separated list of tags. The bot matches tags in post titles (case-insensitive). +- **comment**: The text the bot will post as a stickied comment. +- **status**: + - `enabled`: Bot will comment automatically. + - `log-only`: Bot will log but not comment. + - `disabled`: Bot will not act on this tag. + +## Additional Notes +- The bot only comments once per trigger per post (even if triggered multiple times). +- The bot only auto-comments once per post for each tag. +- All bot actions are logged for transparency. +- If the wiki config is invalid, the bot will notify mods via modmail and pause until fixed. + +## Troubleshooting +- Make sure your wiki config is valid YAML and includes both `triggers` and `post_tags` sections. +- Use old.reddit.com for wiki editing to avoid formatting issues. +- Check bot logs for errors and modmail for config issues. + +## Example Wiki Config Excerpt +``` +triggers: + - trigger: help + comment: | + Thank you for your report! + This post is now approved. + status: enabled +post_tags: + - tag: Bedrock, Java + comment: | + __[Click here if your post says "Sorry, this post was removed by Reddit’s filters"](...)__ + status: enabled +``` diff --git a/README.md b/README.md index a1c224b..f22913f 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ -# ModReplyBot Reddit Bot -ModReplyBot is a Reddit bot for moderators that watches for mod comments containing triggers, approves posts, and leaves stickied comments. It also posts automatic comments based on post tags, and notifies mods when its configuration changes. All configuration is handled via a subreddit wiki page and environment variables. +# ModReplyBot + +ModReplyBot is a Reddit bot for moderators that automates post approval and stickied comments based on subreddit wiki configuration. It responds to mod comments and mod reports containing trigger phrases, and auto-comments on posts with configured tags. All configuration is managed via a subreddit wiki page and environment variables. ## Features -- Responds only to moderator comments containing triggers (ignores non-mod comments) +- Responds to moderator comments containing trigger phrases (starting with `!`) +- Responds to moderator reports containing trigger phrases (starting with `!`) - Approves posts and leaves stickied comments -- Posts automatic comments based on tags in post titles (e.g., [Bedrock], [Java]) +- Posts automatic comments based on tags in post titles (e.g., `[Bedrock]`, `[Java]`) - Triggers, tag comments, and bot config are managed via a subreddit wiki page -- Notifies mods via modmail when the config wiki page changes +- Notifies mods via modmail when the config wiki page changes or is invalid - Persistent database for auto-commented posts (survives restarts and container recreations) - Docker and baremetal support @@ -22,23 +24,27 @@ triggers: comment: | Thank you for your report! This post is now approved. - - trigger: question + status: enabled + - trigger: wc comment: | - This post has been approved. - Your question will be answered soon. + Welcome to the community! + status: log-only post_tags: - tag: Bedrock, Java comment: | __[Click here if your post says "Sorry, this post was removed by Reddit’s filters"](...)__ + status: enabled ``` -- Triggers: Bot responds to mod comments containing !trigger (e.g., !help) with the configured comment. +- Triggers: Bot responds to mod comments and mod reports containing `!trigger` (e.g., `!help`) with the configured comment. - post_tags: Bot posts the comment automatically on new posts with matching tags in the title. +- Status options: `enabled`, `log-only`, `disabled`. ### 2. Environment Variables Create a `.env` file (or set env variables directly) with: + ``` REDDIT_CLIENT_ID=your_client_id REDDIT_CLIENT_SECRET=your_client_secret @@ -46,9 +52,14 @@ REDDIT_USERNAME=your_username REDDIT_PASSWORD=your_password REDDIT_USER_AGENT=modreplybot by /u/your_username REDDIT_SUBREDDIT=your_subreddit -REDDIT_WIKI_PAGE=modbot-config +REDDIT_WIKI_PAGE=modreplybot-config +LOG_LEVEL=Default ``` +docker compose up -d +docker run --env-file .env -v $(pwd)/DB:/app/DB slfhstd.uk/slfhstd/modreplybot:latest +pip install -r requirements.txt + ## Installation ### Docker Compose (Recommended) @@ -59,7 +70,7 @@ REDDIT_WIKI_PAGE=modbot-config docker compose up -d ``` -- The DB folder is mounted for persistent database storage. +* The DB folder is mounted for persistent database storage. ### Docker Run 1. Copy `.env.example` to `.env` and fill in your values. @@ -84,12 +95,17 @@ pip install -r requirements.txt python modreplybot.py ``` + ## Troubleshooting - Ensure your Reddit credentials are correct and have moderator permissions. - The bot must be able to read the wiki page and approve posts. - Check logs for errors. -- The bot only responds to mod comments for triggers. +- The bot only responds to mod comments and mod reports for triggers. - Database is stored in DB/commented_posts.txt and survives container restarts. +- If the wiki config is invalid, the bot will notify mods via modmail and pause until fixed. + +## Moderator Guide +See `ModGuide.md` for a detailed guide to configuring triggers, auto-post tags, and wiki options. ## License MIT diff --git a/config.py b/config.py index b80be1e..c8b6837 100644 --- a/config.py +++ b/config.py @@ -6,9 +6,10 @@ class Config: CLIENT_SECRET = os.environ.get('REDDIT_CLIENT_SECRET') USERNAME = os.environ.get('REDDIT_USERNAME') PASSWORD = os.environ.get('REDDIT_PASSWORD') - USER_AGENT = os.environ.get('REDDIT_USER_AGENT', 'modbot by /u/your_username') + USER_AGENT = os.environ.get('REDDIT_USER_AGENT', 'modreplybot by /u/your_username') SUBREDDIT = os.environ.get('REDDIT_SUBREDDIT') - WIKI_PAGE = os.environ.get('REDDIT_WIKI_PAGE', 'modbot-config') + WIKI_PAGE = os.environ.get('REDDIT_WIKI_PAGE', 'modreplybot-config') + LOG_LEVEL = os.environ.get('LOG_LEVEL', 'Default') @staticmethod def validate(): diff --git a/docker-compose.yml b/docker-compose.yml index 9976646..8e0263d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ services: - modbot: + modreplybot: image: slfhstd.uk/slfhstd/modreplybot:latest container_name: modreplybot env_file: diff --git a/modreplybot.py b/modreplybot.py index 1c36ffc..cc44a4e 100644 --- a/modreplybot.py +++ b/modreplybot.py @@ -4,7 +4,7 @@ from config import get_reddit, Config import time -class ModBot: +class ModReplyBot: def __init__(self): import os self.reddit = get_reddit() @@ -16,6 +16,11 @@ class ModBot: self.commented_posts_file = os.path.join(os.path.dirname(__file__), 'DB', 'commented_posts.txt') self.ensure_config_file() self.load_commented_posts() + self.log_level = Config.LOG_LEVEL + + def log(self, message, debug_only=False): + if self.log_level == 'Debug' or (self.log_level == 'Default' and not debug_only): + print(message) def ensure_config_file(self): import os @@ -36,6 +41,9 @@ class ModBot: f.write(default_yaml) def fetch_yaml_config(self): + # Track last error revision to prevent modmail spam + if not hasattr(self, '_last_config_error_revision'): + self._last_config_error_revision = None import yaml try: wiki_page = Config.WIKI_PAGE @@ -43,6 +51,8 @@ class ModBot: wiki_content = wiki.content_md self._wiki_revision_id = getattr(wiki, 'revision_id', None) config = yaml.safe_load(wiki_content) + if not isinstance(config, dict) or 'triggers' not in config: + raise ValueError("Wiki config missing required 'triggers' key or is not a dict.") self.triggers = [] self.comments = [] self.statuses = [] @@ -60,8 +70,31 @@ class ModBot: for tag in tags: self.tag_comments[tag] = comment self.tag_statuses[tag] = status + # Reset error revision tracker on successful config + self._last_config_error_revision = None + return True except Exception as e: - print(f"Error fetching YAML config from wiki: {e}") + self.log(f"Error fetching YAML config from wiki: {e}") + # Only send modmail if revision is new + revision = getattr(self, '_wiki_revision_id', None) + if revision != self._last_config_error_revision: + self.notify_mods_config_error(str(e)) + self._last_config_error_revision = revision + return False + + def notify_mods_config_error(self, error_message): + try: + subject = "ModReplyBot wiki config error" + body = f"The bot detected an error in the wiki config page and will not reload until it is fixed.\n\nError details: {error_message}\n\nPlease check the wiki config for formatting or missing information." + data = { + "subject": subject, + "text": body, + "to": f"/r/{Config.SUBREDDIT}", + } + self.reddit.post("api/compose/", data=data) + self.log("Sent modmail notification about wiki config error.") + except Exception as e: + self.log(f"Error sending modmail notification about wiki config error: {e}") def load_commented_posts(self): try: @@ -80,105 +113,114 @@ class ModBot: import threading print("ModReplyBot started. Watching for comments and new posts...") try: - self.fetch_yaml_config() - print(f"Triggers loaded: {self.triggers}") - print(f"Tag comments loaded: {self.tag_comments}") - print(f"Reddit user: {self.reddit.user.me()}") - print(f"Subreddit: {self.subreddit.display_name}") + config_ok = self.fetch_yaml_config() + if not config_ok: + self.log("Bot startup aborted due to wiki config error.") + return + self.log(f"Triggers loaded: {self.triggers}") + self.log(f"Tag comments loaded: {self.tag_comments}") + self.log(f"Reddit user: {self.reddit.user.me()}") + self.log(f"Subreddit: {self.subreddit.display_name}") except Exception as e: - print(f"Startup error: {e}") + self.log(f"Startup error: {e}") return - def comment_watcher(): + def mod_comment_watcher(): last_revision = None while True: try: old_revision = last_revision - self.fetch_yaml_config() + config_ok = self.fetch_yaml_config() new_revision = getattr(self, '_wiki_revision_id', None) if old_revision and new_revision and old_revision != new_revision: - print("Wiki config changed, reloading triggers and tag comments.") - self.notify_mods_config_change(new_revision) + if config_ok: + self.log("Wiki config changed, reloading triggers and tag comments.") + self.notify_mods_config_change(new_revision) + else: + self.log("Wiki config error detected, not reloading bot.") + self.log_level = Config.LOG_LEVEL last_revision = new_revision for comment in self.subreddit.stream.comments(skip_existing=True): - self.handle_comment(comment) + if comment.author and comment.author in self.subreddit.moderator(): + self.handle_comment(comment) except Exception as e: - print(f"Comment watcher error: {e}") - # No sleep needed, stream blocks + print(f"Mod comment watcher error: {e}") - def submission_watcher(): - seen_submissions = set() + def mod_report_watcher(): last_revision = None while True: try: old_revision = last_revision - self.fetch_yaml_config() + config_ok = self.fetch_yaml_config() new_revision = getattr(self, '_wiki_revision_id', None) if old_revision and new_revision and old_revision != new_revision: - print("Wiki config changed, reloading triggers and tag comments.") - self.notify_mods_config_change(new_revision) + if config_ok: + self.log("Wiki config changed, reloading triggers and tag comments.") + self.notify_mods_config_change(new_revision) + else: + self.log("Wiki config error detected, not reloading bot.") last_revision = new_revision - new_submissions = list(self.subreddit.new(limit=10)) - found_submission = False - for submission in new_submissions: - if submission.id not in seen_submissions: - found_submission = True - seen_submissions.add(submission.id) - self.handle_submission(submission) - if not found_submission: - print("No submissions detected in new() this cycle.") + for submission in self.subreddit.mod.stream.reports(): + self.handle_submission(submission) except Exception as e: - print(f"Submission watcher error: {e}") + print(f"Mod report watcher error: {e}") import time - time.sleep(5) + time.sleep(30) - threading.Thread(target=comment_watcher, daemon=True).start() - threading.Thread(target=submission_watcher, daemon=True).start() + threading.Thread(target=mod_comment_watcher, daemon=True).start() + threading.Thread(target=mod_report_watcher, daemon=True).start() # Keep main thread alive while True: import time time.sleep(60) def handle_submission(self, submission): - # Respond to new posts based on tag(s) in title - import re - title = submission.title - print(f"New post detected: {submission.id} | Title: {title}") - tag_matches = re.findall(r"\[(.+?)\]", title) - print(f"Tags found in title: {tag_matches}") + # Respond to mod reports containing trigger phrases + print(f"New mod report detected: {submission.id} | Title: {submission.title}") + matched_trigger = None matched_comment = None - matched_tag = None matched_status = None - for tag in tag_matches: - tag_lower = tag.strip().lower() - if tag_lower in self.tag_comments: - matched_comment = self.tag_comments[tag_lower] - matched_tag = tag_lower - matched_status = self.tag_statuses.get(tag_lower, 'enabled') - break - if matched_comment: - if submission.id in self.commented_posts: - print(f"Already auto-commented on post {submission.id}, skipping.") - return + # Check report reasons for triggers + if hasattr(submission, 'mod_reports') and submission.mod_reports: + for report_tuple in submission.mod_reports: + report_reason = report_tuple[0].strip().lower() + for idx, trigger in enumerate(self.triggers): + expected = f"!{trigger.lower()}" + if expected in report_reason: + matched_trigger = trigger + matched_comment = self.comments[idx] + matched_status = self.statuses[idx] if idx < len(self.statuses) else 'enabled' + break + if matched_trigger: + break + if matched_trigger: + # Only skip if this exact trigger was already actioned for this post + trigger_key = f"{submission.id}:{matched_trigger}" + if hasattr(self, 'triggered_posts'): + if trigger_key in self.triggered_posts: + print(f"Already actioned trigger [{matched_trigger}] on post {submission.id}, skipping.") + return + else: + self.triggered_posts = set() try: footer = "^I ^am ^a ^bot ^and ^this ^comment ^was ^made ^automatically. ^Message ^the ^Mod ^team ^if ^I'm ^not ^working ^correctly." comment_text = matched_comment.replace("{{author}}", submission.author.name if submission.author else "unknown") + "\n\n" + footer if matched_status == 'enabled': comment = submission.reply(comment_text) comment.mod.distinguish(sticky=True) - print(f"Commented on new post {submission.id} with tag [{matched_tag}] (auto)") - self.save_commented_post(submission.id) + print(f"Commented on mod report {submission.id} for trigger [{matched_trigger}] (auto)") + self.triggered_posts.add(trigger_key) elif matched_status == 'log-only': - print(f"Log-only: Did not comment on post {submission.id} with tag [{matched_tag}] (auto)") - self.save_commented_post(submission.id) + print(f"Log-only: Did not comment on mod report {submission.id} for trigger [{matched_trigger}] (auto)") + self.triggered_posts.add(trigger_key) elif matched_status == 'disabled': - print(f"Disabled: Did not comment/log for post {submission.id} with tag [{matched_tag}] (auto)") + print(f"Disabled: Did not comment/log for mod report {submission.id} for trigger [{matched_trigger}] (auto)") else: - print(f"Unknown status '{matched_status}' for post {submission.id} with tag [{matched_tag}] (auto)") + print(f"Unknown status '{matched_status}' for mod report {submission.id} for trigger [{matched_trigger}] (auto)") except Exception as e: - print(f"Error commenting on new post: {e}") + print(f"Error commenting on mod report: {e}") else: - print(f"No matching tag found for post {submission.id}") + print(f"No matching trigger found in mod report for post {submission.id}") def handle_comment(self, comment): comment_body = comment.body.lower() @@ -234,5 +276,5 @@ class ModBot: print(f"Error sending modmail notification: {e}") if __name__ == "__main__": - bot = ModBot() + bot = ModReplyBot() bot.run()