Files
ai-agent/cmd/discord/main.go
Christoph K. ee7b4cc74f /deepask: Multi-Step Reasoning mit iterativer RAG-Suche
Neuer Discord-Command für tiefe Recherche in 3 Phasen:
1. Initiale Qdrant-Suche mit der Originalfrage
2. LLM generiert Folgefragen, sucht erneut (max 2 Iterationen)
3. Synthese aller gesammelten Chunks zu umfassender Antwort

Nutzbar via /deepask oder @bot deepask.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 19:49:38 +01:00

1386 lines
43 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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:<imapFolder>:<accIndex>
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 <text> [--due YYYY-MM-DD] [--priority hoch]`, `task done <id>`, `remember <text>`"}
}
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 <frage> Wissensdatenbank abfragen
/research <frage> Alias für /ask
/asknobrain <frage> Direkt ans LLM (kein RAG)
/memory store <text> Text speichern
/memory ingest Markdown-Notizen neu einlesen
/memory url <url> URL-Inhalt importieren
/memory profile <text> Fakt zum Kerngedächtnis hinzufügen
/memory profile-show Kerngedächtnis anzeigen
/knowledge list Gespeicherte Quellen auflisten
/knowledge delete <source> Quelle löschen
/task add <text> [--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))
}