zwischenstand

This commit is contained in:
Christoph K.
2026-03-20 23:24:56 +01:00
parent b1a576f61e
commit 905981cd1e
25 changed files with 3607 additions and 217 deletions

View File

@@ -1,13 +1,18 @@
// discord Discord-Bot für my-brain-importer
// Unterstützt /ask, /research, /memory, /task, /email, /ingest, /remember und @Mention
// 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"
@@ -21,6 +26,7 @@ import (
"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"
@@ -93,6 +99,46 @@ var (
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},
},
},
},
},
{
@@ -151,8 +197,55 @@ var (
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",
},
}
)
@@ -179,8 +272,16 @@ func addToHistory(channelID, role, content string) {
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" {
@@ -253,8 +354,55 @@ func registerCommands() {
}
}
// 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) {
if i.Type != discordgo.InteractionApplicationCommand {
// 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
}
@@ -301,6 +449,20 @@ func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
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},
})
}
}
@@ -318,6 +480,58 @@ func handleMemoryCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
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)}
})
}
}
@@ -348,11 +562,239 @@ func handleTaskCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
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: []string{sub.Name}})
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,
@@ -400,6 +842,23 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
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+">", ""),
)
@@ -417,6 +876,37 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
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 {
@@ -438,12 +928,18 @@ func routeMessage(text, author, channelID string) agents.Response {
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: []string{sub}})
return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: append([]string{sub}, emailArgs...)})
case "task":
action := "list"
@@ -507,28 +1003,24 @@ func sendWelcomeMessage() {
**Slash-Commands:**
` + "```" + `
/ask <frage> Wissensdatenbank abfragen
/research <frage> Alias für /ask
/asknobrain <frage> Direkt ans LLM (kein RAG)
/memory store <text> Text in Wissensdatenbank speichern
/memory ingest Markdown-Notizen neu einlesen
/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 Alle Tasks anzeigen
/task done <id> Task erledigen
/task delete <id> Task löschen
/email summary Letzte Emails zusammenfassen
/email unread Ungelesene Emails zusammenfassen
/email remind Termine aus Emails extrahieren
/task list / done / delete
/email summary / unread / remind / ingest / move / triage
/status Bot-Status
/clear Gesprächsverlauf zurücksetzen
` + "```" + `
**@Mention:**
` + "```" + `
@Brain <frage> Wissensdatenbank (mit Chat-Gedächtnis)
@Brain task add <text> [--due ...] [--priority ...]
@Brain task list / done / delete
@Brain email summary / unread / remind
@Brain remember <text>
` + "```" + `
⚙️ Daemon aktiv: Email-Check + tägliches Morgen-Briefing`
**@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)
@@ -545,7 +1037,73 @@ func getAuthor(i *discordgo.InteractionCreate) string {
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 == "" {
@@ -553,53 +1111,121 @@ func startDaemon() {
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)
// 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)
}
emailTicker := time.NewTicker(emailInterval)
defer emailTicker.Stop()
// 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 <-emailTicker.C:
slog.Info("Daemon: Email-Check gestartet")
notify, err := email.SummarizeUnread()
if err != nil {
slog.Error("Daemon Email-Fehler", "fehler", err)
dg.ChannelMessageSend(channelID, fmt.Sprintf("⚠️ Email-Check fehlgeschlagen: %v", err))
continue
}
if notify != "📭 Keine ungelesenen Emails." {
slog.Info("Daemon: Neue Emails gefunden, sende Zusammenfassung")
dg.ChannelMessageSend(channelID, "📧 **Neue Emails:**\n\n"+notify)
} else {
slog.Info("Daemon: Keine neuen Emails")
}
case <-briefingTimer.C:
slog.Info("Daemon: Morgen-Briefing gestartet")
dailyBriefing(channelID)
briefingTimer.Stop()
briefingTimer = scheduleDaily(reminderHour, 0)
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()
@@ -623,6 +1249,7 @@ func dailyBriefing(channelID string) {
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 {