// discord – Discord-Bot für my-brain-importer // Unterstützt /ask, /research, /memory, /task, /email, /ingest, /remember und @Mention package main import ( "fmt" "log" "log/slog" "os" "os/signal" "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/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: "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", }, }, }, { 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", }, }, }, } ) // 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 } func main() { config.LoadConfig() 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) } } func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { if i.Type != discordgo.InteractionApplicationCommand { 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 "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) } } 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}) }) } } 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] handleAgentResponse(s, i, func() agents.Response { return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: []string{sub.Name}}) }) } 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 } 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()) } // 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 "email": sub := "summary" if len(args) > 0 { sub = strings.ToLower(args[0]) } return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: []string{sub}}) 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", }) 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 in Wissensdatenbank speichern /memory ingest – Markdown-Notizen neu einlesen /task add [--due YYYY-MM-DD] [--priority hoch|mittel|niedrig] /task list – Alle Tasks anzeigen /task done – Task erledigen /task delete – Task löschen /email summary – Letzte Emails zusammenfassen /email unread – Ungelesene Emails zusammenfassen /email remind – Termine aus Emails extrahieren ` + "```" + ` **@Mention:** ` + "```" + ` @Brain – Wissensdatenbank (mit Chat-Gedächtnis) @Brain task add [--due ...] [--priority ...] @Brain task list / done / delete @Brain email summary / unread / remind @Brain remember ` + "```" + ` ⚙️ Daemon aktiv: Email-Check + tägliches Morgen-Briefing` 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" } // startDaemon läuft als Goroutine im Bot-Prozess und sendet proaktive Benachrichtigungen. func startDaemon() { channelID := config.Cfg.Daemon.ChannelID if channelID == "" { log.Println("⚙️ Daemon inaktiv: daemon.channel_id nicht konfiguriert") return } emailInterval := time.Duration(config.Cfg.Daemon.EmailIntervalMin) * time.Minute if emailInterval == 0 { emailInterval = 30 * time.Minute } reminderHour := config.Cfg.Daemon.TaskReminderHour if reminderHour == 0 { reminderHour = 8 } log.Printf("⚙️ Daemon aktiv: Email-Check alle %v, Task-Reminder täglich um %02d:00", emailInterval, reminderHour) emailTicker := time.NewTicker(emailInterval) defer emailTicker.Stop() briefingTimer := scheduleDaily(reminderHour, 0) defer briefingTimer.Stop() for { select { case <-daemonStop: slog.Info("Daemon gestoppt") return case <-emailTicker.C: slog.Info("Daemon: Email-Check gestartet") notify, err := email.SummarizeUnread() if err != nil { slog.Error("Daemon Email-Fehler", "fehler", err) dg.ChannelMessageSend(channelID, fmt.Sprintf("⚠️ Email-Check fehlgeschlagen: %v", err)) continue } if notify != "📭 Keine ungelesenen Emails." { slog.Info("Daemon: Neue Emails gefunden, sende Zusammenfassung") dg.ChannelMessageSend(channelID, "📧 **Neue Emails:**\n\n"+notify) } else { slog.Info("Daemon: Keine neuen Emails") } case <-briefingTimer.C: slog.Info("Daemon: Morgen-Briefing gestartet") dailyBriefing(channelID) briefingTimer.Stop() briefingTimer = scheduleDaily(reminderHour, 0) } } } // 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) } 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)) }