a digital person for bluesky
1"""Feed tool for retrieving Bluesky feeds."""
2from pydantic import BaseModel, Field
3from typing import Optional
4
5
6class FeedArgs(BaseModel):
7 feed_name: Optional[str] = Field(None, description="Named feed preset. Available feeds: 'home' (timeline), 'discover' (what's hot), 'ai-for-grownups', 'atmosphere', 'void-cafe'. If not provided, returns home timeline")
8 max_posts: int = Field(default=25, description="Maximum number of posts to retrieve (max 100)")
9
10
11def get_bluesky_feed(feed_name: str = None, max_posts: int = 25) -> str:
12 """
13 Retrieve a Bluesky feed.
14
15 Args:
16 feed_name: Named feed preset - available options: 'home', 'discover', 'ai-for-grownups', 'atmosphere', 'void-cafe'
17 max_posts: Maximum number of posts to retrieve (max 100)
18
19 Returns:
20 YAML-formatted feed data with posts and metadata
21 """
22 import os
23 import yaml
24 import requests
25
26 try:
27 # Predefined feed mappings (must be inside function for sandboxing)
28 feed_presets = {
29 "home": None, # Home timeline (default)
30 "discover": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot",
31 "ai-for-grownups": "at://did:plc:gfrmhdmjvxn2sjedzboeudef/app.bsky.feed.generator/ai-for-grownups",
32 "atmosphere": "at://did:plc:gfrmhdmjvxn2sjedzboeudef/app.bsky.feed.generator/the-atmosphere",
33 "void-cafe": "at://did:plc:gfrmhdmjvxn2sjedzboeudef/app.bsky.feed.generator/void-cafe"
34 }
35
36 # Validate inputs
37 max_posts = min(max_posts, 100)
38
39 # Resolve feed URI from name
40 if feed_name:
41 # Handle case where agent passes 'FeedName.discover' instead of 'discover'
42 if '.' in feed_name and feed_name.startswith('FeedName.'):
43 feed_name = feed_name.split('.', 1)[1]
44
45 # Look up named preset
46 if feed_name not in feed_presets:
47 available_feeds = list(feed_presets.keys())
48 raise Exception(f"Invalid feed name '{feed_name}'. Available feeds: {available_feeds}")
49 resolved_feed_uri = feed_presets[feed_name]
50 feed_display_name = feed_name
51 else:
52 # Default to home timeline
53 resolved_feed_uri = None
54 feed_display_name = "home"
55
56 # Get credentials from environment
57 username = os.getenv("BSKY_USERNAME")
58 password = os.getenv("BSKY_PASSWORD")
59 pds_host = os.getenv("PDS_URI", "https://bsky.social")
60
61 if not username or not password:
62 raise Exception("BSKY_USERNAME and BSKY_PASSWORD environment variables must be set")
63
64 # Create session
65 session_url = f"{pds_host}/xrpc/com.atproto.server.createSession"
66 session_data = {
67 "identifier": username,
68 "password": password
69 }
70
71 try:
72 session_response = requests.post(session_url, json=session_data, timeout=10)
73 session_response.raise_for_status()
74 session = session_response.json()
75 access_token = session.get("accessJwt")
76
77 if not access_token:
78 raise Exception("Failed to get access token from session")
79 except Exception as e:
80 raise Exception(f"Authentication failed. ({str(e)})")
81
82 # Get feed
83 headers = {"Authorization": f"Bearer {access_token}"}
84
85 if resolved_feed_uri:
86 # Custom feed
87 feed_url = f"{pds_host}/xrpc/app.bsky.feed.getFeed"
88 params = {
89 "feed": resolved_feed_uri,
90 "limit": max_posts
91 }
92 feed_type = "custom"
93 else:
94 # Home timeline
95 feed_url = f"{pds_host}/xrpc/app.bsky.feed.getTimeline"
96 params = {
97 "limit": max_posts
98 }
99 feed_type = "home"
100
101 try:
102 response = requests.get(feed_url, headers=headers, params=params, timeout=10)
103 response.raise_for_status()
104 feed_data = response.json()
105 except Exception as e:
106 raise Exception(f"Failed to get feed. ({str(e)})")
107
108 # Format posts
109 posts = []
110 for item in feed_data.get("feed", []):
111 post = item.get("post", {})
112 author = post.get("author", {})
113 record = post.get("record", {})
114
115 post_data = {
116 "author": {
117 "handle": author.get("handle", ""),
118 "display_name": author.get("displayName", ""),
119 },
120 "text": record.get("text", ""),
121 "created_at": record.get("createdAt", ""),
122 "uri": post.get("uri", ""),
123 "cid": post.get("cid", ""),
124 "like_count": post.get("likeCount", 0),
125 "repost_count": post.get("repostCount", 0),
126 "reply_count": post.get("replyCount", 0),
127 }
128
129 # Add repost info if present
130 if "reason" in item and item["reason"]:
131 reason = item["reason"]
132 if reason.get("$type") == "app.bsky.feed.defs#reasonRepost":
133 by = reason.get("by", {})
134 post_data["reposted_by"] = {
135 "handle": by.get("handle", ""),
136 "display_name": by.get("displayName", ""),
137 }
138
139 # Add reply info if present
140 if "reply" in record and record["reply"]:
141 parent = record["reply"].get("parent", {})
142 post_data["reply_to"] = {
143 "uri": parent.get("uri", ""),
144 "cid": parent.get("cid", ""),
145 }
146
147 posts.append(post_data)
148
149 # Format response
150 feed_result = {
151 "feed": {
152 "type": feed_type,
153 "name": feed_display_name,
154 "post_count": len(posts),
155 "posts": posts
156 }
157 }
158
159 if resolved_feed_uri:
160 feed_result["feed"]["uri"] = resolved_feed_uri
161
162 return yaml.dump(feed_result, default_flow_style=False, sort_keys=False)
163
164 except Exception as e:
165 raise Exception(f"Error retrieving feed: {str(e)}")