Compare commits

13 Commits

Author SHA1 Message Date
slfhstd 5d61c3fea6 changelog 2026-03-11 21:06:04 +00:00
slfhstd a4bd8b9333 2.1 2026-03-11 21:01:49 +00:00
slfhstd ffaae146ac updated bot version 2026-03-11 20:51:11 +00:00
slfhstd b9ddeb5303 2026-03-11 18:25:51 +00:00
slfhstd 0e01f803b2 2026-03-11 18:21:06 +00:00
slfhstd e683449f17 2026-03-11 18:16:11 +00:00
slfhstd e47a28efae Further updates to fix permissions issues 2026-03-11 18:09:41 +00:00
slfhstd c605e00e95 docker build updates to improve security 2026-03-11 17:42:51 +00:00
slfhstd ea3eb899e7 updates to correct env file 2026-03-11 17:34:09 +00:00
slfhstd 2fa007761c gitignore 2026-03-11 17:33:29 +00:00
slfhstd 4f9243ff59 changelog added 2026-03-11 17:29:08 +00:00
slfhstd 36fd5430bc updates 2026-03-11 17:26:02 +00:00
slfhstd 95da1522b6 gitignore 2026-03-11 16:40:55 +00:00
11 changed files with 597 additions and 49 deletions
-8
View File
@@ -1,8 +0,0 @@
# 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
+1
View File
@@ -1 +1,2 @@
modtestposts_config.yaml
prod.env
+253
View File
@@ -0,0 +1,253 @@
# Changelog - TestPostsBot
## Version 2.1.0 - Update Checker System
### New Features
#### 1. Update Checker System
- Bot now periodically checks for new versions from a centralized update server
- Automatically sends modmail notifications to subreddit modteam when updates are available
- Includes changelog URL in notification for easy reference
- 24-hour cooldown prevents spam (max 1 notification per day)
- Runs as a background daemon thread alongside main bot operations
- Tracks last update notification timestamp to file for persistence across restarts
#### 2. Version Management
- Bot version is now centralized at the top of `bot.py` in a dedicated section
- `BOT_VERSION` and `BOT_NAME` constants used throughout the bot
- Easy single-location reference for version updates
- Version automatically included in update checker calls and user agent string
#### 3. Infrastructure
- Created separate `update_server/` project for version management service
- Flask-based REST API serving version information via `/api/version/<bot_name>`
- `versions.json` file stores version details for all bots
- Docker support with Dockerfile for containerized deployment
- Includes comprehensive README with deployment options
### New Files
- **update_checker.py** - Background module that checks for and notifies about updates
### Modified Files
#### bot.py
- Added version constants section at top for easy reference
- Added import for `update_checker` module
- Added `start_update_checker()` call in `main()` function
- User agent now uses version constants: `{BOT_NAME}/{BOT_VERSION}`
#### requirements.txt
- Added `requests` library for HTTP requests to update server
#### docker-compose.yml
- Volume mounts for persistent `versions.json` and log files
### Configuration
Update checker timing is configurable in `update_checker.py`:
- `UPDATE_CHECK_INTERVAL = 60` seconds (check frequency)
- `UPDATE_COOLDOWN = 86400` seconds (24 hours between notifications)
### How It Works
1. On startup, bot creates background thread running update checker
2. Every hour, thread polls `https://updts.slfhstd.uk/api/version/TestPostsBot`
3. If version differs from current `BOT_VERSION`, and 24 hours have passed since last notification, sends modmail
4. Modmail includes current version, available version, and changelog URL
5. Last notification timestamp stored in `DB/.last_update_check.txt`
---
## Version 2.0.0 - Trigger-Based Redesign
### Overview
Complete refactor of TestPostsBot to be a continuously running, trigger-based posting bot that responds to moderator commands via chat messages.
## Major Features Added
### 1. Trigger-Based Posting System
- Bot now runs continuously and listens for chat messages instead of executing once and exiting
- Moderators can trigger posts by sending chat messages containing configured trigger keywords
- Each trigger can post one or multiple posts in sequence
- Posts are made with 2-second delays between submissions to respect rate limiting
### 2. YAML Configuration Format
- **Changed from:** JSON configuration format
- **Changed to:** YAML configuration format for better readability and maintainability
- New config structure supports nested trigger/post relationships
- Example YAML config provided in `example_config.yaml`
### 3. Chat Message Handler
- Added chat message watcher that runs as a background thread
- Listens for messages sent to the bot account by moderators
- Implements special `reload-config` command for validating wiki config without making posts
- Validates that sender is a moderator before processing commands
- Tracks processed message IDs to prevent duplicate processing
### 4. Configuration Validation
- New `validate_config_from_wiki()` function validates YAML format and required structure
- Validates that config has a `posts` key with proper trigger/post structure
- Prevents bot from running with invalid configuration
- `reload-config` command provides feedback on config validity
### 5. Enhanced Logging
- Added prefixed logging for different operations: `[STARTUP]`, `[POSTING]`, `[CHAT WATCH]`
- Detailed debug output showing:
- Messages received and who sent them
- Moderator status verification
- Trigger matching and posting status
- Config validation results
- Error messages with full tracebacks
## File Changes
### New Files
- **example_config.yaml** - Comprehensive example showing trigger and post configuration with multiple scenarios
- **.gitignore** - Added to exclude common files
- **example.env** - Environment configuration template
### Modified Files
#### bot.py
**Old Behavior:**
- Ran once, fetched posts from hardcoded config, made posts, and exited
- No continuous operation
- No trigger system
**New Behavior:**
- Runs continuously in infinite loop
- Spawns chat message watcher as background daemon thread
- Loads triggers from wiki config dynamically
- Only posts when a moderator sends a matching trigger
- Implements `reload-config` special command
- Validates config on startup
- Graceful shutdown on KeyboardInterrupt
**Key Functions:**
- `chat_message_watcher()` - Monitors inbox stream for moderator messages
- `make_posts()` - Posts to subreddit with rate limit delays
- `main()` - Continuous operation loop with thread management
#### config.py
**Old Behavior:**
- Fetched JSON config from wiki
- Simple error handling with fallback empty dict
**New Behavior:**
- Uses `yaml.safe_load()` instead of `json.loads()`
- `fetch_config_from_wiki()` - Fetches and parses YAML config
- `validate_config_from_wiki()` - Validates config format and required keys
- `get_trigger_posts()` - Retrieves posts associated with specific trigger
- Better error messages for YAML parsing failures
#### requirements.txt
**Added:**
- `PyYAML` - Required for YAML config parsing
#### README.md
**Complete Rewrite:**
- Added comprehensive documentation for trigger-based system
- Documented new YAML config format with examples
- Explained how moderators trigger posts
- Added setup instructions for environment variables
- Documented `reload-config` command
- Added Docker and standalone running instructions
- Clarified that only moderators can trigger posts
#### docker-compose.yml
**Updated:**
- Environment variables now leverage .env file
- Updated service configuration for continuous operation
#### Dockerfile
**Updated:**
- Adjusted for continuous operation mode
- Ensures proper signal handling for graceful shutdown
### Configuration Examples
**Old Format (JSON):**
```json
{
"posts": [
{"title": "Test Post 1", "body": "Body for post 1"},
{"title": "Test Post 2", "body": "Body for post 2"}
]
}
```
**New Format (YAML):**
```yaml
posts:
- trigger: "test"
posts:
- title: "Test Post 1"
body: "Body for post 1"
- title: "Test Post 2"
body: "Body for post 2"
- trigger: "weekly-thread"
posts:
- title: "Weekly Thread 1"
body: "Content"
- title: "Weekly Thread 2"
body: "Content"
```
## Operational Changes
### Before (V1)
- Bot runs, posts hardcoded posts, exits
- Single execution cycle
- Config loaded once at startup
- No way to trigger posts without restarting bot
### After (V2)
- Bot runs continuously
- Moderators send chat messages to trigger posts
- Config is fetched fresh for each trigger (allows live updates)
- Special `reload-config` command validates configuration
- Background thread handles message monitoring
- Main thread keeps bot alive indefinitely
## Chat Commands
### Trigger Posts
Send chat message containing trigger keyword (e.g., "modtestposts", "weekly-thread")
- Bot fetches configured posts for that trigger
- Posts them to the subreddit in sequence
- Replies with confirmation of posts made
### Reload Config
Send chat message containing "reload-config"
- Bot validates wiki config YAML format
- Replies with success/failure status
- Useful for verifying config before using triggers
## Technical Improvements
1. **Concurrency:** Uses threading for background message monitoring while keeping main thread alive
2. **Deduplication:** Tracks processed message IDs in `DB/chat_wiki_requests.txt` to prevent duplicate processing
3. **Recovery:** Graceful error handling with continue on failures in message stream
4. **Validation:** Comprehensive config validation before any operations
5. **Logging:** Detailed logging for debugging and monitoring
## Compatibility Notes
- Requires `praw` and `PyYAML` packages
- PRAW version should support `inbox.stream()` method
- Reddit bot account must be moderator of target subreddit
- Reddit bot account must be added to chat conversations where triggers will be sent
## Upgrade Path from V1
1. Update wiki config from JSON to YAML format
2. Restructure config to use triggers (see `example_config.yaml`)
3. Redeploy bot with updated code
4. Send "reload-config" to verify new config works
5. Use trigger keywords to post instead of restarting bot
---
**Version:** 2.0
**Date:** March 11, 2026
**Status:** Production Ready
+1
View File
@@ -0,0 +1 @@
1773261500.1906805
+103
View File
@@ -0,0 +1,103 @@
o9q8xhp
o9q9ibz
o9q9jvz
o9q9pcn
o9qawn5
o9qayrb
o9qb2tq
o9qdm6v
o9qdpsi
o9qdzec
o9qef38
o9qff1w
o9qfgmz
o9qfyl7
o9qgkgs
o9qh4nu
o9qh5uk
o9qhh0b
o9qiha2
o9qikd8
o9qijyj
o9qj7de
o9qja83
o9qjbc5
o9qkdi6
o9qkfdj
o9qkjp9
o9qm2qu
o9qm3gf
o9qm94g
o9qmr6t
o9qmt9p
o9qmylo
o9qnrak
o9qnsm6
o9qo16b
o9qot2n
o9qou0k
o9qp1vq
o9qpkrh
o9qpm3p
o9qpuks
o9qqe50
o9qqfrd
o9qr1ee
o9qrb06
o9qrcv4
o9qs54o
o9qs6i6
o9qsbua
o9qst9s
o9qvs45
o9qwlty
o9qxih7
o9qxjm9
o9qzqpn
o9qzsej
o9qzzma
o9r00bc
o9r093d
o9r111o
o9r6uje
o9r6w0g
o9r7s7b
o9r8j1q
o9r8k85
o9r8qtp
o9r9gzr
o9r9iud
o9r9vwe
o9r9xlc
o9raamy
o9raaid
o9racr4
o9raepg
o9rbepq
o9rd2ve
o9rd34l
o9rd3dx
o9rd3q8
o9rd44n
o9rd5ai
o9rd5ef
o9rd5n2
o9rd5i6
o9rd5vt
o9rd60r
o9rd5rs
o9rdgs4
o9rdqyg
5k3eari
5k3j00u
5k3nm4r
5k3p083
5k3pd9q
5k3pqsx
o9vy9h2
o9wez6a
o9wf1nr
o9wf3as
o9x1j7v
o9x1lp2
o9x1n77
+9
View File
@@ -1,8 +1,17 @@
# Dockerfile for Reddit Test Posts Bot
FROM python:3.11-slim
# Create a non-root user for running the bot
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY bot.py .
COPY config.py .
COPY update_checker.py .
# Create DB directory
RUN mkdir -p /app/DB
ENV PYTHONUNBUFFERED=1
CMD ["python", "bot.py"]
+33 -1
View File
@@ -79,10 +79,35 @@ docker run \
### Docker Compose
```bash
# Edit docker-compose.yml with your credentials
# Edit prod.env with your credentials, then:
docker-compose up
```
#### Security: Running as Non-Root User
By default, the container runs as a non-root user (UID 1000, GID 1000) for improved security. You can customize the user and group IDs by setting environment variables before running:
```bash
# Use specific user and group IDs
USER_ID=1001 GROUP_ID=1001 docker-compose up
# Use default (1000:1000)
docker-compose up
```
The user and group IDs can also be specified in a `.env` file:
```env
USER_ID=1001
GROUP_ID=1001
REDDIT_CLIENT_ID=your_client_id
REDDIT_CLIENT_SECRET=your_client_secret
REDDIT_USERNAME=bot_username
REDDIT_PASSWORD=bot_password
SUBREDDIT=your_subreddit
WIKI_PAGE=testpostsbot_config
```
### Standalone
```bash
@@ -96,3 +121,10 @@ python bot.py
- 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.
## Security
- **Non-Root Execution:** The Docker container runs as a non-root user (UID 1000, GID 1000) by default to minimize security risks. This can be customized via `USER_ID` and `GROUP_ID` environment variables.
- **Credentials:** Store Reddit API credentials in environment variables or `.env` files, never hardcode them.
- **Moderator-Only Commands:** All bot triggers and commands require the sender to be a moderator of the target subreddit.
- **DB Directory:** Processed message IDs are stored in a local `DB/` directory to prevent duplicate processing and maintain stateful operation.
+81 -32
View File
@@ -6,12 +6,18 @@ import time
import os
import threading
from config import fetch_config_from_wiki, validate_config_from_wiki, get_trigger_posts
from update_checker import start_update_checker
# ==================== VERSION ====================
BOT_VERSION = "2.1.0"
BOT_NAME = "TestPostsBot"
# ==================================================
REDDIT_CLIENT_ID = os.environ.get('REDDIT_CLIENT_ID')
REDDIT_CLIENT_SECRET = os.environ.get('REDDIT_CLIENT_SECRET')
REDDIT_USERNAME = os.environ.get('REDDIT_USERNAME')
REDDIT_PASSWORD = os.environ.get('REDDIT_PASSWORD')
REDDIT_USER_AGENT = os.environ.get('REDDIT_USER_AGENT', f'TestPostsBot/0.1 on {REDDIT_USERNAME}')
REDDIT_USER_AGENT = os.environ.get('REDDIT_USER_AGENT', f'{BOT_NAME}/{BOT_VERSION} on {REDDIT_USERNAME}')
SUBREDDIT = os.environ.get('SUBREDDIT')
WIKI_PAGE = os.environ.get('WIKI_PAGE', 'testpostsbot_config')
@@ -66,45 +72,84 @@ def chat_message_watcher(reddit, subreddit_name):
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 has body and author
if not hasattr(message, 'body'):
continue
# Check if message contains any trigger
for post_config in posts_config:
if not isinstance(post_config, dict):
continue
author = getattr(message, 'author', None)
if not author:
continue
trigger = post_config.get('trigger', '').lower()
if trigger and trigger in message_body:
print(f"[CHAT WATCH] Moderator '{author}' triggered '{trigger}'.")
# Check if sender is a moderator
try:
is_mod = author in subreddit.moderator()
except Exception as e:
print(f"[CHAT WATCH] Error checking if {author} is mod: {e}")
continue
# Get posts for this trigger
trigger_posts = get_trigger_posts(reddit, subreddit_name, WIKI_PAGE, trigger)
if not is_mod:
continue
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}'."
message_body_lower = message.body.lower()
print(f"[CHAT WATCH] Moderator '{author}' sent message: {message.body[:100]}")
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}")
# Handle special 'reload-config' command
if 'reload-config' in message_body_lower:
print(f"[CHAT WATCH] Reload-config command detected.")
result = validate_config_from_wiki(reddit, subreddit_name, WIKI_PAGE)
if result:
print("[CHAT WATCH] Wiki config validated successfully.")
reply_text = "Config validated successfully. Config is valid YAML."
else:
print("[CHAT WATCH] Wiki config validation failed.")
reply_text = "Config validation failed. Check the wiki config YAML formatting."
try:
message.reply(reply_text)
print(f"[CHAT WATCH] Replied to message {message.id}.")
except Exception as e:
print(f"[CHAT WATCH] Error replying to message {message.id}: {e}")
continue
# Load current config to check for triggers
config = fetch_config_from_wiki(reddit, subreddit_name, WIKI_PAGE)
posts_config = config.get('posts', [])
trigger_found = False
# 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_lower:
trigger_found = True
print(f"[CHAT WATCH] Matched trigger '{trigger}' in message.")
# 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 message {message.id}.")
except Exception as e:
print(f"[CHAT WATCH] Error replying to message {message.id}: {e}")
# Only process the first matching trigger per message
break
# Only process the first matching trigger per message
break
except Exception as e:
print(f"[CHAT WATCH] Chat message watcher error: {e}")
import traceback
traceback.print_exc()
time.sleep(30)
@@ -141,6 +186,10 @@ def main():
chat_thread.start()
print("[STARTUP] Chat message watcher started.")
# Start update checker in background thread
start_update_checker(reddit, SUBREDDIT, BOT_NAME, BOT_VERSION)
print("[STARTUP] Update checker started.")
# Keep main thread alive
try:
while True:
+5 -3
View File
@@ -1,6 +1,8 @@
services:
testpostbot:
image: slfhstd.uk/slfhstd/testpostbot:latest
testpostsbot:
image: slfhstd.uk/slfhstd/testpostsbot:dev
env_file:
- .env
- prod.env
restart: unless-stopped
volumes:
- ./DB:/app/DB
+1
View File
@@ -1,2 +1,3 @@
praw
PyYAML
requests
+105
View File
@@ -0,0 +1,105 @@
import requests
import threading
import time
import os
from datetime import datetime
UPDATE_CHECK_INTERVAL = 60 # Check every minute
UPDATE_COOLDOWN = 86400 # Don't send mail more than once per 24 hours
LAST_UPDATE_FILE = os.path.join(os.path.dirname(__file__), 'DB', '.last_update_check.txt')
def get_latest_version(bot_name):
"""Fetch latest version info from update server."""
try:
response = requests.get(f'https://updts.slfhstd.uk/api/version/{bot_name}', timeout=10)
if response.status_code == 200:
return response.json()
except Exception as e:
print(f"[UPDATE_CHECKER] Error fetching version: {e}")
return None
def send_update_modmail(reddit, subreddit_name, bot_name, current_version, available_version, changelog_url):
"""Send modmail to subreddit modteam about available update."""
try:
subject = f"🤖 {bot_name} Update Available (v{available_version})"
message = f"""Hello,
An update is available for {bot_name}!
**Current Version:** {current_version}
**Available Version:** {available_version}
Changelog: {changelog_url}
Please visit the update server for installation instructions.
---
This is an automated message from the Update Checker."""
# Build payload for the compose API
data = {
"subject": subject,
"text": message,
"to": f"/r/{subreddit_name}",
}
reddit.post("api/compose/", data=data)
print(f"[UPDATE_CHECKER] Sent update notification to r/{subreddit_name}")
return True
except Exception as e:
print(f"[UPDATE_CHECKER] Error sending modmail: {e}")
return False
def should_send_update_mail():
"""Check if enough time has passed since last update mail."""
if not os.path.exists(LAST_UPDATE_FILE):
return True
try:
with open(LAST_UPDATE_FILE, 'r') as f:
last_check = float(f.read().strip())
return (time.time() - last_check) >= UPDATE_COOLDOWN
except:
return True
def mark_update_mailed():
"""Record when update mail was sent."""
os.makedirs(os.path.dirname(LAST_UPDATE_FILE), exist_ok=True)
with open(LAST_UPDATE_FILE, 'w') as f:
f.write(str(time.time()))
def update_checker_thread(reddit, subreddit_name, bot_name, current_version):
"""Background thread that checks for updates periodically."""
print(f"[UPDATE_CHECKER] Started for {bot_name} v{current_version}")
while True:
try:
version_info = get_latest_version(bot_name)
if version_info:
available_version = version_info.get('version')
changelog_url = version_info.get('changelog_url', '#')
if available_version != current_version and should_send_update_mail():
print(f"[UPDATE_CHECKER] Update found: {current_version} -> {available_version}")
if send_update_modmail(reddit, subreddit_name, bot_name, current_version, available_version, changelog_url):
mark_update_mailed()
except Exception as e:
print(f"[UPDATE_CHECKER] Error in update checker thread: {e}")
time.sleep(UPDATE_CHECK_INTERVAL)
def start_update_checker(reddit, subreddit_name, bot_name, bot_version):
"""Start the update checker in a background thread."""
thread = threading.Thread(
target=update_checker_thread,
args=(reddit, subreddit_name, bot_name, bot_version),
daemon=True
)
thread.start()