1298 lines
40 KiB
Go
1298 lines
40 KiB
Go
// 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: "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: "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 "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 sub.Name == agents.ActionEmailIngest && len(sub.Options) > 0 {
|
||
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",
|
||
})
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
if channelID == "" {
|
||
return
|
||
}
|
||
if len(errs) > 0 {
|
||
dg.ChannelMessageSend(channelID, fmt.Sprintf("⚠️ Nacht-Ingest: %d Emails importiert, %d Fehler:\n%s",
|
||
total, len(errs), strings.Join(errs, "\n")))
|
||
} else if total > 0 {
|
||
dg.ChannelMessageSend(channelID, fmt.Sprintf("🗄️ Nacht-Ingest: %d Emails aus Archiv-Ordnern importiert.", total))
|
||
}
|
||
}
|
||
|
||
// 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))
|
||
}
|