How I Built a Multi-System Astrology Bot in Python (And What Meta Banned Me For) The article describes the development of a Python-based Telegram astrology bot that synthesizes daily forecasts from 13 different astrological systems (including Western, Vedic, and Chinese Ba Zi) using full birth data. The author details technical challenges such as a crash-loop bug caused by a lunar-day index error, and explains how they reduced LLM costs by using a shared cache pattern for identical daily broadcasts. Additionally, the bot implements a hallucination guard that rejects Gemini-generated content if it introduces new planet names not present in the original template, with automatic fallback to alternative models. Вот, держи готовый — копируй в 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/except so 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. python 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=14400 seconds ±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.