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