Files
ai-agent/cmd/discord/main.go
2026-03-19 21:46:12 +01:00

510 lines
15 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"
"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"
)
var (
dg *discordgo.Session
botUser *discordgo.User
daemonStop = make(chan struct{})
researchAgent agents.Agent
memoryAgent agents.Agent
taskAgent agents.Agent
toolAgent agents.Agent
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.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",
},
},
},
}
)
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()
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()
handleAgentResponse(s, i, func() agents.Response {
return researchAgent.Handle(agents.Request{Action: agents.ActionQuery, Args: []string{question}})
})
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} // sub.Name ist bereits der Action-String (add/list/done/delete)
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))
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 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>`, `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:
return researchAgent.Handle(agents.Request{Action: agents.ActionQuery, Args: words})
}
}
func getAuthorFromMessage(m *discordgo.MessageCreate) string {
if m.Author != nil {
return m.Author.Username
}
return "unknown"
}
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()
taskTimer := scheduleDaily(reminderHour, 0)
defer taskTimer.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 <-taskTimer.C:
slog.Info("Daemon: Task-Reminder gestartet")
store := task.NewStore()
open, err := store.OpenTasks()
if err != nil {
slog.Error("Daemon Task-Fehler", "fehler", err)
continue
}
if len(open) > 0 {
var sb strings.Builder
fmt.Fprintf(&sb, "📋 **Tägliche Task-Erinnerung** %d offene Tasks:\n\n", len(open))
for _, t := range open {
shortID := t.ID
if len(shortID) > 6 {
shortID = shortID[len(shortID)-6:]
}
fmt.Fprintf(&sb, "⬜ `%s` %s\n", shortID, t.Text)
}
dg.ChannelMessageSend(channelID, sb.String())
slog.Info("Daemon: Task-Reminder gesendet", "offene_tasks", len(open))
}
taskTimer.Stop()
taskTimer = 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ächster Task-Reminder in %v (um %02d:%02d)", d.Round(time.Minute), hour, minute)
return time.NewTimer(d)
}