English Β· Deutsch
Two small bots that forward content to Bluesky automatically β written for a supporters' project that wanted club news available where it was otherwise missing. Sport is not a requirement: the ticker works just as well for events, clubs or other channels, then without the fixture lookup.
The destination is deliberately fixed to Bluesky. The source is interchangeable: right now there is a WhatsApp channel connector and an Instagram one.
Unofficial. This project has no connection to Meta, Bluesky or any club. It uses unofficial interfaces β please read Risks and legal matters before running it.
| Program | How it runs | Source (today) |
|---|---|---|
skyrelay-matchday.py |
Tied to matchdays: runs only on matchdays, listens for live events and posts at once | WhatsApp channel |
skyrelay-feed.py |
All year round: checks for new posts at regular intervals | Instagram profile |
They are named after how they run, not after the source β so that changing the source later does not force a rename.
A source is currently tied to its program. Whether a combination makes sense at all comes down to a single question: does the source announce new posts by itself (push), or does it have to be asked at intervals?
| Source | Ticker (event driven, time window) | Feed (all year round, polling) |
|---|---|---|
| WhatsApp channel | β built β posts appear within seconds | βοΈ possible: ticker without fixtures and a wide time window (see below) |
| β not sensible β Instagram has no push; ticker speed would mean polling every minute, which reliably gets the account locked | β built | |
| other sources | only where push exists | where regular polling is allowed |
WhatsApp all year round needs no rebuilding: leave the fixture lookup empty
in the configuration ([team] openligadb_filter =), set day_end to 23:59,
and let cron start it daily rather than on matchdays only. The one limitation:
a short gap appears around midnight, because the program stops and, after the
restart, accepts only posts from the day it is currently in.
New sources are welcome, but it pays to check before writing code: is there a usable library? Does the service allow automated access? And above all β can it announce events, or does it at least tolerate frequent polling without locking the account? Only once that is settled is it worth separating source from scheduling. Get in touch through an issue.
- Matchday detection through OpenLigaDB: on days without a match the program stops immediately and opens no connection at all. Not limited to football β what is well maintained there is football (divisions 1β3, the DFB cup, the women's Bundesliga) and ice hockey (DEL, DEL2). For sports without data (basketball, or handball league play) the detection can be switched off; the ticker then runs on every day it is started.
- The match hashtag is generated automatically (
#KSCDSC, home and away the right way round), cup rounds included, and attached to every post. - Several teams of one club can be covered, as long as OpenLigaDB gives them
the same team number β then it is enough to add their league shortcut to
league_shortcuts. When both play on the same day, a channel message cannot be attributed to either match: the posts then carryoverlap_hashtaginstead of the match hashtags, while the profile status line names both fixtures. - Profile status line: the first line of the Bluesky bio switches between "Bot ist an β 1. Spieltag β¦" and "Bot ist aus β nΓ€chstes Spiel β¦".
- Edits in the channel are noticed: the old Bluesky post is deleted and replaced by the corrected version.
- Carries over text, images and video, and builds link preview cards.
- Voice messages and stickers from the WhatsApp channel are carried over. Bluesky knows neither: a voice message becomes a video with an animated waveform (the sound is kept), a sticker becomes an image.
- Videos are handed in later: if the upload to Bluesky's video API fails, the
post goes out immediately with its thumbnail β on a ticker, time is what
counts. The video stays behind and is retried in later runs; when it succeeds,
the bot attaches it as a reply to the post. That way a temporary glitch at
Bluesky does not leave a permanently picture-only post behind. The settings for
it are the
video_retry_*keys inskyrelay.conf.
Both programs run in production and are configurable in full through
skyrelay.conf β club, channels, accounts, texts and file names all live there,
and nothing club specific is left in the code.
- Linux, 64 bit (
x86_64oraarch64). A Raspberry Pi 3B+ is enough, but the system has to be 64 bit β there are no packages for 32 bit (armv7l). - Python 3.10 or newer, plus
python3-venvandtzdata. libmagic1(sudo apt install libmagic1) β neonize will not import without it.ffmpeg(sudo apt install ffmpeg) β only for voice messages from the WhatsApp channel. Without it voice messages are skipped and everything else carries on unchanged.- A Bluesky account for the bot, with an app password (Settings β Privacy and Security β App Passwords).
- For
skyrelay-matchday.py: a separate WhatsApp number whose loss you could live with (see the risks). That account has to follow the channel. - For
skyrelay-feed.py: a secondary Instagram account to fetch with.
From nothing, in one line:
git clone https://github.com/Vijabei/SkyRelay.git && cd SkyRelay && ./install.shinstall.sh checks the system, the architecture and the Python version, fetches
the newest state, creates a virtual environment under venv/, installs the
dependencies β and then starts the setup. It changes nothing about the
system: missing system packages are only reported, never installed for you (no
sudo, no surprises on someone else's machine).
| Script | What for |
|---|---|
./install.sh |
once: check the system, build the venv, fetch the dependencies |
./config.sh |
set up, and change things later β starts the assistant |
./update.sh |
fetch the new state, pull the dependencies along, top up the configuration |
config.sh knows two shortcuts:
./config.sh --check # check the configuration, change nothing
./config.sh --add-missing # add missing keys along with their explanations./update.shFetches the new state (and stops rather than overwriting local changes), pulls
requirements.txt along and asks about the keys that have appeared since.
Running services are left alone β the next cron run picks the new state up on
its own. A ticker listening through a matchday keeps going with the old one until
it stops in the evening.
The easiest way is the setup assistant:
./config.shIt opens a menu in the style of raspi-config β arrow keys, works over SSH.
From the main menu the areas can be reached one at a time, instead of working
through every question in a row:
1 Matchday ticker WhatsApp channel β Bluesky
2 Instagram feed Instagram β Bluesky
3 Codes for hashtags
4 Posts and profile
5 How the posts are built (header, source, hashtags)
6 Time window
7 Check the login to Bluesky
8 Check the configuration
9 Top up the configuration
s Language / Sprache
0 Save and quit
That is also the comfortable way to change things later: correcting a single code or shifting the time window takes seconds. At the top the menu shows what required entries are still missing.
If whiptail is missing, the assistant falls back to asking line by line;
SKYRELAY_SETUP_TEXT=1 forces that mode.
[general]
language = en # de or en; empty = whatever the system saysThis affects only the setup assistant. The log and the console output of the bots are always English β so that the same message reads the same wherever it turns up, whoever reads it and wherever it gets pasted.
In the assistant the language sits under item s. What is offered is whatever a
catalogue has been built for; the catalogues are built by ./install.sh and
./update.sh on their own. Without them the interface speaks German β the
language it is written in.
Contributing another language: see CONTRIBUTING.md.
A configuration goes opaque over the years: a missing key means the program's own default quietly applies β the file does not show you what is really at work. So each bot answers that question:
venv/bin/python skyrelay-feed.py --show-configIt lists every value the programs read, together with where it comes from:
[post]
prefix (file) β½ [Inoffizieller Bot]
source_label (file) WhatsApp-Kanal des Vereins
[feed]
source_label (default) Beitrag auf Instagram
Commenting a line out switches nothing off. A line with a # in front counts
as missing, and then the program's default applies β the setting still works,
only with a different value. To leave something out, make the value empty:
# no source line in the post:
source_label =
# no header:
prefix =The same goes for standing_hashtag. In the listing above the difference is
readable from the origin: (file) means "this is what you wrote", (default)
means "the program decided".
Bluesky limits a post twice β the lexicon definition of app.bsky.feed.post
says it exactly:
{ "type": "string", "maxLength": 3000, "maxGraphemes": 300 }So 300 graphemes and 3000 bytes. A grapheme is what a person sees as one
character: π¨βπ©βπ§βπ¦ is seven code points and 25 bytes and still counts as
one. There is no API to ask β the counter in the Bluesky app runs in the
browser, and the server only says no once the post is finished.
SkyRelay therefore counts for itself, by the same rule (UAX #29), and works out
before splitting what this very post actually carries: the blocks the layout
puts on it, the blank lines between them, and the (2/3) counter. Since the
number of posts in turn decides the width of that counter, this is settled in
rounds until it comes to rest.
What that is worth shows in the same text with different source lines:
| Source line | Frame | Posts |
|---|---|---|
π [Quelle]: {label} with a label |
66 characters | 2 |
π [Quelle] |
50 characters | 1 |
Breaks are taken in this order of preference: paragraph, sentence, word, and only then hard β never inside a URL (Bluesky does not shorten links, and half of one is worthless) and never inside a grapheme cluster.
For the counting the package regex is recommended, which implements UAX #29 in
full. Without it SkyRelay carries on with a built-in approximation that covers
what actually turns up β umlauts, emoji with skin tones, ZWJ sequences, flags.
A git pull without pip install therefore stays harmless; the bot only
says at startup which way of counting it is using.
Where the Bluesky bio already says plainly that a bot is writing,
β½ [Inoffizieller Bot] above every post is wasted space β about 22 characters
of 300:
[post]
bot_notice = auto
bot_notice_marker = Bot| Value | Meaning |
|---|---|
always |
always (the default β what SkyRelay always did) |
never |
never |
auto |
only while the profile description does not carry the notice itself |
With auto the description is checked once per run and the outcome written
to the log. SkyRelay skips its own status line while doing so: that line reads
"π’ Bot ist an", and a search for "Bot" would otherwise always find what the
bot itself wrote rather than what you typed into the bio.
If the profile cannot be read, the notice stays. The same goes for a dry run, which deliberately does not log in β leaving out a disclaimer on a guess would be the worse of the two mistakes.
Which part of the line carries the link is decided by a template. What stands in
[square brackets] becomes the link; {label} is the label from
[post] source_label or [feed] source_label:
[post]
source_template = π [Quelle]: {label}| Template | Result (bold = clickable) |
|---|---|
π [Quelle]: {label} |
π Quelle: WhatsApp-Kanal des Vereins |
π [Quelle] |
π Quelle |
π Quelle: [{label}] |
π Quelle: WhatsApp-Kanal des Vereins |
The first line is the default. Up to version 1.3 the third one applied: the link hung on the label, and the word in front of it was dead text. The second variant saves about 28 characters β which tells against Bluesky's limit of 300.
Without a label the colon disappears with it. Without any square brackets the whole line becomes the link β a source line that cannot be clicked would be pointless.
Until now that was fixed: header and source at the top of the first post, the
hashtags at the bottom of the last. The [layout] section turns it into a
decision β one line per block:
[layout]
# posts ; spot ; order
prefix = first ; top ; 1
source = first ; top ; 2
match_hashtag = last ; bottom ; 1
standing_hashtag = last ; bottom ; 2| Column | Values | Meaning |
|---|---|---|
| posts | first, last, all, none |
on which posts of the thread |
| spot | top, bottom |
above or below the text |
| order | a number | which comes first when two land in the same spot |
The values above are the defaults and produce exactly the look SkyRelay always
had. Leaving a block out entirely can be done two ways: enter none, or make its
content empty (source_label =).
The most comfortable way is item 5 of the assistant β it shows the matrix and, on request, a preview of the finished post, before anything is published:
ββ post 1 of 2 ββββββββββββββββββββββββββββββ
β½ [Inoffizieller Bot]
π Quelle: WhatsApp-Kanal des Vereins
Beispieltext, Teil 1. (1/2)
ββ post 2 of 2 ββββββββββββββββββββββββββββββ
Beispieltext, Teil 2. (2/2)
#DSCWOB #arminia
A misspelt key simply has no effect β with no error message, because the program quietly takes its default instead. So both bots check the configuration on request:
venv/bin/python skyrelay-feed.py --check-configIt reports what no program reads (a typo, or something left over), what is
missing and therefore falls back to a default, and what has stayed undocumented
in skyrelay.conf.example. The call connects to nothing, changes nothing and
posts nothing; the exit status is 0 as long as there are no problems. The
assistant shows the same report under item 8 β there including changes that have
not been saved yet.
After an update the template knows keys that the own file does not. They can be added along with their explanations β existing values, order and comments stay untouched, this only ever adds:
./config.sh --add-missingThe call first shows what would be added and asks. A backup is written as
skyrelay.conf.bak beforehand. The same sits under item 9 of the menu.
For a first setup the assistant walks through the same points as before:
| Purpose | How it goes |
|---|---|
| A sport with fixtures | League and club are looked up live at OpenLigaDB (no hunting for team numbers), and the table of codes for the hashtags is pre-filled β for football divisions 1β3 with the codes in common use, otherwise with derivations clearly marked as proposals (?). Besides football the other maintained leagues can be picked, the DEL for instance. |
| A sport without fixtures | For sports OpenLigaDB does not carry (basketball, handball league play): no matchday detection, a changing hashtag through SKYRELAY_HASHTAG. |
| Something else | For clubs, events, projects: as above, and additionally with neutrally worded default texts instead of football language. |
In every case the post texts, the profile status line and the time window are
asked for, the Bluesky login is checked on request, and the finished
skyrelay.conf is written from all of it. Codes once maintained are kept β even
after a move between divisions, so that cup opponents from other leagues are
still named correctly. Calling it again is the way to change things: existing
values are offered as defaults, and a backup of the old file is written.
Anyone who would rather work by hand copies the commented template:
cp skyrelay.conf.example skyrelay.confFor the matchday ticker at least [bluesky] handle and
[source] channel_invite_link are needed; for a different club also [team]
(the OpenLigaDB search term and team number) and the codes under [team_codes].
For the Instagram mirror the [feed] section is enough (profile, secondary
account and optionally a Bluesky account of its own). Every section is commented
in the template, and an overview lives in the
cheat sheet.
Your own skyrelay.conf is in .gitignore and does not belong in the
repository. Several clubs run side by side through
SKYRELAY_CONFIG=/path/to/file.conf.
Anyone moving over from an older version needs no fresh pairing: existing
dsc_ticker_* files are adopted on the first start.
The app password never belongs in a file, but in an environment variable β one per bot, so that the attribution stays unambiguous:
export BLUESKY_TICKER_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx"; export BLUESKY_FEED_APP_PASSWORD="yyyy-yyyy-yyyy-yyyy"If both bots use the same account, both variables simply hold the same password.
~/.bashrc nor ~/.profile. For automatic operation
the variables belong at the top of the crontab (crontab -e) β there without
quotation marks, or they would become part of the value:
BLUESKY_TICKER_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
BLUESKY_FEED_APP_PASSWORD=yyyy-yyyy-yyyy-yyyyOnce, and interactively in a terminal (not from cron):
SKYRELAY_FORCE=1 SKYRELAY_DRY_RUN=1 venv/bin/python skyrelay-matchday.pyA QR code appears, which you scan on the phone under WhatsApp β Settings β
Linked devices β Link a device. From experience: turn the screen brightness up
and enlarge the terminal a lot, or the camera finds too little contrast. As an
alternative, pair by numeric code β add SKYRELAY_PAIR_PHONE=49xxxxxxxxx for
that.
Signing in through web.whatsapp.com in a browser does not help: the program
is a linked device of its own with a session of its own. That session then lives
in *_session.sqlite3 and is reused by every later run.
In normal operation a daily cron entry is enough β whether there is a match today is something the program decides for itself:
0 6 * * * BLUESKY_TICKER_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" /path/to/SkyRelay/venv/bin/python3 /path/to/SkyRelay/skyrelay-matchday.py >/dev/null 2>&1Two common stumbling blocks: paths are case sensitive, and redirecting the
output (>> skyrelay.log) is not necessary β the program writes its own log,
and otherwise every line would appear in it twice.
Is the bot running?
pgrep -af skyrelay-matchdayEvery other mode (test runs, catching up on missed posts, single tests) lives in CHEATSHEET-matchday.md.
Create the Instagram session of the secondary account once:
venv/bin/instaloader -l your_secondary_accountAfter that call it regularly from cron β the program carries over whatever is new since the last run:
*/15 * * * * BLUESKY_FEED_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" /path/to/SkyRelay/venv/bin/python3 /path/to/SkyRelay/skyrelay-feed.py >/dev/null 2>&1If you use a different Bluesky account for Instagram than for the ticker,
enter it under [feed] bluesky_handle and give the app passwords separately
(see above).
| File | Contents |
|---|---|
README.md |
this document (German: README.de.md) |
skyrelay-setup.py |
the setup assistant (creates and changes skyrelay.conf) |
skyrelay.conf.example |
the commented template of every setting |
skyrelay_common.py |
shared building blocks of both programs (log, images, video upload) |
skyrelay_config.py |
checking, showing and topping up the configuration |
skyrelay_layout.py |
how posts are built, character counting, splitting |
skyrelay_i18n.py |
translations of the interface |
tools/i18n.sh |
creating, aligning and compiling the catalogues |
skyrelay-testlauf.py |
plays real channel messages through the ticker pipeline without publishing |
CHEATSHEET-matchday.md |
every environment variable, mode, file and remedy (German: CHEATSHEET-matchday.de.md) |
ISSUE-DRAFT-neonize-newsletter-panic.md |
a prepared bug report for neonize: the crash while fetching history |
ISSUE-DRAFT-whatsmeow-newsletter-audio-hmac.md |
a prepared bug report for whatsmeow: voice messages cannot be downloaded |
-
neonizeis pinned to0.4.3.post0and was checked in full on 08.08.2026 (see UPGRADE-TEST.md). Back the session file up before switching β the database schema changes. -
Ctrl+C can occasionally be caught by the Go part (from 0.4.x on): the program then ends with
Quitwithout cleaning up, and the profile status line is left saying "Bot ist an". This was seen once; normally the cleanup runs properly. Reset it withSKYRELAY_PROFILE=offif need be. Of no consequence under cron β there the ticker ends properly at the end of the day. -
A crash on channel posts without content: when the channel deletes a post, an empty message is left behind, and the underlying Go part crashes on it. This affects only the catch-up modes, not normal operation β that one listens for events and is not touched by it. How much it affects you depends on the channel: if such a message lies far back, a smaller value is enough. If it lies directly behind the newest post,
SKYRELAY_REPLAYis unusable β fetching two messages already crashes and only1gets through. (In the channel mirrored here that was the case on 19.08.2026.) To check against real material,skyrelay-testlauf.pypages backwards past the broken spot from an explicit starting point:venv/bin/python3 skyrelay-testlauf.py --latest venv/bin/python3 skyrelay-testlauf.py --before <serverID> --type audio,sticker -
instaloaderis pinned to 4.15.2. Version 4.15.3 moved the profile lookup to an endpoint Instagram has been throttling since early August 2026 β the very first request ends in a "429 Too Many Requests" (instaloader#2726, open). Only update once that is solved. -
Polls are skipped: Bluesky has no such format.
-
Voice messages need a detour to download. Channel media sit unencrypted behind
directPathβ images and videos therefore carry nomediaKeyat all. Voice messages drag one along, whereupon whatsmeow tries to decrypt and fails withinvalid media hmac. On exactly that error SkyRelay downloads a second time without the key. Measured on 19.08.2026: none of 5 voice messages downloaded the regular way, all five did without the key, byte for byte. -
Animated stickers lose their movement β the first frame is carried over, because Bluesky plays no animations.
-
Voice messages appear as video and therefore have no sensible image description. Anyone who cares about accessibility should bear that in mind.
-
The waveform colour:
waveform_colorin[audio]takes ffmpeg colour names only (White,DodgerBlue, β¦). Hex values such as0x38BDF8are discarded silently by ffmpeg, which then draws green; SkyRelay warns about it in the log. -
Videos up to roughly 100 MB; larger ones are not carried over (Bluesky's limit).
-
Videos handed in later end up as a reply below the post rather than in the post itself β Bluesky has no way of changing a post after the fact. If the post is deleted in the meantime (through an edit in the WhatsApp channel, say), handing in fails and the job is dropped after
video_retry_max_attemptsattempts. -
Several images in one post: Bluesky takes at most four.
- WhatsApp: access happens through an unofficial client. That breaches the terms of service and can get the number banned. Use only a number whose loss would not hurt you β and never your private one. The same applies to the secondary Instagram account.
- Copyright: you are carrying over someone else's content. Work out for yourself whether you may, and mark the bot as unofficial (the posts carry a notice and a source line for that).
- No connection to Meta, Bluesky or any club. Trademarks and names are used descriptively only.
- No warranty: the interfaces are unofficial and can change at any time. Use at your own risk.
SkyRelay is mostly wiring β the actual work is done by these projects, and the thanks belong to them:
| Project | What for | Licence |
|---|---|---|
| neonize | reaching WhatsApp from Python | Apache-2.0 |
| whatsmeow | the WhatsApp implementation neonize builds on | MPL-2.0 |
| atproto (Python SDK) | talking to Bluesky | MIT |
| Instaloader | fetching Instagram posts | MIT |
| Pillow | image processing and compression | HPND |
| Requests | HTTP calls | Apache-2.0 |
| Segno | the QR code in the terminal for pairing | BSD-3-Clause |
| Babel | building the translation catalogues | BSD-3-Clause |
| regex | counting graphemes to UAX #29 | Apache-2.0 |
| OpenLigaDB | free fixture data (matchday, kickoff, opponent) | a community project |
Improvements are welcome β above all club codes that are missing or unusual. That takes no programming: an issue with the team number and the code is enough. How to go about it is in CONTRIBUTING.md.
PolyForm Noncommercial License 1.0.0 β non-commercial use is free, including supporters' projects, non-profit organisations and educational institutions. The copyright notice has to be kept.
Commercial use β by clubs as businesses, by media houses, or by advertising-funded offerings β requires a separate licence. Please ask through an issue in this repository.
A note: this is a "source available" licence, not an open source licence in the OSI sense β the restriction to non-commercial use is incompatible with that.