social media crossposting tool. 3rd time's the charm
mastodon
misskey
crossposting
bluesky
1from typing import Any
2from atproto import client_utils, Client, AtUri, IdResolver
3from atproto_client import models
4from util import LOGGER
5
6def resolve_identity(
7 handle: str | None = None,
8 did: str | None = None,
9 pds: str | None = None):
10 """helper to try and resolve identity from provided parameters, a valid handle is enough"""
11
12 if did and pds:
13 return did, pds[:-1] if pds.endswith('/') else pds
14
15 resolver = IdResolver()
16 if not did:
17 if not handle:
18 raise Exception("ATP handle not specified!")
19 LOGGER.info("Resolving ATP identity for %s...", handle)
20 did = resolver.handle.resolve(handle)
21 if not did:
22 raise Exception("Failed to resolve DID!")
23
24 if not pds:
25 LOGGER.info("Resolving PDS from DID document...")
26 did_doc = resolver.did.resolve(did)
27 if not did_doc:
28 raise Exception("Failed to resolve DID doc for '%s'", did)
29 pds = did_doc.get_pds_endpoint()
30 if not pds:
31 raise Exception("Failed to resolve PDS!")
32
33 return did, pds[:-1] if pds.endswith('/') else pds
34
35class Client2(Client):
36 def __init__(self, base_url: str | None = None, *args: Any, **kwargs: Any) -> None:
37 super().__init__(base_url, *args, **kwargs)
38
39 def send_video(
40 self,
41 text: str | client_utils.TextBuilder,
42 video: bytes,
43 video_alt: str | None = None,
44 video_aspect_ratio: models.AppBskyEmbedDefs.AspectRatio | None = None,
45 reply_to: models.AppBskyFeedPost.ReplyRef | None = None,
46 langs: list[str] | None = None,
47 facets: list[models.AppBskyRichtextFacet.Main] | None = None,
48 labels: models.ComAtprotoLabelDefs.SelfLabels | None = None,
49 time_iso: str | None = None
50 ) -> models.AppBskyFeedPost.CreateRecordResponse:
51 """same as send_video, but with labels"""
52
53 if video_alt is None:
54 video_alt = ''
55
56 upload = self.upload_blob(video)
57
58 return self.send_post(
59 text,
60 reply_to=reply_to,
61 embed=models.AppBskyEmbedVideo.Main(video=upload.blob, alt=video_alt, aspect_ratio=video_aspect_ratio),
62 langs=langs,
63 facets=facets,
64 labels=labels,
65 time_iso=time_iso
66 )
67
68 def send_images(
69 self,
70 text: str | client_utils.TextBuilder,
71 images: list[bytes],
72 image_alts: list[str] | None = None,
73 image_aspect_ratios: list[models.AppBskyEmbedDefs.AspectRatio] | None = None,
74 reply_to: models.AppBskyFeedPost.ReplyRef | None = None,
75 langs: list[str] | None = None,
76 facets: list[models.AppBskyRichtextFacet.Main] | None = None,
77 labels: models.ComAtprotoLabelDefs.SelfLabels | None = None,
78 time_iso: str | None = None
79 ) -> models.AppBskyFeedPost.CreateRecordResponse:
80 """same as send_images, but with labels"""
81
82 if image_alts is None:
83 image_alts = [''] * len(images)
84 else:
85 diff = len(images) - len(image_alts)
86 image_alts = image_alts + [''] * diff
87
88 if image_aspect_ratios is None:
89 aligned_image_aspect_ratios = [None] * len(images)
90 else:
91 diff = len(images) - len(image_aspect_ratios)
92 aligned_image_aspect_ratios = image_aspect_ratios + [None] * diff
93
94 uploads = [self.upload_blob(image) for image in images]
95
96 embed_images = [
97 models.AppBskyEmbedImages.Image(alt=alt, image=upload.blob, aspect_ratio=aspect_ratio)
98 for alt, upload, aspect_ratio in zip(image_alts, uploads, aligned_image_aspect_ratios)
99 ]
100
101 return self.send_post(
102 text,
103 reply_to=reply_to,
104 embed=models.AppBskyEmbedImages.Main(images=embed_images),
105 langs=langs,
106 facets=facets,
107 labels=labels,
108 time_iso=time_iso
109 )
110
111 def send_post(
112 self,
113 text: str | client_utils.TextBuilder,
114 reply_to: models.AppBskyFeedPost.ReplyRef | None = None,
115 embed:
116 None |
117 models.AppBskyEmbedImages.Main |
118 models.AppBskyEmbedExternal.Main |
119 models.AppBskyEmbedRecord.Main |
120 models.AppBskyEmbedRecordWithMedia.Main |
121 models.AppBskyEmbedVideo.Main = None,
122 langs: list[str] | None = None,
123 facets: list[models.AppBskyRichtextFacet.Main] | None = None,
124 labels: models.ComAtprotoLabelDefs.SelfLabels | None = None,
125 time_iso: str | None = None
126 ) -> models.AppBskyFeedPost.CreateRecordResponse:
127 """same as send_post, but with labels"""
128
129 if isinstance(text, client_utils.TextBuilder):
130 facets = text.build_facets()
131 text = text.build_text()
132
133 repo = self.me and self.me.did
134 if not repo:
135 raise Exception("Client not logged in!")
136
137 if not langs:
138 langs = ['en']
139
140 record = models.AppBskyFeedPost.Record(
141 created_at=time_iso or self.get_current_time_iso(),
142 text=text,
143 reply=reply_to or None,
144 embed=embed or None,
145 langs=langs,
146 facets=facets or None,
147 labels=labels or None
148 )
149 return self.app.bsky.feed.post.create(repo, record)
150
151 def create_gates(self, thread_gate_opts: list[str], quote_gate: bool, post_uri: str, time_iso: str | None = None):
152 account = self.me
153 if not account:
154 raise Exception("Client not logged in!")
155
156 rkey = AtUri.from_str(post_uri).rkey
157 time_iso = time_iso or self.get_current_time_iso()
158
159 if 'everybody' not in thread_gate_opts:
160 allow = []
161 if thread_gate_opts:
162 if 'following' in thread_gate_opts:
163 allow.append(models.AppBskyFeedThreadgate.FollowingRule())
164 if 'followers' in thread_gate_opts:
165 allow.append(models.AppBskyFeedThreadgate.FollowerRule())
166 if 'mentioned' in thread_gate_opts:
167 allow.append(models.AppBskyFeedThreadgate.MentionRule())
168
169 thread_gate = models.AppBskyFeedThreadgate.Record(
170 post=post_uri,
171 created_at=time_iso,
172 allow=allow
173 )
174
175 self.app.bsky.feed.threadgate.create(account.did, thread_gate, rkey)
176
177 if quote_gate:
178 post_gate = models.AppBskyFeedPostgate.Record(
179 post=post_uri,
180 created_at=time_iso,
181 embedding_rules=[
182 models.AppBskyFeedPostgate.DisableRule()
183 ]
184 )
185
186 self.app.bsky.feed.postgate.create(account.did, post_gate, rkey)