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

initial commit

+6263
+26
.dockerignore
··· 1 + # Python-generated files 2 + __pycache__/ 3 + *.py[oc] 4 + build/ 5 + dist/ 6 + wheels/ 7 + *.egg-info 8 + .pytest_cache/ 9 + .venv/ 10 + .mypy_cache/ 11 + .ruff_cache/ 12 + .coverage 13 + htmlcov/ 14 + 15 + # Random junk 16 + .env 17 + .env.* 18 + .DS_Store 19 + data/ 20 + testdata/ 21 + 22 + # IDE 23 + .idea/ 24 + .vscode/ 25 + *.swp 26 + *.swo
+26
.gitignore
··· 1 + # Python-generated files 2 + __pycache__/ 3 + *.py[oc] 4 + build/ 5 + dist/ 6 + wheels/ 7 + *.egg-info 8 + .pytest_cache/ 9 + .venv/ 10 + .mypy_cache/ 11 + .ruff_cache/ 12 + .coverage 13 + htmlcov/ 14 + 15 + # Random junk 16 + .env 17 + .env.* 18 + .DS_Store 19 + data/ 20 + testdata/ 21 + 22 + # IDE 23 + .idea/ 24 + .vscode/ 25 + *.swp 26 + *.swo
+1
.python-version
··· 1 + 3.12
+18
.tangled/workflows/run-tests.yml
··· 1 + when: 2 + - event: ["push", "manual"] 3 + branch: ["next"] 4 + 5 + engine: nixery 6 + 7 + dependencies: 8 + nixpkgs: 9 + - uv 10 + - ruff 11 + - python312 12 + 13 + steps: 14 + - name: run tests 15 + command: | 16 + uv run --python python3.12 pytest -vv 17 + uv run --python python3.12 ruff check . 18 + uv run --python python3.12 mypy .
+41
Containerfile
··· 1 + FROM python:3.12-alpine 2 + COPY --from=ghcr.io/astral-sh/uv:0.7.12 /uv /uvx /bin/ 3 + 4 + # Install build tools & runtime dependencies 5 + RUN apk add --no-cache \ 6 + ffmpeg \ 7 + file \ 8 + libmagic 9 + 10 + RUN mkdir -p /app/data 11 + WORKDIR /app 12 + 13 + # switch to a non-root user 14 + RUN adduser -D -u 1000 app && \ 15 + chown -R app:app /app 16 + USER app 17 + 18 + # Enable bytecode compilation 19 + ENV UV_COMPILE_BYTECODE=1 20 + 21 + # Copy from the cache instead of linking since it's a mounted volume 22 + ENV UV_LINK_MODE=copy 23 + 24 + # Install the project's dependencies using the lockfile and settings 25 + COPY ./uv.lock ./pyproject.toml /app/ 26 + RUN --mount=type=cache,target=/root/.cache/uv \ 27 + uv sync --locked --no-install-project --no-dev 28 + 29 + # Define app data volume 30 + VOLUME /app/data 31 + 32 + # Then, add the rest of the project source code and install it 33 + COPY . /app 34 + RUN --mount=type=cache,target=/root/.cache/uv \ 35 + uv sync --locked --no-dev 36 + 37 + # Place executables in the environment at the front of the path 38 + ENV PATH="/app/.venv/bin:$PATH" 39 + 40 + # Set entrypoint to run the app using uv 41 + ENTRYPOINT ["uv", "run", "main.py"]
+8
README.md
··· 1 + # XPost (next) 2 + 3 + > [!NOTE] 4 + > For xpost v1, see the master branch 5 + 6 + Xpost is a social media cross-posting tool that differs from others by using streaming APIs to allow instant, zero-input cross-posting. This means you can continue posting on your preferred platform without using special apps. 7 + 8 + See [docs](./docs/README.md) for more info and configuration options!
atproto/__init__.py

This is a binary file and will not be displayed.

+294
atproto/models.py
··· 1 + from abc import ABC, abstractmethod 2 + from dataclasses import dataclass, field 3 + from typing import Any 4 + 5 + 6 + URI = "at://" 7 + URI_LEN = len(URI) 8 + 9 + 10 + class AtUri: 11 + @classmethod 12 + def record_uri(cls, uri: str) -> tuple[str, str, str]: 13 + if not uri.startswith(URI): 14 + raise ValueError(f"Ivalid record uri {uri}!") 15 + 16 + did, collection, rid = uri[URI_LEN:].split("/") 17 + if not (did and collection and rid): 18 + raise ValueError(f"Ivalid record uri {uri}!") 19 + 20 + return did, collection, rid 21 + 22 + 23 + @dataclass(kw_only=True) 24 + class StrongRef: 25 + uri: str 26 + cid: str 27 + 28 + def to_dict(self) -> dict[str, Any]: 29 + return {"uri": self.uri, "cid": self.cid} 30 + 31 + @classmethod 32 + def from_dict(cls, data: dict[str, Any]) -> "StrongRef": 33 + return cls(uri=data["uri"], cid=data["cid"]) 34 + 35 + 36 + @dataclass(kw_only=True) 37 + class ReplyRef: 38 + root: StrongRef 39 + parent: StrongRef 40 + 41 + def to_dict(self) -> dict[str, Any]: 42 + return { 43 + "root": self.root.to_dict(), 44 + "parent": self.parent.to_dict(), 45 + } 46 + 47 + 48 + @dataclass(kw_only=True) 49 + class FacetIndex: 50 + byte_start: int 51 + byte_end: int 52 + 53 + def to_dict(self) -> dict[str, int]: 54 + return {"byteStart": self.byte_start, "byteEnd": self.byte_end} 55 + 56 + 57 + @dataclass(kw_only=True) 58 + class FacetFeature(ABC): 59 + @abstractmethod 60 + def to_dict(self) -> dict[str, Any]: 61 + pass 62 + 63 + 64 + @dataclass(kw_only=True) 65 + class MentionFeature(FacetFeature): 66 + did: str 67 + 68 + def to_dict(self) -> dict[str, Any]: 69 + return { 70 + "$type": "app.bsky.richtext.facet#mention", 71 + "did": self.did, 72 + } 73 + 74 + 75 + @dataclass(kw_only=True) 76 + class LinkFeature(FacetFeature): 77 + uri: str 78 + 79 + def to_dict(self) -> dict[str, Any]: 80 + return { 81 + "$type": "app.bsky.richtext.facet#link", 82 + "uri": self.uri, 83 + } 84 + 85 + 86 + @dataclass(kw_only=True) 87 + class TagFeature(FacetFeature): 88 + tag: str 89 + 90 + def to_dict(self) -> dict[str, Any]: 91 + return { 92 + "$type": "app.bsky.richtext.facet#tag", 93 + "tag": self.tag, 94 + } 95 + 96 + 97 + @dataclass(kw_only=True) 98 + class Facet: 99 + index: FacetIndex 100 + features: list[FacetFeature] = field(default_factory=list) 101 + 102 + def to_dict(self) -> dict[str, Any]: 103 + return { 104 + "index": self.index.to_dict(), 105 + "features": [f.to_dict() for f in self.features], 106 + } 107 + 108 + 109 + @dataclass(kw_only=True) 110 + class ImageEmbed: 111 + image: bytes 112 + alt: str | None = None 113 + aspect_ratio: tuple[int, int] | None = None 114 + 115 + def to_dict(self, blob_ref: dict[str, Any]) -> dict[str, Any]: 116 + data: dict[str, Any] = { 117 + "image": blob_ref, 118 + "alt": self.alt or "", 119 + } 120 + if self.aspect_ratio: 121 + data["aspectRatio"] = { 122 + "width": self.aspect_ratio[0], 123 + "height": self.aspect_ratio[1], 124 + } 125 + return data 126 + 127 + 128 + @dataclass(kw_only=True) 129 + class VideoEmbed: 130 + video: bytes 131 + alt: str | None = None 132 + aspect_ratio: tuple[int, int] | None = None 133 + 134 + def to_dict(self, blob_ref: dict[str, Any]) -> dict[str, Any]: 135 + data: dict[str, Any] = { 136 + "$type": "app.bsky.embed.video", 137 + "video": blob_ref, 138 + } 139 + if self.alt: 140 + data["alt"] = self.alt 141 + if self.aspect_ratio: 142 + data["aspectRatio"] = { 143 + "width": self.aspect_ratio[0], 144 + "height": self.aspect_ratio[1], 145 + } 146 + return data 147 + 148 + 149 + @dataclass(kw_only=True) 150 + class RecordEmbed: 151 + record: StrongRef 152 + 153 + def to_dict(self) -> dict[str, Any]: 154 + return { 155 + "$type": "app.bsky.embed.record", 156 + "record": self.record.to_dict(), 157 + } 158 + 159 + 160 + @dataclass(kw_only=True) 161 + class RecordWithMediaEmbed: 162 + record: StrongRef 163 + media: ImageEmbed | VideoEmbed 164 + media_blob_ref: dict[str, Any] 165 + 166 + def to_dict(self) -> dict[str, Any]: 167 + media_data = self.media.to_dict(self.media_blob_ref) 168 + media_type = ( 169 + "app.bsky.embed.images" 170 + if isinstance(self.media, ImageEmbed) 171 + else "app.bsky.embed.video" 172 + ) 173 + return { 174 + "$type": "app.bsky.embed.recordWithMedia", 175 + "record": self.record.to_dict(), 176 + "media": { 177 + "$type": media_type, 178 + **media_data, 179 + }, 180 + } 181 + 182 + 183 + @dataclass(kw_only=True) 184 + class SelfLabel: 185 + val: str 186 + 187 + def to_dict(self) -> dict[str, str]: 188 + return {"val": self.val} 189 + 190 + 191 + @dataclass(kw_only=True) 192 + class SelfLabels: 193 + values: list[SelfLabel] = field(default_factory=list) 194 + 195 + def to_dict(self) -> dict[str, Any]: 196 + return { 197 + "$type": "com.atproto.label.defs#selfLabels", 198 + "values": [v.to_dict() for v in self.values], 199 + } 200 + 201 + 202 + @dataclass(kw_only=True) 203 + class PostRecord: 204 + text: str 205 + created_at: str 206 + facets: list[Facet] | None = None 207 + embed: dict[str, Any] | None = None 208 + reply: ReplyRef | None = None 209 + langs: list[str] | None = None 210 + labels: SelfLabels | None = None 211 + 212 + def to_dict(self) -> dict[str, Any]: 213 + data: dict[str, Any] = { 214 + "$type": "app.bsky.feed.post", 215 + "text": self.text, 216 + "createdAt": self.created_at, 217 + } 218 + if self.facets: 219 + data["facets"] = [f.to_dict() for f in self.facets] 220 + if self.embed: 221 + data["embed"] = self.embed 222 + if self.reply: 223 + data["reply"] = self.reply.to_dict() 224 + if self.langs: 225 + data["langs"] = self.langs 226 + if self.labels: 227 + data["labels"] = self.labels.to_dict() 228 + return data 229 + 230 + 231 + @dataclass(kw_only=True) 232 + class CreateRecordResponse: 233 + uri: str 234 + cid: str 235 + commit: dict[str, Any] | None = None 236 + 237 + @classmethod 238 + def from_dict(cls, data: dict[str, Any]) -> "CreateRecordResponse": 239 + return cls( 240 + uri=data.get("uri", ""), 241 + cid=data.get("cid", ""), 242 + commit=data.get("commit"), 243 + ) 244 + 245 + 246 + @dataclass(kw_only=True) 247 + class RepostRecord: 248 + subject: StrongRef 249 + created_at: str 250 + 251 + def to_dict(self) -> dict[str, Any]: 252 + data: dict[str, Any] = { 253 + "$type": "app.bsky.feed.repost", 254 + "createdAt": self.created_at, 255 + "subject": self.subject.to_dict(), 256 + } 257 + return data 258 + 259 + 260 + @dataclass(kw_only=True) 261 + class ThreadGate: 262 + post: str = "" 263 + created_at: str 264 + allow: list[dict[str, Any]] = field(default_factory=list) 265 + 266 + def to_dict(self) -> dict[str, Any]: 267 + data: dict[str, Any] = { 268 + "$type": "app.bsky.feed.threadgate", 269 + "post": self.post, 270 + "createdAt": self.created_at, 271 + } 272 + if self.allow is not None: 273 + data["allow"] = self.allow 274 + return data 275 + 276 + 277 + @dataclass(kw_only=True) 278 + class PostGate: 279 + post: str 280 + created_at: str 281 + detached_embedding_uris: list[str] | None = None 282 + embedding_rules: list[dict[str, Any]] | None = None 283 + 284 + def to_dict(self) -> dict[str, Any]: 285 + data: dict[str, Any] = { 286 + "$type": "app.bsky.feed.postgate", 287 + "post": self.post, 288 + "createdAt": self.created_at, 289 + } 290 + if self.detached_embedding_uris is not None: 291 + data["detachedEmbeddingUris"] = self.detached_embedding_uris 292 + if self.embedding_rules is not None: 293 + data["embeddingRules"] = self.embedding_rules 294 + return data
+235
atproto/store.py
··· 1 + import base64 2 + import json 3 + import sqlite3 4 + import time 5 + from dataclasses import dataclass 6 + from functools import cached_property 7 + from typing import Any 8 + 9 + from database.connection import DatabasePool 10 + 11 + 12 + def _decode_jwt_payload(token: str) -> dict[str, Any]: 13 + try: 14 + _, claims, _ = token.split(".") 15 + claims = claims + "=" * (4 - len(claims) % 4) if len(claims) % 4 else claims 16 + return json.loads(base64.urlsafe_b64decode(claims)) # type: ignore[no-any-return] 17 + except Exception: 18 + return {} 19 + 20 + 21 + @dataclass 22 + class Session: 23 + access_jwt: str 24 + refresh_jwt: str 25 + handle: str 26 + did: str 27 + pds: str 28 + email: str | None = None 29 + email_confirmed: bool = False 30 + email_auth_factor: bool = False 31 + active: bool = True 32 + status: str | None = None 33 + 34 + @cached_property 35 + def access_payload(self) -> dict[str, Any]: 36 + return _decode_jwt_payload(self.access_jwt) 37 + 38 + @cached_property 39 + def refresh_payload(self) -> dict[str, Any]: 40 + return _decode_jwt_payload(self.refresh_jwt) 41 + 42 + def is_access_token_expired(self, buffer_seconds: int = 60) -> bool: 43 + exp = self.access_payload.get("exp", 0) 44 + return bool(time.time() >= (exp - buffer_seconds)) 45 + 46 + def is_refresh_token_expired(self, buffer_seconds: int = 60) -> bool: 47 + exp = self.refresh_payload.get("exp", 0) 48 + return bool(time.time() >= (exp - buffer_seconds)) 49 + 50 + @classmethod 51 + def from_row(cls, row: sqlite3.Row) -> "Session": 52 + return cls( 53 + access_jwt=row["access_jwt"], 54 + refresh_jwt=row["refresh_jwt"], 55 + handle=row["handle"], 56 + did=row["did"], 57 + pds=row["pds"], 58 + email=row["email"], 59 + email_confirmed=bool(row["email_confirmed"]), 60 + email_auth_factor=bool(row["email_auth_factor"]), 61 + active=bool(row["active"]), 62 + status=row["status"], 63 + ) 64 + 65 + @classmethod 66 + def from_dict(cls, data: dict[str, Any], pds: str) -> "Session": 67 + return cls( 68 + access_jwt=data["accessJwt"], 69 + refresh_jwt=data["refreshJwt"], 70 + handle=data["handle"], 71 + did=data["did"], 72 + pds=pds, 73 + email=data.get("email"), 74 + email_confirmed=data.get("emailConfirmed", False), 75 + email_auth_factor=data.get("emailAuthFactor", False), 76 + active=data.get("active", True), 77 + status=data.get("status"), 78 + ) 79 + 80 + 81 + @dataclass 82 + class IdentityInfo: 83 + did: str 84 + handle: str 85 + pds: str 86 + signing_key: str 87 + 88 + @classmethod 89 + def from_row(cls, row: sqlite3.Row) -> "IdentityInfo": 90 + return cls( 91 + did=row["did"], 92 + handle=row["handle"], 93 + pds=row["pds"], 94 + signing_key=row["signing_key"], 95 + ) 96 + 97 + @classmethod 98 + def from_dict(cls, data: dict[str, Any]) -> "IdentityInfo": 99 + return cls( 100 + did=data["did"], 101 + handle=data["handle"], 102 + pds=data["pds"], 103 + signing_key=data["signing_key"], 104 + ) 105 + 106 + 107 + class AtprotoStore: 108 + def __init__( 109 + self, 110 + db: sqlite3.Connection, 111 + identity_ttl: int = 12 * 60 * 60, 112 + ) -> None: 113 + self.db = db 114 + self.db.row_factory = sqlite3.Row 115 + self.identity_ttl = identity_ttl 116 + 117 + def get_session(self, did: str) -> Session | None: 118 + row = self.db.execute( 119 + "SELECT * FROM atproto_sessions WHERE did = ?", (did,) 120 + ).fetchone() 121 + return Session.from_row(row) if row else None 122 + 123 + def set_session(self, session: Session) -> None: 124 + now = time.time() 125 + self.db.execute( 126 + """ 127 + INSERT OR REPLACE INTO atproto_sessions 128 + (did, pds, handle, access_jwt, refresh_jwt, email, email_confirmed, 129 + email_auth_factor, active, status, created_at) 130 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 131 + """, 132 + ( 133 + session.did, 134 + session.pds, 135 + session.handle, 136 + session.access_jwt, 137 + session.refresh_jwt, 138 + session.email, 139 + session.email_confirmed, 140 + session.email_auth_factor, 141 + session.active, 142 + session.status, 143 + now, 144 + ), 145 + ) 146 + self.db.commit() 147 + 148 + def get_session_by_pds(self, pds: str, identifier: str) -> Session | None: 149 + row = self.db.execute( 150 + """ 151 + SELECT * FROM atproto_sessions 152 + WHERE pds = ? AND (did = ? OR handle = ?) 153 + """, 154 + (pds, identifier, identifier), 155 + ).fetchone() 156 + return Session.from_row(row) if row else None 157 + 158 + def list_sessions_by_pds(self, pds: str) -> list[Session]: 159 + rows = self.db.execute( 160 + "SELECT * FROM atproto_sessions WHERE pds = ?", (pds,) 161 + ).fetchall() 162 + return [Session.from_row(row) for row in rows] 163 + 164 + def remove_session(self, did: str) -> None: 165 + self.db.execute("DELETE FROM atproto_sessions WHERE did = ?", (did,)) 166 + self.db.commit() 167 + 168 + def get_identity(self, identifier: str) -> IdentityInfo | None: 169 + row = self.db.execute( 170 + "SELECT * FROM atproto_identities WHERE identifier = ? AND created_at + ? > ?", 171 + (identifier, self.identity_ttl, time.time()), 172 + ).fetchone() 173 + return IdentityInfo.from_row(row) if row else None 174 + 175 + def set_identity(self, identifier: str, identity: IdentityInfo) -> None: 176 + now = time.time() 177 + for key in (identifier, identity.did, identity.handle): 178 + self.db.execute( 179 + """ 180 + INSERT OR REPLACE INTO atproto_identities 181 + (identifier, did, handle, pds, signing_key, created_at) 182 + VALUES (?, ?, ?, ?, ?, ?) 183 + """, 184 + ( 185 + key, 186 + identity.did, 187 + identity.handle, 188 + identity.pds, 189 + identity.signing_key, 190 + now, 191 + ), 192 + ) 193 + self.db.commit() 194 + 195 + def remove_identity(self, identifier: str) -> None: 196 + self.db.execute( 197 + "DELETE FROM atproto_identities WHERE identifier = ?", (identifier,) 198 + ) 199 + self.db.commit() 200 + 201 + def cleanup_expired(self) -> None: 202 + cutoff = time.time() - self.identity_ttl 203 + self.db.execute( 204 + "DELETE FROM atproto_identities WHERE created_at + ? < ?", 205 + (self.identity_ttl, cutoff), 206 + ) 207 + self.db.commit() 208 + 209 + def flush_all(self) -> tuple[int, int]: 210 + sessions = self.db.execute("SELECT COUNT(*) FROM atproto_sessions").fetchone()[ 211 + 0 212 + ] 213 + identities = self.db.execute( 214 + "SELECT COUNT(*) FROM atproto_identities" 215 + ).fetchone()[0] 216 + self.db.execute("DELETE FROM atproto_sessions") 217 + self.db.execute("DELETE FROM atproto_identities") 218 + self.db.commit() 219 + return sessions, identities 220 + 221 + 222 + _store: AtprotoStore | None = None 223 + 224 + 225 + def get_store(db: DatabasePool) -> AtprotoStore: 226 + global _store 227 + if _store is None: 228 + _store = AtprotoStore(db.get_conn()) 229 + return _store 230 + 231 + 232 + def flush_caches() -> tuple[int, int]: 233 + if _store is not None: 234 + return _store.flush_all() 235 + return 0, 0
+254
atproto/xrpc.py
··· 1 + from dataclasses import dataclass 2 + from typing import Any, TypeVar 3 + 4 + import httpx 5 + 6 + from atproto.store import AtprotoStore, IdentityInfo, Session 7 + from util.util import LOGGER, normalize_service_url 8 + 9 + 10 + class XRPCError(Exception): 11 + def __init__( 12 + self, 13 + message: str, 14 + status_code: int | None = None, 15 + response_data: dict[str, Any] | None = None, 16 + ) -> None: 17 + super().__init__(message) 18 + self.status_code = status_code 19 + self.response_data = response_data 20 + 21 + 22 + T = TypeVar("T") 23 + 24 + 25 + @dataclass 26 + class XRPCResponse: 27 + data: dict[str, Any] 28 + status_code: int 29 + headers: dict[str, str] 30 + 31 + 32 + class XRPCClient: 33 + def __init__( 34 + self, 35 + pds_url: str, 36 + store: AtprotoStore, 37 + http: httpx.Client | None = None, 38 + identifier: str | None = None, 39 + password: str | None = None, 40 + ) -> None: 41 + self.pds_url: str = normalize_service_url(pds_url) 42 + self.store: AtprotoStore = store 43 + self.http: httpx.Client = http if http else httpx.Client() 44 + 45 + if identifier and password: 46 + self.login(identifier, password) 47 + 48 + def login(self, identifier: str, password: str) -> Session: 49 + cached = self.store.get_session_by_pds(self.pds_url, identifier) 50 + if cached and not cached.is_refresh_token_expired(): 51 + return cached 52 + return self.create_session(identifier, password) 53 + 54 + def get_session(self, did: str) -> Session | None: 55 + session = self.store.get_session(did) 56 + if not session: 57 + return None 58 + if session.is_access_token_expired(): 59 + if not session.is_refresh_token_expired(): 60 + LOGGER.info("refreshing session for %s", session.did) 61 + return self.refresh_session(session) 62 + LOGGER.info("both tokens expired for %s, removing session", session.did) 63 + self.store.remove_session(did) 64 + raise ValueError( 65 + "Both access and refresh tokens expired. Please login again." 66 + ) 67 + return session 68 + 69 + def create_session( 70 + self, 71 + identifier: str, 72 + password: str, 73 + auth_factor_token: str | None = None, 74 + ) -> Session: 75 + url = f"{self.pds_url}/xrpc/com.atproto.server.createSession" 76 + payload: dict[str, str] = {"identifier": identifier, "password": password} 77 + if auth_factor_token: 78 + payload["authFactorToken"] = auth_factor_token 79 + 80 + response = self.http.post(url, json=payload, timeout=30) 81 + 82 + match response.status_code: 83 + case 200: 84 + pass 85 + case 401: 86 + raise ValueError("Invalid identifier or password") 87 + case 400: 88 + raise ValueError(f"Authentication failed: {response.json()}") 89 + case _: 90 + raise ValueError( 91 + f"Authentication failed with status {response.status_code}" 92 + ) 93 + 94 + session = Session.from_dict(response.json(), self.pds_url) 95 + self.store.set_session(session) 96 + LOGGER.info("Created session for %s (%s)", session.handle, session.did) 97 + return session 98 + 99 + def refresh_session(self, session: Session) -> Session: 100 + url = f"{self.pds_url}/xrpc/com.atproto.server.refreshSession" 101 + headers = {"Authorization": f"Bearer {session.refresh_jwt}"} 102 + 103 + response = self.http.post(url, headers=headers, timeout=30) 104 + 105 + match response.status_code: 106 + case 200: 107 + pass 108 + case 401: 109 + error_data = response.json() if response.content else {} 110 + raise ValueError(f"Refresh failed: {error_data}") 111 + case 400: 112 + raise ValueError(f"Refresh failed: {response.json()}") 113 + case _: 114 + raise ValueError(f"Refresh failed with status {response.status_code}") 115 + 116 + new_session = Session.from_dict(response.json(), self.pds_url) 117 + self.store.set_session(new_session) 118 + LOGGER.info( 119 + "Refreshed session for %s (%s)", new_session.handle, new_session.did 120 + ) 121 + return new_session 122 + 123 + def get_access_token(self, did: str) -> str | None: 124 + session = self.get_session(did) 125 + return session.access_jwt if session else None 126 + 127 + def call( 128 + self, 129 + method: str, 130 + params: dict[str, Any] | None = None, 131 + data: dict[str, Any] | None = None, 132 + did: str | None = None, 133 + ) -> XRPCResponse: 134 + url = f"{self.pds_url}/xrpc/{method}" 135 + headers = ( 136 + { 137 + "Authorization": f"Bearer {self.get_access_token(did)}", 138 + "Content-Type": "application/json", 139 + } 140 + if did 141 + else {} 142 + ) 143 + 144 + if params and data: 145 + raise ValueError("Cannot specify both params and data") 146 + 147 + try: 148 + if params: 149 + response = self.http.get( 150 + url, params=params, headers=headers, timeout=30 151 + ) 152 + elif data: 153 + response = self.http.post(url, json=data, headers=headers, timeout=30) 154 + else: 155 + response = self.http.get(url, headers=headers, timeout=30) 156 + except httpx.RequestError as e: 157 + raise XRPCError(f"Request failed: {e}") from e 158 + 159 + try: 160 + response_data = response.json() if response.content else {} 161 + except ValueError as e: 162 + raise XRPCError( 163 + f"Invalid JSON response: {e}", status_code=response.status_code 164 + ) from e 165 + 166 + if response.status_code >= 400: 167 + error_msg = response_data.get( 168 + "message", f"Request failed with status {response.status_code}" 169 + ) 170 + raise XRPCError( 171 + error_msg, status_code=response.status_code, response_data=response_data 172 + ) 173 + 174 + return XRPCResponse( 175 + data=response_data, 176 + status_code=response.status_code, 177 + headers=dict(response.headers), 178 + ) 179 + 180 + def upload_blob( 181 + self, 182 + blob: bytes, 183 + content_type: str, 184 + did: str, 185 + ) -> dict[str, Any]: 186 + token = self.get_access_token(did) 187 + if not token: 188 + raise ValueError(f"No valid session found for {did}") 189 + 190 + url = f"{self.pds_url}/xrpc/com.atproto.repo.uploadBlob" 191 + headers = { 192 + "Authorization": f"Bearer {token}", 193 + "Content-Type": content_type, 194 + } 195 + 196 + try: 197 + response = self.http.post(url, content=blob, headers=headers, timeout=60) 198 + except httpx.RequestError as e: 199 + raise XRPCError(f"Blob upload request failed: {e}") from e 200 + 201 + if response.status_code != 200: 202 + error_data = response.json() if response.content else {} 203 + raise XRPCError( 204 + f"Blob upload failed: {response.status_code}", 205 + status_code=response.status_code, 206 + response_data=error_data, 207 + ) 208 + 209 + try: 210 + result: dict[str, Any] = response.json() 211 + except ValueError as e: 212 + raise XRPCError(f"Invalid JSON response from blob upload: {e}") from e 213 + 214 + return result 215 + 216 + 217 + def resolve_identity( 218 + identifier: str, store: AtprotoStore, http: httpx.Client | None = None 219 + ) -> IdentityInfo: 220 + import env 221 + 222 + cached = store.get_identity(identifier) 223 + if cached: 224 + return cached 225 + 226 + url = f"{env.SLINGSHOT_URL}/xrpc/com.bad-example.identity.resolveMiniDoc" 227 + client = http if http else httpx.Client() 228 + 229 + try: 230 + response = client.get(url, params={"identifier": identifier}, timeout=10) 231 + response.raise_for_status() 232 + 233 + try: 234 + data = response.json() 235 + except ValueError as e: 236 + raise ValueError( 237 + f"Invalid JSON response from identity resolver: {e}" 238 + ) from e 239 + 240 + match response.status_code: 241 + case 200: 242 + identity = IdentityInfo.from_dict(data) 243 + store.set_identity(identifier, identity) 244 + return identity 245 + case 404: 246 + raise ValueError(f"Identity not found: {identifier}") 247 + case _: 248 + error_msg = data.get( 249 + "message", 250 + f"Identity resolver returned status {response.status_code}", 251 + ) 252 + raise ValueError(error_msg) 253 + except httpx.RequestError as e: 254 + raise ValueError(f"Failed to resolve identity {identifier}: {e}") from e
bluesky/__init__.py

