Zero-cost, headless Instagram Reel/Feed Factory. Controlled via Telegram. Runs on GitHub Actions. Posts autonomously.
You (Telegram)
│ /post stoicism
▼
Telegram Bot API
│ update JSON
▼
Cloudflare Worker (relay) ──► GitHub API (repository_dispatch)
│
▼
GitHub Actions Runner
│
┌────────────┼────────────────┐
▼ ▼ ▼
Gemini API Pollinations.ai Pixabay API
(text/logic) (AI image gen) (CC0 audio)
│ │ │
└────────────┴─────────────────┘
│
FFmpeg
(render .mp4)
│
▼
Instagram Graph API
(publish post)
│
▼
📲 Telegram notification
"✅ Posted! [link]"
project_oracle/
├── main.py # Orchestrator (entry point)
├── telegram_webhook_handler.py # Processes Telegram updates in Actions
├── requirements.txt
├── config.json # Auto-created; local dev state store
├── core/
│ ├── __init__.py
│ ├── state_manager.py # Quota tracking + GitHub Gist persistence
│ ├── intelligence.py # Gemini 1.5 Flash content generation
│ ├── image_generator.py # Pollinations.ai + HuggingFace fallback
│ ├── audio_fetcher.py # Pixabay CC0 audio downloader
│ ├── video_renderer.py # FFmpeg: Ken Burns + text + audio merge
│ ├── instagram_publisher.py # Instagram Graph API publisher
│ └── telegram_bot.py # Telegram C2 bot
├── assets/
│ ├── images/ # Downloaded/generated images (auto-created)
│ ├── audio/ # Downloaded audio tracks (auto-created)
│ └── output/ # Rendered videos (auto-created)
└── .github/
└── workflows/
└── workflow.yml # GitHub Actions pipeline
- Go to https://aistudio.google.com/app/apikey
- Sign in with Google → Create API Key
- Copy the key (starts with
AIza...) - Free tier limits: 1,500 requests/day, 15 requests/minute
This is the most involved step. Follow carefully.
Prerequisites:
- Instagram Business or Creator account (not personal)
- Facebook Page linked to your Instagram account
Steps:
- Go to https://developers.facebook.com → Create App → "Other" → "Business"
- Add "Instagram Graph API" product to your app
- Go to Graph API Explorer (https://developers.facebook.com/tools/explorer)
- Select your App → Get User Access Token
- Select permissions:
instagram_basic,instagram_content_publish,pages_show_list - Click "Generate Access Token" → copy it
- Convert to Long-Lived Token (valid 60 days):
GET https://graph.facebook.com/v19.0/oauth/access_token ?grant_type=fb_exchange_token &client_id={APP_ID} &client_secret={APP_SECRET} &fb_exchange_token={SHORT_TOKEN} - Get your IG User ID:
Then:
GET https://graph.facebook.com/v19.0/me/accounts ?access_token={LONG_TOKEN}GET https://graph.facebook.com/v19.0/{PAGE_ID}?fields=instagram_business_account&access_token={TOKEN}
⚠️ Token Refresh: Long-lived tokens expire in 60 days. Set a calendar reminder to refresh, or implement auto-refresh (see Appendix A).
- Open Telegram → search
@BotFather - Send
/newbot→ follow prompts → copy the bot token (123456:ABC-DEF...) - Get your personal Chat ID:
- Send any message to your bot
- Visit:
https://api.telegram.org/bot{TOKEN}/getUpdates - Find
"chat": {"id": XXXXXXX}— that's your chat ID
- Go to https://gist.github.com
- Create a new secret Gist with filename
oracle_state.jsonand content{} - Copy the Gist ID from the URL:
gist.github.com/{username}/{GIST_ID} - Your
GITHUB_TOKENis auto-provided by GitHub Actions (no setup needed)
- Register at https://pixabay.com/accounts/register/
- Go to https://pixabay.com/api/docs/ → copy your API key
- Free tier: unlimited requests
- Register at https://huggingface.co
- Go to Settings → Access Tokens → New Token (read scope)
Go to your repo → Settings → Secrets and variables → Actions → New repository secret
| Secret Name | Value | Required? |
|---|---|---|
GEMINI_API_KEY |
AIza... (from Step 1A) | ✅ Yes |
IG_ACCESS_TOKEN |
Long-lived Meta token (Step 1B) | ✅ Yes |
IG_USER_ID |
Numeric Instagram user ID (Step 1B) | ✅ Yes |
TELEGRAM_BOT_TOKEN |
123456:ABC-DEF... (Step 1C) | ✅ Yes |
TELEGRAM_CHAT_ID |
Your numeric chat ID (Step 1C) | ✅ Yes |
GIST_ID |
Gist UUID from URL (Step 1D) | |
PIXABAY_API_KEY |
Pixabay API key (Step 1E) | Optional |
HF_TOKEN |
HuggingFace token (Step 1F) | Optional |
GITHUB_TOKENis automatically available in Actions — don't add it manually.
Since Telegram webhooks need a public HTTPS endpoint and GitHub Actions doesn't expose one, we use a Cloudflare Worker as a relay (free tier: 100,000 req/day).
- Go to https://dash.cloudflare.com → Workers → Create Worker
- Paste this code:
// Cloudflare Worker: Telegram → GitHub Actions relay
export default {
async fetch(request, env) {
if (request.method !== 'POST') {
return new Response('OK', { status: 200 });
}
const update = await request.json();
// Forward to GitHub repository_dispatch
const ghResponse = await fetch(
`https://api.github.com/repos/${env.GH_OWNER}/${env.GH_REPO}/dispatches`,
{
method: 'POST',
headers: {
'Authorization': `token ${env.GH_PAT}`,
'Content-Type': 'application/json',
'User-Agent': 'ProjectOracle/1.0',
},
body: JSON.stringify({
event_type: 'telegram_command',
client_payload: update,
}),
}
);
return new Response(
JSON.stringify({ ok: true, gh_status: ghResponse.status }),
{ headers: { 'Content-Type': 'application/json' } }
);
}
};-
Add Worker environment variables (Settings → Variables):
GH_OWNER= your GitHub usernameGH_REPO= your repository nameGH_PAT= GitHub Personal Access Token withreposcope (create at github.com/settings/tokens)
-
Deploy and copy your Worker URL:
https://your-worker.your-subdomain.workers.dev
Run this once (from your terminal or Postman):
curl -X POST "https://api.telegram.org/bot{YOUR_BOT_TOKEN}/setWebhook" \
-H "Content-Type: application/json" \
-d '{"url": "https://your-worker.your-subdomain.workers.dev"}'Expected response: {"ok":true,"result":true,"description":"Webhook was set"}
- Send
/helpto your bot → should get the command list - Send
/post stoicism→ bot replies "Added to queue" - Wait for the next CRON trigger (or manually dispatch from Actions tab)
- Go to your repo → Actions → 🔮 Project Oracle
- Click "Run workflow"
- Set Mode =
single, Topic =stoicism, Type =reel - Click "Run workflow" → watch the logs
# Clone and set up
git clone https://github.com/your-username/project-oracle
cd project-oracle
pip install -r requirements.txt
# Create .env file
cat > .env << EOF
GEMINI_API_KEY=AIza...
IG_ACCESS_TOKEN=EAABs...
IG_USER_ID=17841...
TELEGRAM_BOT_TOKEN=123456:ABC...
TELEGRAM_CHAT_ID=987654321
PIXABAY_API_KEY=...
EOF
# Run single post
python main.py single "stoicism" reel
# Start Telegram polling (local dev)
python main.py webhookThe "Continuous Generation" system works as follows:
CRON fires (every 6 hours)
│
▼
StateManager.has_quota()?
├── NO → Log "quota exceeded" → Exit (wait for midnight reset)
└── YES ↓
▼
StateManager.get_topic_queue()
│
├── EMPTY → Log "queue empty" → Exit
└── HAS ITEMS ↓
│
▼
For each item in queue:
│
├── has_quota()? NO → Stop loop
├── was_recently_posted()? YES → Skip, remove from queue
└── YES → Run pipeline → Record post → decrement_quota()
→ sleep 30s → next item
Each post consumes:
- 3 Gemini calls (content generation prompt = 1 call; each call counts once)
- 1 image from Pollinations (unlimited — no counter needed)
With Gemini's 1,500 req/day free limit and ~3 calls per post:
- Max posts per day: ~500 (in theory; Instagram limits are lower)
- Instagram posting limit: ~25 posts/day for Graph API
The StateManager tracks both and stops when either ceiling is hit.
The state resets quota counters automatically when the date changes:
# In StateManager._reset_quota_if_new_day()
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
if self._state["quota"]["reset_date"] != today:
# Reset counters → save state| Service | Free Limit | Our Usage | Headroom |
|---|---|---|---|
| GitHub Actions | 2,000 min/month | ~5 min/post | ~400 posts/month |
| Gemini Flash | 1,500 req/day | ~3 req/post | ~500 posts/day |
| Pollinations.ai | Unlimited | 1 image/post | ♾️ Unlimited |
| GitHub Gist | Unlimited | 1 write/post | ♾️ Unlimited |
| Cloudflare Workers | 100,000 req/day | 1 req/command | ♾️ Unlimited |
| Pixabay | Unlimited | 1 track/10 posts | ♾️ Unlimited |
| 25 posts/day | 1 per run |
✅ Real bottleneck: Instagram's own 25 posts/day limit. All other services have far more headroom.
Every post follows this 3-part structure:
┌─────────────────────────────┐
│ HOOK (top 15% of frame) │ ← Creates curiosity. 12 words max.
│ "Most people live on │ Bold, accent color, all-caps.
│ autopilot. Here's why:" │
├─────────────────────────────┤
│ │
│ BODY (center of frame) │ ← The value. 40 words max.
│ "Marcus Aurelius spent │ Regular weight, white text.
│ years training his mind..." │
│ │
├─────────────────────────────┤
│ CTA (bottom 15% of frame) │ ← Drives action. Saves & shares.
│ ↓ Save this for later. │ Bold, accent color.
└─────────────────────────────┘
Frame 1 (0s): zoom = 1.00 (original size)
Frame 450 (15s): zoom = 1.02
Frame 900 (30s): zoom = 1.04 (4% larger than start)
This subtle motion prevents Instagram's algorithm from classifying the post as a "static image" and gives it Reel-level distribution.
Gemini generates exactly 7 hashtags per post:
- 5-6 niche tags (e.g.,
#stoicism,#dailystoic,#marcusaurelius) → Places you in front of your exact audience - 1 broad tag (e.g.,
#philosophy,#mindset) → Catches the algorithm's general category feed
Why not 30 hashtags? Instagram's algorithm since 2022 deprioritises posts that "hashtag stuff." 7 targeted tags outperform 30 generic ones.
Add this to your CRON workflow or run monthly:
# refresh_token.py
import os, httpx
token = os.environ["IG_ACCESS_TOKEN"]
app_id = os.environ["FB_APP_ID"]
app_secret = os.environ["FB_APP_SECRET"]
r = httpx.get(
"https://graph.facebook.com/v19.0/oauth/access_token",
params={
"grant_type": "fb_exchange_token",
"client_id": app_id,
"client_secret": app_secret,
"fb_exchange_token": token,
}
)
new_token = r.json()["access_token"]
print(f"New token (expires in 60 days): {new_token[:30]}...")
# Update your GitHub secret programmatically or manually| Problem | Likely Cause | Fix |
|---|---|---|
GEMINI_API_KEY error |
Key not set in secrets | Add secret to repo settings |
| Image is black/blank | Pollinations timeout | Increase timeout or use HF fallback |
FFmpeg zoompan slow |
CPU-intensive filter | Reduce VIDEO_DURATION in renderer.py |
IG API (#10) error |
Wrong permissions | Re-generate token with correct scopes |
| Telegram bot not responding | Webhook not set | Re-run the setWebhook curl command |
Container stuck IN_PROGRESS |
Large video file | Reduce bitrate in video_renderer.py |
quota_exceeded too early |
Multiple runs in one day | Check config.json reset_date |
| Command | Effect |
|---|---|
/post stoicism |
Add "stoicism" Reel to queue |
/feed mindfulness |
Add "mindfulness" Feed post to queue |
/now productivity |
Post immediately (bypasses queue) |
/status |
Show quota usage, queue size, last post |
/queue |
List all pending topics |
/clear |
Empty the queue |
/help |
Show all commands |