X to Bluesky replicator — mirrors tweets from watched accounts to Bluesky with link embeds
- Python 98%
- Dockerfile 2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .env.example | ||
| .env.template | ||
| .gitignore | ||
| app.py | ||
| docker-compose.yml | ||
| Dockerfile | ||
| e2e_test.py | ||
| README.md | ||
| requirements.txt | ||
twitter2bsky
Mirrors X (Twitter) posts from watched accounts to a Bluesky feed. Built for the Aburrá Valley traffic-alert use case: municipal mobility accounts post closures and incidents on X; this replicator makes them available on Bluesky, near real-time, for free.
Standalone service — no dependency on Hermes. Runs in Docker on your own server.
How it works
- Polls X via authenticated GraphQL (
UserTweets) using session cookies (auth_token,ct0,twid) — no paid API, no credits. - Poll cadence:
POLL_SECONDS± jitter (default 60s ± 15s) to avoid metronome detection patterns. On 429 rate limits the script just keeps retrying on the next cycle — it degrades, never breaks. - Query IDs rotate on X; the script refreshes them from the open-source
agent-twitter-clientrepo on startup, falling back to a built-in value. - New posts are published to Bluesky as a text post with an external link card to the original tweet. Retweets are skipped by default.
- Last-seen tweet ID per account is persisted in
state.json(mounted volume), so restarts never re-post.
Quick start (Docker)
# 1. Put your secrets in place
mkdir -p secrets data
cp /path/to/x_cookies.json secrets/x_cookies.json # {auth_token, ct0, twid}
echo "lr2k-jwuo-e5lb-6lck" > secrets/bsky_app_password.txt
cp .env.example .env # edit as needed
# 2. Dry run first (logs would-post, publishes nothing)
DRY_RUN=1 docker compose up --build
# Ctrl-C after you see it detect a post
# 3. Go live
docker compose up -d --build
# 4. Watch it
docker compose logs -f
Secrets live in ./secrets/ (mounted read-only into the container) — never
baked into the image. If the bot is ever compromised, rotate the Bluesky app
password from Settings → App Passwords (two clicks, no account risk).
Configuration (.env)
| Var | Default | Meaning |
|---|---|---|
BSKY_HANDLE |
movilidadaburra.bsky.social |
Bluesky bot account |
BSKY_APP_PASSWORD |
(file fallback) | App password, or use secrets file |
X_COOKIES_FILE |
/secrets/x_cookies.json |
X session cookies |
WATCH |
MovilidadEnv |
Comma-separated X handles to mirror |
POLL_SECONDS |
60 |
Base poll interval |
JITTER |
15 |
Random ± jitter in seconds |
SKIP_RETWEETS |
1 |
Skip pure retweets |
DRY_RUN |
0 |
Log-only mode (safe first run) |
STATE_FILE |
/data/state.json |
Last-seen IDs (persisted) |
Alternative: systemd (no Docker)
If you prefer not to use Docker:
# /etc/systemd/system/twitter2bsky.service
[Unit]
Description=twitter2bsky replicator
After=network-online.target
[Service]
WorkingDirectory=/opt/twitter2bsky
EnvironmentFile=/opt/twitter2bsky/.env
ExecStart=/usr/bin/python3 -u /opt/twitter2bsky/app.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
systemctl daemon-reload && systemctl enable --now twitter2bsky
(POLL_SECONDS in the env does the timing — no cron needed either way.)
Ad-hoc run modes
python3 app.py --once # single poll cycle, then exit
DRY_RUN=1 python3 app.py # log everything, publish nothing
Caveats
- Cookie session can expire — re-export fresh cookies from the browser and restart the container.
- X may tighten guest/auth access without notice; the script falls back to built-in queryIds and retries, so it degrades gracefully.
- First run backfills: it will mirror the most recent post currently on the
account (usually desirable). Delete
state.jsonto re-mirror.