9.9 KiB
journal-bot — Telegram → Obsidian Daily Journal
Pipeline: Telegram-Bot empfängt Text/Sprache/Foto → semantische Verarbeitung → Eintrag in Christians Obsidian Daily Note.
Zweck
Christian pflegt sein Daily Journal in Obsidian. Dieses Projekt erlaubt es ihm, Einträge bequem per Handy (auch als Sprachnachricht) abzusetzen. Beispiel: „Gestern haben wir eine Party gefeiert" landet in der Daily Note von gestern, nicht von heute. Meta-Phrasen („Schreib ins Journal, dass …") werden vor dem Schreiben gefiltert.
Architektur
Zwei-Stage-Pipeline mit File-Queue dazwischen:
Telegram → [Ingest] → queue/pending/ → [Process] → Obsidian Daily Note
↘ [Claude-Skill /journal-sync] (Premium-Fallback)
- Ingest (
ingest.py): Long-Polling TelegramgetUpdates, Sender-Filter, Voice-Transcription via faster-whisper (Fallback: Telegram-Premiumtranscribed_text), Foto-Download, schreibtQueueItemals JSON inruntime/queue/pending/. - Process (
process.py): claimt nächstes Item, bautProcessorInput, ruft Processor, schreibt Eintrag viaVaultWriterin die Daily Note, verschiebt Item nachdone/. - Zwei Processor-Backends (Protocol in
processor_protocol.py):- LMStudioProcessor (default, lokal, hands-off) — spricht LM Studio per OpenAI-kompatibler API.
- Claude-Code-Skill
/journal-syncim Vault (D:\projects\chrka\brain\.claude\skills\journal-sync.md) — manueller Premium-Pfad, wenn die LLM-Qualität von LM Studio nicht reicht oder LM Studio aus ist.
Crash-Safety: atomic os.rename zwischen pending/working/done/failed, atomic .tmp + replace für State-File, processed_ids-Deque verhindert Doppelverarbeitung.
Projekt-Layout
src/journal_bot/
__main__.py # CLI: ingest | process | both | write
config.py # pydantic-settings (env)
state.py # last_update_id + processed_ids deque
queue.py # QueueItem + atomic file queue
vault_writer.py # append to daily note, frontmatter, Klärungs-Callout
telegram_client.py # httpx.AsyncClient, getUpdates, download, react
transcribe.py # faster-whisper + premium fallback
context.py # Personen + aktive Projekte aus Vault
processor_protocol.py # Protocol + Pydantic ProcessorInput/Output
processor_lmstudio.py # OpenAI-kompatibler LM-Studio-Client
ingest.py # poll → queue
process.py # queue → vault
prompts/
journal_system.md # System-Prompt, gilt für beide Backends
scripts/
run.ps1 # venv-Wrapper für CLI
build-tools.ps1 # publish .NET tools as self-contained EXEs
install-service.ps1 # (Admin) register JournalBotService
install-tray.ps1 # autostart tray @logon (HKCU Run key)
runtime/ # generiert, NICHT committen
queue/{pending,working,done,failed}/
state/state.json
attachments/
logs/
tests/
test_*.py # 38 Tests, alle grün
fixtures/prompt_regression/inputs.yaml
Pfade
- Projekt-Root:
D:\projects\chrka\journal-bot - Vault:
D:\projects\chrka\brain(separates Repo, eigener Git) - Daily Notes landen in:
<vault>\05 Daily Notes\YYYY-MM-DD.md - Anhänge in:
<vault>\07 Anhänge\(Default inattachments_dir)
CLI
.venv/Scripts/python.exe -m journal_bot ingest # Telegram → queue
.venv/Scripts/python.exe -m journal_bot process # queue → vault (via LM Studio)
.venv/Scripts/python.exe -m journal_bot both # ingest dann process
.venv/Scripts/python.exe -m journal_bot write --target-path "05 Daily Notes/2026-06-15.md" --entry-file tmp.md
write ist Helper für den Claude-Skill: schreibt einen vorbereiteten Eintrag ohne LLM-Aufruf.
Konfiguration (.env)
Aus .env.example. Pflichtfelder ohne Defaults:
TELEGRAM_TOKEN— von @BotFatherALLOWED_USER_ID— eigene User-ID (via @userinfobot). Filter im TelegramClient verwirft alle anderen Sender.VAULT_PATH—D:\projects\chrka\brainJOURNAL_BOT_HOME—D:\projects\chrka\journal-bot\runtime(queue/state/logs)
Optional:
LMSTUDIO_URL— Defaulthttp://localhost:1234/v1LMSTUDIO_MODEL— z.B.qwen/qwen3-vl-8b(vision-capable)WHISPER_MODEL— Defaultlarge-v3WHISPER_DEVICE—cpuodercuda
Tests
Python 3.12+, uv-managed venv unter .venv/. uv ist nicht auf PATH — venv-Python direkt verwenden:
.venv/Scripts/python.exe -m pytest -v
- 38 Tests, alle Mocks (kein echter Telegram/LM-Studio-Call).
- pytest-asyncio im Auto-Mode (
pyproject.toml→asyncio_mode = "auto"). respxmockt httpx;pytest-mockfür Sonstiges.- Prompt-Regression in
tests/test_prompt_regression.pymit YAML-Fixtures.
Deployment
.envfüllen.- LM Studio starten, Modell laden, Server auf Port 1234.
- Service + Tray installieren (supersedes the old
install-task.ps1): siehe Service + Tray weiter unten. - Trigger manuell:
scripts/run.ps1 both.
Service + Tray (tools/)
Two thin .NET 10 shells around the Python CLI (solution: tools/JournalBot.slnx):
- JournalBot.Service — Windows Service (LocalSystem),
PeriodicTimerrunspython -m journal_bot ingesteveryIntervalMinutes(default 15). Config inappsettings.jsonnext to the EXE. Logs toruntime/logs/service.log+ Event Log. Onlyingest— no LM Studio needed. - JournalBot.Tray — WPF tray app (user process, autostart @logon). Tray icon + window with status banner (pending/failed), Historie tab (done items, newest first, with target daily note), Log tab, and Ingest/Process/Both buttons. Runs
processhere because LM Studio is a user-session app. Command errors surface in the Log tab (no crash). - JournalBot.Shared —
QueueItemDTO (mirrors the pydantic model, snake_case JSON),BotRunner(subprocess wrapper, kills process tree on cancel),RuntimeReader(reads done/, counts pending/failed, tails logs). xUnit tests inJournalBot.Shared.Tests(5 tests).
Build + install (PowerShell):
scripts\build-tools.ps1 # publish self-contained EXEs to tools\publish
scripts\install-service.ps1 # (Admin) register + start JournalBotService
scripts\install-tray.ps1 # autostart tray @logon (HKCU Run key)
.NET tests: dotnet test tools/JournalBot.slnx. .NET SDK 10 required.
Gotcha: the GUI history shows target_path/written_entry/processed_at, which process now records on done items (queue.py complete() rewrites the JSON instead of a plain rename). Old done items lack these fields (all optional -> render blank).
Note: SDK 10 generates a .slnx solution file (XML), not legacy .sln. Both work with the dotnet CLI.
Known Gotchas (bei Änderungen beachten)
- UTF-8 ist Pflicht. Windows-Default ist cp1252 → zerschießt Umlaute. Immer
encoding="utf-8"beim Lesen/Schreiben. NIE PowerShellSet-Content/Get-Contentohne-Encoding UTF8— sie zerstören UTF-8-Sequenzen (Vorfall in dieser Codebase: „Klärung" → „Klärung", BOM eingefügt). Im Code stattdessenPath.write_text(..., encoding="utf-8")oder überWrite-Tool schreiben. uvnicht auf PATH. Immer.venv/Scripts/python.exedirekt aufrufen.- LM Studio:
response_formatbrauchtjson_schema, nichtjson_object. LM Studio lehntjson_objectmit 400 ab. Wir schicken das Pydantic-JSON-Schema vonProcessorOutputmit. Sieheprocessor_lmstudio.py:62-69. - Telegram
getUpdates-Semantik. Updates bleiben auf Telegram-Server bisoffset > last_update_idim nächsten Call gesendet wird. WennALLOWED_USER_IDfalsch ist, filtert der Client lokal alles raus, der State bleibt bei 0, aber pollt weiter — Updates sind dann seltsam „verschluckt" je nach Reihenfolge. Bei Verdacht:getUpdates?offset=-1zeigt das letzte Update auch nach Confirmation. pending_update_countausgetWebhookInfoist ohne Webhook immer 0. Heißt nicht „keine Updates" — heißt nur „kein Webhook angedockt".process_oncebricht nach erstem Fail ab (process.py). Sonst würdequeue.fail()das Item zurück nachpending/legen und diewhile True-Schleife würde es sofort wieder claimen → Endlosschleife. Testtest_process_fails_item_on_errorerwartetattempts==1.- httpx.Client in LMStudioProcessor wird nie geschlossen. OK für Single-Run-CLI, bei Long-running-Mode später
close()ergänzen. - Image-MIME hartcodiert
image/jpeg. Telegram-Fotos kommen meist als JPEG, PNG-Screenshots werden mislabeled. Vision-Modelle tolerieren das meist. - Frontmatter-Parser ist minimal (kein PyYAML), liest nur flache
key: value-Zeilen. Reicht für00 Kontext/Personen/*.md.
Vault-Konventionen (relevant für Prompt)
- Daily Note:
05 Daily Notes/YYYY-MM-DD.mdmit Frontmattertags: [daily]\ndate: YYYY-MM-DD. - Person verlinken:
[[00 Kontext/Personen/<Display>|<Display>]]. Vorname/Nachname/Spitzname werden aus Frontmatter der Person gematched. - Mehrdeutige Namen (z.B. zwei „Steffen") → Klärungsfrage in
clarifications+ Placeholder[[?]]im Text. - Klärung wird als Obsidian-Callout angehängt:
> [!warning] Klärung\n> - <text>. - Sprache: Deutsch, knapp, faktisch, keine Floskeln.
Beziehung zum Vault
Das Vault-Repo (D:\projects\chrka\brain) ist getrennt. Vom journal-bot wird darauf lesend zugegriffen (Personen, Projekte) und schreibend nur in 05 Daily Notes/ und 07 Anhänge/. Der Claude-Code-Skill /journal-sync liegt im Vault, weil er pro Vault-Session verfügbar sein muss.
Commits & Branch-Strategie
- Feature-Branches von
main, Squash-Merge. - Conventional Commits (
feat/fix/refactor/docs/test/chore). - Englisch, imperativ.
- TDD: failing test → impl → green → commit pro logischer Einheit.
Roadmap / V2-Ideen (nicht jetzt)
- Webhook-Mode statt Long-Polling.
- Routing nach Inbox/Projekt statt nur Daily Note.
- Multi-User-Support.
- Long-running-Daemon mit lifecycle-managed httpx-Client.
- Prompt-Tuning nach echten Inputs (Regressions-Suite erweitern).