social media crossposting tool. 3rd time's the charm
mastodon misskey crossposting bluesky
at next 307 lines 7.7 kB view raw
1import json 2from abc import ABC, abstractmethod 3from dataclasses import dataclass, field 4from typing import Any 5 6from util.util import Result 7 8 9URI = "at://" 10URI_LEN = len(URI) 11 12 13def cid_from_json(data: str | None) -> Result[str, str]: 14 if data is None: 15 return Result.err("Expected json, got None") 16 17 try: 18 return Result.ok(str(json.loads(data)["cid"])) 19 except (json.JSONDecodeError, AttributeError, KeyError) as e: 20 return Result.err(str(e)) 21 22 23class AtUri: 24 @classmethod 25 def record_uri(cls, uri: str) -> tuple[str, str, str]: 26 if not uri.startswith(URI): 27 raise ValueError(f"Ivalid record uri {uri}!") 28 29 did, collection, rid = uri[URI_LEN:].split("/") 30 if not (did and collection and rid): 31 raise ValueError(f"Ivalid record uri {uri}!") 32 33 return did, collection, rid 34 35 36@dataclass(kw_only=True) 37class StrongRef: 38 uri: str 39 cid: str 40 41 def to_dict(self) -> dict[str, Any]: 42 return {"uri": self.uri, "cid": self.cid} 43 44 @classmethod 45 def from_dict(cls, data: dict[str, Any]) -> "StrongRef": 46 return cls(uri=data["uri"], cid=data["cid"]) 47 48 49@dataclass(kw_only=True) 50class ReplyRef: 51 root: StrongRef 52 parent: StrongRef 53 54 def to_dict(self) -> dict[str, Any]: 55 return { 56 "root": self.root.to_dict(), 57 "parent": self.parent.to_dict(), 58 } 59 60 61@dataclass(kw_only=True) 62class FacetIndex: 63 byte_start: int 64 byte_end: int 65 66 def to_dict(self) -> dict[str, int]: 67 return {"byteStart": self.byte_start, "byteEnd": self.byte_end} 68 69 70@dataclass(kw_only=True) 71class FacetFeature(ABC): 72 @abstractmethod 73 def to_dict(self) -> dict[str, Any]: 74 pass 75 76 77@dataclass(kw_only=True) 78class MentionFeature(FacetFeature): 79 did: str 80 81 def to_dict(self) -> dict[str, Any]: 82 return { 83 "$type": "app.bsky.richtext.facet#mention", 84 "did": self.did, 85 } 86 87 88@dataclass(kw_only=True) 89class LinkFeature(FacetFeature): 90 uri: str 91 92 def to_dict(self) -> dict[str, Any]: 93 return { 94 "$type": "app.bsky.richtext.facet#link", 95 "uri": self.uri, 96 } 97 98 99@dataclass(kw_only=True) 100class TagFeature(FacetFeature): 101 tag: str 102 103 def to_dict(self) -> dict[str, Any]: 104 return { 105 "$type": "app.bsky.richtext.facet#tag", 106 "tag": self.tag, 107 } 108 109 110@dataclass(kw_only=True) 111class Facet: 112 index: FacetIndex 113 features: list[FacetFeature] = field(default_factory=list) 114 115 def to_dict(self) -> dict[str, Any]: 116 return { 117 "index": self.index.to_dict(), 118 "features": [f.to_dict() for f in self.features], 119 } 120 121 122@dataclass(kw_only=True) 123class ImageEmbed: 124 image: bytes 125 alt: str | None = None 126 aspect_ratio: tuple[int, int] | None = None 127 128 def to_dict(self, blob_ref: dict[str, Any]) -> dict[str, Any]: 129 data: dict[str, Any] = { 130 "image": blob_ref, 131 "alt": self.alt or "", 132 } 133 if self.aspect_ratio: 134 data["aspectRatio"] = { 135 "width": self.aspect_ratio[0], 136 "height": self.aspect_ratio[1], 137 } 138 return data 139 140 141@dataclass(kw_only=True) 142class VideoEmbed: 143 video: bytes 144 alt: str | None = None 145 aspect_ratio: tuple[int, int] | None = None 146 147 def to_dict(self, blob_ref: dict[str, Any]) -> dict[str, Any]: 148 data: dict[str, Any] = { 149 "$type": "app.bsky.embed.video", 150 "video": blob_ref, 151 } 152 if self.alt: 153 data["alt"] = self.alt 154 if self.aspect_ratio: 155 data["aspectRatio"] = { 156 "width": self.aspect_ratio[0], 157 "height": self.aspect_ratio[1], 158 } 159 return data 160 161 162@dataclass(kw_only=True) 163class RecordEmbed: 164 record: StrongRef 165 166 def to_dict(self) -> dict[str, Any]: 167 return { 168 "$type": "app.bsky.embed.record", 169 "record": self.record.to_dict(), 170 } 171 172 173@dataclass(kw_only=True) 174class RecordWithMediaEmbed: 175 record: StrongRef 176 media: ImageEmbed | VideoEmbed 177 media_blob_ref: dict[str, Any] 178 179 def to_dict(self) -> dict[str, Any]: 180 media_data = self.media.to_dict(self.media_blob_ref) 181 media_type = ( 182 "app.bsky.embed.images" 183 if isinstance(self.media, ImageEmbed) 184 else "app.bsky.embed.video" 185 ) 186 return { 187 "$type": "app.bsky.embed.recordWithMedia", 188 "record": self.record.to_dict(), 189 "media": { 190 "$type": media_type, 191 **media_data, 192 }, 193 } 194 195 196@dataclass(kw_only=True) 197class SelfLabel: 198 val: str 199 200 def to_dict(self) -> dict[str, str]: 201 return {"val": self.val} 202 203 204@dataclass(kw_only=True) 205class SelfLabels: 206 values: list[SelfLabel] = field(default_factory=list) 207 208 def to_dict(self) -> dict[str, Any]: 209 return { 210 "$type": "com.atproto.label.defs#selfLabels", 211 "values": [v.to_dict() for v in self.values], 212 } 213 214 215@dataclass(kw_only=True) 216class PostRecord: 217 text: str 218 created_at: str 219 facets: list[Facet] | None = None 220 embed: dict[str, Any] | None = None 221 reply: ReplyRef | None = None 222 langs: list[str] | None = None 223 labels: SelfLabels | None = None 224 225 def to_dict(self) -> dict[str, Any]: 226 data: dict[str, Any] = { 227 "$type": "app.bsky.feed.post", 228 "text": self.text, 229 "createdAt": self.created_at, 230 } 231 if self.facets: 232 data["facets"] = [f.to_dict() for f in self.facets] 233 if self.embed: 234 data["embed"] = self.embed 235 if self.reply: 236 data["reply"] = self.reply.to_dict() 237 if self.langs: 238 data["langs"] = self.langs 239 if self.labels: 240 data["labels"] = self.labels.to_dict() 241 return data 242 243 244@dataclass(kw_only=True) 245class CreateRecordResponse: 246 uri: str 247 cid: str 248 commit: dict[str, Any] | None = None 249 250 @classmethod 251 def from_dict(cls, data: dict[str, Any]) -> "CreateRecordResponse": 252 return cls( 253 uri=data.get("uri", ""), 254 cid=data.get("cid", ""), 255 commit=data.get("commit"), 256 ) 257 258 259@dataclass(kw_only=True) 260class RepostRecord: 261 subject: StrongRef 262 created_at: str 263 264 def to_dict(self) -> dict[str, Any]: 265 data: dict[str, Any] = { 266 "$type": "app.bsky.feed.repost", 267 "createdAt": self.created_at, 268 "subject": self.subject.to_dict(), 269 } 270 return data 271 272 273@dataclass(kw_only=True) 274class ThreadGate: 275 post: str = "" 276 created_at: str 277 allow: list[dict[str, Any]] = field(default_factory=list) 278 279 def to_dict(self) -> dict[str, Any]: 280 data: dict[str, Any] = { 281 "$type": "app.bsky.feed.threadgate", 282 "post": self.post, 283 "createdAt": self.created_at, 284 } 285 if self.allow is not None: 286 data["allow"] = self.allow 287 return data 288 289 290@dataclass(kw_only=True) 291class PostGate: 292 post: str 293 created_at: str 294 detached_embedding_uris: list[str] | None = None 295 embedding_rules: list[dict[str, Any]] | None = None 296 297 def to_dict(self) -> dict[str, Any]: 298 data: dict[str, Any] = { 299 "$type": "app.bsky.feed.postgate", 300 "post": self.post, 301 "createdAt": self.created_at, 302 } 303 if self.detached_embedding_uris is not None: 304 data["detachedEmbeddingUris"] = self.detached_embedding_uris 305 if self.embedding_rules is not None: 306 data["embeddingRules"] = self.embedding_rules 307 return data