auto deployment und tests

This commit is contained in:
Christoph K.
2026-03-20 07:07:38 +01:00
parent 0e7aa3e7f2
commit 8163f906cc
12 changed files with 500 additions and 66 deletions

View File

@@ -9,6 +9,7 @@ import (
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
@@ -24,16 +25,23 @@ import (
"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{})
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",
@@ -96,6 +104,8 @@ var (
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},
},
},
{
@@ -145,6 +155,29 @@ var (
}
)
// 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()
@@ -187,6 +220,7 @@ func main() {
defer dg.Close()
registerCommands()
sendWelcomeMessage()
go startDaemon()
fmt.Println("✅ Bot läuft. Drücke CTRL+C zum Beenden.")
@@ -227,8 +261,18 @@ func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
switch data.Name {
case "ask", "research":
question := data.Options[0].StringValue()
channelID := i.ChannelID
handleAgentResponse(s, i, func() agents.Response {
return researchAgent.Handle(agents.Request{Action: agents.ActionQuery, Args: []string{question}})
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":
@@ -277,10 +321,24 @@ func handleMemoryCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
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 := 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)
})
@@ -350,7 +408,7 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
slog.Info("Mention", "user", m.Author.Username, "channel", m.ChannelID, "nachricht", question)
s.ChannelTyping(m.ChannelID)
resp := routeMessage(question, getAuthorFromMessage(m))
resp := routeMessage(question, getAuthorFromMessage(m), m.ChannelID)
if resp.Error != nil {
slog.Error("Mention-Fehler", "fehler", resp.Error)
}
@@ -368,10 +426,10 @@ func SendMessage(channelID, text string) error {
// 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 {
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>`, `task done <id>`, `remember <text>`"}
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])
@@ -402,7 +460,16 @@ func routeMessage(text, author string) agents.Response {
})
default:
return researchAgent.Handle(agents.Request{Action: agents.ActionQuery, Args: words})
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
}
}
@@ -413,6 +480,44 @@ func getAuthorFromMessage(m *discordgo.MessageCreate) string {
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
@@ -445,8 +550,8 @@ func startDaemon() {
emailTicker := time.NewTicker(emailInterval)
defer emailTicker.Stop()
taskTimer := scheduleDaily(reminderHour, 0)
defer taskTimer.Stop()
briefingTimer := scheduleDaily(reminderHour, 0)
defer briefingTimer.Stop()
for {
select {
@@ -469,29 +574,11 @@ func startDaemon() {
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)
case <-briefingTimer.C:
slog.Info("Daemon: Morgen-Briefing gestartet")
dailyBriefing(channelID)
briefingTimer.Stop()
briefingTimer = scheduleDaily(reminderHour, 0)
}
}
}
@@ -504,6 +591,63 @@ func scheduleDaily(hour, minute int) *time.Timer {
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)
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))
}