···1+import os
2+import logging
3+from typing import Optional, Dict, Any
4+from atproto_client import Client, Session, SessionEvent, models
5+6+# Configure logging
7+logging.basicConfig(
8+ level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
9+)
10+logger = logging.getLogger("bluesky_session_handler")
11+12+# Load the environment variables
13+import dotenv
14+dotenv.load_dotenv(override=True)
15+16+import yaml
17+import json
18+19+# Strip fields. A list of fields to remove from a JSON object
20+STRIP_FIELDS = [
21+ "cid",
22+ "rev",
23+ "did",
24+ "uri",
25+ "langs",
26+ "threadgate",
27+ "py_type",
28+ "labels",
29+ "facets",
30+ "avatar",
31+ "viewer",
32+ "indexed_at",
33+ "tags",
34+ "associated",
35+ "thread_context",
36+ "image",
37+ "aspect_ratio",
38+ "thumb",
39+ "fullsize",
40+ "root",
41+ "created_at",
42+ "verification",
43+ "like_count",
44+ "quote_count",
45+ "reply_count",
46+ "repost_count",
47+ "embedding_disabled",
48+ "thread_muted",
49+ "reply_disabled",
50+ "pinned",
51+ "like",
52+ "repost",
53+ "blocked_by",
54+ "blocking",
55+ "blocking_by_list",
56+ "followed_by",
57+ "following",
58+ "known_followers",
59+ "muted",
60+ "muted_by_list",
61+ "root_author_like",
62+ "embed",
63+ "entities",
64+]
65+def convert_to_basic_types(obj):
66+ """Convert complex Python objects to basic types for JSON/YAML serialization."""
67+ if hasattr(obj, '__dict__'):
68+ # Convert objects with __dict__ to their dictionary representation
69+ return convert_to_basic_types(obj.__dict__)
70+ elif isinstance(obj, dict):
71+ return {key: convert_to_basic_types(value) for key, value in obj.items()}
72+ elif isinstance(obj, list):
73+ return [convert_to_basic_types(item) for item in obj]
74+ elif isinstance(obj, (str, int, float, bool)) or obj is None:
75+ return obj
76+ else:
77+ # For other types, try to convert to string
78+ return str(obj)
79+80+81+def strip_fields(obj, strip_field_list):
82+ """Recursively strip fields from a JSON object."""
83+ if isinstance(obj, dict):
84+ keys_flagged_for_removal = []
85+86+ # Remove fields from strip list and pydantic metadata
87+ for field in list(obj.keys()):
88+ if field in strip_field_list or field.startswith("__"):
89+ keys_flagged_for_removal.append(field)
90+91+ # Remove flagged keys
92+ for key in keys_flagged_for_removal:
93+ obj.pop(key, None)
94+95+ # Recursively process remaining values
96+ for key, value in list(obj.items()):
97+ obj[key] = strip_fields(value, strip_field_list)
98+ # Remove empty/null values after processing
99+ if (
100+ obj[key] is None
101+ or (isinstance(obj[key], dict) and len(obj[key]) == 0)
102+ or (isinstance(obj[key], list) and len(obj[key]) == 0)
103+ or (isinstance(obj[key], str) and obj[key].strip() == "")
104+ ):
105+ obj.pop(key, None)
106+107+ elif isinstance(obj, list):
108+ for i, value in enumerate(obj):
109+ obj[i] = strip_fields(value, strip_field_list)
110+ # Remove None values from list
111+ obj[:] = [item for item in obj if item is not None]
112+113+ return obj
114+115+116+def thread_to_yaml_string(thread, strip_metadata=True):
117+ """
118+ Convert thread data to a YAML-formatted string for LLM parsing.
119+120+ Args:
121+ thread: The thread data from get_post_thread
122+ strip_metadata: Whether to strip metadata fields for cleaner output
123+124+ Returns:
125+ YAML-formatted string representation of the thread
126+ """
127+ # First convert complex objects to basic types
128+ basic_thread = convert_to_basic_types(thread)
129+130+ if strip_metadata:
131+ # Create a copy and strip unwanted fields
132+ cleaned_thread = strip_fields(basic_thread, STRIP_FIELDS)
133+ else:
134+ cleaned_thread = basic_thread
135+136+ return yaml.dump(cleaned_thread, indent=2, allow_unicode=True, default_flow_style=False)
137+138+139+140+141+142+def get_session(username: str) -> Optional[str]:
143+ try:
144+ with open(f"session_{username}.txt", encoding="UTF-8") as f:
145+ return f.read()
146+ except FileNotFoundError:
147+ logger.debug(f"No existing session found for {username}")
148+ return None
149+150+def save_session(username: str, session_string: str) -> None:
151+ with open(f"session_{username}.txt", "w", encoding="UTF-8") as f:
152+ f.write(session_string)
153+ logger.debug(f"Session saved for {username}")
154+155+def on_session_change(username: str, event: SessionEvent, session: Session) -> None:
156+ logger.info(f"Session changed: {event} {repr(session)}")
157+ if event in (SessionEvent.CREATE, SessionEvent.REFRESH):
158+ logger.info(f"Saving changed session for {username}")
159+ save_session(username, session.export())
160+161+def init_client(username: str, password: str) -> Client:
162+ pds_uri = os.getenv("PDS_URI")
163+ if pds_uri is None:
164+ logger.warning(
165+ "No PDS URI provided. Falling back to bsky.social. Note! If you are on a non-Bluesky PDS, this can cause logins to fail. Please provide a PDS URI using the PDS_URI environment variable."
166+ )
167+ pds_uri = "https://bsky.social"
168+169+ # Print the PDS URI
170+ logger.info(f"Using PDS URI: {pds_uri}")
171+172+ client = Client(pds_uri)
173+ client.on_session_change(
174+ lambda event, session: on_session_change(username, event, session)
175+ )
176+177+ session_string = get_session(username)
178+ if session_string:
179+ logger.info(f"Reusing existing session for {username}")
180+ client.login(session_string=session_string)
181+ else:
182+ logger.info(f"Creating new session for {username}")
183+ client.login(username, password)
184+185+ return client
186+187+188+def default_login() -> Client:
189+ username = os.getenv("BSKY_USERNAME")
190+ password = os.getenv("BSKY_PASSWORD")
191+192+ if username is None:
193+ logger.error(
194+ "No username provided. Please provide a username using the BSKY_USERNAME environment variable."
195+ )
196+ exit()
197+198+ if password is None:
199+ logger.error(
200+ "No password provided. Please provide a password using the BSKY_PASSWORD environment variable."
201+ )
202+ exit()
203+204+ return init_client(username, password)
205+206+def reply_to_post(client: Client, text: str, reply_to_uri: str, reply_to_cid: str, root_uri: Optional[str] = None, root_cid: Optional[str] = None) -> Dict[str, Any]:
207+ """
208+ Reply to a post on Bluesky.
209+210+ Args:
211+ client: Authenticated Bluesky client
212+ text: The reply text
213+ reply_to_uri: The URI of the post being replied to (parent)
214+ reply_to_cid: The CID of the post being replied to (parent)
215+ root_uri: The URI of the root post (if replying to a reply). If None, uses reply_to_uri
216+ root_cid: The CID of the root post (if replying to a reply). If None, uses reply_to_cid
217+218+ Returns:
219+ The response from sending the post
220+ """
221+ # If root is not provided, this is a reply to the root post
222+ if root_uri is None:
223+ root_uri = reply_to_uri
224+ root_cid = reply_to_cid
225+226+ # Create references for the reply
227+ parent_ref = models.create_strong_ref(models.ComAtprotoRepoStrongRef.Main(uri=reply_to_uri, cid=reply_to_cid))
228+ root_ref = models.create_strong_ref(models.ComAtprotoRepoStrongRef.Main(uri=root_uri, cid=root_cid))
229+230+ # Send the reply
231+ response = client.send_post(
232+ text=text,
233+ reply_to=models.AppBskyFeedPost.ReplyRef(parent=parent_ref, root=root_ref)
234+ )
235+236+ logger.info(f"Reply sent successfully: {response.uri}")
237+ return response
238+239+240+def get_post_thread(client: Client, uri: str) -> Optional[Dict[str, Any]]:
241+ """
242+ Get the thread containing a post to find root post information.
243+244+ Args:
245+ client: Authenticated Bluesky client
246+ uri: The URI of the post
247+248+ Returns:
249+ The thread data or None if not found
250+ """
251+ try:
252+ thread = client.app.bsky.feed.get_post_thread({'uri': uri, 'parent_height': 80, 'depth': 10})
253+ return thread
254+ except Exception as e:
255+ logger.error(f"Error fetching post thread: {e}")
256+ return None
257+258+259+def reply_to_notification(client: Client, notification: Any, reply_text: str) -> Optional[Dict[str, Any]]:
260+ """
261+ Reply to a notification (mention or reply).
262+263+ Args:
264+ client: Authenticated Bluesky client
265+ notification: The notification object from list_notifications
266+ reply_text: The text to reply with
267+268+ Returns:
269+ The response from sending the reply or None if failed
270+ """
271+ try:
272+ # Get the post URI and CID from the notification (handle both dict and object)
273+ if isinstance(notification, dict):
274+ post_uri = notification.get('uri')
275+ post_cid = notification.get('cid')
276+ elif hasattr(notification, 'uri') and hasattr(notification, 'cid'):
277+ post_uri = notification.uri
278+ post_cid = notification.cid
279+ else:
280+ post_uri = None
281+ post_cid = None
282+283+ if not post_uri or not post_cid:
284+ logger.error("Notification doesn't have required uri/cid fields")
285+ return None
286+287+ # Get the thread to find the root post
288+ thread_data = get_post_thread(client, post_uri)
289+290+ if thread_data and hasattr(thread_data, 'thread'):
291+ thread = thread_data.thread
292+293+ # Find root post
294+ root_uri = post_uri
295+ root_cid = post_cid
296+297+ # If this has a parent, find the root
298+ if hasattr(thread, 'parent') and thread.parent:
299+ # Keep going up until we find the root
300+ current = thread
301+ while hasattr(current, 'parent') and current.parent:
302+ current = current.parent
303+ if hasattr(current, 'post') and hasattr(current.post, 'uri') and hasattr(current.post, 'cid'):
304+ root_uri = current.post.uri
305+ root_cid = current.post.cid
306+307+ # Reply to the notification
308+ return reply_to_post(
309+ client=client,
310+ text=reply_text,
311+ reply_to_uri=post_uri,
312+ reply_to_cid=post_cid,
313+ root_uri=root_uri,
314+ root_cid=root_cid
315+ )
316+ else:
317+ # If we can't get thread data, just reply directly
318+ return reply_to_post(
319+ client=client,
320+ text=reply_text,
321+ reply_to_uri=post_uri,
322+ reply_to_cid=post_cid
323+ )
324+325+ except Exception as e:
326+ logger.error(f"Error replying to notification: {e}")
327+ return None
328+329+330+if __name__ == "__main__":
331+ client = default_login()
332+ # do something with the client
333+ logger.info("Client is ready to use!")
···1+from letta_client import Letta
2+from typing import Optional
3+4+def upsert_block(letta: Letta, label: str, value: str, **kwargs):
5+ """
6+ Ensures that a block by this label exists. If the block exists, it will
7+ replace content provided by kwargs with the values in this function call.
8+ """
9+ # Get the list of blocks
10+ blocks = letta.blocks.list(label=label)
11+12+ # Check if we had any -- if not, create it
13+ if len(blocks) == 0:
14+ # Make the new block
15+ new_block = letta.blocks.create(
16+ label=label,
17+ value=value,
18+ **kwargs
19+ )
20+21+ return new_block
22+23+ if len(blocks) > 1:
24+ raise Exception(f"{len(blocks)} blocks by the label '{label}' retrieved, label must identify a unique block")
25+26+ else:
27+ existing_block = blocks[0]
28+29+ if kwargs.get('update', False):
30+ # Remove 'update' from kwargs before passing to modify
31+ kwargs_copy = kwargs.copy()
32+ kwargs_copy.pop('update', None)
33+34+ updated_block = letta.blocks.modify(
35+ block_id = existing_block.id,
36+ label = label,
37+ value = value,
38+ **kwargs_copy
39+ )
40+41+ return updated_block
42+ else:
43+ return existing_block
44+45+def upsert_agent(letta: Letta, name: str, **kwargs):
46+ """
47+ Ensures that an agent by this label exists. If the agent exists, it will
48+ update the agent to match kwargs.
49+ """
50+ # Get the list of agents
51+ agents = letta.agents.list(name=name)
52+53+ # Check if we had any -- if not, create it
54+ if len(agents) == 0:
55+ # Make the new agent
56+ new_agent = letta.agents.create(
57+ name=name,
58+ **kwargs
59+ )
60+61+ return new_agent
62+63+ if len(agents) > 1:
64+ raise Exception(f"{len(agents)} agents by the label '{label}' retrieved, label must identify a unique agent")
65+66+ else:
67+ existing_agent = agents[0]
68+69+ if kwargs.get('update', False):
70+ # Remove 'update' from kwargs before passing to modify
71+ kwargs_copy = kwargs.copy()
72+ kwargs_copy.pop('update', None)
73+74+ updated_agent = letta.agents.modify(
75+ agent_id = existing_agent.id,
76+ **kwargs_copy
77+ )
78+79+ return updated_agent
80+ else:
81+ return existing_agent
82+83+84+85+86+87+88+89+90+91+92+93+