Polls the shared MAC Instagram account for new Reels and forwards each one to a MAC Discord channel. Detection is poll-based (Instagram has no "new post" webhook). One small Node worker, state in Postgres, output via a Discord channel webhook.
Part of the MAC Suite. Standalone worker — does not depend on mac-auth.
- Uses the Instagram API with Instagram Login (
graph.instagram.com), so it needs no Facebook Page / Business Portfolio — only the shared Instagram account. - The rotating Instagram token lives in Postgres (
ig_token), never in env. It self-refreshes daily and survives downtime of up to ~60 days. - Fails loud: a lapsed/failed token refresh posts to a Discord ops channel.
- Least privilege: requests
instagram_business_basic(read-only) only.
The bot is worthless if the account behind it gets orphaned. Flag these to the current Marketing/Media Director before deploying:
- Move the Instagram 2FA / recovery phone off a personal number. Every API
path authenticates against this account; if it locks, none survive. Set a
committee-controlled number and
marketing@monashcoding.comas recovery email. - Register the Meta Developer app under
marketing@monashcoding.com(orprojects@monashcoding.com) — never a personal Meta login. - Do NOT create a Facebook account for MAC. (Club policy; this flow doesn't need one.)
- Create a Meta Developer app, type Business, under the institutional inbox.
- Add Instagram product → Instagram API with Instagram Login
(the
graph.instagram.compath — not "Facebook Login for Business"). - Set the OAuth redirect URI to
https://ig-bot.monashcoding.com/auth/callback. - Add the MAC Instagram account as a connected/tester account so it can be authorized in dev mode.
- Requested scope:
instagram_business_basiconly. - Checkpoint: confirm whether read-only access to your own connected account works in dev mode (account added as tester) or needs App Review / Advanced Access. Basic read on an owned account generally works without full review — verify against current Meta gating before assuming.
Record the App ID and App Secret → they go into env (IG_APP_ID,
IG_APP_SECRET).
Single Node 22 container. Express serves three routes; two node-cron jobs work.
Routes
GET /auth/start→ redirects to Instagram's OAuth authorize URL. Used once during bootstrap.GET /auth/callback→ exchanges?codefor a long-lived token, persists it, shows a "connected ✓" page.GET /healthz→ 200 + token status (age, expiry) for uptime checks.
Cron jobs
- Poll (
POLL_CRON, default every 10 min): fetch recent media, filter to the configured product types, post any not-yet-posted reels to Discord. - Token refresh (
REFRESH_CRON, daily): if the token is older thanREFRESH_IF_OLDER_THAN_DAYS, refresh and persist. Self-healing after downtime (works as long as downtime < 60 days).
Schema is created idempotently at startup (ensureSchema in src/db/index.ts) —
no manual migration step. Tables: ig_token, posted_reels, bot_state.
Copy .env.example → .env and fill in. Note the IG token is not an env var
— it is written to ig_token by the OAuth bootstrap and thereafter self-refreshed.
| Var | Purpose |
|---|---|
IG_APP_ID, IG_APP_SECRET |
Meta app credentials (static). |
IG_REDIRECT_URI |
https://ig-bot.monashcoding.com/auth/callback. |
IG_SCOPE |
instagram_business_basic (default). |
DISCORD_WEBHOOK_URL |
Channel for reel posts. |
DISCORD_OPS_WEBHOOK_URL |
Ops/alerts channel (refresh failures). |
POLL_CRON |
Reel poll frequency (default */10 * * * *). |
REFRESH_CRON |
Daily token-refresh check (default 0 4 * * *). |
REFRESH_IF_OLDER_THAN_DAYS |
Refresh when token age exceeds this (1–59, default 45). |
POST_MEDIA_PRODUCT_TYPES |
Comma-sep; default REELS. Set REELS,FEED to post all posts. |
DATABASE_URL |
Postgres connection string. |
POSTGRES_USER/PASSWORD/DB |
Seed the bundled compose Postgres. |
cp .env.example .env # fill in secrets
docker compose up -ddocker-compose.yml ships the worker plus a dedicated Postgres with a named
volume (igbot_pgdata), so the app is fully self-contained. To reuse the existing
suite Postgres instead, delete the db service + volume and point DATABASE_URL
at that instance.
Traefik labels expose the worker on ig-bot.monashcoding.com for the OAuth
callback + healthcheck, with TLS via the letsencrypt certresolver (adjust the
resolver name to match your Traefik setup). Register the app's /auth/callback
as the Meta app redirect URI.
- Deploy; confirm
https://ig-bot.monashcoding.com/healthzresponds (reports"token":"missing"). - A marketing officer opens
https://ig-bot.monashcoding.com/auth/start, logs in with the shared MAC Instagram credentials (2FA code from whoever holds it), and authorizes. - The callback stores the long-lived token in
ig_token./healthznow shows a valid token + expiry. - The first poll seeds
posted_reelssilently (no posts) so the historical back-catalogue is never dumped into the channel. Subsequent new reels post to Discord. - The token self-refreshes daily. If it ever lapses (service down > 60 days), an ops alert fires and you repeat step 2 — a ~5-minute self-serve recovery, no Meta support needed.
npm install
cp .env.example .env # set DATABASE_URL to a local Postgres
npm run dev # tsx watch
# in another shell:
open http://localhost:3000/healthznpm run typecheck / npm run build compile the TypeScript to dist/.