671 lines
20 KiB
Go
671 lines
20 KiB
Go
// discord – Discord-Bot für my-brain-importer
|
||
// Unterstützt /ask, /research, /memory, /task, /email, /ingest, /remember und @Mention
|
||
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"log/slog"
|
||
"os"
|
||
"os/signal"
|
||
"strings"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/bwmarrin/discordgo"
|
||
|
||
"my-brain-importer/internal/agents"
|
||
"my-brain-importer/internal/agents/memory"
|
||
"my-brain-importer/internal/agents/research"
|
||
"my-brain-importer/internal/agents/task"
|
||
"my-brain-importer/internal/agents/tool"
|
||
"my-brain-importer/internal/agents/tool/email"
|
||
"my-brain-importer/internal/brain"
|
||
"my-brain-importer/internal/config"
|
||
"my-brain-importer/internal/diag"
|
||
)
|
||
|
||
// maxHistoryPairs ist die maximale Anzahl gespeicherter Gesprächspaare pro Channel.
|
||
const maxHistoryPairs = 10
|
||
|
||
var (
|
||
dg *discordgo.Session
|
||
botUser *discordgo.User
|
||
daemonStop = make(chan struct{})
|
||
|
||
researchAgent agents.Agent
|
||
memoryAgent agents.Agent
|
||
taskAgent agents.Agent
|
||
toolAgent agents.Agent
|
||
|
||
// Konversationsverlauf pro Channel (in-memory, wird bei Neustart zurückgesetzt).
|
||
historyMu sync.Mutex
|
||
historyCache = make(map[string][]agents.HistoryMessage)
|
||
|
||
commands = []*discordgo.ApplicationCommand{
|
||
{
|
||
Name: "ask",
|
||
Description: "Stelle eine Frage an deinen AI Brain (mit Wissensdatenbank)",
|
||
Options: []*discordgo.ApplicationCommandOption{
|
||
{Type: discordgo.ApplicationCommandOptionString, Name: "frage", Description: "Die Frage", Required: true},
|
||
},
|
||
},
|
||
{
|
||
Name: "research",
|
||
Description: "Wissensdatenbank abfragen (Alias für /ask)",
|
||
Options: []*discordgo.ApplicationCommandOption{
|
||
{Type: discordgo.ApplicationCommandOptionString, Name: "frage", Description: "Die Frage", Required: true},
|
||
},
|
||
},
|
||
{
|
||
Name: "asknobrain",
|
||
Description: "Stelle eine Frage direkt ans LLM (ohne Wissensdatenbank)",
|
||
Options: []*discordgo.ApplicationCommandOption{
|
||
{Type: discordgo.ApplicationCommandOptionString, Name: "frage", Description: "Die Frage", Required: true},
|
||
},
|
||
},
|
||
{
|
||
Name: "ingest",
|
||
Description: "Importiert Markdown-Notizen aus brain_root in die Wissensdatenbank",
|
||
},
|
||
{
|
||
Name: "remember",
|
||
Description: "Speichert eine Nachricht in der Wissensdatenbank (Alias für /memory store)",
|
||
Options: []*discordgo.ApplicationCommandOption{
|
||
{Type: discordgo.ApplicationCommandOptionString, Name: "text", Description: "Der Text", Required: true},
|
||
},
|
||
},
|
||
{
|
||
Name: "memory",
|
||
Description: "Wissen einspeichern",
|
||
Options: []*discordgo.ApplicationCommandOption{
|
||
{
|
||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||
Name: "store",
|
||
Description: "Text in die Wissensdatenbank speichern",
|
||
Options: []*discordgo.ApplicationCommandOption{
|
||
{Type: discordgo.ApplicationCommandOptionString, Name: "text", Description: "Der Text", Required: true},
|
||
},
|
||
},
|
||
{
|
||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||
Name: "ingest",
|
||
Description: "Markdown-Notizen aus brain_root importieren",
|
||
},
|
||
},
|
||
},
|
||
{
|
||
Name: "task",
|
||
Description: "Aufgabenverwaltung",
|
||
Options: []*discordgo.ApplicationCommandOption{
|
||
{
|
||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||
Name: "add",
|
||
Description: "Neuen Task hinzufügen",
|
||
Options: []*discordgo.ApplicationCommandOption{
|
||
{Type: discordgo.ApplicationCommandOptionString, Name: "text", Description: "Der Task", Required: true},
|
||
{Type: discordgo.ApplicationCommandOptionString, Name: "faellig", Description: "Fälligkeitsdatum (YYYY-MM-DD)", Required: false},
|
||
{Type: discordgo.ApplicationCommandOptionString, Name: "prioritaet", Description: "Priorität: hoch, mittel, niedrig", Required: false},
|
||
},
|
||
},
|
||
{
|
||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||
Name: "list",
|
||
Description: "Alle Tasks anzeigen",
|
||
},
|
||
{
|
||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||
Name: "done",
|
||
Description: "Task als erledigt markieren",
|
||
Options: []*discordgo.ApplicationCommandOption{
|
||
{Type: discordgo.ApplicationCommandOptionString, Name: "id", Description: "Task-ID (letzte 6 Zeichen)", Required: true},
|
||
},
|
||
},
|
||
{
|
||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||
Name: "delete",
|
||
Description: "Task löschen",
|
||
Options: []*discordgo.ApplicationCommandOption{
|
||
{Type: discordgo.ApplicationCommandOptionString, Name: "id", Description: "Task-ID (letzte 6 Zeichen)", Required: true},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
Name: "email",
|
||
Description: "Emails via IMAP abrufen und analysieren",
|
||
Options: []*discordgo.ApplicationCommandOption{
|
||
{
|
||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||
Name: "summary",
|
||
Description: "Zusammenfassung der letzten Emails",
|
||
},
|
||
{
|
||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||
Name: "unread",
|
||
Description: "Ungelesene Emails zusammenfassen",
|
||
},
|
||
{
|
||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||
Name: "remind",
|
||
Description: "Termine und Deadlines aus Emails extrahieren",
|
||
},
|
||
},
|
||
},
|
||
}
|
||
)
|
||
|
||
// getHistory gibt den gespeicherten Gesprächsverlauf für einen Channel zurück.
|
||
func getHistory(channelID string) []agents.HistoryMessage {
|
||
historyMu.Lock()
|
||
defer historyMu.Unlock()
|
||
msgs := historyCache[channelID]
|
||
result := make([]agents.HistoryMessage, len(msgs))
|
||
copy(result, msgs)
|
||
return result
|
||
}
|
||
|
||
// addToHistory speichert eine Nachricht im Gesprächsverlauf eines Channels.
|
||
func addToHistory(channelID, role, content string) {
|
||
historyMu.Lock()
|
||
defer historyMu.Unlock()
|
||
msgs := historyCache[channelID]
|
||
msgs = append(msgs, agents.HistoryMessage{Role: role, Content: content})
|
||
// Maximal maxHistoryPairs Paare (= maxHistoryPairs*2 Nachrichten) behalten
|
||
if len(msgs) > maxHistoryPairs*2 {
|
||
msgs = msgs[len(msgs)-maxHistoryPairs*2:]
|
||
}
|
||
historyCache[channelID] = msgs
|
||
}
|
||
|
||
func main() {
|
||
config.LoadConfig()
|
||
|
||
token := config.Cfg.Discord.Token
|
||
if token == "" || token == "dein-discord-bot-token" {
|
||
log.Fatal("❌ Kein Discord-Token in config.yml konfiguriert (discord.token)")
|
||
}
|
||
|
||
logLevel := slog.LevelInfo
|
||
if os.Getenv("DEBUG") == "1" {
|
||
logLevel = slog.LevelDebug
|
||
fmt.Println("🔍 Debug-Logging aktiv (LLM-Prompts + Antworten werden ausgegeben)")
|
||
}
|
||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||
Level: logLevel,
|
||
})))
|
||
|
||
researchAgent = research.New()
|
||
memoryAgent = memory.New()
|
||
taskAgent = task.New()
|
||
toolAgent = tool.New()
|
||
|
||
var err error
|
||
dg, err = discordgo.New("Bot " + token)
|
||
if err != nil {
|
||
log.Fatalf("❌ Discord-Session konnte nicht erstellt werden: %v", err)
|
||
}
|
||
|
||
dg.AddHandler(onReady)
|
||
dg.AddHandler(onInteraction)
|
||
dg.AddHandler(onMessage)
|
||
|
||
dg.Identify.Intents = discordgo.IntentsGuilds |
|
||
discordgo.IntentsGuildMessages |
|
||
discordgo.IntentMessageContent
|
||
|
||
if err = dg.Open(); err != nil {
|
||
log.Fatalf("❌ Verbindung zu Discord fehlgeschlagen: %v", err)
|
||
}
|
||
defer dg.Close()
|
||
|
||
registerCommands()
|
||
runStartupDiag()
|
||
sendWelcomeMessage()
|
||
go startDaemon()
|
||
|
||
fmt.Println("✅ Bot läuft. Drücke CTRL+C zum Beenden.")
|
||
stop := make(chan os.Signal, 1)
|
||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||
<-stop
|
||
fmt.Println("\n👋 Bot wird beendet...")
|
||
close(daemonStop)
|
||
}
|
||
|
||
func onReady(s *discordgo.Session, r *discordgo.Ready) {
|
||
botUser = r.User
|
||
fmt.Printf("✅ Eingeloggt als %s#%s\n", r.User.Username, r.User.Discriminator)
|
||
}
|
||
|
||
func registerCommands() {
|
||
guildID := config.Cfg.Discord.GuildID
|
||
registered, err := dg.ApplicationCommandBulkOverwrite(dg.State.User.ID, guildID, commands)
|
||
if err != nil {
|
||
log.Fatalf("❌ Slash-Commands konnten nicht registriert werden: %v", err)
|
||
}
|
||
scope := "global"
|
||
if guildID != "" {
|
||
scope = "guild " + guildID
|
||
}
|
||
for _, cmd := range registered {
|
||
fmt.Printf("📝 Slash-Command /%s registriert (%s)\n", cmd.Name, scope)
|
||
}
|
||
}
|
||
|
||
func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||
if i.Type != discordgo.InteractionApplicationCommand {
|
||
return
|
||
}
|
||
|
||
data := i.ApplicationCommandData()
|
||
slog.Info("Slash-Command", "command", data.Name, "user", getAuthor(i), "channel", i.ChannelID)
|
||
switch data.Name {
|
||
case "ask", "research":
|
||
question := data.Options[0].StringValue()
|
||
channelID := i.ChannelID
|
||
handleAgentResponse(s, i, func() agents.Response {
|
||
resp := researchAgent.Handle(agents.Request{
|
||
Action: agents.ActionQuery,
|
||
Args: []string{question},
|
||
History: getHistory(channelID),
|
||
})
|
||
if resp.RawAnswer != "" {
|
||
addToHistory(channelID, "user", question)
|
||
addToHistory(channelID, "assistant", resp.RawAnswer)
|
||
}
|
||
return resp
|
||
})
|
||
|
||
case "asknobrain":
|
||
handleAskNoBrain(s, i, data.Options[0].StringValue())
|
||
|
||
case "ingest":
|
||
handleAgentResponse(s, i, func() agents.Response {
|
||
return memoryAgent.Handle(agents.Request{Action: agents.ActionIngest})
|
||
})
|
||
|
||
case "remember":
|
||
text := data.Options[0].StringValue()
|
||
author := getAuthor(i)
|
||
source := fmt.Sprintf("discord/#%s", i.ChannelID)
|
||
handleAgentResponse(s, i, func() agents.Response {
|
||
return memoryAgent.Handle(agents.Request{Action: agents.ActionStore, Args: []string{text}, Author: author, Source: source})
|
||
})
|
||
|
||
case "memory":
|
||
handleMemoryCommand(s, i)
|
||
|
||
case "task":
|
||
handleTaskCommand(s, i)
|
||
|
||
case "email":
|
||
handleEmailCommand(s, i)
|
||
}
|
||
}
|
||
|
||
func handleMemoryCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||
sub := i.ApplicationCommandData().Options[0]
|
||
switch sub.Name {
|
||
case "store":
|
||
text := sub.Options[0].StringValue()
|
||
author := getAuthor(i)
|
||
source := fmt.Sprintf("discord/#%s", i.ChannelID)
|
||
handleAgentResponse(s, i, func() agents.Response {
|
||
return memoryAgent.Handle(agents.Request{Action: agents.ActionStore, Args: []string{text}, Author: author, Source: source})
|
||
})
|
||
case "ingest":
|
||
handleAgentResponse(s, i, func() agents.Response {
|
||
return memoryAgent.Handle(agents.Request{Action: agents.ActionIngest})
|
||
})
|
||
}
|
||
}
|
||
|
||
func handleTaskCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||
sub := i.ApplicationCommandData().Options[0]
|
||
req := agents.Request{Action: sub.Name}
|
||
|
||
if sub.Name == "add" && len(sub.Options) > 0 {
|
||
// Baue args mit Text + optionalen Flags für due/priority
|
||
args := []string{sub.Options[0].StringValue()}
|
||
for _, opt := range sub.Options[1:] {
|
||
switch opt.Name {
|
||
case "faellig":
|
||
args = append(args, "--due", opt.StringValue())
|
||
case "prioritaet":
|
||
args = append(args, "--priority", opt.StringValue())
|
||
}
|
||
}
|
||
req.Args = args
|
||
} else if len(sub.Options) > 0 {
|
||
req.Args = []string{sub.Options[0].StringValue()}
|
||
}
|
||
|
||
handleAgentResponse(s, i, func() agents.Response {
|
||
return taskAgent.Handle(req)
|
||
})
|
||
}
|
||
|
||
func handleEmailCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||
sub := i.ApplicationCommandData().Options[0]
|
||
handleAgentResponse(s, i, func() agents.Response {
|
||
return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: []string{sub.Name}})
|
||
})
|
||
}
|
||
|
||
func handleAskNoBrain(s *discordgo.Session, i *discordgo.InteractionCreate, question string) {
|
||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||
})
|
||
answer, err := brain.ChatDirect(question)
|
||
var reply string
|
||
if err != nil {
|
||
reply = fmt.Sprintf("❌ Fehler: %v", err)
|
||
} else {
|
||
reply = fmt.Sprintf("💬 **Antwort:** _%s_\n\n%s", question, answer)
|
||
}
|
||
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &reply})
|
||
}
|
||
|
||
// handleAgentResponse sendet erst Defer an Discord, führt dann fn() aus und editiert die Antwort.
|
||
// So wird das 3-Sekunden-Timeout von Discord nie überschritten.
|
||
func handleAgentResponse(s *discordgo.Session, i *discordgo.InteractionCreate, fn func() agents.Response) {
|
||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||
})
|
||
resp := fn()
|
||
if resp.Error != nil {
|
||
slog.Error("Agent-Fehler", "fehler", resp.Error)
|
||
}
|
||
msg := resp.Text
|
||
if msg == "" && resp.Error != nil {
|
||
msg = fmt.Sprintf("❌ Fehler: %v", resp.Error)
|
||
}
|
||
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg})
|
||
}
|
||
|
||
func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
|
||
if m.Author.Bot || botUser == nil {
|
||
return
|
||
}
|
||
|
||
mentioned := false
|
||
for _, u := range m.Mentions {
|
||
if u.ID == botUser.ID {
|
||
mentioned = true
|
||
break
|
||
}
|
||
}
|
||
if !mentioned {
|
||
return
|
||
}
|
||
|
||
question := strings.TrimSpace(
|
||
strings.ReplaceAll(m.Content, "<@"+botUser.ID+">", ""),
|
||
)
|
||
if question == "" {
|
||
s.ChannelMessageSend(m.ChannelID, "Stell mir eine Frage! Beispiel: @Brain Was sind meine TODOs?")
|
||
return
|
||
}
|
||
|
||
slog.Info("Mention", "user", m.Author.Username, "channel", m.ChannelID, "nachricht", question)
|
||
s.ChannelTyping(m.ChannelID)
|
||
resp := routeMessage(question, getAuthorFromMessage(m), m.ChannelID)
|
||
if resp.Error != nil {
|
||
slog.Error("Mention-Fehler", "fehler", resp.Error)
|
||
}
|
||
s.ChannelMessageSendReply(m.ChannelID, resp.Text, m.Reference())
|
||
}
|
||
|
||
// SendMessage schickt eine Nachricht proaktiv in einen Channel (für Daemon-Nutzung).
|
||
func SendMessage(channelID, text string) error {
|
||
if dg == nil {
|
||
return fmt.Errorf("Discord-Session nicht initialisiert")
|
||
}
|
||
_, err := dg.ChannelMessageSend(channelID, text)
|
||
return err
|
||
}
|
||
|
||
// routeMessage leitet eine @mention-Nachricht an den passenden Agenten weiter.
|
||
// Unterstützte Präfixe: "email ...", "task ...", "remember ...", sonst Research.
|
||
func routeMessage(text, author, channelID string) agents.Response {
|
||
words := strings.Fields(text)
|
||
if len(words) == 0 {
|
||
return agents.Response{Text: "Stell mir eine Frage oder nutze: `email summary`, `email unread`, `email remind`, `task list`, `task add <text> [--due YYYY-MM-DD] [--priority hoch]`, `task done <id>`, `remember <text>`"}
|
||
}
|
||
|
||
cmd := strings.ToLower(words[0])
|
||
args := words[1:]
|
||
|
||
switch cmd {
|
||
case "email":
|
||
sub := "summary"
|
||
if len(args) > 0 {
|
||
sub = strings.ToLower(args[0])
|
||
}
|
||
return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: []string{sub}})
|
||
|
||
case "task":
|
||
action := "list"
|
||
if len(args) > 0 {
|
||
action = strings.ToLower(args[0])
|
||
args = args[1:]
|
||
}
|
||
return taskAgent.Handle(agents.Request{Action: action, Args: args})
|
||
|
||
case "remember":
|
||
return memoryAgent.Handle(agents.Request{
|
||
Action: agents.ActionStore,
|
||
Args: args,
|
||
Author: author,
|
||
Source: "discord/mention",
|
||
})
|
||
|
||
default:
|
||
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 in Wissensdatenbank speichern
|
||
/memory ingest – Markdown-Notizen neu einlesen
|
||
/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
|
||
` + "```" + `
|
||
**@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`
|
||
|
||
if _, err := dg.ChannelMessageSend(channelID, msg); err != nil {
|
||
log.Printf("⚠️ Willkommensnachricht konnte nicht gesendet werden: %v", err)
|
||
}
|
||
}
|
||
|
||
func getAuthor(i *discordgo.InteractionCreate) string {
|
||
if i.Member != nil {
|
||
return i.Member.User.Username
|
||
}
|
||
if i.User != nil {
|
||
return i.User.Username
|
||
}
|
||
return "unknown"
|
||
}
|
||
|
||
// startDaemon läuft als Goroutine im Bot-Prozess und sendet proaktive Benachrichtigungen.
|
||
func startDaemon() {
|
||
channelID := config.Cfg.Daemon.ChannelID
|
||
if channelID == "" {
|
||
log.Println("⚙️ Daemon inaktiv: daemon.channel_id nicht konfiguriert")
|
||
return
|
||
}
|
||
|
||
emailInterval := time.Duration(config.Cfg.Daemon.EmailIntervalMin) * time.Minute
|
||
if emailInterval == 0 {
|
||
emailInterval = 30 * time.Minute
|
||
}
|
||
reminderHour := config.Cfg.Daemon.TaskReminderHour
|
||
if reminderHour == 0 {
|
||
reminderHour = 8
|
||
}
|
||
|
||
log.Printf("⚙️ Daemon aktiv: Email-Check alle %v, Task-Reminder täglich um %02d:00", emailInterval, reminderHour)
|
||
|
||
emailTicker := time.NewTicker(emailInterval)
|
||
defer emailTicker.Stop()
|
||
|
||
briefingTimer := scheduleDaily(reminderHour, 0)
|
||
defer briefingTimer.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-daemonStop:
|
||
slog.Info("Daemon gestoppt")
|
||
return
|
||
|
||
case <-emailTicker.C:
|
||
slog.Info("Daemon: Email-Check gestartet")
|
||
notify, err := email.SummarizeUnread()
|
||
if err != nil {
|
||
slog.Error("Daemon Email-Fehler", "fehler", err)
|
||
dg.ChannelMessageSend(channelID, fmt.Sprintf("⚠️ Email-Check fehlgeschlagen: %v", err))
|
||
continue
|
||
}
|
||
if notify != "📭 Keine ungelesenen Emails." {
|
||
slog.Info("Daemon: Neue Emails gefunden, sende Zusammenfassung")
|
||
dg.ChannelMessageSend(channelID, "📧 **Neue Emails:**\n\n"+notify)
|
||
} else {
|
||
slog.Info("Daemon: Keine neuen Emails")
|
||
}
|
||
|
||
case <-briefingTimer.C:
|
||
slog.Info("Daemon: Morgen-Briefing gestartet")
|
||
dailyBriefing(channelID)
|
||
briefingTimer.Stop()
|
||
briefingTimer = scheduleDaily(reminderHour, 0)
|
||
}
|
||
}
|
||
}
|
||
|
||
// scheduleDaily gibt einen Timer zurück, der zum nächsten Auftreten von hour:minute feuert.
|
||
func scheduleDaily(hour, minute int) *time.Timer {
|
||
now := time.Now()
|
||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
|
||
if !next.After(now) {
|
||
next = next.Add(24 * time.Hour)
|
||
}
|
||
d := next.Sub(now)
|
||
log.Printf("⏰ Nächstes Morgen-Briefing in %v (um %02d:%02d)", d.Round(time.Minute), hour, minute)
|
||
return time.NewTimer(d)
|
||
}
|
||
|
||
// dailyBriefing sendet eine kombinierte Morgen-Zusammenfassung: offene Tasks + ungelesene Emails.
|
||
func dailyBriefing(channelID string) {
|
||
today := time.Now().Truncate(24 * time.Hour)
|
||
tomorrow := today.Add(24 * time.Hour)
|
||
|
||
// Tasks
|
||
var taskSection strings.Builder
|
||
store := task.NewStore()
|
||
open, err := store.OpenTasks()
|
||
if err != nil {
|
||
slog.Error("Daemon Briefing Task-Fehler", "fehler", err)
|
||
} else if len(open) > 0 {
|
||
fmt.Fprintf(&taskSection, "📋 **Offene Tasks (%d):**\n", len(open))
|
||
for _, t := range open {
|
||
shortID := t.ID
|
||
if len(shortID) > 6 {
|
||
shortID = shortID[len(shortID)-6:]
|
||
}
|
||
urgency := ""
|
||
if t.DueDate != nil {
|
||
due := t.DueDate.Truncate(24 * time.Hour)
|
||
switch {
|
||
case due.Before(today):
|
||
urgency = " ⚠️ **ÜBERFÄLLIG**"
|
||
case due.Equal(today):
|
||
urgency = " 🔴 *heute fällig*"
|
||
case due.Equal(tomorrow):
|
||
urgency = " 🟡 *morgen fällig*"
|
||
}
|
||
}
|
||
fmt.Fprintf(&taskSection, "⬜ `%s` – %s%s\n", shortID, t.Text, urgency)
|
||
}
|
||
}
|
||
|
||
// Emails
|
||
var emailSection string
|
||
notify, err := email.SummarizeUnread()
|
||
if err != nil {
|
||
slog.Error("Daemon Briefing Email-Fehler", "fehler", err)
|
||
} else if notify != "📭 Keine ungelesenen Emails." {
|
||
emailSection = "\n\n📧 **Ungelesene Emails:**\n" + notify
|
||
}
|
||
|
||
var msg strings.Builder
|
||
fmt.Fprintf(&msg, "☀️ **Morgen-Briefing** – %s\n\n", time.Now().Format("02.01.2006"))
|
||
if taskSection.Len() > 0 {
|
||
msg.WriteString(taskSection.String())
|
||
}
|
||
msg.WriteString(emailSection)
|
||
if taskSection.Len() == 0 && emailSection == "" {
|
||
msg.WriteString("✨ Keine offenen Tasks und keine ungelesenen Emails. Guten Morgen!")
|
||
}
|
||
|
||
dg.ChannelMessageSend(channelID, msg.String())
|
||
slog.Info("Daemon: Morgen-Briefing gesendet", "offene_tasks", len(open))
|
||
}
|