Files
ai-agent/cmd/discord/main.go
2026-03-20 07:07:38 +01:00

654 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// discord Discord-Bot für my-brain-importer
// Unterstützt /ask, /research, /memory, /task, /email, /ingest, /remember 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"
)
// 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()
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"
}
// 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))
}