569 lines
18 KiB
Python
569 lines
18 KiB
Python
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
|