a digital entity named phi that roams bsky phi.zzstoatzz.io
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

process all notification types, not just mentions/replies

quotes now go through the full agent pipeline (same as mentions).
likes, reposts, and follows are logged for awareness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

zzstoatzz f1ff98f8 3b6f89f5

+97 -90
+88 -75
src/bot/services/message_handler.py
··· 13 13 14 14 15 15 class MessageHandler: 16 - """Handles incoming mentions using phi agent.""" 16 + """Handles incoming notifications using phi agent.""" 17 17 18 18 def __init__(self, client: BotClient): 19 19 self.client = client 20 20 self.agent = PhiAgent() 21 21 22 - async def handle_mention(self, notification): 23 - """Process a mention or reply notification.""" 22 + async def handle_notification(self, notification): 23 + """Process any notification type.""" 24 + reason = notification.reason 25 + author_handle = notification.author.handle 26 + 24 27 try: 25 - if notification.reason not in ["mention", "reply"]: 26 - return 28 + if reason in ("mention", "reply", "quote"): 29 + await self._handle_post(notification) 30 + elif reason in ("like", "repost"): 31 + # someone engaged with phi's content — log for awareness 32 + logger.info(f"{reason} from @{author_handle}") 33 + bot_status.record_mention() 34 + elif reason == "follow": 35 + logger.info(f"followed by @{author_handle}") 36 + bot_status.record_mention() 37 + else: 38 + logger.debug(f"notification type '{reason}' from @{author_handle}") 39 + except Exception as e: 40 + logger.exception(f"notification handling error: {e}") 41 + bot_status.record_error() 27 42 28 - post_uri = notification.uri 43 + async def _handle_post(self, notification): 44 + """Process a mention, reply, or quote notification.""" 45 + post_uri = notification.uri 29 46 30 - # Get the post that mentioned us 31 - posts = await self.client.get_posts([post_uri]) 32 - if not posts.posts: 33 - logger.warning(f"could not find post {post_uri}") 34 - return 47 + # Get the post 48 + posts = await self.client.get_posts([post_uri]) 49 + if not posts.posts: 50 + logger.warning(f"could not find post {post_uri}") 51 + return 35 52 36 - post = posts.posts[0] 37 - mention_text = post.record.text 38 - author_handle = post.author.handle 53 + post = posts.posts[0] 54 + mention_text = post.record.text 55 + author_handle = post.author.handle 39 56 40 - # Include embed content (images, links, quote posts) in the mention 41 - embed = post.embed if hasattr(post, "embed") and post.embed else None 42 - if not embed and hasattr(post.record, "embed") and post.record.embed: 43 - embed = post.record.embed 57 + # Include embed content (images, links, quote posts) in the mention 58 + embed = post.embed if hasattr(post, "embed") and post.embed else None 59 + if not embed and hasattr(post.record, "embed") and post.record.embed: 60 + embed = post.record.embed 44 61 45 - embed_desc = describe_embed(embed) if embed else None 46 - if embed_desc: 47 - mention_text = f"{mention_text}\n{embed_desc}" 62 + embed_desc = describe_embed(embed) if embed else None 63 + if embed_desc: 64 + mention_text = f"{mention_text}\n{embed_desc}" 48 65 49 - # Extract image URLs for multimodal vision 50 - image_urls = extract_image_urls(embed) if embed else [] 66 + # Extract image URLs for multimodal vision 67 + image_urls = extract_image_urls(embed) if embed else [] 51 68 52 - bot_status.record_mention() 69 + bot_status.record_mention() 53 70 54 - # Build reply reference 55 - parent_ref = models.ComAtprotoRepoStrongRef.Main(uri=post_uri, cid=post.cid) 71 + # Build reply reference 72 + parent_ref = models.ComAtprotoRepoStrongRef.Main(uri=post_uri, cid=post.cid) 56 73 57 - # Check if this is part of a thread 58 - if hasattr(post.record, "reply") and post.record.reply: 59 - root_ref = post.record.reply.root 60 - thread_uri = root_ref.uri 61 - else: 62 - root_ref = parent_ref 63 - thread_uri = post_uri 74 + # Check if this is part of a thread 75 + if hasattr(post.record, "reply") and post.record.reply: 76 + root_ref = post.record.reply.root 77 + thread_uri = root_ref.uri 78 + else: 79 + root_ref = parent_ref 80 + thread_uri = post_uri 64 81 65 - # Fetch thread context directly from network 66 - thread_context = "No previous messages in this thread." 67 - try: 68 - logger.debug(f"fetching thread context for {thread_uri}") 69 - thread_data = await self.client.get_thread(thread_uri, depth=100) 70 - thread_context = build_thread_context(thread_data.thread) 71 - except Exception as e: 72 - logger.warning(f"failed to fetch thread context: {e}") 82 + # Fetch thread context directly from network 83 + thread_context = "No previous messages in this thread." 84 + try: 85 + logger.debug(f"fetching thread context for {thread_uri}") 86 + thread_data = await self.client.get_thread(thread_uri, depth=100) 87 + thread_context = build_thread_context(thread_data.thread) 88 + except Exception as e: 89 + logger.warning(f"failed to fetch thread context: {e}") 73 90 74 - # Process with agent (has episodic memory + MCP tools) 75 - response = await self.agent.process_mention( 76 - mention_text=mention_text, 77 - author_handle=author_handle, 78 - thread_context=thread_context, 79 - thread_uri=thread_uri, 80 - image_urls=image_urls, 81 - ) 91 + # Process with agent (has episodic memory + MCP tools) 92 + response = await self.agent.process_mention( 93 + mention_text=mention_text, 94 + author_handle=author_handle, 95 + thread_context=thread_context, 96 + thread_uri=thread_uri, 97 + image_urls=image_urls, 98 + ) 82 99 83 - # Handle response actions 84 - if response.action == "ignore": 85 - logger.info(f"ignoring @{author_handle}: {response.reason}") 86 - return 100 + # Handle response actions 101 + if response.action == "ignore": 102 + logger.info(f"ignoring @{author_handle}: {response.reason}") 103 + return 87 104 88 - elif response.action == "like": 89 - await self.client.like_post(uri=post_uri, cid=post.cid) 90 - logger.info(f"liked @{author_handle}") 91 - bot_status.record_response() 92 - return 93 - 94 - elif response.action == "repost": 95 - await self.client.repost(uri=post_uri, cid=post.cid) 96 - logger.info(f"reposted @{author_handle}") 97 - bot_status.record_response() 98 - return 105 + elif response.action == "like": 106 + await self.client.like_post(uri=post_uri, cid=post.cid) 107 + logger.info(f"liked @{author_handle}") 108 + bot_status.record_response() 109 + return 99 110 100 - elif response.action == "reply" and response.text: 101 - # Post reply 102 - reply_ref = models.AppBskyFeedPost.ReplyRef( 103 - parent=parent_ref, root=root_ref 104 - ) 105 - await self.client.create_post(response.text, reply_to=reply_ref) 111 + elif response.action == "repost": 112 + await self.client.repost(uri=post_uri, cid=post.cid) 113 + logger.info(f"reposted @{author_handle}") 114 + bot_status.record_response() 115 + return 106 116 107 - bot_status.record_response() 108 - logger.info(f"replied to @{author_handle}: {response.text[:80]}") 117 + elif response.action == "reply" and response.text: 118 + # Post reply 119 + reply_ref = models.AppBskyFeedPost.ReplyRef( 120 + parent=parent_ref, root=root_ref 121 + ) 122 + await self.client.create_post(response.text, reply_to=reply_ref) 109 123 110 - except Exception as e: 111 - logger.exception(f"mention handling error: {e}") 112 - bot_status.record_error() 124 + bot_status.record_response() 125 + logger.info(f"replied to @{author_handle}: {response.text[:80]}")
+9 -15
src/bot/services/notification_poller.py
··· 65 65 response = await self.client.get_notifications() 66 66 notifications = response.notifications 67 67 68 - unread_mentions = [ 69 - n 70 - for n in notifications 71 - if not n.is_read and n.reason in ["mention", "reply"] 72 - ] 68 + unread = [n for n in notifications if not n.is_read] 73 69 74 70 # First poll: show initial state 75 71 if self._first_poll: 76 72 self._first_poll = False 77 73 if notifications: 78 74 logger.info( 79 - f"found {len(notifications)} notifications ({len(unread_mentions)} unread mentions)" 75 + f"found {len(notifications)} notifications ({len(unread)} unread)" 80 76 ) 81 - elif unread_mentions: 82 - logger.info(f"{len(unread_mentions)} new mentions") 77 + elif unread: 78 + logger.info(f"{len(unread)} new notifications") 83 79 84 - processed_any_mentions = False 80 + processed_any = False 85 81 86 82 # Process notifications from oldest to newest 87 83 for notification in reversed(notifications): 88 84 if notification.is_read or notification.uri in self._processed_uris: 89 85 continue 90 86 91 - if notification.reason in ["mention", "reply"]: 92 - logger.debug(f"processing {notification.reason} notification") 93 - self._processed_uris.add(notification.uri) 94 - await self.handler.handle_mention(notification) 95 - processed_any_mentions = True 87 + self._processed_uris.add(notification.uri) 88 + await self.handler.handle_notification(notification) 89 + processed_any = True 96 90 97 91 # Mark all notifications as seen 98 - if processed_any_mentions: 92 + if processed_any: 99 93 await self.client.mark_notifications_seen(check_time) 100 94 logger.info("marked notifications as read") 101 95