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

split streams

zenfyr.dev 6f8993c0 d58e914e

verified
Changed files
+98 -31
bluesky
cross
mastodon
misskey
+52 -8
bluesky/input.py
···
import asyncio
+
import json
import re
from abc import ABC
from dataclasses import dataclass, field
-
from typing import Any, Callable, override
+
from typing import Any, cast, override
import websockets
from bluesky.info import SERVICE, BlueskyService, validate_and_transform
-
from cross.service import InputService, OutputService
+
from cross.service import InputService
from database.connection import DatabasePool
from util.util import LOGGER, normalize_service_url
···
def __init__(self, db: DatabasePool) -> None:
super().__init__(SERVICE, db)
+
def _on_post(self, record: dict[str, Any]):
+
LOGGER.info(record) # TODO
+
+
def _on_repost(self, record: dict[str, Any]):
+
LOGGER.info(record) # TODO
+
+
def _on_delete_post(self, post_id: str, repost: bool):
+
LOGGER.info("%s | %s", post_id, repost) # TODO
+
class BlueskyJetstreamInputService(BlueskyBaseInputService):
def __init__(self, db: DatabasePool, options: BlueskyJetstreamInputOptions) -> None:
···
def get_identity_options(self) -> tuple[str | None, str | None, str | None]:
return (self.options.handle, self.options.did, self.options.pds)
+
def _accept_msg(self, msg: websockets.Data) -> None:
+
data: dict[str, Any] = cast(dict[str, Any], json.loads(msg))
+
if data.get("did") != self.did:
+
return
+
commit: dict[str, Any] | None = data.get("commit")
+
if not commit:
+
return
+
+
commit_type: str = cast(str, commit["operation"])
+
match commit_type:
+
case "create":
+
record: dict[str, Any] = cast(dict[str, Any], commit["record"])
+
record["$xpost.strongRef"] = {
+
"cid": commit["cid"],
+
"uri": f"at://{self.did}/{commit['collection']}/{commit['rkey']}",
+
}
+
+
match cast(str, commit["collection"]):
+
case "app.bsky.feed.post":
+
self._on_post(record)
+
case "app.bsky.feed.repost":
+
self._on_repost(record)
+
case _:
+
pass
+
case "delete":
+
post_id: str = (
+
f"at://{self.did}/{commit['collection']}/{commit['rkey']}"
+
)
+
match cast(str, commit["collection"]):
+
case "app.bsky.feed.post":
+
self._on_delete_post(post_id, False)
+
case "app.bsky.feed.repost":
+
self._on_delete_post(post_id, True)
+
case _:
+
pass
+
case _:
+
pass
+
@override
-
async def listen(
-
self,
-
outputs: list[OutputService],
-
submitter: Callable[[Callable[[], None]], None],
-
):
+
async def listen(self):
url = self.options.jetstream + "?"
url += "wantedCollections=app.bsky.feed.post"
url += "&wantedCollections=app.bsky.feed.repost"
···
async def listen_for_messages():
async for msg in ws:
-
LOGGER.info(msg) # TODO
+
self.submitter(lambda: self._accept_msg(msg))
listen = asyncio.create_task(listen_for_messages())
+5 -6
cross/service.py
···
def __init__(self, url: str, db: DatabasePool) -> None:
self.url: str = url
self.db: DatabasePool = db
-
#self._lock: threading.Lock = threading.Lock()
+
# self._lock: threading.Lock = threading.Lock()
def get_post(self, url: str, user: str, identifier: str) -> sqlite3.Row | None:
cursor = self.db.get_conn().cursor()
···
class InputService(ABC, Service):
+
outputs: list[OutputService]
+
submitter: Callable[[Callable[[], None]], None]
+
@abstractmethod
-
async def listen(
-
self,
-
outputs: list[OutputService],
-
submitter: Callable[[Callable[[], None]], None],
-
):
+
async def listen(self):
pass
+3 -1
main.py
···
thread.start()
LOGGER.info("Connecting to %s...", input.url)
+
input.outputs = outputs
+
input.submitter = lambda c: task_queue.put(c)
try:
-
asyncio.run(input.listen(outputs, lambda c: task_queue.put(c)))
+
asyncio.run(input.listen())
except KeyboardInterrupt:
LOGGER.info("Stopping...")
+21 -8
mastodon/input.py
···
import asyncio
+
import json
import re
from dataclasses import dataclass, field
-
from typing import Any, Callable, override
+
from typing import Any, cast, override
import websockets
-
from cross.service import InputService, OutputService
+
from cross.service import InputService
from database.connection import DatabasePool
from mastodon.info import MastodonService, validate_and_transform
from util.util import LOGGER
···
def _get_token(self) -> str:
return self.options.token
+
def _on_create_post(self, status: dict[str, Any]):
+
LOGGER.info(status) # TODO
+
+
def _on_delete_post(self, status_id: str):
+
LOGGER.info(status_id) # TODO
+
+
def _accept_msg(self, msg: websockets.Data) -> None:
+
data: dict[str, Any] = cast(dict[str, Any], json.loads(msg))
+
event: str = cast(str, data['event'])
+
payload: str = cast(str, data['payload'])
+
+
if event == "update":
+
self._on_create_post(json.loads(payload))
+
elif event == "delete":
+
self._on_delete_post(payload)
+
@override
-
async def listen(
-
self,
-
outputs: list[OutputService],
-
submitter: Callable[[Callable[[], None]], None],
-
):
+
async def listen(self):
url = f"{self.streaming_url}/api/v1/streaming?stream=user"
async for ws in websockets.connect(
···
async def listen_for_messages():
async for msg in ws:
-
LOGGER.info(msg) # TODO
+
self.submitter(lambda: self._accept_msg(msg))
listen = asyncio.create_task(listen_for_messages())
+17 -8
misskey/input.py
···
import re
import uuid
from dataclasses import dataclass, field
-
from typing import Any, Callable, override
+
from typing import Any, cast, override
import websockets
-
from cross.service import InputService, OutputService
+
from cross.service import InputService
from database.connection import DatabasePool
from misskey.info import MisskeyService
from util.util import LOGGER, normalize_service_url
···
def _get_token(self) -> str:
return self.options.token
+
def _on_note(self, note: dict[str, Any]):
+
LOGGER.info(note) # TODO
+
+
def _accept_msg(self, msg: websockets.Data) -> None:
+
data: dict[str, Any] = cast(dict[str, Any], json.loads(msg))
+
+
if data["type"] == "channel":
+
type: str = cast(str, data["body"]["type"])
+
if type == "note" or type == "reply":
+
note_body = data["body"]["body"]
+
self._on_note(note_body)
+
return
+
async def _subscribe_to_home(self, ws: websockets.ClientConnection) -> None:
await ws.send(
json.dumps(
···
LOGGER.info("Subscribed to 'homeTimeline' channel...")
@override
-
async def listen(
-
self,
-
outputs: list[OutputService],
-
submitter: Callable[[Callable[[], None]], None],
-
):
+
async def listen(self):
streaming: str = f"{'wss' if self.url.startswith('https') else 'ws'}://{self.url.split('://', 1)[1]}"
url: str = f"{streaming}/streaming?i={self.options.token}"
···
async def listen_for_messages():
async for msg in ws:
-
LOGGER.info(msg) # TODO
+
self.submitter(lambda: self._accept_msg(msg))
listen = asyncio.create_task(listen_for_messages())