feat(bluesky): 新增Bluesky (AT Protocol) 频道插件

实现了完整的Bluesky频道功能,支持私信轮询、@提及通知、账号会话管理、重复消息过滤、配对授权机制以及完整的API调用能力,包含配置schema和插件元数据定义
This commit is contained in:
Kris 2026-05-21 10:40:50 +08:00
parent b018e3eda4
commit bb85da1ca2
14 changed files with 1827 additions and 0 deletions

View File

@ -0,0 +1,369 @@
import logging
import uuid
from atproto import models as at_models
from yuxi.channel.capabilities import ChannelCapabilities
from yuxi.channel.context import ChannelContext
from yuxi.channel.extensions.base import BaseChannelPlugin
from yuxi.channel.extensions.bluesky.config import (
list_bluesky_account_ids,
resolve_bluesky_account,
)
from yuxi.channel.extensions.bluesky.config_schema import BLUESKY_CONFIG_SCHEMA
from yuxi.channel.extensions.bluesky.dedupe import DedupeTracker
from yuxi.channel.extensions.bluesky.format import BlueskyFormatter
from yuxi.channel.extensions.bluesky.gateway import BlueskyGateway, BlueskyGatewayError
from yuxi.channel.extensions.bluesky.outbound import BlueskyOutbound
from yuxi.channel.extensions.bluesky.security import BlueskySecurity
from yuxi.channel.extensions.bluesky.pairing import BlueskyPairing
from yuxi.channel.extensions.bluesky.status import BlueskyStatus
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
from yuxi.channel.plugins.registry import ChannelPluginRegistry
from yuxi.channel.routing.models import PeerKind
logger = logging.getLogger(__name__)
class BlueskyPlugin(BaseChannelPlugin):
id = "bluesky"
name = "Bluesky"
order = 101
label = "Bluesky"
aliases = ["bsky", "atproto"]
resolve_reply_to_mode = "off"
def __init__(self):
super().__init__()
self._gateway = BlueskyGateway()
self._outbound = BlueskyOutbound(self._gateway)
self._security = BlueskySecurity()
self._pairing = BlueskyPairing()
self._status = BlueskyStatus(self._gateway)
self._formatter = BlueskyFormatter()
self._dedupe = DedupeTracker()
self._config: dict = {}
self._dynamic_allowlist: list[str] = []
@property
def capabilities(self) -> ChannelCapabilities:
return ChannelCapabilities(
chat_types=["direct"],
message_types=["text"],
reactions=True,
typing_indicator=False,
threads=False,
edit=False,
unsend=True,
reply=True,
media=True,
native_commands=False,
polls=False,
streaming=True,
streaming_mode="block_streaming",
block_streaming=True,
block_streaming_chunk_min_chars=800,
block_streaming_chunk_max_chars=2000,
)
def list_account_ids(self, config: dict) -> list[str]:
return list_bluesky_account_ids(config)
async def resolve_account(self, account_id: str) -> dict:
config = getattr(self, "_config", None) or {}
account = resolve_bluesky_account(config, account_id)
return {
"account_id": account.account_id,
"name": account.name,
"handle": account.handle,
"configured": account.is_configured,
"dm_policy": account.dm_policy,
"allow_from": account.allow_from,
"enable_notifications": account.enable_notifications,
"enable_firehose": account.enable_firehose,
}
def is_configured(self, account: dict) -> bool:
return bool(account.get("handle") and account.get("configured"))
def is_enabled(self, account: dict) -> bool:
return True
def disabled_reason(self, account: dict) -> str:
if not account.get("handle"):
return "handle not configured"
if not account.get("configured"):
return "appPassword not configured"
return ""
def describe_account(self, account: dict) -> dict:
return {
"account_id": account.get("account_id", "default"),
"name": account.get("name", ""),
"handle": account.get("handle", ""),
}
async def resolve_allow_from(self, config: dict, account_id: str) -> list[str] | None:
static = self._security.resolve_allow_from(config, account_id) or []
return static + self._dynamic_allowlist
def config_schema(self) -> dict:
return BLUESKY_CONFIG_SCHEMA
async def start(self, ctx: ChannelContext) -> object:
account = resolve_bluesky_account(ctx.config, ctx.account_id)
async def on_dm(dm):
unified = UnifiedMessage(
msg_id=uuid.uuid4().hex,
channel_type="bluesky",
account_id=ctx.account_id,
content=dm.text,
sender=PeerInfo(
kind=PeerKind.DIRECT,
id=dm.sender_did,
display_name=dm.sender_handle,
),
message_type=MessageType.TEXT,
reply_to_id=dm.message_id,
raw_payload={
"convo_id": dm.convo_id,
"sender_did": dm.sender_did,
"sender_handle": dm.sender_handle,
"rev": dm.rev,
},
)
if ctx.queue is not None:
await ctx.queue.put(unified)
async def on_mention(mention):
unified = UnifiedMessage(
msg_id=uuid.uuid4().hex,
channel_type="bluesky",
account_id=ctx.account_id,
content=mention.text,
sender=PeerInfo(
kind=PeerKind.DIRECT,
id=mention.author_did,
display_name=mention.author_handle,
),
message_type=MessageType.TEXT,
reply_to_id=mention.uri,
raw_payload={
"uri": mention.uri,
"cid": mention.cid,
"author_did": mention.author_did,
"author_handle": mention.author_handle,
"reason": mention.reason,
"root_uri": mention.root_uri,
"root_cid": mention.root_cid,
"parent_uri": mention.parent_uri,
"parent_cid": mention.parent_cid,
"source": "mention",
},
)
if ctx.queue is not None:
await ctx.queue.put(unified)
async def authorize_sender(sender_did: str, _text: str) -> bool:
dm_policy = await self._security.resolve_dm_policy(ctx.config, ctx.account_id)
allow_from = self._security.resolve_allow_from(ctx.config, ctx.account_id)
allow_from = allow_from + self._dynamic_allowlist
result = await self._security.authorize(sender_did, dm_policy, allow_from)
if result == "pairing":
logger.info("Bluesky pairing challenge for %s", sender_did)
return False
return result == "allow"
try:
handle = await self._gateway.start(
account_id=ctx.account_id,
account=account,
on_dm=on_dm,
on_mention=on_mention,
authorize_sender=authorize_sender,
)
return handle
except BlueskyGatewayError as e:
logger.error("Bluesky start failed for account %s: %s", ctx.account_id, e)
raise
async def stop(self, ctx: ChannelContext) -> None:
await self._gateway.stop(ctx.account_id)
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
target = target_id.replace("did:", "").strip()
aid = account_id or "default"
return await self._outbound.send_dm(target, content, account_id=aid)
async def send_media(
self,
target_id: str,
media_url: str,
media_type: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
if media_type.startswith("image/"):
upload_result = await self._outbound.upload_blob(media_url, account_id=account_id or "default")
image_embed = at_models.AppBskyEmbedImages.Main(
images=[
at_models.AppBskyEmbedImages.Image(
image=upload_result["blob"],
alt="",
)
]
)
target = target_id.replace("did:", "").strip()
await self._outbound.send_dm(
target,
"",
account_id=account_id or "default",
embed=image_embed,
)
else:
logger.warning("Bluesky media type '%s' not yet supported", media_type)
async def probe(self, account: dict | None = None) -> bool:
aid = account.get("account_id", "default") if account else "default"
snapshot = await self._status.probe(aid)
return snapshot.connected and snapshot.session_valid
def build_summary(self, snapshot: object) -> dict:
return self._status.build_summary(snapshot)
async def check_allowlist(self, peer_id: str, channel_type: str) -> bool:
config = getattr(self, "_config", None) or {}
allow_from = self._security.resolve_allow_from(config, "default")
if not allow_from:
return False
normalized = self._pairing.normalize_allow_entry(peer_id)
for entry in allow_from:
if self._pairing.normalize_allow_entry(entry) == normalized:
return True
return False
def resolve_dm_policy(self) -> dict:
config = getattr(self, "_config", None) or {}
account = resolve_bluesky_account(config, "default")
return {
"mode": account.dm_policy,
"allow_from": account.allow_from,
}
async def generate_code(self, peer_id: str) -> str:
return self._pairing.generate_code(peer_id, "")
async def verify_code(self, peer_id: str, code: str) -> bool:
entry = self._pairing.verify_code(code)
if entry is None:
return False
expected = self._pairing.normalize_allow_entry(peer_id)
actual = self._pairing.normalize_allow_entry(entry["did"])
return actual == expected
def collect_warnings(self, config: dict, account_id: str | None = None, account: dict | None = None) -> list[str]:
warnings = []
aid = account_id or "default"
acct = resolve_bluesky_account(config, aid)
if not acct.handle:
warnings.append("Bluesky handle is not configured")
if not acct.app_password:
warnings.append("Bluesky appPassword is not configured")
return warnings
@property
def config_prefixes(self) -> list[str]:
return ["channels.bluesky"]
async def on_config_changed(self, prev_cfg: dict, next_cfg: dict, account_id: str) -> None:
self._config = next_cfg
async def on_account_removed(self, account_id: str) -> None:
await self._gateway.stop(account_id)
async def on_retire(self) -> None:
await self._gateway.stop_all()
async def build_reply_payload(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
session_key: str | None = None,
agent_config_id: str | None = None,
**kwargs,
):
from yuxi.channel.protocols import BoundReplyPayload
return BoundReplyPayload(
target_id=target_id.replace("did:", "").strip(),
content=content,
reply_to_id=reply_to_id,
thread_id=thread_id,
)
def sanitize_text(self, text: str, payload: object | None = None) -> str:
return self._formatter.sanitize_text(text, payload)
def markdown_to_native(self, md_text: str) -> str:
return self._formatter.markdown_to_native(md_text)
def is_duplicate(self, key: str) -> bool:
return self._dedupe.has(key)
def mark_seen(self, key: str) -> None:
self._dedupe.has(key)
def reset(self) -> None:
self._dedupe._store.clear()
@property
def ttl_seconds(self) -> int:
return self._dedupe.ttl_s
@property
def max_entries(self) -> int:
return self._dedupe.max_entries
async def list_allow_entries(self, config: dict, scope: str) -> list[str]:
static = self._security.resolve_allow_from(config, "default") or []
return static + self._dynamic_allowlist
async def add_allow_entry(
self,
entry: str,
*,
account_id: str | None = None,
) -> None:
did = self._pairing.normalize_allow_entry(entry)
if did not in self._dynamic_allowlist:
self._dynamic_allowlist.append(did)
logger.info("Bluesky allowlist entry added: %s", did)
async def remove_allow_entry(
self,
entry: str,
*,
account_id: str | None = None,
) -> None:
did = self._pairing.normalize_allow_entry(entry)
if did in self._dynamic_allowlist:
self._dynamic_allowlist.remove(did)
logger.info("Bluesky allowlist entry removed: %s", did)
ChannelPluginRegistry.register(BlueskyPlugin())

View File

@ -0,0 +1,28 @@
from .types import BlueskyAccount
def list_bluesky_account_ids(config: dict) -> list[str]:
accounts = config.get("channels", {}).get("bluesky", {}).get("accounts", {})
if accounts:
return list(accounts.keys())
return ["default"]
def resolve_bluesky_account(config: dict, account_id: str = "default") -> BlueskyAccount:
bluesky_cfg = config.get("channels", {}).get("bluesky", {})
account_cfg = bluesky_cfg.get("accounts", {}).get(account_id, bluesky_cfg)
handle = account_cfg.get("handle", "")
app_password = account_cfg.get("appPassword", "")
return BlueskyAccount(
account_id=account_id,
handle=handle,
app_password=app_password,
session_string=account_cfg.get("sessionString", ""),
dm_policy=account_cfg.get("dmPolicy", "pairing"),
allow_from=account_cfg.get("allowFrom", []),
enable_notifications=account_cfg.get("enableNotifications", False),
enable_firehose=account_cfg.get("enableFirehose", False),
name=account_cfg.get("name", handle),
)

View File

@ -0,0 +1,63 @@
from pydantic import BaseModel, Field
class BlueskyAccountConfig(BaseModel):
handle: str = ""
app_password: str = Field(default="", alias="appPassword")
session_string: str = Field(default="", alias="sessionString")
dm_policy: str = Field(default="pairing", alias="dmPolicy")
allow_from: list[str] = Field(default_factory=list, alias="allowFrom")
enable_notifications: bool = Field(default=False, alias="enableNotifications")
enable_firehose: bool = Field(default=False, alias="enableFirehose")
name: str = ""
model_config = {"populate_by_name": True}
class BlueskyConfig(BaseModel):
accounts: dict[str, BlueskyAccountConfig] = Field(default_factory=dict)
BLUESKY_CONFIG_SCHEMA = {
"type": "object",
"properties": {
"handle": {
"type": "string",
"description": "Bluesky handle (e.g. my-bot.bsky.social)",
},
"appPassword": {
"type": "string",
"description": "Bluesky App Password with DM permission",
"sensitive": True,
},
"sessionString": {
"type": "string",
"description": "Persisted session token (auto-managed)",
"sensitive": True,
},
"dmPolicy": {
"type": "string",
"enum": ["pairing", "allowlist", "open", "disabled"],
"default": "pairing",
},
"allowFrom": {
"type": "array",
"items": {"type": "string"},
"description": "Allowed DIDs (did:plc:xxx) for allowlist/pairing policies",
},
"enableNotifications": {
"type": "boolean",
"default": False,
"description": "Enable @mention notification polling",
},
"enableFirehose": {
"type": "boolean",
"default": False,
"description": "Enable Firehose WebSocket (P2 feature)",
},
"name": {
"type": "string",
"description": "Display name for this account",
},
},
}

View File

@ -0,0 +1,29 @@
import time
from collections import OrderedDict
class DedupeTracker:
def __init__(self, max_entries: int = 50_000, ttl_s: int = 3600):
self.max_entries = max_entries
self.ttl_s = ttl_s
self._store: OrderedDict[str, float] = OrderedDict()
self._last_prune = time.monotonic()
def has(self, event_id: str) -> bool:
self._maybe_prune()
if event_id in self._store:
self._store.move_to_end(event_id)
return True
self._store[event_id] = time.monotonic()
if len(self._store) > self.max_entries:
self._store.popitem(last=False)
return False
def _maybe_prune(self):
now = time.monotonic()
if now - self._last_prune < 600:
return
self._last_prune = now
expired = [k for k, ts in self._store.items() if now - ts > self.ttl_s]
for k in expired:
del self._store[k]

View File

@ -0,0 +1,19 @@
DEFAULT_POLL_INTERVAL = 5.0
DEFAULT_NOTIF_INTERVAL = 15.0
DEFAULT_DEDUPE_MAX = 50_000
DEFAULT_DEDUPE_TTL = 3600
DEFAULT_DM_POLICY = "pairing"
PAIRING_CODE_LENGTH = 6
PAIRING_CODE_TTL = 300
DM_TEXT_CHUNK_LIMIT = 2000
POST_CHAR_LIMIT = 300
CREATE_SESSION_LIMIT_5MIN = 30
CREATE_SESSION_LIMIT_DAY = 300
REFRESH_SESSION_LIMIT_5MIN = 30
REFRESH_SESSION_LIMIT_DAY = 300
WRITE_POINTS_CREATE = 3
WRITE_POINTS_UPDATE = 2
WRITE_POINTS_DELETE = 1
WRITE_POINTS_DAILY_LIMIT = 5000

View File

@ -0,0 +1,99 @@
import re
import unicodedata
from atproto import models as at_models
from atproto_client.utils import TextBuilder
from .defaults import DM_TEXT_CHUNK_LIMIT, POST_CHAR_LIMIT
_CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
_MULTI_NEWLINE_RE = re.compile(r"\n{3,}")
def build_post_with_mentions(
parts: list[tuple[str, str | None]],
) -> tuple[str, list[dict] | None]:
has_mentions = any(did is not None for _, did in parts)
if not has_mentions:
text = "".join(content for content, _ in parts)
return text, None
tb = TextBuilder()
for content, did in parts:
if did:
tb.mention(content, did)
else:
tb.text(content)
return tb.build_text(), tb.build_facets()
def plain_post(text: str) -> tuple[str, None]:
return text, None
def build_dm_facets(text: str, mentions: list[dict] | None = None) -> list[dict] | None:
if not mentions:
return None
facets = []
for m in mentions:
did = m.get("did", "")
byte_start = m.get("byte_start")
byte_end = m.get("byte_end")
if byte_start is None or byte_end is None:
continue
facets.append(
at_models.AppBskyRichtextFacet.Main(
index=at_models.AppBskyRichtextFacet.ByteSlice(byte_start=byte_start, byte_end=byte_end),
features=[at_models.AppBskyRichtextFacet.Mention(did=did)],
)
)
return facets if facets else None
class BlueskyFormatter:
max_dm_length = DM_TEXT_CHUNK_LIMIT
max_post_length = POST_CHAR_LIMIT
def sanitize_text(self, text: str, payload: object | None = None) -> str:
text = unicodedata.normalize("NFKC", text)
text = _CONTROL_RE.sub("", text)
text = _MULTI_NEWLINE_RE.sub("\n\n", text)
return text.strip()
def markdown_to_native(self, md_text: str) -> str:
text = md_text
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
text = re.sub(r"```[\s\S]*?```", "", text)
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\*\*\*(.+?)\*\*\*", r"_\1_", text)
text = re.sub(r"\*\*(.+?)\*\*", r"_\1_", text)
text = re.sub(r"\*(.+?)\*", r"_\1_", text)
text = re.sub(r"~~(.+?)~~", r"\1", text)
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", text)
return self.sanitize_text(text)
def native_to_markdown(self, native_content: str) -> str:
return native_content
def strip_mentions(self, text: str, ctx: object | None = None) -> str:
return re.sub(r"@[\w.]+(?:\.bsky\.social)?", "", text).strip()
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
chunks = []
limit = min(limit, self.max_dm_length)
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

View File

@ -0,0 +1,387 @@
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)

