510 lines
15 KiB
Go
510 lines
15 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"
|
||
"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)
|
||
}
|