zwischenstand
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
// discord – Discord-Bot für my-brain-importer
|
||||
// Unterstützt /ask, /research, /memory, /task, /email, /ingest, /remember und @Mention
|
||||
// Unterstützt /ask, /research, /memory, /task, /email, /ingest, /remember, /status, /clear und @Mention
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -21,6 +26,7 @@ import (
|
||||
"my-brain-importer/internal/agents/task"
|
||||
"my-brain-importer/internal/agents/tool"
|
||||
"my-brain-importer/internal/agents/tool/email"
|
||||
"my-brain-importer/internal/agents/tool/rss"
|
||||
"my-brain-importer/internal/brain"
|
||||
"my-brain-importer/internal/config"
|
||||
"my-brain-importer/internal/diag"
|
||||
@@ -93,6 +99,46 @@ var (
|
||||
Name: "ingest",
|
||||
Description: "Markdown-Notizen aus brain_root importieren",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "url",
|
||||
Description: "URL-Inhalt in die Wissensdatenbank importieren",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{Type: discordgo.ApplicationCommandOptionString, Name: "url", Description: "Die URL", Required: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "profile",
|
||||
Description: "Fakt zum Kerngedächtnis hinzufügen",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{Type: discordgo.ApplicationCommandOptionString, Name: "text", Description: "Der Fakt", Required: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "profile-show",
|
||||
Description: "Kerngedächtnis anzeigen",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "knowledge",
|
||||
Description: "Wissensdatenbank verwalten",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "list",
|
||||
Description: "Gespeicherte Quellen auflisten",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "delete",
|
||||
Description: "Quelle aus der Wissensdatenbank löschen",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{Type: discordgo.ApplicationCommandOptionString, Name: "source", Description: "Quellenname (aus /knowledge list)", Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -151,8 +197,55 @@ var (
|
||||
Name: "remind",
|
||||
Description: "Termine und Deadlines aus Emails extrahieren",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "ingest",
|
||||
Description: "Emails aus IMAP-Ordner in Wissensdatenbank importieren",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{Type: discordgo.ApplicationCommandOptionString, Name: "ordner", Description: "IMAP-Ordner (Standard: Archiv)", Required: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "triage",
|
||||
Description: "Letzte 10 Emails klassifizieren und in Wichtig/Unwichtig verschieben",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "move",
|
||||
Description: "Emails in Archivordner verschieben",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "ordner",
|
||||
Description: "Zielordner",
|
||||
Required: true,
|
||||
Choices: []*discordgo.ApplicationCommandOptionChoice{
|
||||
{Name: "Archiv (dauerhaft)", Value: "Archiv"},
|
||||
{Name: "5Jahre (~5 Jahre)", Value: "5Jahre"},
|
||||
{Name: "2Jahre (~2 Jahre)", Value: "2Jahre"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionInteger,
|
||||
Name: "alter",
|
||||
Description: "Alle Emails älter als N Tage verschieben (kein Auswahlmenü)",
|
||||
Required: false,
|
||||
MinValue: floatPtr(1),
|
||||
MaxValue: float64(3650),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "status",
|
||||
Description: "Bot-Status: Verbindungen prüfen, offene Tasks zählen",
|
||||
},
|
||||
{
|
||||
Name: "clear",
|
||||
Description: "Gesprächsverlauf für diesen Channel zurücksetzen",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -179,8 +272,16 @@ func addToHistory(channelID, role, content string) {
|
||||
historyCache[channelID] = msgs
|
||||
}
|
||||
|
||||
// clearHistory löscht den Gesprächsverlauf für einen Channel.
|
||||
func clearHistory(channelID string) {
|
||||
historyMu.Lock()
|
||||
defer historyMu.Unlock()
|
||||
delete(historyCache, channelID)
|
||||
}
|
||||
|
||||
func main() {
|
||||
config.LoadConfig()
|
||||
patchEmailMoveChoices() // Dynamische Archivordner aus Config einsetzen
|
||||
|
||||
token := config.Cfg.Discord.Token
|
||||
if token == "" || token == "dein-discord-bot-token" {
|
||||
@@ -253,8 +354,55 @@ func registerCommands() {
|
||||
}
|
||||
}
|
||||
|
||||
// isAllowed prüft ob ein Discord-User den Bot nutzen darf.
|
||||
// Wenn keine allowed_users konfiguriert sind, ist jeder erlaubt.
|
||||
func isAllowed(userID string) bool {
|
||||
if len(config.Cfg.Discord.AllowedUsers) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, id := range config.Cfg.Discord.AllowedUsers {
|
||||
if id == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getUserID extrahiert die User-ID aus einer Interaktion.
|
||||
func getUserID(i *discordgo.InteractionCreate) string {
|
||||
if i.Member != nil {
|
||||
return i.Member.User.ID
|
||||
}
|
||||
if i.User != nil {
|
||||
return i.User.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if i.Type != discordgo.InteractionApplicationCommand {
|
||||
// Berechtigungsprüfung
|
||||
if !isAllowed(getUserID(i)) {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "❌ Du bist nicht berechtigt, diesen Bot zu nutzen.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
switch i.Type {
|
||||
case discordgo.InteractionMessageComponent:
|
||||
data := i.MessageComponentData()
|
||||
slog.Info("Komponente", "customID", data.CustomID, "user", getAuthor(i))
|
||||
if strings.HasPrefix(data.CustomID, "email_move:") {
|
||||
handleEmailMoveSelect(s, i)
|
||||
}
|
||||
return
|
||||
case discordgo.InteractionApplicationCommand:
|
||||
// handled below
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
@@ -301,6 +449,20 @@ func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
|
||||
case "email":
|
||||
handleEmailCommand(s, i)
|
||||
|
||||
case "knowledge":
|
||||
handleKnowledgeCommand(s, i)
|
||||
|
||||
case "status":
|
||||
handleStatus(s, i)
|
||||
|
||||
case "clear":
|
||||
clearHistory(i.ChannelID)
|
||||
reply := "🗑️ Gesprächsverlauf für diesen Channel gelöscht."
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{Content: reply},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,6 +480,58 @@ func handleMemoryCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
handleAgentResponse(s, i, func() agents.Response {
|
||||
return memoryAgent.Handle(agents.Request{Action: agents.ActionIngest})
|
||||
})
|
||||
case "url":
|
||||
rawURL := sub.Options[0].StringValue()
|
||||
handleAgentResponse(s, i, func() agents.Response {
|
||||
n, err := brain.IngestURL(rawURL)
|
||||
if err != nil {
|
||||
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler beim URL-Import: %v", err)}
|
||||
}
|
||||
return agents.Response{Text: fmt.Sprintf("✅ **%d Chunks** aus URL importiert:\n`%s`", n, rawURL)}
|
||||
})
|
||||
case "profile":
|
||||
text := sub.Options[0].StringValue()
|
||||
handleAgentResponse(s, i, func() agents.Response {
|
||||
if err := brain.AppendCoreMemory(text); err != nil {
|
||||
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||
}
|
||||
return agents.Response{Text: fmt.Sprintf("🧠 Kerngedächtnis aktualisiert: _%s_", text)}
|
||||
})
|
||||
case "profile-show":
|
||||
handleAgentResponse(s, i, func() agents.Response {
|
||||
return agents.Response{Text: brain.ShowCoreMemory()}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleKnowledgeCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
sub := i.ApplicationCommandData().Options[0]
|
||||
switch sub.Name {
|
||||
case "list":
|
||||
handleAgentResponse(s, i, func() agents.Response {
|
||||
sources, err := brain.ListSources(0)
|
||||
if err != nil {
|
||||
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
return agents.Response{Text: "📭 Keine Einträge in der Wissensdatenbank."}
|
||||
}
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "📚 **Quellen in der Wissensdatenbank** (%d):\n```\n", len(sources))
|
||||
for _, s := range sources {
|
||||
fmt.Fprintf(&sb, "%s\n", s)
|
||||
}
|
||||
sb.WriteString("```")
|
||||
return agents.Response{Text: sb.String()}
|
||||
})
|
||||
case "delete":
|
||||
source := sub.Options[0].StringValue()
|
||||
handleAgentResponse(s, i, func() agents.Response {
|
||||
if err := brain.DeleteBySource(source); err != nil {
|
||||
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler beim Löschen: %v", err)}
|
||||
}
|
||||
return agents.Response{Text: fmt.Sprintf("🗑️ Quelle gelöscht: `%s`", source)}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,11 +562,239 @@ func handleTaskCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
|
||||
func handleEmailCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
sub := i.ApplicationCommandData().Options[0]
|
||||
|
||||
// Email-Move zeigt ein Select-Menü statt sofort alle zu verschieben
|
||||
if sub.Name == agents.ActionEmailMove {
|
||||
handleEmailMoveInit(s, i, sub)
|
||||
return
|
||||
}
|
||||
|
||||
args := []string{sub.Name}
|
||||
if sub.Name == agents.ActionEmailIngest && len(sub.Options) > 0 {
|
||||
args = append(args, sub.Options[0].StringValue())
|
||||
}
|
||||
handleAgentResponse(s, i, func() agents.Response {
|
||||
return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: []string{sub.Name}})
|
||||
return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: args})
|
||||
})
|
||||
}
|
||||
|
||||
// handleEmailMoveInit zeigt ein Discord Select-Menü mit Emails zur Auswahl oder verschiebt direkt per Alter.
|
||||
func handleEmailMoveInit(s *discordgo.Session, i *discordgo.InteractionCreate, sub *discordgo.ApplicationCommandInteractionDataOption) {
|
||||
destName, alterDays := "", 0
|
||||
for _, opt := range sub.Options {
|
||||
switch opt.Name {
|
||||
case "ordner":
|
||||
destName = opt.StringValue()
|
||||
case "alter":
|
||||
alterDays = int(opt.IntValue())
|
||||
}
|
||||
}
|
||||
|
||||
imapFolder, ok := tool.ResolveArchiveFolder(destName)
|
||||
if !ok {
|
||||
msg := fmt.Sprintf("❌ Unbekannter Ordner `%s`.", destName)
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{Content: msg},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||
})
|
||||
|
||||
// Bulk-Verschieben aller Emails älter als N Tage — kein Select-Menü nötig
|
||||
if alterDays > 0 {
|
||||
n, err := email.MoveOldEmailsAllAccounts(imapFolder, alterDays)
|
||||
var replyMsg string
|
||||
if err != nil {
|
||||
replyMsg = fmt.Sprintf("❌ Fehler beim Verschieben: %v", err)
|
||||
} else if n == 0 {
|
||||
replyMsg = fmt.Sprintf("📭 Keine Emails älter als %d Tage gefunden.", alterDays)
|
||||
} else {
|
||||
replyMsg = fmt.Sprintf("✅ %d Email(s) älter als %d Tage nach `%s` verschoben.", n, alterDays, imapFolder)
|
||||
}
|
||||
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &replyMsg})
|
||||
return
|
||||
}
|
||||
|
||||
allAccMsgs, err := email.FetchRecentForSelectAllAccounts(25)
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("❌ Emails konnten nicht abgerufen werden: %v", err)
|
||||
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg})
|
||||
return
|
||||
}
|
||||
|
||||
totalEmails := 0
|
||||
for _, a := range allAccMsgs {
|
||||
totalEmails += len(a.Messages)
|
||||
}
|
||||
if totalEmails == 0 {
|
||||
msg := "📭 Keine Emails im Posteingang."
|
||||
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg})
|
||||
return
|
||||
}
|
||||
|
||||
const maxOptions = 25
|
||||
var components []discordgo.MessageComponent
|
||||
var headerParts []string
|
||||
|
||||
for _, accData := range allAccMsgs {
|
||||
if len(accData.Messages) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
msgs := accData.Messages
|
||||
truncated := false
|
||||
if len(msgs) > maxOptions {
|
||||
msgs = msgs[:maxOptions]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
options := make([]discordgo.SelectMenuOption, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
label := m.Subject
|
||||
if label == "" {
|
||||
label = "(kein Betreff)"
|
||||
}
|
||||
if len([]rune(label)) > 97 {
|
||||
label = string([]rune(label)[:97]) + "..."
|
||||
}
|
||||
desc := fmt.Sprintf("%s | %s", m.Date, m.From)
|
||||
if len([]rune(desc)) > 97 {
|
||||
desc = string([]rune(desc)[:97]) + "..."
|
||||
}
|
||||
options = append(options, discordgo.SelectMenuOption{
|
||||
Label: label,
|
||||
Value: fmt.Sprintf("%d", m.SeqNum),
|
||||
Description: desc,
|
||||
})
|
||||
}
|
||||
|
||||
customID := fmt.Sprintf("email_move:%s:%d", imapFolder, accData.AccIndex)
|
||||
minVals := 1
|
||||
maxVals := len(options)
|
||||
accLabel := accData.Account.Name
|
||||
if accLabel == "" {
|
||||
accLabel = accData.Account.User
|
||||
}
|
||||
placeholder := "Email(s) auswählen..."
|
||||
if len(allAccMsgs) > 1 {
|
||||
placeholder = fmt.Sprintf("Email(s) auswählen – %s...", accLabel)
|
||||
}
|
||||
|
||||
components = append(components, discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.SelectMenu{
|
||||
CustomID: customID,
|
||||
Placeholder: placeholder,
|
||||
MinValues: &minVals,
|
||||
MaxValues: maxVals,
|
||||
Options: options,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
note := ""
|
||||
if truncated {
|
||||
note = fmt.Sprintf(" *(erste %d von %d)*", maxOptions, len(accData.Messages))
|
||||
}
|
||||
if len(allAccMsgs) > 1 {
|
||||
headerParts = append(headerParts, fmt.Sprintf("**%s**: %d Email(s)%s", accLabel, len(accData.Messages), note))
|
||||
} else {
|
||||
headerParts = append(headerParts, fmt.Sprintf("%d Email(s)%s", len(accData.Messages), note))
|
||||
}
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("📧 **Emails nach `%s` verschieben**\n%s\nWähle aus welche Emails du verschieben möchtest:", imapFolder, strings.Join(headerParts, "\n"))
|
||||
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: &msg,
|
||||
Components: &components,
|
||||
})
|
||||
}
|
||||
|
||||
// handleEmailMoveSelect verarbeitet die Discord Select-Menü Auswahl und verschiebt die gewählten Emails.
|
||||
func handleEmailMoveSelect(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
data := i.MessageComponentData()
|
||||
// CustomID-Format: email_move:<imapFolder>:<accIndex>
|
||||
parts := strings.SplitN(data.CustomID, ":", 3)
|
||||
if len(parts) != 3 {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{Content: "❌ Interner Fehler: ungültige CustomID."},
|
||||
})
|
||||
return
|
||||
}
|
||||
imapFolder := parts[1]
|
||||
accIndex, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{Content: "❌ Interner Fehler: ungültiger Account-Index."},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
seqNums := make([]uint32, 0, len(data.Values))
|
||||
for _, v := range data.Values {
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
seqNums = append(seqNums, uint32(n))
|
||||
}
|
||||
if len(seqNums) == 0 {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{Content: "❌ Keine gültigen Emails ausgewählt."},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// DeferredMessageUpdate: zeigt Ladezustand, editiert dann die ursprüngliche Nachricht
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseDeferredMessageUpdate,
|
||||
})
|
||||
|
||||
n, err := email.MoveSpecificUnread(accIndex, seqNums, imapFolder)
|
||||
var replyMsg string
|
||||
if err != nil {
|
||||
replyMsg = fmt.Sprintf("❌ Fehler beim Verschieben: %v", err)
|
||||
} else {
|
||||
replyMsg = fmt.Sprintf("✅ **%d Email(s)** nach `%s` verschoben.", n, imapFolder)
|
||||
}
|
||||
|
||||
// Menü entfernen nach Auswahl
|
||||
emptyComponents := []discordgo.MessageComponent{}
|
||||
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: &replyMsg,
|
||||
Components: &emptyComponents,
|
||||
})
|
||||
}
|
||||
|
||||
// handleStatus prüft alle externen Dienste und zeigt offene Task-Anzahl.
|
||||
func handleStatus(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||
})
|
||||
|
||||
results, allOK := diag.RunAll()
|
||||
|
||||
// Task-Zähler
|
||||
store := task.NewStore()
|
||||
open, err := store.OpenTasks()
|
||||
taskInfo := ""
|
||||
if err != nil {
|
||||
taskInfo = "❌ Tasks: Fehler"
|
||||
} else {
|
||||
taskInfo = fmt.Sprintf("📋 Tasks: %d offen", len(open))
|
||||
}
|
||||
|
||||
msg := strings.ReplaceAll(diag.Format(results, allOK), "Start-Diagnose", "Status") + "\n" + taskInfo
|
||||
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg})
|
||||
}
|
||||
|
||||
func handleAskNoBrain(s *discordgo.Session, i *discordgo.InteractionCreate, question string) {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||
@@ -400,6 +842,23 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
|
||||
return
|
||||
}
|
||||
|
||||
// Berechtigungsprüfung
|
||||
if !isAllowed(m.Author.ID) {
|
||||
s.ChannelMessageSendReply(m.ChannelID, "❌ Du bist nicht berechtigt, diesen Bot zu nutzen.", m.Reference())
|
||||
return
|
||||
}
|
||||
|
||||
// Datei-Anhänge prüfen (PDF)
|
||||
for _, att := range m.Attachments {
|
||||
ext := strings.ToLower(filepath.Ext(att.Filename))
|
||||
if ext == ".pdf" {
|
||||
s.ChannelTyping(m.ChannelID)
|
||||
reply := handlePDFAttachment(att)
|
||||
s.ChannelMessageSendReply(m.ChannelID, reply, m.Reference())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
question := strings.TrimSpace(
|
||||
strings.ReplaceAll(m.Content, "<@"+botUser.ID+">", ""),
|
||||
)
|
||||
@@ -417,6 +876,37 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
|
||||
s.ChannelMessageSendReply(m.ChannelID, resp.Text, m.Reference())
|
||||
}
|
||||
|
||||
// handlePDFAttachment lädt eine PDF-Datei herunter, importiert sie und gibt die Antwort zurück.
|
||||
func handlePDFAttachment(att *discordgo.MessageAttachment) string {
|
||||
slog.Info("PDF-Attachment erkannt", "datei", att.Filename, "url", att.URL)
|
||||
|
||||
// PDF herunterladen
|
||||
resp, err := http.Get(att.URL) //nolint:noctx
|
||||
if err != nil {
|
||||
return fmt.Sprintf("❌ PDF konnte nicht geladen werden: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// In temporäre Datei schreiben
|
||||
tmpFile, err := os.CreateTemp("", "brain-pdf-*.pdf")
|
||||
if err != nil {
|
||||
return fmt.Sprintf("❌ Temporäre Datei konnte nicht erstellt werden: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
|
||||
return fmt.Sprintf("❌ PDF konnte nicht gespeichert werden: %v", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
n, err := brain.IngestPDF(tmpFile.Name(), att.Filename)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("❌ PDF-Import fehlgeschlagen: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("✅ **%d Chunks** aus PDF importiert: `%s`", n, att.Filename)
|
||||
}
|
||||
|
||||
// SendMessage schickt eine Nachricht proaktiv in einen Channel (für Daemon-Nutzung).
|
||||
func SendMessage(channelID, text string) error {
|
||||
if dg == nil {
|
||||
@@ -438,12 +928,18 @@ func routeMessage(text, author, channelID string) agents.Response {
|
||||
args := words[1:]
|
||||
|
||||
switch cmd {
|
||||
case "clear":
|
||||
clearHistory(channelID)
|
||||
return agents.Response{Text: "🗑️ Gesprächsverlauf gelöscht."}
|
||||
|
||||
case "email":
|
||||
sub := "summary"
|
||||
emailArgs := []string{}
|
||||
if len(args) > 0 {
|
||||
sub = strings.ToLower(args[0])
|
||||
emailArgs = args[1:] // Restargumente (z.B. Ordnername für "move")
|
||||
}
|
||||
return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: []string{sub}})
|
||||
return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: append([]string{sub}, emailArgs...)})
|
||||
|
||||
case "task":
|
||||
action := "list"
|
||||
@@ -507,28 +1003,24 @@ func sendWelcomeMessage() {
|
||||
|
||||
**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
|
||||
/ask <frage> – Wissensdatenbank abfragen
|
||||
/research <frage> – Alias für /ask
|
||||
/asknobrain <frage> – Direkt ans LLM (kein RAG)
|
||||
/memory store <text> – Text speichern
|
||||
/memory ingest – Markdown-Notizen neu einlesen
|
||||
/memory url <url> – URL-Inhalt importieren
|
||||
/memory profile <text> – Fakt zum Kerngedächtnis hinzufügen
|
||||
/memory profile-show – Kerngedächtnis anzeigen
|
||||
/knowledge list – Gespeicherte Quellen auflisten
|
||||
/knowledge delete <source> – Quelle löschen
|
||||
/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
|
||||
/task list / done / delete
|
||||
/email summary / unread / remind / ingest / move / triage
|
||||
/status – Bot-Status
|
||||
/clear – Gesprächsverlauf zurücksetzen
|
||||
` + "```" + `
|
||||
**@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`
|
||||
**@Mention:** PDF-Anhang schicken → automatisch importiert
|
||||
⚙️ Daemon: IMAP IDLE + Morgen-Briefing + RSS-Feeds`
|
||||
|
||||
if _, err := dg.ChannelMessageSend(channelID, msg); err != nil {
|
||||
log.Printf("⚠️ Willkommensnachricht konnte nicht gesendet werden: %v", err)
|
||||
@@ -545,7 +1037,73 @@ func getAuthor(i *discordgo.InteractionCreate) string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// patchEmailMoveChoices aktualisiert die /email move Choices in der commands-Liste nach dem Laden der Config.
|
||||
// Wird in main() nach config.LoadConfig() aufgerufen.
|
||||
func patchEmailMoveChoices() {
|
||||
choices := buildMoveChoices()
|
||||
for _, cmd := range commands {
|
||||
if cmd.Name != "email" {
|
||||
continue
|
||||
}
|
||||
for _, opt := range cmd.Options {
|
||||
if opt.Name != "move" {
|
||||
continue
|
||||
}
|
||||
for _, subOpt := range opt.Options {
|
||||
if subOpt.Name == "ordner" {
|
||||
subOpt.Choices = choices
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildMoveChoices erstellt Discord-Choices für /email move aus der konfigurierten archive_folders.
|
||||
// Fallback: statische Liste (2Jahre/5Jahre/Archiv) wenn keine archive_folders konfiguriert.
|
||||
func buildMoveChoices() []*discordgo.ApplicationCommandOptionChoice {
|
||||
seen := map[string]bool{}
|
||||
var choices []*discordgo.ApplicationCommandOptionChoice
|
||||
|
||||
for _, acc := range config.AllEmailAccounts() {
|
||||
for _, af := range acc.ArchiveFolders {
|
||||
key := strings.ToLower(af.Name)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
label := af.Name
|
||||
if af.RetentionDays > 0 {
|
||||
label = fmt.Sprintf("%s (%d Tage)", af.Name, af.RetentionDays)
|
||||
}
|
||||
choices = append(choices, &discordgo.ApplicationCommandOptionChoice{
|
||||
Name: label,
|
||||
Value: af.Name,
|
||||
})
|
||||
if len(choices) == 25 { // Discord-Limit
|
||||
slog.Warn("Mehr als 25 Archivordner konfiguriert, Liste wird gekürzt")
|
||||
return choices
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(choices) == 0 {
|
||||
// Legacy-Fallback
|
||||
choices = []*discordgo.ApplicationCommandOptionChoice{
|
||||
{Name: "Archiv (dauerhaft)", Value: "Archiv"},
|
||||
{Name: "5Jahre (~5 Jahre)", Value: "5Jahre"},
|
||||
{Name: "2Jahre (~2 Jahre)", Value: "2Jahre"},
|
||||
}
|
||||
}
|
||||
return choices
|
||||
}
|
||||
|
||||
// floatPtr gibt einen Pointer auf einen float64-Wert zurück (für MinValue in Discord-Options).
|
||||
func floatPtr(v float64) *float64 { return &v }
|
||||
|
||||
// startDaemon läuft als Goroutine im Bot-Prozess und sendet proaktive Benachrichtigungen.
|
||||
// Für Email-Benachrichtigungen wird IMAP IDLE genutzt (Echtzeit).
|
||||
// Alternativ, wenn kein Email-Account konfiguriert ist, läuft nur der Morgen-Briefing-Timer.
|
||||
func startDaemon() {
|
||||
channelID := config.Cfg.Daemon.ChannelID
|
||||
if channelID == "" {
|
||||
@@ -553,53 +1111,121 @@ func startDaemon() {
|
||||
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)
|
||||
// IMAP IDLE für jeden konfigurierten Account starten
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
accounts := config.AllEmailAccounts()
|
||||
if len(accounts) > 0 {
|
||||
log.Printf("⚙️ Daemon aktiv: IMAP IDLE für %d Account(s), Task-Reminder täglich um %02d:00", len(accounts), reminderHour)
|
||||
for _, acc := range accounts {
|
||||
watcher := email.NewIdleWatcher(acc, func(accountName, summary string) {
|
||||
slog.Info("IDLE: Neue Emails, sende Zusammenfassung", "account", accountName)
|
||||
dg.ChannelMessageSend(channelID, fmt.Sprintf("📧 **Neue Emails (%s):**\n\n%s", accountName, summary))
|
||||
})
|
||||
go watcher.Run(ctx)
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚙️ Daemon aktiv (kein Email-Account): Task-Reminder täglich um %02d:00", reminderHour)
|
||||
}
|
||||
|
||||
emailTicker := time.NewTicker(emailInterval)
|
||||
defer emailTicker.Stop()
|
||||
// RSS-Watcher starten (wenn Feeds konfiguriert)
|
||||
if len(config.Cfg.RSSFeeds) > 0 {
|
||||
log.Printf("⚙️ RSS-Watcher aktiv: %d Feed(s)", len(config.Cfg.RSSFeeds))
|
||||
rssWatcher := &rss.Watcher{
|
||||
OnResults: func(summary string) {
|
||||
slog.Info("RSS: Feeds importiert")
|
||||
dg.ChannelMessageSend(channelID, "🗞️ **RSS-Feeds importiert:**\n"+summary)
|
||||
},
|
||||
}
|
||||
go rssWatcher.Run(ctx)
|
||||
}
|
||||
|
||||
cleanupHour := config.Cfg.Daemon.CleanupHour
|
||||
if cleanupHour == 0 {
|
||||
cleanupHour = 2
|
||||
}
|
||||
|
||||
ingestHour := config.Cfg.Daemon.IngestHour
|
||||
if ingestHour == 0 {
|
||||
ingestHour = 23
|
||||
}
|
||||
|
||||
briefingTimer := scheduleDaily(reminderHour, 0)
|
||||
defer briefingTimer.Stop()
|
||||
cleanupTimer := scheduleDaily(cleanupHour, 0)
|
||||
defer cleanupTimer.Stop()
|
||||
ingestTimer := scheduleDaily(ingestHour, 0)
|
||||
defer ingestTimer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-daemonStop:
|
||||
slog.Info("Daemon gestoppt")
|
||||
cancel()
|
||||
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)
|
||||
|
||||
case <-cleanupTimer.C:
|
||||
slog.Info("Daemon: Archiv-Aufräumen gestartet")
|
||||
go func() {
|
||||
summary, err := email.CleanupArchiveFolders()
|
||||
if err != nil {
|
||||
slog.Error("Daemon: Archiv-Aufräumen Fehler", "fehler", err)
|
||||
} else {
|
||||
slog.Info("Daemon: Archiv-Aufräumen abgeschlossen", "ergebnis", summary)
|
||||
}
|
||||
}()
|
||||
cleanupTimer.Stop()
|
||||
cleanupTimer = scheduleDaily(cleanupHour, 0)
|
||||
|
||||
case <-ingestTimer.C:
|
||||
slog.Info("Daemon: Nächtlicher Email-Ingest gestartet")
|
||||
go nightlyIngest(channelID)
|
||||
ingestTimer.Stop()
|
||||
ingestTimer = scheduleDaily(ingestHour, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nightlyIngest importiert Emails aus allen Archiv-Ordnern in die Wissensdatenbank.
|
||||
func nightlyIngest(channelID string) {
|
||||
accounts := config.AllEmailAccounts()
|
||||
total := 0
|
||||
var errs []string
|
||||
|
||||
for _, acc := range accounts {
|
||||
for _, af := range acc.ArchiveFolders {
|
||||
n, err := brain.IngestEmailFolder(acc, af.IMAPFolder, 0)
|
||||
if err != nil {
|
||||
slog.Error("Nacht-Ingest Fehler", "account", acc.Name, "folder", af.IMAPFolder, "fehler", err)
|
||||
errs = append(errs, fmt.Sprintf("%s/%s: %v", acc.Name, af.IMAPFolder, err))
|
||||
continue
|
||||
}
|
||||
slog.Info("Nacht-Ingest abgeschlossen", "account", acc.Name, "folder", af.IMAPFolder, "ingested", n)
|
||||
total += n
|
||||
}
|
||||
}
|
||||
|
||||
if channelID == "" {
|
||||
return
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
dg.ChannelMessageSend(channelID, fmt.Sprintf("⚠️ Nacht-Ingest: %d Emails importiert, %d Fehler:\n%s",
|
||||
total, len(errs), strings.Join(errs, "\n")))
|
||||
} else if total > 0 {
|
||||
dg.ChannelMessageSend(channelID, fmt.Sprintf("🗄️ Nacht-Ingest: %d Emails aus Archiv-Ordnern importiert.", total))
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -623,6 +1249,7 @@ func dailyBriefing(channelID string) {
|
||||
open, err := store.OpenTasks()
|
||||
if err != nil {
|
||||
slog.Error("Daemon Briefing Task-Fehler", "fehler", err)
|
||||
open = nil // explizit nil, len(open)==0 → kein Task-Abschnitt
|
||||
} else if len(open) > 0 {
|
||||
fmt.Fprintf(&taskSection, "📋 **Offene Tasks (%d):**\n", len(open))
|
||||
for _, t := range open {
|
||||
|
||||
Reference in New Issue
Block a user