import mimetypes from pathlib import Path from atproto import models as at_models from .gateway import BlueskyGateway from .types import PostRef class BlueskyOutbound: delivery_mode = "direct" text_chunk_limit = 2000 def __init__(self, gateway: BlueskyGateway): self.gateway = gateway async def send_dm( self, target_did: str, text: str, account_id: str = "default", facets: list[dict] | None = None, embed: dict | None = None, ) -> str: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") dm = handle.dm_client convo = dm.chat.bsky.convo.get_convo_for_members( at_models.ChatBskyConvoGetConvoForMembers.Params(members=[target_did]) ).convo msg_input = at_models.ChatBskyConvoDefs.MessageInput(text=text) if facets: msg_input.facets = facets if embed: msg_input.embed = embed result = dm.chat.bsky.convo.send_message( at_models.ChatBskyConvoSendMessage.Data( convo_id=convo.id, message=msg_input, ) ) return result.id async def send_reply( self, parent: PostRef, text: str, account_id: str = "default", ) -> PostRef: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") client = handle.client post_view = client.get_posts([parent.uri]).posts[0] root_uri = parent.uri root_cid = parent.cid if post_view.record.reply: root_uri = post_view.record.reply.root.uri root_cid = post_view.record.reply.root.cid reply_ref = at_models.AppBskyFeedPost.ReplyRef( root=at_models.ComAtprotoRepoStrongRef.Main(uri=root_uri, cid=root_cid), parent=at_models.ComAtprotoRepoStrongRef.Main( uri=parent.uri, cid=parent.cid, ), ) result = client.send_post(text=text, reply_to=reply_ref) return PostRef(uri=result.uri, cid=result.cid) async def send_post( self, text: str, account_id: str = "default", ) -> PostRef: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") result = handle.client.send_post(text=text) return PostRef(uri=result.uri, cid=result.cid) async def upload_blob( self, file_path: str, account_id: str = "default", ) -> dict: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") path = Path(file_path) mime_type, _ = mimetypes.guess_type(path.name) mime_type = mime_type or "application/octet-stream" with open(path, "rb") as f: blob_data = f.read() blob_ref = handle.client.com.atproto.repo.upload_blob(blob_data) return {"blob": blob_ref.blob, "mime_type": mime_type} async def send_post_with_image( self, text: str, image_path: str, *, alt_text: str = "", account_id: str = "default", ) -> PostRef: upload_result = await self.upload_blob(image_path, account_id) handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") image_embed = at_models.AppBskyEmbedImages.Main( images=[ at_models.AppBskyEmbedImages.Image( image=upload_result["blob"], alt=alt_text or "", ) ] ) result = handle.client.send_post(text=text, embed=image_embed) return PostRef(uri=result.uri, cid=result.cid) async def send_post_with_link( self, text: str, uri: str, *, title: str = "", description: str = "", thumb_blob: dict | None = None, account_id: str = "default", ) -> PostRef: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") external_embed = at_models.AppBskyEmbedExternal.Main( external=at_models.AppBskyEmbedExternal.External( uri=uri, title=title, description=description, thumb=thumb_blob, ) ) result = handle.client.send_post(text=text, embed=external_embed) return PostRef(uri=result.uri, cid=result.cid) async def send_reaction( self, convo_id: str, message_id: str, emoji: str, account_id: str = "default", ) -> None: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") dm = handle.dm_client dm.chat.bsky.convo.add_reaction( at_models.ChatBskyConvoAddReaction.Data( convo_id=convo_id, message_id=message_id, value=emoji, ) ) async def remove_reaction( self, convo_id: str, message_id: str, emoji: str, account_id: str = "default", ) -> None: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") dm = handle.dm_client dm.chat.bsky.convo.remove_reaction( at_models.ChatBskyConvoRemoveReaction.Data( convo_id=convo_id, message_id=message_id, value=emoji, ) ) async def get_messages( self, convo_id: str, *, limit: int = 50, cursor: str | None = None, account_id: str = "default", ) -> dict: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") dm = handle.dm_client params = {"convo_id": convo_id, "limit": min(limit, 100)} if cursor: params["cursor"] = cursor resp = dm.chat.bsky.convo.get_messages(params=params) messages = [] for msg in resp.messages: messages.append( { "id": msg.id, "text": msg.text, "sender_did": msg.sender.did, "sender_handle": msg.sender.handle, "sent_at": str(msg.sent_at) if msg.sent_at else "", "rev": msg.rev, } ) return { "messages": messages, "cursor": resp.cursor, } async def delete_message( self, convo_id: str, message_id: str, account_id: str = "default", ) -> None: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") dm = handle.dm_client dm.chat.bsky.convo.delete_message_for_self( at_models.ChatBskyConvoDeleteMessageForSelf.Data( convo_id=convo_id, message_id=message_id, ) ) async def list_convos( self, *, limit: int = 50, cursor: str | None = None, account_id: str = "default", ) -> dict: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") dm = handle.dm_client params = {"limit": min(limit, 100)} if cursor: params["cursor"] = cursor resp = dm.chat.bsky.convo.list_convos(params=params) convos = [] for c in resp.convos: convos.append( { "id": c.id, "rev": c.rev, "members": [{"did": m.did, "handle": m.handle} for m in c.members], "last_message": { "id": c.last_message.id if c.last_message else None, "text": c.last_message.text if c.last_message else "", "sent_at": str(c.last_message.sent_at) if c.last_message and c.last_message.sent_at else "", } if c.last_message else None, "unread_count": getattr(c, "unread_count", 0), "opened": c.opened, } ) return {"convos": convos, "cursor": resp.cursor} async def delete_post( self, uri: str, account_id: str = "default", ) -> None: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") handle.client.com.atproto.repo.delete_record( at_models.ComAtprotoRepoDeleteRecord.Data( collection="app.bsky.feed.post", repo=handle.me_did, rkey=uri.split("/")[-1], ) ) async def like_post( self, uri: str, cid: str, account_id: str = "default", ) -> str: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") result = handle.client.like(uri=uri, cid=cid) return result.uri async def repost( self, uri: str, cid: str, account_id: str = "default", ) -> str: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") result = handle.client.repost(uri=uri, cid=cid) return result.uri async def search_actors( self, query: str, *, limit: int = 25, typeahead: bool = False, account_id: str = "default", ) -> list[dict]: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") params = {"q": query, "limit": min(limit, 100)} if typeahead: resp = handle.client.app.bsky.actor.search_actors_typeahead(params=params) else: resp = handle.client.app.bsky.actor.search_actors(params=params) return [ { "did": a.did, "handle": a.handle, "display_name": a.display_name, "description": a.description, } for a in resp.actors ] async def get_follows( self, actor: str, *, limit: int = 50, cursor: str | None = None, account_id: str = "default", ) -> dict: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") resp = handle.client.get_follows(actor=actor, limit=min(limit, 100), cursor=cursor) follows = [{"did": f.did, "handle": f.handle, "display_name": f.display_name} for f in resp.follows] return {"follows": follows, "cursor": resp.cursor} async def get_followers( self, actor: str, *, limit: int = 50, cursor: str | None = None, account_id: str = "default", ) -> dict: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") resp = handle.client.get_followers(actor=actor, limit=min(limit, 100), cursor=cursor) followers = [{"did": f.did, "handle": f.handle, "display_name": f.display_name} for f in resp.followers] return {"followers": followers, "cursor": resp.cursor} async def get_profile( self, actor: str, account_id: str = "default", ) -> dict: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") profile = handle.client.get_profile(actor=actor) return { "did": profile.did, "handle": profile.handle, "display_name": profile.display_name, "description": profile.description, "avatar": profile.avatar, "banner": profile.banner, "followers_count": profile.followers_count, "follows_count": profile.follows_count, "posts_count": profile.posts_count, } async def get_preferences( self, account_id: str = "default", ) -> dict: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") resp = handle.client.app.bsky.actor.get_preferences() return {"preferences": [p.model_dump() for p in resp.preferences]} async def follow_user( self, did: str, account_id: str = "default", ) -> str: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") result = handle.client.follow(did) return result.uri async def unfollow_user( self, follow_uri: str, account_id: str = "default", ) -> None: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") handle.client.com.atproto.repo.delete_record( at_models.ComAtprotoRepoDeleteRecord.Data( collection="app.bsky.graph.follow", repo=handle.me_did, rkey=follow_uri.split("/")[-1], ) ) async def get_timeline( self, *, limit: int = 50, cursor: str | None = None, account_id: str = "default", ) -> dict: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") params = {"limit": min(limit, 100)} if cursor: params["cursor"] = cursor resp = handle.client.get_timeline(params=params) posts = [] for feed_view in resp.feed: post = feed_view.post posts.append( { "uri": post.uri, "cid": post.cid, "author_did": post.author.did, "author_handle": post.author.handle, "text": getattr(post.record, "text", ""), "created_at": getattr(post.record, "created_at", ""), } ) return {"posts": posts, "cursor": resp.cursor} async def get_author_feed( self, actor: str, *, limit: int = 50, cursor: str | None = None, account_id: str = "default", ) -> dict: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") params = {"actor": actor, "limit": min(limit, 100)} if cursor: params["cursor"] = cursor resp = handle.client.get_author_feed(params=params) posts = [] for feed_view in resp.feed: post = feed_view.post posts.append( { "uri": post.uri, "cid": post.cid, "author_did": post.author.did, "author_handle": post.author.handle, "text": getattr(post.record, "text", ""), "created_at": getattr(post.record, "created_at", ""), } ) return {"posts": posts, "cursor": resp.cursor} async def search_posts( self, query: str, *, limit: int = 25, cursor: str | None = None, account_id: str = "default", ) -> dict: handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") params = {"q": query, "limit": min(limit, 100)} if cursor: params["cursor"] = cursor resp = handle.client.app.bsky.feed.search_posts(params=params) posts = [] for post in resp.posts: posts.append( { "uri": post.uri, "cid": post.cid, "author_did": post.author.did, "author_handle": post.author.handle, "text": getattr(post.record, "text", ""), "created_at": getattr(post.record, "created_at", ""), } ) return {"posts": posts, "cursor": resp.cursor} async def send_post_with_video( self, text: str, video_path: str, *, alt_text: str = "", account_id: str = "default", ) -> PostRef: upload_result = await self.upload_blob(video_path, account_id) handle = self.gateway.get_client(account_id) if not handle: raise RuntimeError(f"No active Bluesky client for account '{account_id}'") video_embed = at_models.AppBskyEmbedVideo.Main( video=upload_result["blob"], alt=alt_text or "", ) result = handle.client.send_post(text=text, embed=video_embed) return PostRef(uri=result.uri, cid=result.cid) def chunker(self, text: str, limit: int, ctx=None) -> list[str]: chunks = [] while len(text) > limit: split_at = text.rfind("\n", 0, limit) if split_at < 0: split_at = text.rfind(" ", 0, limit) if split_at < 0: split_at = limit chunks.append(text[:split_at]) text = text[split_at:].lstrip() if text: chunks.append(text) return chunks