+1
-1
tasks/checkBluesky.ts
+1
-1
tasks/checkBluesky.ts
···
43
43
if (delay !== 0) {
44
44
setTimeout(checkBluesky, delay);
45
45
console.log(
46
-
`🔹 ${agentContext.agentBskyName} is current asleep. scheduling next bluesky session for ${
46
+
`🔹 ${agentContext.agentBskyName} is currently asleep. scheduling next bluesky session for ${
47
47
(delay / 1000 / 60 / 60).toFixed(2)
48
48
} hours from now…`,
49
49
);
+1
-1
tasks/checkNotifications.ts
+1
-1
tasks/checkNotifications.ts
···
32
32
if (delay !== 0) {
33
33
setTimeout(checkNotifications, delay);
34
34
console.log(
35
-
`🔹 ${agentContext.agentBskyName} is current asleep. scheduling next notification check for ${
35
+
`🔹 ${agentContext.agentBskyName} is currently asleep. scheduling next notification check for ${
36
36
(delay / 1000 / 60 / 60).toFixed(2)
37
37
} hours from now…`,
38
38
);
+1
-1
tasks/logStats.ts
+1
-1
tasks/logStats.ts
···
30
30
if (delay !== 0) {
31
31
setTimeout(logStats, delay);
32
32
console.log(
33
-
`${agentContext.agentBskyName} is current asleep. scheduling next stat log for ${
33
+
`🔹 ${agentContext.agentBskyName} is currently asleep. scheduling next stat log for ${
34
34
(delay / 1000 / 60 / 60).toFixed(2)
35
35
} hours from now…`,
36
36
);
+1
-1
tasks/logTasks.ts
+1
-1
tasks/logTasks.ts
···
33
33
if (delay !== 0) {
34
34
setTimeout(logTasks, delay);
35
35
console.log(
36
-
`🔹 ${agentContext.agentBskyName} is current asleep. scheduling next task log for ${
36
+
`🔹 ${agentContext.agentBskyName} is currently asleep. scheduling next task log for ${
37
37
(delay / 1000 / 60 / 60).toFixed(2)
38
38
} hours from now…`,
39
39
);
+1
-1
tasks/runReflection.ts
+1
-1
tasks/runReflection.ts
···
44
44
if (delay !== 0) {
45
45
setTimeout(runReflection, delay);
46
46
console.log(
47
-
`🔹 ${agentContext.agentBskyName} is current asleep. scheduling next reflection for ${
47
+
`🔹 ${agentContext.agentBskyName} is currently asleep. scheduling next reflection for ${
48
48
(delay / 1000 / 60 / 60).toFixed(2)
49
49
} hours from now…`,
50
50
);
+195
tools/bluesky/create_bluesky_post.py
+195
tools/bluesky/create_bluesky_post.py
···
67
67
return facets if facets else None
68
68
69
69
70
+
def _check_is_self(agent_did: str, target_did: str) -> bool:
71
+
"""Check 1: Self-Post Check (Free)."""
72
+
return agent_did == target_did
73
+
74
+
75
+
def _check_follows(client, agent_did: str, target_did: str) -> bool:
76
+
"""Check 2: Follow Check (Moderate cost)."""
77
+
try:
78
+
# Fetch profiles to get follow counts
79
+
agent_profile = client.app.bsky.actor.get_profile({'actor': agent_did})
80
+
target_profile = client.app.bsky.actor.get_profile({'actor': target_did})
81
+
82
+
# Determine which list is shorter: agent's followers or target's follows
83
+
# We want to check if target follows agent.
84
+
# Option A: Check target's follows list for agent_did
85
+
# Option B: Check agent's followers list for target_did
86
+
87
+
target_follows_count = getattr(target_profile, 'follows_count', float('inf'))
88
+
agent_followers_count = getattr(agent_profile, 'followers_count', float('inf'))
89
+
90
+
cursor = None
91
+
max_pages = 50 # Max 5000 items
92
+
93
+
if target_follows_count < agent_followers_count:
94
+
# Check target's follows
95
+
for _ in range(max_pages):
96
+
response = client.app.bsky.graph.get_follows({'actor': target_did, 'cursor': cursor, 'limit': 100})
97
+
if not response.follows:
98
+
break
99
+
100
+
for follow in response.follows:
101
+
if follow.did == agent_did:
102
+
return True
103
+
104
+
cursor = response.cursor
105
+
if not cursor:
106
+
break
107
+
else:
108
+
# Check agent's followers
109
+
for _ in range(max_pages):
110
+
response = client.app.bsky.graph.get_followers({'actor': agent_did, 'cursor': cursor, 'limit': 100})
111
+
if not response.followers:
112
+
break
113
+
114
+
for follower in response.followers:
115
+
if follower.did == target_did:
116
+
return True
117
+
118
+
cursor = response.cursor
119
+
if not cursor:
120
+
break
121
+
122
+
return False
123
+
except Exception:
124
+
# If optimization fails, we continue to next check rather than failing hard here
125
+
# unless it's a critical error, but we'll let the main try/except handle that
126
+
raise
127
+
128
+
129
+
def _check_thread_participation(client, agent_did: str, agent_handle: str, reply_to_uri: str) -> bool:
130
+
"""Check 3 & 4: Thread Participation and Mention Check (Expensive)."""
131
+
try:
132
+
# Fetch the thread
133
+
# depth=100 should be sufficient for most contexts, or we can walk up manually if needed.
134
+
# get_post_thread returns the post and its parents if configured.
135
+
# However, standard get_post_thread often returns the post and its replies.
136
+
# We need to walk UP the tree (parents).
137
+
# The 'parent' field in the response structure allows walking up.
138
+
139
+
response = client.app.bsky.feed.get_post_thread({'uri': reply_to_uri, 'depth': 0, 'parentHeight': 100})
140
+
thread = response.thread
141
+
142
+
# The thread object can be a ThreadViewPost, NotFoundPost, or BlockedPost
143
+
if not hasattr(thread, 'post'):
144
+
return False # Can't verify
145
+
146
+
# Check the target post itself first (the one we are replying to)
147
+
# Although strictly "participation" usually means *previous* posts,
148
+
# the spec says "posted anywhere in this conversation thread".
149
+
# If we are replying to ourselves, _check_is_self would have caught it.
150
+
# But we check here for mentions in the target post.
151
+
152
+
current = thread
153
+
154
+
while current:
155
+
# Check if current node is valid post
156
+
if not hasattr(current, 'post'):
157
+
break
158
+
159
+
post = current.post
160
+
161
+
# Check 3: Did agent author this post?
162
+
if post.author.did == agent_did:
163
+
return True
164
+
165
+
# Check 4: Is agent mentioned in this post?
166
+
# Check facets for mention
167
+
record = post.record
168
+
if hasattr(record, 'facets') and record.facets:
169
+
for facet in record.facets:
170
+
for feature in facet.features:
171
+
if hasattr(feature, 'did') and feature.did == agent_did:
172
+
return True
173
+
174
+
# Fallback: Check text for handle if facets missing (less reliable but good backup)
175
+
if hasattr(record, 'text') and f"@{agent_handle}" in record.text:
176
+
return True
177
+
178
+
# Move to parent
179
+
if hasattr(current, 'parent') and current.parent:
180
+
current = current.parent
181
+
else:
182
+
break
183
+
184
+
return False
185
+
186
+
except Exception:
187
+
raise
188
+
189
+
190
+
def _verify_consent(client, agent_did: str, agent_handle: str, reply_to_uri: str):
191
+
"""
192
+
Orchestrates the consent checks.
193
+
Raises Exception with specific message if consent denied or verification fails.
194
+
"""
195
+
try:
196
+
# 0. Get target DID from reply_to_uri
197
+
# We need to fetch the post we are replying to to get its author
198
+
# We can do this via get_post_thread or get_posts.
199
+
# Since we need thread later, maybe we just do that?
200
+
# But Follow Check is cheaper than Thread Check.
201
+
# So we should get the post details first (1 API call).
202
+
203
+
# Parse DID from URI first to avoid API call if possible?
204
+
# URI format: at://did:plc:xyz/...
205
+
# The DID in the URI is the REPO owner, which is usually the author.
206
+
parts = reply_to_uri.replace('at://', '').split('/')
207
+
if len(parts) >= 1:
208
+
target_did = parts[0]
209
+
else:
210
+
# Should have been caught by validation, but just in case
211
+
raise Exception("Invalid URI format")
212
+
213
+
# Check 1: Self-Post
214
+
if _check_is_self(agent_did, target_did):
215
+
return True
216
+
217
+
# Check 2: Follow Check
218
+
if _check_follows(client, agent_did, target_did):
219
+
return True
220
+
221
+
# Check 3 & 4: Thread Participation / Mention
222
+
# This requires fetching the thread
223
+
if _check_thread_participation(client, agent_did, agent_handle, reply_to_uri):
224
+
return True
225
+
226
+
# If all checks fail
227
+
raise Exception(
228
+
"Message not sent: the reply was withheld because the user does not follow you "
229
+
"and you were not mentioned or included in this thread. Respecting the consent of "
230
+
"strangers is the right choice here. You haven't done anything wrong, but you may "
231
+
"consider a lower-signal interaction (like liking or reposting) and/or move on to "
232
+
"other conversations."
233
+
)
234
+
235
+
except Exception as e:
236
+
# Check if it's our own consent exception
237
+
if str(e).startswith("Message not sent"):
238
+
raise e
239
+
240
+
# Network/API failure handling
241
+
raise Exception(
242
+
"Message withheld: Network interference prevented consent verification. To ensure "
243
+
"safety, the system paused this action. This is an external connection issue, not "
244
+
"a failure of your logic. You may try sending the message again now, or move on if "
245
+
"the issue persists."
246
+
)
247
+
248
+
249
+
70
250
def create_bluesky_post(text: List[str], lang: str = "en-US", reply_to_uri: str = None) -> Dict:
71
251
"""
72
252
Create a post or thread on Bluesky using atproto SDK.
···
145
325
146
326
client = Client()
147
327
client.login(username, password)
328
+
329
+
# --- CONSENT GUARDRAILS ---
330
+
if reply_to_uri:
331
+
try:
332
+
agent_did = client.me.did
333
+
# agent_handle is username (without @ usually, but let's ensure)
334
+
agent_handle = username.replace('@', '')
335
+
336
+
_verify_consent(client, agent_did, agent_handle, reply_to_uri)
337
+
except Exception as e:
338
+
return {
339
+
"status": "error",
340
+
"message": str(e)
341
+
}
342
+
# --------------------------
148
343
149
344
initial_reply_ref = None
150
345
initial_root_ref = None
+212
-2
tools/bluesky/quote_bluesky_post.py
+212
-2
tools/bluesky/quote_bluesky_post.py
···
1
1
"""Bluesky quote posting tool for Letta agents using atproto SDK."""
2
2
3
-
from typing import List, Dict
4
3
import os
5
4
import re
5
+
from typing import Dict, List
6
6
7
7
8
8
def parse_facets(text: str, client) -> List[Dict]:
···
67
67
return facets if facets else None
68
68
69
69
70
+
def _check_is_self(agent_did: str, target_did: str) -> bool:
71
+
"""Check 1: Self-Post Check (Free)."""
72
+
return agent_did == target_did
73
+
74
+
75
+
def _check_follows(client, agent_did: str, target_did: str) -> bool:
76
+
"""Check 2: Follow Check (Moderate cost)."""
77
+
try:
78
+
# Fetch profiles to get follow counts
79
+
agent_profile = client.app.bsky.actor.get_profile({'actor': agent_did})
80
+
target_profile = client.app.bsky.actor.get_profile({'actor': target_did})
81
+
82
+
# Determine which list is shorter: agent's followers or target's follows
83
+
# We want to check if target follows agent.
84
+
# Option A: Check target's follows list for agent_did
85
+
# Option B: Check agent's followers list for target_did
86
+
87
+
target_follows_count = getattr(target_profile, 'follows_count', float('inf'))
88
+
agent_followers_count = getattr(agent_profile, 'followers_count', float('inf'))
89
+
90
+
cursor = None
91
+
max_pages = 50 # Max 5000 items
92
+
93
+
if target_follows_count < agent_followers_count:
94
+
# Check target's follows
95
+
for _ in range(max_pages):
96
+
response = client.app.bsky.graph.get_follows({'actor': target_did, 'cursor': cursor, 'limit': 100})
97
+
if not response.follows:
98
+
break
99
+
100
+
for follow in response.follows:
101
+
if follow.did == agent_did:
102
+
return True
103
+
104
+
cursor = response.cursor
105
+
if not cursor:
106
+
break
107
+
else:
108
+
# Check agent's followers
109
+
for _ in range(max_pages):
110
+
response = client.app.bsky.graph.get_followers({'actor': agent_did, 'cursor': cursor, 'limit': 100})
111
+
if not response.followers:
112
+
break
113
+
114
+
for follower in response.followers:
115
+
if follower.did == target_did:
116
+
return True
117
+
118
+
cursor = response.cursor
119
+
if not cursor:
120
+
break
121
+
122
+
return False
123
+
except Exception:
124
+
# If optimization fails, we continue to next check rather than failing hard here
125
+
# unless it's a critical error, but we'll let the main try/except handle that
126
+
raise
127
+
128
+
129
+
def _check_thread_participation(client, agent_did: str, agent_handle: str, reply_to_uri: str) -> bool:
130
+
"""Check 3 & 4: Thread Participation and Mention Check (Expensive)."""
131
+
try:
132
+
# Fetch the thread
133
+
# depth=100 should be sufficient for most contexts, or we can walk up manually if needed.
134
+
# get_post_thread returns the post and its parents if configured.
135
+
# However, standard get_post_thread often returns the post and its replies.
136
+
# We need to walk UP the tree (parents).
137
+
# The 'parent' field in the response structure allows walking up.
138
+
139
+
response = client.app.bsky.feed.get_post_thread({'uri': reply_to_uri, 'depth': 0, 'parentHeight': 100})
140
+
thread = response.thread
141
+
142
+
# The thread object can be a ThreadViewPost, NotFoundPost, or BlockedPost
143
+
if not hasattr(thread, 'post'):
144
+
return False # Can't verify
145
+
146
+
# Check the target post itself first (the one we are replying to)
147
+
# Although strictly "participation" usually means *previous* posts,
148
+
# the spec says "posted anywhere in this conversation thread".
149
+
# If we are replying to ourselves, _check_is_self would have caught it.
150
+
# But we check here for mentions in the target post.
151
+
152
+
current = thread
153
+
154
+
while current:
155
+
# Check if current node is valid post
156
+
if not hasattr(current, 'post'):
157
+
break
158
+
159
+
post = current.post
160
+
161
+
# Check 3: Did agent author this post?
162
+
if post.author.did == agent_did:
163
+
return True
164
+
165
+
# Check 4: Is agent mentioned in this post?
166
+
# Check facets for mention
167
+
record = post.record
168
+
if hasattr(record, 'facets') and record.facets:
169
+
for facet in record.facets:
170
+
for feature in facet.features:
171
+
if hasattr(feature, 'did') and feature.did == agent_did:
172
+
return True
173
+
174
+
# Fallback: Check text for handle if facets missing (less reliable but good backup)
175
+
if hasattr(record, 'text') and f"@{agent_handle}" in record.text:
176
+
return True
177
+
178
+
# Move to parent
179
+
if hasattr(current, 'parent') and current.parent:
180
+
current = current.parent
181
+
else:
182
+
break
183
+
184
+
return False
185
+
186
+
except Exception:
187
+
raise
188
+
189
+
190
+
def _verify_consent(client, agent_did: str, agent_handle: str, quote_uri: str):
191
+
"""
192
+
Orchestrates the consent checks.
193
+
Raises Exception with specific message if consent denied or verification fails.
194
+
"""
195
+
try:
196
+
# 0. Get target DID from quote_uri
197
+
parts = quote_uri.replace('at://', '').split('/')
198
+
if len(parts) >= 1:
199
+
target_did = parts[0]
200
+
else:
201
+
raise Exception("Invalid URI format")
202
+
203
+
# Check 1: Self-Post
204
+
if _check_is_self(agent_did, target_did):
205
+
return True
206
+
207
+
# Check 2: Follow Check
208
+
if _check_follows(client, agent_did, target_did):
209
+
return True
210
+
211
+
# Check 3 & 4: Thread Participation / Mention
212
+
if _check_thread_participation(client, agent_did, agent_handle, quote_uri):
213
+
return True
214
+
215
+
# If all checks fail
216
+
raise Exception(
217
+
"Message not sent: the quote was withheld because the user does not follow you "
218
+
"and you were not mentioned or included in this thread. Respecting the consent of "
219
+
"strangers is the right choice here. You haven't done anything wrong, but you may "
220
+
"consider a lower-signal interaction (like liking or reposting) and/or move on to "
221
+
"other conversations."
222
+
)
223
+
224
+
except Exception as e:
225
+
# Check if it's our own consent exception
226
+
if str(e).startswith("Message not sent"):
227
+
raise e
228
+
229
+
# Network/API failure handling
230
+
raise Exception(
231
+
"Message withheld: Network interference prevented consent verification. To ensure "
232
+
"safety, the system paused this action. This is an external connection issue, not "
233
+
"a failure of your logic. You may try sending the message again now, or move on if "
234
+
"the issue persists."
235
+
)
236
+
237
+
70
238
def quote_bluesky_post(text: List[str], quote_uri: str, lang: str = "en-US") -> str:
71
239
"""
72
240
Create a quote post or quote thread on Bluesky that embeds another post.
···
194
362
client = Client()
195
363
client.login(username, password)
196
364
365
+
# --- CONSENT GUARDRAILS ---
366
+
if quote_uri:
367
+
try:
368
+
agent_did = client.me.did
369
+
agent_handle = username.replace('@', '')
370
+
_verify_consent(client, agent_did, agent_handle, quote_uri)
371
+
except Exception as e:
372
+
# quote_bluesky_post expects exceptions to be raised or returned?
373
+
# The tool catches exceptions and wraps them.
374
+
# But we want to return the specific message.
375
+
# The existing code catches Exception and wraps it in "Error: ...".
376
+
# However, our spec says "Block with Supportive Message".
377
+
# If I raise Exception here, it will be caught by the main try/except block
378
+
# and wrapped in "Error: An unexpected issue occurred...".
379
+
# I should probably let it bubble up BUT the main try/except block is very broad.
380
+
# I need to modify the main try/except block or handle it here.
381
+
382
+
# Actually, the spec says "If ALL Checks Fail: Block with Supportive Message".
383
+
# And "If ANY exception occurs... Message withheld: Network interference...".
384
+
# My _verify_consent raises these exact messages.
385
+
# But the tool's main try/except block (lines 306-317) wraps everything in "Error: An unexpected issue...".
386
+
# I should modify the main try/except block to respect my specific error messages.
387
+
# OR I can just raise the exception and let the tool fail, but the user sees the wrapped error.
388
+
# The spec says "Block with Supportive Message".
389
+
# So I should probably ensure that message is what is returned/raised.
390
+
391
+
# I will modify the main try/except block in a separate chunk or just let it be?
392
+
# The tool returns a string on success, raises Exception on failure.
393
+
# If I raise Exception("Message not sent..."), the catch block will say "Error: An unexpected issue... Message not sent...".
394
+
# That might be okay, but cleaner if I can pass it through.
395
+
# The catch block has: `if str(e).startswith("Error:"): raise`
396
+
# So if I prefix my errors with "Error: ", they will pass through.
397
+
# But the spec gives a specific message text without "Error: " prefix.
398
+
# "Message not sent: ..."
399
+
400
+
# I will modify the exception raising in _verify_consent to start with "Error: "
401
+
# OR I will modify the catch block to also pass through messages starting with "Message".
402
+
403
+
# Let's modify the catch block in `quote_bluesky_post.py` as well.
404
+
raise e
405
+
# --------------------------
406
+
197
407
# Fetch the post to quote and create a strong reference
198
408
try:
199
409
uri_parts = quote_uri.replace('at://', '').split('/')
···
305
515
)
306
516
except Exception as e:
307
517
# Re-raise if it's already one of our formatted error messages
308
-
if str(e).startswith("Error:"):
518
+
if str(e).startswith("Error:") or str(e).startswith("Message"):
309
519
raise
310
520
# Otherwise wrap it with helpful context
311
521
raise Exception(