A Bluesky bot that
- Posts daily moon phase and moon sign updates.
- Responds to mentions and replies with personalized moon house interpretations.
- Built with Node.js, deployed on Fly.io, and triggered via GitHub Actions.
Every day at 9:00 AM ET, a GitHub Actions cron job hits the /daily endpoint on the Fly.io server, which posts the current moon phase and moon sign to Bluesky.
Every 5 minutes:
GitHub Actions → curl /mentions
The bot:
- Fetches notifications from Bluesky
- Filters:
- Only
mentionorreply - Only notifications from today (TIMEZONE-aware)
- Only notifications newer than
lastSeenAt - Never responds to itself
- Only
- Extracts the rising sign from user text
- Calculates which house the Moon is transiting
- Generates a response using the Vercel AI SDK
- Replies threaded correctly to the user
- Saves state to disk (volume)
This ensures:
- No historical spam
- No duplicate replies
- Clean threading
- Safe cron execution
The bot uses:
- Vercel AI SDK (
ai) @ai-sdk/openai- Model:
gpt-4o-mini
Example:
const { generateText } = require("ai");
const { openai } = require("@ai-sdk/openai");
const model = openai("gpt-4o-mini", {
apiKey: process.env.OPENAI_API_KEY,
});Responses are:
- House-aware (Moon sign + Rising sign logic)
- Constrained to ≤ 280 characters (Bluesky-safe)
- Generated using a strict prompt template
- Clamped before posting to prevent API rejection
Moon phase data is fetched from the ipgeolocation.io Astronomy API. Rather than trusting the API's own phase label directly, we derive the phase ourselves for accuracy using two data points from the response:
moon_illumination_percentage— how much of the moon's surface is litmoon_angle— the ecliptic angle, which tells us whether the moon is waxing or waning
The derivation logic:
| Illumination | Angle | Phase |
|---|---|---|
| < 2% | any | New Moon |
| 2–48% | ≤ 180° | Waxing Crescent |
| 2–48% | > 180° | Waning Crescent |
| 48–52% | ≤ 180° | First Quarter |
| 48–52% | > 180° | Last Quarter |
| 52–99.5% | ≤ 180° | Waxing Gibbous |
| 52–99.5% | > 180° | Waning Gibbous |
| ≥ 99.5% and within 5° of 180° | — | Full Moon |
This approach is more precise than relying solely on the API's label, which can bucket high-illumination days (e.g. 97–99%) as Full Moon prematurely.
Timezone offset is derived dynamically from the TIMEZONE environment variable using the Intl API, so DST transitions are handled automatically without any manual config changes.
The bot runs as a persistent Node.js server on Fly.io rather than as a standalone script because:
- It needs to be reachable via HTTP so GitHub Actions can trigger it on a schedule with a simple
curlcall - Fly.io's autostop/autostart feature means the machine only runs when requests come in, keeping costs at zero on the free tier
- Deployment is handled automatically on push to
mainvia thefly-deploy.ymlGitHub Action
The bot uses a Fly.io volume mounted at:
/data
State file:
/data/mentions_state.json
Stored data example:
{
"lastSeenAt": "...",
"processed": ["uri::cid", "..."]
}- Prevents replying twice
- Prevents historical backfill
- Survives Fly autostop
- Survives deploys
- Survives machine restarts
A file-based lock (mentions.lock) prevents overlapping cron runs.
git clone https://github.com/avaldivi/moon-cycle-bot
cd moon-cycle-bot
touch .envAdd the following to your .env for local testing:
BLUESKY_USERNAME= # your Bluesky handle, e.g. yourbot.bsky.social
BLUESKY_PASSWORD= # Bluesky app password (not your account password)
LAT_COORDINATE= # latitude of your location, e.g. 34.12
LONG_COORDINATE= # longitude of your location, e.g. -83.99
TIMEZONE= # IANA timezone name, e.g. America/New_York
IPGEO_API_KEY= # API key from ipgeolocation.io (free tier: 1,000 req/day)
OPENAI_API_KEY= # OpenAI API key for LLM-generated post content
FLY_API_TOKEN= # Fly.io deploy token for GitHub Actions CI/CD
Set Fly.io endpoint url as a Github repo secret for Github Actions
FLY_APY_URL= # Fly.io API URL
- Bluesky app password — Settings → Privacy and Security → App Passwords in the Bluesky app
- ipgeolocation.io — Sign up at ipgeolocation.io, free tier includes 1,000 requests/day
- Fly.io deploy token — Run
fly tokens create deploy -a your-app-nameand store the output as a GitHub secret namedFLY_API_TOKEN
Set your environment variables on Fly.io directly rather than relying on .env in production:
fly secrets set BLUESKY_USERNAME=yourbot.bsky.social
fly secrets set BLUESKY_PASSWORD=your-app-password
fly secrets set LAT_COORDINATE=34.12
fly secrets set LONG_COORDINATE=-83.99
fly secrets set TIMEZONE="America/New_York"
fly secrets set IPGEO_API_KEY=your-key
fly secrets set OPENAI_API_KEY=your-key| Workflow | Trigger | Purpose |
|---|---|---|
| fly-deploy.yml | Push to main | Deploy to Fly.io |
| daily-moon-post.yml | Daily | Calls /daily |
| check-mentions.yml | Every 5 minutes | Calls /mentions |
npm install
node server.jsThen trigger a post manually:
curl -X POST http://localhost:3000/dailyTest mentions:
curl -X POST http://localhost:3000/mentions