ForcePilot/backend/package/yuxi/channel/extensions/bluesky/outbound.py
Kris bb85da1ca2 feat(bluesky): 新增Bluesky (AT Protocol) 频道插件
实现了完整的Bluesky频道功能,支持私信轮询、@提及通知、账号会话管理、重复消息过滤、配对授权机制以及完整的API调用能力,包含配置schema和插件元数据定义
2026-05-21 10:40:50 +08:00

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