social media crossposting tool. 3rd time's the charm
mastodon misskey crossposting bluesky

some progress

zenfyr.dev ac341580 c956a17f

verified
+192 -5
+1
cross/post.py
··· 31 31 id: str 32 32 parent_id: str | None 33 33 tokens: list[Token] 34 + text_type: str = "text/plain" 34 35 attachments: AttachmentKeeper = field(default_factory=AttachmentKeeper)
+57 -5
cross/service.py
··· 1 + import logging 1 2 import sqlite3 2 3 from abc import ABC, abstractmethod 3 4 from typing import Any, Callable, cast 4 - import logging 5 5 6 6 from cross.post import Post 7 7 from database.connection import DatabasePool ··· 44 44 _ = cursor.execute("SELECT * FROM posts WHERE id = ?", (id,)) 45 45 return cast(sqlite3.Row, cursor.fetchone()) 46 46 47 + def _get_mappings( 48 + self, original: int, service: str, user: str 49 + ) -> list[sqlite3.Row]: 50 + cursor = self.db.get_conn().cursor() 51 + _ = cursor.execute( 52 + """ 53 + SELECT * 54 + FROM posts AS p 55 + JOIN mappings AS m 56 + ON p.id = m.mapped 57 + WHERE m.original = ? 58 + AND p.service = ? 59 + AND p.user = ? 60 + ORDER BY p.id; 61 + """, 62 + (original, service, user), 63 + ) 64 + return cursor.fetchall() 65 + 66 + def _find_mapped_thread( 67 + self, parent: str, iservice: str, iuser: str, oservice: str, ouser: str 68 + ): 69 + reply_data = self._get_post(iservice, iuser, parent) 70 + if not reply_data: 71 + return None 72 + 73 + reply_mappings: list[sqlite3.Row] | None = self._get_mappings( 74 + reply_data["id"], oservice, ouser 75 + ) 76 + if not reply_mappings: 77 + return None 78 + 79 + reply_identifier: sqlite3.Row = reply_mappings[-1] 80 + root_identifier: sqlite3.Row = reply_mappings[0] 81 + 82 + if reply_data["root_id"]: 83 + root_data = self._get_post_by_id(reply_data["root_id"]) 84 + if not root_data: 85 + return None 86 + 87 + root_mappings = self._get_mappings(reply_data["root_id"], oservice, ouser) 88 + if not root_mappings: 89 + return None 90 + root_identifier = root_mappings[0] 91 + 92 + return ( 93 + root_identifier[0], # real ids 94 + reply_identifier[0], 95 + reply_data["root_id"], # db ids 96 + reply_data["id"], 97 + ) 98 + 47 99 def _insert_post(self, post_data: dict[str, Any]): 48 100 values = [post_data.get(col) for col in columns] 49 101 cursor = self.db.get_conn().cursor() ··· 80 132 81 133 82 134 class OutputService(Service): 83 - def accept_post(self, post: Post): 135 + def accept_post(self, service: str, user: str, post: Post): 84 136 self.log.warning("NOT IMPLEMENTED (%s), accept_post %s", self.url, post.id) 85 137 86 - def delete_post(self, post_id: str): 138 + def delete_post(self, service: str, user: str, post_id: str): 87 139 self.log.warning("NOT IMPLEMENTED (%s), delete_post %s", self.url, post_id) 88 140 89 - def accept_repost(self, repost_id: str, reposted_id: str): 141 + def accept_repost(self, service: str, user: str, repost_id: str, reposted_id: str): 90 142 self.log.warning( 91 143 "NOT IMPLEMENTED (%s), accept_repost %s of %s", 92 144 self.url, ··· 94 146 reposted_id, 95 147 ) 96 148 97 - def delete_repost(self, repost_id: str): 149 + def delete_repost(self, service: str, user: str, repost_id: str): 98 150 self.log.warning("NOT IMPLEMENTED (%s), delete_repost %s", self.url, repost_id) 99 151 100 152
+134
mastodon/output.py
··· 1 1 from dataclasses import dataclass 2 2 from typing import Any, override 3 3 4 + import requests 5 + 6 + from cross.attachments import ( 7 + LanguagesAttachment, 8 + QuoteAttachment, 9 + RemoteUrlAttachment, 10 + SensitiveAttachment, 11 + ) 12 + from cross.post import Post 4 13 from cross.service import OutputService 5 14 from database.connection import DatabasePool 6 15 from mastodon.info import InstanceInfo, MastodonService, validate_and_transform ··· 38 47 self.log.info("Getting %s configuration...", self.url) 39 48 response = self.fetch_instance_info() 40 49 self.instance_info: InstanceInfo = InstanceInfo.from_api(response) 50 + 51 + def accept_post(self, service: str, user: str, post: Post): 52 + new_root_id: int | None = None 53 + new_parent_id: int | None = None 54 + 55 + reply_ref: str | None = None 56 + if post.parent_id: 57 + thread = self._find_mapped_thread( 58 + post.parent_id, service, user, self.url, self.user_id 59 + ) 60 + 61 + if not thread: 62 + self.log.error("Failed to find thread tuple in the database!") 63 + return 64 + _, reply_ref, new_root_id, new_parent_id = thread 65 + 66 + quote = post.attachments.get(QuoteAttachment) 67 + if quote: 68 + if quote.quoted_user != user: 69 + self.log.info("Quoted other user, skipping!") 70 + return 71 + 72 + quoted_post = self._get_post(service, user, quote.quoted_id) 73 + if not quoted_post: 74 + self.log.error("Failed to find quoted post in the database!") 75 + return 76 + 77 + quoted_mappings = self._get_mappings(quoted_post["id"], self.url, self.user_id) 78 + if not quoted_mappings: 79 + self.log.error("Failed to find mappings for quoted post!") 80 + return 81 + 82 + quoted_local_id = quoted_mappings[-1][0] 83 + # TODO resolve service identifier 84 + 85 + post_tokens = post.tokens.copy() 86 + 87 + remote_url = post.attachments.get(RemoteUrlAttachment) 88 + if remote_url and remote_url.url and post.text_type == "text/x.misskeymarkdown": 89 + # TODO stip mfm 90 + pass 91 + 92 + raw_statuses = [] # TODO split tokens and media across posts 93 + if not raw_statuses: 94 + self.log.error("Failed to split post into statuses!") 95 + return 96 + 97 + langs = post.attachments.get(LanguagesAttachment) 98 + sensitive = post.attachments.get(SensitiveAttachment) 99 + 100 + if langs and langs.langs: 101 + pass # TODO 102 + 103 + if sensitive and sensitive.sensitive: 104 + pass # TODO 105 + 106 + def delete_post(self, service: str, user: str, post_id: str): 107 + post = self._get_post(service, user, post_id) 108 + if not post: 109 + self.log.info("Post not found in db, skipping delete..") 110 + return 111 + 112 + mappings = self._get_mappings(post["id"], self.url, self.user_id) 113 + for mapping in mappings[::-1]: 114 + self.log.info("Deleting '%s'...", mapping["identifier"]) 115 + requests.delete( 116 + f"{self.url}/api/v1/statuses/{mapping['identifier']}", 117 + headers={"Authorization": f"Bearer {self._get_token()}"}, 118 + ) 119 + self._delete_post_by_id(mapping["id"]) 120 + 121 + def accept_repost(self, service: str, user: str, repost_id: str, reposted_id: str): 122 + reposted = self._get_post(service, user, reposted_id) 123 + if not reposted: 124 + self.log.info("Post not found in db, skipping repost..") 125 + return 126 + 127 + mappings = self._get_mappings(reposted["id"], self.url, self.user_id) 128 + if mappings: 129 + rsp = requests.post( 130 + f"{self.url}/api/v1/statuses/{mappings[0]['identifier']}/reblog", 131 + headers={"Authorization": f"Bearer {self._get_token()}"}, 132 + ) 133 + 134 + if rsp.status_code != 200: 135 + self.log.error( 136 + "Failed to boost status! status_code: %s, msg: %s", 137 + rsp.status_code, 138 + rsp.content, 139 + ) 140 + return 141 + 142 + self._insert_post( 143 + { 144 + "user": self.user_id, 145 + "service": self.url, 146 + "identifier": rsp.json()["id"], 147 + "reposted": mappings[0]["id"], 148 + } 149 + ) 150 + inserted = self._get_post(self.url, self.user_id, rsp.json()["id"]) 151 + if not inserted: 152 + raise ValueError("Inserted post not found!") 153 + self._insert_post_mapping(reposted["id"], inserted["id"]) 154 + 155 + def delete_repost(self, service: str, user: str, repost_id: str): 156 + repost = self._get_post(service, user, repost_id) 157 + if not repost: 158 + self.log.info("Repost not found in db, skipping delete..") 159 + return 160 + 161 + mappings = self._get_mappings(repost["id"], self.url, self.user_id) 162 + rmappings = self._get_mappings(repost["reposted"], self.url, self.user_id) 163 + 164 + if mappings and rmappings: 165 + self.log.info( 166 + "Removing '%s' Repost of '%s'...", 167 + mappings[0]["identifier"], 168 + rmappings[0]["identifier"], 169 + ) 170 + requests.post( 171 + f"{self.url}/api/v1/statuses/{rmappings[0]['identifier']}/unreblog", 172 + headers={"Authorization": f"Bearer {self._get_token()}"}, 173 + ) 174 + self._delete_post_by_id(mappings[0]["id"]) 41 175 42 176 @override 43 177 def _get_token(self) -> str: