ENFRKOantony langlois
work

Vocab Catcher

visitautomationPythonTelegramGeminiObsidian

Telegram bot that turns words you just met into Obsidian flashcards · Gemini JSON-mode enrichment · one text message becomes a spaced-repetition card in seconds

Problem to solve

Language learners like myself often use flashcards to learn and remember new words. A flashcard has one side with the English (or any language) meaning and one side with the studied language. In my case, Korean.

But this process is very bothersome and can take a long time. And while I was learning Korean, I came to realize that in everyday life, when a friend teaches me a new word or when I encounter a new word in the street for example, I don't have time to create a flashcard for every word on the spot or write a detailed definition, so I end up forgetting them 5 minutes later, which is a massive compounding waste of knowledge.

How I solved it

I solved that problem by providing a way to quickly dump new words, which then takes care on its own of automatically creating flashcards in Obsidian. Allowing you to seamlessly learn new words during the day and to be able to revise them when getting home, to memorize them in the long term.

A Telegram bot, to which you can message a word with optionally a brief meaning of that word with it, then a Python script installed on a VPS takes care of turning that word into a flashcard, using Gemini to get a more detailed, accurate meaning.

It will create 2 flashcards, one with just the definition of that word, and one with a sentence containing a blank in order to train yourself to use that word in real sentences. Obsidian flashcards are simply markdown files.

Once the flashcard is created, the Telegram bot answers us back by confirming, and with the meaning of that word, to be sure that we got the right meaning.

The flashcards are then pushed to a GitHub repo and, using the Obsidian Git plugin, Obsidian desktop and mobile can fetch the cards from that repo so that they're added in real time to your flashcards.

Architecture

phone
Client · capture
Telegram app
any device
User texts a word the moment they meet it: a Korean word, a romanization, or a plain English description
SaaS
External · message broker
Telegram Bot API
api.telegram.org
Queues updates until the bot fetches them · no inbound port needed
desktop · mobile
Client · review
Obsidian
Spaced Repetition plugin
Renders each note as two flashcards: word::meaning and a cloze sentence
Python · asyncio
Core · polling loop
vocab-catcher bot
bot.py · single file
Long-polls Telegram, enriches each word through Gemini, builds the note and cloze, writes it to the vault
long-poll getUpdatesJSON-mode enrichmentcloze builderself-kill watchdoggit sync (VPS branch)
gemini-2.5-flash
External · LLM
Google Gemini
google-genai SDK
One JSON call returns spelling, meaning, example sentence, theme, surface form
Markdown files
Storage · notes
Obsidian vault
OBSIDIAN_VAULT_PATH
One note per word, tagged #flashcards/<lang>/<theme>
private repo
External · sync
Vault git remote
SSH deploy key
VPS pushes each note · devices pull via the Obsidian Git plugin

The user texts a word to the bot from the Telegram app; the Telegram Bot API holds it until the bot picks it up over getUpdates long-polling. The handler makes one JSON-mode call to Gemini, which returns spelling, meaning, an example sentence, a theme, and the word's exact surface form in that sentence. From this the bot builds a Markdown note with a word::meaning card and a cloze card, writes it to the Obsidian vault, and (on the VPS branch) commits and pushes it to the vault git remote in the background. The bot replies Saved: ..., and the user's devices pull the note and review it in Obsidian.

Deployed: Bot · laptop → macOS launchd user agent (macos-background-service branch) · Bot · always-on → VPS · systemd with Restart=always (vps-deployment branch)

Outcomes

  • ~120 lines · entire core in one file · bot.py
  • 0 open ports · long-polling only · no webhook, domain, or TLS
  • 2 cards / word · definition + cloze, generated automatically
  • self-healing · watchdog exits on stall · supervisor restarts

Skills demonstrated: LLM structured output (JSON mode) · crash-only design with supervisor restart · best-effort git sync behind an async lock · deployment variants as additive git branches

Problems and solutions

One constraint drives everything: capturing a word must take seconds, and the pipeline must never die silently.

▒ Structured output instead of parsing prose

Problem: A free-text Gemini reply would need brittle string parsing, and a bad parse means a corrupted flashcard the user only discovers weeks later, mid-review.

Forced JSON mode (response_mime_type: application/json) plus a system instruction pinning the exact keys. The handler consumes the reply with a plain json.loads.

  • Rejected regex-parsing a prose answer: silent corruption is worse than a visible failure
  • A malformed reply fails loudly ('Failed to process'), so the user just resends the word
  • Every downstream step becomes trivial dict access, no defensive parsing code

▩ Cloze cards for a language that conjugates

Problem: Korean inflects: the dictionary form of a verb rarely appears verbatim in a real sentence, so blanking the dictionary form inside the example fails.

The prompt also asks Gemini for the exact surface form as it appears in its own example sentence. Cloze generation is then a single literal string replace.

  • Rejected writing morphology rules in Python: hopeless for an arbitrary LANGUAGE setting
  • If the surface form does not match, the cloze card is skipped rather than saved wrong
  • Pushing the linguistics onto the model keeps the code language-agnostic: one env var switches Korean to Japanese or Spanish

░ Long-polling instead of webhooks

Problem: The bot runs on a laptop or a cheap VPS; a webhook demands a public HTTPS endpoint, a domain, and certificate upkeep for a single-user tool.

Long-polling via run_polling with infinite bootstrap retries. Telegram queues messages server-side while the bot is offline and delivers them on reconnect.

  • Rejected webhook infrastructure: zero benefit at one user's message volume
  • Accepted the one-poller-per-token limit (Telegram returns 409 Conflict), documented in the runbook
  • Nothing is exposed to the internet, so there is no attack surface to maintain

▥ A watchdog that kills its own process

Problem: The poller can stall silently after a laptop sleep/wake or network change: the process looks alive to launchd while receiving nothing.

An asyncio task checks updater.running every 30 s and calls os._exit(1) on stall; launchd or systemd restarts the process with fresh state.

  • Rejected in-process reconnection logic: fighting the library's internals is fragile
  • Crash-only design: restart is the one recovery path, so it is always well-tested
  • The same trick works unchanged under both launchd and systemd

▨ Deployment variants as branches, not flags

Problem: Three ways to run (foreground dev, macOS background agent, VPS with git sync) would fill the single file with config flags and dead code paths.

main stays minimal; the launchd installer lives on one branch, and the VPS branch adds vault_sync.py plus three lines in bot.py.

  • Rejected env-var feature flags: every deployment would carry the others' dead code
  • Accepted keeping branches in sync with main, kept cheap by strictly additive diffs (66 lines)
  • Each checkout reads as exactly the code it runs, nothing hypothetical

▣ Git sync that never blocks a save

Problem: On the VPS the vault must reach the user's devices, but a failed push must not lose the note or crash the message handler.

Best-effort sync: the note hits disk first, then commit + push run in a thread behind an asyncio lock; failures are logged, never raised.

  • Rejected Obsidian Sync and Syncthing: git gives history plus deploy-key auth on a private repo for free
  • The lock serializes concurrent messages so the git index never races
  • A failed push self-heals: the pending commit rides along with the next word's push