feat(bluesky): 新增Bluesky (AT Protocol) 频道插件
实现了完整的Bluesky频道功能,支持私信轮询、@提及通知、账号会话管理、重复消息过滤、配对授权机制以及完整的API调用能力,包含配置schema和插件元数据定义
This commit is contained in:
parent
b018e3eda4
commit
bb85da1ca2
369
backend/package/yuxi/channel/extensions/bluesky/__init__.py
Normal file
369
backend/package/yuxi/channel/extensions/bluesky/__init__.py
Normal 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())
|
||||
28
backend/package/yuxi/channel/extensions/bluesky/config.py
Normal file
28
backend/package/yuxi/channel/extensions/bluesky/config.py
Normal 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),
|
||||
)
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
29
backend/package/yuxi/channel/extensions/bluesky/dedupe.py
Normal file
29
backend/package/yuxi/channel/extensions/bluesky/dedupe.py
Normal 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]
|
||||
19
backend/package/yuxi/channel/extensions/bluesky/defaults.py
Normal file
19
backend/package/yuxi/channel/extensions/bluesky/defaults.py
Normal 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
|
||||
99
backend/package/yuxi/channel/extensions/bluesky/format.py
Normal file
99
backend/package/yuxi/channel/extensions/bluesky/format.py
Normal 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
|
||||
387
backend/package/yuxi/channel/extensions/bluesky/gateway.py
Normal file
387
backend/package/yuxi/channel/extensions/bluesky/gateway.py
Normal 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)
|
||||
568
backend/package/yuxi/channel/extensions/bluesky/outbound.py
Normal file
568
backend/package/yuxi/channel/extensions/bluesky/outbound.py
Normal 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
|
||||
39
backend/package/yuxi/channel/extensions/bluesky/pairing.py
Normal file
39
backend/package/yuxi/channel/extensions/bluesky/pairing.py
Normal 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]
|
||||
27
backend/package/yuxi/channel/extensions/bluesky/plugin.json
Normal file
27
backend/package/yuxi/channel/extensions/bluesky/plugin.json
Normal 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
|
||||
}
|
||||
}
|
||||
41
backend/package/yuxi/channel/extensions/bluesky/security.py
Normal file
41
backend/package/yuxi/channel/extensions/bluesky/security.py
Normal 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"
|
||||
@ -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()
|
||||
71
backend/package/yuxi/channel/extensions/bluesky/status.py
Normal file
71
backend/package/yuxi/channel/extensions/bluesky/status.py
Normal 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,
|
||||
}
|
||||
51
backend/package/yuxi/channel/extensions/bluesky/types.py
Normal file
51
backend/package/yuxi/channel/extensions/bluesky/types.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user