This is a binary file and will not be displayed.

+359
bluesky/client.py
··· 1 + import logging 2 + from datetime import UTC, datetime 3 + from typing import Any, cast 4 + 5 + import httpx 6 + 7 + from atproto.models import ( 8 + AtUri, 9 + CreateRecordResponse, 10 + Facet, 11 + ImageEmbed, 12 + PostGate, 13 + PostRecord, 14 + RecordEmbed, 15 + RecordWithMediaEmbed, 16 + ReplyRef, 17 + RepostRecord, 18 + SelfLabels, 19 + StrongRef, 20 + ThreadGate, 21 + VideoEmbed, 22 + ) 23 + from atproto.store import AtprotoStore 24 + from atproto.xrpc import XRPCClient, XRPCError, resolve_identity 25 + from util.util import normalize_service_url 26 + 27 + 28 + logger = logging.getLogger(__name__) 29 + 30 + 31 + class BlueskyClient: 32 + def __init__( 33 + self, 34 + pds_url: str, 35 + store: AtprotoStore, 36 + identifier: str, 37 + http: httpx.Client | None = None, 38 + password: str | None = None, 39 + ) -> None: 40 + self.pds_url: str = normalize_service_url(pds_url) 41 + self.store: AtprotoStore = store 42 + 43 + identity = resolve_identity(identifier, store, http) 44 + self.did: str = identity.did 45 + self.xrpc: XRPCClient = XRPCClient(pds_url, store, http, self.did, password) 46 + 47 + def _get_timestamp(self, time_iso: str | None = None) -> str: 48 + if time_iso: 49 + return time_iso 50 + return datetime.now(UTC).isoformat().replace("+00:00", "Z") 51 + 52 + def _upload_blob(self, data: bytes, content_type: str) -> dict[str, Any]: 53 + return self.xrpc.upload_blob(data, content_type, self.did) 54 + 55 + def send_post( 56 + self, 57 + text: str, 58 + facets: list[Facet] | None = None, 59 + embed: dict[str, Any] | None = None, 60 + reply_to: ReplyRef | None = None, 61 + labels: SelfLabels | None = None, 62 + langs: list[str] | None = None, 63 + time_iso: str | None = None, 64 + ) -> CreateRecordResponse: 65 + record = PostRecord( 66 + text=text, 67 + facets=facets, 68 + embed=embed, 69 + reply=reply_to, 70 + labels=labels, 71 + langs=langs, 72 + created_at=self._get_timestamp(time_iso), 73 + ) 74 + 75 + response = self.xrpc.call( 76 + "com.atproto.repo.createRecord", 77 + data={ 78 + "repo": self.did, 79 + "collection": "app.bsky.feed.post", 80 + "record": record.to_dict(), 81 + }, 82 + did=self.did, 83 + ) 84 + 85 + return CreateRecordResponse.from_dict(response.data) 86 + 87 + def send_images( 88 + self, 89 + text: str, 90 + images: list[ImageEmbed], 91 + facets: list[Facet] | None = None, 92 + embed: dict[str, Any] | None = None, 93 + reply_to: ReplyRef | None = None, 94 + labels: SelfLabels | None = None, 95 + langs: list[str] | None = None, 96 + time_iso: str | None = None, 97 + ) -> CreateRecordResponse: 98 + image_refs: list[dict[str, Any]] = [] 99 + for img in images[:4]: 100 + blob_ref = self._upload_blob(img.image, "image/jpeg") 101 + image_data = img.to_dict(blob_ref["blob"]) 102 + image_refs.append(image_data) 103 + 104 + image_embed: dict[str, Any] = { 105 + "$type": "app.bsky.embed.images", 106 + "images": image_refs, 107 + } 108 + 109 + if embed: 110 + combined_embed: dict[str, Any] = { 111 + "$type": "app.bsky.embed.recordWithMedia", 112 + "record": embed, 113 + "media": image_embed, 114 + } 115 + return self.send_post( 116 + text=text, 117 + facets=facets, 118 + embed=combined_embed, 119 + reply_to=reply_to, 120 + labels=labels, 121 + langs=langs, 122 + time_iso=time_iso, 123 + ) 124 + 125 + return self.send_post( 126 + text=text, 127 + facets=facets, 128 + embed=image_embed, 129 + reply_to=reply_to, 130 + labels=labels, 131 + langs=langs, 132 + time_iso=time_iso, 133 + ) 134 + 135 + def send_video( 136 + self, 137 + text: str, 138 + video: bytes, 139 + alt: str | None = None, 140 + aspect_ratio: tuple[int, int] | None = None, 141 + facets: list[Facet] | None = None, 142 + embed: dict[str, Any] | None = None, 143 + reply_to: ReplyRef | None = None, 144 + labels: SelfLabels | None = None, 145 + langs: list[str] | None = None, 146 + time_iso: str | None = None, 147 + ) -> CreateRecordResponse: 148 + blob_ref = self._upload_blob(video, "video/mp4") 149 + 150 + video_embed = VideoEmbed( 151 + video=video, 152 + alt=alt, 153 + aspect_ratio=aspect_ratio, 154 + ) 155 + video_embed_dict = video_embed.to_dict(blob_ref["blob"]) 156 + 157 + if embed: 158 + combined_embed: dict[str, Any] = { 159 + "$type": "app.bsky.embed.recordWithMedia", 160 + "record": embed, 161 + "media": video_embed_dict, 162 + } 163 + return self.send_post( 164 + text=text, 165 + facets=facets, 166 + embed=combined_embed, 167 + reply_to=reply_to, 168 + labels=labels, 169 + langs=langs, 170 + time_iso=time_iso, 171 + ) 172 + 173 + return self.send_post( 174 + text=text, 175 + facets=facets, 176 + embed=video_embed_dict, 177 + reply_to=reply_to, 178 + labels=labels, 179 + langs=langs, 180 + time_iso=time_iso, 181 + ) 182 + 183 + def send_quote( 184 + self, 185 + text: str, 186 + quoted_uri: str, 187 + quoted_cid: str, 188 + facets: list[Facet] | None = None, 189 + embed_media: ImageEmbed | VideoEmbed | None = None, 190 + embed_blob_ref: dict[str, Any] | None = None, 191 + reply_to: ReplyRef | None = None, 192 + labels: SelfLabels | None = None, 193 + langs: list[str] | None = None, 194 + time_iso: str | None = None, 195 + ) -> CreateRecordResponse: 196 + quoted_ref = StrongRef(uri=quoted_uri, cid=quoted_cid) 197 + 198 + if embed_media and embed_blob_ref: 199 + embed: dict[str, Any] = RecordWithMediaEmbed( 200 + record=quoted_ref, 201 + media=embed_media, 202 + media_blob_ref=embed_blob_ref, 203 + ).to_dict() 204 + else: 205 + embed = RecordEmbed(record=quoted_ref).to_dict() 206 + 207 + return self.send_post( 208 + text=text, 209 + facets=facets, 210 + embed=embed, 211 + reply_to=reply_to, 212 + labels=labels, 213 + langs=langs, 214 + time_iso=time_iso, 215 + ) 216 + 217 + def repost( 218 + self, subject_uri: str, subject_cid: str, time_iso: str | None = None 219 + ) -> CreateRecordResponse: 220 + subject = StrongRef(uri=subject_uri, cid=subject_cid) 221 + record = RepostRecord( 222 + subject=subject, 223 + created_at=self._get_timestamp(time_iso), 224 + ) 225 + 226 + record_dict = record.to_dict() 227 + 228 + response = self.xrpc.call( 229 + "com.atproto.repo.createRecord", 230 + data={ 231 + "repo": self.did, 232 + "collection": "app.bsky.feed.repost", 233 + "record": record_dict, 234 + }, 235 + did=self.did, 236 + ) 237 + 238 + return CreateRecordResponse.from_dict(response.data) 239 + 240 + def delete_post(self, post_uri: str) -> None: 241 + _, _, rkey = AtUri.record_uri(post_uri) 242 + 243 + self.xrpc.call( 244 + "com.atproto.repo.deleteRecord", 245 + data={ 246 + "repo": self.did, 247 + "collection": "app.bsky.feed.post", 248 + "rkey": rkey, 249 + }, 250 + did=self.did, 251 + ) 252 + 253 + def delete_repost(self, repost_uri: str) -> None: 254 + _, _, rkey = AtUri.record_uri(repost_uri) 255 + 256 + self.xrpc.call( 257 + "com.atproto.repo.deleteRecord", 258 + data={ 259 + "repo": self.did, 260 + "collection": "app.bsky.feed.repost", 261 + "rkey": rkey, 262 + }, 263 + did=self.did, 264 + ) 265 + 266 + def create_threadgate( 267 + self, post_uri: str, allow_gates: list[str] | None 268 + ) -> CreateRecordResponse: 269 + allow: list[dict[str, Any]] = [] 270 + if allow_gates: 271 + for gate in allow_gates: 272 + match gate: 273 + case "mentioned": 274 + allow.append({"$type": "app.bsky.feed.threadgate#mentionRule"}) 275 + case "following": 276 + allow.append( 277 + {"$type": "app.bsky.feed.threadgate#followingRule"} 278 + ) 279 + case "followers": 280 + allow.append({"$type": "app.bsky.feed.threadgate#followerRule"}) 281 + 282 + threadgate = ThreadGate( 283 + allow=allow, post=post_uri, created_at=self._get_timestamp() 284 + ) 285 + 286 + _, _, rkey = AtUri.record_uri(post_uri) 287 + 288 + response = self.xrpc.call( 289 + "com.atproto.repo.createRecord", 290 + data={ 291 + "repo": self.did, 292 + "collection": "app.bsky.feed.threadgate", 293 + "record": threadgate.to_dict(), 294 + "rkey": rkey, 295 + }, 296 + did=self.did, 297 + ) 298 + 299 + return CreateRecordResponse.from_dict(response.data) 300 + 301 + def create_postgate( 302 + self, post_uri: str, quote_gate: bool = True 303 + ) -> CreateRecordResponse: 304 + postgate = PostGate( 305 + post=post_uri, 306 + created_at=self._get_timestamp(), 307 + embedding_rules=[{"$type": "app.bsky.feed.postgate#disableRule"}] 308 + if quote_gate 309 + else None, 310 + ) 311 + 312 + _, _, rkey = AtUri.record_uri(post_uri) 313 + 314 + response = self.xrpc.call( 315 + "com.atproto.repo.createRecord", 316 + data={ 317 + "repo": self.did, 318 + "collection": "app.bsky.feed.postgate", 319 + "record": postgate.to_dict(), 320 + "rkey": rkey, 321 + }, 322 + did=self.did, 323 + ) 324 + 325 + return CreateRecordResponse.from_dict(response.data) 326 + 327 + def create_gates( 328 + self, 329 + post_uri: str, 330 + thread_gate: list[str] | None, 331 + quote_gate: bool, 332 + ) -> tuple[CreateRecordResponse | None, CreateRecordResponse | None]: 333 + threadgate_response: CreateRecordResponse | None = None 334 + postgate_response: CreateRecordResponse | None = None 335 + 336 + if thread_gate is not None: 337 + threadgate_response = self.create_threadgate(post_uri, thread_gate) 338 + 339 + if quote_gate: 340 + postgate_response = self.create_postgate(post_uri, quote_gate) 341 + 342 + return threadgate_response, postgate_response 343 + 344 + def get_post(self, uri: str) -> dict[str, Any] | None: 345 + try: 346 + response = self.xrpc.call( 347 + "app.bsky.feed.getPosts", 348 + params={"uris": [uri]}, 349 + ) 350 + posts = cast(list[dict[str, Any]], response.data.get("posts", [])) 351 + return posts[0] if posts else None 352 + except XRPCError as e: 353 + if e.status_code == 404: 354 + return None 355 + logger.warning("Failed to get post %s: %s", uri, e) 356 + return None 357 + except Exception as e: 358 + logger.warning("Unexpected error getting post %s: %s", uri, e) 359 + return None
+54
bluesky/info.py
··· 1 + from abc import ABC, abstractmethod 2 + from typing import Any 3 + 4 + from atproto.store import AtprotoStore 5 + from atproto.xrpc import resolve_identity 6 + from cross.service import Service 7 + from util.util import normalize_service_url 8 + 9 + 10 + SERVICE = "https://bsky.app" 11 + 12 + 13 + def validate_and_transform(data: dict[str, Any]) -> None: 14 + if not data.get("handle") and not data.get("did"): 15 + raise KeyError("no 'handle' or 'did' specified for bluesky!") 16 + 17 + if "did" in data: 18 + did = str(data["did"]) # only did:web and did:plc are supported 19 + if not did.startswith("did:plc:") and not did.startswith("did:web:"): 20 + raise ValueError( 21 + f"Invalid DID format: {did}! Only did:plc: and did:web: are supported." 22 + ) 23 + 24 + if "pds" in data: 25 + data["pds"] = normalize_service_url(data["pds"]) 26 + 27 + 28 + class BlueskyService(ABC, Service): 29 + pds: str 30 + did: str 31 + _store: AtprotoStore 32 + 33 + def _init_identity(self) -> None: 34 + handle, did, pds = self.get_identity_options() 35 + if did: 36 + self.did = did 37 + if pds: 38 + self.pds = pds 39 + 40 + if not did: 41 + if not handle: 42 + raise KeyError("No did: or atproto handle provided!") 43 + self.log.info("Resolving ATP identity for %s...", handle) 44 + identity = resolve_identity(handle, self._store) 45 + self.did = identity.did 46 + 47 + if not pds: 48 + self.log.info("Resolving PDS for %s...", self.did) 49 + identity = resolve_identity(self.did, self._store) 50 + self.pds = identity.pds 51 + 52 + @abstractmethod 53 + def get_identity_options(self) -> tuple[str | None, str | None, str | None]: 54 + pass
+323
bluesky/input.py
··· 1 + import asyncio 2 + import json 3 + import re 4 + from abc import ABC 5 + from dataclasses import dataclass, field 6 + from typing import Any, cast, override 7 + 8 + import httpx 9 + import websockets 10 + 11 + import env 12 + from atproto.models import AtUri 13 + from atproto.store import get_store 14 + from bluesky.info import SERVICE, BlueskyService, validate_and_transform 15 + from bluesky.richtext import richtext_to_tokens 16 + from cross.attachments import ( 17 + LabelsAttachment, 18 + LanguagesAttachment, 19 + MediaAttachment, 20 + QuoteAttachment, 21 + RemoteUrlAttachment, 22 + ) 23 + from cross.media import Blob, download_blob 24 + from cross.post import Post, PostRef 25 + from cross.service import InputService 26 + from database.connection import DatabasePool 27 + 28 + 29 + @dataclass(kw_only=True) 30 + class BlueskyInputOptions: 31 + handle: str | None = None 32 + did: str | None = None 33 + pds: str | None = None 34 + filters: list[re.Pattern[str]] = field(default_factory=lambda: []) 35 + 36 + @classmethod 37 + def from_dict(cls, data: dict[str, Any]) -> "BlueskyInputOptions": 38 + validate_and_transform(data) 39 + 40 + if "filters" in data: 41 + data["filters"] = [re.compile(r) for r in data["filters"]] 42 + 43 + return BlueskyInputOptions(**data) 44 + 45 + 46 + class BlueskyBaseInputService(BlueskyService, InputService, ABC): 47 + def __init__(self, db: DatabasePool, http: httpx.Client) -> None: 48 + super().__init__(SERVICE, db) 49 + self.http = http 50 + 51 + def _on_post(self, record: dict[str, Any]): 52 + post_uri = cast(str, record["$xpost.strongRef"]["uri"]) 53 + post_cid = cast(str, record["$xpost.strongRef"]["cid"]) 54 + 55 + self.log.info("Processing new post: %s", post_uri) 56 + 57 + if self._is_post_crossposted(self.url, self.did, post_uri): 58 + self.log.info( 59 + "Skipping %s, already crossposted", 60 + post_uri, 61 + ) 62 + return 63 + 64 + parent_uri = cast( 65 + str, None if not record.get("reply") else record["reply"]["parent"]["uri"] 66 + ) 67 + parent = None 68 + if parent_uri: 69 + parent = self._get_post(self.url, self.did, parent_uri) 70 + if not parent: 71 + self.log.info( 72 + "Skipping %s, parent %s not found in db", post_uri, parent_uri 73 + ) 74 + return 75 + 76 + tokens = richtext_to_tokens(record["text"], record.get("facets", [])) 77 + post = Post( 78 + id=post_uri, 79 + author=self.did, 80 + service=self.url, 81 + parent_id=parent_uri, 82 + tokens=tokens, 83 + ) 84 + 85 + did, _, rid = AtUri.record_uri(post_uri) 86 + post.attachments.put( 87 + RemoteUrlAttachment(url=f"https://bsky.app/profile/{did}/post/{rid}") 88 + ) 89 + 90 + embed: dict[str, Any] = record.get("embed", {}) 91 + blob_urls: list[tuple[str, str, str | None]] = [] 92 + 93 + def handle_embeds(embed: dict[str, Any]) -> str | None: 94 + nonlocal blob_urls, post 95 + match cast(str, embed["$type"]): 96 + case "app.bsky.embed.record" | "app.bsky.embed.recordWithMedia": 97 + rcrd = ( 98 + embed["record"]["record"] 99 + if embed["record"].get("record") 100 + else embed["record"] 101 + ) 102 + did, collection, _ = AtUri.record_uri(rcrd["uri"]) 103 + if collection != "app.bsky.feed.post": 104 + return f"Unhandled record collection {collection}" 105 + if did != self.did: 106 + return "" 107 + 108 + rquote = self._get_post(self.url, did, rcrd["uri"]) 109 + if not rquote: 110 + return f"Quote {rcrd['uri']} not found in the db" 111 + post.attachments.put( 112 + QuoteAttachment(quoted_id=rcrd["uri"], quoted_user=did) 113 + ) 114 + 115 + if embed.get("media"): 116 + return handle_embeds(embed["media"]) 117 + case "app.bsky.embed.images": 118 + for image in embed["images"]: 119 + blob_cid = image["image"]["ref"]["$link"] 120 + url = f"{self.pds}/xrpc/com.atproto.sync.getBlob?did={self.did}&cid={blob_cid}" 121 + blob_urls.append((url, blob_cid, image.get("alt"))) 122 + case "app.bsky.embed.video": 123 + blob_cid = embed["video"]["ref"]["$link"] 124 + url = f"{self.pds}/xrpc/com.atproto.sync.getBlob?did={self.did}&cid={blob_cid}" 125 + blob_urls.append((url, blob_cid, embed.get("alt"))) 126 + case _: 127 + self.log.warning(f"Unhandled embed type {embed['$type']}") 128 + return None 129 + 130 + if embed: 131 + fexit = handle_embeds(embed) 132 + if fexit is not None: 133 + self.log.info("Skipping %s! %s", post_uri, fexit) 134 + return 135 + 136 + if blob_urls: 137 + blobs: list[Blob] = [] 138 + for url, cid, alt in blob_urls: 139 + self.log.info("Downloading %s...", cid) 140 + blob: Blob | None = download_blob(url, alt, client=self.http) 141 + if not blob: 142 + self.log.error( 143 + "Skipping %s! Failed to download blob %s.", post_uri, cid 144 + ) 145 + return 146 + blobs.append(blob) 147 + post.attachments.put(MediaAttachment(blobs=blobs)) 148 + 149 + if "langs" in record: 150 + post.attachments.put(LanguagesAttachment(langs=record["langs"])) 151 + if "labels" in record: 152 + post.attachments.put( 153 + LabelsAttachment( 154 + labels=[ 155 + label["val"].replace("-", " ") for label in record["values"] 156 + ] 157 + ), 158 + ) 159 + 160 + if parent: 161 + self._insert_post( 162 + { 163 + "user": self.did, 164 + "service": self.url, 165 + "identifier": post_uri, 166 + "parent": parent["id"], 167 + "root": parent["id"] if not parent["root"] else parent["root"], 168 + "extra_data": json.dumps({"cid": post_cid}), 169 + } 170 + ) 171 + else: 172 + self._insert_post( 173 + { 174 + "user": self.did, 175 + "service": self.url, 176 + "identifier": post_uri, 177 + "extra_data": json.dumps({"cid": post_cid}), 178 + } 179 + ) 180 + 181 + self.log.info("Post stored in DB: %s", post_uri) 182 + 183 + for out in self.outputs: 184 + self.submitter(lambda: out.accept_post(post)) 185 + 186 + def _on_repost(self, record: dict[str, Any]): 187 + post_uri = cast(str, record["$xpost.strongRef"]["uri"]) 188 + post_cid = cast(str, record["$xpost.strongRef"]["cid"]) 189 + 190 + self.log.info("Processing repost: %s", post_uri) 191 + 192 + reposted_uri = cast(str, record["subject"]["uri"]) 193 + reposted = self._get_post(self.url, self.did, reposted_uri) 194 + if not reposted: 195 + self.log.info( 196 + "Skipping repost '%s' as reposted post '%s' was not found in the db.", 197 + post_uri, 198 + reposted_uri, 199 + ) 200 + return 201 + 202 + self._insert_post( 203 + { 204 + "user": self.did, 205 + "service": self.url, 206 + "identifier": post_uri, 207 + "reposted": reposted["id"], 208 + "extra_data": json.dumps({"cid": post_cid}), 209 + } 210 + ) 211 + 212 + self.log.info("Repost stored in DB: %s", post_uri) 213 + 214 + repost_ref = PostRef(id=post_uri, author=self.did, service=self.url) 215 + reposted_ref = PostRef(id=reposted_uri, author=self.did, service=self.url) 216 + for out in self.outputs: 217 + self.submitter(lambda: out.accept_repost(repost_ref, reposted_ref)) 218 + 219 + def _on_delete_post(self, post_id: str, repost: bool): 220 + self.log.info("Processing delete for %s (repost: %s)...", post_id, repost) 221 + post = self._get_post(self.url, self.did, post_id) 222 + if not post: 223 + self.log.warning("Post not found in DB: %s", post_id) 224 + return 225 + 226 + post_ref = PostRef(id=post_id, author=self.did, service=self.url) 227 + if repost: 228 + self.log.info("Deleting repost: %s", post_id) 229 + for output in self.outputs: 230 + self.submitter(lambda: output.delete_repost(post_ref)) 231 + else: 232 + self.log.info("Deleting post: %s", post_id) 233 + for output in self.outputs: 234 + self.submitter(lambda: output.delete_post(post_ref)) 235 + self.submitter(lambda: self._delete_post_by_id(post["id"])) 236 + self.log.info("Delete successful for %s", post_id) 237 + 238 + 239 + class BlueskyJetstreamInputService(BlueskyBaseInputService): 240 + def __init__( 241 + self, 242 + db: DatabasePool, 243 + http: httpx.Client, 244 + options: BlueskyInputOptions, 245 + ) -> None: 246 + super().__init__(db, http) 247 + self.options: BlueskyInputOptions = options 248 + self._store = get_store(db) 249 + self._init_identity() 250 + 251 + @override 252 + def get_identity_options(self) -> tuple[str | None, str | None, str | None]: 253 + return (self.options.handle, self.options.did, self.options.pds) 254 + 255 + def _accept_msg(self, msg: websockets.Data) -> None: 256 + data: dict[str, Any] = cast(dict[str, Any], json.loads(msg)) 257 + if data.get("did") != self.did: 258 + return 259 + commit: dict[str, Any] | None = data.get("commit") 260 + if not commit: 261 + return 262 + 263 + commit_type: str = cast(str, commit["operation"]) 264 + match commit_type: 265 + case "create": 266 + record: dict[str, Any] = cast(dict[str, Any], commit["record"]) 267 + record["$xpost.strongRef"] = { 268 + "cid": commit["cid"], 269 + "uri": f"at://{self.did}/{commit['collection']}/{commit['rkey']}", 270 + } 271 + 272 + match cast(str, commit["collection"]): 273 + case "app.bsky.feed.post": 274 + self._on_post(record) 275 + case "app.bsky.feed.repost": 276 + self._on_repost(record) 277 + case _: 278 + pass 279 + case "delete": 280 + post_id: str = ( 281 + f"at://{self.did}/{commit['collection']}/{commit['rkey']}" 282 + ) 283 + match cast(str, commit["collection"]): 284 + case "app.bsky.feed.post": 285 + self._on_delete_post(post_id, False) 286 + case "app.bsky.feed.repost": 287 + self._on_delete_post(post_id, True) 288 + case _: 289 + pass 290 + case _: 291 + pass 292 + 293 + @override 294 + async def listen(self): 295 + url = env.JETSTREAM_URL + "?" 296 + url += "wantedCollections=app.bsky.feed.post" 297 + url += "&wantedCollections=app.bsky.feed.repost" 298 + url += f"&wantedDids={self.did}" 299 + 300 + async for ws in websockets.connect( 301 + url, 302 + ping_interval=20, 303 + ping_timeout=10, 304 + close_timeout=5, 305 + ): 306 + try: 307 + self.log.info("Listening to %s...", env.JETSTREAM_URL) 308 + 309 + async def listen_for_messages(): 310 + async for msg in ws: 311 + self.submitter(lambda: self._accept_msg(msg)) 312 + 313 + listen = asyncio.create_task(listen_for_messages()) 314 + 315 + _ = await asyncio.gather(listen) 316 + except websockets.ConnectionClosedError as e: 317 + self.log.error(e, stack_info=True, exc_info=True) 318 + self.log.info("Reconnecting to %s...", env.JETSTREAM_URL) 319 + continue 320 + except TimeoutError as e: 321 + self.log.error("Connection timeout: %s", e) 322 + self.log.info("Reconnecting to %s...", env.JETSTREAM_URL) 323 + continue
+658
bluesky/output.py
··· 1 + import json 2 + import re 3 + from dataclasses import dataclass 4 + from typing import Any, override 5 + 6 + import httpx 7 + 8 + import misskey.mfm as mfm 9 + from atproto.models import ( 10 + Facet, 11 + ImageEmbed, 12 + RecordEmbed, 13 + ReplyRef, 14 + SelfLabel, 15 + SelfLabels, 16 + StrongRef, 17 + ) 18 + from atproto.store import get_store 19 + from bluesky.client import BlueskyClient 20 + from bluesky.info import SERVICE, BlueskyService, validate_and_transform 21 + from bluesky.richtext import tokens_to_richtext 22 + from cross.attachments import ( 23 + LabelsAttachment, 24 + LanguagesAttachment, 25 + MediaAttachment, 26 + QuoteAttachment, 27 + RemoteUrlAttachment, 28 + SensitiveAttachment, 29 + ) 30 + from cross.media import Blob, compress_image, convert_to_mp4, get_media_meta 31 + from cross.post import Post, PostRef 32 + from cross.service import OutputService 33 + from cross.tokens import LinkToken, TextToken, Token 34 + from database.connection import DatabasePool 35 + from util.splitter import TokenSplitter 36 + 37 + 38 + ALLOWED_GATES: list[str] = ["mentioned", "following", "followers"] 39 + 40 + ADULT_PATTERN = re.compile(r"\b(adult|sexual|nsfw)\b", re.IGNORECASE) 41 + PORN_PATTERN = re.compile(r"\b(porn|explicit)\b", re.IGNORECASE) 42 + 43 + 44 + @dataclass(kw_only=True) 45 + class BlueskyOutputOptions: 46 + handle: str | None = None 47 + did: str | None = None 48 + pds: str | None = None 49 + password: str = "" 50 + quote_gate: bool = False 51 + thread_gate: list[str] | None = None 52 + encode_videos: bool = True 53 + 54 + @classmethod 55 + def from_dict(cls, data: dict[str, Any]) -> "BlueskyOutputOptions": 56 + validate_and_transform(data) 57 + 58 + if "password" not in data: 59 + raise KeyError("password is required for bluesky") 60 + 61 + if "quote_gate" in data: 62 + data["quote_gate"] = bool(data["quote_gate"]) 63 + 64 + if ( 65 + "thread_gate" in data 66 + and isinstance(data["thread_gate"], list) 67 + and any(v not in ALLOWED_GATES for v in data["thread_gate"]) 68 + ): 69 + raise ValueError( 70 + f"'thread_gate' only accepts {', '.join(ALLOWED_GATES)}, " 71 + f"got: {data['thread_gate']}" 72 + ) 73 + 74 + if "encode_videos" in data: 75 + data["encode_videos"] = bool(data["encode_videos"]) 76 + 77 + return BlueskyOutputOptions(**data) 78 + 79 + 80 + class BlueskyOutputService(BlueskyService, OutputService): 81 + def __init__( 82 + self, db: DatabasePool, http: httpx.Client, options: BlueskyOutputOptions 83 + ) -> None: 84 + super().__init__(SERVICE, db) 85 + self.http = http 86 + self.options: BlueskyOutputOptions = options 87 + 88 + self._store = get_store(db) 89 + self._init_identity() 90 + 91 + self._client = BlueskyClient( 92 + self.pds, 93 + self._store, 94 + self.did, 95 + http, 96 + self.options.password, 97 + ) 98 + self.options.password = "" 99 + self.log.info("Logged in as %s", self.did) 100 + 101 + @override 102 + def get_identity_options(self) -> tuple[str | None, str | None, str | None]: 103 + return (self.options.handle, self.options.did, self.options.pds) 104 + 105 + def _split_attachments( 106 + self, attachments: list[Blob] 107 + ) -> tuple[list[Blob], list[Blob]]: 108 + supported: list[Blob] = [] 109 + unsupported: list[Blob] = [] 110 + 111 + for blob in attachments: 112 + if blob.mime.startswith(("image/", "video/")): 113 + supported.append(blob) 114 + else: 115 + unsupported.append(blob) 116 + 117 + return supported, unsupported 118 + 119 + def _split_media_per_post( 120 + self, 121 + token_blocks: list[list[Any]], 122 + media: list[Blob], 123 + ) -> list[tuple[list[Any], list[Blob]]]: 124 + posts: list[dict[str, Any]] = [ 125 + {"tokens": block, "attachments": []} for block in token_blocks 126 + ] 127 + available_indices: list[int] = list(range(len(posts))) 128 + current_image_post_idx: int | None = None 129 + 130 + def make_blank_post() -> dict[str, Any]: 131 + return {"tokens": [], "attachments": []} 132 + 133 + def pop_next_empty_index() -> int: 134 + if available_indices: 135 + return available_indices.pop(0) 136 + new_idx = len(posts) 137 + posts.append(make_blank_post()) 138 + return new_idx 139 + 140 + for blob in media: 141 + if blob.mime.startswith("video/"): 142 + current_image_post_idx = None 143 + idx = pop_next_empty_index() 144 + posts[idx]["attachments"].append(blob) 145 + elif blob.mime.startswith("image/"): 146 + if ( 147 + current_image_post_idx is not None 148 + and len(posts[current_image_post_idx]["attachments"]) < 4 149 + ): 150 + posts[current_image_post_idx]["attachments"].append(blob) 151 + else: 152 + idx = pop_next_empty_index() 153 + posts[idx]["attachments"].append(blob) 154 + current_image_post_idx = idx 155 + 156 + result: list[tuple[list[Any], list[Blob]]] = [] 157 + for p in posts: 158 + result.append((p["tokens"], p["attachments"])) 159 + 160 + return result 161 + 162 + def _build_labels( 163 + self, 164 + spoiler: str | None, 165 + is_sensitive: bool, 166 + ) -> SelfLabels | None: 167 + unique_labels: set[str] = set() 168 + 169 + if spoiler: 170 + unique_labels.add("graphic-media") 171 + 172 + if PORN_PATTERN.search(spoiler): 173 + unique_labels.add("porn") 174 + elif ADULT_PATTERN.search(spoiler): 175 + unique_labels.add("sexual") 176 + 177 + if is_sensitive: 178 + unique_labels.add("graphic-media") 179 + 180 + if not unique_labels: 181 + return None 182 + 183 + return SelfLabels(values=[SelfLabel(val=label) for label in unique_labels]) 184 + 185 + @override 186 + def accept_post(self, post: Post): 187 + self.log.info( 188 + "Accepting post %s (author: %s, service: %s)...", 189 + post.id, 190 + post.author, 191 + post.service, 192 + ) 193 + reply_to: ReplyRef | None = None 194 + new_root_id: int | None = None 195 + new_parent_id: int | None = None 196 + 197 + if post.parent_id: 198 + parent = self._get_post(post.service, post.author, post.parent_id) 199 + if not parent: 200 + self.log.error("Parent post not found in DB: %s", post.parent_id) 201 + return 202 + 203 + thread = self._find_mapped_thread( 204 + parent["identifier"], 205 + parent["service"], 206 + parent["user"], 207 + self.url, 208 + self.did, 209 + ) 210 + if not thread: 211 + self.log.error( 212 + "Failed to find thread tuple in the database for parent: %s", 213 + post.parent_id, 214 + ) 215 + return 216 + 217 + root_uri, reply_uri, root_db_id, reply_db_id = thread 218 + 219 + root_post = self._get_post(self.url, self.did, root_uri) 220 + reply_post = self._get_post(self.url, self.did, reply_uri) 221 + 222 + if not root_post or not reply_post: 223 + self.log.error("Failed to fetch parent posts from database!") 224 + return 225 + 226 + try: 227 + root_cid_data = root_post["extra_data"] 228 + root_cid = ( 229 + json.loads(root_cid_data).get("cid", "") if root_cid_data else "" 230 + ) 231 + reply_cid_data = reply_post["extra_data"] 232 + reply_cid = ( 233 + json.loads(reply_cid_data).get("cid", "") if reply_cid_data else "" 234 + ) 235 + except (json.JSONDecodeError, AttributeError, KeyError): 236 + self.log.error("Failed to parse CID from database!") 237 + return 238 + 239 + root_ref = StrongRef(uri=root_uri, cid=root_cid) 240 + reply_ref = StrongRef(uri=reply_uri, cid=reply_cid) 241 + reply_to = ReplyRef(root=root_ref, parent=reply_ref) 242 + new_root_id = root_db_id 243 + new_parent_id = reply_db_id 244 + 245 + labels_attachment = post.attachments.get(LabelsAttachment) 246 + spoiler: str | None = ( 247 + labels_attachment.labels[0] 248 + if labels_attachment and labels_attachment.labels 249 + else None 250 + ) 251 + sensitive_attachment = post.attachments.get(SensitiveAttachment) 252 + is_sensitive = sensitive_attachment.sensitive if sensitive_attachment else False 253 + 254 + labels = self._build_labels(spoiler, is_sensitive) 255 + 256 + langs: list[str] | None = None 257 + langs_attachment = post.attachments.get(LanguagesAttachment) 258 + if langs_attachment and langs_attachment.langs: 259 + langs = langs_attachment.langs[:3] 260 + 261 + media_attachment = post.attachments.get(MediaAttachment) 262 + all_media = media_attachment.blobs if media_attachment else [] 263 + supported_media, unsupported_media = self._split_attachments(all_media) 264 + 265 + tokens: list[Token] = [] 266 + 267 + if spoiler: 268 + tokens.append(TextToken(text=f"[{spoiler}]\n\n")) 269 + 270 + tokens.extend(post.tokens) 271 + 272 + if unsupported_media: 273 + tokens.append(TextToken(text="\n")) 274 + for attachment in unsupported_media: 275 + url = ( 276 + attachment.url 277 + if hasattr(attachment, "url") and attachment.url 278 + else attachment.name or "unknown" 279 + ) 280 + 281 + name = "💾 file" 282 + if attachment.name: 283 + if attachment.mime.startswith("audio/"): 284 + name = "🎵 " + attachment.name 285 + elif attachment.mime.startswith("text/"): 286 + name = "📄 " + attachment.name 287 + else: 288 + name = "💾 " + attachment.name 289 + 290 + if len(name) > 28: 291 + name = name[: 28 - 1] + "…" 292 + 293 + tokens.append(LinkToken(href=url, label=f"[{name}]")) 294 + tokens.append(TextToken(text=" ")) 295 + 296 + if post.text_type == "text/x.misskeymarkdown": 297 + tokens, status = mfm.strip_mfm(tokens) 298 + remote_url = post.attachments.get(RemoteUrlAttachment) 299 + if status and remote_url and remote_url.url: 300 + tokens.append(TextToken(text="\n")) 301 + tokens.append( 302 + LinkToken( 303 + href=remote_url.url, label="[Post contains MFM, see original]" 304 + ) 305 + ) 306 + 307 + quote_attachment = post.attachments.get(QuoteAttachment) 308 + quoted_cid: str | None = None 309 + quoted_uri: str | None = None 310 + if quote_attachment: 311 + if quote_attachment.quoted_user != post.author: 312 + self.log.info("Quoted other user, skipping quote!") 313 + return 314 + 315 + quoted_post = self._get_post( 316 + post.service, post.author, quote_attachment.quoted_id 317 + ) 318 + if not quoted_post: 319 + self.log.error("Failed to find quoted post in the database!") 320 + else: 321 + quoted_mappings = self._get_mappings( 322 + quoted_post["id"], self.url, self.did 323 + ) 324 + if not quoted_mappings: 325 + self.log.error("Failed to find mappings for quoted post!") 326 + else: 327 + bluesky_quoted_post = self._get_post( 328 + self.url, self.did, quoted_mappings[0]["identifier"] 329 + ) 330 + if not bluesky_quoted_post: 331 + self.log.error("Failed to find Bluesky quoted post!") 332 + else: 333 + quoted_cid_data = bluesky_quoted_post["extra_data"] 334 + quoted_cid = ( 335 + json.loads(quoted_cid_data).get("cid", "") 336 + if quoted_cid_data 337 + else "" 338 + ) 339 + quoted_uri = quoted_mappings[0]["identifier"] 340 + 341 + splitter = TokenSplitter(max_chars=300, max_link_len=30) 342 + token_blocks = splitter.split(tokens) 343 + 344 + if token_blocks is None: 345 + self.log.error( 346 + "Skipping '%s' as it contains links/tags that are too long!", post.id 347 + ) 348 + return 349 + 350 + for blob in supported_media: 351 + if blob.mime.startswith("image/") and len(blob.io) > 2_000_000: 352 + self.log.error( 353 + "Skipping post '%s', image too large!", 354 + post.id, 355 + ) 356 + return 357 + if blob.mime.startswith("video/"): 358 + if blob.mime != "video/mp4" and not self.options.encode_videos: 359 + self.log.info( 360 + "Video is not mp4, but encoding is disabled. Skipping '%s'...", 361 + post.id, 362 + ) 363 + return 364 + if len(blob.io) > 100_000_000: 365 + self.log.error( 366 + "Skipping post '%s', video too large!", 367 + post.id, 368 + ) 369 + return 370 + 371 + baked_media = self._split_media_per_post( 372 + [list(block) for block in token_blocks], 373 + supported_media, 374 + ) 375 + 376 + precomputed_richtexts: list[tuple[str, list[Facet]]] = [] 377 + for block in token_blocks: 378 + result = tokens_to_richtext(block) 379 + if result is None: 380 + self.log.error( 381 + "Skipping '%s' as it contains invalid rich text types!", 382 + post.id, 383 + ) 384 + return 385 + precomputed_richtexts.append(result) 386 + 387 + created_records: list[tuple[str, str]] = [] 388 + post_root_ref: StrongRef | None = None 389 + previous_reply_ref: StrongRef | None = None 390 + 391 + richtext_index = 0 392 + 393 + for i, (block_tokens, attachments) in enumerate(baked_media): 394 + if block_tokens and richtext_index < len(precomputed_richtexts): 395 + text, facets = precomputed_richtexts[richtext_index] 396 + richtext_index += 1 397 + else: 398 + text = "" 399 + facets = [] 400 + 401 + current_reply_to: ReplyRef | None = None 402 + if i == 0: 403 + current_reply_to = reply_to 404 + elif previous_reply_ref and post_root_ref: 405 + current_reply_to = ReplyRef( 406 + root=post_root_ref, parent=previous_reply_ref 407 + ) 408 + 409 + embed: dict[str, Any] | None = None 410 + if i == 0 and quoted_uri and quoted_cid: 411 + if attachments and attachments[0].mime.startswith("image/"): 412 + embed = RecordEmbed( 413 + record=StrongRef(uri=quoted_uri, cid=quoted_cid) 414 + ).to_dict() 415 + else: 416 + embed = RecordEmbed( 417 + record=StrongRef(uri=quoted_uri, cid=quoted_cid) 418 + ).to_dict() 419 + 420 + if not attachments: 421 + response = self._client.send_post( 422 + text=text or " ", 423 + facets=facets or None, 424 + embed=embed, 425 + reply_to=current_reply_to, 426 + labels=labels, 427 + langs=langs, 428 + ) 429 + elif attachments[0].mime.startswith("image/"): 430 + images: list[ImageEmbed] = [] 431 + for img_blob in attachments[:4]: 432 + image_io = img_blob.io 433 + if len(image_io) > 1_000_000: 434 + self.log.info("Compressing %s...", img_blob.name or "image") 435 + compressed = compress_image(img_blob) 436 + image_io = compressed.io 437 + 438 + try: 439 + meta = get_media_meta(image_io) 440 + aspect_ratio = (meta.width, meta.height) 441 + except Exception as e: 442 + self.log.error(e) 443 + aspect_ratio = None 444 + 445 + images.append( 446 + ImageEmbed( 447 + image=image_io, 448 + alt=img_blob.alt, 449 + aspect_ratio=aspect_ratio, 450 + ) 451 + ) 452 + 453 + response = self._client.send_images( 454 + text=text or "", 455 + images=images, 456 + facets=facets or None, 457 + embed=embed, 458 + reply_to=current_reply_to, 459 + labels=labels, 460 + langs=langs, 461 + ) 462 + else: 463 + video_blob = attachments[0] 464 + video_io = video_blob.io 465 + 466 + if video_blob.mime != "video/mp4": 467 + self.log.info("Converting %s to mp4...", video_blob.name or "video") 468 + converted = convert_to_mp4(video_blob) 469 + video_io = converted.io 470 + 471 + try: 472 + meta = get_media_meta(video_io) 473 + aspect_ratio = (meta.width, meta.height) 474 + duration = meta.duration 475 + except Exception as e: 476 + self.log.error(e) 477 + aspect_ratio = None 478 + duration = None 479 + 480 + if duration and duration > 180: 481 + self.log.info( 482 + "Skipping post '%s', video too long (%.1f > 180s)!", 483 + post.id, 484 + duration, 485 + ) 486 + return 487 + 488 + response = self._client.send_video( 489 + text=text or "", 490 + video=video_io, 491 + alt=video_blob.alt, 492 + aspect_ratio=aspect_ratio, 493 + embed=embed, 494 + reply_to=current_reply_to, 495 + labels=labels, 496 + langs=langs, 497 + ) 498 + 499 + created_records.append((response.uri, response.cid)) 500 + 501 + if post_root_ref is None: 502 + post_root_ref = StrongRef(uri=response.uri, cid=response.cid) 503 + previous_reply_ref = StrongRef(uri=response.uri, cid=response.cid) 504 + 505 + if i == 0: 506 + self._client.create_gates( 507 + response.uri, 508 + self.options.thread_gate, 509 + self.options.quote_gate, 510 + ) 511 + 512 + db_post = self._get_post(post.service, post.author, post.id) 513 + if not db_post: 514 + self.log.error("Post not found in database!") 515 + return 516 + 517 + if new_root_id is None or new_parent_id is None: 518 + self._insert_post( 519 + { 520 + "user": self.did, 521 + "service": self.url, 522 + "identifier": created_records[0][0], 523 + "parent": None, 524 + "root": None, 525 + "reposted": None, 526 + "extra_data": json.dumps({"cid": created_records[0][1]}), 527 + "crossposted": 1, 528 + } 529 + ) 530 + new_post = self._get_post(self.url, self.did, created_records[0][0]) 531 + if not new_post: 532 + raise ValueError("Inserted post not found!") 533 + new_root_id = new_post["id"] 534 + new_parent_id = new_root_id 535 + 536 + self._insert_post_mapping(db_post["id"], new_parent_id) 537 + 538 + for uri, cid in created_records[1:]: 539 + self._insert_post( 540 + { 541 + "user": self.did, 542 + "service": self.url, 543 + "identifier": uri, 544 + "parent": new_parent_id, 545 + "root": new_root_id, 546 + "reposted": None, 547 + "extra_data": json.dumps({"cid": cid}), 548 + "crossposted": 1, 549 + } 550 + ) 551 + reply_post = self._get_post(self.url, self.did, uri) 552 + if not reply_post: 553 + raise ValueError("Inserted reply post not found!") 554 + new_parent_id = reply_post["id"] 555 + self._insert_post_mapping(db_post["id"], new_parent_id) 556 + 557 + self.log.info( 558 + "Post accepted successfully: %s -> %s", 559 + post.id, 560 + [r[0] for r in created_records], 561 + ) 562 + 563 + @override 564 + def delete_post(self, post: PostRef): 565 + self.log.info( 566 + "Deleting post %s (author: %s, service: %s)...", 567 + post.id, 568 + post.author, 569 + post.service, 570 + ) 571 + db_post = self._get_post(post.service, post.author, post.id) 572 + if not db_post: 573 + self.log.warning( 574 + "Post not found in DB: %s (author: %s, service: %s)", 575 + post.id, 576 + post.author, 577 + post.service, 578 + ) 579 + return 580 + 581 + mappings = self._get_mappings(db_post["id"], self.url, self.did) 582 + for mapping in mappings[::-1]: 583 + self.log.info("Deleting '%s'...", mapping["identifier"]) 584 + self._client.delete_post(mapping["identifier"]) 585 + self._delete_post_by_id(mapping["id"]) 586 + self.log.info("Post deleted successfully: %s", post.id) 587 + 588 + @override 589 + def accept_repost(self, repost: PostRef, reposted: PostRef): 590 + self.log.info( 591 + "Accepting repost %s of %s (author: %s, service: %s)...", 592 + repost.id, 593 + reposted.id, 594 + repost.author, 595 + repost.service, 596 + ) 597 + db_repost = self._get_post(repost.service, repost.author, repost.id) 598 + db_reposted = self._get_post(reposted.service, reposted.author, reposted.id) 599 + if not db_repost or not db_reposted: 600 + self.log.info("Post not found in db, skipping repost..") 601 + return 602 + 603 + mappings = self._get_mappings(db_reposted["id"], self.url, self.did) 604 + if not mappings: 605 + return 606 + 607 + try: 608 + cid = json.loads(mappings[0]["extra_data"])["cid"] 609 + except (json.JSONDecodeError, AttributeError, KeyError): 610 + self.log.exception("Failed to parse CID from extra_data!") 611 + return 612 + 613 + response = self._client.repost(mappings[0]["identifier"], cid) 614 + 615 + self._insert_post( 616 + { 617 + "user": self.did, 618 + "service": self.url, 619 + "identifier": response.uri, 620 + "parent": None, 621 + "root": None, 622 + "reposted": mappings[0]["id"], 623 + "extra_data": json.dumps({"cid": response.cid}), 624 + "crossposted": 1, 625 + } 626 + ) 627 + inserted = self._get_post(self.url, self.did, response.uri) 628 + if not inserted: 629 + raise ValueError("Inserted post not found!") 630 + self._insert_post_mapping(db_repost["id"], inserted["id"]) 631 + self.log.info("Repost accepted successfully: %s", repost.id) 632 + 633 + @override 634 + def delete_repost(self, repost: PostRef): 635 + self.log.info( 636 + "Deleting repost %s (author: %s, service: %s)...", 637 + repost.id, 638 + repost.author, 639 + repost.service, 640 + ) 641 + db_repost = self._get_post(repost.service, repost.author, repost.id) 642 + if not db_repost: 643 + self.log.warning( 644 + "Repost not found in DB: %s (author: %s, service: %s)", 645 + repost.id, 646 + repost.author, 647 + repost.service, 648 + ) 649 + return 650 + 651 + mappings = self._get_mappings(db_repost["id"], self.url, self.did) 652 + if mappings: 653 + self.log.info("Deleting '%s'...", mappings[0]["identifier"]) 654 + self._client.delete_repost(mappings[0]["identifier"]) 655 + self._delete_post_by_id(mappings[0]["id"]) 656 + self.log.info("Repost deleted successfully: %s", repost.id) 657 + else: 658 + self.log.error([mappings])
+171
bluesky/richtext.py
··· 1 + from atproto.models import ( 2 + Facet, 3 + FacetFeature, 4 + FacetIndex, 5 + LinkFeature, 6 + MentionFeature, 7 + TagFeature, 8 + ) 9 + from cross.tokens import LinkToken, MentionToken, TagToken, TextToken, Token 10 + from util.splitter import canonical_label 11 + 12 + 13 + def richtext_to_tokens(text: str, facets: list[dict]) -> list[Token]: 14 + if not text: 15 + return [] 16 + ut8_text = text.encode("utf-8") 17 + if not facets: 18 + return [TextToken(text=ut8_text.decode("utf-8"))] 19 + 20 + slices: list[tuple[int, int, str, str]] = [] 21 + for facet in facets: 22 + features: list[dict] = facet.get("features", []) 23 + if not features: 24 + continue 25 + feature = features[0] 26 + feature_type = feature["$type"] 27 + index = facet["index"] 28 + match feature_type: 29 + case "app.bsky.richtext.facet#tag": 30 + slices.append( 31 + (index["byteStart"], index["byteEnd"], "tag", feature["tag"]) 32 + ) 33 + case "app.bsky.richtext.facet#link": 34 + slices.append( 35 + (index["byteStart"], index["byteEnd"], "link", feature["uri"]) 36 + ) 37 + case "app.bsky.richtext.facet#mention": 38 + slices.append( 39 + (index["byteStart"], index["byteEnd"], "mention", feature["did"]) 40 + ) 41 + 42 + if not slices: 43 + return [TextToken(text=ut8_text.decode("utf-8"))] 44 + 45 + slices.sort(key=lambda s: s[0]) 46 + unique: list[tuple[int, int, str, str]] = [] 47 + current_end = 0 48 + for start, end, ttype, val in slices: 49 + if start >= current_end: 50 + unique.append((start, end, ttype, val)) 51 + current_end = end 52 + 53 + if not unique: 54 + return [TextToken(text=ut8_text.decode("utf-8"))] 55 + 56 + tokens: list[Token] = [] 57 + prev = 0 58 + 59 + for start, end, ttype, val in unique: 60 + if start > prev: 61 + tokens.append(TextToken(text=ut8_text[prev:start].decode("utf-8"))) 62 + match ttype: 63 + case "link": 64 + label = ut8_text[start:end].decode("utf-8") 65 + split = val.split("://", 1) 66 + if ( 67 + len(split) > 1 68 + and split[1].startswith(label) 69 + or (label.endswith("...") and split[1].startswith(label[:-3])) 70 + ): 71 + tokens.append(LinkToken(href=val)) 72 + prev = end 73 + continue 74 + tokens.append(LinkToken(href=val, label=label)) 75 + case "tag": 76 + tag = ut8_text[start:end].decode("utf-8") 77 + tokens.append(TagToken(tag=tag[1:] if tag.startswith("#") else tag)) 78 + case "mention": 79 + mention = ut8_text[start:end].decode("utf-8") 80 + tokens.append( 81 + MentionToken( 82 + username=mention[1:] if mention.startswith("@") else mention, 83 + uri=val, 84 + ) 85 + ) 86 + prev = end 87 + 88 + if prev < len(ut8_text): 89 + tokens.append(TextToken(text=ut8_text[prev:].decode("utf-8"))) 90 + 91 + return tokens 92 + 93 + 94 + def tokens_to_richtext(tokens: list[Token]) -> tuple[str, list[Facet]] | None: 95 + segments: list[tuple[str, FacetFeature | None]] = [] 96 + byte_offset = 0 97 + 98 + for token in tokens: 99 + match token: 100 + case TextToken(): 101 + text_bytes = token.text.encode("utf-8") 102 + segments.append((token.text, None)) 103 + byte_offset += len(text_bytes) 104 + 105 + case TagToken(): 106 + tag_text = f"#{token.tag}" 107 + tag_bytes = tag_text.encode("utf-8") 108 + segments.append( 109 + ( 110 + tag_text, 111 + TagFeature(tag=token.tag), 112 + ) 113 + ) 114 + byte_offset += len(tag_bytes) 115 + 116 + case MentionToken(): 117 + mention_text = f"@{token.username}" 118 + mention_bytes = mention_text.encode("utf-8") 119 + segments.append( 120 + ( 121 + mention_text, 122 + MentionFeature(did=token.uri) 123 + if token.uri 124 + else MentionFeature(did=""), 125 + ) 126 + ) 127 + byte_offset += len(mention_bytes) 128 + 129 + case LinkToken(): 130 + href = token.href 131 + label = token.label if token.label else href 132 + 133 + if canonical_label(token.label, token.href): 134 + max_label_len = 30 135 + label_bytes = label.encode("utf-8") 136 + if len(label_bytes) > max_label_len: 137 + label = label[: max_label_len - 1] + "…" 138 + label_bytes = label.encode("utf-8") 139 + else: 140 + label_bytes = label.encode("utf-8") 141 + 142 + segments.append( 143 + ( 144 + label, 145 + LinkFeature(uri=href), 146 + ) 147 + ) 148 + byte_offset += len(label_bytes) 149 + 150 + case _: 151 + return None 152 + 153 + text = "".join(seg[0] for seg in segments) 154 + facets: list[Facet] = [] 155 + 156 + current_offset = 0 157 + for seg_text, seg_feature in segments: 158 + if seg_feature: 159 + seg_bytes = seg_text.encode("utf-8") 160 + facets.append( 161 + Facet( 162 + index=FacetIndex( 163 + byte_start=current_offset, 164 + byte_end=current_offset + len(seg_bytes), 165 + ), 166 + features=[seg_feature], 167 + ) 168 + ) 169 + current_offset += len(seg_text.encode("utf-8")) 170 + 171 + return text, facets
+95
bluesky/tokens.py
··· 1 + from cross.tokens import LinkToken, MentionToken, TagToken, TextToken, Token 2 + 3 + 4 + def tokenize_post(text: str, facets: list[dict]) -> list[Token]: 5 + def decode(ut8: bytes) -> str: 6 + return ut8.decode(encoding="utf-8") 7 + 8 + if not text: 9 + return [] 10 + ut8_text = text.encode(encoding="utf-8") 11 + if not facets: 12 + return [TextToken(text=decode(ut8_text))] 13 + 14 + slices: list[tuple[int, int, str, str]] = [] 15 + 16 + for facet in facets: 17 + features: list[dict] = facet.get("features", []) 18 + if not features: 19 + continue 20 + 21 + # we don't support overlapping facets/features 22 + feature = features[0] 23 + feature_type = feature["$type"] 24 + index = facet["index"] 25 + match feature_type: 26 + case "app.bsky.richtext.facet#tag": 27 + slices.append( 28 + (index["byteStart"], index["byteEnd"], "tag", feature["tag"]) 29 + ) 30 + case "app.bsky.richtext.facet#link": 31 + slices.append( 32 + (index["byteStart"], index["byteEnd"], "link", feature["uri"]) 33 + ) 34 + case "app.bsky.richtext.facet#mention": 35 + slices.append( 36 + (index["byteStart"], index["byteEnd"], "mention", feature["did"]) 37 + ) 38 + 39 + if not slices: 40 + return [TextToken(text=decode(ut8_text))] 41 + 42 + slices.sort(key=lambda s: s[0]) 43 + unique: list[tuple[int, int, str, str]] = [] 44 + current_end = 0 45 + for start, end, ttype, val in slices: 46 + if start >= current_end: 47 + unique.append((start, end, ttype, val)) 48 + current_end = end 49 + 50 + if not unique: 51 + return [TextToken(text=decode(ut8_text))] 52 + 53 + tokens: list[Token] = [] 54 + prev = 0 55 + 56 + for start, end, ttype, val in unique: 57 + if start > prev: 58 + # text between facets 59 + tokens.append(TextToken(text=decode(ut8_text[prev:start]))) 60 + # facet token 61 + match ttype: 62 + case "link": 63 + label = decode(ut8_text[start:end]) 64 + 65 + # try to unflatten links 66 + split = val.split("://", 1) 67 + if len(split) > 1: 68 + if split[1].startswith(label): 69 + tokens.append(LinkToken(href=val)) 70 + prev = end 71 + continue 72 + 73 + if label.endswith("...") and split[1].startswith(label[:-3]): 74 + tokens.append(LinkToken(href=val)) 75 + prev = end 76 + continue 77 + 78 + tokens.append(LinkToken(href=val, label=label)) 79 + case "tag": 80 + tag = decode(ut8_text[start:end]) 81 + tokens.append(TagToken(tag=tag[1:] if tag.startswith("#") else tag)) 82 + case "mention": 83 + mention = decode(ut8_text[start:end]) 84 + tokens.append( 85 + MentionToken( 86 + username=mention[1:] if mention.startswith("@") else mention, 87 + uri=val, 88 + ) 89 + ) 90 + prev = end 91 + 92 + if prev < len(ut8_text): 93 + tokens.append(TextToken(text=decode(ut8_text[prev:]))) 94 + 95 + return tokens
cross/__init__.py

