#!/usr/bin/env python3 import praw import sqlite3 import time import datetime import re import config # ============================================================================== # DATABASE MANAGER # ============================================================================== class DatabaseManager: def __init__(self, db_name="valentines_event.db"): self.conn = sqlite3.connect(db_name, check_same_thread=False) self.cursor = self.conn.cursor() self.create_tables() def create_tables(self): # Participants table self.cursor.execute(''' CREATE TABLE IF NOT EXISTS participants ( username TEXT PRIMARY KEY, joined_at REAL ) ''') self.cursor.execute(''' CREATE TABLE IF NOT EXISTS commented_users ( username TEXT PRIMARY KEY ) ''') # Wishes table self.cursor.execute(''' CREATE TABLE IF NOT EXISTS wishes ( id INTEGER PRIMARY KEY AUTOINCREMENT, recipient TEXT, sender TEXT, message TEXT, received_at REAL, FOREIGN KEY(recipient) REFERENCES participants(username), UNIQUE(sender, recipient) ) ''') # Conversations table self.cursor.execute(''' CREATE TABLE IF NOT EXISTS conversations ( sender TEXT PRIMARY KEY, state TEXT, pending_recipient TEXT, pending_message TEXT, timestamp REAL ) ''') # Tracks if the final gift has been sent to a user self.cursor.execute(''' CREATE TABLE IF NOT EXISTS deliveries ( username TEXT PRIMARY KEY, delivered_at TIMESTAMP ) ''') self.conn.commit() def add_participant(self, username): try: self.cursor.execute("INSERT INTO participants VALUES (?, ?)", (username, datetime.datetime.now())) self.conn.commit() return True except sqlite3.IntegrityError: return False def add_commented_user(self, username): try: self.cursor.execute("INSERT INTO commented_users VALUES (?)", (username, )) self.conn.commit() return True except sqlite3.IntegrityError: return False def get_commented_users(self, username): try: self.cursor.execute("SELECT * FROM commented_users WHERE username=?", (username,)) return self.cursor.fetchone() except sqlite3.IntegrityError: return None def save_wish(self, sender, recipient, message): """Inserts or Replaces a wish""" self.cursor.execute("SELECT username FROM participants WHERE username = ?", (recipient,)) if not self.cursor.fetchone(): self.add_participant(recipient) self.cursor.execute(''' INSERT OR REPLACE INTO wishes (recipient, sender, message, received_at) VALUES (?, ?, ?, ?) ''', (recipient, sender, message, datetime.datetime.now())) self.conn.commit() return True def check_existing_wish(self, sender, recipient): self.cursor.execute("SELECT message FROM wishes WHERE sender = ? AND recipient = ?", (sender, recipient)) result = self.cursor.fetchone() return result[0] if result else None def get_forest_data(self): self.cursor.execute(''' SELECT p.username, COUNT(w.id) as wish_count FROM participants p LEFT JOIN wishes w ON p.username = w.recipient GROUP BY p.username ORDER BY p.username ASC ''') return self.cursor.fetchall() def set_conversation_state(self, sender, state, recipient, message): self.cursor.execute(''' INSERT OR REPLACE INTO conversations (sender, state, pending_recipient, pending_message, timestamp) VALUES (?, ?, ?, ?, ?) ''', (sender, state, recipient, message, datetime.datetime.now())) self.conn.commit() def get_conversation(self, sender): self.cursor.execute("SELECT state, pending_recipient, pending_message FROM conversations WHERE sender = ?", (sender,)) return self.cursor.fetchone() def clear_conversation(self, sender): self.cursor.execute("DELETE FROM conversations WHERE sender = ?", (sender,)) self.conn.commit() def get_undelivered_users(self): """Returns list of usernames who have wishes but haven't received them yet.""" self.cursor.execute(''' SELECT DISTINCT p.username FROM participants p JOIN wishes w ON p.username = w.recipient LEFT JOIN deliveries d ON p.username = d.username WHERE d.username IS NULL ''') return [row[0] for row in self.cursor.fetchall()] def get_user_wishes(self, username): self.cursor.execute("SELECT sender, message FROM wishes WHERE recipient = ?", (username,)) return self.cursor.fetchall() def mark_delivered(self, username): self.cursor.execute("INSERT INTO deliveries VALUES (?, ?)", (username, datetime.datetime.now())) self.conn.commit() # ============================================================================== # SAFETY FILTER # ============================================================================== class SafetyFilter: def __init__(self, blocklist): self.blocklist = blocklist def is_safe(self, text): """ Returns True if text is safe, False if it contains blocked terms. Performs a simple substring check (case-insensitive). """ if not text: return True text_lower = text.lower() for bad_word in self.blocklist: # Check if the bad word exists in the text # Note: logic can be improved with regex \b word boundaries if needed if bad_word in text_lower: return False return True # ============================================================================== # BOT LOGIC # ============================================================================== class ValentinesBot: # Store the last updated overall count of wishes. last_updated_count = 0 def __init__(self): print("💕 Initializing Valentine's Helper...") self.reddit = praw.Reddit( client_id=config.eventBotClientId, client_secret=config.eventBotSecret, user_agent=config.user_agent, username=config.username, password=config.password ) self.reddit.validate_on_submit = True self.db = DatabaseManager() self.safety = SafetyFilter(config.blocklist) self.subreddit = self.reddit.subreddit(config.subreddit_name) self.last_dashboard_update = 0 self.username_pattern = re.compile(r"^u/[A-Za-z0-9_-]{3,20}$", re.IGNORECASE) self.yes_pattern = re.compile(r"^(yes|y|yeah|yup|ja|si|oui|confirm)$", re.IGNORECASE) self.no_pattern = re.compile(r"^(no|nop|cancel|nah|not quite|negative|nuh uh)$", re.IGNORECASE) def get_visual_icon(self, count): if count == 0: return "🌱" elif count == 1: return "💕 (1 Heart)" elif count <= 5: return "💕" * count + f" ({count} Hearts)" else: return f"💖💖💖 ({count} Hearts!)" def verify_user_flair(self, username): try: flair_list = list(self.subreddit.flair(redditor=username)) if not flair_list: return False user_flair = flair_list[0] if user_flair['flair_text'] or user_flair['flair_css_class']: return True return False except Exception as e: print(f"⚠️ Error checking flair for {username}: {e}") return False def update_dashboard(self): now = time.time() if now - self.last_dashboard_update < config.update_post_interval: return data = self.db.get_forest_data() overall_count = 0 if data: for username, count in data: overall_count += count if overall_count == self.last_updated_count: # Nothing changed. print(f"No change in dashboard, last checked {datetime.datetime.fromtimestamp(now).strftime('%Y-%m-%d %H:%M:%S')}...", end="\r") return print("📝 Updating Valentine's Garden Dashboard...") self.last_updated_count = overall_count body = "# 💝 The Valentine's Garden 💝\n\n" body += "### How to Participate:\n" body += "1. Send a DM to u/{}\n".format(config.username) body += "2. **First Line:** The username (MUST start with `u/` e.g. `u/AmoreMio666`)\n" body += "3. **Next Lines:** Your message.\n\n" body += "**Note:** The bot will ask you to confirm before saving.\n\n" body += "See the announcement post [here](https://www.reddit.com/r/NoNutYearlyCommunity/comments/1r0yknk/introducing_the_nnyc_valentines_wish_bot/)\n" body += "---\n\n" body += "| User | Garden Status |\n| :--- | :--- |\n" if not data: body += "| The Garden is quiet... | Be the first to send a message! |\n" for username, count in data: icon = self.get_visual_icon(count) body += f"| u/{username} | {icon} |\n" body += "\n\n---\n*Updated: " + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "*" try: submission = self.reddit.submission(id=config.valentines_post_id) submission.edit(body, ) self.last_dashboard_update = now print("✅ Dashboard updated.") except Exception as e: print(f"❌ Error updating dashboard: {e}") def parse_new_wish_request(self, message): body_lines = message.body.strip().split('\n') body_lines = [line.strip() for line in body_lines if line.strip()] if not body_lines: return None, None first_line = body_lines[0] if self.username_pattern.match(first_line): target_user = first_line[2:].lower() wish_content = '\n'.join(body_lines[1:]) return target_user, wish_content return None, None def process_inbox(self): try: for message in self.reddit.inbox.unread(): if message.was_comment: continue sender = message.author.name if message.author else "Unknown" print(f"📩 Message from {sender}") conversation = self.db.get_conversation(sender) if conversation: self.handle_confirmation(message, sender, conversation) else: self.handle_new_request(message, sender) message.mark_read() except Exception as e: print(f"⚠️ Error checking inbox: {e}") def handle_new_request(self, message, sender): target_user, wish_content = self.parse_new_wish_request(message) if not self.verify_user_flair(sender): message.reply(f"Sorry, u/{sender} but you do not have an active Flair in r/{config.subreddit_name}.\n" "We only allow messages for active, flaired community members.") return # ERROR: Bad Format if not target_user: reply = ("I couldn't understand your request. Please format your message exactly like this:\n\n" "`u/TargetUsername`\n\n" "`Your message here...`\n\n" "Make sure the username is on the **first line** and starts with **u/**.") message.reply(reply) return # ERROR: Empty Body if not wish_content: message.reply(f"You didn't write a message for u/{target_user.lower()}! Please try again with text in the body.") return # SAFETY CHECK if not self.safety.is_safe(wish_content): print(f"⛔ Blocked unsafe content from {sender}") message.reply("⛔ **Message Rejected**\n\n" "Your message contains words or phrases that are not allowed in this event. " "Please ensure your message is kind and follows the community guidelines.") return # ERROR: No Flair if not self.verify_user_flair(target_user.lower()): message.reply(f"Sorry, u/{target_user.lower()} does not have a User Flair in r/{config.subreddit_name}.\n" "We only allow messages for active, flaired community members.") return # CHECK: Existing Wish existing_msg = self.db.check_existing_wish(sender, target_user.lower()) if existing_msg: self.db.set_conversation_state(sender, "CONFIRM_REPLACE", target_user.lower(), wish_content) reply = (f"⚠️ **You already sent a message to u/{target_user}.**\n\n" f"**Old Message:** {existing_msg[:100]}...\n\n" f"**Do you want to REPLACE it with:**\n" f"> {wish_content}\n\n" f"Reply **YES** to confirm or **NO** to cancel.") message.reply(reply) else: self.db.set_conversation_state(sender, "CONFIRM_NEW", target_user.lower(), wish_content) reply = (f"💌 **Valentine's Confirmation** 💌\n\n" f"I extracted that you want to send a message to **u/{target_user.lower()}**.\n\n" f"**Message:**\n" f"> {wish_content}\n\n" f"Is this correct? Reply **YES** to save or **NO** to cancel.") message.reply(reply) def handle_confirmation(self, message, sender, conversation): state, target, content = conversation user_response = message.body.strip().lower() # SAFETY CHECK (Re-check in case DB state was manipulated or rules changed) if not self.safety.is_safe(content): self.db.clear_conversation(sender) message.reply("⛔ **Message Rejected** during confirmation. Content violates safety rules.") return if self.yes_pattern.match(user_response): self.db.save_wish(sender, target.lower(), content) self.db.clear_conversation(sender) print(f"💾 Message saved for {target.lower()}") if self.db.get_commented_users(target.lower()): print("User already existed before, no need to update") else: print("New user, add comment") self.db.add_commented_user(target.lower()) submission = self.reddit.submission(id=config.valentines_post_id) submission.reply(config.comment_base_text.format(username=target.lower())) if state == "CONFIRM_REPLACE": message.reply(f"✅ Your message for u/{target.lower()} has been **updated**! 💕") print(f"{sender} has updated their message for u/{target.lower()} to **{content}**.") else: message.reply(f"✅ Your message for u/{target.lower()} has been **saved**! 💕") print(f"{sender} has saved their message for u/{target.lower()}: **{content}**.") elif self.no_pattern.match(user_response): self.db.clear_conversation(sender) message.reply("❌ Cancelled. You can send a new message to start over.") print(f"🚫 Action cancelled by {sender}") else: message.reply("I didn't catch that. Please reply **YES** to confirm or **NO** to cancel.") def run(self): print("🚀 Valentine's Bot Started. Press Ctrl+C to stop.") while True:# 1. Process new wishes self.process_inbox() # 2. Update the public post self.update_dashboard() # 3. Check if it's Valentine's Day yet self.check_distribution() time.sleep(config.check_interval) def check_distribution(self): """Checks if it is Feb 14th UTC (Midnight) or later and triggers distribution.""" now_utc = datetime.datetime.now(datetime.timezone.utc) # Check: Is it February AND is it the 14th or later? if now_utc.month == 2 and now_utc.day >= 14: self.distribute_gifts() def distribute_gifts(self): recipients = self.db.get_undelivered_users() # Silent exit if no pending deliveries (prevents log spam) if not recipients: return print(f"💝 Happy Valentine's Day! Distributing messages to {len(recipients)} users...") for username in recipients: wishes = self.db.get_user_wishes(username.lower()) if not wishes: continue print(f"💌 Preparing messages for u/{username.lower()}...") # --- PAGINATION LOGIC --- messages_to_send = [] current_part_content = "" current_part_num = 1 # Header for first message header = f"Happy Valentine's Day u/{username.lower()}!\n\nThe wait is over. Here are your messages:\n\n---\n\n" current_part_content += header for sender, message_text in wishes: # Format the single wish wish_block = f"**From: u/{sender}**\n> {message_text}\n\n---\n\n" # Check character limit (safe buffer of 9000 chars) if len(current_part_content) + len(wish_block) > 9000: # Current part full. Close it and start new one. current_part_content += f"\n*(Continued in Part {current_part_num + 1}...)*" messages_to_send.append(current_part_content) current_part_num += 1 current_part_content = f"**Valentine's Messages Part {current_part_num}**\n\n---\n\n" current_part_content += wish_block else: current_part_content += wish_block # Append the footer to the last part current_part_content += f"\nYou received {len(wishes)} heart(s). Have a wonderful Valentine's Day!\n" current_part_content += f"\n*Automated message from r/{config.subreddit_name}*" messages_to_send.append(current_part_content) # --- SENDING LOOP --- try: for index, body in enumerate(messages_to_send): subject_line = "💌 Open your Valentine's Time Capsule! 💝" if len(messages_to_send) > 1: subject_line += f" (Part {index + 1}/{len(messages_to_send)})" self.reddit.redditor(username).message(subject=subject_line, message=body) # Small sleep between parts to ensure order and avoid spam trigger time.sleep(2) self.db.mark_delivered(username.lower()) print(f"✅ Delivered {len(messages_to_send)} part(s) to u/{username.lower()}") time.sleep(5) # Delay between USERS except Exception as e: print(f"❌ Failed to send to u/{username.lower()}: {e}") if __name__ == "__main__": bot = ValentinesBot() bot.run()