Updates pending full V1 release

This commit is contained in:
2026-03-10 18:37:11 +00:00
parent 3e5a330929
commit 9719a9b5f9
7 changed files with 230 additions and 75 deletions
+1
View File
@@ -7,3 +7,4 @@ REDDIT_PASSWORD=your_password
REDDIT_USER_AGENT=modreplybot on /u/your_username REDDIT_USER_AGENT=modreplybot on /u/your_username
REDDIT_SUBREDDIT=your_subreddit REDDIT_SUBREDDIT=your_subreddit
REDDIT_WIKI_PAGE=modreplybot-config REDDIT_WIKI_PAGE=modreplybot-config
LOG_LEVEL=Default # Options: Default, Debug
+13
View File
@@ -12,3 +12,16 @@
1rpyvk2 1rpyvk2
1rpz5j5 1rpz5j5
1rpzgxq 1rpzgxq
1rq014r
1rpyv9d
1rpxb6z
1rpsp6z
1rpsgbi
1rps6ab
1rprg5y
1rpqfb7
1rpju64
1rpir3o
1rq2nve
1rq2nwm
1rq2nwm
+82
View File
@@ -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/<your_subreddit>/wiki/<wiki_page>
```
## 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 Reddits 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 Reddits filters"](...)__
status: enabled
```
+28 -12
View File
@@ -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 ## 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 - 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 - 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) - Persistent database for auto-commented posts (survives restarts and container recreations)
- Docker and baremetal support - Docker and baremetal support
@@ -22,23 +24,27 @@ triggers:
comment: | comment: |
Thank you for your report! Thank you for your report!
This post is now approved. This post is now approved.
- trigger: question status: enabled
- trigger: wc
comment: | comment: |
This post has been approved. Welcome to the community!
Your question will be answered soon. status: log-only
post_tags: post_tags:
- tag: Bedrock, Java - tag: Bedrock, Java
comment: | comment: |
__[Click here if your post says "Sorry, this post was removed by Reddits filters"](...)__ __[Click here if your post says "Sorry, this post was removed by Reddits 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. - 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 ### 2. Environment Variables
Create a `.env` file (or set env variables directly) with: Create a `.env` file (or set env variables directly) with:
``` ```
REDDIT_CLIENT_ID=your_client_id REDDIT_CLIENT_ID=your_client_id
REDDIT_CLIENT_SECRET=your_client_secret REDDIT_CLIENT_SECRET=your_client_secret
@@ -46,9 +52,14 @@ REDDIT_USERNAME=your_username
REDDIT_PASSWORD=your_password REDDIT_PASSWORD=your_password
REDDIT_USER_AGENT=modreplybot by /u/your_username REDDIT_USER_AGENT=modreplybot by /u/your_username
REDDIT_SUBREDDIT=your_subreddit 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 ## Installation
### Docker Compose (Recommended) ### Docker Compose (Recommended)
@@ -59,7 +70,7 @@ REDDIT_WIKI_PAGE=modbot-config
docker compose up -d docker compose up -d
``` ```
- The DB folder is mounted for persistent database storage. * The DB folder is mounted for persistent database storage.
### Docker Run ### Docker Run
1. Copy `.env.example` to `.env` and fill in your values. 1. Copy `.env.example` to `.env` and fill in your values.
@@ -84,12 +95,17 @@ pip install -r requirements.txt
python modreplybot.py python modreplybot.py
``` ```
## Troubleshooting ## Troubleshooting
- Ensure your Reddit credentials are correct and have moderator permissions. - Ensure your Reddit credentials are correct and have moderator permissions.
- The bot must be able to read the wiki page and approve posts. - The bot must be able to read the wiki page and approve posts.
- Check logs for errors. - 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. - 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 ## License
MIT MIT
+3 -2
View File
@@ -6,9 +6,10 @@ class Config:
CLIENT_SECRET = os.environ.get('REDDIT_CLIENT_SECRET') CLIENT_SECRET = os.environ.get('REDDIT_CLIENT_SECRET')
USERNAME = os.environ.get('REDDIT_USERNAME') USERNAME = os.environ.get('REDDIT_USERNAME')
PASSWORD = os.environ.get('REDDIT_PASSWORD') 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') 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 @staticmethod
def validate(): def validate():
+1 -1
View File
@@ -1,5 +1,5 @@
services: services:
modbot: modreplybot:
image: slfhstd.uk/slfhstd/modreplybot:latest image: slfhstd.uk/slfhstd/modreplybot:latest
container_name: modreplybot container_name: modreplybot
env_file: env_file:
+102 -60
View File
@@ -4,7 +4,7 @@ from config import get_reddit, Config
import time import time
class ModBot: class ModReplyBot:
def __init__(self): def __init__(self):
import os import os
self.reddit = get_reddit() 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.commented_posts_file = os.path.join(os.path.dirname(__file__), 'DB', 'commented_posts.txt')
self.ensure_config_file() self.ensure_config_file()
self.load_commented_posts() 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): def ensure_config_file(self):
import os import os
@@ -36,6 +41,9 @@ class ModBot:
f.write(default_yaml) f.write(default_yaml)
def fetch_yaml_config(self): 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 import yaml
try: try:
wiki_page = Config.WIKI_PAGE wiki_page = Config.WIKI_PAGE
@@ -43,6 +51,8 @@ class ModBot:
wiki_content = wiki.content_md wiki_content = wiki.content_md
self._wiki_revision_id = getattr(wiki, 'revision_id', None) self._wiki_revision_id = getattr(wiki, 'revision_id', None)
config = yaml.safe_load(wiki_content) 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.triggers = []
self.comments = [] self.comments = []
self.statuses = [] self.statuses = []
@@ -60,8 +70,31 @@ class ModBot:
for tag in tags: for tag in tags:
self.tag_comments[tag] = comment self.tag_comments[tag] = comment
self.tag_statuses[tag] = status self.tag_statuses[tag] = status
# Reset error revision tracker on successful config
self._last_config_error_revision = None
return True
except Exception as e: 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): def load_commented_posts(self):
try: try:
@@ -80,105 +113,114 @@ class ModBot:
import threading import threading
print("ModReplyBot started. Watching for comments and new posts...") print("ModReplyBot started. Watching for comments and new posts...")
try: try:
self.fetch_yaml_config() config_ok = self.fetch_yaml_config()
print(f"Triggers loaded: {self.triggers}") if not config_ok:
print(f"Tag comments loaded: {self.tag_comments}") self.log("Bot startup aborted due to wiki config error.")
print(f"Reddit user: {self.reddit.user.me()}") return
print(f"Subreddit: {self.subreddit.display_name}") 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: except Exception as e:
print(f"Startup error: {e}") self.log(f"Startup error: {e}")
return return
def comment_watcher(): def mod_comment_watcher():
last_revision = None last_revision = None
while True: while True:
try: try:
old_revision = last_revision old_revision = last_revision
self.fetch_yaml_config() config_ok = self.fetch_yaml_config()
new_revision = getattr(self, '_wiki_revision_id', None) new_revision = getattr(self, '_wiki_revision_id', None)
if old_revision and new_revision and old_revision != new_revision: if old_revision and new_revision and old_revision != new_revision:
print("Wiki config changed, reloading triggers and tag comments.") if config_ok:
self.notify_mods_config_change(new_revision) 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 last_revision = new_revision
for comment in self.subreddit.stream.comments(skip_existing=True): 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: except Exception as e:
print(f"Comment watcher error: {e}") print(f"Mod comment watcher error: {e}")
# No sleep needed, stream blocks
def submission_watcher(): def mod_report_watcher():
seen_submissions = set()
last_revision = None last_revision = None
while True: while True:
try: try:
old_revision = last_revision old_revision = last_revision
self.fetch_yaml_config() config_ok = self.fetch_yaml_config()
new_revision = getattr(self, '_wiki_revision_id', None) new_revision = getattr(self, '_wiki_revision_id', None)
if old_revision and new_revision and old_revision != new_revision: if old_revision and new_revision and old_revision != new_revision:
print("Wiki config changed, reloading triggers and tag comments.") if config_ok:
self.notify_mods_config_change(new_revision) 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 last_revision = new_revision
new_submissions = list(self.subreddit.new(limit=10)) for submission in self.subreddit.mod.stream.reports():
found_submission = False self.handle_submission(submission)
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.")
except Exception as e: except Exception as e:
print(f"Submission watcher error: {e}") print(f"Mod report watcher error: {e}")
import time import time
time.sleep(5) time.sleep(30)
threading.Thread(target=comment_watcher, daemon=True).start() threading.Thread(target=mod_comment_watcher, daemon=True).start()
threading.Thread(target=submission_watcher, daemon=True).start() threading.Thread(target=mod_report_watcher, daemon=True).start()
# Keep main thread alive # Keep main thread alive
while True: while True:
import time import time
time.sleep(60) time.sleep(60)
def handle_submission(self, submission): def handle_submission(self, submission):
# Respond to new posts based on tag(s) in title # Respond to mod reports containing trigger phrases
import re print(f"New mod report detected: {submission.id} | Title: {submission.title}")
title = submission.title matched_trigger = None
print(f"New post detected: {submission.id} | Title: {title}")
tag_matches = re.findall(r"\[(.+?)\]", title)
print(f"Tags found in title: {tag_matches}")
matched_comment = None matched_comment = None
matched_tag = None
matched_status = None matched_status = None
for tag in tag_matches: # Check report reasons for triggers
tag_lower = tag.strip().lower() if hasattr(submission, 'mod_reports') and submission.mod_reports:
if tag_lower in self.tag_comments: for report_tuple in submission.mod_reports:
matched_comment = self.tag_comments[tag_lower] report_reason = report_tuple[0].strip().lower()
matched_tag = tag_lower for idx, trigger in enumerate(self.triggers):
matched_status = self.tag_statuses.get(tag_lower, 'enabled') expected = f"!{trigger.lower()}"
break if expected in report_reason:
if matched_comment: matched_trigger = trigger
if submission.id in self.commented_posts: matched_comment = self.comments[idx]
print(f"Already auto-commented on post {submission.id}, skipping.") matched_status = self.statuses[idx] if idx < len(self.statuses) else 'enabled'
return 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: try:
footer = "^I ^am ^a ^bot ^and ^this ^comment ^was ^made ^automatically. ^Message ^the ^Mod ^team ^if ^I'm ^not ^working ^correctly." 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 comment_text = matched_comment.replace("{{author}}", submission.author.name if submission.author else "unknown") + "\n\n" + footer
if matched_status == 'enabled': if matched_status == 'enabled':
comment = submission.reply(comment_text) comment = submission.reply(comment_text)
comment.mod.distinguish(sticky=True) comment.mod.distinguish(sticky=True)
print(f"Commented on new post {submission.id} with tag [{matched_tag}] (auto)") print(f"Commented on mod report {submission.id} for trigger [{matched_trigger}] (auto)")
self.save_commented_post(submission.id) self.triggered_posts.add(trigger_key)
elif matched_status == 'log-only': elif matched_status == 'log-only':
print(f"Log-only: Did not comment on post {submission.id} with tag [{matched_tag}] (auto)") print(f"Log-only: Did not comment on mod report {submission.id} for trigger [{matched_trigger}] (auto)")
self.save_commented_post(submission.id) self.triggered_posts.add(trigger_key)
elif matched_status == 'disabled': 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: 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: except Exception as e:
print(f"Error commenting on new post: {e}") print(f"Error commenting on mod report: {e}")
else: 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): def handle_comment(self, comment):
comment_body = comment.body.lower() comment_body = comment.body.lower()
@@ -234,5 +276,5 @@ class ModBot:
print(f"Error sending modmail notification: {e}") print(f"Error sending modmail notification: {e}")
if __name__ == "__main__": if __name__ == "__main__":
bot = ModBot() bot = ModReplyBot()
bot.run() bot.run()