This is a binary file and will not be displayed.

+39
cross/attachments.py
··· 1 + from dataclasses import dataclass 2 + 3 + from cross.media import Blob 4 + 5 + 6 + @dataclass(kw_only=True) 7 + class Attachment: 8 + pass 9 + 10 + 11 + @dataclass(kw_only=True) 12 + class LabelsAttachment(Attachment): 13 + labels: list[str] 14 + 15 + 16 + @dataclass(kw_only=True) 17 + class LanguagesAttachment(Attachment): 18 + langs: list[str] 19 + 20 + 21 + @dataclass(kw_only=True) 22 + class SensitiveAttachment(Attachment): 23 + sensitive: bool 24 + 25 + 26 + @dataclass(kw_only=True) 27 + class RemoteUrlAttachment(Attachment): 28 + url: str 29 + 30 + 31 + @dataclass(kw_only=True) 32 + class MediaAttachment(Attachment): 33 + blobs: list[Blob] 34 + 35 + 36 + @dataclass(kw_only=True) 37 + class QuoteAttachment(Attachment): 38 + quoted_id: str 39 + quoted_user: str
+176
cross/media.py
··· 1 + import json 2 + import os 3 + import re 4 + import subprocess 5 + import urllib.parse 6 + from dataclasses import dataclass, field 7 + from typing import Any, cast 8 + 9 + import httpx 10 + import magic 11 + 12 + 13 + FILENAME = re.compile(r'filename="?([^\";]*)"?') 14 + MAGIC = magic.Magic(mime=True) 15 + 16 + 17 + @dataclass 18 + class Blob: 19 + url: str 20 + mime: str 21 + io: bytes = field(repr=False) 22 + name: str | None = None 23 + alt: str | None = None 24 + 25 + 26 + @dataclass 27 + class MediaInfo: 28 + width: int 29 + height: int 30 + duration: float | None = None 31 + 32 + 33 + def mime_from_bytes(io: bytes) -> str: 34 + mime = MAGIC.from_buffer(io) 35 + if not mime: 36 + mime = "application/octet-stream" 37 + return str(mime) 38 + 39 + 40 + def download_blob( 41 + url: str, 42 + alt: str | None = None, 43 + max_bytes: int = 100_000_000, 44 + client: httpx.Client | None = None, 45 + ) -> Blob | None: 46 + name = get_filename_from_url(url, client) 47 + io = download_chuncked(url, max_bytes, client) 48 + if not io: 49 + return None 50 + return Blob(url, mime_from_bytes(io), io, name, alt) 51 + 52 + 53 + def download_chuncked( 54 + url: str, max_bytes: int = 100_000_000, client: httpx.Client | None = None 55 + ) -> bytes | None: 56 + if client is None: 57 + client = httpx.Client() 58 + with client.stream("GET", url, timeout=20) as response: 59 + if response.status_code != 200: 60 + return None 61 + 62 + downloaded_bytes = b"" 63 + current_size = 0 64 + 65 + for chunk in response.iter_bytes(chunk_size=8192): 66 + if not chunk: 67 + continue 68 + 69 + current_size += len(chunk) 70 + if current_size > max_bytes: 71 + return None 72 + 73 + downloaded_bytes += chunk 74 + 75 + return downloaded_bytes 76 + 77 + 78 + def get_filename_from_url(url: str, client: httpx.Client | None = None) -> str: 79 + try: 80 + if client is None: 81 + client = httpx.Client() 82 + response = client.head(url, timeout=5, follow_redirects=True) 83 + disposition = response.headers.get("Content-Disposition") 84 + if disposition: 85 + filename = FILENAME.findall(disposition) 86 + if filename: 87 + return str(filename[0]) 88 + except httpx.RequestError: 89 + pass 90 + 91 + parsed_url = urllib.parse.urlparse(url) 92 + base_name = os.path.basename(parsed_url.path) 93 + 94 + # hardcoded fix to return the cid for pds blobs 95 + if base_name == "com.atproto.sync.getBlob": 96 + qs = urllib.parse.parse_qs(parsed_url.query) 97 + if qs and qs.get("cid"): 98 + return str(qs["cid"][0]) 99 + 100 + return base_name 101 + 102 + 103 + def convert_to_mp4(video: Blob) -> Blob: 104 + cmd = [ 105 + "ffmpeg", 106 + "-i", "pipe:0", 107 + "-c:v", "copy", 108 + "-c:a", "aac", 109 + "-b:a", "128k", 110 + "-movflags", "frag_keyframe+empty_moov+default_base_moof", 111 + "-f", "mp4", 112 + "pipe:1", 113 + ] # fmt: skip 114 + 115 + proc = subprocess.Popen( 116 + cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE 117 + ) 118 + out_bytes, err = proc.communicate(input=video.io) 119 + 120 + if proc.returncode != 0: 121 + raise RuntimeError(f"ffmpeg compress failed: {err.decode()}") 122 + 123 + return Blob(video.url, mime_from_bytes(out_bytes), out_bytes, video.name, video.alt) 124 + 125 + 126 + def compress_image(image: Blob, quality: int = 95) -> Blob: 127 + cmd = [ 128 + "ffmpeg", 129 + "-f", "image2pipe", 130 + "-i", "pipe:0", 131 + "-c:v", "webp", 132 + "-q:v", str(quality), 133 + "-f", "image2pipe", 134 + "pipe:1", 135 + ] # fmt: skip 136 + 137 + proc = subprocess.Popen( 138 + cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE 139 + ) 140 + out_bytes, err = proc.communicate(input=image.io) 141 + 142 + if proc.returncode != 0: 143 + raise RuntimeError(f"ffmpeg compress failed: {err.decode()}") 144 + 145 + return Blob(image.url, "image/webp", out_bytes, image.name, image.alt) 146 + 147 + 148 + def probe_bytes(bytes: bytes) -> dict[str, Any]: 149 + cmd = [ 150 + "ffprobe", 151 + "-v", "error", 152 + "-show_format", 153 + "-show_streams", 154 + "-print_format", "json", 155 + "pipe:0", 156 + ] # fmt: skip 157 + proc = subprocess.run(cmd, input=bytes, capture_output=True) 158 + 159 + if proc.returncode != 0: 160 + raise RuntimeError(f"ffprobe failed: {proc.stderr.decode()}") 161 + 162 + return json.loads(proc.stdout) # type: ignore[no-any-return] 163 + 164 + 165 + def get_media_meta(bytes: bytes) -> MediaInfo: 166 + probe = probe_bytes(bytes) 167 + streams = [s for s in probe["streams"] if s["codec_type"] == "video"] 168 + if not streams: 169 + raise ValueError("No video stream found") 170 + 171 + media: dict[str, Any] = cast(dict[str, Any], streams[0]) 172 + return MediaInfo( 173 + width=int(media["width"]), 174 + height=int(media["height"]), 175 + duration=float(media.get("duration", probe["format"].get("duration"))), 176 + )
+42
cross/post.py
··· 1 + from dataclasses import dataclass, field 2 + from typing import TypeVar 3 + 4 + from cross.attachments import Attachment 5 + from cross.tokens import Token 6 + 7 + 8 + T = TypeVar("T", bound=Attachment) 9 + 10 + 11 + class AttachmentKeeper: 12 + def __init__(self) -> None: 13 + self._map: dict[type, Attachment] = {} 14 + 15 + def put(self, attachment: Attachment) -> None: 16 + self._map[attachment.__class__] = attachment 17 + 18 + def get(self, cls: type[T]) -> T | None: 19 + instance = self._map.get(cls) 20 + if instance is None: 21 + return None 22 + if not isinstance(instance, cls): 23 + raise TypeError(f"Expected {cls.__name__}, got {type(instance).__name__}") 24 + return instance 25 + 26 + def __repr__(self) -> str: 27 + return f"AttachmentKeeper(_map={self._map.values()})" 28 + 29 + 30 + @dataclass(kw_only=True) 31 + class PostRef: 32 + id: str 33 + author: str 34 + service: str 35 + 36 + 37 + @dataclass(kw_only=True) 38 + class Post(PostRef): 39 + parent_id: str | None 40 + tokens: list[Token] 41 + text_type: str = "text/plain" 42 + attachments: AttachmentKeeper = field(default_factory=AttachmentKeeper)
+175
cross/service.py
··· 1 + import logging 2 + import sqlite3 3 + from abc import ABC, abstractmethod 4 + from collections.abc import Callable 5 + from typing import Any, cast 6 + 7 + from cross.post import Post, PostRef 8 + from database.connection import DatabasePool 9 + 10 + 11 + columns: list[str] = [ 12 + "user", 13 + "service", 14 + "identifier", 15 + "parent", 16 + "root", 17 + "reposted", 18 + "extra_data", 19 + "crossposted", 20 + ] 21 + placeholders: str = ", ".join(["?" for _ in columns]) 22 + column_names: str = ", ".join(columns) 23 + 24 + 25 + class Service: 26 + def __init__(self, url: str, db: DatabasePool) -> None: 27 + self.url: str = url 28 + self.db: DatabasePool = db 29 + self.log: logging.Logger = logging.getLogger(self.__class__.__name__) 30 + # self._lock: threading.Lock = threading.Lock() 31 + 32 + def _get_post(self, url: str, user: str, identifier: str) -> sqlite3.Row | None: 33 + cursor = self.db.get_conn().cursor() 34 + _ = cursor.execute( 35 + """ 36 + SELECT * FROM posts 37 + WHERE service = ? 38 + AND user = ? 39 + AND identifier = ? 40 + """, 41 + (url, user, identifier), 42 + ) 43 + return cast(sqlite3.Row, cursor.fetchone()) 44 + 45 + def _get_post_by_id(self, id: int) -> sqlite3.Row | None: 46 + cursor = self.db.get_conn().cursor() 47 + _ = cursor.execute("SELECT * FROM posts WHERE id = ?", (id,)) 48 + return cast(sqlite3.Row, cursor.fetchone()) 49 + 50 + def _get_mappings( 51 + self, original: int, service: str, user: str 52 + ) -> list[sqlite3.Row]: 53 + cursor = self.db.get_conn().cursor() 54 + _ = cursor.execute( 55 + """ 56 + SELECT * 57 + FROM posts AS p 58 + JOIN mappings AS m 59 + ON p.id = m.mapped 60 + WHERE m.original = ? 61 + AND p.service = ? 62 + AND p.user = ? 63 + ORDER BY p.id; 64 + """, 65 + (original, service, user), 66 + ) 67 + return cursor.fetchall() 68 + 69 + def _find_mapped_thread( 70 + self, parent: str, iservice: str, iuser: str, oservice: str, ouser: str 71 + ): 72 + reply_data = self._get_post(iservice, iuser, parent) 73 + if not reply_data: 74 + return None 75 + 76 + reply_mappings: list[sqlite3.Row] | None = self._get_mappings( 77 + reply_data["id"], oservice, ouser 78 + ) 79 + if not reply_mappings: 80 + return None 81 + 82 + reply_identifier: sqlite3.Row = reply_mappings[-1] 83 + root_identifier: sqlite3.Row = reply_mappings[0] 84 + 85 + if reply_data["root"]: 86 + root_data = self._get_post_by_id(reply_data["root"]) 87 + if not root_data: 88 + return None 89 + 90 + root_mappings = self._get_mappings(reply_data["root"], oservice, ouser) 91 + if not root_mappings: 92 + return None 93 + root_identifier = root_mappings[0] 94 + 95 + return ( 96 + root_identifier["identifier"], # real ids 97 + reply_identifier["identifier"], 98 + reply_data["root"], # db ids 99 + reply_data["id"], 100 + ) 101 + 102 + def _insert_post(self, post_data: dict[str, Any]): 103 + values = [post_data.get(col) for col in columns] 104 + cursor = self.db.get_conn().cursor() 105 + _ = cursor.execute( 106 + f"INSERT INTO posts ({column_names}) VALUES ({placeholders})", values 107 + ) 108 + 109 + def _insert_post_mapping(self, original: int, mapped: int): 110 + cursor = self.db.get_conn().cursor() 111 + _ = cursor.execute( 112 + "INSERT OR IGNORE INTO mappings (original, mapped) VALUES (?, ?);", 113 + (original, mapped), 114 + ) 115 + _ = cursor.execute( 116 + "INSERT OR IGNORE INTO mappings (original, mapped) VALUES (?, ?);", 117 + (mapped, original), 118 + ) 119 + 120 + def _delete_post(self, url: str, user: str, identifier: str): 121 + cursor = self.db.get_conn().cursor() 122 + _ = cursor.execute( 123 + """ 124 + DELETE FROM posts 125 + WHERE identifier = ? 126 + AND service = ? 127 + AND user = ? 128 + """, 129 + (identifier, url, user), 130 + ) 131 + 132 + def _delete_post_by_id(self, id: int): 133 + cursor = self.db.get_conn().cursor() 134 + _ = cursor.execute("DELETE FROM posts WHERE id = ?", (id,)) 135 + 136 + def _is_post_crossposted(self, url: str, user: str, identifier: str) -> bool: 137 + cursor = self.db.get_conn().cursor() 138 + row = cursor.execute( 139 + """ 140 + SELECT crossposted FROM posts 141 + WHERE service = ? 142 + AND user = ? 143 + AND identifier = ? 144 + """, 145 + (url, user, identifier), 146 + ).fetchone() 147 + return bool(row and row["crossposted"]) 148 + 149 + 150 + class OutputService(Service): 151 + def accept_post(self, post: Post): 152 + self.log.warning("NOT IMPLEMENTED (%s), accept_post %s", self.url, post.id) 153 + 154 + def delete_post(self, post: PostRef): 155 + self.log.warning("NOT IMPLEMENTED (%s), delete_post %s", self.url, post.id) 156 + 157 + def accept_repost(self, repost: PostRef, reposted: PostRef): 158 + self.log.warning( 159 + "NOT IMPLEMENTED (%s), accept_repost %s of %s", 160 + self.url, 161 + repost.id, 162 + reposted.id, 163 + ) 164 + 165 + def delete_repost(self, repost: PostRef): 166 + self.log.warning("NOT IMPLEMENTED (%s), delete_repost %s", self.url, repost.id) 167 + 168 + 169 + class InputService(ABC, Service): 170 + outputs: list[OutputService] 171 + submitter: Callable[[Callable[[], None]], None] 172 + 173 + @abstractmethod 174 + async def listen(self): 175 + pass
+28
cross/tokens.py
··· 1 + from dataclasses import dataclass 2 + 3 + 4 + @dataclass(kw_only=True) 5 + class Token: 6 + pass 7 + 8 + 9 + @dataclass(kw_only=True) 10 + class TextToken(Token): 11 + text: str 12 + 13 + 14 + @dataclass(kw_only=True) 15 + class LinkToken(Token): 16 + href: str 17 + label: str | None = None 18 + 19 + 20 + @dataclass(kw_only=True) 21 + class TagToken(Token): 22 + tag: str 23 + 24 + 25 + @dataclass(kw_only=True) 26 + class MentionToken(Token): 27 + username: str 28 + uri: str | None = None
database/__init__.py

