+120
-70
bsky.py
+120
-70
bsky.py
···
11
11
from datetime import datetime
12
12
from collections import defaultdict
13
13
import time
14
+
import argparse
14
15
15
16
from utils import (
16
17
upsert_block,
···
80
81
# Message tracking counters
81
82
message_counters = defaultdict(int)
82
83
start_time = time.time()
84
+
85
+
# Testing mode flag
86
+
TESTING_MODE = False
83
87
84
88
def export_agent_state(client, agent):
85
89
"""Export agent state to agent_archive/ (timestamped) and agents/ (current)."""
···
186
190
return void_agent
187
191
188
192
189
-
def process_mention(void_agent, atproto_client, notification_data, queue_filepath=None):
193
+
def process_mention(void_agent, atproto_client, notification_data, queue_filepath=None, testing_mode=False):
190
194
"""Process a mention and generate a reply using the Letta agent.
191
195
192
196
Args:
···
474
478
logger.debug("Successfully received response from Letta API")
475
479
logger.debug(f"Number of messages in response: {len(message_response.messages) if hasattr(message_response, 'messages') else 'N/A'}")
476
480
477
-
# Extract successful bluesky_reply tool calls from the agent's response
481
+
# Extract successful add_post_to_bluesky_reply_thread tool calls from the agent's response
478
482
reply_candidates = []
479
483
tool_call_results = {} # Map tool_call_id to status
480
484
···
483
487
# First pass: collect tool return statuses
484
488
for message in message_response.messages:
485
489
if hasattr(message, 'tool_call_id') and hasattr(message, 'status') and hasattr(message, 'name'):
486
-
if message.name == 'bluesky_reply':
490
+
if message.name == 'add_post_to_bluesky_reply_thread':
487
491
tool_call_results[message.tool_call_id] = message.status
488
492
logger.debug(f"Tool result: {message.tool_call_id} -> {message.status}")
493
+
elif message.name == 'bluesky_reply':
494
+
logger.error("❌ DEPRECATED TOOL DETECTED: bluesky_reply is no longer supported!")
495
+
logger.error("Please use add_post_to_bluesky_reply_thread instead.")
496
+
logger.error("Update the agent's tools using register_tools.py")
497
+
# Export agent state before terminating
498
+
export_agent_state(CLIENT, void_agent)
499
+
logger.info("=== BOT TERMINATED DUE TO DEPRECATED TOOL USE ===")
500
+
exit(1)
489
501
490
502
# Second pass: process messages and check for successful tool calls
491
503
for i, message in enumerate(message_response.messages, 1):
···
534
546
logger.info("=== BOT TERMINATED BY AGENT ===")
535
547
exit(0)
536
548
537
-
# Collect bluesky_reply tool calls - only if they were successful
549
+
# Check for deprecated bluesky_reply tool
538
550
if hasattr(message, 'tool_call') and message.tool_call:
539
551
if message.tool_call.name == 'bluesky_reply':
552
+
logger.error("❌ DEPRECATED TOOL DETECTED: bluesky_reply is no longer supported!")
553
+
logger.error("Please use add_post_to_bluesky_reply_thread instead.")
554
+
logger.error("Update the agent's tools using register_tools.py")
555
+
# Export agent state before terminating
556
+
export_agent_state(CLIENT, void_agent)
557
+
logger.info("=== BOT TERMINATED DUE TO DEPRECATED TOOL USE ===")
558
+
exit(1)
559
+
560
+
# Collect add_post_to_bluesky_reply_thread tool calls - only if they were successful
561
+
elif message.tool_call.name == 'add_post_to_bluesky_reply_thread':
540
562
tool_call_id = message.tool_call.tool_call_id
541
563
tool_status = tool_call_results.get(tool_call_id, 'unknown')
542
564
543
565
if tool_status == 'success':
544
566
try:
545
567
args = json.loads(message.tool_call.arguments)
546
-
# Handle both old format (message) and new format (messages)
547
-
reply_messages = args.get('messages', [])
548
-
if not reply_messages:
549
-
# Fallback to old format for backward compatibility
550
-
old_message = args.get('message', '')
551
-
if old_message:
552
-
reply_messages = [old_message]
568
+
reply_text = args.get('text', '')
569
+
reply_lang = args.get('lang', 'en-US')
553
570
554
-
reply_lang = args.get('lang', 'en-US')
555
-
if reply_messages: # Only add if there's actual content
556
-
reply_candidates.append((reply_messages, reply_lang))
557
-
if len(reply_messages) == 1:
558
-
logger.info(f"Found successful bluesky_reply candidate: {reply_messages[0][:50]}... (lang: {reply_lang})")
559
-
else:
560
-
logger.info(f"Found successful bluesky_reply thread candidate with {len(reply_messages)} messages (lang: {reply_lang})")
571
+
if reply_text: # Only add if there's actual content
572
+
reply_candidates.append((reply_text, reply_lang))
573
+
logger.info(f"Found successful add_post_to_bluesky_reply_thread candidate: {reply_text[:50]}... (lang: {reply_lang})")
561
574
except json.JSONDecodeError as e:
562
575
logger.error(f"Failed to parse tool call arguments: {e}")
563
576
elif tool_status == 'error':
564
-
logger.info(f"⚠️ Skipping failed bluesky_reply tool call (status: error)")
577
+
logger.info(f"⚠️ Skipping failed add_post_to_bluesky_reply_thread tool call (status: error)")
565
578
else:
566
-
logger.warning(f"⚠️ Skipping bluesky_reply tool call with unknown status: {tool_status}")
579
+
logger.warning(f"⚠️ Skipping add_post_to_bluesky_reply_thread tool call with unknown status: {tool_status}")
567
580
568
581
if reply_candidates:
569
-
logger.info(f"Found {len(reply_candidates)} successful bluesky_reply candidates, using only the first one to avoid duplicates")
582
+
# Aggregate reply posts into a thread
583
+
reply_messages = []
584
+
reply_langs = []
585
+
for text, lang in reply_candidates:
586
+
reply_messages.append(text)
587
+
reply_langs.append(lang)
570
588
571
-
# Only use the first successful reply to avoid sending multiple responses
572
-
reply_messages, reply_lang = reply_candidates[0]
589
+
# Use the first language for the entire thread (could be enhanced later)
590
+
reply_lang = reply_langs[0] if reply_langs else 'en-US'
591
+
592
+
logger.info(f"Found {len(reply_candidates)} add_post_to_bluesky_reply_thread calls, building thread")
573
593
574
594
# Print the generated reply for testing
575
-
print(f"\n=== GENERATED REPLY (FIRST SUCCESSFUL) ===")
595
+
print(f"\n=== GENERATED REPLY THREAD ===")
576
596
print(f"To: @{author_handle}")
577
597
if len(reply_messages) == 1:
578
598
print(f"Reply: {reply_messages[0]}")
···
581
601
for j, msg in enumerate(reply_messages, 1):
582
602
print(f" {j}. {msg}")
583
603
print(f"Language: {reply_lang}")
584
-
if len(reply_candidates) > 1:
585
-
print(f"Note: Skipped {len(reply_candidates) - 1} additional successful candidates to avoid duplicates")
586
604
print(f"======================\n")
587
605
588
-
# Send the reply(s) with language
589
-
if len(reply_messages) == 1:
590
-
# Single reply - use existing function
591
-
cleaned_text = bsky_utils.remove_outside_quotes(reply_messages[0])
592
-
logger.info(f"Sending single reply: {cleaned_text[:50]}... (lang: {reply_lang})")
593
-
response = bsky_utils.reply_to_notification(
594
-
client=atproto_client,
595
-
notification=notification_data,
596
-
reply_text=cleaned_text,
597
-
lang=reply_lang
598
-
)
606
+
# Send the reply(s) with language (unless in testing mode)
607
+
if testing_mode:
608
+
logger.info("🧪 TESTING MODE: Skipping actual Bluesky post")
609
+
response = True # Simulate success
599
610
else:
600
-
# Multiple replies - use new threaded function
601
-
cleaned_messages = [bsky_utils.remove_outside_quotes(msg) for msg in reply_messages]
602
-
logger.info(f"Sending threaded reply with {len(cleaned_messages)} messages (lang: {reply_lang})")
603
-
response = bsky_utils.reply_with_thread_to_notification(
604
-
client=atproto_client,
605
-
notification=notification_data,
606
-
reply_messages=cleaned_messages,
607
-
lang=reply_lang
608
-
)
611
+
if len(reply_messages) == 1:
612
+
# Single reply - use existing function
613
+
cleaned_text = bsky_utils.remove_outside_quotes(reply_messages[0])
614
+
logger.info(f"Sending single reply: {cleaned_text[:50]}... (lang: {reply_lang})")
615
+
response = bsky_utils.reply_to_notification(
616
+
client=atproto_client,
617
+
notification=notification_data,
618
+
reply_text=cleaned_text,
619
+
lang=reply_lang
620
+
)
621
+
else:
622
+
# Multiple replies - use new threaded function
623
+
cleaned_messages = [bsky_utils.remove_outside_quotes(msg) for msg in reply_messages]
624
+
logger.info(f"Sending threaded reply with {len(cleaned_messages)} messages (lang: {reply_lang})")
625
+
response = bsky_utils.reply_with_thread_to_notification(
626
+
client=atproto_client,
627
+
notification=notification_data,
628
+
reply_messages=cleaned_messages,
629
+
lang=reply_lang
630
+
)
609
631
610
632
if response:
611
633
logger.info(f"Successfully replied to @{author_handle}")
···
614
636
logger.error(f"Failed to send reply to @{author_handle}")
615
637
return False
616
638
else:
617
-
logger.warning(f"No bluesky_reply tool calls found for mention from @{author_handle}, removing notification from queue")
618
-
return True
639
+
logger.warning(f"No add_post_to_bluesky_reply_thread tool calls found for mention from @{author_handle}, keeping notification in queue")
640
+
return False
619
641
620
642
except Exception as e:
621
643
logger.error(f"Error processing mention: {e}")
···
702
724
filename = f"{priority_prefix}{timestamp}_{notification.reason}_{notif_hash}.json"
703
725
filepath = QUEUE_DIR / filename
704
726
705
-
# Skip if already exists (duplicate)
706
-
if filepath.exists():
707
-
logger.debug(f"Notification already queued: {filename}")
708
-
return False
727
+
# Check if this notification URI is already in the queue
728
+
for existing_file in QUEUE_DIR.glob("*.json"):
729
+
if existing_file.name == "processed_notifications.json":
730
+
continue
731
+
try:
732
+
with open(existing_file, 'r') as f:
733
+
existing_data = json.load(f)
734
+
if existing_data.get('uri') == notification.uri:
735
+
logger.debug(f"Notification already queued (URI: {notification.uri})")
736
+
return False
737
+
except:
738
+
continue
709
739
710
740
# Write to file
711
741
with open(filepath, 'w') as f:
···
720
750
return False
721
751
722
752
723
-
def load_and_process_queued_notifications(void_agent, atproto_client):
753
+
def load_and_process_queued_notifications(void_agent, atproto_client, testing_mode=False):
724
754
"""Load and process all notifications from the queue in priority order."""
725
755
try:
726
756
# Get all JSON files in queue directory (excluding processed_notifications.json)
···
749
779
# Process based on type using dict data directly
750
780
success = False
751
781
if notif_data['reason'] == "mention":
752
-
success = process_mention(void_agent, atproto_client, notif_data, queue_filepath=filepath)
782
+
success = process_mention(void_agent, atproto_client, notif_data, queue_filepath=filepath, testing_mode=testing_mode)
753
783
if success:
754
784
message_counters['mentions'] += 1
755
785
elif notif_data['reason'] == "reply":
756
-
success = process_mention(void_agent, atproto_client, notif_data, queue_filepath=filepath)
786
+
success = process_mention(void_agent, atproto_client, notif_data, queue_filepath=filepath, testing_mode=testing_mode)
757
787
if success:
758
788
message_counters['replies'] += 1
759
789
elif notif_data['reason'] == "follow":
···
779
809
780
810
# Handle file based on processing result
781
811
if success:
782
-
filepath.unlink()
783
-
logger.info(f"✅ Successfully processed and removed: {filepath.name}")
784
-
785
-
# Mark as processed to avoid reprocessing
786
-
processed_uris = load_processed_notifications()
787
-
processed_uris.add(notif_data['uri'])
788
-
save_processed_notifications(processed_uris)
812
+
if testing_mode:
813
+
logger.info(f"🧪 TESTING MODE: Keeping queue file: {filepath.name}")
814
+
else:
815
+
filepath.unlink()
816
+
logger.info(f"✅ Successfully processed and removed: {filepath.name}")
817
+
818
+
# Mark as processed to avoid reprocessing
819
+
processed_uris = load_processed_notifications()
820
+
processed_uris.add(notif_data['uri'])
821
+
save_processed_notifications(processed_uris)
789
822
790
823
elif success is None: # Special case for moving to error directory
791
824
error_path = QUEUE_ERROR_DIR / filepath.name
···
808
841
logger.error(f"Error loading queued notifications: {e}")
809
842
810
843
811
-
def process_notifications(void_agent, atproto_client):
844
+
def process_notifications(void_agent, atproto_client, testing_mode=False):
812
845
"""Fetch new notifications, queue them, and process the queue."""
813
846
try:
814
847
# Get current time for marking notifications as seen
···
882
915
if save_notification_to_queue(notification):
883
916
new_count += 1
884
917
885
-
# Mark all notifications as seen immediately after queuing
886
-
if new_count > 0:
887
-
atproto_client.app.bsky.notification.update_seen({'seen_at': last_seen_at})
888
-
logger.info(f"Queued {new_count} new notifications and marked as seen")
918
+
# Mark all notifications as seen immediately after queuing (unless in testing mode)
919
+
if testing_mode:
920
+
logger.info("🧪 TESTING MODE: Skipping marking notifications as seen")
889
921
else:
890
-
logger.debug("No new notifications to queue")
922
+
if new_count > 0:
923
+
atproto_client.app.bsky.notification.update_seen({'seen_at': last_seen_at})
924
+
logger.info(f"Queued {new_count} new notifications and marked as seen")
925
+
else:
926
+
logger.debug("No new notifications to queue")
891
927
892
928
# Now process the entire queue (old + new notifications)
893
-
load_and_process_queued_notifications(void_agent, atproto_client)
929
+
load_and_process_queued_notifications(void_agent, atproto_client, testing_mode)
894
930
895
931
except Exception as e:
896
932
logger.error(f"Error processing notifications: {e}")
897
933
898
934
899
935
def main():
936
+
# Parse command line arguments
937
+
parser = argparse.ArgumentParser(description='Void Bot - Bluesky autonomous agent')
938
+
parser.add_argument('--test', action='store_true', help='Run in testing mode (no messages sent, queue files preserved)')
939
+
args = parser.parse_args()
940
+
941
+
global TESTING_MODE
942
+
TESTING_MODE = args.test
943
+
944
+
if TESTING_MODE:
945
+
logger.info("🧪 === RUNNING IN TESTING MODE ===")
946
+
logger.info(" - No messages will be sent to Bluesky")
947
+
logger.info(" - Queue files will not be deleted")
948
+
logger.info(" - Notifications will not be marked as seen")
949
+
print("\n")
900
950
"""Main bot loop that continuously monitors for notifications."""
901
951
global start_time
902
952
start_time = time.time()
···
925
975
while True:
926
976
try:
927
977
cycle_count += 1
928
-
process_notifications(void_agent, atproto_client)
978
+
process_notifications(void_agent, atproto_client, TESTING_MODE)
929
979
# Log cycle completion with stats
930
980
elapsed_time = time.time() - start_time
931
981
total_messages = sum(message_counters.values())