social media crossposting tool. 3rd time's the charm
mastodon
misskey
crossposting
bluesky
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