mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 03:01:24 +02:00
Replace 21 separate NestJS Matrix bot processes (~2.1 GB RAM, ~4.2 GB Docker images) with a single Go binary using plugin architecture (8.6 MB binary, ~30 MB RAM). New services: - services/mana-matrix-bot/ — Go Matrix bot with 21 plugins (mautrix-go, Redis sessions) - services/mana-api-gateway-go/ — Go API gateway (rate limiting, API keys, credit billing) Deleted: - 21 services/matrix-*-bot/ directories - packages/bot-services/ and packages/matrix-bot-common/ - Legacy deploy scripts and CI build jobs Updated: - docker-compose.macmini.yml: new Go services, legacy bots removed - CI/CD: change detection + build jobs for Go services - Root package.json: new dev:matrix, build:matrix, test:matrix scripts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
410 lines
13 KiB
Go
410 lines
13 KiB
Go
package calendar
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/manacore/mana-matrix-bot/internal/plugin"
|
|
"github.com/manacore/mana-matrix-bot/internal/services"
|
|
)
|
|
|
|
func init() {
|
|
plugin.Register("calendar", func() plugin.Plugin { return &CalendarPlugin{} })
|
|
}
|
|
|
|
// Event represents a calendar event from the backend.
|
|
type Event struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
StartTime string `json:"startTime"`
|
|
EndTime string `json:"endTime"`
|
|
IsAllDay bool `json:"isAllDay"`
|
|
Location *string `json:"location"`
|
|
Notes *string `json:"notes"`
|
|
Calendar *string `json:"calendar"`
|
|
}
|
|
|
|
// CreateEventInput is the request body for creating an event.
|
|
type CreateEventInput struct {
|
|
Title string `json:"title"`
|
|
StartTime string `json:"startTime"`
|
|
EndTime string `json:"endTime"`
|
|
IsAllDay bool `json:"isAllDay"`
|
|
Location *string `json:"location,omitempty"`
|
|
}
|
|
|
|
// CalendarPlugin implements the Matrix calendar bot.
|
|
type CalendarPlugin struct {
|
|
backend *services.BackendClient
|
|
router *plugin.CommandRouter
|
|
detector *plugin.KeywordDetector
|
|
}
|
|
|
|
func (p *CalendarPlugin) Name() string { return "calendar" }
|
|
|
|
func (p *CalendarPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
|
|
if cfg.BackendURL == "" {
|
|
return fmt.Errorf("calendar plugin requires BackendURL")
|
|
}
|
|
p.backend = services.NewBackendClient(cfg.BackendURL)
|
|
|
|
p.router = plugin.NewCommandRouter()
|
|
p.router.Handle("!help", p.cmdHelp)
|
|
p.router.Handle("!hilfe", p.cmdHelp)
|
|
p.router.Handle("!today", p.cmdToday)
|
|
p.router.Handle("!heute", p.cmdToday)
|
|
p.router.Handle("!tomorrow", p.cmdTomorrow)
|
|
p.router.Handle("!morgen", p.cmdTomorrow)
|
|
p.router.Handle("!week", p.cmdWeek)
|
|
p.router.Handle("!woche", p.cmdWeek)
|
|
p.router.Handle("!events", p.cmdUpcoming)
|
|
p.router.Handle("!termine", p.cmdUpcoming)
|
|
p.router.Handle("!upcoming", p.cmdUpcoming)
|
|
p.router.Handle("!termin", p.cmdCreate)
|
|
p.router.Handle("!event", p.cmdCreate)
|
|
p.router.Handle("!neu", p.cmdCreate)
|
|
p.router.Handle("!add", p.cmdCreate)
|
|
p.router.Handle("!details", p.cmdDetails)
|
|
p.router.Handle("!info", p.cmdDetails)
|
|
p.router.Handle("!delete", p.cmdDelete)
|
|
p.router.Handle("!löschen", p.cmdDelete)
|
|
p.router.Handle("!entfernen", p.cmdDelete)
|
|
p.router.Handle("!calendars", p.cmdCalendars)
|
|
p.router.Handle("!kalender", p.cmdCalendars)
|
|
p.router.Handle("!status", p.cmdStatus)
|
|
|
|
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
|
|
plugin.KeywordCommand{Keywords: []string{"was steht heute an", "termine heute"}, Command: "today"},
|
|
plugin.KeywordCommand{Keywords: []string{"termine morgen", "was ist morgen"}, Command: "tomorrow"},
|
|
plugin.KeywordCommand{Keywords: []string{"diese woche", "wochenübersicht"}, Command: "week"},
|
|
plugin.KeywordCommand{Keywords: []string{"zeige kalender", "meine kalender"}, Command: "calendars"},
|
|
))
|
|
|
|
slog.Info("calendar plugin initialized", "backend", cfg.BackendURL)
|
|
return nil
|
|
}
|
|
|
|
func (p *CalendarPlugin) Commands() []plugin.CommandDef {
|
|
return []plugin.CommandDef{
|
|
{Patterns: []string{"!today", "!heute"}, Description: "Heutige Termine", Category: "Kalender"},
|
|
{Patterns: []string{"!tomorrow", "!morgen"}, Description: "Termine morgen", Category: "Kalender"},
|
|
{Patterns: []string{"!week", "!woche"}, Description: "Wochenübersicht", Category: "Kalender"},
|
|
{Patterns: []string{"!events", "!termine"}, Description: "Nächste 14 Tage", Category: "Kalender"},
|
|
{Patterns: []string{"!termin", "!event"}, Description: "Neuen Termin erstellen", Category: "Kalender"},
|
|
{Patterns: []string{"!details [nr]"}, Description: "Termin-Details", Category: "Kalender"},
|
|
{Patterns: []string{"!delete [nr]", "!löschen [nr]"}, Description: "Termin löschen", Category: "Kalender"},
|
|
{Patterns: []string{"!calendars", "!kalender"}, Description: "Kalender anzeigen", Category: "Kalender"},
|
|
}
|
|
}
|
|
|
|
func (p *CalendarPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
|
|
matched, err := p.router.Route(mc)
|
|
if matched {
|
|
return err
|
|
}
|
|
|
|
cmd := p.detector.Detect(mc.Body)
|
|
switch cmd {
|
|
case "help":
|
|
return p.cmdHelp(mc, "")
|
|
case "today":
|
|
return p.cmdToday(mc, "")
|
|
case "tomorrow":
|
|
return p.cmdTomorrow(mc, "")
|
|
case "week":
|
|
return p.cmdWeek(mc, "")
|
|
case "calendars":
|
|
return p.cmdCalendars(mc, "")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// --- Command Handlers ---
|
|
|
|
func (p *CalendarPlugin) cmdToday(mc *plugin.MessageContext, _ string) error {
|
|
return p.fetchAndShowEvents(mc, "/api/events/today", "📅 **Termine heute:**", "Keine Termine für heute.")
|
|
}
|
|
|
|
func (p *CalendarPlugin) cmdTomorrow(mc *plugin.MessageContext, _ string) error {
|
|
return p.fetchAndShowEvents(mc, "/api/events/tomorrow", "📅 **Termine morgen:**", "Keine Termine für morgen.")
|
|
}
|
|
|
|
func (p *CalendarPlugin) cmdWeek(mc *plugin.MessageContext, _ string) error {
|
|
return p.fetchAndShowEvents(mc, "/api/events/week", "📅 **Diese Woche:**", "Keine Termine diese Woche.")
|
|
}
|
|
|
|
func (p *CalendarPlugin) cmdUpcoming(mc *plugin.MessageContext, _ string) error {
|
|
return p.fetchAndShowEvents(mc, "/api/events/upcoming?days=14", "📅 **Nächste 14 Tage:**", "Keine anstehenden Termine.")
|
|
}
|
|
|
|
func (p *CalendarPlugin) fetchAndShowEvents(mc *plugin.MessageContext, path, header, emptyMsg string) error {
|
|
ctx := context.Background()
|
|
|
|
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
|
|
if !ok {
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
|
|
return nil
|
|
}
|
|
|
|
var events []Event
|
|
if err := p.backend.Get(ctx, path, token, &events); err != nil {
|
|
slog.Error("fetch events failed", "error", err)
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Termine konnten nicht geladen werden.")
|
|
return nil
|
|
}
|
|
|
|
if len(events) == 0 {
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 "+emptyMsg)
|
|
return nil
|
|
}
|
|
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatEventList(header, events))
|
|
return nil
|
|
}
|
|
|
|
func (p *CalendarPlugin) cmdCreate(mc *plugin.MessageContext, args string) error {
|
|
ctx := context.Background()
|
|
|
|
if args == "" {
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib einen Termin an.\n\nBeispiel: `!termin Meeting morgen um 14:00`")
|
|
return nil
|
|
}
|
|
|
|
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
|
|
if !ok {
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
|
|
return nil
|
|
}
|
|
|
|
title, startTime, endTime, isAllDay := parseEventInput(args)
|
|
|
|
input := CreateEventInput{
|
|
Title: title,
|
|
StartTime: startTime,
|
|
EndTime: endTime,
|
|
IsAllDay: isAllDay,
|
|
}
|
|
|
|
var event Event
|
|
if err := p.backend.Post(ctx, "/api/events", token, input, &event); err != nil {
|
|
slog.Error("create event failed", "error", err)
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Termin konnte nicht erstellt werden.")
|
|
return nil
|
|
}
|
|
|
|
response := fmt.Sprintf("✅ Termin erstellt: **%s**\n📆 %s", event.Title, formatEventTime(event))
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
|
|
return nil
|
|
}
|
|
|
|
func (p *CalendarPlugin) cmdDetails(mc *plugin.MessageContext, args string) error {
|
|
ctx := context.Background()
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Details-Funktion wird noch implementiert.")
|
|
return nil
|
|
}
|
|
|
|
func (p *CalendarPlugin) cmdDelete(mc *plugin.MessageContext, args string) error {
|
|
ctx := context.Background()
|
|
|
|
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
|
|
if !ok {
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
|
|
return nil
|
|
}
|
|
|
|
num, err := strconv.Atoi(strings.TrimSpace(args))
|
|
if err != nil || num < 1 {
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine gültige Terminnummer an.\n\nBeispiel: `!delete 1`")
|
|
return nil
|
|
}
|
|
|
|
// Get current events to find by number
|
|
var events []Event
|
|
if err := p.backend.Get(ctx, "/api/events/upcoming?days=14", token, &events); err != nil {
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Termine konnten nicht geladen werden.")
|
|
return nil
|
|
}
|
|
|
|
if num > len(events) {
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("❌ Termin #%d nicht gefunden.", num))
|
|
return nil
|
|
}
|
|
|
|
event := events[num-1]
|
|
if err := p.backend.Delete(ctx, "/api/events/"+event.ID, token); err != nil {
|
|
slog.Error("delete event failed", "error", err)
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Termin konnte nicht gelöscht werden.")
|
|
return nil
|
|
}
|
|
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("🗑️ %s", event.Title))
|
|
return nil
|
|
}
|
|
|
|
func (p *CalendarPlugin) cmdCalendars(mc *plugin.MessageContext, _ string) error {
|
|
ctx := context.Background()
|
|
|
|
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
|
|
if !ok {
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
|
|
return nil
|
|
}
|
|
|
|
var calendars []struct {
|
|
Name string `json:"name"`
|
|
ID string `json:"id"`
|
|
}
|
|
if err := p.backend.Get(ctx, "/api/calendars", token, &calendars); err != nil {
|
|
slog.Error("get calendars failed", "error", err)
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kalender konnten nicht geladen werden.")
|
|
return nil
|
|
}
|
|
|
|
if len(calendars) == 0 {
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Kalender vorhanden.")
|
|
return nil
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("**Deine Kalender:**\n\n")
|
|
for _, cal := range calendars {
|
|
sb.WriteString("• ")
|
|
sb.WriteString(cal.Name)
|
|
sb.WriteByte('\n')
|
|
}
|
|
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
|
|
return nil
|
|
}
|
|
|
|
func (p *CalendarPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
|
|
ctx := context.Background()
|
|
loggedIn := mc.Session.Manager.IsLoggedIn(mc.Session.UserID)
|
|
status := "❌ Nicht angemeldet"
|
|
if loggedIn {
|
|
status = "✅ Angemeldet"
|
|
}
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("📊 **Status**\n\n%s\n🔄 Synchronisiert mit calendar-backend", status))
|
|
return nil
|
|
}
|
|
|
|
func (p *CalendarPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
|
|
ctx := context.Background()
|
|
help := `**📅 Calendar Bot - Befehle**
|
|
|
|
**Termine anzeigen:**
|
|
• ` + "`!heute`" + ` — Heutige Termine
|
|
• ` + "`!morgen`" + ` — Termine morgen
|
|
• ` + "`!woche`" + ` — Wochenübersicht
|
|
• ` + "`!termine`" + ` — Nächste 14 Tage
|
|
|
|
**Verwalten:**
|
|
• ` + "`!termin Meeting morgen um 14:00`" + ` — Neuer Termin
|
|
• ` + "`!löschen 1`" + ` — Termin #1 löschen
|
|
• ` + "`!kalender`" + ` — Kalender anzeigen
|
|
|
|
**System:**
|
|
• ` + "`!status`" + ` — Verbindungsstatus
|
|
• ` + "`!hilfe`" + ` — Diese Hilfe`
|
|
|
|
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
|
|
return nil
|
|
}
|
|
|
|
// --- Formatting ---
|
|
|
|
func formatEventList(header string, events []Event) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(header)
|
|
sb.WriteString("\n\n")
|
|
|
|
for i, evt := range events {
|
|
sb.WriteString(fmt.Sprintf("**%d.** %s\n", i+1, evt.Title))
|
|
sb.WriteString(fmt.Sprintf(" 🕐 %s\n", formatEventTime(evt)))
|
|
}
|
|
|
|
sb.WriteString("\n📋 Details: `!details [Nr]` | 🗑️ Löschen: `!löschen [Nr]`")
|
|
return sb.String()
|
|
}
|
|
|
|
func formatEventTime(evt Event) string {
|
|
if evt.IsAllDay {
|
|
return "Ganztägig"
|
|
}
|
|
|
|
t, err := time.Parse(time.RFC3339, evt.StartTime)
|
|
if err != nil {
|
|
return evt.StartTime
|
|
}
|
|
|
|
today := time.Now().Format("2006-01-02")
|
|
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
|
|
eventDate := t.Format("2006-01-02")
|
|
|
|
var dayStr string
|
|
switch eventDate {
|
|
case today:
|
|
dayStr = "Heute"
|
|
case tomorrow:
|
|
dayStr = "Morgen"
|
|
default:
|
|
dayStr = t.Format("02.01")
|
|
}
|
|
|
|
return fmt.Sprintf("%s, %s", dayStr, t.Format("15:04"))
|
|
}
|
|
|
|
// --- Input Parsing ---
|
|
|
|
var reTime = regexp.MustCompile(`(?i)(?:um\s+)?(\d{1,2}):(\d{2})`)
|
|
|
|
func parseEventInput(input string) (title string, startTime string, endTime string, isAllDay bool) {
|
|
now := time.Now()
|
|
startDate := now
|
|
|
|
// Check for date keywords
|
|
lower := strings.ToLower(input)
|
|
if strings.Contains(lower, "morgen") || strings.Contains(lower, "tomorrow") {
|
|
startDate = now.AddDate(0, 0, 1)
|
|
input = strings.NewReplacer("morgen", "", "tomorrow", "").Replace(input)
|
|
} else if strings.Contains(lower, "übermorgen") {
|
|
startDate = now.AddDate(0, 0, 2)
|
|
input = strings.Replace(input, "übermorgen", "", 1)
|
|
}
|
|
|
|
// Check for "ganztägig"
|
|
if strings.Contains(lower, "ganztägig") || strings.Contains(lower, "ganztaegig") || strings.Contains(lower, "all day") {
|
|
isAllDay = true
|
|
input = strings.NewReplacer("ganztägig", "", "ganztaegig", "", "all day", "").Replace(input)
|
|
}
|
|
|
|
// Extract time
|
|
if m := reTime.FindStringSubmatch(input); len(m) == 3 {
|
|
h, _ := strconv.Atoi(m[1])
|
|
min, _ := strconv.Atoi(m[2])
|
|
startDate = time.Date(startDate.Year(), startDate.Month(), startDate.Day(), h, min, 0, 0, startDate.Location())
|
|
input = reTime.ReplaceAllString(input, "")
|
|
} else if !isAllDay {
|
|
// Default to current time + 1 hour
|
|
startDate = time.Date(startDate.Year(), startDate.Month(), startDate.Day(), now.Hour()+1, 0, 0, 0, now.Location())
|
|
}
|
|
|
|
// Remove "um" keyword
|
|
input = strings.NewReplacer(" um ", " ", "Um ", "").Replace(input)
|
|
|
|
startTime = startDate.Format(time.RFC3339)
|
|
endTime = startDate.Add(1 * time.Hour).Format(time.RFC3339)
|
|
title = strings.TrimSpace(input)
|
|
|
|
if title == "" {
|
|
title = "Neuer Termin"
|
|
}
|
|
|
|
return
|
|
}
|