View File

@ -0,0 +1,568 @@
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

View File

@ -0,0 +1,39 @@
import secrets
import time
from .defaults import PAIRING_CODE_LENGTH, PAIRING_CODE_TTL
class BlueskyPairing:
id_label = "DID"
def __init__(self):
self._pending: dict[str, dict] = {}
def generate_code(self, sender_did: str, sender_handle: str = "") -> str:
self._cleanup_expired()
code = secrets.token_hex(PAIRING_CODE_LENGTH // 2)[:PAIRING_CODE_LENGTH].upper()
self._pending[code] = {
"did": sender_did,
"handle": sender_handle,
"created_at": time.time(),
}
return code
def verify_code(self, code: str) -> dict | None:
self._cleanup_expired()
return self._pending.pop(code, None)
def normalize_allow_entry(self, entry: str) -> str:
return entry.replace("did:", "").strip().lower()
def get_pending(self) -> list[dict]:
self._cleanup_expired()
return [{"code": code, **entry} for code, entry in self._pending.items()]
def _cleanup_expired(self):
now = time.time()
expired = [code for code, entry in self._pending.items() if now - entry["created_at"] > PAIRING_CODE_TTL]
for code in expired:
del self._pending[code]

View File

@ -0,0 +1,27 @@
{
"id": "bluesky",
"name": "Bluesky",
"version": "1.0.0",
"description": "Bluesky (AT Protocol) channel plugin for ForcePilot. Supports DM via chat.bsky.convo polling and public @mention interactions.",
"author": "ForcePilot",
"order": 101,
"enabled": true,
"capabilities": {
"chat_types": ["direct"],
"message_types": ["text"],
"reactions": true,
"typing_indicator": false,
"threads": false,
"edit": false,
"unsend": true,
"reply": true,
"media": true,
"native_commands": false,
"polls": false,
"streaming": true,
"streaming_mode": "block_streaming",
"block_streaming": true,
"block_streaming_chunk_min_chars": 800,
"block_streaming_chunk_max_chars": 2000
}
}

View File

@ -0,0 +1,41 @@
DM_POLICY_OPEN = "open"
DM_POLICY_PAIRING = "pairing"
DM_POLICY_ALLOWLIST = "allowlist"
DM_POLICY_DISABLED = "disabled"
VALID_DM_POLICIES = {
DM_POLICY_OPEN,
DM_POLICY_PAIRING,
DM_POLICY_ALLOWLIST,
DM_POLICY_DISABLED,
}
class BlueskySecurity:
def __init__(self):
self.default_policy = DM_POLICY_PAIRING
async def resolve_dm_policy(self, config: dict, account_id: str) -> str:
bluesky_cfg = config.get("channels", {}).get("bluesky", {})
account_cfg = bluesky_cfg.get("accounts", {}).get(account_id, bluesky_cfg)
policy = account_cfg.get("dmPolicy", self.default_policy)
return policy if policy in VALID_DM_POLICIES else self.default_policy
def resolve_allow_from(self, config: dict, account_id: str) -> list[str]:
bluesky_cfg = config.get("channels", {}).get("bluesky", {})
account_cfg = bluesky_cfg.get("accounts", {}).get(account_id, bluesky_cfg)
raw = account_cfg.get("allowFrom", [])
return [entry.replace("did:", "").strip().lower() for entry in raw if entry.strip()]
async def authorize(self, sender_did: str, dm_policy: str, allow_from: list[str]) -> str:
normalized = sender_did.strip().lower()
if dm_policy == DM_POLICY_OPEN:
return "allow"
if dm_policy == DM_POLICY_DISABLED:
return "block"
if dm_policy == DM_POLICY_ALLOWLIST:
return "allow" if normalized in allow_from else "block"
if dm_policy == DM_POLICY_PAIRING:
return "allow" if normalized in allow_from else "pairing"
return "block"

View File

@ -0,0 +1,36 @@
import os
from pathlib import Path
SESSION_DIR = Path.home() / ".forcepilot" / "bluesky"
def _session_dir() -> Path:
env_dir = os.environ.get("BLUESKY_SESSION_DIR")
if env_dir:
return Path(env_dir)
return SESSION_DIR
def save_session(account_id: str, session_string: str) -> None:
session_dir = _session_dir()
session_dir.mkdir(parents=True, exist_ok=True)
path = session_dir / f"{account_id}.session"
tmp = session_dir / f"{account_id}.session.tmp"
tmp.write_text(session_string)
tmp.chmod(0o600)
if path.exists():
path.unlink()
tmp.rename(path)
def load_session(account_id: str) -> str | None:
path = _session_dir() / f"{account_id}.session"
if path.exists():
return path.read_text().strip()
return None
def delete_session(account_id: str) -> None:
path = _session_dir() / f"{account_id}.session"
if path.exists():
path.unlink()

View File

@ -0,0 +1,71 @@
import datetime
from dataclasses import dataclass
@dataclass
class BlueskyStatusSnapshot:
account_id: str
connected: bool
session_valid: bool
dm_service_reachable: bool
me_did: str = ""
me_handle: str = ""
agent_count: int = 0
last_dm_poll_at: datetime.datetime | None = None
last_dm_poll_error: str = ""
class BlueskyStatus:
def __init__(self, gateway):
self._gateway = gateway
async def probe(self, account_id: str) -> BlueskyStatusSnapshot:
handle = self._gateway.get_client(account_id)
if not handle:
return BlueskyStatusSnapshot(
account_id=account_id,
connected=False,
session_valid=False,
dm_service_reachable=False,
)
session_valid = False
me_did = ""
me_handle = ""
try:
me = handle.client.me
if me:
session_valid = True
me_did = getattr(me, "did", "")
me_handle = getattr(me, "handle", "")
except Exception:
pass
dm_reachable = False
try:
handle.dm_client.chat.bsky.convo.list_convos(params={"limit": 1})
dm_reachable = True
except Exception:
pass
return BlueskyStatusSnapshot(
account_id=account_id,
connected=True,
session_valid=session_valid,
dm_service_reachable=dm_reachable,
me_did=me_did,
me_handle=me_handle,
agent_count=len(self._gateway._active_clients),
)
def build_summary(self, snapshot: BlueskyStatusSnapshot) -> dict:
return {
"channel": "bluesky",
"account_id": snapshot.account_id,
"connected": snapshot.connected,
"session_valid": snapshot.session_valid,
"dm_service_reachable": snapshot.dm_service_reachable,
"handle": snapshot.me_handle,
"did": snapshot.me_did,
"agent_count": snapshot.agent_count,
}

View File

@ -0,0 +1,51 @@
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class BlueskyAccount:
account_id: str = "default"
handle: str = ""
app_password: str = ""
session_string: str = ""
dm_policy: str = "pairing"
allow_from: list[str] = field(default_factory=list)
enable_notifications: bool = False
enable_firehose: bool = False
name: str = ""
@property
def is_configured(self) -> bool:
return bool(self.handle and self.app_password)
@dataclass
class InboundDM:
message_id: str
convo_id: str
sender_did: str
sender_handle: str
text: str
sent_at: datetime
rev: str = ""
@dataclass
class InboundMention:
uri: str
cid: str
author_did: str
author_handle: str
text: str
reason: str
indexed_at: datetime
root_uri: str | None = None
root_cid: str | None = None
parent_uri: str | None = None
parent_cid: str | None = None
@dataclass
class PostRef:
uri: str
cid: str