# How I Built a Multi-System Astrology Bot in Python (And What Meta Banned Me For)

> Source: <https://dev.to/shustyk/how-i-built-a-multi-system-astrology-bot-in-python-and-what-meta-banned-me-for-1j4e>
> Published: 2026-05-23 16:50:11+00:00

Вот, держи готовый — копируй в 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.
