mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 12:21:10 +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>
441 lines
13 KiB
Go
441 lines
13 KiB
Go
package clock
|
||
|
||
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("clock", func() plugin.Plugin { return &ClockPlugin{} })
|
||
}
|
||
|
||
// Timer represents a timer from the backend.
|
||
type Timer struct {
|
||
ID string `json:"id"`
|
||
Label string `json:"label"`
|
||
Duration int `json:"duration"` // total seconds
|
||
Remaining int `json:"remaining"` // remaining seconds
|
||
Status string `json:"status"` // running, paused, finished
|
||
}
|
||
|
||
// Alarm represents an alarm from the backend.
|
||
type Alarm struct {
|
||
ID string `json:"id"`
|
||
Time string `json:"time"` // HH:MM
|
||
Label string `json:"label"`
|
||
}
|
||
|
||
// ClockPlugin implements the Matrix clock bot.
|
||
type ClockPlugin struct {
|
||
backend *services.BackendClient
|
||
router *plugin.CommandRouter
|
||
detector *plugin.KeywordDetector
|
||
}
|
||
|
||
func (p *ClockPlugin) Name() string { return "clock" }
|
||
|
||
func (p *ClockPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
|
||
if cfg.BackendURL == "" {
|
||
return fmt.Errorf("clock 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("!timer", p.cmdTimer)
|
||
p.router.Handle("!stop", p.cmdStop)
|
||
p.router.Handle("!stopp", p.cmdStop)
|
||
p.router.Handle("!pause", p.cmdStop)
|
||
p.router.Handle("!resume", p.cmdResume)
|
||
p.router.Handle("!weiter", p.cmdResume)
|
||
p.router.Handle("!reset", p.cmdReset)
|
||
p.router.Handle("!timers", p.cmdTimers)
|
||
p.router.Handle("!alarm", p.cmdAlarm)
|
||
p.router.Handle("!alarms", p.cmdAlarms)
|
||
p.router.Handle("!alarme", p.cmdAlarms)
|
||
p.router.Handle("!zeit", p.cmdTime)
|
||
p.router.Handle("!time", p.cmdTime)
|
||
p.router.Handle("!status", p.cmdStatus)
|
||
|
||
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
|
||
plugin.KeywordCommand{Keywords: []string{"timer status", "laufend"}, Command: "status"},
|
||
plugin.KeywordCommand{Keywords: []string{"stopp", "anhalten"}, Command: "stop"},
|
||
plugin.KeywordCommand{Keywords: []string{"weiter", "fortsetzen"}, Command: "resume"},
|
||
plugin.KeywordCommand{Keywords: []string{"zeit", "uhrzeit", "wie spät"}, Command: "time"},
|
||
))
|
||
|
||
slog.Info("clock plugin initialized", "backend", cfg.BackendURL)
|
||
return nil
|
||
}
|
||
|
||
func (p *ClockPlugin) Commands() []plugin.CommandDef {
|
||
return []plugin.CommandDef{
|
||
{Patterns: []string{"!timer [dauer]"}, Description: "Timer starten (25m, 1h30m)", Category: "Timer"},
|
||
{Patterns: []string{"!stop", "!stopp"}, Description: "Timer pausieren", Category: "Timer"},
|
||
{Patterns: []string{"!resume", "!weiter"}, Description: "Timer fortsetzen", Category: "Timer"},
|
||
{Patterns: []string{"!reset"}, Description: "Timer zurücksetzen", Category: "Timer"},
|
||
{Patterns: []string{"!timers"}, Description: "Alle Timer", Category: "Timer"},
|
||
{Patterns: []string{"!alarm [zeit]"}, Description: "Alarm setzen (07:30)", Category: "Alarm"},
|
||
{Patterns: []string{"!alarms", "!alarme"}, Description: "Alle Alarme", Category: "Alarm"},
|
||
{Patterns: []string{"!zeit", "!time"}, Description: "Aktuelle Uhrzeit", Category: "Uhr"},
|
||
}
|
||
}
|
||
|
||
func (p *ClockPlugin) 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 "status":
|
||
return p.cmdStatus(mc, "")
|
||
case "stop":
|
||
return p.cmdStop(mc, "")
|
||
case "resume":
|
||
return p.cmdResume(mc, "")
|
||
case "time":
|
||
return p.cmdTime(mc, "")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// --- Command Handlers ---
|
||
|
||
func (p *ClockPlugin) cmdTimer(mc *plugin.MessageContext, args string) error {
|
||
ctx := context.Background()
|
||
|
||
if args == "" {
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine Dauer an.\n\nBeispiel: `!timer 25m` oder `!timer 1h30m`")
|
||
return nil
|
||
}
|
||
|
||
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
|
||
|
||
// Parse duration and optional label
|
||
parts := strings.SplitN(args, " ", 2)
|
||
seconds := parseDuration(parts[0])
|
||
if seconds <= 0 {
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Ungültige Dauer. Beispiele: `25m`, `1h`, `1h30m`, `90`")
|
||
return nil
|
||
}
|
||
|
||
label := ""
|
||
if len(parts) > 1 {
|
||
label = parts[1]
|
||
}
|
||
|
||
body := map[string]any{
|
||
"duration": seconds,
|
||
"label": label,
|
||
}
|
||
|
||
var timer Timer
|
||
if err := p.backend.Post(ctx, "/api/v1/timers", token, body, &timer); err != nil {
|
||
slog.Error("create timer failed", "error", err)
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Timer konnte nicht erstellt werden.")
|
||
return nil
|
||
}
|
||
|
||
// Start the timer
|
||
p.backend.Post(ctx, "/api/v1/timers/"+timer.ID+"/start", token, nil, &timer)
|
||
|
||
response := fmt.Sprintf("▶️ **Timer gestartet**\n\n⏱️ %s", formatDuration(seconds))
|
||
if label != "" {
|
||
response += fmt.Sprintf("\n📝 %s", label)
|
||
}
|
||
response += "\n\n`!stop` zum Pausieren"
|
||
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
|
||
return nil
|
||
}
|
||
|
||
func (p *ClockPlugin) cmdStop(mc *plugin.MessageContext, _ string) error {
|
||
ctx := context.Background()
|
||
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
|
||
|
||
var timer Timer
|
||
if err := p.backend.Get(ctx, "/api/v1/timers/running", token, &timer); err != nil {
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kein laufender Timer gefunden.")
|
||
return nil
|
||
}
|
||
|
||
p.backend.Post(ctx, "/api/v1/timers/"+timer.ID+"/pause", token, nil, nil)
|
||
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
|
||
fmt.Sprintf("⏸️ **Timer pausiert**\n\nVerbleibend: %s\n\n`!resume` zum Fortsetzen, `!reset` zum Zurücksetzen",
|
||
formatDuration(timer.Remaining)))
|
||
return nil
|
||
}
|
||
|
||
func (p *ClockPlugin) cmdResume(mc *plugin.MessageContext, _ string) error {
|
||
ctx := context.Background()
|
||
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
|
||
|
||
var timer Timer
|
||
if err := p.backend.Get(ctx, "/api/v1/timers/running", token, &timer); err != nil {
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kein pausierter Timer gefunden.")
|
||
return nil
|
||
}
|
||
|
||
p.backend.Post(ctx, "/api/v1/timers/"+timer.ID+"/resume", token, nil, nil)
|
||
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
|
||
fmt.Sprintf("▶️ **Timer läuft weiter**\n\nVerbleibend: %s", formatDuration(timer.Remaining)))
|
||
return nil
|
||
}
|
||
|
||
func (p *ClockPlugin) cmdReset(mc *plugin.MessageContext, _ string) error {
|
||
ctx := context.Background()
|
||
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
|
||
|
||
var timer Timer
|
||
if err := p.backend.Get(ctx, "/api/v1/timers/running", token, &timer); err != nil {
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kein aktiver Timer gefunden.")
|
||
return nil
|
||
}
|
||
|
||
p.backend.Post(ctx, "/api/v1/timers/"+timer.ID+"/reset", token, nil, nil)
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "🔄 Timer zurückgesetzt.")
|
||
return nil
|
||
}
|
||
|
||
func (p *ClockPlugin) cmdTimers(mc *plugin.MessageContext, _ string) error {
|
||
ctx := context.Background()
|
||
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
|
||
|
||
var timers []Timer
|
||
if err := p.backend.Get(ctx, "/api/v1/timers", token, &timers); err != nil {
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Timer konnten nicht geladen werden.")
|
||
return nil
|
||
}
|
||
|
||
if len(timers) == 0 {
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Timer vorhanden.\n\nNeuen Timer: `!timer 25m`")
|
||
return nil
|
||
}
|
||
|
||
var sb strings.Builder
|
||
sb.WriteString("**⏱️ Timer:**\n\n")
|
||
for _, t := range timers {
|
||
icon := "⏸️"
|
||
if t.Status == "running" {
|
||
icon = "▶️"
|
||
} else if t.Status == "finished" {
|
||
icon = "✅"
|
||
}
|
||
sb.WriteString(fmt.Sprintf("%s **%s** — %s / %s\n", icon, t.Label, formatDuration(t.Remaining), formatDuration(t.Duration)))
|
||
}
|
||
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
|
||
return nil
|
||
}
|
||
|
||
func (p *ClockPlugin) cmdAlarm(mc *plugin.MessageContext, args string) error {
|
||
ctx := context.Background()
|
||
|
||
if args == "" {
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine Uhrzeit an.\n\nBeispiel: `!alarm 07:30 Aufstehen`")
|
||
return nil
|
||
}
|
||
|
||
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
|
||
|
||
parts := strings.SplitN(args, " ", 2)
|
||
alarmTime := parseAlarmTime(parts[0])
|
||
if alarmTime == "" {
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Ungültige Uhrzeit. Beispiel: `07:30`")
|
||
return nil
|
||
}
|
||
|
||
label := ""
|
||
if len(parts) > 1 {
|
||
label = parts[1]
|
||
}
|
||
|
||
body := map[string]any{
|
||
"time": alarmTime,
|
||
"label": label,
|
||
}
|
||
|
||
var alarm Alarm
|
||
if err := p.backend.Post(ctx, "/api/v1/alarms", token, body, &alarm); err != nil {
|
||
slog.Error("create alarm failed", "error", err)
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Alarm konnte nicht erstellt werden.")
|
||
return nil
|
||
}
|
||
|
||
response := fmt.Sprintf("⏰ **Alarm gestellt!**\n\nZeit: %s Uhr", alarmTime)
|
||
if label != "" {
|
||
response += fmt.Sprintf("\n📝 %s", label)
|
||
}
|
||
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
|
||
return nil
|
||
}
|
||
|
||
func (p *ClockPlugin) cmdAlarms(mc *plugin.MessageContext, _ string) error {
|
||
ctx := context.Background()
|
||
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
|
||
|
||
var alarms []Alarm
|
||
if err := p.backend.Get(ctx, "/api/v1/alarms", token, &alarms); err != nil {
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Alarme konnten nicht geladen werden.")
|
||
return nil
|
||
}
|
||
|
||
if len(alarms) == 0 {
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Alarme gesetzt.\n\nNeuen Alarm: `!alarm 07:30`")
|
||
return nil
|
||
}
|
||
|
||
var sb strings.Builder
|
||
sb.WriteString("**⏰ Alarme:**\n\n")
|
||
for _, a := range alarms {
|
||
sb.WriteString(fmt.Sprintf("• %s Uhr", a.Time))
|
||
if a.Label != "" {
|
||
sb.WriteString(fmt.Sprintf(" — %s", a.Label))
|
||
}
|
||
sb.WriteByte('\n')
|
||
}
|
||
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
|
||
return nil
|
||
}
|
||
|
||
func (p *ClockPlugin) cmdTime(mc *plugin.MessageContext, _ string) error {
|
||
ctx := context.Background()
|
||
now := time.Now()
|
||
days := []string{"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"}
|
||
months := []string{"", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"}
|
||
|
||
response := fmt.Sprintf("**%s Uhr**\n%s, %d. %s %d",
|
||
now.Format("15:04"),
|
||
days[now.Weekday()],
|
||
now.Day(),
|
||
months[now.Month()],
|
||
now.Year())
|
||
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
|
||
return nil
|
||
}
|
||
|
||
func (p *ClockPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
|
||
ctx := context.Background()
|
||
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
|
||
|
||
var timer Timer
|
||
timerStatus := "Kein aktiver Timer"
|
||
if err := p.backend.Get(ctx, "/api/v1/timers/running", token, &timer); err == nil {
|
||
timerStatus = fmt.Sprintf("▶️ %s — %s / %s", timer.Label, formatDuration(timer.Remaining), formatDuration(timer.Duration))
|
||
}
|
||
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
|
||
fmt.Sprintf("**🕐 Clock Bot Status**\n\n%s", timerStatus))
|
||
return nil
|
||
}
|
||
|
||
func (p *ClockPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
|
||
ctx := context.Background()
|
||
help := `**🕐 Clock Bot - Befehle**
|
||
|
||
**Timer:**
|
||
• ` + "`!timer 25m`" + ` — Timer starten
|
||
• ` + "`!stop`" + ` — Pausieren
|
||
• ` + "`!resume`" + ` — Fortsetzen
|
||
• ` + "`!reset`" + ` — Zurücksetzen
|
||
• ` + "`!timers`" + ` — Alle Timer
|
||
|
||
**Alarm:**
|
||
• ` + "`!alarm 07:30 Aufstehen`" + ` — Alarm setzen
|
||
• ` + "`!alarme`" + ` — Alle Alarme
|
||
|
||
**Uhr:**
|
||
• ` + "`!zeit`" + ` — Aktuelle Uhrzeit
|
||
|
||
**Dauer-Formate:** ` + "`25m`" + `, ` + "`1h`" + `, ` + "`1h30m`" + `, ` + "`90`" + ` (Minuten)`
|
||
|
||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
|
||
return nil
|
||
}
|
||
|
||
// --- Parsing ---
|
||
|
||
var reDuration = regexp.MustCompile(`(?i)^(?:(\d+)h)?(?:(\d+)m?)?$`)
|
||
|
||
func parseDuration(input string) int {
|
||
input = strings.TrimSpace(strings.ToLower(input))
|
||
|
||
m := reDuration.FindStringSubmatch(input)
|
||
if m == nil {
|
||
// Try plain number (minutes)
|
||
if n, err := strconv.Atoi(input); err == nil && n > 0 {
|
||
return n * 60
|
||
}
|
||
return 0
|
||
}
|
||
|
||
hours := 0
|
||
minutes := 0
|
||
if m[1] != "" {
|
||
hours, _ = strconv.Atoi(m[1])
|
||
}
|
||
if m[2] != "" {
|
||
minutes, _ = strconv.Atoi(m[2])
|
||
}
|
||
|
||
total := hours*3600 + minutes*60
|
||
if total == 0 && hours == 0 && minutes == 0 {
|
||
return 0
|
||
}
|
||
return total
|
||
}
|
||
|
||
func parseAlarmTime(input string) string {
|
||
input = strings.TrimSpace(input)
|
||
|
||
// Match HH:MM
|
||
if matched, _ := regexp.MatchString(`^\d{1,2}:\d{2}$`, input); matched {
|
||
return input
|
||
}
|
||
|
||
// Match "7 Uhr 30" or "7 30"
|
||
re := regexp.MustCompile(`^(\d{1,2})\s*(?:uhr\s*)?(\d{2})?$`)
|
||
if m := re.FindStringSubmatch(strings.ToLower(input)); m != nil {
|
||
h := m[1]
|
||
min := "00"
|
||
if m[2] != "" {
|
||
min = m[2]
|
||
}
|
||
return fmt.Sprintf("%s:%s", h, min)
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
func formatDuration(seconds int) string {
|
||
if seconds < 0 {
|
||
seconds = 0
|
||
}
|
||
h := seconds / 3600
|
||
m := (seconds % 3600) / 60
|
||
s := seconds % 60
|
||
|
||
if h > 0 {
|
||
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
|
||
}
|
||
return fmt.Sprintf("%d:%02d", m, s)
|
||
}
|