@ -12,6 +12,7 @@ import shutil
import requests
import requests
import time
import time
import openai
import openai
from requests_oauthlib import OAuth1
from dotenv import load_dotenv
from dotenv import load_dotenv
from datetime import datetime , timezone , timedelta
from datetime import datetime , timezone , timedelta
from openai import OpenAI
from openai import OpenAI
@ -161,79 +162,68 @@ def generate_article_tweet(author, post, persona):
logging . info ( f " Generated tweet: { tweet } " )
logging . info ( f " Generated tweet: { tweet } " )
return tweet
return tweet
def post_tweet ( author , tweet , reply_to_id = None ) :
def post_tweet ( author , content , media_ids = None , reply_to_id = None ) :
"""
"""
Post a tweet after checking real - time X API rate limits .
Post a tweet for an author using X API v2 .
Updates rate_limit_info . json with API - provided data .
Returns ( tweet_id , tweet_data ) if successful , ( None , None ) if rate - limited or failed .
"""
"""
from foodie_config import X_API_CREDENTIALS
import tweepy
logger = logging . getLogger ( __name__ )
logger = logging . getLogger ( __name__ )
username = author [ ' username ' ]
credentials = X_API_CREDENTIALS . get ( author [ " username" ] )
credentials = X_API_CREDENTIALS . get ( username )
if not credentials :
if not credentials :
logger . error ( f " No X credentials found for { author [ ' username ' ] } " )
logger . error ( f " No X API credentials for { username } " )
return False
return None , None
# Check rate limit before posting
# Check rate limit
if check_author_rate_limit ( author ) :
can_post , remaining , reset = check_author_rate_limit ( author )
logger . error ( f " Cannot post tweet for { author [ ' username ' ] } : Rate limit exceeded " )
if not can_post :
return False
reset_time = datetime . fromtimestamp ( reset , tz = timezone . utc ) . strftime ( ' % Y- % m- %d % H: % M: % S ' )
logger . info ( f " Cannot post tweet for { username } : rate-limited. Remaining: { remaining } , Reset at: { reset_time } " )
logger . debug ( f " Attempting to post tweet for { author [ ' username ' ] } (handle: { credentials [ ' x_username ' ] } ) " )
return None , None
logger . debug ( f " Tweet content: { tweet } " )
oauth = OAuth1 (
client_key = credentials [ ' api_key ' ] ,
client_secret = credentials [ ' api_secret ' ] ,
resource_owner_key = credentials [ ' access_token ' ] ,
resource_owner_secret = credentials [ ' access_token_secret ' ]
)
url = ' https://api.x.com/2/tweets '
payload = { ' text ' : content }
if media_ids :
payload [ ' media ' ] = { ' media_ids ' : media_ids }
if reply_to_id :
if reply_to_id :
logger . debug ( f " Replying to tweet ID: { reply_to_id } " )
payload [ ' reply ' ] = { ' in_reply_to_tweet_id ' : reply_to_id }
rate_limit_file = ' /home/shane/foodie_automator/rate_limit_info.json '
rate_limit_info = load_json_file ( rate_limit_file , default = { } )
username = author [ " username " ]
try :
try :
client = tweepy . Client (
response = requests . post ( url , json = payload , auth = oauth )
consumer_key = credentials [ " api_key " ] ,
headers = response . headers
consumer_secret = credentials [ " api_secret " ] ,
access_token = credentials [ " access_token " ] ,
# Update rate limit info
access_token_secret = credentials [ " access_token_secret " ]
rate_limit_file = ' /home/shane/foodie_automator/rate_limit_info.json '
)
rate_limit_info = load_json_file ( rate_limit_file , default = { } )
response = client . create_tweet (
remaining = int ( headers . get ( ' x-user-limit-24hour-remaining ' , remaining ) )
text = tweet ,
reset = int ( headers . get ( ' x-user-limit-24hour-reset ' , reset ) )
in_reply_to_tweet_id = reply_to_id
rate_limit_info [ username ] = { ' tweet_remaining ' : remaining , ' tweet_reset ' : reset }
)
save_json_file ( rate_limit_file , rate_limit_info )
tweet_id = response . data [ ' id ' ]
logger . info ( f " Successfully posted tweet { tweet_id } for { author [ ' username ' ] } (handle: { credentials [ ' x_username ' ] } ): { tweet } " )
if response . status_code == 201 :
tweet_data = response . json ( )
# Update rate limit info with fresh API data
tweet_id = tweet_data . get ( ' data ' , { } ) . get ( ' id ' )
remaining , reset = get_x_rate_limit_status ( author )
logger . info ( f " Successfully tweeted for { username } : { content [ : 50 ] } ... (ID: { tweet_id } ) " )
if remaining is not None and reset is not None :
return tweet_id , tweet_data
rate_limit_info [ username ] = {
elif response . status_code == 429 :
' tweet_remaining ' : max ( 0 , remaining - 1 ) , # Account for this tweet
' tweet_reset ' : reset
}
save_json_file ( rate_limit_file , rate_limit_info )
logger . info ( f " Updated rate limit for { username } : { rate_limit_info [ username ] [ ' tweet_remaining ' ] } remaining, reset at { datetime . fromtimestamp ( reset , tz = timezone . utc ) } " )
else :
logger . warning ( f " Failed to update rate limit info for { username } after posting " )
return { " id " : tweet_id }
except tweepy . TweepyException as e :
logger . error ( f " Failed to post tweet for { author [ ' username ' ] } (handle: { credentials [ ' x_username ' ] } ): { e } " )
if hasattr ( e , ' response ' ) and e . response and e . response . status_code == 429 :
remaining , reset = get_x_rate_limit_status ( author )
if remaining is None :
remaining = 0
reset = time . time ( ) + 86400
rate_limit_info [ username ] = {
' tweet_remaining ' : remaining ,
' tweet_reset ' : reset
}
save_json_file ( rate_limit_file , rate_limit_info )
logger . info ( f " Rate limit exceeded for { username } : { remaining } remaining, reset at { datetime . fromtimestamp ( reset , tz = timezone . utc ) } " )
logger . info ( f " Rate limit exceeded for { username } : { remaining } remaining, reset at { datetime . fromtimestamp ( reset , tz = timezone . utc ) } " )
return False
return None , None
elif response . status_code == 403 :
logger . error ( f " 403 Forbidden for { username } : { response . text } " )
return None , None
else :
logger . error ( f " Failed to tweet for { username } : { response . status_code } - { response . text } " )
return None , None
except Exception as e :
except Exception as e :
logger . error ( f " Unexpected error posting tweet for { author [ ' username ' ] } (handle: { credentials [ ' x_username ' ] } ): { e } " , exc_info = True )
logger . error ( f " Unexpected error posting tweet for { username } : { e } " , exc_info = True )
return False
return None , None
def select_best_persona ( interest_score , content = " " ) :
def select_best_persona ( interest_score , content = " " ) :
logging . info ( " Using select_best_persona with interest_score and content " )
logging . info ( " Using select_best_persona with interest_score and content " )
@ -1173,15 +1163,16 @@ def check_rate_limit(response):
def check_author_rate_limit ( author , max_tweets = 17 , tweet_window_seconds = 86400 ) :
def check_author_rate_limit ( author , max_tweets = 17 , tweet_window_seconds = 86400 ) :
"""
"""
Check if an author is rate - limited for tweets using real - time X API data .
Check if an author is rate - limited for tweets using real - time X API v2 data .
Returns ( can_post , remaining , reset_timestamp ) where can_post is False if rate - limited .
Returns ( can_post , remaining , reset_timestamp ) where can_post is True if tweets are available .
Caches API results in memory for the current script run .
Caches API results in memory for 1 minute .
Falls back to rate_limit_info . json or assumes 1 tweet remaining if API fails .
"""
"""
logger = logging . getLogger ( __name__ )
logger = logging . getLogger ( __name__ )
rate_limit_file = ' /home/shane/foodie_automator/rate_limit_info.json '
rate_limit_file = ' /home/shane/foodie_automator/rate_limit_info.json '
current_time = time . time ( )
current_time = time . time ( )
# In-memory cache for rate limit status (reset per script run)
# In-memory cache
if not hasattr ( check_author_rate_limit , " cache " ) :
if not hasattr ( check_author_rate_limit , " cache " ) :
check_author_rate_limit . cache = { }
check_author_rate_limit . cache = { }
@ -1194,15 +1185,15 @@ def check_author_rate_limit(author, max_tweets=17, tweet_window_seconds=86400):
else :
else :
remaining , reset = get_x_rate_limit_status ( author )
remaining , reset = get_x_rate_limit_status ( author )
if remaining is None or reset is None :
if remaining is None or reset is None :
# Fallback: Load from rate_limit_info.json or assume rate-limited
# Fallback: Load from rate_limit_info.json or assume 1 tweet remaining
rate_limit_info = load_json_file ( rate_limit_file , default = { } )
rate_limit_info = load_json_file ( rate_limit_file , default = { } )
if username not in rate_limit_info or current_time > = rate_limit_info . get ( username , { } ) . get ( ' tweet_reset ' , 0 ) :
if username not in rate_limit_info or current_time > = rate_limit_info . get ( username , { } ) . get ( ' tweet_reset ' , 0 ) :
rate_limit_info [ username ] = {
rate_limit_info [ username ] = {
' tweet_remaining ' : 0 , # Conservative assumption
' tweet_remaining ' : 1 , # Allow one tweet to avoid blocking
' tweet_reset ' : current_time + tweet_window_seconds
' tweet_reset ' : current_time + tweet_window_seconds
}
}
save_json_file ( rate_limit_file , rate_limit_info )
save_json_file ( rate_limit_file , rate_limit_info )
remaining = rate_limit_info [ username ] . get ( ' tweet_remaining ' , 0 )
remaining = rate_limit_info [ username ] . get ( ' tweet_remaining ' , 1 )
reset = rate_limit_info [ username ] . get ( ' tweet_reset ' , current_time + tweet_window_seconds )
reset = rate_limit_info [ username ] . get ( ' tweet_reset ' , current_time + tweet_window_seconds )
logger . warning ( f " X API rate limit check failed for { username } , using fallback: { remaining } remaining " )
logger . warning ( f " X API rate limit check failed for { username } , using fallback: { remaining } remaining " )
check_author_rate_limit . cache [ cache_key ] = ( remaining , reset )
check_author_rate_limit . cache [ cache_key ] = ( remaining , reset )
@ -1214,6 +1205,11 @@ def check_author_rate_limit(author, max_tweets=17, tweet_window_seconds=86400):
else :
else :
logger . info ( f " Rate limit for { username } : { remaining } / { max_tweets } tweets remaining " )
logger . info ( f " Rate limit for { username } : { remaining } / { max_tweets } tweets remaining " )
# Update rate_limit_info.json
rate_limit_info = load_json_file ( rate_limit_file , default = { } )
rate_limit_info [ username ] = { ' tweet_remaining ' : remaining , ' tweet_reset ' : reset }
save_json_file ( rate_limit_file , rate_limit_info )
return can_post , remaining , reset
return can_post , remaining , reset
def get_next_author_round_robin ( ) :
def get_next_author_round_robin ( ) :
@ -1238,47 +1234,62 @@ def get_next_author_round_robin():
def get_x_rate_limit_status ( author ) :
def get_x_rate_limit_status ( author ) :
"""
"""
Query X API for the user ' s tweet rate limit status.
Check the X API v2 rate limit status for an author by attempting a test tweet .
Returns ( remaining , reset_timestamp ) or ( None , None ) if the query fails .
Returns ( remaining , reset ) where remaining is the number of tweets left in the 24 - hour window ,
and reset is the Unix timestamp when the limit resets .
Returns ( None , None ) if the check fails .
"""
"""
from foodie_config import X_API_CREDENTIALS
import tweepy
logger = logging . getLogger ( __name__ )
logger = logging . getLogger ( __name__ )
username = author [ ' username ' ]
credentials = X_API_CREDENTIALS . get ( author [ " username" ] )
credentials = X_API_CREDENTIALS . get ( username )
if not credentials :
if not credentials :
logger . error ( f " No X credentials for { author [ ' username ' ] } " )
logger . error ( f " No X API credentials found for { username } " )
return None , None
return None , None
oauth = OAuth1 (
client_key = credentials [ ' api_key ' ] ,
client_secret = credentials [ ' api_secret ' ] ,
resource_owner_key = credentials [ ' access_token ' ] ,
resource_owner_secret = credentials [ ' access_token_secret ' ]
)
url = ' https://api.x.com/2/tweets '
payload = { ' text ' : f ' Test tweet to check rate limits for { username } - please ignore ' }
try :
try :
client = tweepy . Client (
response = requests . post ( url , json = payload , auth = oauth )
consumer_key = credentials [ " api_key " ] ,
headers = response . headers
consumer_secret = credentials [ " api_secret " ] ,
access_token = credentials [ " access_token " ] ,
# Extract rate limit info from headers
access_token_secret = credentials [ " access_token_secret " ]
remaining = int ( headers . get ( ' x-user-limit-24hour-remaining ' , 0 ) )
)
reset = int ( headers . get ( ' x-user-limit-24hour-reset ' , 0 ) )
# Tweepy v2 doesn't directly expose rate limit status, so use API v1.1 for rate limit check
api = tweepy . API (
if response . status_code == 201 :
tweepy . OAuth1UserHandler (
# Tweet posted successfully, delete it
consumer_key = credentials [ " api_key " ] ,
tweet_id = response . json ( ) . get ( ' data ' , { } ) . get ( ' id ' )
consumer_secret = credentials [ " api_secret " ] ,
if tweet_id :
access_token = credentials [ " access_token " ] ,
delete_url = f ' https://api.x.com/2/tweets/ { tweet_id } '
access_token_secret = credentials [ " access_token_secret " ]
delete_response = requests . delete ( delete_url , auth = oauth )
)
if delete_response . status_code == 200 :
)
logger . info ( f " Successfully deleted test tweet { tweet_id } for { username } " )
rate_limits = api . rate_limit_status ( )
else :
tweet_limits = rate_limits [ " resources " ] [ " statuses " ] [ " /statuses/update " ]
logger . warning ( f " Failed to delete test tweet { tweet_id } for { username } : { delete_response . status_code } " )
remaining = tweet_limits [ " remaining " ]
elif response . status_code == 429 :
reset = tweet_limits [ " reset " ]
# Rate limit exceeded
logger . info ( f " X API rate limit for { author [ ' username ' ] } : { remaining } remaining, reset at { datetime . fromtimestamp ( reset , tz = timezone . utc ) } " )
logger . info ( f " Rate limit exceeded for { username } : { remaining } remaining, reset at { datetime . fromtimestamp ( reset , tz = timezone . utc ) } " )
elif response . status_code == 403 :
# Forbidden (e.g., account restrictions), but headers may still provide rate limit info
logger . warning ( f " 403 Forbidden for { username } , but rate limit info available: { remaining } remaining, reset at { datetime . fromtimestamp ( reset , tz = timezone . utc ) } " )
else :
logger . error ( f " Unexpected response for { username } : { response . status_code } - { response . text } " )
return None , None
logger . info ( f " Rate limit for { username } : { remaining } remaining, reset at { datetime . fromtimestamp ( reset , tz = timezone . utc ) } " )
return remaining , reset
return remaining , reset
except tweepy . TweepyException as e :
logger . error ( f " Failed to fetch X rate limit for { author [ ' username ' ] } : { e } " )
return None , None
except Exception as e :
except Exception as e :
logger . error ( f " Unexpected error fetching X rate limit for { author [ ' username ' ] } : { e } " , exc_info = True )
logger . error ( f " Unexpected error fetching X rate limit for { username } : { e } " , exc_info = True )
return None , None
return None , None
def prepare_post_data ( summary , title , main_topic = None ) :
def prepare_post_data ( summary , title , main_topic = None ) :
try :
try :
logging . info ( f " Preparing post data for summary: { summary [ : 100 ] } ... " )
logging . info ( f " Preparing post data for summary: { summary [ : 100 ] } ... " )