This is a binary file and will not be displayed.

+33
database/connection.py
··· 1 + import sqlite3 2 + import threading 3 + from pathlib import Path 4 + 5 + 6 + class DatabasePool: 7 + def __init__(self, db: Path) -> None: 8 + self.db: Path = db 9 + self._local: threading.local = threading.local() 10 + self._conns: list[sqlite3.Connection] = [] 11 + 12 + def get_conn(self) -> sqlite3.Connection: 13 + if getattr(self._local, "conn", None) is None: 14 + self._local.conn = get_conn(self.db) 15 + self._conns.append(self._local.conn) 16 + return self._local.conn # type: ignore[no-any-return] 17 + 18 + def close(self) -> None: 19 + for c in self._conns: 20 + c.close() 21 + 22 + 23 + def get_conn(db: Path) -> sqlite3.Connection: 24 + conn = sqlite3.connect(db, autocommit=True, check_same_thread=False) 25 + conn.row_factory = sqlite3.Row 26 + _ = conn.executescript(""" 27 + PRAGMA journal_mode = WAL; 28 + PRAGMA mmap_size = 134217728; 29 + PRAGMA cache_size = 4000; 30 + PRAGMA synchronous = NORMAL; 31 + PRAGMA foreign_keys = ON; 32 + """) 33 + return conn
+61
database/migrations.py
··· 1 + import sqlite3 2 + from collections.abc import Callable 3 + from pathlib import Path 4 + 5 + from database.connection import get_conn 6 + from util.util import LOGGER 7 + 8 + 9 + class DatabaseMigrator: 10 + def __init__(self, db_path: Path, migrations_folder: Path) -> None: 11 + self.db_path: Path = db_path 12 + self.migrations_folder: Path = migrations_folder 13 + self.conn: sqlite3.Connection = get_conn(db_path) 14 + _ = self.conn.execute("PRAGMA foreign_keys = OFF;") 15 + self.conn.autocommit = False 16 + 17 + def close(self): 18 + self.conn.close() 19 + 20 + def get_version(self) -> int: 21 + cursor = self.conn.cursor() 22 + _ = cursor.execute("PRAGMA user_version") 23 + return int(cursor.fetchone()[0]) 24 + 25 + def set_version(self, version: int): 26 + cursor = self.conn.cursor() 27 + _ = cursor.execute(f"PRAGMA user_version = {version}") 28 + self.conn.commit() 29 + 30 + def apply_migration( 31 + self, 32 + version: int, 33 + filename: str, 34 + migration: Callable[[sqlite3.Connection], None], 35 + ) -> None: 36 + try: 37 + migration(self.conn) 38 + self.set_version(version) 39 + self.conn.commit() 40 + LOGGER.info("Applied migration: %s..", filename) 41 + except sqlite3.Error as e: 42 + self.conn.rollback() 43 + raise Exception(f"Error applying migration {filename}: {e}") 44 + 45 + def migrate(self): 46 + current_version = self.get_version() 47 + from migrations._registry import load_migrations 48 + 49 + migrations = load_migrations(self.migrations_folder) 50 + 51 + if not migrations: 52 + LOGGER.warning("No migration files found.") 53 + return 54 + 55 + pending = [m for m in migrations if m[0] > current_version] 56 + if not pending: 57 + LOGGER.info("No pending migrations.") 58 + return 59 + 60 + for version, filename, migration in pending: 61 + self.apply_migration(version, filename, migration)
+91
docs/README.md
··· 1 + # XPost documentation 2 + 3 + ## Installation 4 + 5 + The recommended approach is to use the official container image from `ghcr.io/zenfyrdev/xpost:latest` 6 + 7 + ### Podman Quadlets 8 + 9 + Example Rootful Quadlet. Make sure the data dir exists on the host and is owned by `1000:1000`! 10 + 11 + ``` 12 + [Unit] 13 + Description=XPost 14 + 15 + [Container] 16 + Image=ghcr.io/zenfyrdev/xpost:latest 17 + EnvironmentFile=/etc/containers/systemd/xpost/.env 18 + Volume=/var/containers/xpost/data:/app/data:Z 19 + 20 + [Service] 21 + Restart=always 22 + RestartSec=10s 23 + 24 + [Install] 25 + WantedBy=default.target 26 + ``` 27 + 28 + ### Docker Compose 29 + 30 + Make sure the data dir exists on the host and is owned by `1000:1000`! 31 + 32 + ``` 33 + services: 34 + xpost: 35 + image: ghcr.io/zenfyrdev/xpost:latest 36 + restart: unless-stopped 37 + env_file: ./.env 38 + volumes: 39 + - ./data:/app/data 40 + ``` 41 + 42 + ### Native Install 43 + 44 + The project uses uv, so a native install using a `.venv` is pretty simple. 45 + 46 + 1. Make sure that `ffmpeg` and `libmagic` are installed. 47 + 2. Download and install [uv](https://github.com/astral-sh/uv) 48 + 3. Clone the project `https://tangled.org/zenfyr.dev/xpost` 49 + 4. Run `uv sync --locked` 50 + 51 + 52 + ## Quickstart 53 + 54 + Upon first launch, XPost will create a folder `./data` with an example `settings.json` 55 + 56 + Edit the file, and then start XPost again. 57 + 58 + ## Features 59 + 60 + - Based on streaming APIs to instantly crosspost new posts. 61 + - Splitting large posts into multiple smaller ones to fit into character limits. 62 + - Supports quotes and reposts. 63 + 64 + 65 + ## Configuration 66 + 67 + XPost config accepts an array of input -> outputs pairs. find all services and example configs in [services.md](./services.md). 68 + 69 + All `"key": "value"` options can be set to `env:VARIABLE` to read envvars instead of storing things directly in the settings file. 70 + 71 + Additionally XPost accepts some envvars. 72 + 73 + | Key | Description | 74 + |-----------------|---------------------------------------------------------------------------------------------------------------| 75 + | `DATA_DIR` | Base data directory. | 76 + | `SETTINGS_DIR` | `settings.json` file location. | 77 + | `DATABASE_DIR` | `data.db` file location. | 78 + | `SLINGSHOT_URL` | URL of the [microcosm](https://www.microcosm.blue/) slingshot service for resolving identities. | 79 + | `JETSTREAM_URL` | URL of the [Jetstream](https://github.com/bluesky-social/jetstream) service for listening for incoming posts. | 80 + 81 + ## Advanced Features 82 + 83 + ### bi-directional crossposting 84 + 85 + **This is experimental and unstable.** 86 + 87 + XPost supports pointing to services directly at each other, posts crossposted from other services are marked as so, and are skipped to avoid creating infinite loops. 88 + 89 + While this works for most things, cross-service replies can end up being duplicated multiple times over or cause an infinite loop, this usually happens when a post from one service is split into multiple other on the other. 90 + 91 + e.g. Post A from Mastodon ends up as Post A1 and Post A2 on bsky, replying from bsky to either may result in Reply B being posted twice, or getting stuck in a loop.
+162
docs/services.md
··· 1 + # Services 2 + 3 + ## Input 4 + 5 + Input services ingest data from other websites. 6 + 7 + ### Bluesky Jetstream 8 + 9 + This service uses a [Jetstream](https://github.com/bluesky-social/jetstream) to listen for posts. 10 + 11 + ``` 12 + { 13 + "services": [ 14 + { 15 + "input": { 16 + "type": "bluesky-jetstream", 17 + "handle": "bsky.app" 18 + }, 19 + "outputs": [] 20 + } 21 + ] 22 + } 23 + ``` 24 + 25 + | Key | Description | 26 + |----------|-----------------------------------------------------------------------| 27 + | `handle` | Account handle. Used to resolve `did` and `pds`. | 28 + | `did` | Account identifier. Can be specified instead of a `handle`. | 29 + | `pds` | Account host. Optional, will be resolved from `did` if not specified. | 30 + 31 + ### Mastodon WebSocket 32 + 33 + Uses a WebSocket to listen to the home timeline. 34 + 35 + ``` 36 + { 37 + "services": [ 38 + { 39 + "input": { 40 + "type": "mastodon-wss", 41 + "instance": "https://mastodon.social", 42 + "token": "***" 43 + }, 44 + "outputs": [] 45 + } 46 + ] 47 + } 48 + ``` 49 + 50 + | Key | Description | 51 + |----------------------|--------------------------------------------------------| 52 + | `instance` | Account host. | 53 + | `token` | Account access token. | 54 + | `allowed_visibility` | Post visibilities that ware allowed to be crossposted. | 55 + 56 + #### Getting a token 57 + 58 + **Mastodon:** 59 + 60 + - Go to Settings -> Development 61 + - Click "New Application" 62 + - Set a name (e.g. xpost), allow "read", "write", "profile" perms. 63 + - Click on the new application and copy "Your access token" 64 + 65 + **Non-Mastodon** 66 + 67 + Software like iceshrimp/akkoma can either use https://getauth.thms.uk/?client_name=xpost&scopes=read%20write%20profile or get the token using dev tools on any web client. (any `/api/v*` request, the `authorization` header, copy the value besides `Bearer `) 68 + 69 + ### Misskey WebSocket 70 + 71 + Uses a WebSocket to listen to the home timeline channel. 72 + 73 + > [!NOTE] 74 + > Misskey WSS doesn't support deletes, crossposted posts have to be manually deleted (or look into [bi-directional](./README.md#bi-directional-crossposting) crossposting) 75 + 76 + ``` 77 + { 78 + "services": [ 79 + { 80 + "input": { 81 + "type": "misskey-wss", 82 + "instance": "https://misskey.io", 83 + "token": "***" 84 + }, 85 + "outputs": [] 86 + } 87 + ] 88 + } 89 + ``` 90 + 91 + | Key | Description | 92 + |----------------------|--------------------------------------------------------| 93 + | `instance` | Account host. | 94 + | `token` | Account access token. | 95 + | `allowed_visibility` | Post visibilities that ware allowed to be crossposted. | 96 + 97 + #### Getting a token 98 + 99 + Use Dev Tools 💔 100 + 101 + ## Output 102 + 103 + ### Bluesky 104 + 105 + ``` 106 + { 107 + "services": [ 108 + { 109 + "input": {}, 110 + "outputs": [ 111 + { 112 + "type": "bluesky", 113 + "handle": "bsky.app", 114 + "password": "***" 115 + } 116 + ] 117 + } 118 + ] 119 + } 120 + ``` 121 + 122 + | Key | Description | 123 + |---------------|---------------------------------------------------------------------------------------------------------| 124 + | `handle` | Account handle. Used to resolve `did` and `pds`. | 125 + | `did` | Account identifier. Can be specified instead of a `handle`. | 126 + | `pds` | Account host. Optional, will be resolved from `did` if not specified. | 127 + | `password` | Account App Password. | 128 + | `quote_gate` | Disable ability for others to quote. | 129 + | `thread_gate` | Limit replies to the post. null - everybody, [] - nobody. accepts "mentioned", "following", "followers" | 130 + 131 + #### App Password 132 + 133 + Please do not use the main password. 134 + 135 + - Go to Settings -> Privacy and Security -> App Passwords 136 + - Click "Add App Password" 137 + - Copy the new password (it will not be shown again!) 138 + 139 + ### Mastodon 140 + 141 + ``` 142 + { 143 + "services": [ 144 + { 145 + "input": {}, 146 + "outputs": [ 147 + { 148 + "type": "mastodon", 149 + "instance": "https://mastodon.social", 150 + "token": "***" 151 + } 152 + ] 153 + } 154 + ] 155 + } 156 + ``` 157 + 158 + | Key | Description | 159 + |--------------|--------------------------------------------------------| 160 + | `instance` | Account host. | 161 + | `token` | Account access token. | 162 + | `visibility` | What visibility to set for crossposted posts |
+18
env.py
··· 1 + import os 2 + from pathlib import Path 3 + 4 + 5 + DEV = bool(os.environ.get("DEV")) or False 6 + 7 + DATA_DIR = Path(os.environ.get("DATA_DIR") or "./data") 8 + SETTINGS_DIR = Path( 9 + os.environ.get("SETTINGS_DIR") or DATA_DIR.joinpath("settings.json") 10 + ) 11 + DATABASE_DIR = Path(os.environ.get("DATABASE_DIR") or DATA_DIR.joinpath("data.db")) 12 + 13 + MIGRATIONS_DIR = Path(os.environ.get("MIGRATIONS_DIR") or "./migrations") 14 + 15 + SLINGSHOT_URL = os.environ.get("SLINGSHOT_URL") or "https://slingshot.microcosm.blue" 16 + JETSTREAM_URL = ( 17 + os.environ.get("JETSTREAM_URL") or "wss://jetstream2.us-west.bsky.network/subscribe" 18 + )
+166
main.py
··· 1 + import argparse 2 + import asyncio 3 + import json 4 + import queue 5 + import threading 6 + from collections.abc import Callable 7 + from typing import Any 8 + 9 + import env 10 + from database.connection import DatabasePool 11 + from database.migrations import DatabaseMigrator 12 + from util.util import LOGGER, read_env, shutdown_hook 13 + 14 + 15 + EXAMPLE_CONFIG = { 16 + "services": [ 17 + { 18 + "input": {"type": "bluesky-jetstream", "handle": "bsky.app"}, 19 + "outputs": [ 20 + { 21 + "type": "mastodon", 22 + "instance": "https://mastodon.social", 23 + "token": "env:MASTODON_TOKEN", 24 + } 25 + ], 26 + } 27 + ] 28 + } 29 + 30 + 31 + def dump_example_config() -> None: 32 + env.SETTINGS_DIR.parent.mkdir(parents=True, exist_ok=True) 33 + with open(env.SETTINGS_DIR, "w") as f: 34 + json.dump(EXAMPLE_CONFIG, f, indent=2) 35 + 36 + 37 + def flush_caches() -> None: 38 + from atproto.store import flush_caches as flush_atproto_caches 39 + from atproto.store import get_store 40 + 41 + db_pool = DatabasePool(env.DATABASE_DIR) 42 + get_store(db_pool) 43 + 44 + LOGGER.info("Flushing atproto caches...") 45 + sessions, identities = flush_atproto_caches() 46 + LOGGER.info("Flushed %d sessions and %d identities", sessions, identities) 47 + LOGGER.info("Cache flush complete!") 48 + 49 + db_pool.close() 50 + 51 + 52 + def main() -> None: 53 + parser = argparse.ArgumentParser( 54 + description="xpost: social media crossposting tool" 55 + ) 56 + parser.add_argument( 57 + "--flush-caches", 58 + action="store_true", 59 + help="Flush all caches (like sessions and identities)", 60 + ) 61 + args = parser.parse_args() 62 + 63 + if args.flush_caches: 64 + flush_caches() 65 + return 66 + 67 + if not env.DATA_DIR.exists(): 68 + env.DATA_DIR.mkdir(parents=True) 69 + 70 + if not env.SETTINGS_DIR.exists(): 71 + LOGGER.info("First launch detected! Creating %s and exiting!", env.SETTINGS_DIR) 72 + dump_example_config() 73 + LOGGER.info("Example config written to %s", env.SETTINGS_DIR) 74 + LOGGER.info("Please edit the config file and run again!") 75 + return 76 + 77 + migrator = DatabaseMigrator(env.DATABASE_DIR, env.MIGRATIONS_DIR) 78 + try: 79 + migrator.migrate() 80 + except Exception: 81 + LOGGER.exception("Failed to migrate database!") 82 + return 83 + finally: 84 + migrator.close() 85 + 86 + db_pool = DatabasePool(env.DATABASE_DIR) 87 + import httpx 88 + 89 + http_client = httpx.Client(timeout=httpx.Timeout(30)) 90 + 91 + LOGGER.info("Bootstrapping registries...") 92 + from registry import create_input_service, create_output_service 93 + from registry_bootstrap import bootstrap 94 + 95 + bootstrap() 96 + 97 + LOGGER.info("Loading settings...") 98 + 99 + with open(env.SETTINGS_DIR) as f: 100 + settings = json.load(f) 101 + read_env(settings) 102 + 103 + if "services" not in settings: 104 + raise KeyError("No `services` specified in settings!") 105 + 106 + service_pairs: list[tuple[Any, list[Any]]] = [] 107 + for svc in settings["services"]: 108 + if "input" not in svc: 109 + raise KeyError("Each service must have an `input` field!") 110 + if "outputs" not in svc: 111 + raise KeyError("Each service must have an `outputs` field!") 112 + 113 + inp = create_input_service(db_pool, http_client, svc["input"]) 114 + outs = [ 115 + create_output_service(db_pool, http_client, data) for data in svc["outputs"] 116 + ] 117 + service_pairs.append((inp, outs)) 118 + 119 + LOGGER.info("Starting task worker...") 120 + 121 + def worker(task_queue: queue.Queue[Callable[[], None] | None]): 122 + while True: 123 + task = task_queue.get() 124 + if task is None: 125 + break 126 + 127 + try: 128 + task() 129 + except Exception: 130 + LOGGER.exception("Exception in worker thread!") 131 + finally: 132 + task_queue.task_done() 133 + 134 + task_queue: queue.Queue[Callable[[], None] | None] = queue.Queue() 135 + thread = threading.Thread(target=worker, args=(task_queue,), daemon=True) 136 + thread.start() 137 + 138 + for inp, outs in service_pairs: 139 + inp.outputs = outs 140 + inp.submitter = lambda c: task_queue.put(c) 141 + 142 + inputs = [inp for inp, _ in service_pairs] 143 + LOGGER.info("Starting %d input service(s)...", len(inputs)) 144 + try: 145 + asyncio.run(_run_all_inputs(inputs)) 146 + except KeyboardInterrupt: 147 + LOGGER.info("Stopping...") 148 + 149 + task_queue.join() 150 + task_queue.put(None) 151 + thread.join() 152 + 153 + for shook in shutdown_hook: 154 + shook() 155 + 156 + db_pool.close() 157 + http_client.close() 158 + 159 + 160 + async def _run_all_inputs(inputs: list[Any]) -> None: 161 + tasks = [asyncio.create_task(inp.listen()) for inp in inputs] 162 + await asyncio.gather(*tasks, return_exceptions=True) 163 + 164 + 165 + if __name__ == "__main__": 166 + main()
mastodon/__init__.py

