managarten/services/mana-matrix-bot/internal/plugins/clock/clock.go
Till JS 819568c3df feat(infra): consolidate 21 Matrix bots into Go binary + add Go API gateway
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>
2026-03-27 21:03:00 +01:00

441 lines
13 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}