+8
.dockerignore
+8
.dockerignore
+50
.tangled/workflows/build-images.yml
+50
.tangled/workflows/build-images.yml
···
+41
Containerfile
+41
Containerfile
···
+37
-10
README.md
+37
-10
README.md
···+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.+XPost tries to support as many features as possible. for example, when cross-posting from mastodon to bluesky, unsupported file types will be attached as links. posts with mixed media or too many files will be split and spread across text.+the tool may undergo breaking changes as new features are added, so proceed with caution when deploying.first install `ffmpeg`, `ffprobe` and `libmagic`, make sure that `ffmpeg` is available on PATH! `ffmpeg` and `libmagic` are required to crosspost media.···+the official immage is available on [docker hub](https://hub.docker.com/r/melontini/xpost). example `compose.yaml`. this assumes that data dir is `./data`, and env file is `./.config/docker.env`. add `:Z` to volume mounts for podman.···-**this is meant for self-hosted PDSs that don't emmit a billion events per second.** a jetstream version will be available soon.-listens to repo operation events emmited by the PDS. handle becomes optional if you specify a DID.+listens to repo operation events emmited by Jetstream. handle becomes optional if you specify a DID.+"jetstream": "wss://jetstream2.us-east.bsky.network/subscribe" //optional, change jetstream endpoint···-"token": "env:MASTODON_TOKEN", // Must be a mastodon token. get from something like phanpy + webtools. or https://getauth.thms.uk/?client_name=xpost&scopes=read:statuses%20write:statuses%20profile but doesn't work with all software+"token": "env:MASTODON_TOKEN", // Must be a mastodon token. get from something like phanpy + webtools. or https://getauth.thms.uk/?client_name=xpost&scopes=read%20write%20profile but doesn't work with all software···+"bsky_appview": "env:BLUESKY_APPVIEW", // bypass suspensions by specifying a different appview (e.g. did:web:bsky.zeppelin.social)"encode_videos": true, // bluesky only accepts mp4 videos, try to convert if the video is not mp4···
-186
atproto2.py
-186
atproto2.py
···-embed=models.AppBskyEmbedVideo.Main(video=upload.blob, alt=video_alt, aspect_ratio=video_aspect_ratio),-def create_gates(self, thread_gate_opts: list[str], quote_gate: bool, post_uri: str, time_iso: str | None = None):
+196
bluesky/atproto2.py
+196
bluesky/atproto2.py
···
+199
bluesky/common.py
+199
bluesky/common.py
···
+203
bluesky/input.py
+203
bluesky/input.py
···
+481
bluesky/output.py
+481
bluesky/output.py
···
-684
bluesky.py
-684
bluesky.py
···-from atproto_firehose import models as firehose_models, parse_subscribe_repos_message as parse_firehose-async def listen(self, outputs: list[cross.Output], submit: Callable[[Callable[[], Any]], Any]):-raise ValueError(f"'thread_gate' only accepts {', '.join(ALLOWED_GATES)} or [], got: {thread_gate}")-root_record = models.AppBskyFeedPost.CreateRecordResponse(uri=str(root_ref['uri']), cid=str(root_ref['cid']))-reply_record = models.AppBskyFeedPost.CreateRecordResponse(uri=str(reply_ref['uri']), cid=str(reply_ref['cid']))-labels = models.ComAtprotoLabelDefs.SelfLabels(values=[models.ComAtprotoLabelDefs.SelfLabel(val=label) for label in unique_labels])-LOGGER.error("Skipping post_id '%s', failed to download attachment! File too large.", post.get_id())-LOGGER.error("Skipping post_id '%s', failed to download attachment! File too large?", post.get_id())
+151
-232
cross.py
+151
-232
cross.py
···-MD_INLINE_LINK = re.compile(r"\[([^\]]+)\]\(\s*((?:(?:[A-Za-z][A-Za-z0-9+.\-]*://)|mailto:)[^\s\)]+)\s*\)", re.IGNORECASE)-MD_AUTOLINK = re.compile(r"<((?:(?:[A-Za-z][A-Za-z0-9+.\-]*://)|mailto:)[^\s>]+)>", re.IGNORECASE)-def tokenize_markdown(text: str, tags: list[str], handles: list[tuple[str, str]]) -> list[Token]:-def split_tokens(tokens: list[Token], max_chars: int, max_link_len: int = 35) -> list[list[Token]]:
-191
database.py
-191
database.py
···-reply_mappings: list[str] | None = find_mappings(db, reply_data['id'], output_service, output_user)-def insert_reply(db: DataBaseWorker, identifier: str, user_id: str, serivce: str, parent: int, root: int) -> int:-def find_mappings(db: DataBaseWorker, original_post: int, service: str, user_id: str) -> list[str]:-def find_post(db: DataBaseWorker, identifier: str, user_id: str, service: str) -> dict | None:
+80
-55
main.py
+80
-55
main.py
············
-193
markeddown.py
-193
markeddown.py
···
+52
mastodon/common.py
+52
mastodon/common.py
···
+225
mastodon/input.py
+225
mastodon/input.py
···+f"'allowed_visibility' only accepts {', '.join(ALLOWED_VISIBILITY)}, got: {allowed_visibility}"
+448
mastodon/output.py
+448
mastodon/output.py
···
-602
mastodon.py
-602
mastodon.py
···-raise ValueError(f"'allowed_visibility' only accepts {', '.join(ALLOWED_VISIBILITY)}, got: {allowed_visibility}")-self.token = util.as_envvar(settings.get('token')) or (_ for _ in ()).throw(ValueError("'token' is required"))-instance: str = util.as_envvar(settings.get('instance')) or (_ for _ in ()).throw(ValueError("'instance' is required"))-if status.get('reblog') or (status.get('quote_id') or status.get('quote')) or status.get('poll'):-success = database.try_insert_post(self.db, status['id'], in_reply, self.user_id, self.service)-async def listen(self, outputs: list[cross.Output], submit: Callable[[Callable[[], Any]], Any]):-raise ValueError(f"'visibility' only accepts {', '.join(ALLOWED_POSTING_VISIBILITY)}, got: {visibility}")-self.token = util.as_envvar(settings.get('token')) or (_ for _ in ()).throw(ValueError("'token' is required"))-instance: str = util.as_envvar(settings.get('instance')) or (_ for _ in ()).throw(ValueError("'instance' is required"))-self.characters_reserved_per_url: int = statuses_config.get('characters_reserved_per_url', 23)-self.supported_mime_types: list[str] = media_config.get('supported_mime_types', POSSIBLE_MIMES)-split_tokens = cross.split_tokens(tokens, self.max_characters, self.characters_reserved_per_url)
-143
media_util.py
-143
media_util.py
···-proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)-proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+54
misskey/common.py
+54
misskey/common.py
···
+202
misskey/input.py
+202
misskey/input.py
···+f"'allowed_visibility' only accepts {', '.join(ALLOWED_VISIBILITY)}, got: {allowed_visibility}"
+38
misskey/mfm_util.py
+38
misskey/mfm_util.py
···
-183
misskey.py
-183
misskey.py
···-raise ValueError(f"'allowed_visibility' only accepts {', '.join(ALLOWED_VISIBILITY)}, got: {allowed_visibility}")-self.token = util.as_envvar(settings.get('token')) or (_ for _ in ()).throw(ValueError("'token' is required"))-instance: str = util.as_envvar(settings.get('instance')) or (_ for _ in ()).throw(ValueError("'instance' is required"))-async def listen(self, outputs: list[cross.Output], submit: Callable[[Callable[[], Any]], Any]):
-1
pyproject.toml
-1
pyproject.toml
+290
util/database.py
+290
util/database.py
···
+172
util/html_util.py
+172
util/html_util.py
···
+123
util/md_util.py
+123
util/md_util.py
···
+160
util/media.py
+160
util/media.py
···
+43
util/util.py
+43
util/util.py
···
-36
util.py
-36
util.py
···
-36
uv.lock
-36
uv.lock
···-sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload-time = "2025-04-15T17:05:13.836Z" }-{ url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" },-sdist = { url = "https://files.pythonhosted.org/packages/c9/aa/4acaf814ff901145da37332e05bb510452ebed97bc9602695059dd46ef39/bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925", size = 698, upload-time = "2024-01-17T18:15:47.371Z" }-{ url = "https://files.pythonhosted.org/packages/51/bb/bf7aab772a159614954d84aa832c129624ba6c32faa559dfb200a534e50b/bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc", size = 1189, upload-time = "2024-01-17T18:15:48.613Z" },···-sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" }-{ url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" },······