This is a binary file and will not be displayed.

+143
mastodon/info.py
··· 1 + from abc import ABC, abstractmethod 2 + from dataclasses import dataclass 3 + from typing import Any, override 4 + 5 + import httpx 6 + 7 + from cross.service import Service 8 + from cross.tokens import LinkToken, MentionToken, TagToken 9 + from database.connection import DatabasePool 10 + from util.html import HTMLToTokensParser 11 + from util.util import normalize_service_url 12 + 13 + 14 + def validate_and_transform(data: dict[str, Any]): 15 + if "token" not in data or "instance" not in data: 16 + raise KeyError("Missing required values 'token' or 'instance'") 17 + 18 + data["instance"] = normalize_service_url(data["instance"]) 19 + 20 + 21 + @dataclass(kw_only=True) 22 + class InstanceInfo: 23 + max_characters: int = 500 24 + max_media_attachments: int = 4 25 + characters_reserved_per_url: int = 23 26 + 27 + image_size_limit: int = 16777216 28 + video_size_limit: int = 103809024 29 + 30 + text_format: str = "text/plain" 31 + 32 + @classmethod 33 + def from_api(cls, data: dict[str, Any]) -> "InstanceInfo": 34 + config: dict[str, Any] = {} 35 + 36 + if "statuses" in data: 37 + statuses_config: dict[str, Any] = data.get("statuses", {}) 38 + if "max_characters" in statuses_config: 39 + config["max_characters"] = statuses_config["max_characters"] 40 + if "max_media_attachments" in statuses_config: 41 + config["max_media_attachments"] = statuses_config[ 42 + "max_media_attachments" 43 + ] 44 + if "characters_reserved_per_url" in statuses_config: 45 + config["characters_reserved_per_url"] = statuses_config[ 46 + "characters_reserved_per_url" 47 + ] 48 + 49 + # glitch content type 50 + if "supported_mime_types" in statuses_config: 51 + text_mimes: list[str] = statuses_config["supported_mime_types"] 52 + 53 + if "text/x.misskeymarkdown" in text_mimes: 54 + config["text_format"] = "text/x.misskeymarkdown" 55 + elif "text/markdown" in text_mimes: 56 + config["text_format"] = "text/markdown" 57 + 58 + if "media_attachments" in data: 59 + media_config: dict[str, Any] = data["media_attachments"] 60 + if "image_size_limit" in media_config: 61 + config["image_size_limit"] = media_config["image_size_limit"] 62 + if "video_size_limit" in media_config: 63 + config["video_size_limit"] = media_config["video_size_limit"] 64 + 65 + # *oma extensions 66 + if "max_toot_chars" in data: 67 + config["max_characters"] = data["max_toot_chars"] 68 + if "upload_limit" in data: 69 + config["image_size_limit"] = data["upload_limit"] 70 + config["video_size_limit"] = data["upload_limit"] 71 + 72 + if "pleroma" in data: 73 + pleroma: dict[str, Any] = data["pleroma"] 74 + if "metadata" in pleroma: 75 + metadata: dict[str, Any] = pleroma["metadata"] 76 + if "post_formats" in metadata: 77 + post_formats: list[str] = metadata["post_formats"] 78 + 79 + if "text/x.misskeymarkdown" in post_formats: 80 + config["text_format"] = "text/x.misskeymarkdown" 81 + elif "text/markdown" in post_formats: 82 + config["text_format"] = "text/markdown" 83 + 84 + return InstanceInfo(**config) 85 + 86 + 87 + class MastodonService(ABC, Service): 88 + def __init__(self, url: str, db: DatabasePool, http: httpx.Client) -> None: 89 + super().__init__(url, db) 90 + self.http = http 91 + 92 + def verify_credentials(self): 93 + token = self._get_token() 94 + response = self.http.get( 95 + f"{self.url}/api/v1/accounts/verify_credentials", 96 + headers={"Authorization": f"Bearer {token}"}, 97 + ) 98 + if response.status_code != 200: 99 + self.log.error("Failed to validate user credentials!") 100 + response.raise_for_status() 101 + return dict(response.json()) 102 + 103 + def fetch_instance_info(self): 104 + token = self._get_token() 105 + responce = self.http.get( 106 + f"{self.url}/api/v1/instance", 107 + headers={"Authorization": f"Bearer {token}"}, 108 + ) 109 + if responce.status_code != 200: 110 + self.log.error("Failed to get instance info!") 111 + responce.raise_for_status() 112 + return dict(responce.json()) 113 + 114 + @abstractmethod 115 + def _get_token(self) -> str: 116 + pass 117 + 118 + 119 + class StatusParser(HTMLToTokensParser): 120 + def __init__(self, status: dict[str, Any]) -> None: 121 + super().__init__() 122 + self.tags: set[str] = {tag["url"] for tag in status.get("tags", [])} 123 + self.mentions: set[str] = {m["url"] for m in status.get("mentions", [])} 124 + 125 + @override 126 + def handle_a_endtag(self): 127 + label, _attr = self._tag_stack.pop("a") 128 + 129 + href = _attr.get("href") 130 + if href: 131 + cls = _attr.get("class", "") 132 + if cls: 133 + if "hashtag" in cls and href in self.tags: 134 + tag = label[1:] if label.startswith("#") else label 135 + 136 + self.tokens.append(TagToken(tag=tag)) 137 + return 138 + if "mention" in cls and href in self.mentions: 139 + username = label[1:] if label.startswith("@") else label 140 + 141 + self.tokens.append(MentionToken(username=username, uri=href)) 142 + return 143 + self.tokens.append(LinkToken(href=href, label=label))
+281
mastodon/input.py
··· 1 + import asyncio 2 + import json 3 + import re 4 + from dataclasses import dataclass, field 5 + from typing import Any, cast, override 6 + 7 + import httpx 8 + import websockets 9 + 10 + from cross.attachments import ( 11 + LabelsAttachment, 12 + LanguagesAttachment, 13 + MediaAttachment, 14 + QuoteAttachment, 15 + RemoteUrlAttachment, 16 + SensitiveAttachment, 17 + ) 18 + from cross.media import Blob, download_blob 19 + from cross.post import Post, PostRef 20 + from cross.service import InputService 21 + from database.connection import DatabasePool 22 + from mastodon.info import MastodonService, StatusParser, validate_and_transform 23 + 24 + 25 + ALLOWED_VISIBILITY: list[str] = ["public", "unlisted"] 26 + 27 + 28 + @dataclass(kw_only=True) 29 + class MastodonInputOptions: 30 + token: str 31 + instance: str 32 + allowed_visibility: list[str] = field( 33 + default_factory=lambda: ALLOWED_VISIBILITY.copy() 34 + ) 35 + filters: list[re.Pattern[str]] = field(default_factory=lambda: []) 36 + 37 + @classmethod 38 + def from_dict(cls, data: dict[str, Any]) -> "MastodonInputOptions": 39 + validate_and_transform(data) 40 + 41 + if "allowed_visibility" in data: 42 + for vis in data.get("allowed_visibility", []): 43 + if vis not in ALLOWED_VISIBILITY: 44 + raise ValueError(f"Invalid visibility option {vis}!") 45 + 46 + if "filters" in data: 47 + data["filters"] = [re.compile(r) for r in data["filters"]] 48 + 49 + return MastodonInputOptions(**data) 50 + 51 + 52 + class MastodonInputService(MastodonService, InputService): 53 + def __init__( 54 + self, db: DatabasePool, http: httpx.Client, options: MastodonInputOptions 55 + ) -> None: 56 + super().__init__(options.instance, db, http) 57 + self.options: MastodonInputOptions = options 58 + 59 + self.log.info("Verifying %s credentails...", self.url) 60 + response = self.verify_credentials() 61 + self.user_id: str = response["id"] 62 + 63 + self.log.info("Getting %s configuration...", self.url) 64 + response = self.fetch_instance_info() 65 + self.streaming_url: str = response["urls"]["streaming_api"] 66 + 67 + @override 68 + def _get_token(self) -> str: 69 + return self.options.token 70 + 71 + def _on_create_post(self, status: dict[str, Any]): 72 + self.log.info("Processing new post: %s", status["id"]) 73 + 74 + if status["account"]["id"] != self.user_id: 75 + return 76 + 77 + if status["visibility"] not in self.options.allowed_visibility: 78 + self.log.info( 79 + "Skipping post with disallowed visibility: %s (%s)", 80 + status["id"], 81 + status["visibility"], 82 + ) 83 + return 84 + 85 + if self._is_post_crossposted(self.url, self.user_id, status["id"]): 86 + self.log.info( 87 + "Skipping %s, already crossposted", 88 + status["id"], 89 + ) 90 + return 91 + 92 + reblog: dict[str, Any] | None = status.get("reblog") 93 + if reblog: 94 + if reblog["account"]["id"] != self.user_id: 95 + return 96 + self._on_reblog(status, reblog) 97 + return 98 + 99 + if status.get("poll"): 100 + self.log.info("Skipping '%s'! Contains a poll..", status["id"]) 101 + return 102 + 103 + quote: dict[str, Any] | None = status.get("quote") 104 + if quote: 105 + quote = quote["quoted_status"] if quote.get("quoted_status") else quote 106 + if not quote or quote["account"]["id"] != self.user_id: 107 + return 108 + 109 + rquote = self._get_post(self.url, self.user_id, quote["id"]) 110 + if not rquote: 111 + self.log.info( 112 + "Skipping %s, parent %s not found in db", status["id"], quote["id"] 113 + ) 114 + return 115 + 116 + in_reply: str | None = status.get("in_reply_to_id") 117 + in_reply_to: str | None = status.get("in_reply_to_account_id") 118 + if in_reply_to and in_reply_to != self.user_id: 119 + return 120 + 121 + parent = None 122 + if in_reply: 123 + parent = self._get_post(self.url, self.user_id, in_reply) 124 + if not parent: 125 + self.log.info( 126 + "Skipping %s, parent %s not found in db", status["id"], in_reply 127 + ) 128 + return 129 + parser = StatusParser(status) 130 + parser.feed(status["content"]) 131 + tokens = parser.get_result() 132 + 133 + post = Post( 134 + id=status["id"], 135 + author=self.user_id, 136 + service=self.url, 137 + parent_id=in_reply, 138 + tokens=tokens, 139 + text_type="text/html", 140 + ) 141 + 142 + if quote: 143 + post.attachments.put( 144 + QuoteAttachment(quoted_id=quote["id"], quoted_user=self.user_id) 145 + ) 146 + if status.get("url"): 147 + post.attachments.put(RemoteUrlAttachment(url=status["url"])) 148 + if status.get("sensitive"): 149 + post.attachments.put(SensitiveAttachment(sensitive=True)) 150 + if status.get("language"): 151 + post.attachments.put(LanguagesAttachment(langs=[status["language"]])) 152 + if status.get("spoiler_text"): 153 + post.attachments.put(LabelsAttachment(labels=[status["spoiler_text"]])) 154 + 155 + blobs: list[Blob] = [] 156 + for media in status.get("media_attachments", []): 157 + self.log.info("Downloading %s...", media["url"]) 158 + blob: Blob | None = download_blob( 159 + media["url"], media.get("alt"), client=self.http 160 + ) 161 + if not blob: 162 + self.log.error( 163 + "Skipping %s! Failed to download media %s.", 164 + status["id"], 165 + media["url"], 166 + ) 167 + return 168 + blobs.append(blob) 169 + 170 + if blobs: 171 + post.attachments.put(MediaAttachment(blobs=blobs)) 172 + 173 + if parent: 174 + self._insert_post( 175 + { 176 + "user": self.user_id, 177 + "service": self.url, 178 + "identifier": status["id"], 179 + "parent": parent["id"], 180 + "root": parent["id"] if not parent["root"] else parent["root"], 181 + } 182 + ) 183 + else: 184 + self._insert_post( 185 + { 186 + "user": self.user_id, 187 + "service": self.url, 188 + "identifier": status["id"], 189 + } 190 + ) 191 + 192 + self.log.info("Post stored in DB: %s", status["id"]) 193 + 194 + for out in self.outputs: 195 + self.submitter(lambda: out.accept_post(post)) 196 + 197 + def _on_reblog(self, status: dict[str, Any], reblog: dict[str, Any]): 198 + self.log.info("Processing reblog: %s", status["id"]) 199 + reposted = self._get_post(self.url, self.user_id, reblog["id"]) 200 + if not reposted: 201 + self.log.info( 202 + "Skipping repost '%s' as reposted post '%s' was not found in the db.", 203 + status["id"], 204 + reblog["id"], 205 + ) 206 + return 207 + 208 + self._insert_post( 209 + { 210 + "user": self.user_id, 211 + "service": self.url, 212 + "identifier": status["id"], 213 + "reposted": reposted["id"], 214 + } 215 + ) 216 + 217 + self.log.info("Reblog stored in DB: %s", status["id"]) 218 + 219 + repost_ref = PostRef(id=status["id"], author=self.user_id, service=self.url) 220 + reposted_ref = PostRef(id=reblog["id"], author=self.user_id, service=self.url) 221 + for out in self.outputs: 222 + self.submitter(lambda: out.accept_repost(repost_ref, reposted_ref)) 223 + 224 + def _on_delete_post(self, status_id: str): 225 + self.log.info("Processing delete for %s...", status_id) 226 + post = self._get_post(self.url, self.user_id, status_id) 227 + if not post: 228 + self.log.warning("Post not found in DB: %s", status_id) 229 + return 230 + 231 + post_ref = PostRef(id=status_id, author=self.user_id, service=self.url) 232 + if post["reposted"]: 233 + self.log.info("Deleting repost: %s", status_id) 234 + for output in self.outputs: 235 + self.submitter(lambda: output.delete_repost(post_ref)) 236 + else: 237 + self.log.info("Deleting post: %s", status_id) 238 + for output in self.outputs: 239 + self.submitter(lambda: output.delete_post(post_ref)) 240 + self.submitter(lambda: self._delete_post_by_id(post["id"])) 241 + self.log.info("Delete processed successfully for %s", status_id) 242 + 243 + def _accept_msg(self, msg: websockets.Data) -> None: 244 + data: dict[str, Any] = cast(dict[str, Any], json.loads(msg)) 245 + event: str = cast(str, data["event"]) 246 + payload: str = cast(str, data["payload"]) 247 + 248 + if event == "update": 249 + self._on_create_post(json.loads(payload)) 250 + elif event == "delete": 251 + self._on_delete_post(payload) 252 + 253 + @override 254 + async def listen(self): 255 + url = f"{self.streaming_url}/api/v1/streaming?stream=user" 256 + 257 + async for ws in websockets.connect( 258 + url, 259 + additional_headers={"Authorization": f"Bearer {self.options.token}"}, 260 + ping_interval=20, 261 + ping_timeout=10, 262 + close_timeout=5, 263 + ): 264 + try: 265 + self.log.info("Listening to %s...", self.streaming_url) 266 + 267 + async def listen_for_messages(): 268 + async for msg in ws: 269 + self.submitter(lambda: self._accept_msg(msg)) 270 + 271 + listen = asyncio.create_task(listen_for_messages()) 272 + 273 + _ = await asyncio.gather(listen) 274 + except websockets.ConnectionClosedError as e: 275 + self.log.error(e, stack_info=True, exc_info=True) 276 + self.log.info("Reconnecting to %s...", self.streaming_url) 277 + continue 278 + except TimeoutError as e: 279 + self.log.error("Connection timeout: %s", e) 280 + self.log.info("Reconnecting to %s...", self.streaming_url) 281 + continue
+567
mastodon/output.py
··· 1 + import time 2 + from dataclasses import dataclass 3 + from typing import Any, override 4 + 5 + import httpx 6 + 7 + import misskey.mfm as mfm 8 + from cross.attachments import ( 9 + LanguagesAttachment, 10 + MediaAttachment, 11 + QuoteAttachment, 12 + RemoteUrlAttachment, 13 + SensitiveAttachment, 14 + ) 15 + from cross.media import Blob 16 + from cross.post import Post, PostRef 17 + from cross.service import OutputService 18 + from cross.tokens import LinkToken, TagToken, TextToken, Token 19 + from database.connection import DatabasePool 20 + from mastodon.info import InstanceInfo, MastodonService, validate_and_transform 21 + from util.splitter import TokenSplitter, canonical_label 22 + 23 + 24 + ALLOWED_POSTING_VISIBILITY: list[str] = ["public", "unlisted", "private"] 25 + TEXT_MIMES: list[str] = ["text/x.misskeymarkdown", "text/markdown", "text/plain"] 26 + 27 + 28 + @dataclass(kw_only=True) 29 + class MastodonOutputOptions: 30 + token: str 31 + instance: str 32 + visibility: str = "public" 33 + 34 + @classmethod 35 + def from_dict(cls, data: dict[str, Any]) -> "MastodonOutputOptions": 36 + validate_and_transform(data) 37 + 38 + if ( 39 + "visibility" in data 40 + and data["visibility"] not in ALLOWED_POSTING_VISIBILITY 41 + ): 42 + raise ValueError(f"Invalid visibility option {data['visibility']}!") 43 + 44 + return MastodonOutputOptions(**data) 45 + 46 + 47 + @dataclass 48 + class MediaUploadResult: 49 + id: str 50 + processed: bool = False 51 + 52 + 53 + class MastodonOutputService(MastodonService, OutputService): 54 + def __init__( 55 + self, db: DatabasePool, http: httpx.Client, options: MastodonOutputOptions 56 + ) -> None: 57 + super().__init__(options.instance, db, http) 58 + self.options: MastodonOutputOptions = options 59 + 60 + self.log.info("Verifying %s credentails...", self.url) 61 + response = self.verify_credentials() 62 + self.user_id: str = response["id"] 63 + 64 + self.log.info("Getting %s configuration...", self.url) 65 + response = self.fetch_instance_info() 66 + self.instance_info: InstanceInfo = InstanceInfo.from_api(response) 67 + 68 + def _token_to_string(self, tokens: list[Token]) -> str | None: 69 + text: str = "" 70 + for token in tokens: 71 + match token: 72 + case TextToken(): 73 + text += token.text 74 + case TagToken(): 75 + text += f"#{token.tag}" 76 + case LinkToken(): 77 + if canonical_label(token.label, token.href): 78 + text += token.href 79 + else: 80 + if self.instance_info.text_format == "text/plain": 81 + if token.label: 82 + text += f"{token.label} ({token.href})" 83 + else: 84 + text += token.href 85 + elif self.instance_info.text_format in { 86 + "text/x.misskeymarkdown", 87 + "text/markdown", 88 + }: 89 + if token.label: 90 + text += f"[{token.label}]({token.href})" 91 + else: 92 + text += token.href 93 + else: 94 + return None 95 + return text 96 + 97 + def _split_tokens_and_media( 98 + self, 99 + tokens: list[Token], 100 + media: list[Blob], 101 + ) -> list[tuple[str, list[Blob]]] | None: 102 + splitter = TokenSplitter( 103 + max_chars=self.instance_info.max_characters, 104 + max_link_len=self.instance_info.characters_reserved_per_url, 105 + ) 106 + split_token_blocks = splitter.split(tokens) 107 + 108 + if split_token_blocks is None: 109 + return None 110 + 111 + post_texts: list[str] = [] 112 + for block in split_token_blocks: 113 + baked_text = self._token_to_string(block) 114 + if baked_text is None: 115 + return None 116 + post_texts.append(baked_text) 117 + 118 + if not post_texts: 119 + post_texts = [""] 120 + 121 + posts: list[dict[str, Any]] = [ 122 + {"text": text, "attachments": []} for text in post_texts 123 + ] 124 + available_indices: list[int] = list(range(len(posts))) 125 + current_image_post_idx: int | None = None 126 + # video_post_idx: int | None = None 127 + 128 + def make_blank_post() -> dict[str, Any]: 129 + return {"text": "", "attachments": []} 130 + 131 + def pop_next_empty_index() -> int: 132 + if available_indices: 133 + return available_indices.pop(0) 134 + new_idx = len(posts) 135 + posts.append(make_blank_post()) 136 + return new_idx 137 + 138 + for blob in media: 139 + if blob.mime.startswith(("video/", "audio/")): 140 + current_image_post_idx = None 141 + idx = pop_next_empty_index() 142 + posts[idx]["attachments"].append(blob) 143 + elif blob.mime.startswith("image/"): 144 + if ( 145 + current_image_post_idx is not None 146 + and len(posts[current_image_post_idx]["attachments"]) 147 + < self.instance_info.max_media_attachments 148 + ): 149 + posts[current_image_post_idx]["attachments"].append(blob) 150 + else: 151 + idx = pop_next_empty_index() 152 + posts[idx]["attachments"].append(blob) 153 + current_image_post_idx = idx 154 + 155 + result: list[tuple[str, list[Blob]]] = [] 156 + for p in posts: 157 + result.append((p["text"], p["attachments"])) 158 + return result 159 + 160 + def _upload_media(self, attachments: list[Blob]) -> list[str] | None: 161 + for blob in attachments: 162 + if ( 163 + blob.mime.startswith("image/") 164 + and len(blob.io) > self.instance_info.image_size_limit 165 + ): 166 + self.log.error( 167 + "Image too large: %s bytes (limit: %s)", 168 + len(blob.io), 169 + self.instance_info.image_size_limit, 170 + ) 171 + return None 172 + if ( 173 + blob.mime.startswith("video/") 174 + and len(blob.io) > self.instance_info.video_size_limit 175 + ): 176 + self.log.error( 177 + "Video too large: %s bytes (limit: %s)", 178 + len(blob.io), 179 + self.instance_info.video_size_limit, 180 + ) 181 + return None 182 + if ( 183 + not blob.mime.startswith(("image/", "video/")) 184 + and len(blob.io) > 7_000_000 185 + ): 186 + self.log.error("File too large: %s bytes", len(blob.io)) 187 + return None 188 + 189 + uploads: list[MediaUploadResult] = [] 190 + 191 + for blob in attachments: 192 + files = { 193 + "file": ( 194 + blob.name or "file", 195 + blob.io, 196 + blob.mime, 197 + ) 198 + } 199 + data = {} 200 + if blob.alt: 201 + data["description"] = blob.alt 202 + 203 + response = self.http.post( 204 + f"{self.url}/api/v2/media", 205 + headers={"Authorization": f"Bearer {self._get_token()}"}, 206 + files=files, 207 + data=data, 208 + ) 209 + 210 + if response.status_code == 200: 211 + self.log.info( 212 + "Uploaded %s! (%s)", blob.name or "unknown", response.json()["id"] 213 + ) 214 + uploads.append( 215 + MediaUploadResult(id=response.json()["id"], processed=True) 216 + ) 217 + elif response.status_code == 202: 218 + self.log.info("Waiting for %s to process!", blob.name or "unknown") 219 + uploads.append( 220 + MediaUploadResult(id=response.json()["id"], processed=False) 221 + ) 222 + else: 223 + self.log.error( 224 + "Failed to upload %s! %s", 225 + blob.name or "unknown", 226 + response.text, 227 + ) 228 + response.raise_for_status() 229 + 230 + while any(not result.processed for result in uploads): 231 + self.log.info("Waiting for media to process...") 232 + time.sleep(3) 233 + for media_result in uploads: 234 + if media_result.processed: 235 + continue 236 + response = self.http.get( 237 + f"{self.url}/api/v1/media/{media_result.id}", 238 + headers={"Authorization": f"Bearer {self._get_token()}"}, 239 + ) 240 + if response.status_code == 206: 241 + continue 242 + if response.status_code == 200: 243 + media_result.processed = True 244 + continue 245 + response.raise_for_status() 246 + 247 + return [result.id for result in uploads] 248 + 249 + @override 250 + def accept_post(self, post: Post): 251 + self.log.info( 252 + "Accepting post %s (author: %s, service: %s)...", 253 + post.id, 254 + post.author, 255 + post.service, 256 + ) 257 + new_root_id: int | None = None 258 + new_parent_id: int | None = None 259 + 260 + reply_ref: str | None = None 261 + if post.parent_id: 262 + thread = self._find_mapped_thread( 263 + post.parent_id, post.service, post.author, self.url, self.user_id 264 + ) 265 + if not thread: 266 + self.log.error("Failed to find thread tuple in the database!") 267 + return 268 + _, reply_ref, new_root_id, new_parent_id = thread 269 + 270 + quoted_status_id: str | None = None 271 + quote = post.attachments.get(QuoteAttachment) 272 + if quote: 273 + if quote.quoted_user != post.author: 274 + self.log.info("Quoted other user, skipping!") 275 + return 276 + 277 + quoted_post = self._get_post(post.service, post.author, quote.quoted_id) 278 + if not quoted_post: 279 + self.log.error("Failed to find quoted post in the database!") 280 + return 281 + 282 + quoted_mappings = self._get_mappings( 283 + quoted_post["id"], self.url, self.user_id 284 + ) 285 + if not quoted_mappings: 286 + self.log.error("Failed to find mappings for quoted post!") 287 + return 288 + 289 + quoted_status_id = quoted_mappings[-1]["identifier"] 290 + 291 + post_tokens = post.tokens 292 + if ( 293 + post.text_type == "text/x.misskeymarkdown" 294 + and self.instance_info.text_format != "text/x.misskeymarkdown" 295 + ): 296 + post_tokens, status = mfm.strip_mfm(post_tokens) 297 + remote_url = post.attachments.get(RemoteUrlAttachment) 298 + if status and remote_url and remote_url.url: 299 + post_tokens.append(TextToken(text="\n")) 300 + post_tokens.append( 301 + LinkToken( 302 + href=remote_url.url, label="[Post contains MFM, see original]" 303 + ) 304 + ) 305 + 306 + lang = "en" 307 + langs = post.attachments.get(LanguagesAttachment) 308 + if langs and langs.langs: 309 + lang = langs.langs[0] 310 + 311 + sensitive = post.attachments.get(SensitiveAttachment) 312 + 313 + media_attachment = post.attachments.get(MediaAttachment) 314 + media_blobs = media_attachment.blobs if media_attachment else [] 315 + 316 + raw_statuses = self._split_tokens_and_media(post_tokens, media_blobs) 317 + if not raw_statuses: 318 + self.log.error("Failed to split post into statuses!") 319 + return 320 + 321 + baked_statuses: list[tuple[str, list[str] | None]] = [] 322 + for status_text, raw_media in raw_statuses: 323 + media_ids: list[str] | None = None 324 + if raw_media: 325 + media_ids = self._upload_media(raw_media) 326 + if not media_ids: 327 + self.log.error("Failed to upload attachments!") 328 + return 329 + baked_statuses.append((status_text, media_ids)) 330 + 331 + created_statuses: list[str] = [] 332 + payload_sensitive = sensitive.sensitive if sensitive else False 333 + 334 + for i, (status_text, media_ids) in enumerate(baked_statuses): 335 + payload: dict[str, Any] = { 336 + "status": status_text or "", 337 + "media_ids": media_ids or [], 338 + "visibility": self.options.visibility, 339 + "content_type": self.instance_info.text_format, 340 + "language": lang, 341 + } 342 + 343 + if media_ids or (sensitive and sensitive.sensitive): 344 + payload["sensitive"] = payload_sensitive 345 + 346 + if sensitive and sensitive.sensitive: 347 + payload["sensitive"] = True 348 + 349 + if reply_ref and i == 0: 350 + payload["in_reply_to_id"] = reply_ref 351 + 352 + if quoted_status_id and i == 0: 353 + payload["quoted_status_id"] = quoted_status_id 354 + 355 + response = self.http.post( 356 + f"{self.url}/api/v1/statuses", 357 + headers={ 358 + "Authorization": f"Bearer {self._get_token()}", 359 + "Content-Type": "application/json", 360 + }, 361 + json=payload, 362 + ) 363 + 364 + if response.status_code != 200: 365 + self.log.error( 366 + "Failed to post status! %s - %s", 367 + response.status_code, 368 + response.text, 369 + ) 370 + response.raise_for_status() 371 + 372 + status_id = response.json()["id"] 373 + self.log.info("Created new status %s!", status_id) 374 + created_statuses.append(status_id) 375 + 376 + if i == 0: 377 + reply_ref = status_id 378 + 379 + db_post = self._get_post(post.service, post.author, post.id) 380 + if not db_post: 381 + self.log.error("Post not found in database!") 382 + return 383 + 384 + if new_root_id is None or new_parent_id is None: 385 + self._insert_post( 386 + { 387 + "user": self.user_id, 388 + "service": self.url, 389 + "identifier": created_statuses[0], 390 + "parent": None, 391 + "root": None, 392 + "reposted": None, 393 + "extra_data": None, 394 + "crossposted": 1, 395 + } 396 + ) 397 + new_post = self._get_post(self.url, self.user_id, created_statuses[0]) 398 + if not new_post: 399 + raise ValueError("Inserted post not found!") 400 + new_root_id = new_post["id"] 401 + new_parent_id = new_root_id 402 + 403 + self._insert_post_mapping(db_post["id"], new_parent_id) 404 + 405 + for status_id in created_statuses[1:]: 406 + self._insert_post( 407 + { 408 + "user": self.user_id, 409 + "service": self.url, 410 + "identifier": status_id, 411 + "parent": new_parent_id, 412 + "root": new_root_id, 413 + "reposted": None, 414 + "extra_data": None, 415 + "crossposted": 1, 416 + } 417 + ) 418 + reply_post = self._get_post(self.url, self.user_id, status_id) 419 + if not reply_post: 420 + raise ValueError("Inserted reply post not found!") 421 + new_parent_id = reply_post["id"] 422 + self._insert_post_mapping(db_post["id"], new_parent_id) 423 + 424 + self.log.info("Post accepted successfully: %s -> %s", post.id, created_statuses) 425 + 426 + @override 427 + def delete_post(self, post: PostRef): 428 + self.log.info( 429 + "Deleting post %s (author: %s, service: %s)...", 430 + post.id, 431 + post.author, 432 + post.service, 433 + ) 434 + db_post = self._get_post(post.service, post.author, post.id) 435 + if not db_post: 436 + self.log.warning( 437 + "Post not found in DB: %s (author: %s, service: %s)", 438 + post.id, 439 + post.author, 440 + post.service, 441 + ) 442 + return 443 + 444 + mappings = self._get_mappings(db_post["id"], self.url, self.user_id) 445 + 446 + for mapping in mappings[::-1]: 447 + self.log.info("Deleting '%s'...", mapping["identifier"]) 448 + self.http.delete( 449 + f"{self.url}/api/v1/statuses/{mapping['identifier']}", 450 + headers={"Authorization": f"Bearer {self._get_token()}"}, 451 + ) 452 + self._delete_post_by_id(mapping["id"]) 453 + self.log.info("Post deleted successfully: %s", post.id) 454 + 455 + @override 456 + def accept_repost(self, repost: PostRef, reposted: PostRef): 457 + self.log.info( 458 + "Accepting repost %s of %s (author: %s, service: %s)...", 459 + repost.id, 460 + reposted.id, 461 + repost.author, 462 + repost.service, 463 + ) 464 + original = self._get_post(reposted.service, reposted.author, reposted.id) 465 + if not original: 466 + self.log.info("Post not found in db, skipping repost..") 467 + return 468 + 469 + mappings = self._get_mappings(original["id"], self.url, self.user_id) 470 + if not mappings: 471 + self.log.error("No mappings found for reposted post!") 472 + return 473 + 474 + response = self.http.post( 475 + f"{self.url}/api/v1/statuses/{mappings[0]['identifier']}/reblog", 476 + headers={"Authorization": f"Bearer {self._get_token()}"}, 477 + ) 478 + 479 + if response.status_code != 200: 480 + self.log.error( 481 + "Failed to boost status! status_code: %s, msg: %s", 482 + response.status_code, 483 + response.content, 484 + ) 485 + return 486 + 487 + self._insert_post( 488 + { 489 + "user": self.user_id, 490 + "service": self.url, 491 + "identifier": response.json()["id"], 492 + "parent": None, 493 + "root": None, 494 + "reposted": mappings[0]["id"], 495 + "extra_data": None, 496 + "crossposted": 1, 497 + } 498 + ) 499 + inserted = self._get_post(self.url, self.user_id, response.json()["id"]) 500 + if not inserted: 501 + raise ValueError("Inserted post not found!") 502 + 503 + original_repost = self._get_post(repost.service, repost.author, repost.id) 504 + if not original_repost: 505 + self.log.error("original repost not found in DB: %s", repost.id) 506 + return 507 + 508 + self._insert_post_mapping(original_repost["id"], inserted["id"]) 509 + self.log.info("Repost accepted successfully: %s", repost.id) 510 + 511 + @override 512 + def delete_repost(self, repost: PostRef): 513 + self.log.info( 514 + "Deleting repost %s (author: %s, service: %s)...", 515 + repost.id, 516 + repost.author, 517 + repost.service, 518 + ) 519 + db_repost = self._get_post(repost.service, repost.author, repost.id) 520 + if not db_repost: 521 + self.log.warning( 522 + "Repost not found in DB: %s (author: %s, service: %s)", 523 + repost.id, 524 + repost.author, 525 + repost.service, 526 + ) 527 + return 528 + 529 + mappings = self._get_mappings(db_repost["id"], self.url, self.user_id) 530 + rmappings = self._get_mappings(db_repost["reposted"], self.url, self.user_id) 531 + 532 + if not mappings: 533 + self.log.warning("No mappings found for repost %s", repost.id) 534 + return 535 + if not rmappings: 536 + self.log.warning( 537 + "No mappings found for original post %s (reposted_id=%s)", 538 + repost.id, 539 + db_repost["reposted"], 540 + ) 541 + return 542 + 543 + self.log.info( 544 + "Removing '%s' Repost of '%s'...", 545 + mappings[0]["identifier"], 546 + rmappings[0]["identifier"], 547 + ) 548 + 549 + response = self.http.post( 550 + f"{self.url}/api/v1/statuses/{rmappings[0]['identifier']}/unreblog", 551 + headers={"Authorization": f"Bearer {self._get_token()}"}, 552 + ) 553 + 554 + if response.status_code != 200: 555 + self.log.error( 556 + "Failed to unreblog! status_code: %s, msg: %s", 557 + response.status_code, 558 + response.text, 559 + ) 560 + return 561 + 562 + self._delete_post_by_id(mappings[0]["id"]) 563 + self.log.info("Repost deleted successfully: %s", repost.id) 564 + 565 + @override 566 + def _get_token(self) -> str: 567 + return self.options.token
+21
migrations/001_initdb_v1.py
··· 1 + import sqlite3 2 + 3 + 4 + def migrate(conn: sqlite3.Connection): 5 + _ = conn.execute(""" 6 + CREATE TABLE IF NOT EXISTS posts ( 7 + id INTEGER PRIMARY KEY AUTOINCREMENT, 8 + user_id TEXT NOT NULL, 9 + service TEXT NOT NULL, 10 + identifier TEXT NOT NULL, 11 + parent_id INTEGER NULL REFERENCES posts(id) ON DELETE SET NULL, 12 + root_id INTEGER NULL REFERENCES posts(id) ON DELETE SET NULL 13 + ); 14 + """) 15 + _ = conn.execute(""" 16 + CREATE TABLE IF NOT EXISTS mappings ( 17 + original_post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, 18 + mapped_post_id INTEGER NOT NULL 19 + ); 20 + """) 21 + pass
+11
migrations/002_add_reposted_column_v1.py
··· 1 + import sqlite3 2 + 3 + 4 + def migrate(conn: sqlite3.Connection): 5 + columns = conn.execute("PRAGMA table_info(posts)") 6 + column_names = [col[1] for col in columns] 7 + if "reposted_id" not in column_names: 8 + _ = conn.execute(""" 9 + ALTER TABLE posts 10 + ADD COLUMN reposted_id INTEGER NULL REFERENCES posts(id) ON DELETE SET NULL 11 + """)
+26
migrations/003_add_extra_data_column_v1.py
··· 1 + import json 2 + import sqlite3 3 + 4 + 5 + def migrate(conn: sqlite3.Connection): 6 + columns = conn.execute("PRAGMA table_info(posts)") 7 + column_names = [col[1] for col in columns] 8 + if "extra_data" not in column_names: 9 + _ = conn.execute(""" 10 + ALTER TABLE posts 11 + ADD COLUMN extra_data TEXT NULL 12 + """) 13 + 14 + # migrate old bsky identifiers from json to uri as id and cid in extra_data 15 + data = conn.execute( 16 + "SELECT id, identifier FROM posts WHERE service = 'https://bsky.app';" 17 + ).fetchall() 18 + rewrites: list[tuple[str, str, int]] = [] 19 + for row in data: 20 + if row[1][0] == "{" and row[1][-1] == "}": 21 + data = json.loads(row[1]) 22 + rewrites.append((data["uri"], json.dumps({"cid": data["cid"]}), row[0])) 23 + if rewrites: 24 + _ = conn.executemany( 25 + "UPDATE posts SET identifier = ?, extra_data = ? WHERE id = ?;", rewrites 26 + )
+52
migrations/004_initdb_next.py
··· 1 + import sqlite3 2 + 3 + 4 + def migrate(conn: sqlite3.Connection): 5 + cursor = conn.cursor() 6 + 7 + old_posts = cursor.execute("SELECT * FROM posts;").fetchall() 8 + old_mappings = cursor.execute("SELECT * FROM mappings;").fetchall() 9 + 10 + _ = cursor.execute("DROP TABLE posts;") 11 + _ = cursor.execute("DROP TABLE mappings;") 12 + 13 + _ = cursor.execute(""" 14 + CREATE TABLE posts ( 15 + id INTEGER UNIQUE PRIMARY KEY AUTOINCREMENT, 16 + user TEXT NOT NULL, 17 + service TEXT NOT NULL, 18 + identifier TEXT NOT NULL, 19 + parent INTEGER NULL REFERENCES posts(id), 20 + root INTEGER NULL REFERENCES posts(id), 21 + reposted INTEGER NULL REFERENCES posts(id), 22 + extra_data TEXT NULL 23 + ); 24 + """) 25 + 26 + _ = cursor.execute(""" 27 + CREATE TABLE mappings ( 28 + original INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, 29 + mapped INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, 30 + UNIQUE(original, mapped) 31 + ); 32 + """) 33 + 34 + for old_post in old_posts: 35 + _ = cursor.execute( 36 + """ 37 + INSERT INTO posts (id, user, service, identifier, parent, root, reposted, extra_data) 38 + VALUES (:id, :user_id, :service, :identifier, :parent_id, :root_id, :reposted_id, :extra_data) 39 + """, 40 + dict(old_post), 41 + ) 42 + 43 + for mapping in old_mappings: 44 + original, mapped = mapping["original_post_id"], mapping["mapped_post_id"] 45 + _ = cursor.execute( 46 + "INSERT OR IGNORE INTO mappings (original, mapped) VALUES (?, ?)", 47 + (original, mapped), 48 + ) 49 + _ = cursor.execute( 50 + "INSERT OR IGNORE INTO mappings (original, mapped) VALUES (?, ?)", 51 + (mapped, original), 52 + )
+12
migrations/005_add_indexes.py
··· 1 + import sqlite3 2 + 3 + 4 + def migrate(conn: sqlite3.Connection): 5 + _ = conn.execute(""" 6 + CREATE INDEX IF NOT EXISTS idx_posts_service_user_identifier 7 + ON posts (service, user, identifier); 8 + """) 9 + _ = conn.execute(""" 10 + CREATE UNIQUE INDEX IF NOT EXISTS ux_mappings_original_mapped 11 + ON mappings (original, mapped); 12 + """)
+35
migrations/006_add_atproto_tables.py
··· 1 + import sqlite3 2 + 3 + 4 + def migrate(conn: sqlite3.Connection): 5 + _ = conn.execute(""" 6 + CREATE TABLE IF NOT EXISTS atproto_sessions ( 7 + did TEXT PRIMARY KEY, 8 + pds TEXT NOT NULL, 9 + handle TEXT NOT NULL, 10 + access_jwt TEXT NOT NULL, 11 + refresh_jwt TEXT NOT NULL, 12 + email TEXT, 13 + email_confirmed INTEGER DEFAULT 0, 14 + email_auth_factor INTEGER DEFAULT 0, 15 + active INTEGER DEFAULT 1, 16 + status TEXT, 17 + created_at REAL NOT NULL 18 + ) 19 + """) 20 + _ = conn.execute(""" 21 + CREATE TABLE IF NOT EXISTS atproto_identities ( 22 + identifier TEXT PRIMARY KEY, 23 + did TEXT NOT NULL, 24 + handle TEXT NOT NULL, 25 + pds TEXT NOT NULL, 26 + signing_key TEXT NOT NULL, 27 + created_at REAL NOT NULL 28 + ) 29 + """) 30 + _ = conn.execute(""" 31 + CREATE INDEX IF NOT EXISTS idx_sessions_pds ON atproto_sessions(pds) 32 + """) 33 + _ = conn.execute(""" 34 + CREATE INDEX IF NOT EXISTS idx_identities_created_at ON atproto_identities(created_at) 35 + """)
+10
migrations/007_add_crossposted_column.py
··· 1 + import sqlite3 2 + 3 + 4 + def migrate(conn: sqlite3.Connection): 5 + _ = conn.execute(""" 6 + ALTER TABLE posts ADD COLUMN crossposted INTEGER DEFAULT 0 7 + """) 8 + _ = conn.execute(""" 9 + CREATE INDEX IF NOT EXISTS idx_posts_crossposted ON posts(crossposted) 10 + """)
migrations/__init__.py

