Вот, держи готовый — копируй в body dev.to:
Every horoscope app reduces you to 1 of 12 sun signs. Real astrologers don't work like that — they cross-reference Western astrology, Vedic (Jyotish), Chinese Ba Zi, numerology, Human Design, and more. So I built a Telegram bot that does the same: one daily forecast synthesized from 13 systems, based on your full birth date.
It's been live for ~1 month. Small still — 83 users — but I want to share the parts that actually taught me something.
The Architecture: Why Combining 13 Systems Is a Data Problem, Not an Astrology Problem
Each system is a separate calculator. Western astrology needs ecliptic longitudes (I use Skyfield + NASA ephemeris). Vedic needs tithi (lunar day, 1-30) and nakshatra (27 lunar mansions). Ba Zi needs solar-term boundaries to assign the day-pillar element. Numerology needs digit-reductions with master-number exceptions (11, 22, 33 don't reduce before arithmetic).
Each one is finicky in its own way. Combine them and you get an interesting failure mode: latent bugs that wait for the calendar.
My favourite: a lunar-day translation table had 5 entries, but _tithi_group(30) returned index 5 (Amavasya / new moon). The bug sat dormant for weeks. Then a new moon arrived:
day_label = _TITHI_DAY_LABEL[lang][group_idx]
# IndexError: list index out of range
Content generation crashed for all three languages. The bot's startup also called ensure_content(today), so it entered a crash-loop. I learned two things that day:
- Latent bugs wait for the calendar. Any code path that runs only on specific astronomical events needs explicit tests at those boundary conditions.
-
Startup hooks shouldn't crash the process. Wrap them in
try/exceptso the bot stays alive and the admin can still introspect via diagnostic commands.
LLM Cost Architecture: One Sentinel Saved 99% of the Bill
The bot rewrites raw template output into warm conversational language using Gemini. Daily, monthly, yearly forecasts. With per-user rewriting, costs scale linearly with users — bad.
But the general forecast (the morning broadcast everyone receives) is identical for every user. So I use a sentinel pattern: user_id=0 means "shared cache row". The first user to trigger the daily LLM rewrite warms the cache; everyone else reads from it.
async def get_cached(session, user_id, date, lang, content_type):
row = await session.get(LLMOutputCache,
(user_id, content_type, 0, date, lang))
return row.text if row else None
This is a 5-line idea, but it cut my LLM bill from "uncomfortable" to "barely noticeable." Pre-warm cron at 03:00 UTC fills the cache before anyone wakes up.
The Hallucination Guard
Gemini is happy to invent astrological facts that aren't in your seed. The seed mentions the Moon; the rewrite confidently introduces Venus. For an astrology bot, that's a catastrophe — users trust the output.
My guard tokenises both texts and rejects the rewrite if any new planet name appears in the output that wasn't in the input. Sign names are tolerated (LLM often adds "the Scorpio Moon" as natural metaphor — that's fine), but actual planet additions = reject and fall back to Groq, then to plain template.
new_planets = _extract_astro_tokens(rewritten) \
- _extract_astro_tokens(original)
new_planets &= _PLANET_TOKENS
if new_planets:
log.warning("hallucination guard fired: %s", new_planets)
return None # fall back
About 2-3% of Gemini outputs trigger it. The bot silently falls back; the user never sees garbage.
Auto-posting: Single Source of Truth
I publish the same daily forecast to Telegram channel, Instagram (carousel of 4 PNG slides), and Threads. Three different formats, three different APIs, one piece of source content.
Key insight: share the cached LLM rewrite across surfaces. The IG caption pulls from llm_output_cache for user_id=0. Threads' main post pulls from the same cache and crops at the nearest sentence boundary under 500 chars. Zero extra LLM cost; one truth.
main_text = await get_cached(0, today, lang, CONTENT_TYPE_DAILY)
if len(main_text) > 500:
head = main_text[:500]
for sep in (". ", "! ", "? "):
idx = head.rfind(sep)
if idx >= 200:
main_text = head[:idx+1].rstrip()
break
The IG slide renderer uses a separate Gemini call with response_mime_type=application/json for tight char budgets (slides have visual constraints PNG-renderer must respect). One LLM call per language per day, cached 24h in Redis.
The Meta Ban (Or: What I Did Wrong)
Here's the part I'd undo. I had:
- Per-post engagement-bait on every Threads/IG post: "leave a reaction, share with someone" — identical wording every day.
- Daily 5-post self-reply chains (main post + numerology reply + Ba Zi reply + Jyotish reply + CTA-with-link reply).
- Machine-perfect timing: 04:02 UTC ±0 every single day.
Each of these is a textbook spam signal. The combination — automated bot posting, identical engagement-bait, daily self-reply chains with outbound links — is exactly what Meta's integrity systems are designed to penalise.
The English account was disabled outright: "We've reviewed your account and found that it doesn't follow our Community Standards on account integrity." The Russian one survived but was shadow-restricted (posts publish via API but the account vanishes from search/profiles).
The de-spam was straightforward in code:
- Dropped per-post engagement-bait, kept only a soft "link in bio" CTA
- Cut the 5-post chain to a single forecast post
- Added
jitter=14400seconds (±4h) to the cron so the post lands at varying times each day
scheduler.add_job(
send_threads_post,
trigger="cron",
hour=10, minute=0,
jitter=14400, # ±4h — fires anywhere in 06:00-14:00 UTC daily
id="threads_post",
)
The harder lesson: automated social posting on Meta platforms is fragile by design. Meta does not want pure-broadcast bot accounts. A new account you create and immediately hook to a cron will get banned again, the same way. If social presence matters to a project, the human-run path is the only durable one.
Honest Numbers After 1 Month
- 83 users
- DAU/MAU ratio: ~9% (healthy benchmarks are 20%+ — retention is my real problem)
- Profile completion rate: 73.5% (onboarding works)
- Most-used feature: monthly forecast (high re-engagement, 7 users opened it 25 times in a week)
- Least-used feature: invite/referral (1 invite in 30 days — turns out shipping a referral mechanism in code is nothing if it's not surfaced in the UI)
- Paid conversions: 0 (haven't pushed monetisation yet)
What I'd Tell Past-Me
- Distribution is harder than the product. I shipped the bot in 3 weeks. Getting people to use it is the actual work, and it's an entirely different skill.
- Boring infrastructure decisions compound. Sentinel cache, hallucination guard, dockerised stack with admin diagnostic commands — none of these are cool. All of them have saved hours.
- Don't optimise for channels that hate you. Meta's auto-poster ban is a feature, not a bug. Build for the channels where your behaviour is welcome.
The bot is live and free: t.me/CosmoCast_bot — send your birth date, get the forecast.
Happy to answer anything in the comments about the LLM cost architecture, the hallucination guard, the auto-poster setup, or the Meta-ban post-mortem.
Top comments (0)