social media crossposting tool. 3rd time's the charm
mastodon
misskey
crossposting
bluesky
1from abc import ABC, abstractmethod
2from dataclasses import dataclass
3from typing import Any
4
5import requests
6
7from cross.service import Service
8from util.util import normalize_service_url
9
10
11def validate_and_transform(data: dict[str, Any]):
12 if "token" not in data or "instance" not in data:
13 raise KeyError("Missing required values 'token' or 'instance'")
14
15 data["instance"] = normalize_service_url(data["instance"])
16
17
18@dataclass(kw_only=True)
19class InstanceInfo:
20 max_characters: int = 500
21 max_media_attachments: int = 4
22 characters_reserved_per_url: int = 23
23
24 image_size_limit: int = 16777216
25 video_size_limit: int = 103809024
26
27 text_format: str = "text/plain"
28
29 @classmethod
30 def from_api(cls, data: dict[str, Any]) -> "InstanceInfo":
31 config: dict[str, Any] = {}
32
33 if "statuses" in data:
34 statuses_config: dict[str, Any] = data.get("statuses", {})
35 if "max_characters" in statuses_config:
36 config["max_characters"] = statuses_config["max_characters"]
37 if "max_media_attachments" in statuses_config:
38 config["max_media_attachments"] = statuses_config[
39 "max_media_attachments"
40 ]
41 if "characters_reserved_per_url" in statuses_config:
42 config["characters_reserved_per_url"] = statuses_config[
43 "characters_reserved_per_url"
44 ]
45
46 # glitch content type
47 if "supported_mime_types" in statuses_config:
48 text_mimes: list[str] = statuses_config["supported_mime_types"]
49
50 if "text/x.misskeymarkdown" in text_mimes:
51 config["text_format"] = "text/x.misskeymarkdown"
52 elif "text/markdown" in text_mimes:
53 config["text_format"] = "text/markdown"
54
55 if "media_attachments" in data:
56 media_config: dict[str, Any] = data["media_attachments"]
57 if "image_size_limit" in media_config:
58 config["image_size_limit"] = media_config["image_size_limit"]
59 if "video_size_limit" in media_config:
60 config["video_size_limit"] = media_config["video_size_limit"]
61
62 # *oma extensions
63 if "max_toot_chars" in data:
64 config["max_characters"] = data["max_toot_chars"]
65 if "upload_limit" in data:
66 config["image_size_limit"] = data["upload_limit"]
67 config["video_size_limit"] = data["upload_limit"]
68
69 if "pleroma" in data:
70 pleroma: dict[str, Any] = data["pleroma"]
71 if "metadata" in pleroma:
72 metadata: dict[str, Any] = pleroma["metadata"]
73 if "post_formats" in metadata:
74 post_formats: list[str] = metadata["post_formats"]
75
76 if "text/x.misskeymarkdown" in post_formats:
77 config["text_format"] = "text/x.misskeymarkdown"
78 elif "text/markdown" in post_formats:
79 config["text_format"] = "text/markdown"
80
81 return InstanceInfo(**config)
82
83
84class MastodonService(ABC, Service):
85 def verify_credentials(self):
86 token = self._get_token()
87 response = requests.get(
88 f"{self.url}/api/v1/accounts/verify_credentials",
89 headers={"Authorization": f"Bearer {token}"},
90 )
91 if response.status_code != 200:
92 self.log.error("Failed to validate user credentials!")
93 response.raise_for_status()
94 return dict(response.json())
95
96 def fetch_instance_info(self):
97 token = self._get_token()
98 responce = requests.get(
99 f"{self.url}/api/v1/instance",
100 headers={"Authorization": f"Bearer {token}"},
101 )
102 if responce.status_code != 200:
103 self.log.error("Failed to get instance info!")
104 responce.raise_for_status()
105 return dict(responce.json())
106
107 @abstractmethod
108 def _get_token(self) -> str:
109 pass