llm mail integration

This commit is contained in:
Christoph K.
2026-03-19 21:46:12 +01:00
parent fdc7a8588d
commit 0e7aa3e7f2
19 changed files with 1707 additions and 306 deletions

View File

@@ -1,252 +1,509 @@
// discord Discord-Bot für my-brain-importer
// Unterstützt /ask, /ingest und @Mention
package main
import (
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"github.com/bwmarrin/discordgo"
"my-brain-importer/internal/brain"
"my-brain-importer/internal/config"
)
var (
dg *discordgo.Session
botUser *discordgo.User
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, die du stellen möchtest",
Required: true,
},
},
},
{
Name: "asknobrain",
Description: "Stelle eine Frage direkt ans LLM (ohne Wissensdatenbank)",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "frage",
Description: "Die Frage, die du stellen möchtest",
Required: true,
},
},
},
{
Name: "ingest",
Description: "Importiert Markdown-Notizen aus brain_root in die Wissensdatenbank",
},
{
Name: "remember",
Description: "Speichert eine Nachricht in der Wissensdatenbank",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "text",
Description: "Der Text, der gespeichert werden soll",
Required: true,
},
},
},
}
)
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)")
}
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()
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...")
}
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
}
switch i.ApplicationCommandData().Name {
case "ask":
handleAsk(s, i, i.ApplicationCommandData().Options[0].StringValue(), true)
case "asknobrain":
handleAsk(s, i, i.ApplicationCommandData().Options[0].StringValue(), false)
case "ingest":
handleIngest(s, i)
case "remember":
handleRemember(s, i)
}
}
func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.Author.Bot {
return
}
if botUser == nil {
return
}
mentioned := false
for _, u := range m.Mentions {
if u.ID == botUser.ID {
mentioned = true
break
}
}
if !mentioned {
return
}
// Mention aus der Nachricht entfernen
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
}
s.ChannelTyping(m.ChannelID)
reply := queryAndFormat(question)
s.ChannelMessageSendReply(m.ChannelID, reply, m.Reference())
}
func handleAsk(s *discordgo.Session, i *discordgo.InteractionCreate, question string, useBrain bool) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
})
var reply string
if useBrain {
reply = queryAndFormat(question)
} else {
answer, err := brain.ChatDirect(question)
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,
})
}
func handleIngest(s *discordgo.Session, i *discordgo.InteractionCreate) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
})
fmt.Println("📥 Ingest gestartet via Discord...")
brain.RunIngest(config.Cfg.BrainRoot)
msg := fmt.Sprintf("✅ Ingest abgeschlossen! Quelle: `%s`", config.Cfg.BrainRoot)
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: &msg,
})
}
func handleRemember(s *discordgo.Session, i *discordgo.InteractionCreate) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
})
text := i.ApplicationCommandData().Options[0].StringValue()
var author string
if i.Member != nil {
author = i.Member.User.Username
} else if i.User != nil {
author = i.User.Username
} else {
author = "unknown"
}
source := fmt.Sprintf("discord/#%s", i.ChannelID)
err := brain.IngestChatMessage(text, author, source)
var msg string
if err != nil {
msg = fmt.Sprintf("❌ Fehler beim Speichern: %v", err)
} else {
msg = fmt.Sprintf("✅ Gespeichert: _%s_", text)
}
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg})
}
func queryAndFormat(question string) string {
answer, chunks, err := brain.AskQuery(question)
if err != nil {
return fmt.Sprintf("❌ Fehler: %v", err)
}
if len(chunks) == 0 {
return "❌ Keine relevanten Informationen in der Datenbank gefunden.\nFüge mehr Daten mit `/ingest` hinzu."
}
var sb strings.Builder
fmt.Fprintf(&sb, "💬 **Antwort auf:** _%s_\n\n", question)
sb.WriteString(answer)
sb.WriteString("\n\n📚 **Quellen:**\n")
for _, chunk := range chunks {
fmt.Fprintf(&sb, "• %.1f%% %s\n", chunk.Score*100, chunk.Source)
}
return sb.String()
}
// 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"
"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"
)
var (
dg *discordgo.Session
botUser *discordgo.User
daemonStop = make(chan struct{})
researchAgent agents.Agent
memoryAgent agents.Agent
taskAgent agents.Agent
toolAgent agents.Agent
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.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",
},
},
},
}
)
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()
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()
handleAgentResponse(s, i, func() agents.Response {
return researchAgent.Handle(agents.Request{Action: agents.ActionQuery, Args: []string{question}})
})
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} // sub.Name ist bereits der Action-String (add/list/done/delete)
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))
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 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 <text>`, `task done <id>`, `remember <text>`"}
}
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:
return researchAgent.Handle(agents.Request{Action: agents.ActionQuery, Args: words})
}
}
func getAuthorFromMessage(m *discordgo.MessageCreate) string {
if m.Author != nil {
return m.Author.Username
}
return "unknown"
}
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()
taskTimer := scheduleDaily(reminderHour, 0)
defer taskTimer.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 <-taskTimer.C:
slog.Info("Daemon: Task-Reminder gestartet")
store := task.NewStore()
open, err := store.OpenTasks()
if err != nil {
slog.Error("Daemon Task-Fehler", "fehler", err)
continue
}
if len(open) > 0 {
var sb strings.Builder
fmt.Fprintf(&sb, "📋 **Tägliche Task-Erinnerung** %d offene Tasks:\n\n", len(open))
for _, t := range open {
shortID := t.ID
if len(shortID) > 6 {
shortID = shortID[len(shortID)-6:]
}
fmt.Fprintf(&sb, "⬜ `%s` %s\n", shortID, t.Text)
}
dg.ChannelMessageSend(channelID, sb.String())
slog.Info("Daemon: Task-Reminder gesendet", "offene_tasks", len(open))
}
taskTimer.Stop()
taskTimer = 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ächster Task-Reminder in %v (um %02d:%02d)", d.Round(time.Minute), hour, minute)
return time.NewTimer(d)
}