Skip to content
blacktomangPublic

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

Β 

History

48 Commits

Folders and files

Repository files navigation

kitacatat πŸ’Έ

A personal finance tracker for family use (2 users). Send your spending to a Telegram bot as casual text (makan siang 50rb, gaji 5jt) or a photo of a receipt / bank-transfer / e-wallet screenshot. A Go bot OCRs images, asks Gemini to structure the text into transaction(s), saves them to Postgres (Supabase), and a React dashboard visualizes everything.

Telegram message ─▢ Go bot ─▢ (OCR if image) ─▢ Gemini (structured JSON)
                                                     β”‚
                                                     β–Ό
                                            Postgres (Supabase)
                                                     β”‚
                                                     β–Ό
                                       React dashboard (reads via RLS)

Repo layout

apps/
  bot/               # Go: Telegram bot + OCR + Gemini + Postgres
    cmd/bot/         #   entrypoint
    internal/
      config/        #   typed config from env (.env via godotenv)
      domain/        #   Transaction, Category/Type enums, validation
      ocr/           #   gosseract (Tesseract) wrapper: bytes -> text
      ai/            #   provider-agnostic Parser: text -> []Transaction (gemini | openai)
      store/         #   sqlc-generated queries + pgx pool wrapper
      telegram/      #   text / photo / image-document handlers
    db/queries/      #   sqlc query definitions
  dashboard/         # React 19 + Vite + TanStack Router/Query + Tailwind v4 + Recharts
supabase/
  config.toml        # Supabase CLI project config
  migrations/        # SQL migrations (schema source of truth, applied via `supabase db push`)
  functions/
    telegram-login/  # Edge Function: verifies Telegram login β†’ Supabase session
packages/
  config/            # shared tsconfig + eslint for the JS side
turbo.json           # build / dev / lint pipeline
pnpm-workspace.yaml

πŸ”‘ Accounts & keys you must supply

Put all of these in a .env file at the repo root (copy from .env.example):

Variable Where to get it
TELEGRAM_BOT_TOKEN Message @BotFather on Telegram β†’ /newbot β†’ copy the token.
AI_PROVIDER / AI_MODEL / AI_API_KEY The LLM provider (gemini or openai-compatible), model id, and key. See Choosing an AI provider.
DATABASE_URL Supabase β†’ create a project β†’ Project Settings β†’ Database β†’ Connection string (URI). Include ?sslmode=require.
VITE_SUPABASE_URL Supabase β†’ Project Settings β†’ API β†’ Project URL.
VITE_SUPABASE_PUBLISHABLE_KEY Supabase β†’ Project Settings β†’ API Keys β†’ publishable key (sb_publishable_…). Replaces the legacy anon key.
VITE_TELEGRAM_BOT_USERNAME Your bot's username (without @) from BotFather β€” the login screen links to it so you can DM /login.
TESSDATA_PREFIX Path to Tesseract trained data (see install step). macOS Homebrew: /opt/homebrew/share/tessdata.
ALLOWED_TELEGRAM_IDS Optional. Comma-separated Telegram user IDs allowed to use the bot. Empty = any linked account.

No hardcoded user IDs. Access is granted by signing into the dashboard with Telegram (see Login (Sign in with Telegram)). The bot writes to Postgres directly with DATABASE_URL (bypasses RLS); the dashboard reads with the publishable key gated by Row Level Security.

Prerequisites

  • Go 1.24+ β€” brew install go
  • pnpm + Node 20+ β€” brew install pnpm node
  • Tesseract with Indonesian + English trained data (for local OCR):
    • macOS: brew install tesseract tesseract-lang (installs ind/eng into /opt/homebrew/share/tessdata)
    • Debian/Ubuntu: sudo apt-get install tesseract-ocr tesseract-ocr-ind tesseract-ocr-eng libtesseract-dev libleptonica-dev
  • sqlc (generate DB code) β€” brew install sqlc
  • Supabase CLI (migrations + local stack) β€” brew install supabase/tap/supabase
  • Docker (for the local Supabase stack used by pnpm dev) β€” Docker Desktop / OrbStack / colima

The bot uses CGO to link Tesseract. On macOS Homebrew, Leptonica is keg-only; the bot's npm scripts (apps/bot/scripts/go.sh) set the right CGO_* paths automatically, so pnpm dev / turbo build just work. On Linux the apt headers are already on the default path.

Setup

# 1. Install JS deps (Turbo, dashboard deps, shared config)
pnpm install

# 2. Configure
cp .env.example .env
$EDITOR .env          # fill in the keys from the table above

