X to Bluesky replicator — mirrors tweets from watched accounts to Bluesky with link embeds
  • Python 98%
  • Dockerfile 2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-04 16:17:27 -05:00
.env.example Add optional LLM content filter (LLM_FILTER toggle, LLM_PROMPT b64, OpenAI-compatible) 2026-08-03 22:36:06 -05:00
.env.template Initial: X-to-Bluesky replicator — base64 cookie support, env-only config (no secrets) 2026-08-03 18:37:43 -05:00
.gitignore Initial: X-to-Bluesky replicator — base64 cookie support, env-only config (no secrets) 2026-08-03 18:37:43 -05:00
app.py Log LLM decisions: show tweet snippet + model answer for audit 2026-08-04 16:17:27 -05:00
docker-compose.yml Initial: X-to-Bluesky replicator — base64 cookie support, env-only config (no secrets) 2026-08-03 18:37:43 -05:00
Dockerfile Initial: X-to-Bluesky replicator — base64 cookie support, env-only config (no secrets) 2026-08-03 18:37:43 -05:00
e2e_test.py Initial: X-to-Bluesky replicator — base64 cookie support, env-only config (no secrets) 2026-08-03 18:37:43 -05:00
README.md Initial: X-to-Bluesky replicator — base64 cookie support, env-only config (no secrets) 2026-08-03 18:37:43 -05:00
requirements.txt Initial: X-to-Bluesky replicator — base64 cookie support, env-only config (no secrets) 2026-08-03 18:37:43 -05:00

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-client repo 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.json to re-mirror.