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

388 lines
13 KiB
Python

import asyncio
import logging
import random
from dataclasses import dataclass
from atproto import Client, SessionEvent
from atproto import models as at_models
from .dedupe import DedupeTracker
from .session_store import load_session, save_session
from .types import BlueskyAccount, InboundDM, InboundMention
logger = logging.getLogger(__name__)
@dataclass
class BlueskyClientHandle:
client: Client
dm_client: Client
me_did: str
me_handle: str
account_id: str
@dataclass
class BlueskyPollHandle:
task: asyncio.Task
dedupe: DedupeTracker
class BlueskyGatewayError(Exception):
pass
class BlueskyGateway:
def __init__(self):
self._active_clients: dict[str, BlueskyClientHandle] = {}
self._poll_handles: dict[str, BlueskyPollHandle] = {}
self._notif_tasks: dict[str, asyncio.Task] = {}
async def _create_or_restore_client(self, account: BlueskyAccount, account_id: str) -> BlueskyClientHandle:
client = Client()
session_str = account.session_string or load_session(account_id)
if session_str:
try:
client.login(session_string=session_str)
me = client.me
logger.info(
"Bluesky session restored for %s (%s)",
account_id,
me.handle,
)
client.on_session_change = lambda event, session: (
save_session(account_id, client.export_session_string())
if event in (SessionEvent.CREATE, SessionEvent.REFRESH)
else None
)
self._label_as_bot(client, me.did)
return BlueskyClientHandle(
client=client,
dm_client=client.with_bsky_chat_proxy(),
me_did=me.did,
me_handle=me.handle,
account_id=account_id,
)
except Exception as e:
logger.warning(
"Bluesky session restore failed for %s: %s, will re-login",
account_id,
e,
)
client.login(account.handle, account.app_password)
me = client.me
logger.info("Bluesky login success for %s (%s)", account_id, me.handle)
session_str = client.export_session_string()
save_session(account_id, session_str)
client.on_session_change = lambda event, session: (
save_session(account_id, client.export_session_string())
if event in (SessionEvent.CREATE, SessionEvent.REFRESH)
else None
)
self._label_as_bot(client, me.did)
return BlueskyClientHandle(
client=client,
dm_client=client.with_bsky_chat_proxy(),
me_did=me.did,
me_handle=me.handle,
account_id=account_id,
)
def _label_as_bot(self, client: Client, did: str):
try:
profile = client.get_profile(actor=did)
existing_labels = []
if profile.labels:
existing_labels = [
at_models.ComAtprotoLabelDefs.SelfLabel(val=label.val)
for label in profile.labels
if hasattr(label, "val")
]
if any(label.val == "bot" for label in existing_labels):
return
new_labels = existing_labels + [at_models.ComAtprotoLabelDefs.SelfLabel(val="bot")]
client.app.bsky.actor.profile.put(
did,
at_models.AppBskyActorProfile.Record(
display_name=profile.display_name or "",
description=profile.description or "",
avatar=profile.avatar,
banner=profile.banner,
labels=at_models.ComAtprotoLabelDefs.SelfLabels(values=new_labels),
),
)
logger.info("Bluesky bot self-label applied for %s", did)
except Exception as e:
logger.warning("Failed to apply bot self-label: %s", e)
async def start(
self,
account_id: str,
account: BlueskyAccount,
on_dm: callable,
on_mention: callable,
authorize_sender: callable,
) -> BlueskyClientHandle:
if not account.is_configured:
raise BlueskyGatewayError(f"Bluesky account '{account_id}' not configured")
handle = await self._create_or_restore_client(account, account_id)
self._active_clients[account_id] = handle
dedupe = DedupeTracker()
task = asyncio.create_task(
self._poll_dm_loop(
account_id,
handle,
on_dm,
authorize_sender,
dedupe,
)
)
self._poll_handles[account_id] = BlueskyPollHandle(task=task, dedupe=dedupe)
if account.enable_notifications:
notif_task = asyncio.create_task(
self._poll_notifications_loop(
account_id,
handle,
on_mention,
dedupe,
)
)
self._notif_tasks[account_id] = notif_task
return handle
async def stop(self, account_id: str):
handle = self._poll_handles.pop(account_id, None)
if handle:
handle.task.cancel()
try:
await handle.task
except asyncio.CancelledError:
pass
notif_task = self._notif_tasks.pop(account_id, None)
if notif_task:
notif_task.cancel()
try:
await notif_task
except asyncio.CancelledError:
pass
self._active_clients.pop(account_id, None)
logger.info("Bluesky gateway stopped for account %s", account_id)
def get_client(self, account_id: str) -> BlueskyClientHandle | None:
return self._active_clients.get(account_id)
async def stop_all(self):
for account_id in list(self._active_clients.keys()):
await self.stop(account_id)
async def _poll_dm_loop(
self,
account_id: str,
handle: BlueskyClientHandle,
on_dm: callable,
authorize_sender: callable,
dedupe: DedupeTracker,
interval: float = 5.0,
):
dm_client = handle.dm_client
cursor: str | None = None
consecutive_errors = 0
max_interval = 120.0
while True:
try:
params = {}
if cursor:
params["cursor"] = cursor
log = dm_client.chat.bsky.convo.get_log(params=params)
cursor = log.cursor
for item in log.logs:
self._process_log_item(
item,
handle,
on_dm,
authorize_sender,
dedupe,
)
consecutive_errors = 0
except asyncio.CancelledError:
raise
except Exception as e:
consecutive_errors += 1
delay = min(interval * (2**consecutive_errors), max_interval)
delay *= random.uniform(0.8, 1.2)
logger.error(
"Bluesky DM poll error for %s (attempt %d, retry in %.1fs): %s",
account_id,
consecutive_errors,
delay,
e,
)
await asyncio.sleep(delay)
continue
await asyncio.sleep(interval)
def _process_log_item(
self,
item,
handle: BlueskyClientHandle,
on_dm: callable,
authorize_sender: callable,
dedupe: DedupeTracker,
):
is_create_message = isinstance(item, at_models.ChatBskyConvoGetLog.LogCreateMessage)
is_add_reaction = isinstance(item, at_models.ChatBskyConvoGetLog.LogAddReaction)
is_remove_reaction = isinstance(item, at_models.ChatBskyConvoGetLog.LogRemoveReaction)
if is_create_message:
msg = item.message
mid = msg.id
if dedupe.has(mid):
return
if msg.sender.did == handle.me_did:
return
dm = InboundDM(
message_id=mid,
convo_id=item.convo_id,
sender_did=msg.sender.did,
sender_handle=msg.sender.handle or msg.sender.did,
text=msg.text,
sent_at=msg.sent_at,
rev=msg.rev,
)
asyncio.create_task(self._dispatch_dm(dm, handle, on_dm, authorize_sender))
elif is_add_reaction or is_remove_reaction:
mid = f"{item.convo_id}:{item.message_id}:{item.reaction.value}"
if dedupe.has(mid):
return
if item.reaction.sender.did == handle.me_did:
return
asyncio.create_task(self._dispatch_reaction(item, on_dm, is_add_reaction))
async def _dispatch_dm(
self,
dm: InboundDM,
handle: BlueskyClientHandle,
on_dm: callable,
authorize_sender: callable,
):
try:
if not await authorize_sender(dm.sender_did, dm.text):
return
await on_dm(dm)
try:
handle.dm_client.chat.bsky.convo.update_read(
at_models.ChatBskyConvoUpdateRead.Data(
convo_id=dm.convo_id,
message_id=dm.message_id,
)
)
except Exception:
pass
except Exception as e:
logger.error("DM dispatch error: %s", e)
async def _dispatch_reaction(self, item, on_dm, is_add):
reaction_dm = InboundDM(
message_id=item.message_id,
convo_id=item.convo_id,
sender_did=item.reaction.sender.did,
sender_handle=item.reaction.sender.handle or item.reaction.sender.did,
text=f"[reaction:{'add' if is_add else 'remove'}:{item.reaction.value}]",
sent_at=item.reaction.created_at,
rev="",
)
try:
await on_dm(reaction_dm)
except Exception as e:
logger.error("Reaction dispatch error: %s", e)
async def _poll_notifications_loop(
self,
account_id: str,
handle: BlueskyClientHandle,
on_mention: callable,
dedupe: DedupeTracker,
interval: float = 15.0,
):
client = handle.client
consecutive_errors = 0
max_interval = 120.0
while True:
try:
resp = client.app.bsky.notification.list_notifications(params={"limit": 50})
for notif in resp.notifications:
if notif.reason not in ("mention", "reply", "like", "repost", "follow", "quote"):
continue
if dedupe.has(notif.uri):
continue
mention = InboundMention(
uri=notif.uri,
cid=notif.cid,
author_did=notif.author.did,
author_handle=notif.author.handle or notif.author.did,
text=getattr(notif.record, "text", f"[{notif.reason}]"),
reason=notif.reason,
indexed_at=notif.indexed_at,
)
try:
thread = client.get_post_thread(uri=notif.uri)
if thread.thread.reply:
mention.root_uri = thread.thread.reply.root.uri
mention.root_cid = thread.thread.reply.root.cid
mention.parent_uri = thread.thread.reply.parent.uri
mention.parent_cid = thread.thread.reply.parent.cid
except Exception:
pass
asyncio.create_task(self._dispatch_mention(mention, on_mention))
consecutive_errors = 0
except asyncio.CancelledError:
raise
except Exception as e:
consecutive_errors += 1
delay = min(interval * (2**consecutive_errors), max_interval)
delay *= random.uniform(0.8, 1.2)
logger.error(
"Bluesky notification poll error for %s (attempt %d, retry in %.1fs): %s",
account_id,
consecutive_errors,
delay,
e,
)
await asyncio.sleep(delay)
continue
await asyncio.sleep(interval)
async def _dispatch_mention(self, mention: InboundMention, on_mention: callable):
try:
await on_mention(mention)
except Exception as e:
logger.error("Mention dispatch error: %s", e)