# 3. Schema.
#    LOCAL DEV: skip this β€” `pnpm dev` boots a local Supabase stack and applies
#    supabase/migrations automatically.
#    HOSTED (shared/prod) project: link it once and push the migrations.
#    Find <project-ref> in your project's URL or Project Settings β†’ General.
supabase login                       # one-time, opens a browser
supabase link --project-ref <project-ref>
supabase db push                     # applies supabase/migrations/*  (= pnpm db:push)

# 4. (Re)generate type-safe DB code from db/queries β€” only needed if you change
#    the schema (supabase/migrations) or queries; generated code is committed.
cd apps/bot && sqlc generate && cd -
#    …or: pnpm --filter @kitacatat/bot run sqlc

#    To add a new migration later:  pnpm db:new <name>   (then edit the file,
#    then pnpm db:push). To iterate locally with Docker: supabase db reset.

Run

# Local dev: boots the Dockerized Supabase stack, points both apps at it,
# then runs bot + dashboard. (Requires Docker running.)
pnpm dev

# Run only the apps against whatever your env points to (e.g. a hosted
# Supabase) without starting the local stack:
pnpm dev:apps

# Or individually:
pnpm --filter @kitacatat/bot run dev          # starts the Telegram bot
pnpm --filter @kitacatat/dashboard run dev    # Vite dev server (http://localhost:5173)

What pnpm dev does: runs supabase start (applies supabase/migrations to a local Postgres), then exports the local stack's connection details onto DATABASE_URL / VITE_SUPABASE_URL / VITE_SUPABASE_PUBLISHABLE_KEY before launching Turbo. These exported vars override .env, so locally your .env only needs the non-Supabase secrets: TELEGRAM_BOT_TOKEN, GEMINI_API_KEY, VITE_TELEGRAM_BOT_USERNAME, and TESSDATA_PREFIX.

Handy local URLs (from supabase start): Studio (DB UI) at http://127.0.0.1:54323. Stop the stack with pnpm supabase:stop.

Login (Sign in with Telegram)

Auth is Telegram-native via a bot-issued login code β€” no email, no widget, no HTTPS domain required (so it works on localhost):

  1. You DM /login to the bot.
  2. The bot (Telegram has already authenticated you) writes a short-lived, single-use token to login_tokens and replies with a dashboard link ($DASHBOARD_URL/?token=…).
  3. Opening the link makes the dashboard POST the token to the telegram-login Edge Function, which validates it, provisions a Supabase user whose profile carries the telegram_id, and returns a one-time OTP the dashboard exchanges for a session.

Logging in is the registration, and it's what authorizes the bot. Optionally restrict the bot to specific Telegram accounts with ALLOWED_TELEGRAM_IDS (comma-separated IDs) β€” every bot handler is gated on that allowlist, so unlisted users are blocked before they can even /login. Without it, any linked account may use the bot. Registration is capped at MAX_PROFILES (default 2) accounts; returning users always get in. The Supabase session (access + refresh) is then managed by Supabase as usual.

One-time setup: deploy the Edge Function (no bot domain needed):

supabase functions deploy telegram-login   # config sets verify_jwt=false
# optional: change the account cap (default 2)
# supabase secrets set MAX_PROFILES=2

SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are injected automatically. Set DASHBOARD_URL for the bot (defaults to http://localhost:5173) so the login link points at the right place.

Then DM your bot on Telegram:

  • makan siang 50rb β†’ 1 expense, category food
  • gaji 5jt β†’ 1 income, category salary
  • a photo of a receipt β†’ OCR β†’ the total is captured (add a caption for extra context, e.g. "belanja bulanan")
  • an image sent as a file (document) β†’ handled the same way

Build

pnpm build        # = turbo run build
# bot     -> apps/bot/bin/bot
# dashboard -> apps/dashboard/dist

Bot Docker image (OCR runs inside the container)

cd apps/bot
docker build -t kitacatat-bot .
docker run --rm --env-file ../../.env kitacatat-bot

The image is multi-stage: it builds with the Tesseract/Leptonica dev headers (CGO_ENABLED=1) and ships a slim runtime that installs the Tesseract runtime plus tesseract-ocr-ind + tesseract-ocr-eng and sets TESSDATA_PREFIX.

Deployment (GitHub CI/CD)

Three targets, each deployed from GitHub Actions in .github/workflows/ (with path filters so only the changed app redeploys):

Piece Host Workflow
Bot (Go, always-on) Your VPS (Docker) bot.yml β†’ build β†’ push to GHCR β†’ SSH pull & restart
Dashboard (Vite SPA) Cloudflare Pages dashboard.yml β†’ build + wrangler pages deploy
DB + Edge Function Supabase supabase.yml β†’ db push + functions deploy
PR checks β€” ci.yml β†’ pnpm build + pnpm lint (installs Tesseract for the Go build)

The bot long-polls Telegram (outbound only), so the VPS needs no domain, no open ports, no HTTPS β€” just Docker and outbound internet.

One-time setup

  1. VPS (Debian/Ubuntu): install Docker, then create the runtime env file the container reads (secrets stay on the box, not in GitHub):
    curl -fsSL https://get.docker.com | sh
    sudo mkdir -p /opt/kitacatat
    sudo tee /opt/kitacatat/.env >/dev/null <<'EOF'
    TELEGRAM_BOT_TOKEN=…
    DATABASE_URL=…                 # your Supabase connection string
    DASHBOARD_URL=https://<your-pages-domain>
    AI_PROVIDER=openai
    AI_BASE_URL=https://api.groq.com/openai/v1
    AI_MODEL=llama-3.3-70b-versatile
    AI_API_KEY=…
    EOF
    (TESSDATA_PREFIX is already set inside the image.) Add the public key whose private half you'll put in SSH_KEY to ~/.ssh/authorized_keys.
  2. Cloudflare Pages: create a project named kitacatat (wrangler pages project create kitacatat).
  3. Supabase: have a hosted project (its <project-ref>).

GitHub repo secrets

Secret Used by
SSH_HOST, SSH_USER, SSH_KEY (private key), SSH_PORT (optional) bot.yml (deploy over SSH)
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID dashboard.yml
VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY, VITE_TELEGRAM_BOT_USERNAME dashboard.yml (build-time)
SUPABASE_ACCESS_TOKEN, SUPABASE_PROJECT_REF, SUPABASE_DB_PASSWORD supabase.yml

The image is pushed to GHCR using the built-in GITHUB_TOKEN (no secret needed). The bot's runtime env lives in /opt/kitacatat/.env on the VPS, not in GitHub.

After secrets are set, every push to main deploys the pieces it touched.

Data model & Row Level Security

The migrations in supabase/migrations/ create:

  • profiles β€” one row per Supabase Auth user (auto-created by a trigger on auth.users), with a unique telegram_id + telegram_username. These are set by the telegram-login Edge Function when the user signs in with Telegram.
  • transactions.user_id is a uuid FK to profiles(id) β€” the bot looks up the profile by telegram_id and stamps each transaction with it.

RLS (all policies scoped with TO authenticated):

  • profiles: a user can only read/update their own row (auth.uid() = id; the update policy also has a matching WITH CHECK).
  • transactions: any logged-in family member can read all rows (USING (true)) β€” a shared household view. To isolate per user instead, change the policy to USING ((select auth.uid()) = user_id) (noted inline in the migration).
  • The bot writes via the direct Postgres connection, which bypasses RLS, so there are no write policies/grants for anon/authenticated.

Choosing an AI provider

internal/ai is provider-agnostic β€” a Parser interface with two implementations, selected by env:

  • AI_PROVIDER=gemini (gemini.go) β€” Google's native SDK with a strict response schema. Default model gemini-2.5-flash.
  • AI_PROVIDER=openai (openai.go) β€” any OpenAI-compatible /chat/completions endpoint. Switch providers by just setting AI_BASE_URL + AI_MODEL + AI_API_KEY β€” no code changes. Works with OpenAI, Groq, OpenRouter, DeepSeek, Together, local Ollama, …

This app's calls are tiny (a short message or OCR text in, small JSON out), so cost is fractions of a cent per message β€” pick for reliability, not price:

Want Set
Cheapest reliable Gemini AI_PROVIDER=gemini, AI_MODEL=gemini-2.5-flash-lite (enable billing to avoid free-tier "high demand" 503s)
Free + fast AI_PROVIDER=openai, AI_BASE_URL=https://api.groq.com/openai/v1, a Groq Llama model
One key, many models AI_PROVIDER=openai, AI_BASE_URL=https://openrouter.ai/api/v1, an OpenRouter model

gemini-2.0-flash is retired (0 free quota) β€” don't use it.

How parsing works

internal/ai sends the text (plus any photo caption) to the configured model in JSON mode. The model is told that amounts are Indonesian Rupiah (Rp50.000, 50.000, 50rb, 5jt, 1.250.000); to extract one transaction per line item on an itemized receipt (each with its own AI-inferred category, skipping the grand total to avoid double-counting) or a single transaction for a plain note/total; to use a date from the text (else now), and to infer type and category. The Gemini provider additionally enforces a strict response schema. Every field is then validated in Go (amount > 0, type/category in the allowed sets) and invalid items are dropped before saving.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages