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
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