// discord – Discord-Bot für my-brain-importer // Unterstützt /ask, /research, /memory, /task, /email, /ingest, /remember, /status, /clear und @Mention package main import ( "context" "fmt" "io" "log" "log/slog" "net/http" "os" "os/signal" "path/filepath" "strconv" "strings" "sync" "syscall" "time" "github.com/bwmarrin/discordgo" "my-brain-importer/internal/agents" "my-brain-importer/internal/agents/memory" "my-brain-importer/internal/agents/research" "my-brain-importer/internal/agents/task" "my-brain-importer/internal/agents/tool" "my-brain-importer/internal/agents/tool/email" "my-brain-importer/internal/agents/tool/rss" "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. const maxHistoryPairs = 10 var ( dg *discordgo.Session botUser *discordgo.User daemonStop = make(chan struct{}) researchAgent agents.Agent memoryAgent agents.Agent taskAgent agents.Agent toolAgent agents.Agent // Konversationsverlauf pro Channel (in-memory, wird bei Neustart zurückgesetzt). historyMu sync.Mutex historyCache = make(map[string][]agents.HistoryMessage) commands = []*discordgo.ApplicationCommand{ { Name: "ask", Description: "Stelle eine Frage an deinen AI Brain (mit Wissensdatenbank)", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "frage", Description: "Die Frage", Required: true}, }, }, { Name: "research", Description: "Wissensdatenbank abfragen (Alias für /ask)", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "frage", Description: "Die Frage", Required: true}, }, }, { Name: "deepask", Description: "Tiefe Recherche mit Multi-Step Reasoning (mehrere Suchdurchläufe)", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "frage", Description: "Die Frage", Required: true}, }, }, { Name: "asknobrain", Description: "Stelle eine Frage direkt ans LLM (ohne Wissensdatenbank)", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "frage", Description: "Die Frage", Required: true}, }, }, { Name: "ingest", Description: "Importiert Markdown-Notizen aus brain_root in die Wissensdatenbank", }, { Name: "remember", Description: "Speichert eine Nachricht in der Wissensdatenbank (Alias für /memory store)", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "text", Description: "Der Text", Required: true}, }, }, { Name: "memory", Description: "Wissen einspeichern", Options: []*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "store", Description: "Text in die Wissensdatenbank speichern", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "text", Description: "Der Text", Required: true}, }, }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "ingest", Description: "Markdown-Notizen aus brain_root importieren", }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "url", Description: "URL-Inhalt in die Wissensdatenbank importieren", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "url", Description: "Die URL", Required: true}, }, }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "profile", Description: "Fakt zum Kerngedächtnis hinzufügen", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "text", Description: "Der Fakt", Required: true}, }, }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "profile-show", Description: "Kerngedächtnis anzeigen", }, }, }, { Name: "knowledge", Description: "Wissensdatenbank verwalten", Options: []*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list", Description: "Gespeicherte Quellen auflisten", }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "delete", Description: "Quelle aus der Wissensdatenbank löschen", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "source", Description: "Quellenname (aus /knowledge list)", Required: true}, }, }, }, }, { Name: "task", Description: "Aufgabenverwaltung", Options: []*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "add", Description: "Neuen Task hinzufügen", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "text", Description: "Der Task", Required: true}, {Type: discordgo.ApplicationCommandOptionString, Name: "faellig", Description: "Fälligkeitsdatum (YYYY-MM-DD)", Required: false}, {Type: discordgo.ApplicationCommandOptionString, Name: "prioritaet", Description: "Priorität: hoch, mittel, niedrig", Required: false}, }, }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list", Description: "Alle Tasks anzeigen", }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "done", Description: "Task als erledigt markieren", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "id", Description: "Task-ID (letzte 6 Zeichen)", Required: true}, }, }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "delete", Description: "Task löschen", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "id", Description: "Task-ID (letzte 6 Zeichen)", Required: true}, }, }, }, }, { Name: "email", Description: "Emails via IMAP abrufen und analysieren", Options: []*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "summary", Description: "Zusammenfassung der letzten Emails", }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "unread", Description: "Ungelesene Emails zusammenfassen", }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remind", Description: "Termine und Deadlines aus Emails extrahieren", }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "ingest", Description: "Emails aus IMAP-Ordner in Wissensdatenbank importieren", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "ordner", Description: "IMAP-Ordner (Standard: Archiv)", Required: false}, }, }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "triage", Description: "Letzte 10 Emails klassifizieren und in Wichtig/Unwichtig verschieben", }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "triage-history", Description: "Letzte Triage-Entscheidungen anzeigen", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionInteger, Name: "anzahl", Description: "Anzahl (Standard: 10)", Required: false}, }, }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "triage-correct", Description: "Triage-Entscheidung korrigieren (wichtig↔unwichtig umkehren)", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "betreff", Description: "Email-Betreff (Teilstring reicht)", Required: true}, }, }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "triage-search", Description: "Ähnliche Triage-Entscheidungen suchen", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "query", Description: "Suchbegriff", Required: true}, }, }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "move", Description: "Emails in Archivordner verschieben", Options: []*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionString, Name: "ordner", Description: "Zielordner", Required: true, Choices: []*discordgo.ApplicationCommandOptionChoice{ {Name: "Archiv (dauerhaft)", Value: "Archiv"}, {Name: "5Jahre (~5 Jahre)", Value: "5Jahre"}, {Name: "2Jahre (~2 Jahre)", Value: "2Jahre"}, }, }, { Type: discordgo.ApplicationCommandOptionInteger, Name: "alter", Description: "Alle Emails älter als N Tage verschieben (kein Auswahlmenü)", Required: false, MinValue: floatPtr(1), MaxValue: float64(3650), }, }, }, }, }, { Name: "status", Description: "Bot-Status: Verbindungen prüfen, offene Tasks zählen", }, { Name: "clear", Description: "Gesprächsverlauf für diesen Channel zurücksetzen", }, } ) // getHistory gibt den gespeicherten Gesprächsverlauf für einen Channel zurück. func getHistory(channelID string) []agents.HistoryMessage { historyMu.Lock() defer historyMu.Unlock() msgs := historyCache[channelID] result := make([]agents.HistoryMessage, len(msgs)) copy(result, msgs) return result } // addToHistory speichert eine Nachricht im Gesprächsverlauf eines Channels. func addToHistory(channelID, role, content string) { historyMu.Lock() defer historyMu.Unlock() msgs := historyCache[channelID] msgs = append(msgs, agents.HistoryMessage{Role: role, Content: content}) // Maximal maxHistoryPairs Paare (= maxHistoryPairs*2 Nachrichten) behalten if len(msgs) > maxHistoryPairs*2 { msgs = msgs[len(msgs)-maxHistoryPairs*2:] } historyCache[channelID] = msgs } // clearHistory löscht den Gesprächsverlauf für einen Channel. func clearHistory(channelID string) { historyMu.Lock() defer historyMu.Unlock() delete(historyCache, channelID) } func main() { config.LoadConfig() patchEmailMoveChoices() // Dynamische Archivordner aus Config einsetzen token := config.Cfg.Discord.Token if token == "" || token == "dein-discord-bot-token" { log.Fatal("❌ Kein Discord-Token in config.yml konfiguriert (discord.token)") } logLevel := slog.LevelInfo if os.Getenv("DEBUG") == "1" { logLevel = slog.LevelDebug fmt.Println("🔍 Debug-Logging aktiv (LLM-Prompts + Antworten werden ausgegeben)") } slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ Level: logLevel, }))) researchAgent = research.New() memoryAgent = memory.New() taskAgent = task.New() toolAgent = tool.New() var err error dg, err = discordgo.New("Bot " + token) if err != nil { log.Fatalf("❌ Discord-Session konnte nicht erstellt werden: %v", err) } dg.AddHandler(onReady) dg.AddHandler(onInteraction) dg.AddHandler(onMessage) dg.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentMessageContent if err = dg.Open(); err != nil { log.Fatalf("❌ Verbindung zu Discord fehlgeschlagen: %v", err) } defer dg.Close() registerCommands() runStartupDiag() sendWelcomeMessage() go startDaemon() fmt.Println("✅ Bot läuft. Drücke CTRL+C zum Beenden.") stop := make(chan os.Signal, 1) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) <-stop fmt.Println("\n👋 Bot wird beendet...") close(daemonStop) } func onReady(s *discordgo.Session, r *discordgo.Ready) { botUser = r.User fmt.Printf("✅ Eingeloggt als %s#%s\n", r.User.Username, r.User.Discriminator) } func registerCommands() { guildID := config.Cfg.Discord.GuildID registered, err := dg.ApplicationCommandBulkOverwrite(dg.State.User.ID, guildID, commands) if err != nil { log.Fatalf("❌ Slash-Commands konnten nicht registriert werden: %v", err) } scope := "global" if guildID != "" { scope = "guild " + guildID } for _, cmd := range registered { fmt.Printf("📝 Slash-Command /%s registriert (%s)\n", cmd.Name, scope) } } // isAllowed prüft ob ein Discord-User den Bot nutzen darf. // Wenn keine allowed_users konfiguriert sind, ist jeder erlaubt. func isAllowed(userID string) bool { if len(config.Cfg.Discord.AllowedUsers) == 0 { return true } for _, id := range config.Cfg.Discord.AllowedUsers { if id == userID { return true } } return false } // getUserID extrahiert die User-ID aus einer Interaktion. func getUserID(i *discordgo.InteractionCreate) string { if i.Member != nil { return i.Member.User.ID } if i.User != nil { return i.User.ID } return "" } func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { // Berechtigungsprüfung if !isAllowed(getUserID(i)) { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "❌ Du bist nicht berechtigt, diesen Bot zu nutzen.", Flags: discordgo.MessageFlagsEphemeral, }, }) return } switch i.Type { case discordgo.InteractionMessageComponent: data := i.MessageComponentData() slog.Info("Komponente", "customID", data.CustomID, "user", getAuthor(i)) if strings.HasPrefix(data.CustomID, "email_move:") { handleEmailMoveSelect(s, i) } return case discordgo.InteractionApplicationCommand: // handled below default: return } data := i.ApplicationCommandData() slog.Info("Slash-Command", "command", data.Name, "user", getAuthor(i), "channel", i.ChannelID) switch data.Name { case "ask", "research": question := data.Options[0].StringValue() channelID := i.ChannelID handleAgentResponse(s, i, func() agents.Response { resp := researchAgent.Handle(agents.Request{ Action: agents.ActionQuery, Args: []string{question}, History: getHistory(channelID), }) if resp.RawAnswer != "" { addToHistory(channelID, "user", question) addToHistory(channelID, "assistant", resp.RawAnswer) } return resp }) case "deepask": question := data.Options[0].StringValue() channelID := i.ChannelID handleAgentResponse(s, i, func() agents.Response { resp := researchAgent.Handle(agents.Request{ Action: agents.ActionDeepAsk, Args: []string{question}, History: getHistory(channelID), }) if resp.RawAnswer != "" { addToHistory(channelID, "user", question) addToHistory(channelID, "assistant", resp.RawAnswer) } return resp }) case "asknobrain": handleAskNoBrain(s, i, data.Options[0].StringValue()) case "ingest": handleAgentResponse(s, i, func() agents.Response { return memoryAgent.Handle(agents.Request{Action: agents.ActionIngest}) }) case "remember": text := data.Options[0].StringValue() author := getAuthor(i) source := fmt.Sprintf("discord/#%s", i.ChannelID) handleAgentResponse(s, i, func() agents.Response { return memoryAgent.Handle(agents.Request{Action: agents.ActionStore, Args: []string{text}, Author: author, Source: source}) }) case "memory": handleMemoryCommand(s, i) case "task": handleTaskCommand(s, i) case "email": handleEmailCommand(s, i) case "knowledge": handleKnowledgeCommand(s, i) case "status": handleStatus(s, i) case "clear": clearHistory(i.ChannelID) reply := "🗑️ Gesprächsverlauf für diesen Channel gelöscht." s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: reply}, }) } } func handleMemoryCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { sub := i.ApplicationCommandData().Options[0] switch sub.Name { case "store": text := sub.Options[0].StringValue() author := getAuthor(i) source := fmt.Sprintf("discord/#%s", i.ChannelID) handleAgentResponse(s, i, func() agents.Response { return memoryAgent.Handle(agents.Request{Action: agents.ActionStore, Args: []string{text}, Author: author, Source: source}) }) case "ingest": handleAgentResponse(s, i, func() agents.Response { return memoryAgent.Handle(agents.Request{Action: agents.ActionIngest}) }) case "url": rawURL := sub.Options[0].StringValue() handleAgentResponse(s, i, func() agents.Response { n, err := brain.IngestURL(rawURL) if err != nil { return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler beim URL-Import: %v", err)} } return agents.Response{Text: fmt.Sprintf("✅ **%d Chunks** aus URL importiert:\n`%s`", n, rawURL)} }) case "profile": text := sub.Options[0].StringValue() handleAgentResponse(s, i, func() agents.Response { if err := brain.AppendCoreMemory(text); err != nil { return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)} } return agents.Response{Text: fmt.Sprintf("🧠 Kerngedächtnis aktualisiert: _%s_", text)} }) case "profile-show": handleAgentResponse(s, i, func() agents.Response { return agents.Response{Text: brain.ShowCoreMemory()} }) } } func handleKnowledgeCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { sub := i.ApplicationCommandData().Options[0] switch sub.Name { case "list": handleAgentResponse(s, i, func() agents.Response { sources, err := brain.ListSources(0) if err != nil { return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)} } if len(sources) == 0 { return agents.Response{Text: "📭 Keine Einträge in der Wissensdatenbank."} } var sb strings.Builder fmt.Fprintf(&sb, "📚 **Quellen in der Wissensdatenbank** (%d):\n```\n", len(sources)) for _, s := range sources { fmt.Fprintf(&sb, "%s\n", s) } sb.WriteString("```") return agents.Response{Text: sb.String()} }) case "delete": source := sub.Options[0].StringValue() handleAgentResponse(s, i, func() agents.Response { if err := brain.DeleteBySource(source); err != nil { return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler beim Löschen: %v", err)} } return agents.Response{Text: fmt.Sprintf("🗑️ Quelle gelöscht: `%s`", source)} }) } } func handleTaskCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { sub := i.ApplicationCommandData().Options[0] req := agents.Request{Action: sub.Name} if sub.Name == "add" && len(sub.Options) > 0 { // Baue args mit Text + optionalen Flags für due/priority args := []string{sub.Options[0].StringValue()} for _, opt := range sub.Options[1:] { switch opt.Name { case "faellig": args = append(args, "--due", opt.StringValue()) case "prioritaet": args = append(args, "--priority", opt.StringValue()) } } req.Args = args } else if len(sub.Options) > 0 { req.Args = []string{sub.Options[0].StringValue()} } handleAgentResponse(s, i, func() agents.Response { return taskAgent.Handle(req) }) } func handleEmailCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { sub := i.ApplicationCommandData().Options[0] // Email-Move zeigt ein Select-Menü statt sofort alle zu verschieben if sub.Name == agents.ActionEmailMove { handleEmailMoveInit(s, i, sub) return } args := []string{sub.Name} if len(sub.Options) > 0 { switch sub.Name { case agents.ActionEmailIngest: args = append(args, sub.Options[0].StringValue()) case agents.ActionEmailTriageHistory: args = append(args, fmt.Sprintf("%d", sub.Options[0].IntValue())) case agents.ActionEmailTriageCorrect: args = append(args, sub.Options[0].StringValue()) case agents.ActionEmailTriageSearch: args = append(args, sub.Options[0].StringValue()) } } handleAgentResponse(s, i, func() agents.Response { return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: args}) }) } // handleEmailMoveInit zeigt ein Discord Select-Menü mit Emails zur Auswahl oder verschiebt direkt per Alter. func handleEmailMoveInit(s *discordgo.Session, i *discordgo.InteractionCreate, sub *discordgo.ApplicationCommandInteractionDataOption) { destName, alterDays := "", 0 for _, opt := range sub.Options { switch opt.Name { case "ordner": destName = opt.StringValue() case "alter": alterDays = int(opt.IntValue()) } } imapFolder, ok := tool.ResolveArchiveFolder(destName) if !ok { msg := fmt.Sprintf("❌ Unbekannter Ordner `%s`.", destName) s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: msg}, }) return } s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, }) // Bulk-Verschieben aller Emails älter als N Tage — kein Select-Menü nötig if alterDays > 0 { n, err := email.MoveOldEmailsAllAccounts(imapFolder, alterDays) var replyMsg string if err != nil { replyMsg = fmt.Sprintf("❌ Fehler beim Verschieben: %v", err) } else if n == 0 { replyMsg = fmt.Sprintf("📭 Keine Emails älter als %d Tage gefunden.", alterDays) } else { replyMsg = fmt.Sprintf("✅ %d Email(s) älter als %d Tage nach `%s` verschoben.", n, alterDays, imapFolder) } s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &replyMsg}) return } allAccMsgs, err := email.FetchRecentForSelectAllAccounts(25) if err != nil { msg := fmt.Sprintf("❌ Emails konnten nicht abgerufen werden: %v", err) s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg}) return } totalEmails := 0 for _, a := range allAccMsgs { totalEmails += len(a.Messages) } if totalEmails == 0 { msg := "📭 Keine Emails im Posteingang." s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg}) return } const maxOptions = 25 var components []discordgo.MessageComponent var headerParts []string for _, accData := range allAccMsgs { if len(accData.Messages) == 0 { continue } msgs := accData.Messages truncated := false if len(msgs) > maxOptions { msgs = msgs[:maxOptions] truncated = true } options := make([]discordgo.SelectMenuOption, 0, len(msgs)) for _, m := range msgs { label := m.Subject if label == "" { label = "(kein Betreff)" } if len([]rune(label)) > 97 { label = string([]rune(label)[:97]) + "..." } desc := fmt.Sprintf("%s | %s", m.Date, m.From) if len([]rune(desc)) > 97 { desc = string([]rune(desc)[:97]) + "..." } options = append(options, discordgo.SelectMenuOption{ Label: label, Value: fmt.Sprintf("%d", m.SeqNum), Description: desc, }) } customID := fmt.Sprintf("email_move:%s:%d", imapFolder, accData.AccIndex) minVals := 1 maxVals := len(options) accLabel := accData.Account.Name if accLabel == "" { accLabel = accData.Account.User } placeholder := "Email(s) auswählen..." if len(allAccMsgs) > 1 { placeholder = fmt.Sprintf("Email(s) auswählen – %s...", accLabel) } components = append(components, discordgo.ActionsRow{ Components: []discordgo.MessageComponent{ discordgo.SelectMenu{ CustomID: customID, Placeholder: placeholder, MinValues: &minVals, MaxValues: maxVals, Options: options, }, }, }) note := "" if truncated { note = fmt.Sprintf(" *(erste %d von %d)*", maxOptions, len(accData.Messages)) } if len(allAccMsgs) > 1 { headerParts = append(headerParts, fmt.Sprintf("**%s**: %d Email(s)%s", accLabel, len(accData.Messages), note)) } else { headerParts = append(headerParts, fmt.Sprintf("%d Email(s)%s", len(accData.Messages), note)) } } msg := fmt.Sprintf("📧 **Emails nach `%s` verschieben**\n%s\nWähle aus welche Emails du verschieben möchtest:", imapFolder, strings.Join(headerParts, "\n")) s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ Content: &msg, Components: &components, }) } // handleEmailMoveSelect verarbeitet die Discord Select-Menü Auswahl und verschiebt die gewählten Emails. func handleEmailMoveSelect(s *discordgo.Session, i *discordgo.InteractionCreate) { data := i.MessageComponentData() // CustomID-Format: email_move:: parts := strings.SplitN(data.CustomID, ":", 3) if len(parts) != 3 { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "❌ Interner Fehler: ungültige CustomID."}, }) return } imapFolder := parts[1] accIndex, err := strconv.Atoi(parts[2]) if err != nil { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "❌ Interner Fehler: ungültiger Account-Index."}, }) return } seqNums := make([]uint32, 0, len(data.Values)) for _, v := range data.Values { n, err := strconv.ParseUint(v, 10, 32) if err != nil { continue } seqNums = append(seqNums, uint32(n)) } if len(seqNums) == 0 { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "❌ Keine gültigen Emails ausgewählt."}, }) return } // DeferredMessageUpdate: zeigt Ladezustand, editiert dann die ursprüngliche Nachricht s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseDeferredMessageUpdate, }) n, err := email.MoveSpecificUnread(accIndex, seqNums, imapFolder) var replyMsg string if err != nil { replyMsg = fmt.Sprintf("❌ Fehler beim Verschieben: %v", err) } else { replyMsg = fmt.Sprintf("✅ **%d Email(s)** nach `%s` verschoben.", n, imapFolder) } // Menü entfernen nach Auswahl emptyComponents := []discordgo.MessageComponent{} s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ Content: &replyMsg, Components: &emptyComponents, }) } // handleStatus prüft alle externen Dienste und zeigt offene Task-Anzahl. func handleStatus(s *discordgo.Session, i *discordgo.InteractionCreate) { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, }) results, allOK := diag.RunAll() // Task-Zähler store := task.NewStore() open, err := store.OpenTasks() taskInfo := "" if err != nil { taskInfo = "❌ Tasks: Fehler" } else { taskInfo = fmt.Sprintf("📋 Tasks: %d offen", len(open)) } msg := strings.ReplaceAll(diag.Format(results, allOK), "Start-Diagnose", "Status") + "\n" + taskInfo s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg}) } func handleAskNoBrain(s *discordgo.Session, i *discordgo.InteractionCreate, question string) { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, }) answer, err := brain.ChatDirect(question) var reply string if err != nil { reply = fmt.Sprintf("❌ Fehler: %v", err) } else { reply = fmt.Sprintf("💬 **Antwort:** _%s_\n\n%s", question, answer) } s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &reply}) } // handleAgentResponse sendet erst Defer an Discord, führt dann fn() aus und editiert die Antwort. // So wird das 3-Sekunden-Timeout von Discord nie überschritten. func handleAgentResponse(s *discordgo.Session, i *discordgo.InteractionCreate, fn func() agents.Response) { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, }) resp := fn() if resp.Error != nil { slog.Error("Agent-Fehler", "fehler", resp.Error) } msg := resp.Text if msg == "" && resp.Error != nil { msg = fmt.Sprintf("❌ Fehler: %v", resp.Error) } s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg}) } func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) { if m.Author.Bot || botUser == nil { return } mentioned := false for _, u := range m.Mentions { if u.ID == botUser.ID { mentioned = true break } } if !mentioned { return } // Berechtigungsprüfung if !isAllowed(m.Author.ID) { s.ChannelMessageSendReply(m.ChannelID, "❌ Du bist nicht berechtigt, diesen Bot zu nutzen.", m.Reference()) return } // Datei-Anhänge prüfen (PDF) for _, att := range m.Attachments { ext := strings.ToLower(filepath.Ext(att.Filename)) if ext == ".pdf" { s.ChannelTyping(m.ChannelID) reply := handlePDFAttachment(att) s.ChannelMessageSendReply(m.ChannelID, reply, m.Reference()) return } } question := strings.TrimSpace( strings.ReplaceAll(m.Content, "<@"+botUser.ID+">", ""), ) if question == "" { s.ChannelMessageSend(m.ChannelID, "Stell mir eine Frage! Beispiel: @Brain Was sind meine TODOs?") return } slog.Info("Mention", "user", m.Author.Username, "channel", m.ChannelID, "nachricht", question) s.ChannelTyping(m.ChannelID) resp := routeMessage(question, getAuthorFromMessage(m), m.ChannelID) if resp.Error != nil { slog.Error("Mention-Fehler", "fehler", resp.Error) } s.ChannelMessageSendReply(m.ChannelID, resp.Text, m.Reference()) } // handlePDFAttachment lädt eine PDF-Datei herunter, importiert sie und gibt die Antwort zurück. func handlePDFAttachment(att *discordgo.MessageAttachment) string { slog.Info("PDF-Attachment erkannt", "datei", att.Filename, "url", att.URL) // PDF herunterladen resp, err := http.Get(att.URL) //nolint:noctx if err != nil { return fmt.Sprintf("❌ PDF konnte nicht geladen werden: %v", err) } defer resp.Body.Close() // In temporäre Datei schreiben tmpFile, err := os.CreateTemp("", "brain-pdf-*.pdf") if err != nil { return fmt.Sprintf("❌ Temporäre Datei konnte nicht erstellt werden: %v", err) } defer os.Remove(tmpFile.Name()) defer tmpFile.Close() if _, err := io.Copy(tmpFile, resp.Body); err != nil { return fmt.Sprintf("❌ PDF konnte nicht gespeichert werden: %v", err) } tmpFile.Close() n, err := brain.IngestPDF(tmpFile.Name(), att.Filename) if err != nil { return fmt.Sprintf("❌ PDF-Import fehlgeschlagen: %v", err) } return fmt.Sprintf("✅ **%d Chunks** aus PDF importiert: `%s`", n, att.Filename) } // SendMessage schickt eine Nachricht proaktiv in einen Channel (für Daemon-Nutzung). func SendMessage(channelID, text string) error { if dg == nil { return fmt.Errorf("Discord-Session nicht initialisiert") } _, err := dg.ChannelMessageSend(channelID, text) return err } // routeMessage leitet eine @mention-Nachricht an den passenden Agenten weiter. // Unterstützte Präfixe: "email ...", "task ...", "remember ...", sonst Research. func routeMessage(text, author, channelID string) agents.Response { words := strings.Fields(text) if len(words) == 0 { return agents.Response{Text: "Stell mir eine Frage oder nutze: `email summary`, `email unread`, `email remind`, `task list`, `task add [--due YYYY-MM-DD] [--priority hoch]`, `task done `, `remember `"} } cmd := strings.ToLower(words[0]) args := words[1:] switch cmd { case "clear": clearHistory(channelID) return agents.Response{Text: "🗑️ Gesprächsverlauf gelöscht."} case "email": sub := "summary" emailArgs := []string{} if len(args) > 0 { sub = strings.ToLower(args[0]) emailArgs = args[1:] // Restargumente (z.B. Ordnername für "move") } return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: append([]string{sub}, emailArgs...)}) case "task": action := "list" if len(args) > 0 { action = strings.ToLower(args[0]) args = args[1:] } return taskAgent.Handle(agents.Request{Action: action, Args: args}) case "remember": return memoryAgent.Handle(agents.Request{ Action: agents.ActionStore, Args: args, Author: author, Source: "discord/mention", }) case "deepask": resp := researchAgent.Handle(agents.Request{ Action: agents.ActionDeepAsk, Args: args, History: getHistory(channelID), }) if resp.RawAnswer != "" { addToHistory(channelID, "user", strings.Join(args, " ")) addToHistory(channelID, "assistant", resp.RawAnswer) } return resp default: resp := researchAgent.Handle(agents.Request{ Action: agents.ActionQuery, Args: words, History: getHistory(channelID), }) if resp.RawAnswer != "" { addToHistory(channelID, "user", text) addToHistory(channelID, "assistant", resp.RawAnswer) } return resp } } func getAuthorFromMessage(m *discordgo.MessageCreate) string { if m.Author != nil { return m.Author.Username } 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 if channelID == "" { return } msg := `🤖 **Brain-Bot ist online!** **Slash-Commands:** ` + "```" + ` /ask – Wissensdatenbank abfragen /research – Alias für /ask /asknobrain – Direkt ans LLM (kein RAG) /memory store – Text speichern /memory ingest – Markdown-Notizen neu einlesen /memory url – URL-Inhalt importieren /memory profile – Fakt zum Kerngedächtnis hinzufügen /memory profile-show – Kerngedächtnis anzeigen /knowledge list – Gespeicherte Quellen auflisten /knowledge delete – Quelle löschen /task add [--due YYYY-MM-DD] [--priority hoch|mittel|niedrig] /task list / done / delete /email summary / unread / remind / ingest / move / triage /status – Bot-Status /clear – Gesprächsverlauf zurücksetzen ` + "```" + ` **@Mention:** PDF-Anhang schicken → automatisch importiert ⚙️ Daemon: IMAP IDLE + Morgen-Briefing + RSS-Feeds` if _, err := dg.ChannelMessageSend(channelID, msg); err != nil { log.Printf("⚠️ Willkommensnachricht konnte nicht gesendet werden: %v", err) } } func getAuthor(i *discordgo.InteractionCreate) string { if i.Member != nil { return i.Member.User.Username } if i.User != nil { return i.User.Username } return "unknown" } // patchEmailMoveChoices aktualisiert die /email move Choices in der commands-Liste nach dem Laden der Config. // Wird in main() nach config.LoadConfig() aufgerufen. func patchEmailMoveChoices() { choices := buildMoveChoices() for _, cmd := range commands { if cmd.Name != "email" { continue } for _, opt := range cmd.Options { if opt.Name != "move" { continue } for _, subOpt := range opt.Options { if subOpt.Name == "ordner" { subOpt.Choices = choices return } } } } } // buildMoveChoices erstellt Discord-Choices für /email move aus der konfigurierten archive_folders. // Fallback: statische Liste (2Jahre/5Jahre/Archiv) wenn keine archive_folders konfiguriert. func buildMoveChoices() []*discordgo.ApplicationCommandOptionChoice { seen := map[string]bool{} var choices []*discordgo.ApplicationCommandOptionChoice for _, acc := range config.AllEmailAccounts() { for _, af := range acc.ArchiveFolders { key := strings.ToLower(af.Name) if seen[key] { continue } seen[key] = true label := af.Name if af.RetentionDays > 0 { label = fmt.Sprintf("%s (%d Tage)", af.Name, af.RetentionDays) } choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ Name: label, Value: af.Name, }) if len(choices) == 25 { // Discord-Limit slog.Warn("Mehr als 25 Archivordner konfiguriert, Liste wird gekürzt") return choices } } } if len(choices) == 0 { // Legacy-Fallback choices = []*discordgo.ApplicationCommandOptionChoice{ {Name: "Archiv (dauerhaft)", Value: "Archiv"}, {Name: "5Jahre (~5 Jahre)", Value: "5Jahre"}, {Name: "2Jahre (~2 Jahre)", Value: "2Jahre"}, } } return choices } // floatPtr gibt einen Pointer auf einen float64-Wert zurück (für MinValue in Discord-Options). func floatPtr(v float64) *float64 { return &v } // startDaemon läuft als Goroutine im Bot-Prozess und sendet proaktive Benachrichtigungen. // Für Email-Benachrichtigungen wird IMAP IDLE genutzt (Echtzeit). // Alternativ, wenn kein Email-Account konfiguriert ist, läuft nur der Morgen-Briefing-Timer. func startDaemon() { channelID := config.Cfg.Daemon.ChannelID if channelID == "" { log.Println("⚙️ Daemon inaktiv: daemon.channel_id nicht konfiguriert") return } reminderHour := config.Cfg.Daemon.TaskReminderHour if reminderHour == 0 { reminderHour = 8 } // IMAP IDLE für jeden konfigurierten Account starten ctx, cancel := context.WithCancel(context.Background()) accounts := config.AllEmailAccounts() if len(accounts) > 0 { log.Printf("⚙️ Daemon aktiv: IMAP IDLE für %d Account(s), Task-Reminder täglich um %02d:00", len(accounts), reminderHour) for _, acc := range accounts { watcher := email.NewIdleWatcher(acc, func(accountName, summary string) { slog.Info("IDLE: Neue Emails, sende Zusammenfassung", "account", accountName) dg.ChannelMessageSend(channelID, fmt.Sprintf("📧 **Neue Emails (%s):**\n\n%s", accountName, summary)) }) go watcher.Run(ctx) } } else { log.Printf("⚙️ Daemon aktiv (kein Email-Account): Task-Reminder täglich um %02d:00", reminderHour) } // RSS-Watcher starten (wenn Feeds konfiguriert) if len(config.Cfg.RSSFeeds) > 0 { log.Printf("⚙️ RSS-Watcher aktiv: %d Feed(s)", len(config.Cfg.RSSFeeds)) rssWatcher := &rss.Watcher{ OnResults: func(summary string) { slog.Info("RSS: Feeds importiert") dg.ChannelMessageSend(channelID, "🗞️ **RSS-Feeds importiert:**\n"+summary) }, } go rssWatcher.Run(ctx) } cleanupHour := config.Cfg.Daemon.CleanupHour if cleanupHour == 0 { cleanupHour = 2 } ingestHour := config.Cfg.Daemon.IngestHour if ingestHour == 0 { ingestHour = 23 } briefingTimer := scheduleDaily(reminderHour, 0) defer briefingTimer.Stop() cleanupTimer := scheduleDaily(cleanupHour, 0) defer cleanupTimer.Stop() ingestTimer := scheduleDaily(ingestHour, 0) defer ingestTimer.Stop() for { select { case <-daemonStop: slog.Info("Daemon gestoppt") cancel() return case <-briefingTimer.C: slog.Info("Daemon: Morgen-Briefing gestartet") dailyBriefing(channelID) briefingTimer.Stop() briefingTimer = scheduleDaily(reminderHour, 0) case <-cleanupTimer.C: slog.Info("Daemon: Archiv-Aufräumen gestartet") go func() { summary, err := email.CleanupArchiveFolders() if err != nil { slog.Error("Daemon: Archiv-Aufräumen Fehler", "fehler", err) } else { slog.Info("Daemon: Archiv-Aufräumen abgeschlossen", "ergebnis", summary) } }() cleanupTimer.Stop() cleanupTimer = scheduleDaily(cleanupHour, 0) case <-ingestTimer.C: slog.Info("Daemon: Nächtlicher Email-Ingest gestartet") go nightlyIngest(channelID) ingestTimer.Stop() ingestTimer = scheduleDaily(ingestHour, 0) } } } // nightlyIngest importiert Emails aus allen Archiv-Ordnern in die Wissensdatenbank. func nightlyIngest(channelID string) { accounts := config.AllEmailAccounts() total := 0 var errs []string for _, acc := range accounts { for _, af := range acc.ArchiveFolders { n, err := brain.IngestEmailFolder(acc, af.IMAPFolder, 0) if err != nil { slog.Error("Nacht-Ingest Fehler", "account", acc.Name, "folder", af.IMAPFolder, "fehler", err) errs = append(errs, fmt.Sprintf("%s/%s: %v", acc.Name, af.IMAPFolder, err)) continue } slog.Info("Nacht-Ingest abgeschlossen", "account", acc.Name, "folder", af.IMAPFolder, "ingested", n) total += n } } // Triage-Lernen: Entscheidungen aus Ordnerstruktur ableiten wichtig, unwichtig, learnErr := email.LearnFromFoldersAllAccounts() if learnErr != nil { slog.Error("Triage-Lernen Fehler", "fehler", learnErr) } else if wichtig+unwichtig > 0 { slog.Info("Triage-Lernen abgeschlossen", "wichtig", wichtig, "unwichtig", unwichtig) } if channelID == "" { return } msg := "" if len(errs) > 0 { msg = fmt.Sprintf("⚠️ Nacht-Ingest: %d Emails importiert, %d Fehler:\n%s", total, len(errs), strings.Join(errs, "\n")) } else if total > 0 { msg = fmt.Sprintf("🗄️ Nacht-Ingest: %d Emails aus Archiv-Ordnern importiert.", total) } if wichtig+unwichtig > 0 { learnMsg := fmt.Sprintf("🧠 Triage-Lernen: %d wichtig, %d unwichtig gelernt.", wichtig, unwichtig) if msg != "" { msg += "\n" + learnMsg } else { msg = learnMsg } } if msg != "" { dg.ChannelMessageSend(channelID, msg) } } // scheduleDaily gibt einen Timer zurück, der zum nächsten Auftreten von hour:minute feuert. func scheduleDaily(hour, minute int) *time.Timer { now := time.Now() next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location()) if !next.After(now) { next = next.Add(24 * time.Hour) } d := next.Sub(now) log.Printf("⏰ Nächstes Morgen-Briefing in %v (um %02d:%02d)", d.Round(time.Minute), hour, minute) return time.NewTimer(d) } // dailyBriefing sendet eine kombinierte Morgen-Zusammenfassung: offene Tasks + ungelesene Emails. func dailyBriefing(channelID string) { today := time.Now().Truncate(24 * time.Hour) tomorrow := today.Add(24 * time.Hour) // Tasks var taskSection strings.Builder store := task.NewStore() open, err := store.OpenTasks() if err != nil { slog.Error("Daemon Briefing Task-Fehler", "fehler", err) open = nil // explizit nil, len(open)==0 → kein Task-Abschnitt } else if len(open) > 0 { fmt.Fprintf(&taskSection, "📋 **Offene Tasks (%d):**\n", len(open)) for _, t := range open { shortID := t.ID if len(shortID) > 6 { shortID = shortID[len(shortID)-6:] } urgency := "" if t.DueDate != nil { due := t.DueDate.Truncate(24 * time.Hour) switch { case due.Before(today): urgency = " ⚠️ **ÜBERFÄLLIG**" case due.Equal(today): urgency = " 🔴 *heute fällig*" case due.Equal(tomorrow): urgency = " 🟡 *morgen fällig*" } } fmt.Fprintf(&taskSection, "⬜ `%s` – %s%s\n", shortID, t.Text, urgency) } } // Emails var emailSection string notify, err := email.SummarizeUnread() if err != nil { slog.Error("Daemon Briefing Email-Fehler", "fehler", err) } else if notify != "📭 Keine ungelesenen Emails." { emailSection = "\n\n📧 **Ungelesene Emails:**\n" + notify } var msg strings.Builder fmt.Fprintf(&msg, "☀️ **Morgen-Briefing** – %s\n\n", time.Now().Format("02.01.2006")) if taskSection.Len() > 0 { msg.WriteString(taskSection.String()) } msg.WriteString(emailSection) if taskSection.Len() == 0 && emailSection == "" { msg.WriteString("✨ Keine offenen Tasks und keine ungelesenen Emails. Guten Morgen!") } dg.ChannelMessageSend(channelID, msg.String()) slog.Info("Daemon: Morgen-Briefing gesendet", "offene_tasks", len(open)) }