This is a binary file and will not be displayed.

+37
migrations/_registry.py
··· 1 + import importlib.util 2 + import sqlite3 3 + from collections.abc import Callable 4 + from pathlib import Path 5 + 6 + 7 + def load_migrations( 8 + path: Path, 9 + ) -> list[tuple[int, str, Callable[[sqlite3.Connection], None]]]: 10 + migrations: list[tuple[int, str, Callable[[sqlite3.Connection], None]]] = [] 11 + migration_files = sorted( 12 + [f for f in path.glob("*.py") if not f.stem.startswith("_")] 13 + ) 14 + 15 + for filepath in migration_files: 16 + filename = filepath.stem 17 + version_str = filename.split("_")[0] 18 + 19 + try: 20 + version = int(version_str) 21 + except ValueError: 22 + raise ValueError("migrations must start with a number!!") 23 + 24 + spec = importlib.util.spec_from_file_location(filepath.stem, filepath) 25 + if not spec or not spec.loader: 26 + raise Exception(f"Failed to load spec from file: {filepath}") 27 + 28 + module = importlib.util.module_from_spec(spec) 29 + spec.loader.exec_module(module) 30 + 31 + if hasattr(module, "migrate"): 32 + migrations.append((version, filename, module.migrate)) 33 + else: 34 + raise ValueError(f"Migration {filepath.name} missing 'migrate' function") 35 + 36 + migrations.sort(key=lambda x: x[0]) 37 + return migrations
misskey/__init__.py

This is a binary file and will not be displayed.

