diff --git a/CLAUDE.md b/CLAUDE.md index fb583db..761ce9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**my-brain-importer** is a personal AI assistant and RAG (Retrieval-Augmented Generation) system written in Go. It ingests Markdown notes into a Qdrant vector database, answers questions using a local LLM (LocalAI), and is primarily controlled via Discord. A background daemon sends proactive email summaries and task reminders. +**my-brain-importer** is a personal AI assistant and RAG (Retrieval-Augmented Generation) system written in Go. It ingests Markdown notes into a Qdrant vector database, answers questions using a local LLM (LocalAI), and is primarily controlled via Discord. A background daemon sends proactive email summaries and a daily morning briefing. ## Commands @@ -31,6 +31,10 @@ go test ./... # Tidy dependencies go mod tidy + +# Deployment auf Remote-Server (192.168.1.118) +cp deploy.env.example deploy.env # einmalig: Credentials eintragen +bash deploy.sh # build + upload + systemctl restart ``` Binaries are output to `./bin/`. The `config.yml` file must exist in the working directory at runtime. @@ -41,18 +45,18 @@ Binaries are output to `./bin/`. The `config.yml` file must exist in the working Discord (primäres Interface) ↓ Slash-Commands + @Mention cmd/discord/main.go - ├── internal/agents/research/ → brain.AskQuery() + ├── internal/agents/research/ → brain.AskQuery() + Konversationsverlauf ├── internal/agents/memory/ → brain.RunIngest(), brain.IngestChatMessage() - ├── internal/agents/task/ → tasks.json (atomisches JSON) - └── internal/agents/tool/email/ → IMAP + LLM-Zusammenfassung + ├── internal/agents/task/ → tasks.json (atomisches JSON, DueDate + Priority) + └── internal/agents/tool/email/ → IMAP + LLM-Zusammenfassung + Move to Processed ↓ [Daemon-Goroutine] startDaemon() - ├── Email-Check (alle N min) → #localagent Discord-Channel - └── Task-Reminder (täglich) → #localagent Discord-Channel + ├── Email-Check (alle N min) → #localagent Discord-Channel + └── Morgen-Briefing (täglich 8h) → Tasks + Emails kombiniert cmd/ingest/ + cmd/ask/ (CLI-Tools, direkt nutzbar) ↓ -internal/brain/ (Core RAG-Logik, unverändert) +internal/brain/ (Core RAG-Logik) ↓ Qdrant (gRPC) + LocalAI (HTTP, OpenAI-kompatibel) ``` @@ -67,22 +71,23 @@ Qdrant (gRPC) + LocalAI (HTTP, OpenAI-kompatibel) | `cmd/mailtest/` | Testprogramm: IMAP + LLM-Test | | `internal/brain/` | Core RAG: Embeddings, Qdrant-Suche, LLM-Streaming | | `internal/config/` | Konfiguration + Client-Initialisierung (globale `Cfg`) | -| `internal/agents/` | Agent-Interface (`Request`/`Response`) | -| `internal/agents/research/` | Research-Agent: Wissensdatenbank-Abfragen | +| `internal/agents/` | Agent-Interface (`Request`/`Response`/`HistoryMessage`) | +| `internal/agents/research/` | Research-Agent: Wissensdatenbank-Abfragen (mit History) | | `internal/agents/memory/` | Memory-Agent: Ingest + Chat-Speicherung | | `internal/agents/task/` | Task-Agent: Aufgabenverwaltung (tasks.json) | | `internal/agents/tool/` | Tool-Dispatcher | -| `internal/agents/tool/email/` | IMAP-Client + LLM-Email-Analyse | +| `internal/agents/tool/email/` | IMAP-Client + LLM-Email-Analyse + Move to Processed | ### Discord Commands | Slash-Command | @Mention | Funktion | |---------------|----------|---------| -| `/ask`, `/research` | `@bot ` | Wissensdatenbank abfragen | +| `/ask`, `/research` | `@bot ` | Wissensdatenbank abfragen (mit Chat-Gedächtnis) | | `/asknobrain` | – | Direkt an LLM (kein RAG) | | `/memory store` | `@bot remember ` | Text speichern | | `/memory ingest` | `@bot ingest` | Markdown neu einlesen | -| `/task add/list/done/delete` | `@bot task ` | Aufgaben verwalten | +| `/task add [faellig] [prioritaet]` | `@bot task add [--due YYYY-MM-DD] [--priority hoch]` | Task hinzufügen | +| `/task list/done/delete` | `@bot task ` | Aufgaben verwalten | | `/email summary/unread/remind` | `@bot email ` | Email-Analyse | | `/remember` | – | Alias für `/memory store` | | `/ingest` | – | Alias für `/memory ingest` | @@ -97,6 +102,37 @@ Qdrant (gRPC) + LocalAI (HTTP, OpenAI-kompatibel) - **LLM-Fallback**: Email-Zusammenfassung zeigt Rohliste wenn LLM nicht erreichbar - **Daemon**: läuft als Goroutine im Discord-Bot-Prozess (`startDaemon()`) - **config.Cfg**: globale Variable — bei Tests muss `config.LoadConfig()` aufgerufen oder Cfg direkt gesetzt werden +- **Konversationsverlauf**: Pro Discord-Channel werden die letzten 10 Frage-Antwort-Paare in-memory gehalten und als History an `brain.AskQuery()` übergeben +- **Task-Felder**: `DueDate *time.Time` und `Priority string` (hoch/mittel/niedrig) — rückwärtskompatibel (omitempty) +- **Email processed_folder**: Nach Zusammenfassung werden ungelesene Emails in konfigurierten IMAP-Ordner verschoben (leer = deaktiviert) +- **Morgen-Briefing**: `dailyBriefing()` kombiniert offene Tasks (mit Fälligkeits-Highlighting) + ungelesene Emails täglich um 8:00 + +## config.yml – Neue Felder + +```yaml +email: + processed_folder: "Processed" # Zielordner nach Zusammenfassung (leer = kein Verschieben) + +daemon: + task_reminder_hour: 8 # Uhrzeit des Morgen-Briefings (Standard: 8) +``` + +## Deployment + +```bash +# deploy.env (nicht in Git): +DEPLOY_HOST=192.168.1.118 +DEPLOY_USER=christoph +DEPLOY_PASS=... +DEPLOY_DIR=/home/christoph/brain-bot +SERVICE_NAME=brain-bot +DEPLOY_CONFIG=true + +# Systemd-Service (einmalig): +sudo systemctl unmask brain-bot # falls masked +``` + +Script `deploy.sh` baut das Linux-Binary, überträgt es per `sshpass`/`scp` und startet den systemd-Service neu. ## Model Limitations diff --git a/cmd/discord/main.go b/cmd/discord/main.go index 814cb28..a8dd459 100644 --- a/cmd/discord/main.go +++ b/cmd/discord/main.go @@ -23,6 +23,7 @@ import ( "my-brain-importer/internal/agents/tool/email" "my-brain-importer/internal/brain" "my-brain-importer/internal/config" + "my-brain-importer/internal/diag" ) // maxHistoryPairs ist die maximale Anzahl gespeicherter Gesprächspaare pro Channel. @@ -220,6 +221,7 @@ func main() { defer dg.Close() registerCommands() + runStartupDiag() sendWelcomeMessage() go startDaemon() @@ -480,6 +482,21 @@ func getAuthorFromMessage(m *discordgo.MessageCreate) string { return "unknown" } +// runStartupDiag prüft alle externen Dienste und loggt + sendet das Ergebnis in den Daemon-Channel. +func runStartupDiag() { + results, allOK := diag.RunAll() + diag.Log(results) + + channelID := config.Cfg.Daemon.ChannelID + if channelID == "" { + return + } + msg := diag.Format(results, allOK) + if _, err := dg.ChannelMessageSend(channelID, msg); err != nil { + log.Printf("⚠️ Diagnose-Nachricht konnte nicht gesendet werden: %v", err) + } +} + // sendWelcomeMessage schickt beim Bot-Start eine Begrüßung in den konfigurierten Daemon-Channel. func sendWelcomeMessage() { channelID := config.Cfg.Daemon.ChannelID diff --git a/deploy.sh b/deploy.sh index 2ebb120..fa0450d 100755 --- a/deploy.sh +++ b/deploy.sh @@ -35,9 +35,9 @@ scp_cmd() { sshpass -p "$DEPLOY_PASS" scp $SSH_OPTS "$@" } -# sudo ohne TTY: Passwort per stdin mit -S +# sudo ohne TTY: Passwort per stdin (-S), -p '' unterdrückt den Prompt auf stderr sudo_cmd() { - ssh_cmd "echo '${SUDO_PASS}' | sudo -S $*" + ssh_cmd "echo '${SUDO_PASS}' | sudo -S -p '' $*" } # ── Build ──────────────────────────────────────────────────────────────────── @@ -47,11 +47,18 @@ echo "✅ Binary gebaut" # ── Dateien übertragen ─────────────────────────────────────────────────────── echo "📁 Zielverzeichnis ${DEPLOY_DIR} auf ${DEPLOY_HOST}..." -ssh_cmd "mkdir -p '${DEPLOY_DIR}'" +if ! ssh_cmd "mkdir -p ${DEPLOY_DIR}"; then + echo "❌ Verzeichnis konnte nicht erstellt werden – prüfe Rechte auf ${DEPLOY_HOST}" + exit 1 +fi echo "🚀 Übertrage Binary..." -scp_cmd bin/discord-linux "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_DIR}/discord" -ssh_cmd "chmod +x '${DEPLOY_DIR}/discord'" +# Als temporäre Datei hochladen, dann atomar ersetzen (laufendes Binary kann nicht überschrieben werden) +if ! scp_cmd bin/discord-linux "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_DIR}/discord.new"; then + echo "❌ SCP fehlgeschlagen" + exit 1 +fi +ssh_cmd "chmod +x '${DEPLOY_DIR}/discord.new' && mv '${DEPLOY_DIR}/discord.new' '${DEPLOY_DIR}/discord'" if [[ "$DEPLOY_CONFIG" == "true" ]]; then echo "📋 Übertrage config.yml..." diff --git a/internal/agents/agent_test.go b/internal/agents/agent_test.go new file mode 100644 index 0000000..64cb44b --- /dev/null +++ b/internal/agents/agent_test.go @@ -0,0 +1,43 @@ +package agents + +import "testing" + +func TestHistoryMessage_Fields(t *testing.T) { + h := HistoryMessage{Role: "user", Content: "Hallo"} + if h.Role != "user" { + t.Errorf("Role: %q", h.Role) + } + if h.Content != "Hallo" { + t.Errorf("Content: %q", h.Content) + } +} + +func TestRequest_HistoryAppend(t *testing.T) { + req := Request{ + Action: ActionQuery, + Args: []string{"Frage"}, + History: []HistoryMessage{ + {Role: "user", Content: "Vorherige Frage"}, + {Role: "assistant", Content: "Vorherige Antwort"}, + }, + } + if len(req.History) != 2 { + t.Errorf("History len: %d", len(req.History)) + } + if req.History[0].Role != "user" { + t.Errorf("erstes Element: %q", req.History[0].Role) + } +} + +func TestResponse_RawAnswer(t *testing.T) { + resp := Response{ + Text: "**Formatierte** Antwort", + RawAnswer: "Formatierte Antwort", + } + if resp.RawAnswer == "" { + t.Error("RawAnswer sollte gesetzt sein") + } + if resp.Text == resp.RawAnswer { + t.Error("Text und RawAnswer sollten sich unterscheiden") + } +} diff --git a/internal/agents/task/agent_test.go b/internal/agents/task/agent_test.go new file mode 100644 index 0000000..b794571 --- /dev/null +++ b/internal/agents/task/agent_test.go @@ -0,0 +1,236 @@ +package task + +import ( + "os" + "strings" + "testing" + "time" + + "my-brain-importer/internal/agents" +) + +func newTestAgent(t *testing.T) (*Agent, func()) { + t.Helper() + f, err := os.CreateTemp("", "tasks_agent_*.json") + if err != nil { + t.Fatalf("temp-Datei: %v", err) + } + name := f.Name() + f.Close() + os.Remove(name) // Datei entfernen → loadLocked gibt leeres Slice zurück + a := &Agent{store: &Store{path: name}} + return a, func() { os.Remove(name) } +} + +// ── parseAddArgs ───────────────────────────────────────────────────────────── + +func TestParseAddArgs_TextOnly(t *testing.T) { + text, prio, due := parseAddArgs([]string{"Arzttermin", "buchen"}) + if text != "Arzttermin buchen" { + t.Errorf("text: %q", text) + } + if prio != "" { + t.Errorf("prio sollte leer sein: %q", prio) + } + if due != nil { + t.Error("due sollte nil sein") + } +} + +func TestParseAddArgs_AllFlags(t *testing.T) { + text, prio, due := parseAddArgs([]string{"Zahnarzt", "--due", "2026-12-01", "--priority", "hoch"}) + if text != "Zahnarzt" { + t.Errorf("text: %q", text) + } + if prio != "hoch" { + t.Errorf("prio: %q", prio) + } + if due == nil { + t.Fatal("due sollte gesetzt sein") + } + if due.Format("2006-01-02") != "2026-12-01" { + t.Errorf("due: %v", due) + } +} + +func TestParseAddArgs_ShortFlags(t *testing.T) { + text, prio, due := parseAddArgs([]string{"Meeting", "-p", "mittel", "-d", "2026-06-15"}) + if text != "Meeting" { + t.Errorf("text: %q", text) + } + if prio != "mittel" { + t.Errorf("prio: %q", prio) + } + if due == nil || due.Format("2006-01-02") != "2026-06-15" { + t.Errorf("due: %v", due) + } +} + +func TestParseAddArgs_InvalidDate(t *testing.T) { + _, _, due := parseAddArgs([]string{"Task", "--due", "kein-datum"}) + if due != nil { + t.Error("ungültiges Datum sollte nil ergeben") + } +} + +// ── Agent.Handle ───────────────────────────────────────────────────────────── + +func TestAgent_Add(t *testing.T) { + a, cleanup := newTestAgent(t) + defer cleanup() + + resp := a.Handle(agents.Request{Action: agents.ActionAdd, Args: []string{"Mein Task"}}) + if resp.Error != nil { + t.Fatalf("Add: %v", resp.Error) + } + if !strings.Contains(resp.Text, "Mein Task") { + t.Errorf("Antwort enthält keinen Task-Text: %q", resp.Text) + } +} + +func TestAgent_Add_WithDueAndPriority(t *testing.T) { + a, cleanup := newTestAgent(t) + defer cleanup() + + resp := a.Handle(agents.Request{ + Action: agents.ActionAdd, + Args: []string{"Frist", "--due", "2026-12-31", "--priority", "hoch"}, + }) + if resp.Error != nil { + t.Fatalf("Add: %v", resp.Error) + } + if !strings.Contains(resp.Text, "Frist") { + t.Errorf("Task-Text fehlt: %q", resp.Text) + } + if !strings.Contains(resp.Text, "hoch") { + t.Errorf("Priorität fehlt: %q", resp.Text) + } + if !strings.Contains(resp.Text, "31.12.2026") { + t.Errorf("Datum fehlt: %q", resp.Text) + } +} + +func TestAgent_Add_NoArgs(t *testing.T) { + a, cleanup := newTestAgent(t) + defer cleanup() + + resp := a.Handle(agents.Request{Action: agents.ActionAdd, Args: []string{}}) + if !strings.Contains(resp.Text, "❌") { + t.Errorf("erwartet Fehlermeldung, got: %q", resp.Text) + } +} + +func TestAgent_List_Empty(t *testing.T) { + a, cleanup := newTestAgent(t) + defer cleanup() + + resp := a.Handle(agents.Request{Action: agents.ActionList}) + if !strings.Contains(resp.Text, "Keine Tasks") { + t.Errorf("erwartet 'Keine Tasks': %q", resp.Text) + } +} + +func TestAgent_List_ShowsDueDate(t *testing.T) { + a, cleanup := newTestAgent(t) + defer cleanup() + + due := time.Now().Add(48 * time.Hour) // übermorgen + a.store.Add("Übermorgen", "", &due) + + resp := a.Handle(agents.Request{Action: agents.ActionList}) + if !strings.Contains(resp.Text, "📅") { + t.Errorf("Datum-Icon fehlt in Liste: %q", resp.Text) + } +} + +func TestAgent_List_ShowsOverdue(t *testing.T) { + a, cleanup := newTestAgent(t) + defer cleanup() + + past := time.Now().Add(-48 * time.Hour) // vorgestern + a.store.Add("Überfällig", "", &past) + + resp := a.Handle(agents.Request{Action: agents.ActionList}) + if !strings.Contains(resp.Text, "ÜBERFÄLLIG") { + t.Errorf("ÜBERFÄLLIG fehlt: %q", resp.Text) + } +} + +func TestAgent_Done(t *testing.T) { + a, cleanup := newTestAgent(t) + defer cleanup() + + addResp := a.Handle(agents.Request{Action: agents.ActionAdd, Args: []string{"Erledigen"}}) + // Short-ID aus Antwort extrahieren + tasks, _ := a.store.Load() + if len(tasks) == 0 { + t.Fatal("kein Task angelegt") + } + id := tasks[0].ID + shortID := id[len(id)-6:] + + _ = addResp + resp := a.Handle(agents.Request{Action: agents.ActionDone, Args: []string{shortID}}) + if resp.Error != nil { + t.Fatalf("Done: %v", resp.Error) + } + if !strings.Contains(resp.Text, "✅") { + t.Errorf("erwartet ✅: %q", resp.Text) + } +} + +func TestAgent_Delete(t *testing.T) { + a, cleanup := newTestAgent(t) + defer cleanup() + + a.Handle(agents.Request{Action: agents.ActionAdd, Args: []string{"Löschen"}}) + tasks, _ := a.store.Load() + id := tasks[0].ID + shortID := id[len(id)-6:] + + resp := a.Handle(agents.Request{Action: agents.ActionDelete, Args: []string{shortID}}) + if resp.Error != nil { + t.Fatalf("Delete: %v", resp.Error) + } + if !strings.Contains(resp.Text, "🗑️") { + t.Errorf("erwartet 🗑️: %q", resp.Text) + } + + tasks, _ = a.store.Load() + if len(tasks) != 0 { + t.Errorf("Task sollte gelöscht sein, noch %d vorhanden", len(tasks)) + } +} + +func TestAgent_UnknownAction(t *testing.T) { + a, cleanup := newTestAgent(t) + defer cleanup() + + resp := a.Handle(agents.Request{Action: "unknown"}) + if !strings.Contains(resp.Text, "❌") { + t.Errorf("erwartet Fehlermeldung: %q", resp.Text) + } +} + +// ── resolveID ──────────────────────────────────────────────────────────────── + +func TestResolveID_FullMatch(t *testing.T) { + tasks := []Task{{ID: "123456789"}} + if got := resolveID(tasks, "123456789"); got != "123456789" { + t.Errorf("got %q", got) + } +} + +func TestResolveID_ShortMatch(t *testing.T) { + tasks := []Task{{ID: "123456789"}} + if got := resolveID(tasks, "456789"); got != "123456789" { + t.Errorf("got %q", got) + } +} + +func TestResolveID_NotFound(t *testing.T) { + tasks := []Task{{ID: "123456789"}} + if got := resolveID(tasks, "000000"); got != "" { + t.Errorf("erwartet leer, got %q", got) + } +} diff --git a/internal/agents/task/store_test.go b/internal/agents/task/store_test.go new file mode 100644 index 0000000..85dbfba --- /dev/null +++ b/internal/agents/task/store_test.go @@ -0,0 +1,169 @@ +package task + +import ( + "os" + "testing" + "time" +) + +func newTestStore(t *testing.T) (*Store, func()) { + t.Helper() + f, err := os.CreateTemp("", "tasks_test_*.json") + if err != nil { + t.Fatalf("temp-Datei erstellen: %v", err) + } + name := f.Name() + f.Close() + os.Remove(name) // Datei entfernen → loadLocked gibt leeres Slice zurück + s := &Store{path: name} + return s, func() { os.Remove(name) } +} + +func TestStore_AddAndLoad(t *testing.T) { + s, cleanup := newTestStore(t) + defer cleanup() + + task, err := s.Add("Test Task", "hoch", nil) + if err != nil { + t.Fatalf("Add: %v", err) + } + if task.Text != "Test Task" { + t.Errorf("Text: got %q, want %q", task.Text, "Test Task") + } + if task.Priority != "hoch" { + t.Errorf("Priority: got %q, want %q", task.Priority, "hoch") + } + if task.Done { + t.Error("neuer Task sollte nicht Done sein") + } + + tasks, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(tasks) != 1 { + t.Fatalf("len: got %d, want 1", len(tasks)) + } +} + +func TestStore_AddWithDueDate(t *testing.T) { + s, cleanup := newTestStore(t) + defer cleanup() + + due := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC) + task, err := s.Add("Frist-Task", "mittel", &due) + if err != nil { + t.Fatalf("Add: %v", err) + } + if task.DueDate == nil { + t.Fatal("DueDate sollte gesetzt sein") + } + if !task.DueDate.Equal(due) { + t.Errorf("DueDate: got %v, want %v", task.DueDate, due) + } +} + +func TestStore_MarkDone(t *testing.T) { + s, cleanup := newTestStore(t) + defer cleanup() + + task, _ := s.Add("Erledigbarer Task", "", nil) + if err := s.MarkDone(task.ID); err != nil { + t.Fatalf("MarkDone: %v", err) + } + + tasks, _ := s.Load() + if !tasks[0].Done { + t.Error("Task sollte Done=true sein") + } + if tasks[0].DoneAt == nil { + t.Error("DoneAt sollte gesetzt sein") + } +} + +func TestStore_MarkDone_NotFound(t *testing.T) { + s, cleanup := newTestStore(t) + defer cleanup() + + err := s.MarkDone("nichtexistent") + if err == nil { + t.Error("erwartet Fehler für unbekannte ID") + } +} + +func TestStore_Delete(t *testing.T) { + s, cleanup := newTestStore(t) + defer cleanup() + + t1, _ := s.Add("Task 1", "", nil) + s.Add("Task 2", "", nil) + + if err := s.Delete(t1.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + + tasks, _ := s.Load() + if len(tasks) != 1 { + t.Fatalf("nach Delete: got %d, want 1", len(tasks)) + } + if tasks[0].Text != "Task 2" { + t.Errorf("falscher Task verblieben: %q", tasks[0].Text) + } +} + +func TestStore_Delete_NotFound(t *testing.T) { + s, cleanup := newTestStore(t) + defer cleanup() + + err := s.Delete("nichtexistent") + if err == nil { + t.Error("erwartet Fehler für unbekannte ID") + } +} + +func TestStore_OpenTasks(t *testing.T) { + s, cleanup := newTestStore(t) + defer cleanup() + + t1, _ := s.Add("Offen", "", nil) + s.Add("Auch offen", "", nil) + s.MarkDone(t1.ID) + + open, err := s.OpenTasks() + if err != nil { + t.Fatalf("OpenTasks: %v", err) + } + if len(open) != 1 { + t.Fatalf("OpenTasks: got %d, want 1", len(open)) + } + if open[0].Text != "Auch offen" { + t.Errorf("falscher offener Task: %q", open[0].Text) + } +} + +func TestStore_EmptyFile(t *testing.T) { + s, cleanup := newTestStore(t) + defer cleanup() + + tasks, err := s.Load() + if err != nil { + t.Fatalf("Load auf leerer Datei: %v", err) + } + if len(tasks) != 0 { + t.Errorf("erwartet leer, got %d", len(tasks)) + } +} + +func TestStore_Idempotent_MultipleAdds(t *testing.T) { + s, cleanup := newTestStore(t) + defer cleanup() + + s.Add("A", "", nil) + s.Add("B", "niedrig", nil) + s.Add("C", "hoch", nil) + + tasks, _ := s.Load() + if len(tasks) != 3 { + t.Fatalf("erwartet 3 Tasks, got %d", len(tasks)) + } +} diff --git a/internal/agents/tool/email/summary_test.go b/internal/agents/tool/email/summary_test.go new file mode 100644 index 0000000..6c8426f --- /dev/null +++ b/internal/agents/tool/email/summary_test.go @@ -0,0 +1,72 @@ +package email + +import ( + "strings" + "testing" + "time" +) + +var testMessages = []Message{ + {Subject: "Rechnung März", From: "buchhaltung@firma.de", Date: "2026-03-01 09:00"}, + {Subject: "Meeting Einladung", From: "chef@firma.de", Date: "2026-03-02 10:30"}, + {Subject: "Newsletter", From: "news@shop.de", Date: "2026-03-03 08:00"}, +} + +func TestFormatEmailList_ContainsFields(t *testing.T) { + result := formatEmailList(testMessages) + + checks := []string{"Rechnung März", "buchhaltung@firma.de", "Meeting Einladung", "Newsletter"} + for _, s := range checks { + if !strings.Contains(result, s) { + t.Errorf("formatEmailList: fehlt %q in:\n%s", s, result) + } + } +} + +func TestFormatEmailList_NumberedLines(t *testing.T) { + result := formatEmailList(testMessages) + if !strings.Contains(result, "[1]") { + t.Error("fehlt [1] in Ausgabe") + } + if !strings.Contains(result, "[3]") { + t.Error("fehlt [3] in Ausgabe") + } +} + +func TestFormatEmailList_Empty(t *testing.T) { + result := formatEmailList([]Message{}) + if result != "" { + t.Errorf("leere Liste sollte leeren String ergeben, got: %q", result) + } +} + +func TestFallbackList_ContainsWarning(t *testing.T) { + result := fallbackList(testMessages) + if !strings.Contains(result, "LLM nicht verfügbar") { + t.Errorf("Fallback-Hinweis fehlt: %q", result) + } +} + +func TestFallbackList_ContainsAllMessages(t *testing.T) { + result := fallbackList(testMessages) + for _, m := range testMessages { + if !strings.Contains(result, m.Subject) { + t.Errorf("Betreff fehlt: %q", m.Subject) + } + } +} + +func TestParseMessages_Empty(t *testing.T) { + result := parseMessages(nil) + if len(result) != 0 { + t.Errorf("erwartet leer, got %d", len(result)) + } +} + +func TestMessage_DateFormat(t *testing.T) { + // Datum muss im Format "2006-01-02 15:04" formatiert werden + m := testMessages[0] + if _, err := time.Parse("2006-01-02 15:04", m.Date); err != nil { + t.Errorf("Datumsformat ungültig: %v", err) + } +} diff --git a/internal/diag/diag.go b/internal/diag/diag.go new file mode 100644 index 0000000..121137f --- /dev/null +++ b/internal/diag/diag.go @@ -0,0 +1,120 @@ +// diag – Start-Diagnose: prüft Erreichbarkeit aller externen Dienste +package diag + +import ( + "fmt" + "log/slog" + "net" + "net/http" + "strings" + "time" + + "my-brain-importer/internal/config" +) + +// Result ist das Ergebnis einer einzelnen Prüfung. +type Result struct { + Name string + OK bool + Message string +} + +// RunAll führt alle Verbindungs-Checks durch und gibt eine Zusammenfassung zurück. +// warnings = Dienste die konfiguriert aber nicht erreichbar sind. +func RunAll() (results []Result, allOK bool) { + allOK = true + cfg := config.Cfg + + check := func(name string, ok bool, msg string) { + results = append(results, Result{Name: name, OK: ok, Message: msg}) + if !ok { + allOK = false + } + } + + // Qdrant + qdrantAddr := fmt.Sprintf("%s:%s", cfg.Qdrant.Host, cfg.Qdrant.Port) + ok, msg := tcpCheck(qdrantAddr) + check("Qdrant ("+qdrantAddr+")", ok, msg) + + // LocalAI Chat + if cfg.Chat.URL != "" { + ok, msg = httpCheck(cfg.Chat.URL) + check("LocalAI Chat ("+cfg.Chat.URL+")", ok, msg) + } + + // LocalAI Embedding (nur prüfen wenn andere URL als Chat) + if cfg.Embedding.URL != "" && cfg.Embedding.URL != cfg.Chat.URL { + ok, msg = httpCheck(cfg.Embedding.URL) + check("LocalAI Embedding ("+cfg.Embedding.URL+")", ok, msg) + } + + // IMAP + if cfg.Email.Host != "" { + imapAddr := fmt.Sprintf("%s:%d", cfg.Email.Host, cfg.Email.Port) + ok, msg = tcpCheck(imapAddr) + check("IMAP ("+imapAddr+")", ok, msg) + } + + return results, allOK +} + +// Log gibt die Ergebnisse über slog aus. +func Log(results []Result) { + for _, r := range results { + if r.OK { + slog.Info("Dienst-Check", "dienst", r.Name, "status", "OK", "info", r.Message) + } else { + slog.Warn("Dienst-Check", "dienst", r.Name, "status", "FEHLER", "info", r.Message) + } + } +} + +// Format gibt eine menschenlesbare Zusammenfassung zurück (für Discord/stdout). +func Format(results []Result, allOK bool) string { + var sb strings.Builder + sb.WriteString("🔍 **Start-Diagnose:**\n") + for _, r := range results { + icon := "✅" + if !r.OK { + icon = "❌" + } + fmt.Fprintf(&sb, "%s %s – %s\n", icon, r.Name, r.Message) + } + if allOK { + sb.WriteString("\n✅ Alle Dienste erreichbar.") + } else { + sb.WriteString("\n⚠️ Einige Dienste sind nicht erreichbar — Bot läuft, aber Funktionen könnten fehlen.") + } + return sb.String() +} + +func tcpCheck(addr string) (bool, string) { + conn, err := net.DialTimeout("tcp", addr, 3*time.Second) + if err != nil { + return false, "nicht erreichbar: " + err.Error() + } + conn.Close() + return true, "TCP OK" +} + +func httpCheck(baseURL string) (bool, string) { + // Normalisiere URL: entferne trailing /v1 etc., hänge /v1/models an + url := strings.TrimRight(baseURL, "/") + if !strings.HasSuffix(url, "/models") { + // Gehe zur Basis-URL zurück und frage /v1/models + if idx := strings.LastIndex(url, "/v1"); idx >= 0 { + url = url[:idx] + } + url += "/v1/models" + } + + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get(url) + if err != nil { + return false, "nicht erreichbar: " + err.Error() + } + resp.Body.Close() + return resp.StatusCode == http.StatusOK, + fmt.Sprintf("HTTP %d", resp.StatusCode) +} diff --git a/test-integration.sh b/test-integration.sh new file mode 100755 index 0000000..5206004 --- /dev/null +++ b/test-integration.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# test-integration.sh – Prüft Erreichbarkeit aller externen Dienste +# Kann manuell oder automatisch beim Bot-Start aufgerufen werden. +# +# Exit-Code: 0 = alle Dienste OK, 1 = mindestens ein Dienst nicht erreichbar + +set -euo pipefail + +CONFIG_FILE="${1:-./config.yml}" +TIMEOUT=5 +ERRORS=0 + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +ok() { echo -e " ${GREEN}✓${NC} $*"; } +fail() { echo -e " ${RED}✗${NC} $*"; ((ERRORS++)); } +warn() { echo -e " ${YELLOW}⚠${NC} $*"; } + +# ── Konfiguration lesen ────────────────────────────────────────────────────── +if [[ ! -f "$CONFIG_FILE" ]]; then + echo "❌ config.yml nicht gefunden: $CONFIG_FILE" + exit 1 +fi + +read_yaml() { + python3 -c " +import yaml, sys +with open('$CONFIG_FILE') as f: + cfg = yaml.safe_load(f) +keys = '$1'.split('.') +val = cfg +for k in keys: + val = val.get(k, '') if isinstance(val, dict) else '' +print(val or '') +" 2>/dev/null || echo "" +} + +QDRANT_HOST=$(read_yaml qdrant.host) +QDRANT_PORT=$(read_yaml qdrant.port) +CHAT_URL=$(read_yaml chat.url) +EMB_URL=$(read_yaml embedding.url) +IMAP_HOST=$(read_yaml email.host) +IMAP_PORT=$(read_yaml email.port) + +echo "" +echo "🔍 Integrations-Diagnose" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# ── Qdrant ─────────────────────────────────────────────────────────────────── +echo "" +echo "📦 Qdrant (${QDRANT_HOST}:${QDRANT_PORT})" +if nc -z -w "$TIMEOUT" "$QDRANT_HOST" "$QDRANT_PORT" 2>/dev/null; then + ok "TCP-Verbindung erfolgreich" +else + fail "TCP-Verbindung fehlgeschlagen (${QDRANT_HOST}:${QDRANT_PORT})" +fi + +# ── LocalAI (Chat) ─────────────────────────────────────────────────────────── +echo "" +echo "🤖 LocalAI Chat (${CHAT_URL})" +if [[ -n "$CHAT_URL" ]]; then + MODELS_URL="${CHAT_URL%/v1*}/v1/models" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$TIMEOUT" "$MODELS_URL" 2>/dev/null || echo "000") + if [[ "$HTTP_CODE" == "200" ]]; then + ok "HTTP ${HTTP_CODE} – erreichbar" + elif [[ "$HTTP_CODE" == "000" ]]; then + fail "Keine Verbindung (Timeout/Refused)" + else + warn "HTTP ${HTTP_CODE} – unerwartet aber erreichbar" + fi +else + warn "chat.url nicht konfiguriert" +fi + +# ── LocalAI (Embedding) ────────────────────────────────────────────────────── +echo "" +echo "🔢 LocalAI Embedding (${EMB_URL})" +if [[ -n "$EMB_URL" && "$EMB_URL" != "$CHAT_URL" ]]; then + MODELS_URL="${EMB_URL%/v1*}/v1/models" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$TIMEOUT" "$MODELS_URL" 2>/dev/null || echo "000") + if [[ "$HTTP_CODE" == "200" ]]; then + ok "HTTP ${HTTP_CODE} – erreichbar" + elif [[ "$HTTP_CODE" == "000" ]]; then + fail "Keine Verbindung (Timeout/Refused)" + else + warn "HTTP ${HTTP_CODE}" + fi +else + ok "Gleicher Endpunkt wie Chat – übersprungen" +fi + +# ── IMAP ──────────────────────────────────────────────────────────────────── +echo "" +echo "📧 IMAP (${IMAP_HOST}:${IMAP_PORT})" +if [[ -n "$IMAP_HOST" && -n "$IMAP_PORT" ]]; then + if nc -z -w "$TIMEOUT" "$IMAP_HOST" "$IMAP_PORT" 2>/dev/null; then + ok "TCP-Verbindung erfolgreich" + else + fail "TCP-Verbindung fehlgeschlagen (${IMAP_HOST}:${IMAP_PORT})" + fi +else + warn "IMAP nicht konfiguriert – übersprungen" +fi + +# ── Unit-Tests ─────────────────────────────────────────────────────────────── +echo "" +echo "🧪 Go Unit-Tests" +if go test ./... -count=1 -timeout 30s 2>&1 | grep -E "^(ok|FAIL|---)" | while read -r line; do + if echo "$line" | grep -q "^ok"; then + ok "$line" + elif echo "$line" | grep -q "^FAIL\|^--- FAIL"; then + fail "$line" + fi +done; then + : +fi +# Prüfe Exit-Code der Tests separat +if ! go test ./... -count=1 -timeout 30s > /dev/null 2>&1; then + ((ERRORS++)) +fi + +# ── Ergebnis ──────────────────────────────────────────────────────────────── +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +if [[ "$ERRORS" -eq 0 ]]; then + echo -e "${GREEN}✅ Alle Checks bestanden${NC}" + exit 0 +else + echo -e "${RED}❌ ${ERRORS} Check(s) fehlgeschlagen${NC}" + exit 1 +fi