V2 release

This commit is contained in:
2026-03-10 22:29:35 +00:00
parent 9719a9b5f9
commit 56acc187a9
5 changed files with 414 additions and 157 deletions
+37
View File
@@ -0,0 +1,37 @@
3cmp3o4
3fx1qfn
3g7v9uc
3jeabq9
3wskhjw
3x39w4t
3yuu0aa
3zzls88
3zzlt5d
3zzlvdb
3zzm08q
402nk2v
4abxjnc
4abxt0q
4abxurh
4dl3vu7
4lq8osg
4n5ukvi
4n5ullm
4uvaecx
4v59c9t
4z0edd4
51digym
52yjb3r
52yjj84
o8428zh
o844qn1
o844sou
o846cxy
o846f5z
5hmwzis
5ju7six
5ju8grz
5ju8ve3
5jubgcs
5judtbe
5jugfpj
+51 -27
View File
@@ -1,27 +1,51 @@
1roi8zs
1roi5cl
1roi245
1roi088
1rlvzus
1rlvufz
1rlvqyo
1rlvfof
1rlpiwm
1rlpivb
1rpysqt
1rpyvk2
1rpz5j5
1rpzgxq
1rq014r
1rpyv9d
1rpxb6z
1rpsp6z
1rpsgbi
1rps6ab
1rprg5y
1rpqfb7
1rpju64
1rpir3o
1rq2nve
1rq2nwm
1rq2nwm
1rq7d74
1rq7d8m
1rq7jgx
1rq7jif
1rq7t4b
1rq7t2q
1rq7yfy
1rq7yes
1rq857k
1rq856a
1rq7p24
1rq8h7x
1rq8h62
1rq8h62
1rq856a
1rq8q3r
1rq8q2l
1rq8q2l
1rq8q2l
1rq8xxd
1rq8xwd
1rq94gj
1rq94ex
1rq97u4
1rq97st
1rq9dmi
1rq9dlb
1rq9lze
1rq9lxo
1rq9pgz
1rq9pfg
1rq9ubo
1rq9uaq
1rq9z7v
1rq9z6p
1rqa30g
1rqa2yy
1rqa6th
1rqa6rz
1rqaawn
1rqaerv
1rqaeq6
1rqaeq6
1rqaeq6
1rqaerv
1rqb476
1rqbf64
1rqb45z
1rqbf4n
1rqbf4n
1rqbf4n
+26 -5
View File
@@ -1,3 +1,4 @@
# ModReplyBot Moderator Guide
## Wiki Configuration Page
@@ -12,7 +13,7 @@ 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`).
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: `!test`).
### Example Trigger Configuration
```
@@ -22,6 +23,10 @@ triggers:
Thank you for your report!
This post is now approved.
status: enabled
flair_id: 12345678-aaaa-bbbb-cccc-1234567890ab
stickied: true
lock_post: false
lock_comment: false
- trigger: wc
comment: |
Welcome to the community!
@@ -31,9 +36,13 @@ triggers:
- **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.
- `enabled`: Bot will comment and perform actions.
- `log-only`: Bot will log but not comment.
- `disabled`: Bot will not act on this trigger.
- **flair_id**: Optional. Set post flair by ID.
- **stickied**: Optional. Sticky the bot's comment.
- **lock_post**: Optional. Lock the post.
- **lock_comment**: Optional. Lock the bot's comment.
## Auto-Post Tags
@@ -46,6 +55,7 @@ post_tags:
comment: |
__[Click here if your post says "Sorry, this post was removed by Reddits filters"](...)__
status: enabled
flair_id: 12345678-aaaa-bbbb-cccc-1234567890ab
```
- **tag**: Comma-separated list of tags. The bot matches tags in post titles (case-insensitive).
@@ -54,17 +64,23 @@ post_tags:
- `enabled`: Bot will comment automatically.
- `log-only`: Bot will log but not comment.
- `disabled`: Bot will not act on this tag.
- **flair_id**: Optional. Set post flair by ID.
## Chat-Based Config Reload
- To reload the wiki config, send a chat message containing `reload-config` to the bot account from a moderator account.
- The bot will reply to the chat message indicating whether the config is valid or not.
- Chat message IDs are tracked in `/DB/chat_wiki_requests.txt` to prevent duplicate reloads after restarts.
## 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.
- If the wiki config is invalid, the bot will reply to the chat message with an error.
## 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.
- Check bot logs for errors and chat replies for config issues.
## Example Wiki Config Excerpt
```
@@ -74,9 +90,14 @@ triggers:
Thank you for your report!
This post is now approved.
status: enabled
flair_id: 12345678-aaaa-bbbb-cccc-1234567890ab
stickied: true
lock_post: false
lock_comment: false
post_tags:
- tag: Bedrock, Java
comment: |
__[Click here if your post says "Sorry, this post was removed by Reddits filters"](...)__
status: enabled
flair_id: 12345678-aaaa-bbbb-cccc-1234567890ab
```
+37 -9
View File
@@ -18,6 +18,29 @@ ModReplyBot is a Reddit bot for moderators that automates post approval and stic
### 1. Wiki Page Configuration
Edit your subreddit wiki page (name set by `REDDIT_WIKI_PAGE` env variable) with YAML like:
```
triggers:
- trigger: help
# ModReplyBot
ModReplyBot is a Reddit bot for moderators that automates post actions and stickied comments based on subreddit wiki configuration. It responds to mod comments and mod reports containing trigger phrases, auto-comments on posts with configured tags, and now supports chat-based config reloads. All configuration is managed via a subreddit wiki page and environment variables.
## Features
- Responds to moderator comments containing trigger phrases (starting with `!`)
- Responds to moderator reports containing trigger phrases (starting with `!`)
- Sets post flair, locks posts/comments, and posts stickied comments
- 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
- Chat-based config reload: send a chat message containing `reload-config` to the bot from a moderator account to reload the wiki config
- Persistent database for auto-commented posts and processed chat requests (survives restarts and container recreations)
- Docker and baremetal support
## Configuration
### 1. Wiki Page Configuration
Edit your subreddit wiki page (name set by `REDDIT_WIKI_PAGE` env variable) with YAML like:
```
triggers:
- trigger: help
@@ -25,6 +48,10 @@ triggers:
Thank you for your report!
This post is now approved.
status: enabled
flair_id: 12345678-aaaa-bbbb-cccc-1234567890ab
stickied: true
lock_post: false
lock_comment: false
- trigger: wc
comment: |
Welcome to the community!
@@ -35,16 +62,17 @@ post_tags:
comment: |
__[Click here if your post says "Sorry, this post was removed by Reddits filters"](...)__
status: enabled
flair_id: 12345678-aaaa-bbbb-cccc-1234567890ab
```
- 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.
- Triggers: Bot responds to mod comments and mod reports containing `!trigger` (e.g., `!help`) with the configured comment and actions.
- post_tags: Bot posts the comment automatically on new posts with matching tags in the title and can set flair.
- Status options: `enabled`, `log-only`, `disabled`.
- Optional actions: `flair_id`, `stickied`, `lock_post`, `lock_comment`.
### 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
@@ -56,10 +84,6 @@ 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)
@@ -95,14 +119,18 @@ pip install -r requirements.txt
python modreplybot.py
```
## Chat-Based Config Reload
- To reload the wiki config, send a chat message containing `reload-config` to the bot account from a moderator account.
- The bot will reply to the chat message indicating whether the config is valid or not.
- Chat message IDs are tracked in `/DB/chat_wiki_requests.txt` to prevent duplicate reloads after restarts.
## 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 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.
- Database is stored in `/DB/commented_posts.txt` and `/DB/chat_wiki_requests.txt` and survives container restarts.
- If the wiki config is invalid, the bot will reply to the chat message with an error.
## Moderator Guide
See `ModGuide.md` for a detailed guide to configuring triggers, auto-post tags, and wiki options.
+174 -27
View File
@@ -5,6 +5,52 @@ from config import get_reddit, Config
import time
class ModReplyBot:
def chat_message_watcher(self):
chat_requests_file = os.path.join(os.path.dirname(__file__), 'DB', 'chat_wiki_requests.txt')
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())
while True:
try:
for message in self.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') and 'reload-config' in message.body.lower():
# Check if sender is a moderator
author = getattr(message, 'author', None)
if author and author in self.subreddit.moderator():
print(f"[CHAT WATCH] Moderator '{author}' requested config reload.")
result = self.fetch_yaml_config()
if result:
print("[CHAT WATCH] Wiki config reloaded successfully.")
reply_text = "Config reloaded successfully. Config is valid."
else:
print("[CHAT WATCH] Wiki config reload failed.")
reply_text = "Config reload failed. Config is invalid."
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}")
except Exception as e:
print(f"Chat message watcher error: {e}")
import time
time.sleep(30)
def comment_only(self, submission, comment_text):
try:
comment = submission.reply(comment_text)
comment.mod.distinguish(sticky=True)
print(f"Commented (no approval) on: {submission.id}")
self.save_commented_post(submission.id)
except Exception as e:
print(f"Error commenting (no approval): {e}")
def __init__(self):
import os
self.reddit = get_reddit()
@@ -14,6 +60,11 @@ class ModReplyBot:
self.comments = []
self.commented_posts = set()
self.commented_posts_file = os.path.join(os.path.dirname(__file__), 'DB', 'commented_posts.txt')
self.tagged_commented_posts_file = os.path.join(os.path.dirname(__file__), 'DB', 'tagged_commented_posts.txt')
self.tagged_commented_posts = set()
self._wiki_config_cache = None
self._wiki_config_cache_time = 0
self._wiki_config_cache_ttl = 300 # seconds (5 minutes)
self.ensure_config_file()
self.load_commented_posts()
self.log_level = Config.LOG_LEVEL
@@ -44,43 +95,64 @@ class ModReplyBot:
# 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, time
now = time.time()
# Use cache if not expired
if self._wiki_config_cache and (now - self._wiki_config_cache_time < self._wiki_config_cache_ttl):
config = self._wiki_config_cache
self._wiki_revision_id = getattr(self, '_wiki_revision_id', None)
else:
try:
wiki_page = Config.WIKI_PAGE
wiki = self.subreddit.wiki[wiki_page]
wiki_content = wiki.content_md
self._wiki_revision_id = getattr(wiki, 'revision_id', None)
config = yaml.safe_load(wiki_content)
self._wiki_config_cache = config
self._wiki_config_cache_time = now
except Exception as e:
self.log(f"Error fetching YAML config from wiki: {e}")
revision = getattr(self, '_wiki_revision_id', None)
if revision != self._last_config_error_revision:
self._last_config_error_revision = revision
return False
if not isinstance(config, dict) or 'triggers' not in config:
raise ValueError("Wiki config missing required 'triggers' key or is not a dict.")
self.log("Wiki config missing required 'triggers' key or is not a dict.")
return False
self.triggers = []
self.comments = []
self.statuses = []
self.flair_ids = []
self.stickied = []
self.lock_post = []
self.lock_comment = []
self.tag_comments = {}
self.tag_statuses = {}
self.tag_flair_ids = {}
for entry in config.get('triggers', []):
self.triggers.append(entry.get('trigger', '').strip())
self.comments.append(entry.get('comment', '').strip())
self.statuses.append(entry.get('status', 'enabled').strip().lower())
self.flair_ids.append(entry.get('flair_id', '').strip())
self.stickied.append(bool(entry.get('stickied', False)))
# Parse lock_post as a proper boolean
lock_post_val = entry.get('lock_post', False)
if isinstance(lock_post_val, str):
lock_post_val = lock_post_val.lower() in ['true', '1', 'yes']
self.lock_post.append(bool(lock_post_val))
self.lock_comment.append(bool(entry.get('lock_comment', False)))
for entry in config.get('post_tags', []):
tags_str = entry.get('tag', '').strip()
comment = entry.get('comment', '').strip()
status = entry.get('status', 'enabled').strip().lower()
flair_id = entry.get('flair_id', '').strip()
tags = [t.strip().lower() for t in tags_str.split(',') if t.strip()]
for tag in tags:
self.tag_comments[tag] = comment
self.tag_statuses[tag] = status
# Reset error revision tracker on successful config
self.tag_flair_ids[tag] = flair_id
self._last_config_error_revision = None
return True
except Exception as 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:
@@ -135,7 +207,6 @@ class ModReplyBot:
if old_revision and new_revision and old_revision != 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
@@ -145,6 +216,8 @@ class ModReplyBot:
self.handle_comment(comment)
except Exception as e:
print(f"Mod comment watcher error: {e}")
import time
time.sleep(5)
def mod_report_watcher():
last_revision = None
@@ -169,6 +242,49 @@ class ModReplyBot:
threading.Thread(target=mod_comment_watcher, daemon=True).start()
threading.Thread(target=mod_report_watcher, daemon=True).start()
threading.Thread(target=self.chat_message_watcher, daemon=True).start()
def tag_post_watcher():
while True:
try:
for submission in self.subreddit.stream.submissions(skip_existing=True):
flair = (submission.link_flair_text or '').strip().lower()
title = submission.title.strip()
# Extract tags from title in square brackets
import re
title_tags = re.findall(r'\[(.*?)\]', title)
title_tags_lower = [t.strip().lower() for t in title_tags]
print(f"[TAG WATCH] Post {submission.id}: title='{title}', flair='{flair}', title_tags={title_tags_lower}")
matched_tag = None
# Check flair first
if flair in self.tag_comments:
matched_tag = flair
else:
# Check each tag in title
for tag in title_tags_lower:
if tag in self.tag_comments:
matched_tag = tag
break
if matched_tag:
# Only comment if not already actioned
if submission.id not in self.commented_posts:
status = self.tag_statuses.get(matched_tag, 'enabled')
comment_text = self.tag_comments[matched_tag]
flair_id = self.tag_flair_ids.get(matched_tag, '')
if flair_id:
try:
submission.flair.select(flair_id)
print(f"[TAG WATCH] Set flair '{flair_id}' for post {submission.id}")
except Exception as e:
print(f"[TAG WATCH] Error setting flair '{flair_id}' for post {submission.id}: {e}")
print(f"Auto-commenting on post {submission.id} with tag '{matched_tag}'")
self.comment_only(submission, comment_text)
except Exception as e:
print(f"Tag post watcher error: {e}")
import time
time.sleep(30)
threading.Thread(target=tag_post_watcher, daemon=True).start()
# Keep main thread alive
while True:
@@ -180,6 +296,7 @@ class ModReplyBot:
matched_trigger = None
matched_comment = None
matched_status = None
matched_idx = None
# Check report reasons for triggers
if hasattr(submission, 'mod_reports') and submission.mod_reports:
for report_tuple in submission.mod_reports:
@@ -190,6 +307,7 @@ class ModReplyBot:
matched_trigger = trigger
matched_comment = self.comments[idx]
matched_status = self.statuses[idx] if idx < len(self.statuses) else 'enabled'
matched_idx = idx
break
if matched_trigger:
break
@@ -205,18 +323,8 @@ class ModReplyBot:
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 mod report {submission.id} for trigger [{matched_trigger}] (auto)")
self.approve_and_comment(submission, comment_text, matched_status, matched_idx)
self.triggered_posts.add(trigger_key)
elif matched_status == 'log-only':
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 mod report {submission.id} for trigger [{matched_trigger}] (auto)")
else:
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 mod report: {e}")
else:
@@ -240,15 +348,54 @@ class ModReplyBot:
print(f"Error removing comment: {e}")
submission = comment.submission
self.fetch_yaml_config()
self.approve_and_comment(submission, self.comments[idx], status)
self.approve_and_comment(submission, self.comments[idx], status, idx)
break
def approve_and_comment(self, submission, comment_text, status='enabled'):
def approve_and_comment(self, submission, comment_text, status='enabled', trigger_idx=None):
try:
submission.mod.approve()
print(f"[DEBUG] approve_and_comment called with trigger_idx={trigger_idx}")
if trigger_idx is not None:
print(f"[DEBUG] Config for trigger_idx={trigger_idx}: lock_post={self.lock_post[trigger_idx]}, stickied={self.stickied[trigger_idx]}, lock_comment={self.lock_comment[trigger_idx]}, flair_id={self.flair_ids[trigger_idx]}")
# Approve post if configured for this trigger
# Set flair, stickied, lock_post, lock_comment if configured for this trigger
if trigger_idx is not None:
flair_id = self.flair_ids[trigger_idx]
if flair_id:
try:
submission.flair.select(flair_id)
print(f"[DEBUG] Set flair '{flair_id}' for post {submission.id}")
except Exception as e:
print(f"[DEBUG] Error setting flair '{flair_id}' for post {submission.id}: {e}")
print(f"[DEBUG] lock_post[{trigger_idx}] = {self.lock_post[trigger_idx]}")
if self.lock_post[trigger_idx]:
try:
submission.mod.lock()
print(f"[DEBUG] Locked post {submission.id}")
except Exception as e:
print(f"[DEBUG] Error locking post {submission.id}: {e}")
if status == 'enabled':
print(f"[DEBUG] Submission object: {submission}, ID: {submission.id}, Type: {type(submission)}")
comment = submission.reply(comment_text)
comment.mod.distinguish(sticky=True)
print(f"[DEBUG] Comment object: {comment}, ID: {comment.id}, Type: {type(comment)}")
# Always sticky if stickied is True, match tag logic
if trigger_idx is not None and self.stickied[trigger_idx]:
try:
result = comment.mod.distinguish(sticky=True)
print(f"[DEBUG] Stickied bot comment {comment.id} on post {submission.id}, result: {result}")
except Exception as e:
print(f"[DEBUG] Error stickying comment {comment.id} on post {submission.id}: {e}")
else:
try:
result = comment.mod.distinguish()
print(f"[DEBUG] Distinguished bot comment {comment.id} on post {submission.id}, result: {result}")
except Exception as e:
print(f"[DEBUG] Error distinguishing comment {comment.id} on post {submission.id}: {e}")
if trigger_idx is not None and self.lock_comment[trigger_idx]:
try:
comment.mod.lock()
print(f"[DEBUG] Locked bot comment {comment.id} on post {submission.id}")
except Exception as e:
print(f"[DEBUG] Error locking comment {comment.id} on post {submission.id}: {e}")
print(f"Approved and commented on: {submission.id}")
self.save_commented_post(submission.id)
elif status == 'log-only':