+27
misskey/info.py
··· 1 + from abc import ABC, abstractmethod 2 + 3 + import httpx 4 + 5 + from cross.service import Service 6 + from database.connection import DatabasePool 7 + 8 + 9 + class MisskeyService(ABC, Service): 10 + def __init__(self, url: str, db: DatabasePool, http: httpx.Client) -> None: 11 + super().__init__(url, db) 12 + self.http = http 13 + 14 + def verify_credentials(self): 15 + response = self.http.post( 16 + f"{self.url}/api/i", 17 + json={"i": self._get_token()}, 18 + headers={"Content-Type": "application/json"}, 19 + ) 20 + if response.status_code != 200: 21 + self.log.error("Failed to validate user credentials!") 22 + response.raise_for_status() 23 + return dict(response.json()) 24 + 25 + @abstractmethod 26 + def _get_token(self) -> str: 27 + pass
+270
misskey/input.py
··· 1 + import asyncio 2 + import json 3 + import re 4 + import uuid 5 + from dataclasses import dataclass, field 6 + from typing import Any, cast, override 7 + 8 + import httpx 9 + import websockets 10 + 11 + from cross.attachments import ( 12 + LabelsAttachment, 13 + MediaAttachment, 14 + QuoteAttachment, 15 + RemoteUrlAttachment, 16 + SensitiveAttachment, 17 + ) 18 + from cross.media import Blob, download_blob 19 + from cross.post import Post, PostRef 20 + from cross.service import InputService 21 + from database.connection import DatabasePool 22 + from misskey.info import MisskeyService 23 + from util.markdown import MarkdownParser 24 + from util.util import normalize_service_url 25 + 26 + 27 + ALLOWED_VISIBILITY = ["public", "home"] 28 + 29 + 30 + @dataclass 31 + class MisskeyInputOptions: 32 + token: str 33 + instance: str 34 + allowed_visibility: list[str] = field( 35 + default_factory=lambda: ALLOWED_VISIBILITY.copy() 36 + ) 37 + filters: list[re.Pattern[str]] = field(default_factory=lambda: []) 38 + 39 + @classmethod 40 + def from_dict(cls, data: dict[str, Any]) -> "MisskeyInputOptions": 41 + data["instance"] = normalize_service_url(data["instance"]) 42 + 43 + if "allowed_visibility" in data: 44 + for vis in data.get("allowed_visibility", []): 45 + if vis not in ALLOWED_VISIBILITY: 46 + raise ValueError(f"Invalid visibility option {vis}!") 47 + 48 + if "filters" in data: 49 + data["filters"] = [re.compile(r) for r in data["filters"]] 50 + 51 + return MisskeyInputOptions(**data) 52 + 53 + 54 + class MisskeyInputService(MisskeyService, InputService): 55 + def __init__( 56 + self, db: DatabasePool, http: httpx.Client, options: MisskeyInputOptions 57 + ) -> None: 58 + super().__init__(options.instance, db, http) 59 + self.options: MisskeyInputOptions = options 60 + 61 + self.log.info("Verifying %s credentails...", self.url) 62 + response = self.verify_credentials() 63 + self.user_id: str = response["id"] 64 + 65 + @override 66 + def _get_token(self) -> str: 67 + return self.options.token 68 + 69 + def _on_note(self, note: dict[str, Any]): 70 + self.log.info("Processing new note: %s", note["id"]) 71 + 72 + if note["userId"] != self.user_id: 73 + return 74 + 75 + if note["visibility"] not in self.options.allowed_visibility: 76 + self.log.info( 77 + "Skipping note with disallowed visibility: %s (%s)", 78 + note["id"], 79 + note["visibility"], 80 + ) 81 + return 82 + 83 + if self._is_post_crossposted(self.url, self.user_id, note["id"]): 84 + self.log.info( 85 + "Skipping %s, already crossposted", 86 + note["id"], 87 + ) 88 + return 89 + 90 + if note.get("poll"): 91 + self.log.info("Skipping '%s'! Contains a poll..", note["id"]) 92 + return 93 + 94 + renote: dict[str, Any] | None = note.get("renote") 95 + if renote: 96 + if note.get("text") is None: 97 + self._on_renote(note, renote) 98 + return 99 + 100 + if renote["userId"] != self.user_id: 101 + return 102 + 103 + rrenote = self._get_post(self.url, self.user_id, renote["id"]) 104 + if not rrenote: 105 + self.log.info( 106 + "Skipping %s, quote %s not found in db", note["id"], renote["id"] 107 + ) 108 + return 109 + 110 + reply: dict[str, Any] | None = note.get("reply") 111 + if reply and reply.get("userId") != self.user_id: 112 + self.log.info("Skipping '%s'! Reply to other user..", note["id"]) 113 + return 114 + 115 + parent = None 116 + if reply: 117 + parent = self._get_post(self.url, self.user_id, reply["id"]) 118 + if not parent: 119 + self.log.info( 120 + "Skipping %s, parent %s not found in db", note["id"], reply["id"] 121 + ) 122 + return 123 + 124 + mention_handles: dict = note.get("mentionHandles") or {} 125 + tags: list[str] = note.get("tags") or [] 126 + 127 + handles: list[tuple[str, str]] = [] 128 + for _key, value in mention_handles.items(): 129 + handles.append((value, value)) 130 + 131 + parser = MarkdownParser() # TODO MFM parser 132 + tokens = parser.parse(note.get("text", ""), tags, handles) 133 + post = Post( 134 + id=note["id"], 135 + author=self.user_id, 136 + service=self.url, 137 + parent_id=reply["id"] if reply else None, 138 + tokens=tokens, 139 + text_type="text/x.misskeymarkdown", 140 + ) 141 + 142 + post.attachments.put(RemoteUrlAttachment(url=self.url + "/notes/" + note["id"])) 143 + if renote: 144 + post.attachments.put( 145 + QuoteAttachment(quoted_id=renote["id"], quoted_user=self.user_id) 146 + ) 147 + if any(a.get("isSensitive", False) for a in note.get("files", [])): 148 + post.attachments.put(SensitiveAttachment(sensitive=True)) 149 + if note.get("cw"): 150 + post.attachments.put(LabelsAttachment(labels=[note["cw"]])) 151 + 152 + blobs: list[Blob] = [] 153 + for media in note.get("files", []): 154 + self.log.info("Downloading %s...", media["url"]) 155 + blob: Blob | None = download_blob( 156 + media["url"], media.get("comment", ""), client=self.http 157 + ) 158 + if not blob: 159 + self.log.error( 160 + "Skipping %s! Failed to download media %s.", 161 + note["id"], 162 + media["url"], 163 + ) 164 + return 165 + blobs.append(blob) 166 + 167 + if blobs: 168 + post.attachments.put(MediaAttachment(blobs=blobs)) 169 + 170 + if parent: 171 + self._insert_post( 172 + { 173 + "user": self.user_id, 174 + "service": self.url, 175 + "identifier": note["id"], 176 + "parent": parent["id"], 177 + "root": parent["id"] if not parent["root"] else parent["root"], 178 + } 179 + ) 180 + else: 181 + self._insert_post( 182 + { 183 + "user": self.user_id, 184 + "service": self.url, 185 + "identifier": note["id"], 186 + } 187 + ) 188 + 189 + self.log.info("Note stored in DB: %s", note["id"]) 190 + 191 + for out in self.outputs: 192 + self.submitter(lambda: out.accept_post(post)) 193 + 194 + def _on_renote(self, note: dict[str, Any], renote: dict[str, Any]): 195 + self.log.info("Processing renote: %s", note["id"]) 196 + reposted = self._get_post(self.url, self.user_id, renote["id"]) 197 + if not reposted: 198 + self.log.info( 199 + "Skipping repost '%s' as reposted post '%s' was not found in the db.", 200 + note["id"], 201 + renote["id"], 202 + ) 203 + return 204 + 205 + self._insert_post( 206 + { 207 + "user": self.user_id, 208 + "service": self.url, 209 + "identifier": note["id"], 210 + "reposted": reposted["id"], 211 + } 212 + ) 213 + 214 + self.log.info("Renote stored in DB: %s", note["id"]) 215 + 216 + repost_ref = PostRef(id=note["id"], author=self.user_id, service=self.url) 217 + reposted_ref = PostRef(id=renote["id"], author=self.user_id, service=self.url) 218 + for out in self.outputs: 219 + self.submitter(lambda: out.accept_repost(repost_ref, reposted_ref)) 220 + 221 + def _accept_msg(self, msg: websockets.Data) -> None: 222 + data: dict[str, Any] = cast(dict[str, Any], json.loads(msg)) 223 + 224 + if data["type"] == "channel": 225 + type: str = cast(str, data["body"]["type"]) 226 + if type == "note" or type == "reply": 227 + note_body = data["body"]["body"] 228 + self._on_note(note_body) 229 + 230 + async def _subscribe_to_home(self, ws: websockets.ClientConnection) -> None: 231 + await ws.send( 232 + json.dumps( 233 + { 234 + "type": "connect", 235 + "body": {"channel": "homeTimeline", "id": str(uuid.uuid4())}, 236 + } 237 + ) 238 + ) 239 + self.log.info("Subscribed to 'homeTimeline' channel...") 240 + 241 + @override 242 + async def listen(self): 243 + streaming: str = f"{'wss' if self.url.startswith('https') else 'ws'}://{self.url.split('://', 1)[1]}" 244 + url: str = f"{streaming}/streaming?i={self.options.token}" 245 + 246 + async for ws in websockets.connect( 247 + url, 248 + ping_interval=20, 249 + ping_timeout=10, 250 + close_timeout=5, 251 + ): 252 + try: 253 + self.log.info("Listening to %s...", streaming) 254 + await self._subscribe_to_home(ws) 255 + 256 + async def listen_for_messages(): 257 + async for msg in ws: 258 + self.submitter(lambda: self._accept_msg(msg)) 259 + 260 + listen = asyncio.create_task(listen_for_messages()) 261 + 262 + _ = await asyncio.gather(listen) 263 + except websockets.ConnectionClosedError as e: 264 + self.log.error(e, stack_info=True, exc_info=True) 265 + self.log.info("Reconnecting to %s...", streaming) 266 + continue 267 + except TimeoutError as e: 268 + self.log.error("Connection timeout: %s", e) 269 + self.log.info("Reconnecting to %s...", streaming) 270 + continue
+43
misskey/mfm.py
··· 1 + import re 2 + 3 + from cross.tokens import LinkToken, TextToken, Token 4 + 5 + 6 + MFM_PATTERN = re.compile(r"\$\[([^\[\]]+)\]") 7 + 8 + 9 + def strip_mfm(tokens: list[Token]) -> tuple[list[Token], bool]: 10 + modified = False 11 + original: str | None 12 + 13 + for tk in tokens: 14 + if isinstance(tk, TextToken): 15 + original = tk.text 16 + cleaned = __strip_mfm(original) 17 + if cleaned != original: 18 + modified = True 19 + tk.text = cleaned or "" 20 + 21 + elif isinstance(tk, LinkToken): 22 + original = tk.label 23 + cleaned = __strip_mfm(original) 24 + if cleaned != original: 25 + modified = True 26 + tk.label = cleaned 27 + 28 + return tokens, modified 29 + 30 + 31 + def __strip_mfm(text: str | None) -> str | None: 32 + if text is None: 33 + return None 34 + 35 + def match_contents(match: re.Match[str]): 36 + content = match.group(1).strip() 37 + parts = content.split(" ", 1) 38 + return parts[1] if len(parts) > 1 else "" 39 + 40 + while MFM_PATTERN.search(text): 41 + text = MFM_PATTERN.sub(match_contents, text) 42 + 43 + return text
+74
pyproject.toml
··· 1 + [project] 2 + name = "xpost" 3 + version = "0.1.0" 4 + description = "social media crossposting tool" 5 + readme = "README.md" 6 + requires-python = ">=3.12" 7 + dependencies = [ 8 + "dnspython>=2.8.0", 9 + "grapheme>=0.6.0", 10 + "httpx>=0.27.0", 11 + "python-magic>=0.4.27", 12 + "websockets>=15.0.1", 13 + ] 14 + 15 + [dependency-groups] 16 + dev = [ 17 + "pytest>=8.4.2", 18 + "ruff>=0.9.0", 19 + "mypy>=1.15.0", 20 + ] 21 + 22 + [tool.pytest.ini_options] 23 + pythonpath = ["."] 24 + 25 + [tool.ruff] 26 + target-version = "py312" 27 + line-length = 88 28 + src = ["."] 29 + 30 + [tool.ruff.lint] 31 + select = [ 32 + "E", # pycodestyle errors 33 + "W", # pycodestyle warnings 34 + "F", # Pyflakes 35 + "I", # isort 36 + "B", # flake8-bugbear 37 + "C4", # flake8-comprehensions 38 + "UP", # pyupgrade 39 + "ARG", # flake8-unused-arguments 40 + "SIM", # flake8-simplify 41 + ] 42 + ignore = [ 43 + "E501", # line too long (handled by formatter) 44 + "B008", # do not perform function calls in argument defaults 45 + "B904", # raise without from inside except 46 + "B023", # loop variable binding in lambdas (false positive for async for) 47 + ] 48 + 49 + [tool.ruff.lint.isort] 50 + force-single-line = false 51 + lines-after-imports = 2 52 + 53 + [tool.ruff.format] 54 + quote-style = "double" 55 + indent-style = "space" 56 + skip-magic-trailing-comma = false 57 + line-ending = "auto" 58 + 59 + [tool.mypy] 60 + python_version = "3.12" 61 + warn_return_any = true 62 + warn_unused_configs = true 63 + warn_unused_ignores = true 64 + disallow_untyped_defs = false 65 + disallow_incomplete_defs = false 66 + check_untyped_defs = true 67 + no_implicit_optional = true 68 + warn_redundant_casts = true 69 + strict_equality = true 70 + 71 + # external libraries 72 + [[tool.mypy.overrides]] 73 + module = ["grapheme"] 74 + ignore_missing_imports = true
+43
registry.py
··· 1 + from collections.abc import Callable 2 + from typing import Any 3 + 4 + import httpx 5 + 6 + from cross.service import InputService, OutputService 7 + from database.connection import DatabasePool 8 + 9 + 10 + input_factories: dict[ 11 + str, Callable[[DatabasePool, httpx.Client, dict[str, Any]], InputService] 12 + ] = {} 13 + output_factories: dict[ 14 + str, Callable[[DatabasePool, httpx.Client, dict[str, Any]], OutputService] 15 + ] = {} 16 + 17 + 18 + def create_input_service( 19 + db: DatabasePool, http: httpx.Client, data: dict[str, Any] 20 + ) -> InputService: 21 + if "type" not in data: 22 + raise ValueError("No `type` field in input data!") 23 + type: str = str(data["type"]) 24 + del data["type"] 25 + 26 + factory = input_factories.get(type) 27 + if not factory: 28 + raise KeyError(f"No such input service {type}!") 29 + return factory(db, http, data) 30 + 31 + 32 + def create_output_service( 33 + db: DatabasePool, http: httpx.Client, data: dict[str, Any] 34 + ) -> OutputService: 35 + if "type" not in data: 36 + raise ValueError("No `type` field in input data!") 37 + type: str = str(data["type"]) 38 + del data["type"] 39 + 40 + factory = output_factories.get(type) 41 + if not factory: 42 + raise KeyError(f"No such output service {type}!") 43 + return factory(db, http, data)
+42
registry_bootstrap.py
··· 1 + from typing import Any 2 + 3 + import httpx 4 + 5 + from database.connection import DatabasePool 6 + from registry import input_factories, output_factories 7 + 8 + 9 + class LazyFactory: 10 + def __init__(self, module_path: str, class_name: str, options_class_name: str): 11 + self.module_path: str = module_path 12 + self.class_name: str = class_name 13 + self.options_class_name: str = options_class_name 14 + 15 + def __call__(self, db: DatabasePool, http: httpx.Client, d: dict[str, Any]): 16 + module = __import__( 17 + self.module_path, fromlist=[self.class_name, self.options_class_name] 18 + ) 19 + service_class = getattr(module, self.class_name) 20 + options_class = getattr(module, self.options_class_name) 21 + return service_class(db, http, options_class.from_dict(d)) 22 + 23 + 24 + def bootstrap(): 25 + input_factories["mastodon-wss"] = LazyFactory( 26 + "mastodon.input", "MastodonInputService", "MastodonInputOptions" 27 + ) 28 + input_factories["misskey-wss"] = LazyFactory( 29 + "misskey.input", "MisskeyInputService", "MisskeyInputOptions" 30 + ) 31 + input_factories["bluesky-jetstream"] = LazyFactory( 32 + "bluesky.input", "BlueskyJetstreamInputService", "BlueskyInputOptions" 33 + ) 34 + output_factories["stderr"] = LazyFactory( 35 + "util.dummy", "StderrOutputService", "DummyOptions" 36 + ) 37 + output_factories["bluesky"] = LazyFactory( 38 + "bluesky.output", "BlueskyOutputService", "BlueskyOutputOptions" 39 + ) 40 + output_factories["mastodon"] = LazyFactory( 41 + "mastodon.output", "MastodonOutputService", "MastodonOutputOptions" 42 + )
+63
tests/util/util_test.py
··· 1 + from unittest.mock import patch 2 + 3 + import pytest 4 + 5 + import util.util as u 6 + 7 + 8 + def test_normalize_service_url_http(): 9 + assert u.normalize_service_url("http://example.com") == "http://example.com" 10 + assert u.normalize_service_url("http://example.com/") == "http://example.com" 11 + 12 + 13 + def test_normalize_service_url_invalid_schemes(): 14 + with pytest.raises(ValueError, match="Invalid service url"): 15 + _ = u.normalize_service_url("ftp://example.com") 16 + with pytest.raises(ValueError, match="Invalid service url"): 17 + _ = u.normalize_service_url("example.com") 18 + with pytest.raises(ValueError, match="Invalid service url"): 19 + _ = u.normalize_service_url("//example.com") 20 + 21 + 22 + def test_read_env_missing_env_var(): 23 + data = {"token": "env:MISSING_VAR", "keep": "value"} 24 + with patch.dict("os.environ", {}, clear=True): 25 + u.read_env(data) 26 + assert data == {"keep": "value"} 27 + assert "token" not in data 28 + 29 + 30 + def test_read_env_no_env_prefix(): 31 + data = {"token": "literal_value", "number": 123} 32 + u.read_env(data) 33 + assert data == {"token": "literal_value", "number": 123} 34 + 35 + 36 + def test_read_env_deeply_nested(): 37 + data = {"level1": {"level2": {"token": "env:DEEP_TOKEN"}}} 38 + with patch.dict("os.environ", {"DEEP_TOKEN": "deep_secret"}): 39 + u.read_env(data) 40 + assert data["level1"]["level2"]["token"] == "deep_secret" 41 + 42 + 43 + def test_read_env_mixed_types(): 44 + data: dict[str, object] = { 45 + "string": "env:TOKEN", 46 + "number": 42, 47 + "list": [1, 2, 3], 48 + "none": None, 49 + "bool": True, 50 + } 51 + with patch.dict("os.environ", {"TOKEN": "secret"}): 52 + u.read_env(data) 53 + assert data["string"] == "secret" 54 + assert data["number"] == 42 55 + assert data["list"] == [1, 2, 3] 56 + assert data["none"] is None 57 + assert data["bool"] is True 58 + 59 + 60 + def test_read_env_empty_dict(): 61 + data: dict[str, object] = {} 62 + u.read_env(data) 63 + assert data == {}
util/__init__.py

This is a binary file and will not be displayed.

+48
util/cache.py
··· 1 + import pickle 2 + import time 3 + from abc import ABC, abstractmethod 4 + from pathlib import Path 5 + from typing import override 6 + 7 + 8 + class Cacheable(ABC): 9 + @abstractmethod 10 + def dump_cache(self, path: Path): 11 + pass 12 + 13 + @abstractmethod 14 + def load_cache(self, path: Path): 15 + pass 16 + 17 + 18 + class TTLCache[K, V](Cacheable): 19 + def __init__(self, ttl_seconds: int = 3600) -> None: 20 + self.ttl: int = ttl_seconds 21 + self.__cache: dict[K, tuple[V, float]] = {} 22 + 23 + def get(self, key: K) -> V | None: 24 + if key in self.__cache: 25 + value, timestamp = self.__cache[key] 26 + if time.time() - timestamp < self.ttl: 27 + return value 28 + else: 29 + del self.__cache[key] 30 + return None 31 + 32 + def set(self, key: K, value: V) -> None: 33 + self.__cache[key] = (value, time.time()) 34 + 35 + def clear(self) -> None: 36 + self.__cache.clear() 37 + 38 + @override 39 + def dump_cache(self, path: Path) -> None: 40 + path.parent.mkdir(parents=True, exist_ok=True) 41 + with open(path, "wb") as f: 42 + pickle.dump(self.__cache, f) 43 + 44 + @override 45 + def load_cache(self, path: Path): 46 + if path.exists(): 47 + with open(path, "rb") as f: 48 + self.__cache = pickle.load(f)
+32
util/dummy.py
··· 1 + from typing import override 2 + 3 + from cross.post import Post, PostRef 4 + from cross.service import OutputService 5 + from database.connection import DatabasePool 6 + 7 + 8 + class DummyOptions: 9 + @classmethod 10 + def from_dict(cls, _obj) -> "DummyOptions": 11 + return DummyOptions() 12 + 13 + 14 + class StderrOutputService(OutputService): 15 + def __init__(self, db: DatabasePool, _options: DummyOptions) -> None: 16 + super().__init__("http://localhost", db) 17 + 18 + @override 19 + def accept_post(self, post: Post): 20 + self.log.info("%s", post) 21 + 22 + @override 23 + def accept_repost(self, repost: PostRef, reposted: PostRef): 24 + self.log.info("%s, %s", repost.id, reposted.id) 25 + 26 + @override 27 + def delete_post(self, post: PostRef): 28 + self.log.info("%s", post.id) 29 + 30 + @override 31 + def delete_repost(self, repost: PostRef): 32 + self.log.info("%s", repost.id)
+151
util/html.py
··· 1 + from html.parser import HTMLParser 2 + from typing import override 3 + 4 + from cross.tokens import LinkToken, TextToken, Token 5 + from util.splitter import canonical_label 6 + 7 + 8 + class HTMLToTokensParser(HTMLParser): 9 + def __init__(self) -> None: 10 + super().__init__() 11 + self.tokens: list[Token] = [] 12 + 13 + self._tag_stack: dict[str, tuple[str, dict[str, str | None]]] = {} 14 + self.in_pre: bool = False 15 + self.in_code: bool = False 16 + self.invisible: bool = False 17 + 18 + def handle_a_endtag(self): 19 + label, _attr = self._tag_stack.pop("a") 20 + 21 + href = _attr.get("href") 22 + if href: 23 + if canonical_label(label, href): 24 + self.tokens.append(LinkToken(href=href)) 25 + else: 26 + self.tokens.append(LinkToken(href=href, label=label)) 27 + 28 + def append_text(self, text: str): 29 + self.tokens.append(TextToken(text=text)) 30 + 31 + def append_newline(self): 32 + if self.tokens: 33 + last_token = self.tokens[-1] 34 + if isinstance(last_token, TextToken) and not last_token.text.endswith("\n"): 35 + self.tokens.append(TextToken(text="\n")) 36 + 37 + @override 38 + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: 39 + _attr = dict(attrs) 40 + 41 + if self.invisible: 42 + return 43 + 44 + match tag: 45 + case "p": 46 + cls = _attr.get("class", "") 47 + if cls and "quote-inline" in cls: 48 + self.invisible = True 49 + case "a": 50 + self._tag_stack["a"] = ("", _attr) 51 + case "code": 52 + if not self.in_pre: 53 + self.append_text("`") 54 + self.in_code = True 55 + case "pre": 56 + self.append_newline() 57 + self.append_text("```\n") 58 + self.in_pre = True 59 + case "blockquote": 60 + self.append_newline() 61 + self.append_text("> ") 62 + case "strong" | "b": 63 + self.append_text("**") 64 + case "em" | "i": 65 + self.append_text("*") 66 + case "del" | "s": 67 + self.append_text("~~") 68 + case "br": 69 + self.append_text("\n") 70 + case "h1" | "h2" | "h3" | "h4" | "h5" | "h6": 71 + level = int(tag[1]) 72 + self.append_text("\n" + "#" * level + " ") 73 + case _: 74 + # self.builder.extend(f"<{tag}>".encode("utf-8")) 75 + pass 76 + 77 + @override 78 + def handle_endtag(self, tag: str) -> None: 79 + if self.invisible: 80 + if tag == "p": 81 + self.invisible = False 82 + return 83 + 84 + match tag: 85 + case "a": 86 + if "a" in self._tag_stack: 87 + self.handle_a_endtag() 88 + case "code": 89 + if not self.in_pre and self.in_code: 90 + self.append_text("`") 91 + self.in_code = False 92 + case "pre": 93 + self.append_newline() 94 + self.append_text("```\n") 95 + self.in_pre = False 96 + case "blockquote": 97 + self.append_text("\n") 98 + case "strong" | "b": 99 + self.append_text("**") 100 + case "em" | "i": 101 + self.append_text("*") 102 + case "del" | "s": 103 + self.append_text("~~") 104 + case "p": 105 + self.append_text("\n\n") 106 + case "h1" | "h2" | "h3" | "h4" | "h5" | "h6": 107 + self.append_text("\n") 108 + case _: 109 + # self.builder.extend(f"</{tag}>".encode("utf-8")) 110 + pass 111 + 112 + @override 113 + def handle_data(self, data: str) -> None: 114 + if self.invisible: 115 + return 116 + 117 + if self._tag_stack.get("a"): 118 + label, _attr = self._tag_stack.pop("a") 119 + self._tag_stack["a"] = (label + data, _attr) 120 + else: 121 + self.append_text(data) 122 + 123 + def get_result(self) -> list[Token]: 124 + if not self.tokens: 125 + return [] 126 + 127 + combined: list[Token] = [] 128 + buffer: list[str] = [] 129 + 130 + def flush_buffer(): 131 + if buffer: 132 + merged = "".join(buffer) 133 + combined.append(TextToken(text=merged)) 134 + buffer.clear() 135 + 136 + for token in self.tokens: 137 + if isinstance(token, TextToken): 138 + buffer.append(token.text) 139 + else: 140 + flush_buffer() 141 + combined.append(token) 142 + 143 + flush_buffer() 144 + 145 + if combined and isinstance(combined[-1], TextToken): 146 + if combined[-1].text.endswith("\n\n"): 147 + combined[-1] = TextToken(text=combined[-1].text[:-2]) 148 + 149 + if combined[-1].text.endswith("\n"): 150 + combined[-1] = TextToken(text=combined[-1].text[:-1]) 151 + return combined
+127
util/markdown.py
··· 1 + import re 2 + 3 + from cross.tokens import LinkToken, MentionToken, TagToken, TextToken, Token 4 + from util.html import HTMLToTokensParser 5 + from util.splitter import canonical_label 6 + 7 + 8 + URL = re.compile(r"(?:(?:[A-Za-z][A-Za-z0-9+.-]*://)|mailto:)[^\s]+", re.IGNORECASE) 9 + MD_INLINE_LINK = re.compile( 10 + r"\[([^\]]+)\]\(\s*((?:(?:[A-Za-z][A-Za-z0-9+.\-]*://)|mailto:)[^\s\)]+)\s*\)", 11 + re.IGNORECASE, 12 + ) 13 + MD_AUTOLINK = re.compile( 14 + r"<((?:(?:[A-Za-z][A-Za-z0-9+.\-]*://)|mailto:)[^\s>]+)>", re.IGNORECASE 15 + ) 16 + HASHTAG = re.compile(r"(?<!\w)\#([\w]+)") 17 + FEDIVERSE_HANDLE = re.compile(r"(?<![\w@])@([\w\.-]+)(?:@([\w\.-]+\.[\w\.-]+))?") 18 + 19 + REGEXES = [URL, MD_INLINE_LINK, MD_AUTOLINK, HASHTAG, FEDIVERSE_HANDLE] 20 + 21 + 22 + # TODO autolinks are broken by the html parser 23 + class MarkdownParser: 24 + def parse( 25 + self, text: str, tags: list[str], handles: list[tuple[str, str]] 26 + ) -> list[Token]: 27 + if not text: 28 + return [] 29 + 30 + tokenizer = HTMLToTokensParser() 31 + tokenizer.feed(text) 32 + html_tokens = tokenizer.get_result() 33 + 34 + tokens: list[Token] = [] 35 + 36 + for tk in html_tokens: 37 + if isinstance(tk, TextToken): 38 + tokens.extend(self.__tokenize_md(tk.text, tags, handles)) 39 + elif isinstance(tk, LinkToken): 40 + if not tk.label or canonical_label(tk.label, tk.href): 41 + tokens.append(tk) 42 + continue 43 + 44 + tokens.extend( 45 + self.__tokenize_md(f"[{tk.label}]({tk.href})", tags, handles) 46 + ) 47 + else: 48 + tokens.append(tk) 49 + 50 + return tokens 51 + 52 + def __tokenize_md( 53 + self, text: str, tags: list[str], handles: list[tuple[str, str]] 54 + ) -> list[Token]: 55 + index: int = 0 56 + total: int = len(text) 57 + buffer: list[str] = [] 58 + 59 + tokens: list[Token] = [] 60 + 61 + def flush(): 62 + nonlocal buffer 63 + if buffer: 64 + tokens.append(TextToken(text="".join(buffer))) 65 + buffer = [] 66 + 67 + while index < total: 68 + if text[index] == "[": 69 + md_inline = MD_INLINE_LINK.match(text, index) 70 + if md_inline: 71 + flush() 72 + label = md_inline.group(1) 73 + href = md_inline.group(2) 74 + tokens.append(LinkToken(href=href, label=label)) 75 + index = md_inline.end() 76 + continue 77 + 78 + if text[index] == "<": 79 + md_auto = MD_AUTOLINK.match(text, index) 80 + if md_auto: 81 + flush() 82 + href = md_auto.group(1) 83 + tokens.append(LinkToken(href=href, label=None)) 84 + index = md_auto.end() 85 + continue 86 + 87 + if text[index] == "#": 88 + tag = HASHTAG.match(text, index) 89 + if tag: 90 + tag_text = tag.group(1) 91 + if tag_text.lower() in tags: 92 + flush() 93 + tokens.append(TagToken(tag=tag_text)) 94 + index = tag.end() 95 + continue 96 + 97 + if text[index] == "@": 98 + handle = FEDIVERSE_HANDLE.match(text, index) 99 + if handle: 100 + handle_text = handle.group(0) 101 + stripped_handle = handle_text.strip() 102 + 103 + match = next( 104 + (pair for pair in handles if stripped_handle in pair), None 105 + ) 106 + 107 + if match: 108 + flush() 109 + tokens.append( 110 + MentionToken(username=match[1], uri=None) 111 + ) # TODO: misskey doesn’t provide a uri 112 + index = handle.end() 113 + continue 114 + 115 + url = URL.match(text, index) 116 + if url: 117 + flush() 118 + href = url.group(0) 119 + tokens.append(LinkToken(href=href, label=None)) 120 + index = url.end() 121 + continue 122 + 123 + buffer.append(text[index]) 124 + index += 1 125 + 126 + flush() 127 + return tokens
+187
util/splitter.py
··· 1 + import re 2 + from functools import lru_cache 3 + 4 + import grapheme 5 + 6 + from cross.tokens import LinkToken, TagToken, TextToken, Token 7 + 8 + 9 + def canonical_label(label: str | None, href: str): 10 + if not label or label == href: 11 + return True 12 + split = href.split("://", 1) 13 + return len(split) > 1 and split[1] == label 14 + 15 + 16 + @lru_cache(maxsize=1024) 17 + def _grapheme_length(text: str) -> int: 18 + return int(grapheme.length(text)) 19 + 20 + 21 + TEXT_SPLITTER = re.compile(r"(\n{2,}|[!.,;:]+|\s+|[^\s!.,;:\n]+)") 22 + 23 + 24 + class TokenSplitter: 25 + def __init__(self, max_chars: int, max_link_len: int = 35): 26 + self.max_chars = max_chars 27 + self.max_link_len = max_link_len 28 + self.blocks: list[list[Token]] = [] 29 + self.current_block: list[Token] = [] 30 + self.current_length = 0 31 + self.best_split_idx: tuple[int, int, int] | None = None 32 + 33 + def _save_block(self): 34 + if self.current_block: 35 + self.blocks.append(self.current_block) 36 + self.current_block = [] 37 + self.current_length = 0 38 + self.best_split_idx = None 39 + 40 + def _get_token_length(self, token: Token) -> int: 41 + if isinstance(token, TextToken): 42 + return _grapheme_length(token.text) 43 + elif isinstance(token, LinkToken): 44 + if token.label: 45 + return ( 46 + min(_grapheme_length(token.label), self.max_link_len) 47 + if canonical_label(token.label, token.href) 48 + else _grapheme_length(token.label) 49 + ) 50 + return min(_grapheme_length(token.href), self.max_link_len) 51 + elif isinstance(token, TagToken): 52 + return 1 + _grapheme_length(token.tag) 53 + return 0 54 + 55 + def _classify_segment(self, seg: str) -> tuple[bool, bool, bool, bool]: 56 + is_paragraph = bool(re.match(r"^\n{2,}$", seg)) 57 + is_sentence = bool(re.match(r"^[!.,;:]+$", seg)) 58 + is_word = bool(re.match(r"^\s+$", seg)) and not is_paragraph 59 + is_content = not is_paragraph and not is_sentence and not is_word 60 + return is_paragraph, is_sentence, is_word, is_content 61 + 62 + def _maybe_update_split_point(self, priority: int): 63 + should_update = ( 64 + self.best_split_idx is None 65 + or priority > self.best_split_idx[2] 66 + or ( 67 + priority == self.best_split_idx[2] 68 + and self.current_length > self.best_split_idx[1] 69 + and self.current_length <= self.max_chars 70 + ) 71 + ) 72 + if should_update: 73 + self.best_split_idx = ( 74 + len(self.current_block) - 1, 75 + self.current_length, 76 + priority, 77 + ) 78 + 79 + def _add_segment(self, seg: str): 80 + self.current_block.append(TextToken(text=seg)) 81 + self.current_length += _grapheme_length(seg) 82 + 83 + def _split_oversized_text(self, seg: str) -> list[list[Token]]: 84 + result: list[list[Token]] = [] 85 + remaining = seg 86 + while remaining: 87 + remaining_len = _grapheme_length(remaining) 88 + if remaining_len <= self.max_chars: 89 + result.append([TextToken(text=remaining)]) 90 + break 91 + chunk_size = self.max_chars - 1 92 + chunk = grapheme.slice(remaining, 0, chunk_size) + "-" 93 + result.append([TextToken(text=chunk)]) 94 + remaining = grapheme.slice(remaining, chunk_size, remaining_len) 95 + if remaining.startswith(" ") and not remaining.startswith(" "): 96 + remaining = remaining[1:] 97 + return result 98 + 99 + def _strip_leading_space(self): 100 + if self.current_block and isinstance(self.current_block[0], TextToken): 101 + first_token = self.current_block[0] 102 + if first_token.text == " ": 103 + self.current_block.pop(0) 104 + self.current_length -= 1 105 + elif first_token.text.startswith(" ") and not first_token.text.startswith( 106 + " " 107 + ): 108 + self.current_block[0] = TextToken(text=first_token.text[1:]) 109 + self.current_length -= 1 110 + 111 + def _split_at_boundary(self): 112 + if self.best_split_idx: 113 + idx, split_length, _ = self.best_split_idx 114 + self.blocks.append(self.current_block[: idx + 1]) 115 + self.current_block = self.current_block[idx + 1 :] 116 + self.current_length = self.current_length - split_length 117 + self.best_split_idx = None 118 + self._strip_leading_space() 119 + return True 120 + elif self.current_block: 121 + self.blocks.append(self.current_block) 122 + self.current_block = [] 123 + self.current_length = 0 124 + return True 125 + return False 126 + 127 + def _process_text_token(self, token: TextToken): 128 + segments = [s for s in TEXT_SPLITTER.findall(token.text) if s] 129 + 130 + for seg in segments: 131 + seg_len = _grapheme_length(seg) 132 + is_paragraph, is_sentence, is_word, is_content = self._classify_segment(seg) 133 + 134 + while self.current_length + seg_len > self.max_chars: 135 + if self._split_at_boundary(): 136 + pass 137 + else: 138 + if self.current_block: 139 + self.blocks.append(self.current_block) 140 + self.current_block = [] 141 + self.current_length = 0 142 + for block in self._split_oversized_text(seg): 143 + self.blocks.append(block) 144 + seg = "" 145 + seg_len = 0 146 + break 147 + 148 + if seg: 149 + self._add_segment(seg) 150 + if is_paragraph: 151 + self._maybe_update_split_point(3) 152 + elif is_sentence: 153 + self._maybe_update_split_point(2) 154 + elif is_word: 155 + self._maybe_update_split_point(1) 156 + elif is_content: 157 + self._maybe_update_split_point(0) 158 + 159 + def _process_token(self, token: Token) -> bool: 160 + if isinstance(token, TextToken): 161 + self._process_text_token(token) 162 + return True 163 + 164 + token_len = self._get_token_length(token) 165 + 166 + if token_len > self.max_chars: 167 + return False 168 + 169 + if self.current_length + token_len > self.max_chars and self.current_block: 170 + self.blocks.append(self.current_block) 171 + self.current_block = [] 172 + self.current_length = 0 173 + self.best_split_idx = None 174 + 175 + self.current_block.append(token) 176 + self.current_length += token_len 177 + if self.best_split_idx is None or self.best_split_idx[2] < 0: 178 + self.best_split_idx = (len(self.current_block) - 1, self.current_length, 0) 179 + return True 180 + 181 + def split(self, tokens: list[Token]) -> list[list[Token]] | None: 182 + for token in tokens: 183 + if not self._process_token(token): 184 + return None 185 + 186 + self._save_block() 187 + return self.blocks
+40
util/util.py
··· 1 + import logging 2 + import os 3 + import sys 4 + from collections.abc import Callable 5 + from typing import Any 6 + 7 + import env 8 + 9 + 10 + shutdown_hook: list[Callable[[], None]] = [] 11 + 12 + logging.basicConfig(stream=sys.stderr, level=logging.DEBUG if env.DEV else logging.INFO) 13 + logging.getLogger("httpx").setLevel(logging.WARNING) 14 + logging.getLogger("httpcore").setLevel(logging.WARNING) 15 + LOGGER = logging.getLogger("XPost") 16 + 17 + 18 + def normalize_service_url(url: str) -> str: 19 + if not url.startswith("https://") and not url.startswith("http://"): 20 + raise ValueError(f"Invalid service url {url}! Only http/https are supported.") 21 + 22 + return url[:-1] if url.endswith("/") else url 23 + 24 + 25 + def read_env(data: dict[str, Any]) -> None: 26 + keys = list(data.keys()) 27 + for key in keys: 28 + val = data[key] 29 + match val: 30 + case str(): 31 + if val.startswith("env:"): 32 + envval = os.environ.get(val[4:]) 33 + if envval is None: 34 + del data[key] 35 + else: 36 + data[key] = envval 37 + case dict(): 38 + read_env(val) 39 + case _: 40 + pass
+367
uv.lock
··· 1 + version = 1 2 + revision = 3 3 + requires-python = ">=3.12" 4 + 5 + [[package]] 6 + name = "anyio" 7 + version = "4.12.1" 8 + source = { registry = "https://pypi.org/simple" } 9 + dependencies = [ 10 + { name = "idna" }, 11 + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 12 + ] 13 + sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } 14 + wheels = [ 15 + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, 16 + ] 17 + 18 + [[package]] 19 + name = "certifi" 20 + version = "2025.10.5" 21 + source = { registry = "https://pypi.org/simple" } 22 + sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } 23 + wheels = [ 24 + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, 25 + ] 26 + 27 + [[package]] 28 + name = "colorama" 29 + version = "0.4.6" 30 + source = { registry = "https://pypi.org/simple" } 31 + sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 32 + wheels = [ 33 + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 34 + ] 35 + 36 + [[package]] 37 + name = "dnspython" 38 + version = "2.8.0" 39 + source = { registry = "https://pypi.org/simple" } 40 + sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } 41 + wheels = [ 42 + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, 43 + ] 44 + 45 + [[package]] 46 + name = "grapheme" 47 + version = "0.6.0" 48 + source = { registry = "https://pypi.org/simple" } 49 + sdist = { url = "https://files.pythonhosted.org/packages/ce/e7/bbaab0d2a33e07c8278910c1d0d8d4f3781293dfbc70b5c38197159046bf/grapheme-0.6.0.tar.gz", hash = "sha256:44c2b9f21bbe77cfb05835fec230bd435954275267fea1858013b102f8603cca", size = 207306, upload-time = "2020-03-07T17:13:55.492Z" } 50 + 51 + [[package]] 52 + name = "h11" 53 + version = "0.16.0" 54 + source = { registry = "https://pypi.org/simple" } 55 + sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } 56 + wheels = [ 57 + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, 58 + ] 59 + 60 + [[package]] 61 + name = "httpcore" 62 + version = "1.0.9" 63 + source = { registry = "https://pypi.org/simple" } 64 + dependencies = [ 65 + { name = "certifi" }, 66 + { name = "h11" }, 67 + ] 68 + sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } 69 + wheels = [ 70 + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, 71 + ] 72 + 73 + [[package]] 74 + name = "httpx" 75 + version = "0.28.1" 76 + source = { registry = "https://pypi.org/simple" } 77 + dependencies = [ 78 + { name = "anyio" }, 79 + { name = "certifi" }, 80 + { name = "httpcore" }, 81 + { name = "idna" }, 82 + ] 83 + sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } 84 + wheels = [ 85 + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, 86 + ] 87 + 88 + [[package]] 89 + name = "idna" 90 + version = "3.11" 91 + source = { registry = "https://pypi.org/simple" } 92 + sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } 93 + wheels = [ 94 + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, 95 + ] 96 + 97 + [[package]] 98 + name = "iniconfig" 99 + version = "2.3.0" 100 + source = { registry = "https://pypi.org/simple" } 101 + sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } 102 + wheels = [ 103 + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, 104 + ] 105 + 106 + [[package]] 107 + name = "librt" 108 + version = "0.8.0" 109 + source = { registry = "https://pypi.org/simple" } 110 + sdist = { url = "https://files.pythonhosted.org/packages/8a/3f/4ca7dd7819bf8ff303aca39c3c60e5320e46e766ab7f7dd627d3b9c11bdf/librt-0.8.0.tar.gz", hash = "sha256:cb74cdcbc0103fc988e04e5c58b0b31e8e5dd2babb9182b6f9490488eb36324b", size = 177306, upload-time = "2026-02-12T14:53:54.743Z" } 111 + wheels = [ 112 + { url = "https://files.pythonhosted.org/packages/fb/53/f3bc0c4921adb0d4a5afa0656f2c0fbe20e18e3e0295e12985b9a5dc3f55/librt-0.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:17269dd2745dbe8e42475acb28e419ad92dfa38214224b1b01020b8cac70b645", size = 66511, upload-time = "2026-02-12T14:52:30.34Z" }, 113 + { url = "https://files.pythonhosted.org/packages/89/4b/4c96357432007c25a1b5e363045373a6c39481e49f6ba05234bb59a839c1/librt-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f4617cef654fca552f00ce5ffdf4f4b68770f18950e4246ce94629b789b92467", size = 68628, upload-time = "2026-02-12T14:52:31.491Z" }, 114 + { url = "https://files.pythonhosted.org/packages/47/16/52d75374d1012e8fc709216b5eaa25f471370e2a2331b8be00f18670a6c7/librt-0.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5cb11061a736a9db45e3c1293cfcb1e3caf205912dfa085734ba750f2197ff9a", size = 198941, upload-time = "2026-02-12T14:52:32.489Z" }, 115 + { url = "https://files.pythonhosted.org/packages/fc/11/d5dd89e5a2228567b1228d8602d896736247424484db086eea6b8010bcba/librt-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4bb00bd71b448f16749909b08a0ff16f58b079e2261c2e1000f2bbb2a4f0a45", size = 210009, upload-time = "2026-02-12T14:52:33.634Z" }, 116 + { url = "https://files.pythonhosted.org/packages/49/d8/fc1a92a77c3020ee08ce2dc48aed4b42ab7c30fb43ce488d388673b0f164/librt-0.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95a719a049f0eefaf1952673223cf00d442952273cbd20cf2ed7ec423a0ef58d", size = 224461, upload-time = "2026-02-12T14:52:34.868Z" }, 117 + { url = "https://files.pythonhosted.org/packages/7f/98/eb923e8b028cece924c246104aa800cf72e02d023a8ad4ca87135b05a2fe/librt-0.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bd32add59b58fba3439d48d6f36ac695830388e3da3e92e4fc26d2d02670d19c", size = 217538, upload-time = "2026-02-12T14:52:36.078Z" }, 118 + { url = "https://files.pythonhosted.org/packages/fd/67/24e80ab170674a1d8ee9f9a83081dca4635519dbd0473b8321deecddb5be/librt-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f764b2424cb04524ff7a486b9c391e93f93dc1bd8305b2136d25e582e99aa2f", size = 225110, upload-time = "2026-02-12T14:52:37.301Z" }, 119 + { url = "https://files.pythonhosted.org/packages/d8/c7/6fbdcbd1a6e5243c7989c21d68ab967c153b391351174b4729e359d9977f/librt-0.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f04ca50e847abc486fa8f4107250566441e693779a5374ba211e96e238f298b9", size = 217758, upload-time = "2026-02-12T14:52:38.89Z" }, 120 + { url = "https://files.pythonhosted.org/packages/4b/bd/4d6b36669db086e3d747434430073e14def032dd58ad97959bf7e2d06c67/librt-0.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9ab3a3475a55b89b87ffd7e6665838e8458e0b596c22e0177e0f961434ec474a", size = 218384, upload-time = "2026-02-12T14:52:40.637Z" }, 121 + { url = "https://files.pythonhosted.org/packages/50/2d/afe966beb0a8f179b132f3e95c8dd90738a23e9ebdba10f89a3f192f9366/librt-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e36a8da17134ffc29373775d88c04832f9ecfab1880470661813e6c7991ef79", size = 241187, upload-time = "2026-02-12T14:52:43.55Z" }, 122 + { url = "https://files.pythonhosted.org/packages/02/d0/6172ea4af2b538462785ab1a68e52d5c99cfb9866a7caf00fdf388299734/librt-0.8.0-cp312-cp312-win32.whl", hash = "sha256:4eb5e06ebcc668677ed6389164f52f13f71737fc8be471101fa8b4ce77baeb0c", size = 54914, upload-time = "2026-02-12T14:52:44.676Z" }, 123 + { url = "https://files.pythonhosted.org/packages/d4/cb/ceb6ed6175612a4337ad49fb01ef594712b934b4bc88ce8a63554832eb44/librt-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:0a33335eb59921e77c9acc05d0e654e4e32e45b014a4d61517897c11591094f8", size = 62020, upload-time = "2026-02-12T14:52:45.676Z" }, 124 + { url = "https://files.pythonhosted.org/packages/f1/7e/61701acbc67da74ce06ddc7ba9483e81c70f44236b2d00f6a4bfee1aacbf/librt-0.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:24a01c13a2a9bdad20997a4443ebe6e329df063d1978bbe2ebbf637878a46d1e", size = 52443, upload-time = "2026-02-12T14:52:47.218Z" }, 125 + { url = "https://files.pythonhosted.org/packages/6d/32/3edb0bcb4113a9c8bdcd1750663a54565d255027657a5df9d90f13ee07fa/librt-0.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7f820210e21e3a8bf8fde2ae3c3d10106d4de9ead28cbfdf6d0f0f41f5b12fa1", size = 66522, upload-time = "2026-02-12T14:52:48.219Z" }, 126 + { url = "https://files.pythonhosted.org/packages/30/ab/e8c3d05e281f5d405ebdcc5bc8ab36df23e1a4b40ac9da8c3eb9928b72b9/librt-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4831c44b8919e75ca0dfb52052897c1ef59fdae19d3589893fbd068f1e41afbf", size = 68658, upload-time = "2026-02-12T14:52:50.351Z" }, 127 + { url = "https://files.pythonhosted.org/packages/7c/d3/74a206c47b7748bbc8c43942de3ed67de4c231156e148b4f9250869593df/librt-0.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:88c6e75540f1f10f5e0fc5e87b4b6c290f0e90d1db8c6734f670840494764af8", size = 199287, upload-time = "2026-02-12T14:52:51.938Z" }, 128 + { url = "https://files.pythonhosted.org/packages/fa/29/ef98a9131cf12cb95771d24e4c411fda96c89dc78b09c2de4704877ebee4/librt-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9646178cd794704d722306c2c920c221abbf080fede3ba539d5afdec16c46dad", size = 210293, upload-time = "2026-02-12T14:52:53.128Z" }, 129 + { url = "https://files.pythonhosted.org/packages/5b/3e/89b4968cb08c53d4c2d8b02517081dfe4b9e07a959ec143d333d76899f6c/librt-0.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e1af31a710e17891d9adf0dbd9a5fcd94901a3922a96499abdbf7ce658f4e01", size = 224801, upload-time = "2026-02-12T14:52:54.367Z" }, 130 + { url = "https://files.pythonhosted.org/packages/6d/28/f38526d501f9513f8b48d78e6be4a241e15dd4b000056dc8b3f06ee9ce5d/librt-0.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:507e94f4bec00b2f590fbe55f48cd518a208e2474a3b90a60aa8f29136ddbada", size = 218090, upload-time = "2026-02-12T14:52:55.758Z" }, 131 + { url = "https://files.pythonhosted.org/packages/02/ec/64e29887c5009c24dc9c397116c680caffc50286f62bd99c39e3875a2854/librt-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f1178e0de0c271231a660fbef9be6acdfa1d596803464706862bef6644cc1cae", size = 225483, upload-time = "2026-02-12T14:52:57.375Z" }, 132 + { url = "https://files.pythonhosted.org/packages/ee/16/7850bdbc9f1a32d3feff2708d90c56fc0490b13f1012e438532781aa598c/librt-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:71fc517efc14f75c2f74b1f0a5d5eb4a8e06aa135c34d18eaf3522f4a53cd62d", size = 218226, upload-time = "2026-02-12T14:52:58.534Z" }, 133 + { url = "https://files.pythonhosted.org/packages/1c/4a/166bffc992d65ddefa7c47052010a87c059b44a458ebaf8f5eba384b0533/librt-0.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0583aef7e9a720dd40f26a2ad5a1bf2ccbb90059dac2b32ac516df232c701db3", size = 218755, upload-time = "2026-02-12T14:52:59.701Z" }, 134 + { url = "https://files.pythonhosted.org/packages/da/5d/9aeee038bcc72a9cfaaee934463fe9280a73c5440d36bd3175069d2cb97b/librt-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d0f76fc73480d42285c609c0ea74d79856c160fa828ff9aceab574ea4ecfd7b", size = 241617, upload-time = "2026-02-12T14:53:00.966Z" }, 135 + { url = "https://files.pythonhosted.org/packages/64/ff/2bec6b0296b9d0402aa6ec8540aa19ebcb875d669c37800cb43d10d9c3a3/librt-0.8.0-cp313-cp313-win32.whl", hash = "sha256:e79dbc8f57de360f0ed987dc7de7be814b4803ef0e8fc6d3ff86e16798c99935", size = 54966, upload-time = "2026-02-12T14:53:02.042Z" }, 136 + { url = "https://files.pythonhosted.org/packages/08/8d/bf44633b0182996b2c7ea69a03a5c529683fa1f6b8e45c03fe874ff40d56/librt-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:25b3e667cbfc9000c4740b282df599ebd91dbdcc1aa6785050e4c1d6be5329ab", size = 62000, upload-time = "2026-02-12T14:53:03.822Z" }, 137 + { url = "https://files.pythonhosted.org/packages/5c/fd/c6472b8e0eac0925001f75e366cf5500bcb975357a65ef1f6b5749389d3a/librt-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:e9a3a38eb4134ad33122a6d575e6324831f930a771d951a15ce232e0237412c2", size = 52496, upload-time = "2026-02-12T14:53:04.889Z" }, 138 + { url = "https://files.pythonhosted.org/packages/e0/13/79ebfe30cd273d7c0ce37a5f14dc489c5fb8b722a008983db2cfd57270bb/librt-0.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:421765e8c6b18e64d21c8ead315708a56fc24f44075059702e421d164575fdda", size = 66078, upload-time = "2026-02-12T14:53:06.085Z" }, 139 + { url = "https://files.pythonhosted.org/packages/4b/8f/d11eca40b62a8d5e759239a80636386ef88adecb10d1a050b38cc0da9f9e/librt-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:48f84830a8f8ad7918afd743fd7c4eb558728bceab7b0e38fd5a5cf78206a556", size = 68309, upload-time = "2026-02-12T14:53:07.121Z" }, 140 + { url = "https://files.pythonhosted.org/packages/9c/b4/f12ee70a3596db40ff3c88ec9eaa4e323f3b92f77505b4d900746706ec6a/librt-0.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f09d4884f882baa39a7e36bbf3eae124c4ca2a223efb91e567381d1c55c6b06", size = 196804, upload-time = "2026-02-12T14:53:08.164Z" }, 141 + { url = "https://files.pythonhosted.org/packages/8b/7e/70dbbdc0271fd626abe1671ad117bcd61a9a88cdc6a10ccfbfc703db1873/librt-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:693697133c3b32aa9b27f040e3691be210e9ac4d905061859a9ed519b1d5a376", size = 206915, upload-time = "2026-02-12T14:53:09.333Z" }, 142 + { url = "https://files.pythonhosted.org/packages/79/13/6b9e05a635d4327608d06b3c1702166e3b3e78315846373446cf90d7b0bf/librt-0.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5512aae4648152abaf4d48b59890503fcbe86e85abc12fb9b096fe948bdd816", size = 221200, upload-time = "2026-02-12T14:53:10.68Z" }, 143 + { url = "https://files.pythonhosted.org/packages/35/6c/e19a3ac53e9414de43a73d7507d2d766cd22d8ca763d29a4e072d628db42/librt-0.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:995d24caa6bbb34bcdd4a41df98ac6d1af637cfa8975cb0790e47d6623e70e3e", size = 214640, upload-time = "2026-02-12T14:53:12.342Z" }, 144 + { url = "https://files.pythonhosted.org/packages/30/f0/23a78464788619e8c70f090cfd099cce4973eed142c4dccb99fc322283fd/librt-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b9aef96d7593584e31ef6ac1eb9775355b0099fee7651fae3a15bc8657b67b52", size = 221980, upload-time = "2026-02-12T14:53:13.603Z" }, 145 + { url = "https://files.pythonhosted.org/packages/03/32/38e21420c5d7aa8a8bd2c7a7d5252ab174a5a8aaec8b5551968979b747bf/librt-0.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4f6e975377fbc4c9567cb33ea9ab826031b6c7ec0515bfae66a4fb110d40d6da", size = 215146, upload-time = "2026-02-12T14:53:14.8Z" }, 146 + { url = "https://files.pythonhosted.org/packages/bb/00/bd9ecf38b1824c25240b3ad982fb62c80f0a969e6679091ba2b3afb2b510/librt-0.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:daae5e955764be8fd70a93e9e5133c75297f8bce1e802e1d3683b98f77e1c5ab", size = 215203, upload-time = "2026-02-12T14:53:16.087Z" }, 147 + { url = "https://files.pythonhosted.org/packages/b9/60/7559bcc5279d37810b98d4a52616febd7b8eef04391714fd6bdf629598b1/librt-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7bd68cebf3131bb920d5984f75fe302d758db33264e44b45ad139385662d7bc3", size = 237937, upload-time = "2026-02-12T14:53:17.236Z" }, 148 + { url = "https://files.pythonhosted.org/packages/41/cc/be3e7da88f1abbe2642672af1dc00a0bccece11ca60241b1883f3018d8d5/librt-0.8.0-cp314-cp314-win32.whl", hash = "sha256:1e6811cac1dcb27ca4c74e0ca4a5917a8e06db0d8408d30daee3a41724bfde7a", size = 50685, upload-time = "2026-02-12T14:53:18.888Z" }, 149 + { url = "https://files.pythonhosted.org/packages/38/27/e381d0df182a8f61ef1f6025d8b138b3318cc9d18ad4d5f47c3bf7492523/librt-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:178707cda89d910c3b28bf5aa5f69d3d4734e0f6ae102f753ad79edef83a83c7", size = 57872, upload-time = "2026-02-12T14:53:19.942Z" }, 150 + { url = "https://files.pythonhosted.org/packages/c5/0c/ca9dfdf00554a44dea7d555001248269a4bab569e1590a91391feb863fa4/librt-0.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3e8b77b5f54d0937b26512774916041756c9eb3e66f1031971e626eea49d0bf4", size = 48056, upload-time = "2026-02-12T14:53:21.473Z" }, 151 + { url = "https://files.pythonhosted.org/packages/f2/ed/6cc9c4ad24f90c8e782193c7b4a857408fd49540800613d1356c63567d7b/librt-0.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:789911e8fa40a2e82f41120c936b1965f3213c67f5a483fc5a41f5839a05dcbb", size = 68307, upload-time = "2026-02-12T14:53:22.498Z" }, 152 + { url = "https://files.pythonhosted.org/packages/84/d8/0e94292c6b3e00b6eeea39dd44d5703d1ec29b6dafce7eea19dc8f1aedbd/librt-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2b37437e7e4ef5e15a297b36ba9e577f73e29564131d86dd75875705e97402b5", size = 70999, upload-time = "2026-02-12T14:53:23.603Z" }, 153 + { url = "https://files.pythonhosted.org/packages/0e/f4/6be1afcbdeedbdbbf54a7c9d73ad43e1bf36897cebf3978308cd64922e02/librt-0.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:671a6152edf3b924d98a5ed5e6982ec9cb30894085482acadce0975f031d4c5c", size = 220782, upload-time = "2026-02-12T14:53:25.133Z" }, 154 + { url = "https://files.pythonhosted.org/packages/f0/8d/f306e8caa93cfaf5c6c9e0d940908d75dc6af4fd856baa5535c922ee02b1/librt-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8992ca186a1678107b0af3d0c9303d8c7305981b9914989b9788319ed4d89546", size = 235420, upload-time = "2026-02-12T14:53:27.047Z" }, 155 + { url = "https://files.pythonhosted.org/packages/d6/f2/65d86bd462e9c351326564ca805e8457442149f348496e25ccd94583ffa2/librt-0.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:001e5330093d887b8b9165823eca6c5c4db183fe4edea4fdc0680bbac5f46944", size = 246452, upload-time = "2026-02-12T14:53:28.341Z" }, 156 + { url = "https://files.pythonhosted.org/packages/03/94/39c88b503b4cb3fcbdeb3caa29672b6b44ebee8dcc8a54d49839ac280f3f/librt-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d920789eca7ef71df7f31fd547ec0d3002e04d77f30ba6881e08a630e7b2c30e", size = 238891, upload-time = "2026-02-12T14:53:29.625Z" }, 157 + { url = "https://files.pythonhosted.org/packages/e3/c6/6c0d68190893d01b71b9569b07a1c811e280c0065a791249921c83dc0290/librt-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:82fb4602d1b3e303a58bfe6165992b5a78d823ec646445356c332cd5f5bbaa61", size = 250249, upload-time = "2026-02-12T14:53:30.93Z" }, 158 + { url = "https://files.pythonhosted.org/packages/52/7a/f715ed9e039035d0ea637579c3c0155ab3709a7046bc408c0fb05d337121/librt-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4d3e38797eb482485b486898f89415a6ab163bc291476bd95712e42cf4383c05", size = 240642, upload-time = "2026-02-12T14:53:32.174Z" }, 159 + { url = "https://files.pythonhosted.org/packages/c2/3c/609000a333debf5992efe087edc6467c1fdbdddca5b610355569bbea9589/librt-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a905091a13e0884701226860836d0386b88c72ce5c2fdfba6618e14c72be9f25", size = 239621, upload-time = "2026-02-12T14:53:33.39Z" }, 160 + { url = "https://files.pythonhosted.org/packages/b9/df/87b0673d5c395a8f34f38569c116c93142d4dc7e04af2510620772d6bd4f/librt-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:375eda7acfce1f15f5ed56cfc960669eefa1ec8732e3e9087c3c4c3f2066759c", size = 262986, upload-time = "2026-02-12T14:53:34.617Z" }, 161 + { url = "https://files.pythonhosted.org/packages/09/7f/6bbbe9dcda649684773aaea78b87fff4d7e59550fbc2877faa83612087a3/librt-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:2ccdd20d9a72c562ffb73098ac411de351b53a6fbb3390903b2d33078ef90447", size = 51328, upload-time = "2026-02-12T14:53:36.15Z" }, 162 + { url = "https://files.pythonhosted.org/packages/bb/f3/e1981ab6fa9b41be0396648b5850267888a752d025313a9e929c4856208e/librt-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:25e82d920d4d62ad741592fcf8d0f3bda0e3fc388a184cb7d2f566c681c5f7b9", size = 58719, upload-time = "2026-02-12T14:53:37.183Z" }, 163 + { url = "https://files.pythonhosted.org/packages/94/d1/433b3c06e78f23486fe4fdd19bc134657eb30997d2054b0dbf52bbf3382e/librt-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:92249938ab744a5890580d3cb2b22042f0dce71cdaa7c1369823df62bedf7cbc", size = 48753, upload-time = "2026-02-12T14:53:38.539Z" }, 164 + ] 165 + 166 + [[package]] 167 + name = "mypy" 168 + version = "1.19.1" 169 + source = { registry = "https://pypi.org/simple" } 170 + dependencies = [ 171 + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, 172 + { name = "mypy-extensions" }, 173 + { name = "pathspec" }, 174 + { name = "typing-extensions" }, 175 + ] 176 + sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } 177 + wheels = [ 178 + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, 179 + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, 180 + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, 181 + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, 182 + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, 183 + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, 184 + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, 185 + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, 186 + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, 187 + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, 188 + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, 189 + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, 190 + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, 191 + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, 192 + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, 193 + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, 194 + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, 195 + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, 196 + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, 197 + ] 198 + 199 + [[package]] 200 + name = "mypy-extensions" 201 + version = "1.1.0" 202 + source = { registry = "https://pypi.org/simple" } 203 + sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } 204 + wheels = [ 205 + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, 206 + ] 207 + 208 + [[package]] 209 + name = "packaging" 210 + version = "25.0" 211 + source = { registry = "https://pypi.org/simple" } 212 + sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } 213 + wheels = [ 214 + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, 215 + ] 216 + 217 + [[package]] 218 + name = "pathspec" 219 + version = "1.0.4" 220 + source = { registry = "https://pypi.org/simple" } 221 + sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } 222 + wheels = [ 223 + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, 224 + ] 225 + 226 + [[package]] 227 + name = "pluggy" 228 + version = "1.6.0" 229 + source = { registry = "https://pypi.org/simple" } 230 + sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } 231 + wheels = [ 232 + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, 233 + ] 234 + 235 + [[package]] 236 + name = "pygments" 237 + version = "2.19.2" 238 + source = { registry = "https://pypi.org/simple" } 239 + sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } 240 + wheels = [ 241 + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, 242 + ] 243 + 244 + [[package]] 245 + name = "pytest" 246 + version = "8.4.2" 247 + source = { registry = "https://pypi.org/simple" } 248 + dependencies = [ 249 + { name = "colorama", marker = "sys_platform == 'win32'" }, 250 + { name = "iniconfig" }, 251 + { name = "packaging" }, 252 + { name = "pluggy" }, 253 + { name = "pygments" }, 254 + ] 255 + sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } 256 + wheels = [ 257 + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, 258 + ] 259 + 260 + [[package]] 261 + name = "python-magic" 262 + version = "0.4.27" 263 + source = { registry = "https://pypi.org/simple" } 264 + sdist = { url = "https://files.pythonhosted.org/packages/da/db/0b3e28ac047452d079d375ec6798bf76a036a08182dbb39ed38116a49130/python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", size = 14677, upload-time = "2022-06-07T20:16:59.508Z" } 265 + wheels = [ 266 + { url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840, upload-time = "2022-06-07T20:16:57.763Z" }, 267 + ] 268 + 269 + [[package]] 270 + name = "ruff" 271 + version = "0.15.1" 272 + source = { registry = "https://pypi.org/simple" } 273 + sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" } 274 + wheels = [ 275 + { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" }, 276 + { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" }, 277 + { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" }, 278 + { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" }, 279 + { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" }, 280 + { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" }, 281 + { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" }, 282 + { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" }, 283 + { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" }, 284 + { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" }, 285 + { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" }, 286 + { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" }, 287 + { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" }, 288 + { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" }, 289 + { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" }, 290 + { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" }, 291 + { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, 292 + ] 293 + 294 + [[package]] 295 + name = "typing-extensions" 296 + version = "4.15.0" 297 + source = { registry = "https://pypi.org/simple" } 298 + sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } 299 + wheels = [ 300 + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, 301 + ] 302 + 303 + [[package]] 304 + name = "websockets" 305 + version = "15.0.1" 306 + source = { registry = "https://pypi.org/simple" } 307 + sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } 308 + wheels = [ 309 + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, 310 + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, 311 + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, 312 + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, 313 + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, 314 + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, 315 + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, 316 + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, 317 + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, 318 + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, 319 + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, 320 + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, 321 + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, 322 + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, 323 + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, 324 + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, 325 + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, 326 + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, 327 + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, 328 + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, 329 + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, 330 + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, 331 + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, 332 + ] 333 + 334 + [[package]] 335 + name = "xpost" 336 + version = "0.1.0" 337 + source = { virtual = "." } 338 + dependencies = [ 339 + { name = "dnspython" }, 340 + { name = "grapheme" }, 341 + { name = "httpx" }, 342 + { name = "python-magic" }, 343 + { name = "websockets" }, 344 + ] 345 + 346 + [package.dev-dependencies] 347 + dev = [ 348 + { name = "mypy" }, 349 + { name = "pytest" }, 350 + { name = "ruff" }, 351 + ] 352 + 353 + [package.metadata] 354 + requires-dist = [ 355 + { name = "dnspython", specifier = ">=2.8.0" }, 356 + { name = "grapheme", specifier = ">=0.6.0" }, 357 + { name = "httpx", specifier = ">=0.27.0" }, 358 + { name = "python-magic", specifier = ">=0.4.27" }, 359 + { name = "websockets", specifier = ">=15.0.1" }, 360 + ] 361 + 362 + [package.metadata.requires-dev] 363 + dev = [ 364 + { name = "mypy", specifier = ">=1.15.0" }, 365 + { name = "pytest", specifier = ">=8.4.2" }, 366 + { name = "ruff", specifier = ">=0.9.0" }, 367 + ]