# foodie_engagement_tweet.py import json import logging import random import signal import sys import fcntl import os import time from datetime import datetime, timedelta, timezone from openai import OpenAI from foodie_utils import ( post_tweet, AUTHORS, SUMMARY_MODEL, check_author_rate_limit, load_json_file, save_json_file, # Add this update_system_activity, get_next_author_round_robin ) from foodie_config import X_API_CREDENTIALS, AUTHOR_BACKGROUNDS_FILE from dotenv import load_dotenv print("Loading environment variables") load_dotenv() print(f"Environment variables loaded: OPENAI_API_KEY={bool(os.getenv('OPENAI_API_KEY'))}") SCRIPT_NAME = "foodie_engagement_tweet" LOCK_FILE = "/home/shane/foodie_automator/locks/foodie_engagement_tweet.lock" LOG_FILE = "/home/shane/foodie_automator/logs/foodie_engagement_tweet.log" LOG_PRUNE_DAYS = 30 MAX_RETRIES = 3 RETRY_BACKOFF = 2 def setup_logging(): """Initialize logging with pruning of old logs.""" print("Entering setup_logging") try: log_dir = os.path.dirname(LOG_FILE) print(f"Ensuring log directory exists: {log_dir}") os.makedirs(log_dir, exist_ok=True) print(f"Log directory permissions: {os.stat(log_dir).st_mode & 0o777}, owner: {os.stat(log_dir).st_uid}") if os.path.exists(LOG_FILE): print(f"Pruning old logs in {LOG_FILE}") with open(LOG_FILE, 'r') as f: lines = f.readlines() cutoff = datetime.now(timezone.utc) - timedelta(days=LOG_PRUNE_DAYS) pruned_lines = [] malformed_count = 0 for line in lines: if len(line) < 19 or not line[:19].replace('-', '').replace(':', '').replace(' ', '').isdigit(): malformed_count += 1 continue try: timestamp = datetime.strptime(line[:19], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc) if timestamp > cutoff: pruned_lines.append(line) except ValueError: malformed_count += 1 continue print(f"Skipped {malformed_count} malformed log lines during pruning") with open(LOG_FILE, 'w') as f: f.writelines(pruned_lines) print(f"Log file pruned, new size: {os.path.getsize(LOG_FILE)} bytes") print(f"Configuring logging to {LOG_FILE}") logging.basicConfig( filename=LOG_FILE, level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) console_handler = logging.StreamHandler() console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')) logging.getLogger().addHandler(console_handler) logging.getLogger("openai").setLevel(logging.WARNING) logging.info("Logging initialized for foodie_engagement_tweet.py") print("Logging setup complete") except Exception as e: print(f"Failed to setup logging: {e}") sys.exit(1) def acquire_lock(): """Acquire a lock to prevent concurrent runs.""" print("Entering acquire_lock") try: lock_dir = os.path.dirname(LOCK_FILE) print(f"Ensuring lock directory exists: {lock_dir}") os.makedirs(lock_dir, exist_ok=True) print(f"Opening lock file: {LOCK_FILE}") lock_fd = open(LOCK_FILE, 'w') print(f"Attempting to acquire lock on {LOCK_FILE}") fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) lock_fd.write(str(os.getpid())) lock_fd.flush() print(f"Lock acquired, PID: {os.getpid()}") return lock_fd except IOError as e: print(f"Failed to acquire lock, another instance is running: {e}") logging.info("Another instance of foodie_engagement_tweet.py is running") sys.exit(0) except Exception as e: print(f"Unexpected error in acquire_lock: {e}") sys.exit(1) def signal_handler(sig, frame): """Handle termination signals gracefully.""" print(f"Received signal: {sig}") logging.info("Received termination signal, marking script as stopped...") update_system_activity(SCRIPT_NAME, "stopped") sys.exit(0) signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) # Initialize OpenAI client print("Initializing OpenAI client") try: client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) if not os.getenv("OPENAI_API_KEY"): print("OPENAI_API_KEY is not set") logging.error("OPENAI_API_KEY is not set in environment variables") raise ValueError("OPENAI_API_KEY is required") print("OpenAI client initialized") except Exception as e: print(f"Failed to initialize OpenAI client: {e}") logging.error(f"Failed to initialize OpenAI client: {e}", exc_info=True) sys.exit(1) # Load author backgrounds print(f"Loading author backgrounds from {AUTHOR_BACKGROUNDS_FILE}") try: with open(AUTHOR_BACKGROUNDS_FILE, 'r') as f: AUTHOR_BACKGROUNDS = json.load(f) print(f"Author backgrounds loaded: {len(AUTHOR_BACKGROUNDS)} entries") except Exception as e: print(f"Failed to load author_backgrounds.json: {e}") logging.error(f"Failed to load author_backgrounds.json: {e}", exc_info=True) sys.exit(1) def generate_engagement_tweet(author): """Generate an engagement tweet using author background themes and persona.""" print(f"Generating tweet for author: {author['username']}") try: credentials = X_API_CREDENTIALS.get(author["username"]) if not credentials: print(f"No X credentials found for {author['username']}") logging.error(f"No X credentials found for {author['username']}") return None author_handle = credentials["x_username"] print(f"Author handle: {author_handle}") background = next((bg for bg in AUTHOR_BACKGROUNDS if bg["username"] == author["username"]), {}) if not background or "engagement_themes" not in background: print(f"No background or themes for {author['username']}, using default theme") logging.warning(f"No background or engagement themes found for {author['username']}") theme = "food trends" else: theme = random.choice(background["engagement_themes"]) print(f"Selected theme: {theme}") # Get the author's persona from AUTHORS persona = next((a["persona"] for a in AUTHORS if a["username"] == author["username"]), "Unknown") prompt = ( f"Generate a concise tweet (under 230 characters) for {author_handle} as a {persona}. " f"Create an engaging, specific question about {theme} to spark interaction (e.g., 'What's your go-to sushi spot in Tokyo?'). " f"Include a call to action to follow {author_handle} or like the tweet, and mention InsiderFoodie.com with a link to https://insiderfoodie.com. " f"Avoid using the word 'elevate'—use more humanized language like 'level up' or 'bring to life'. " f"Do not include emojis, hashtags, or reward-driven incentives (e.g., giveaways)." ) print(f"OpenAI prompt: {prompt}") for attempt in range(MAX_RETRIES): print(f"Attempt {attempt + 1} to generate tweet") try: response = client.chat.completions.create( model=SUMMARY_MODEL, messages=[ {"role": "system", "content": "You are a social media expert crafting engaging tweets."}, {"role": "user", "content": prompt} ], max_tokens=100, temperature=0.7 ) tweet = response.choices[0].message.content.strip() if len(tweet) > 280: tweet = tweet[:277] + "..." print(f"Generated tweet: {tweet}") logging.debug(f"Generated engagement tweet: {tweet}") return tweet except Exception as e: print(f"Failed to generate tweet (attempt {attempt + 1}): {e}") logging.warning(f"Failed to generate engagement tweet for {author['username']} (attempt {attempt + 1}): {e}") if attempt < MAX_RETRIES - 1: time.sleep(RETRY_BACKOFF * (2 ** attempt)) else: print(f"Exhausted retries for {author['username']}") logging.error(f"Failed to generate engagement tweet after {MAX_RETRIES} attempts") engagement_templates = [ f"What's your favorite {theme} dish? Share below and follow {author_handle} for more on InsiderFoodie.com! Link: https://insiderfoodie.com", f"Which {theme} spot is a must-visit? Tell us and like this tweet for more from {author_handle} on InsiderFoodie.com! Link: https://insiderfoodie.com", f"Got a {theme} hidden gem? Share it and follow {author_handle} for more on InsiderFoodie.com! Link: https://insiderfoodie.com", f"What's the best {theme} you've tried? Let us know and like this tweet to keep up with {author_handle} on InsiderFoodie.com! Link: https://insiderfoodie.com" ] template = random.choice(engagement_templates) print(f"Using fallback tweet: {template}") logging.info(f"Using fallback engagement tweet: {template}") return template except Exception as e: print(f"Error in generate_engagement_tweet for {author['username']}: {e}") logging.error(f"Error in generate_engagement_tweet for {author['username']}: {e}", exc_info=True) return None def post_engagement_tweet(): """Post engagement tweets for all authors with a delay between posts.""" print("Entering post_engagement_tweet") try: logging.info("Starting foodie_engagement_tweet.py") posted = False state_file = '/home/shane/foodie_automator/author_state.json' state = load_json_file(state_file, default={'last_author_index': -1}) delay_seconds = 30 # Delay between posts to avoid rate limits and spread engagement # Iterate through all authors for index, author in enumerate(AUTHORS): username = author['username'] print(f"Processing author: {username}") logging.info(f"Processing author: {username}") try: print("Checking rate limit") if not check_author_rate_limit(author): print(f"Rate limit exceeded for {username}, skipping") logging.info(f"Rate limit exceeded for {username}, skipping") continue print("Generating tweet") tweet = generate_engagement_tweet(author) if not tweet: print(f"Failed to generate tweet for {username}, skipping") logging.error(f"Failed to generate engagement tweet for {username}, skipping") continue print(f"Posting tweet: {tweet}") logging.info(f"Posting engagement tweet for {username}: {tweet}") if post_tweet(author, tweet): print(f"Successfully posted tweet for {username}") logging.info(f"Successfully posted engagement tweet for {username}") posted = True # Update last_author_index to maintain round-robin consistency state['last_author_index'] = index save_json_file(state_file, state) else: print(f"Failed to post tweet for {username}") logging.warning(f"Failed to post tweet for {username}") # Add delay between posts (except for the last author) if index < len(AUTHORS) - 1: print(f"Waiting {delay_seconds} seconds before next post") logging.info(f"Waiting {delay_seconds} seconds before next post") time.sleep(delay_seconds) except Exception as e: print(f"Error posting tweet for {username}: {e}") logging.error(f"Error posting tweet for {username}: {e}", exc_info=True) continue print("Completed post_engagement_tweet") logging.info("Completed foodie_engagement_tweet.py") sleep_time = 86400 # 1 day for cron return posted, sleep_time except Exception as e: print(f"Unexpected error in post_engagement_tweet: {e}") logging.error(f"Unexpected error in post_engagement_tweet: {e}", exc_info=True) sleep_time = 86400 # 1 day return False, sleep_time def main(): """Main function to run the script.""" print("Starting main") lock_fd = None try: print("Acquiring lock") lock_fd = acquire_lock() print("Setting up logging") setup_logging() print("Updating system activity to running") update_system_activity(SCRIPT_NAME, "running", os.getpid()) print("Checking author state file") author_state_file = "/home/shane/foodie_automator/author_state.json" if not os.path.exists(author_state_file): print(f"Author state file not found: {author_state_file}") logging.error(f"Author state file not found: {author_state_file}") raise FileNotFoundError(f"Author state file not found: {author_state_file}") print(f"Author state file exists: {author_state_file}") print("Posting engagement tweet") posted, sleep_time = post_engagement_tweet() print("Updating system activity to stopped") update_system_activity(SCRIPT_NAME, "stopped") print(f"Run completed, posted: {posted}, sleep_time: {sleep_time}") logging.info(f"Run completed, posted: {posted}, sleep_time: {sleep_time} seconds") return posted, sleep_time except Exception as e: print(f"Exception in main: {e}") logging.error(f"Fatal error in main: {e}", exc_info=True) print(f"Fatal error: {e}") update_system_activity(SCRIPT_NAME, "stopped") sleep_time = 86400 # 1 day for cron print(f"Run completed, sleep_time: {sleep_time}") logging.info(f"Run completed, sleep_time: {sleep_time} seconds") return False, sleep_time finally: if lock_fd: print("Releasing lock") fcntl.flock(lock_fd, fcntl.LOCK_UN) lock_fd.close() os.remove(LOCK_FILE) if os.path.exists(LOCK_FILE) else None print(f"Lock file removed: {LOCK_FILE}") if __name__ == "__main__": posted, sleep_time = main()